parent
066525964f
commit
ce2623fc4f
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@ -1,48 +0,0 @@ |
||||
<template> |
||||
<div :style="{height,width}"/> |
||||
</template> |
||||
|
||||
<script> |
||||
import {logic, resize} from "@/mixin/chart" |
||||
|
||||
export default { |
||||
mixins: [resize, logic], |
||||
|
||||
props: { |
||||
title: String, |
||||
data: Array |
||||
}, |
||||
|
||||
watch: { |
||||
data: { |
||||
deep: true, |
||||
handler(val) { |
||||
this.init(val) |
||||
} |
||||
} |
||||
}, |
||||
|
||||
methods: { |
||||
init(data) { |
||||
this.chart && this.chart.setOption({ |
||||
title: { |
||||
text: this.title, |
||||
left: 'center', |
||||
align: 'right' |
||||
}, |
||||
tooltip: { |
||||
trigger: 'item', |
||||
formatter: '{b} : {c} ({d}%)' |
||||
}, |
||||
series: [ |
||||
{ |
||||
type: 'pie', |
||||
center: ['50%', '58%'], |
||||
data |
||||
} |
||||
] |
||||
}) |
||||
} |
||||
} |
||||
} |
||||
</script> |
||||
@ -1,52 +0,0 @@ |
||||
<template> |
||||
<el-card :class="{collapsed}"> |
||||
<div slot="header" class="clearfix"> |
||||
<slot name="header">{{ header }}</slot> |
||||
<i class="el-icon-arrow-up collapse-card-icon"/> |
||||
</div> |
||||
<slot/> |
||||
</el-card> |
||||
</template> |
||||
|
||||
<script> |
||||
export default { |
||||
name: "CollapseCard", |
||||
|
||||
props: { |
||||
header: String |
||||
}, |
||||
|
||||
data: () => ({collapsed: false}), |
||||
|
||||
methods: { |
||||
collapse() { |
||||
this.collapsed = !this.collapsed |
||||
} |
||||
}, |
||||
|
||||
mounted() { |
||||
const dom = this.$el.querySelector('.el-card__header') |
||||
dom.addEventListener('click', this.collapse) |
||||
this.$once('hook:beforeDestroy', () => { |
||||
dom.removeEventListener('click', this.collapse) |
||||
}) |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
<style> |
||||
.collapse-card-icon { |
||||
float: right; |
||||
font-weight: bold; |
||||
transition: transform .3s; |
||||
cursor: pointer; |
||||
} |
||||
|
||||
.collapsed .collapse-card-icon { |
||||
transform: rotate(180deg); |
||||
} |
||||
|
||||
.el-card.collapsed > .el-card__body { |
||||
display: none; |
||||
} |
||||
</style> |
||||
@ -1,39 +0,0 @@ |
||||
<template> |
||||
<el-row type="flex" justify="space-between"> |
||||
<el-col v-show="!expanded" :span="extraSpan"> |
||||
<slot name="extra"/> |
||||
</el-col> |
||||
<div style="display: flex;justify-content: center;align-items: center"> |
||||
<el-link :icon="iconClass" :underline="false" style="font-size: 16px" @click="expanded = !expanded"/> |
||||
</div> |
||||
<el-col :span="defaultSpan"> |
||||
<slot/> |
||||
</el-col> |
||||
</el-row> |
||||
</template> |
||||
|
||||
<script> |
||||
export default { |
||||
name: "ExtraArea", |
||||
|
||||
props: { |
||||
extra: {type: Number, default: 5} |
||||
}, |
||||
|
||||
data: () => ({expanded: false}), |
||||
|
||||
computed: { |
||||
extraSpan() { |
||||
return this.expanded ? 0 : this.extra |
||||
}, |
||||
defaultSpan() { |
||||
return 23 - this.extraSpan |
||||
}, |
||||
iconClass() { |
||||
return `el-icon-arrow-${this.expanded ? 'right' : 'left'}` |
||||
} |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
|
||||
@ -1,23 +0,0 @@ |
||||
<script type="text/jsx"> |
||||
//从muse-ui处搬运,https://muse-ui.org/#/zh-CN/progress |
||||
export default { |
||||
name: "LinerProgress", |
||||
|
||||
functional: true, |
||||
|
||||
props: {show: Boolean}, |
||||
|
||||
render(h, context) { |
||||
if (context.props.show) { |
||||
return ( |
||||
<div class="liner-progress"> |
||||
<div class="liner-progress-background"/> |
||||
<div class="linear-progress-indeterminate"/> |
||||
</div> |
||||
) |
||||
} |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
<style lang="scss" src="./style.scss"></style> |
||||
@ -1,38 +0,0 @@ |
||||
@import "~@/asset/style/variables.scss"; |
||||
|
||||
.liner-progress { |
||||
position: relative; |
||||
height: 4px; |
||||
display: block; |
||||
width: 100%; |
||||
margin: 0; |
||||
overflow: hidden; |
||||
|
||||
.liner-progress-background { |
||||
position: absolute; |
||||
top: 0; |
||||
bottom: 0; |
||||
left: 0; |
||||
right: 0; |
||||
background-color: $--color-primary; |
||||
opacity: .3; |
||||
} |
||||
|
||||
.linear-progress-indeterminate { |
||||
position: absolute; |
||||
top: 0; |
||||
bottom: 0; |
||||
width: 40%; |
||||
background-color: $--color-primary; |
||||
animation: linear-progress-animate .84s cubic-bezier(.445, .05, .55, .95) infinite; |
||||
} |
||||
|
||||
@keyframes linear-progress-animate { |
||||
0% { |
||||
left: -40%; |
||||
} |
||||
100% { |
||||
left: 100%; |
||||
} |
||||
} |
||||
} |
||||
@ -1,139 +0,0 @@ |
||||
<template> |
||||
<div class="org-tree-container"> |
||||
<div class="org-tree" :class="{horizontal, collapsable}"> |
||||
<org-tree-node |
||||
:data="dataCloned" |
||||
:props="props" |
||||
:horizontal="horizontal" |
||||
:label-width="labelWidth" |
||||
:collapsable="collapsable" |
||||
:label-class-name="labelClassName" |
||||
@on-expand="handleExpand" |
||||
@on-node-click="handleNodeClick" |
||||
/> |
||||
</div> |
||||
</div> |
||||
</template> |
||||
|
||||
<script> |
||||
import OrgTreeNode from "./node" |
||||
|
||||
export default { |
||||
name: 'OrgTree', |
||||
|
||||
components: {OrgTreeNode}, |
||||
|
||||
props: { |
||||
data: {required: true}, |
||||
props: { |
||||
type: Object, |
||||
default: () => ({ |
||||
id: 'id', |
||||
label: 'label', |
||||
expand: 'expand', |
||||
children: 'children' |
||||
}) |
||||
}, |
||||
horizontal: Boolean, |
||||
collapsable: Boolean, |
||||
labelWidth: [String, Number], |
||||
labelClassName: [Function, String], |
||||
expandAll: Boolean |
||||
}, |
||||
|
||||
data() { |
||||
return { |
||||
flatData: {}, |
||||
dataCloned: {} |
||||
} |
||||
}, |
||||
|
||||
watch: { |
||||
data(newData) { |
||||
this._cloneData(newData) |
||||
this._mapData(this.dataCloned, item => { |
||||
const {expand} = this.flatData[item[this.props.id]] || {} |
||||
if (expand) this.$set(item, this.props.expand, true) |
||||
}) |
||||
this._toggleExpand(this.dataCloned, this.expandAll) |
||||
}, |
||||
expandAll(status) { |
||||
this._toggleExpand(this.dataCloned, status) |
||||
} |
||||
}, |
||||
|
||||
methods: { |
||||
_cloneData(newData) { |
||||
this.dataCloned = JSON.parse(JSON.stringify(newData)) |
||||
}, |
||||
_setFlatData(data) { |
||||
this.flatData[data[this.props.id]] = data |
||||
}, |
||||
/** |
||||
* 工具方法,用于遍历树状数据的每个节点, fn为在该节点做的操作,其有一个参数即当前节点数据 |
||||
*/ |
||||
_mapData(data, fn) { |
||||
fn(data) |
||||
const children = data[this.props.children] |
||||
children && children.forEach(child => this._mapData(child, fn)) |
||||
}, |
||||
/** |
||||
* 用来便利所有节点数据,将树状数据扁平化存放到flatData,用于数据更新后展开状态的恢复 |
||||
*/ |
||||
_updateExpandStatus() { |
||||
this._mapData(this.dataCloned, this._setFlatData) |
||||
}, |
||||
_toggleExpand(data, status) { |
||||
if (Array.isArray(data)) { |
||||
data.forEach(item => { |
||||
this.$set(item, this.props.expand, status) |
||||
const children = item[this.props.children] |
||||
if (children) { |
||||
this._toggleExpand(children, status) |
||||
} |
||||
}) |
||||
} |
||||
else { |
||||
this.$set(data, this.props.expand, status) |
||||
const children = data[this.props.children] |
||||
if (children) { |
||||
this._toggleExpand(children, status) |
||||
} |
||||
} |
||||
}, |
||||
collapse(list) { |
||||
list.forEach(child => { |
||||
if (child[this.props.expand]) { |
||||
child[this.props.expand] = false |
||||
} |
||||
const children = child[this.props.children] |
||||
children && this.collapse(children) |
||||
}) |
||||
}, |
||||
handleExpand(data) { |
||||
if (this.props.expand in data) { |
||||
data[this.props.expand] = !data[this.props.expand] |
||||
const children = data[this.props.children] |
||||
if (!data[this.props.expand] && children) { |
||||
this.collapse(children) |
||||
} |
||||
} |
||||
else this.$set(data, this.props.expand, true) |
||||
|
||||
this.$emit('on-expand', data, data[this.props.expand]) |
||||
this._updateExpandStatus() |
||||
}, |
||||
handleNodeClick(e, data) { |
||||
this.$emit('on-node-click', e, data, () => this.handleExpand(data)) |
||||
} |
||||
}, |
||||
|
||||
mounted() { |
||||
this._cloneData(this.data) |
||||
this._updateExpandStatus() |
||||
this._toggleExpand(this.dataCloned, this.expandAll) |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
<style lang="scss" src="./style.scss"></style> |
||||
@ -1,121 +0,0 @@ |
||||
<script type="text/jsx"> |
||||
// 判断是否叶子节点 |
||||
const isLeaf = (data, prop) => { |
||||
return !Array.isArray(data[prop]) || data[prop].length === 0 |
||||
} |
||||
|
||||
// 创建 node 节点 |
||||
const renderNode = (h, data, context) => { |
||||
const {props} = context |
||||
const cls = ['org-tree-node'] |
||||
const childNodes = [] |
||||
const children = data[props.props.children] |
||||
|
||||
if (isLeaf(data, props.props.children)) { |
||||
cls.push('is-leaf') |
||||
} |
||||
else if (props.collapsable && !data[props.props.expand]) { |
||||
cls.push('collapsed') |
||||
} |
||||
|
||||
childNodes.push(renderLabel(h, data, context)) |
||||
|
||||
if (!props.collapsable || data[props.props.expand]) { |
||||
childNodes.push(renderChildren(h, children, context)) |
||||
} |
||||
|
||||
return <div class={cls.join(' ')}>{childNodes}</div> |
||||
} |
||||
|
||||
// 创建 label 节点 |
||||
const renderLabel = (h, data, context) => { |
||||
const {props} = context |
||||
const label = data[props.props.label] |
||||
const clickHandler = context.listeners['on-node-click'] |
||||
const mousedownHandler = e => { |
||||
e.stopPropagation() |
||||
if (context.listeners['on-node-mousedown']) { |
||||
context.listeners['on-node-mousedown'](e, data) |
||||
} |
||||
} |
||||
const mouseupHandler = context.listeners['on-node-mouseup'] |
||||
const touchstartHandler = context.listeners['on-node-touchstart'] |
||||
const touchleaveHandler = context.listeners['on-node-touchleave'] |
||||
|
||||
const childNodes = [] |
||||
if (context.parent.$scopedSlots.default) { |
||||
childNodes.push(context.parent.$scopedSlots.default(data)) |
||||
} |
||||
else childNodes.push(label) |
||||
|
||||
if (props.collapsable && !isLeaf(data, props.props.children)) { |
||||
childNodes.push(renderBtn(h, data, context)) |
||||
} |
||||
|
||||
const cls = ['org-tree-node-label-inner'] |
||||
let {labelWidth, labelClassName} = props |
||||
if (typeof labelWidth === 'number') { |
||||
labelWidth += 'px' |
||||
} |
||||
if (typeof labelClassName === 'function') { |
||||
labelClassName = labelClassName(data) |
||||
} |
||||
labelClassName && cls.push(labelClassName) |
||||
|
||||
return ( |
||||
<div |
||||
class="org-tree-node-label" |
||||
on-click={e => clickHandler && clickHandler(e, data)} |
||||
on-mousedown={mousedownHandler} |
||||
on-mouseup={e => mouseupHandler && mouseupHandler(e, data)} |
||||
on-touchstart={e => touchstartHandler && touchstartHandler(e, data)} |
||||
on-touchleave={e => touchleaveHandler && touchleaveHandler(e, data)} |
||||
> |
||||
<div class={cls.join(' ')} style={{width: labelWidth}}> |
||||
{childNodes} |
||||
</div> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
// 创建展开折叠按钮 |
||||
const renderBtn = (h, data, context) => { |
||||
const {props} = context |
||||
const expandHandler = context.listeners['on-expand'] |
||||
const onClock = e => { |
||||
e.stopPropagation() |
||||
expandHandler && expandHandler(data) |
||||
} |
||||
const onMousedown = e => e.stopPropagation() |
||||
const cls = ['org-tree-node-btn'] |
||||
|
||||
data[props.props.expand] && cls.push('expanded') |
||||
|
||||
return ( |
||||
<span class="org-tree-button-wrapper" on-click={onClock} on-mousedown={onMousedown}> |
||||
<span class={cls.join(' ')}/> |
||||
</span> |
||||
) |
||||
} |
||||
|
||||
// 创建 node 子节点 |
||||
const renderChildren = (h, list, context) => { |
||||
if (Array.isArray(list) && list.length) { |
||||
return ( |
||||
<div class="org-tree-node-children"> |
||||
{list.map(item => renderNode(h, item, context))} |
||||
</div> |
||||
) |
||||
} |
||||
} |
||||
|
||||
export default { |
||||
name: "OrgTreeNode", |
||||
|
||||
functional: true, |
||||
|
||||
render(h, context) { |
||||
return renderNode(h, context.props.data, context) |
||||
} |
||||
} |
||||
</script> |
||||
@ -1,261 +0,0 @@ |
||||
.org-tree-container { |
||||
display: inline-block; |
||||
padding: 15px; |
||||
|
||||
.org-tree { |
||||
display: table; |
||||
text-align: center; |
||||
|
||||
&:before, &:after { |
||||
content: ''; |
||||
display: table; |
||||
} |
||||
|
||||
&:after { |
||||
clear: both; |
||||
} |
||||
} |
||||
|
||||
.org-tree-node, |
||||
.org-tree-node-children { |
||||
position: relative; |
||||
margin: 0; |
||||
padding: 0; |
||||
list-style-type: none; |
||||
|
||||
&:before, &:after { |
||||
transition: all .35s; |
||||
} |
||||
} |
||||
|
||||
.org-tree-node-label { |
||||
position: relative; |
||||
display: inline-block; |
||||
z-index: 1; |
||||
|
||||
.org-tree-node-label-inner { |
||||
padding: 10px 15px; |
||||
text-align: center; |
||||
border-radius: 3px; |
||||
box-shadow: 0 1px 5px rgba(0, 0, 0, .15); |
||||
} |
||||
} |
||||
|
||||
.org-tree-node-btn { |
||||
position: absolute; |
||||
top: 100%; |
||||
left: 50%; |
||||
width: 20px; |
||||
height: 20px; |
||||
z-index: 10; |
||||
margin-left: -10px; |
||||
margin-top: 10px; |
||||
background-color: #ffffff; |
||||
border: 1px solid #ccc; |
||||
border-radius: 50%; |
||||
box-shadow: 0 0 2px rgba(0, 0, 0, .15); |
||||
cursor: pointer; |
||||
transition: all .35s ease; |
||||
|
||||
&:hover { |
||||
background-color: #e7e8e9; |
||||
transform: scale(1.15); |
||||
} |
||||
|
||||
&:before, &:after { |
||||
content: ''; |
||||
position: absolute; |
||||
} |
||||
|
||||
&:before { |
||||
top: 50%; |
||||
left: 4px; |
||||
right: 4px; |
||||
height: 0; |
||||
border-top: 1px solid #ccc; |
||||
} |
||||
|
||||
&:after { |
||||
top: 4px; |
||||
left: 50%; |
||||
bottom: 4px; |
||||
width: 0; |
||||
border-left: 1px solid #ccc; |
||||
} |
||||
|
||||
&.expanded:after { |
||||
border: none; |
||||
} |
||||
} |
||||
|
||||
.org-tree-node { |
||||
padding-top: 20px; |
||||
display: table-cell; |
||||
vertical-align: top; |
||||
|
||||
&.is-leaf, &.collapsed { |
||||
padding-left: 10px; |
||||
padding-right: 10px; |
||||
} |
||||
|
||||
&:before, &:after { |
||||
content: ''; |
||||
position: absolute; |
||||
top: 0; |
||||
left: 0; |
||||
width: 50%; |
||||
height: 19px; |
||||
} |
||||
|
||||
&:after { |
||||
left: 50%; |
||||
border-left: 1px solid #ddd; |
||||
} |
||||
|
||||
&:not(:first-child):before, |
||||
&:not(:last-child):after { |
||||
border-top: 1px solid #ddd; |
||||
} |
||||
|
||||
} |
||||
|
||||
.collapsable .org-tree-node.collapsed { |
||||
padding-bottom: 30px; |
||||
|
||||
.org-tree-node-label:after { |
||||
content: ''; |
||||
position: absolute; |
||||
top: 100%; |
||||
left: 0; |
||||
width: 50%; |
||||
height: 20px; |
||||
border-right: 1px solid #ddd; |
||||
} |
||||
} |
||||
|
||||
.org-tree > .org-tree-node { |
||||
padding-top: 0; |
||||
|
||||
&:after { |
||||
border-left: 0; |
||||
} |
||||
} |
||||
|
||||
.org-tree-node-children { |
||||
padding-top: 20px; |
||||
display: table; |
||||
|
||||
&:before { |
||||
content: ''; |
||||
position: absolute; |
||||
top: 0; |
||||
left: 50%; |
||||
width: 0; |
||||
height: 20px; |
||||
border-left: 1px solid #ddd; |
||||
} |
||||
|
||||
&:after { |
||||
content: ''; |
||||
display: table; |
||||
clear: both; |
||||
} |
||||
} |
||||
|
||||
.horizontal { |
||||
.org-tree-node { |
||||
display: table-cell; |
||||
float: none; |
||||
padding-top: 0; |
||||
padding-left: 20px; |
||||
|
||||
&.is-leaf, &.collapsed { |
||||
padding-top: 10px; |
||||
padding-bottom: 10px; |
||||
} |
||||
|
||||
&:before, &:after { |
||||
width: 19px; |
||||
height: 50%; |
||||
} |
||||
|
||||
&:after { |
||||
top: 50%; |
||||
left: 0; |
||||
border-left: 0; |
||||
} |
||||
|
||||
&:only-child:before { |
||||
top: 1px; |
||||
border-bottom: 1px solid #ddd; |
||||
} |
||||
|
||||
&:not(:first-child):before, |
||||
&:not(:last-child):after { |
||||
border-top: 0; |
||||
border-left: 1px solid #ddd; |
||||
} |
||||
|
||||
&:not(:only-child):after { |
||||
border-top: 1px solid #ddd; |
||||
} |
||||
|
||||
.org-tree-node-inner { |
||||
display: table; |
||||
} |
||||
|
||||
} |
||||
|
||||
.org-tree-node-label { |
||||
display: table-cell; |
||||
vertical-align: middle; |
||||
} |
||||
|
||||
&.collapsable .org-tree-node.collapsed { |
||||
padding-right: 30px; |
||||
|
||||
.org-tree-node-label:after { |
||||
top: 0; |
||||
left: 100%; |
||||
width: 20px; |
||||
height: 50%; |
||||
border-right: 0; |
||||
border-bottom: 1px solid #ddd; |
||||
} |
||||
} |
||||
|
||||
.org-tree-node-btn { |
||||
top: 50%; |
||||
left: 100%; |
||||
margin-top: -11px; |
||||
margin-left: 9px; |
||||
} |
||||
|
||||
& > .org-tree-node:only-child:before { |
||||
border-bottom: 0; |
||||
} |
||||
|
||||
.org-tree-node-children { |
||||
display: table-cell; |
||||
padding-top: 0; |
||||
padding-left: 20px; |
||||
|
||||
&:before { |
||||
top: 50%; |
||||
left: 0; |
||||
width: 20px; |
||||
height: 0; |
||||
border-left: 0; |
||||
border-top: 1px solid #ddd; |
||||
} |
||||
|
||||
&:after { |
||||
display: none; |
||||
} |
||||
|
||||
& > .org-tree-node { |
||||
display: block; |
||||
} |
||||
} |
||||
} |
||||
} |
||||
@ -1,304 +0,0 @@ |
||||
<template> |
||||
<el-select |
||||
ref="select" |
||||
:value="value" |
||||
:disabled="readonly" |
||||
:size="size" |
||||
popper-append-to-body |
||||
clearable |
||||
@clear="() => $emit('clear')" |
||||
@visible-change="visibleChange" |
||||
> |
||||
<template slot="empty"> |
||||
<div class="rg-header"> |
||||
<h3>行政区划选择</h3> |
||||
|
||||
<button class="rg-removeall-button" type="button" title="清除已选" @click="removeAll"> |
||||
<i class="el-icon-delete"/> |
||||
</button> |
||||
|
||||
<button class="rg-done-button" type="button" title="完成" @click="done"> |
||||
<i class="el-icon-check"/> |
||||
</button> |
||||
</div> |
||||
|
||||
<div class="rg-search"> |
||||
<input |
||||
ref="searchInput" |
||||
v-model="searchText" |
||||
class="rg-input" |
||||
type="text" |
||||
placeholder="输入地区id或名称搜索" |
||||
@input="search" |
||||
> |
||||
</div> |
||||
|
||||
<div class="rg-level-tabs"> |
||||
<ul> |
||||
<li v-for="(tab,index) in selected" :key="tab.name" :class="{active:index+1 === currentLevel}"> |
||||
<a href="javascript:" @click="() => clickTab(index,tab)">{{ tab.name }}</a> |
||||
</li> |
||||
</ul> |
||||
</div> |
||||
|
||||
<div v-loading="loading" class="rg-results-container"> |
||||
<ul class="rg-results"> |
||||
<li |
||||
v-for="item in items" |
||||
:key="item.id" |
||||
:class="{'rg-item': true, 'active': isItemActive(item.id)}" |
||||
@click="() => clickItem(item)" |
||||
> |
||||
{{ limit ? item.name + `(${item.value})` : item.name }} |
||||
</li> |
||||
<li v-if="items.length === 0" class="rg-message-box">无匹配项目</li> |
||||
</ul> |
||||
</div> |
||||
</template> |
||||
</el-select> |
||||
</template> |
||||
|
||||
<script> |
||||
import {debounce, deepClone} from "@/util" |
||||
import {createLimitTree, getNodeId} from "@/util/tree" |
||||
import {store, init} from '../store' |
||||
import common from '../mixin' |
||||
|
||||
const PROVINCE_CITIES = [ |
||||
{id: '11', name: '北京市'}, |
||||
{id: '12', name: '天津市'}, |
||||
{id: '31', name: '上海市'}, |
||||
{id: '50', name: '重庆市'} |
||||
] |
||||
const DEFAULT_TABS = [{name: '省/直辖市'}, {name: '市'}, {name: '区/县'}, {name: '乡/镇/街道'}] |
||||
|
||||
//根据node的类型生成查找断言,支持{id:'10',...}、{name:'北京市',...}、'10'、'北京市' 等四种类型 |
||||
function predicate(node) { |
||||
if (typeof node === 'object') { |
||||
const key = node.hasOwnProperty('id') ? 'id' : 'name' |
||||
return item => item[key] === node[key] |
||||
} |
||||
else { |
||||
const firstCharCode = node.charCodeAt(0) |
||||
const key = 48 <= firstCharCode && firstCharCode <= 57 ? 'id' : 'name' |
||||
return item => item[key] === node |
||||
} |
||||
} |
||||
|
||||
//根据叶子id获取树节点 |
||||
function topDownById(tree, id) { |
||||
//判断深度,深度=id.length/2 |
||||
const depth = Math.floor(id.length / 2) |
||||
if (depth < 1) return [] |
||||
const result = [] |
||||
for (let i = 1; i <= depth; i++) { |
||||
const parentId = id.substring(0, i * 2) |
||||
const parent = tree.find(node => node.id === parentId) |
||||
if (!parent) return result |
||||
result.push(parent) |
||||
if (i === 1 && PROVINCE_CITIES.some(i => i.id === parentId)) { |
||||
i++ |
||||
} |
||||
tree = parent.children || [] |
||||
} |
||||
return result |
||||
} |
||||
|
||||
export default { |
||||
name: "RegionTabSelector", |
||||
|
||||
mixins: [common], |
||||
|
||||
props: { |
||||
value: [String, Array], |
||||
maxLevel: {type: Number, default: 3}, |
||||
separation: {type: String, default: ','} |
||||
}, |
||||
|
||||
data() { |
||||
return { |
||||
loading: false, |
||||
searchText: '', |
||||
currentLevel: 1, |
||||
realMaxLevel: this.maxLevel, |
||||
selected: [], |
||||
treeData: [], |
||||
items: [] |
||||
} |
||||
}, |
||||
|
||||
computed: { |
||||
regionTree() { |
||||
return store.data |
||||
}, |
||||
}, |
||||
|
||||
watch: { |
||||
currentLevel(v) { |
||||
this.setItems(v) |
||||
} |
||||
}, |
||||
|
||||
methods: { |
||||
//select的下拉框展开收起 |
||||
visibleChange(v) { |
||||
if (v) { |
||||
this.transformValueToSelected() |
||||
this.$nextTick(() => this.$refs.searchInput.focus()) |
||||
} |
||||
else this.removeAll() |
||||
}, |
||||
|
||||
search() { |
||||
const parent = this.selected[this.currentLevel - 2] || {children: this.treeData} |
||||
|
||||
if (!this.searchText) return this.items = parent.children |
||||
|
||||
//以0-9开头的的搜索词都根据id去查 |
||||
const firstCharCode = this.searchText.charCodeAt(0) |
||||
const useId = 48 <= firstCharCode && firstCharCode <= 57 |
||||
const predicate = ( |
||||
() => useId |
||||
? item => item.id.startsWith(this.searchText) |
||||
: item => item.name.startsWith(this.searchText) |
||||
)() |
||||
|
||||
this.items = parent.children.filter(predicate) |
||||
}, |
||||
|
||||
clickTab(index, tab) { |
||||
//点击当前激活的tab时不处理 |
||||
if (index + 1 === this.currentLevel) return |
||||
|
||||
this.currentLevel = index + 1 |
||||
}, |
||||
|
||||
clickItem(item) { |
||||
this.setTabs(item, this.currentLevel) |
||||
this.nextTab() |
||||
}, |
||||
|
||||
setTabs(item, level) { |
||||
const prev = this.selected.slice(0, level - 1) |
||||
prev.push(item) |
||||
const next = deepClone(DEFAULT_TABS) |
||||
|
||||
//选择省份时 |
||||
if (level === 1) { |
||||
//选择直辖市的时候,最大深度-1,移除'市'tab |
||||
if (PROVINCE_CITIES.some(i => i.id === item.id)) { |
||||
next.splice(1, 1) |
||||
this.realMaxLevel = this.maxLevel - 1 |
||||
} |
||||
else this.realMaxLevel = this.maxLevel |
||||
} |
||||
|
||||
this.selected = prev.concat(next.slice(level, this.realMaxLevel)) |
||||
}, |
||||
|
||||
nextTab() { |
||||
this.searchText = '' |
||||
//若选择的是最后一级节点,直接完成 |
||||
if (this.currentLevel >= this.realMaxLevel) { |
||||
this.done() |
||||
} |
||||
else this.currentLevel++ |
||||
}, |
||||
|
||||
//判断是否是已选项 |
||||
isItemActive(id) { |
||||
const selected = this.selected[this.currentLevel - 1] |
||||
return selected && selected.id === id |
||||
}, |
||||
|
||||
//根据激活的tab改变显示的行政区划 |
||||
setItems(level) { |
||||
//顶级节点返回完整的树 |
||||
if (level === 1) this.items = this.treeData |
||||
|
||||
//否则返回上一级节点的children |
||||
else { |
||||
const parent = this.selected[level - 2] |
||||
this.items = parent ? parent.children || [] : [] |
||||
} |
||||
}, |
||||
|
||||
//将传入的value转换为已选项 |
||||
transformValueToSelected() { |
||||
if (!this.value) return |
||||
|
||||
const result = [], |
||||
loopArray = Array.isArray(this.value) |
||||
? this.value |
||||
: this.value.split(this.separation).filter(Boolean) |
||||
|
||||
for (let i = 0; i < loopArray.length; i++) { |
||||
const str = loopArray[i], parent = result[i - 1] || {children: this.treeData} |
||||
const node = parent.children.find(predicate(str)) |
||||
|
||||
//只要有一次不满足就退出,保留之前的查找结果 |
||||
if (!node) break |
||||
|
||||
result.push(node) |
||||
} |
||||
|
||||
result.forEach((item, index) => this.setTabs(item, index + 1)) |
||||
}, |
||||
|
||||
removeAll() { |
||||
this.realMaxLevel = this.maxLevel |
||||
this.selected = deepClone(DEFAULT_TABS) |
||||
this.currentLevel = 1 |
||||
this.searchText = '' |
||||
this.setItems(this.currentLevel) |
||||
}, |
||||
|
||||
done() { |
||||
const result = this.selected.filter(item => item.id) |
||||
this.$emit('input', result.map(item => item.name).join(this.separation)) |
||||
|
||||
if (result.length === 0) { |
||||
this.$emit('select', {}, []) |
||||
return this.$refs.select.blur() |
||||
} |
||||
|
||||
const node = result[result.length - 1] |
||||
const payload = [node] |
||||
|
||||
if (this.getChildrenOnSelect) { |
||||
const ids = getNodeId(node.children) |
||||
ids.unshift(node.id) |
||||
payload.push(ids) |
||||
} |
||||
|
||||
this.$emit('select', ...payload) |
||||
|
||||
this.$refs.select.blur() |
||||
}, |
||||
|
||||
init() { |
||||
this.loading = true |
||||
const hasInit = this.regionTree.length > 0 |
||||
const promise = () => hasInit ? Promise.resolve() : init(this.regionDataUrl) |
||||
return promise() |
||||
.then(() => this.limit ? this.limitApi() : Promise.resolve()) |
||||
.then(data => { |
||||
this.treeData = data ? createLimitTree(this.regionTree, data) : this.regionTree |
||||
this.setItems(this.currentLevel) |
||||
}) |
||||
.finally(() => this.loading = false) |
||||
} |
||||
}, |
||||
|
||||
created() { |
||||
this.search = debounce(this.search, 200) |
||||
this.selected = deepClone(DEFAULT_TABS) |
||||
}, |
||||
|
||||
mounted() { |
||||
this.init() |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
<style lang="scss" src="./style.scss"></style> |
||||
@ -1,139 +0,0 @@ |
||||
.rg-header, .rg-search { |
||||
padding: 2px 10px 0; |
||||
background-color: #ffffff; |
||||
} |
||||
|
||||
.rg-header { |
||||
h3 { |
||||
padding: 6px; |
||||
margin: 0; |
||||
color: #24292e; |
||||
font-size: 16px; |
||||
} |
||||
|
||||
button { |
||||
position: absolute; |
||||
-webkit-appearance: none; |
||||
padding: 0; |
||||
cursor: pointer; |
||||
background: 0 0; |
||||
border: 0; |
||||
outline: none; |
||||
color: #999; |
||||
top: 9px; |
||||
|
||||
i { |
||||
font-size: 16px; |
||||
} |
||||
|
||||
&:hover { |
||||
color: #000000; |
||||
} |
||||
|
||||
&.rg-removeall-button { |
||||
right: 32px |
||||
} |
||||
|
||||
&.rg-done-button { |
||||
right: 8px; |
||||
} |
||||
} |
||||
} |
||||
|
||||
.rg-level-tabs { |
||||
margin-top: 10px; |
||||
background-color: #ffffff; |
||||
|
||||
ul { |
||||
padding: 0; |
||||
margin: 0; |
||||
line-height: 1.5; |
||||
border-bottom: 1px solid #eee; |
||||
|
||||
li { |
||||
display: inline-block; |
||||
position: relative; |
||||
|
||||
a { |
||||
display: block; |
||||
padding: 0.2rem 1rem 0.6rem; |
||||
font-size: 14px; |
||||
color: #bbb; |
||||
text-decoration: none; |
||||
cursor: pointer; |
||||
line-height: 1.43; |
||||
} |
||||
|
||||
&.active { |
||||
a { |
||||
color: #333; |
||||
background-color: #ffffff; |
||||
font-weight: 600; |
||||
} |
||||
|
||||
&::after { |
||||
content: ""; |
||||
display: block; |
||||
position: absolute; |
||||
bottom: 0; |
||||
height: 0.2rem; |
||||
width: 100%; |
||||
background-color: #bbb; |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
.rg-results-container { |
||||
.rg-results { |
||||
margin: 0; |
||||
padding: 5px; |
||||
width: 364px; |
||||
line-height: 1.5; |
||||
|
||||
li { |
||||
overflow: hidden; |
||||
padding: 3px 10px; |
||||
font-size: 14px; |
||||
color: #000000; |
||||
cursor: pointer; |
||||
|
||||
&.rg-item { |
||||
display: inline-block; |
||||
color: #777; |
||||
|
||||
&:hover { |
||||
color: #000000; |
||||
background-color: #F5F5F5; |
||||
} |
||||
|
||||
&.active { |
||||
background-color: #E4EAEE; |
||||
color: #000000; |
||||
} |
||||
} |
||||
|
||||
&.rg-message-box { |
||||
height: 30px; |
||||
line-height: 30px; |
||||
text-align: center; |
||||
cursor: default; |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
.rg-input { |
||||
background-color: #f5f5f5; |
||||
border: 0; |
||||
width: 100%; |
||||
font-size: 14px; |
||||
padding: 6px; |
||||
outline: none !important; |
||||
border-radius: 2px; |
||||
|
||||
&::-webkit-input-placeholder { |
||||
color: #999; |
||||
} |
||||
} |
||||
@ -1,82 +0,0 @@ |
||||
<script type="text/jsx"> |
||||
import {store, init} from '../store' |
||||
import AbstractDialog from "@/component/abstract/Dialog" |
||||
import {createLimitTree, getNodeId} from "@/util/tree" |
||||
|
||||
export default { |
||||
components: {AbstractDialog}, |
||||
|
||||
data() { |
||||
return { |
||||
loading: false, |
||||
visible: false, |
||||
handler: null, |
||||
limitTree: [], |
||||
getChildrenOnSelect: false, |
||||
limit: false, |
||||
limitApi: null |
||||
} |
||||
}, |
||||
|
||||
computed: { |
||||
regionTree() { |
||||
return store.data |
||||
} |
||||
}, |
||||
|
||||
methods: { |
||||
closeDialog() { |
||||
this.visible = false |
||||
}, |
||||
|
||||
nodeClick(obj) { |
||||
const payload = [obj] |
||||
|
||||
if (this.getChildrenOnSelect) { |
||||
const ids = getNodeId(obj.children) |
||||
ids.unshift(obj.id) |
||||
payload.push(ids) |
||||
} |
||||
|
||||
this.handler(payload) |
||||
|
||||
this.closeDialog() |
||||
}, |
||||
|
||||
init() { |
||||
this.loading = true |
||||
const hasInit = this.regionTree.length > 0 |
||||
const promise = () => hasInit ? Promise.resolve() : init(this.regionDataUrl) |
||||
return promise() |
||||
.then(() => this.limit ? this.limitApi() : Promise.resolve()) |
||||
.then(data => data && (this.limitTree = createLimitTree(this.regionTree, data))) |
||||
.finally(() => this.loading = false) |
||||
} |
||||
}, |
||||
|
||||
render() { |
||||
return ( |
||||
<abstract-dialog |
||||
v-model={this.visible} |
||||
class="tree-dialog" |
||||
title="选择行政区域" |
||||
loading={this.loading} |
||||
v-on:close={this.closeDialog} |
||||
> |
||||
<el-tree |
||||
data={this.limit ? this.limitTree : this.regionTree} |
||||
expand-on-click-node={false} |
||||
node-key="id" |
||||
v-on:node-click={this.nodeClick} |
||||
> |
||||
{({data}) => ( |
||||
<span class="el-tree-node__label"> |
||||
{this.limit ? data.name + `(${data.value})` : data.name} |
||||
</span> |
||||
)} |
||||
</el-tree> |
||||
</abstract-dialog> |
||||
) |
||||
} |
||||
} |
||||
</script> |
||||
@ -1,79 +0,0 @@ |
||||
<script type="text/jsx"> |
||||
import Vue from 'vue' |
||||
import TreeDialog from "./TreeDialog" |
||||
import common from '../mixin' |
||||
|
||||
const TreeDialogConstructor = Vue.extend(TreeDialog) |
||||
|
||||
let limit, full |
||||
|
||||
export default { |
||||
name: "RegionTreeSelector", |
||||
|
||||
mixins: [common], |
||||
|
||||
data() { |
||||
return { |
||||
dialogVisible: false, |
||||
limitTree: [] |
||||
} |
||||
}, |
||||
|
||||
methods: { |
||||
openDialog() { |
||||
if (this.limit) { |
||||
this.initLimit() |
||||
return limit.visible = true |
||||
} |
||||
else { |
||||
!full && this.initFull() |
||||
full.visible = true |
||||
} |
||||
}, |
||||
|
||||
handler(payload) { |
||||
this.$emit('select', ...payload) |
||||
}, |
||||
|
||||
initLimit() { |
||||
if (limit) { |
||||
limit.getChildrenOnSelect = this.getChildrenOnSelect |
||||
limit.limit = this.limit |
||||
limit.limitApi = this.limitApi |
||||
return limit.init() |
||||
} |
||||
|
||||
const data = {...this.$props, handler: this.handler} |
||||
limit = new TreeDialogConstructor({data}).$mount() |
||||
document.body.appendChild(limit.$el) |
||||
|
||||
return limit.init() |
||||
}, |
||||
|
||||
initFull() { |
||||
if (full) { |
||||
return full.getChildrenOnSelect = this.getChildrenOnSelect |
||||
} |
||||
|
||||
const data = {getChildrenOnSelect: this.getChildrenOnSelect, handler: this.handler} |
||||
full = new TreeDialogConstructor({data}).$mount() |
||||
document.body.appendChild(full.$el) |
||||
|
||||
return full.init() |
||||
} |
||||
}, |
||||
|
||||
render() { |
||||
return ( |
||||
<el-input |
||||
value={this.value} |
||||
readonly={this.readonly} |
||||
size={this.size} |
||||
clearable |
||||
v-on:clear={() => this.$emit('clear')} |
||||
v-on:focus={this.openDialog} |
||||
/> |
||||
) |
||||
} |
||||
} |
||||
</script> |
||||
@ -1,33 +0,0 @@ |
||||
<script> |
||||
const Tree = () => import('./Tree').then(_ => _.default) |
||||
const Tab = () => import('./Tab').then(_ => _.default) |
||||
|
||||
export default { |
||||
name: "RegionSelector", |
||||
|
||||
functional: true, |
||||
|
||||
props: { |
||||
type: { |
||||
type: String, |
||||
default: 'tab', |
||||
validator: v => ['tree', 'tab'].includes(v) |
||||
}, |
||||
|
||||
//省市地区json数据请求地址 |
||||
regionDataUrl: { |
||||
type: String, |
||||
default: `${process.env.BASE_URL}static/json/region-pca.json` |
||||
} |
||||
}, |
||||
|
||||
render(h, context) { |
||||
if (!context.data.props) { |
||||
context.data.props = {} |
||||
} |
||||
context.data.props.regionDataUrl = context.props.regionDataUrl |
||||
|
||||
return h(context.props.type === 'tree' ? Tree : Tab, context.data) |
||||
} |
||||
} |
||||
</script> |
||||
@ -1,11 +0,0 @@ |
||||
export default { |
||||
props: { |
||||
value: String, |
||||
readonly: Boolean, |
||||
size: String, |
||||
getChildrenOnSelect: Boolean, |
||||
limit: Boolean, |
||||
limitApi: Function, |
||||
regionDataUrl: String |
||||
} |
||||
} |
||||
@ -1,11 +0,0 @@ |
||||
import Vue from 'vue' |
||||
|
||||
export const store = Vue.observable({ |
||||
data: [] |
||||
}) |
||||
|
||||
export function init(url) { |
||||
return fetch(url) |
||||
.then(r => r.json()) |
||||
.then(r => store.data = r || []) |
||||
} |
||||
@ -1,217 +0,0 @@ |
||||
<template> |
||||
<el-select |
||||
ref="select" |
||||
:value="value" |
||||
:multiple="multiple" |
||||
:disabled="disabled" |
||||
:size="size" |
||||
:clearable="clearable" |
||||
:placeholder="placeholder" |
||||
:popper-append-to-body="popperAppendToBody" |
||||
:automatic-dropdown="automaticDropdown" |
||||
@input="e => $emit('input',e)" |
||||
@change="e => $emit('change',e)" |
||||
@remove-tag="removeTag" |
||||
> |
||||
<div class="tree-select-container" slot="empty"> |
||||
<el-input |
||||
ref="search" |
||||
v-if="filterable && filterMethod" |
||||
v-model="search" |
||||
size="mini" |
||||
clearable |
||||
@input="onSearch" |
||||
/> |
||||
<el-tree |
||||
ref="tree" |
||||
:data="data" |
||||
:node-key="nodeKey" |
||||
:props="props" |
||||
:show-checkbox="multiple" |
||||
:default-checked-keys="defaultCheckedKeys" |
||||
:current-node-key="multiple ? undefined : value" |
||||
:highlight-current="!multiple" |
||||
:expand-on-click-node="false" |
||||
:check-on-click-node="multiple" |
||||
:filter-node-method="filterMethod" |
||||
:accordion="accordion" |
||||
@node-click="nodeClick" |
||||
@check="check" |
||||
> |
||||
<slot slot-scope="{node,data}"> |
||||
<span :class="{'el-tree-node__label':true,'is-disabled':node.disabled}">{{ node.label }}</span> |
||||
</slot> |
||||
</el-tree> |
||||
</div> |
||||
</el-select> |
||||
</template> |
||||
|
||||
<script> |
||||
import {debounce} from "@/util" |
||||
import {flatTree} from "@/util/tree" |
||||
|
||||
export default { |
||||
name: "TreeSelect", |
||||
|
||||
props: { |
||||
value: {required: true}, |
||||
data: {type: Array, default: () => []}, |
||||
multiple: Boolean, |
||||
disabled: Boolean, |
||||
size: String, |
||||
clearable: {type: Boolean, default: true}, |
||||
placeholder: String, |
||||
filterable: Boolean, |
||||
filterMethod: Function, |
||||
nodeKey: {type: String, default: 'id'}, |
||||
props: { |
||||
default() { |
||||
return { |
||||
children: 'children', |
||||
label: 'label', |
||||
disabled: 'disabled' |
||||
} |
||||
} |
||||
}, |
||||
accordion: {type: Boolean, default: true}, |
||||
popperAppendToBody: Boolean, |
||||
automaticDropdown: Boolean |
||||
}, |
||||
|
||||
data() { |
||||
return { |
||||
currentNode: null, |
||||
search: '' |
||||
} |
||||
}, |
||||
|
||||
computed: { |
||||
defaultCheckedKeys() { |
||||
return this.multiple ? this.value : [] |
||||
} |
||||
}, |
||||
|
||||
watch: { |
||||
value(v) { |
||||
const method = this.multiple ? 'setCheckedKeys' : 'setCurrentKey' |
||||
this.$refs.tree[method](v || null) |
||||
if (!v && !this.multiple) this.currentNode = null |
||||
}, |
||||
|
||||
data() { |
||||
this.initSelectOptions() |
||||
} |
||||
}, |
||||
|
||||
methods: { |
||||
removeTag(v) { |
||||
const tree = this.$refs.tree |
||||
const value = [...this.value] |
||||
|
||||
//被移除的选项的所有父级的ID |
||||
const parents = [] |
||||
let removeNode = tree.getNode(v) |
||||
while (removeNode && removeNode.parent) { |
||||
parents.push(removeNode.parent.key) |
||||
removeNode = removeNode.parent |
||||
} |
||||
|
||||
//移除所有子级、所有父级 |
||||
for (let i = value.length - 1; i >= 0; i--) { |
||||
const isChild = this.getNodeParent(tree.getNode(value[i]), ({key}) => key === v) |
||||
if (isChild || parents.includes(value[i])) { |
||||
value.splice(i, 1) |
||||
} |
||||
} |
||||
|
||||
value.length < this.value.length && this.$emit('input', value) |
||||
}, |
||||
|
||||
onSearch(v) { |
||||
this.$refs.tree.filter(v) |
||||
}, |
||||
|
||||
nodeClick(data, node) { |
||||
if (this.multiple) return |
||||
|
||||
if (node.disabled) { |
||||
return this.$refs.tree.setCurrentKey(null) |
||||
} |
||||
|
||||
if (this.currentNode === data) { |
||||
this.currentNode = null |
||||
this.$refs.tree.setCurrentKey(null) |
||||
} |
||||
else this.currentNode = data |
||||
|
||||
this.$emit('input', this.currentNode && this.currentNode[this.nodeKey], this.currentNode) |
||||
this.$refs.select.blur() |
||||
}, |
||||
|
||||
check(data, {checkedKeys}) { |
||||
this.multiple && this.$emit('input', checkedKeys) |
||||
}, |
||||
|
||||
getNodeParent(node, predicate) { |
||||
if (!node || predicate(node)) return node |
||||
return this.getNodeParent(node.parent, predicate) |
||||
}, |
||||
|
||||
initSelectOptions() { |
||||
this.$refs.select.cachedOptions = |
||||
flatTree(this.data, this.props.children) |
||||
.map(i => ({ |
||||
value: i[this.nodeKey], |
||||
currentLabel: i[this.props.label] |
||||
})) |
||||
this.selectOptionsTrigger() |
||||
}, |
||||
|
||||
//模拟el-select对options的watch |
||||
selectOptionsTrigger() { |
||||
const select = this.$refs.select |
||||
if (select.$isServer) return |
||||
this.$nextTick(() => { |
||||
select.broadcast('ElSelectDropdown', 'updatePopper') |
||||
}) |
||||
if (select.multiple) { |
||||
select.resetInputHeight() |
||||
} |
||||
let inputs = select.$el.querySelectorAll('input') |
||||
if ([].indexOf.call(inputs, document.activeElement) === -1) { |
||||
select.setSelected() |
||||
} |
||||
if (select.defaultFirstOption && (select.filterable || select.remote) && select.filteredOptionsCount) { |
||||
select.checkDefaultFirstOption() |
||||
} |
||||
} |
||||
}, |
||||
|
||||
created() { |
||||
this.onSearch = debounce(this.onSearch) |
||||
}, |
||||
|
||||
mounted() { |
||||
this.currentNode = this.$refs.tree.getCurrentNode() |
||||
this.initSelectOptions() |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
<style lang="scss"> |
||||
@import "~@/asset/style/variables.scss"; |
||||
|
||||
.tree-select-container { |
||||
padding: 8px; |
||||
|
||||
> .el-input { |
||||
margin-bottom: 8px; |
||||
} |
||||
|
||||
.el-tree-node__label.is-disabled { |
||||
background-color: #edf2fc; |
||||
border-color: $border-color-light; |
||||
cursor: not-allowed; |
||||
} |
||||
} |
||||
</style> |
||||
@ -1,242 +0,0 @@ |
||||
<template> |
||||
<el-upload |
||||
ref="upload" |
||||
:before-upload="beforeUpload" |
||||
:class="{disabled: hideUploader}" |
||||
:file-list="tempData" |
||||
:http-request="httpRequest" |
||||
:limit="limit" |
||||
:multiple="multiple" |
||||
:on-error="error" |
||||
:on-exceed="exceed" |
||||
:on-success="success" |
||||
action="" |
||||
list-type="picture-card" |
||||
> |
||||
<i class="el-icon-plus"/> |
||||
|
||||
<template v-slot:file="{file}"> |
||||
<span |
||||
class="el-upload-list__item-actions" |
||||
@mouseenter="e => handleBlockMouseEnter(e,file)" |
||||
@mouseleave="handleBlockMouseLeave" |
||||
> |
||||
<span class="el-upload-list__item-preview" @click="() => preview(file)"> |
||||
<i class="el-icon-zoom-in"/> |
||||
</span> |
||||
<span class="el-upload-list__item-delete" @click="() => download(file)"> |
||||
<i class="el-icon-download"/> |
||||
</span> |
||||
<span v-if="!disabled" class="el-upload-list__item-delete" @click="() => remove(file)"> |
||||
<i class="el-icon-delete"/> |
||||
</span> |
||||
</span> |
||||
|
||||
<template v-if="file.status === 'success'"> |
||||
<img :src="file.url" class="el-upload-list__item-thumbnail"> |
||||
<label class="el-upload-list__item-status-label"> |
||||
<i class="el-icon-upload-success el-icon-check"/> |
||||
</label> |
||||
</template> |
||||
|
||||
<div v-if="file.status === 'uploading'" class="progress-mask"> |
||||
<el-progress :percentage="file.percentage" type="circle"/> |
||||
</div> |
||||
</template> |
||||
|
||||
<el-tooltip ref="tooltip" :content="tooltipContent" popper-class="upload-tooltip"/> |
||||
</el-upload> |
||||
</template> |
||||
|
||||
<script> |
||||
/* |
||||
* 直传七牛云 |
||||
* 传入fileList自动拼接七牛云外链前缀,并增加downloadUrl属性(图片类型的文件url与downloadUrl相同) |
||||
* 非图片类型的文件使用kkFileView预览 |
||||
* success、remove事件中的file.url均不带七牛云外链前缀 |
||||
* */ |
||||
import axios from 'axios' |
||||
import {deleteUpload} from '@/api/file' |
||||
import {file as fileConfig} from '@/config' |
||||
import {debounce, isEmpty, deepClone} from '@/util' |
||||
import {elError} from "@/util/message" |
||||
import {isImage, isDoc, isPdf, isPpt, isRar, isXls, isTxt, isZip} from "@/util/validate" |
||||
import {preview, download, upload, autoCompleteUrl} from "@/util/file" |
||||
|
||||
const typeMapper = [ |
||||
{test: isImage}, {test: isDoc, type: 'doc'}, {test: isPdf, type: 'pdf'}, {test: isPpt, type: 'ppt'}, |
||||
{test: isRar, type: 'rar'}, {test: isXls, type: 'xls'}, {test: isTxt, type: 'txt'}, {test: isZip, type: 'zip'}, |
||||
] |
||||
|
||||
function parseMaxSize(maxSize) { |
||||
if (typeof maxSize === 'number') { |
||||
return maxSize |
||||
} |
||||
|
||||
const map = {KB: 1024, MB: 1024 * 1024}, |
||||
upper = maxSize.toUpperCase(), |
||||
num = upper.replace(/[^0-9]/ig, ""), |
||||
unit = upper.replace(num, "") |
||||
|
||||
return parseInt(num) * (map[unit] || 1) |
||||
} |
||||
|
||||
function getCoverImage(url, fileType) { |
||||
const mapper = typeMapper.find(({test}) => test(fileType)) |
||||
|
||||
return mapper && mapper.type ? `${process.env.BASE_URL}static/img/fileType/${mapper.type}.png` : url |
||||
} |
||||
|
||||
export default { |
||||
name: 'UploadFile', |
||||
|
||||
props: { |
||||
disabled: Boolean, |
||||
multiple: {type: Boolean, default: true}, |
||||
fileList: {type: Array, default: () => []}, |
||||
limit: {type: Number, default: 10}, |
||||
maxSize: {type: [Number, String], default: '10MB'} |
||||
}, |
||||
|
||||
data() { |
||||
return { |
||||
tempData: this.fileList, |
||||
data: this.fileList, |
||||
tooltipContent: null |
||||
} |
||||
}, |
||||
|
||||
computed: { |
||||
count() { |
||||
return this.data.length |
||||
}, |
||||
hideUploader() { |
||||
return this.disabled || this.count >= this.limit |
||||
}, |
||||
previewUrlList() { |
||||
return this.data ? this.data.map(i => i.downloadUrl) : [] |
||||
} |
||||
}, |
||||
|
||||
watch: { |
||||
fileList: { |
||||
immediate: true, |
||||
handler(fileList) { |
||||
if (isEmpty(fileList)) this.data = [] |
||||
else { |
||||
this.data = [...this.fileList.map(file => { |
||||
const downloadUrl = autoCompleteUrl(file.url) |
||||
return {...file, url: getCoverImage(downloadUrl, file.name), downloadUrl} |
||||
})] |
||||
} |
||||
this.tempData = deepClone(this.data) |
||||
} |
||||
} |
||||
}, |
||||
|
||||
methods: { |
||||
//模仿el-table的tooltip |
||||
handleBlockMouseEnter(event, file) { |
||||
const tooltip = this.$refs.tooltip |
||||
this.tooltipContent = file.name |
||||
tooltip.referenceElm = event.target |
||||
tooltip.$refs.popper && (tooltip.$refs.popper.style.display = 'none') |
||||
tooltip.doDestroy() |
||||
tooltip.setExpectedState(true) |
||||
this.activateTooltip(tooltip) |
||||
}, |
||||
handleBlockMouseLeave() { |
||||
const tooltip = this.$refs.tooltip |
||||
if (tooltip) { |
||||
tooltip.setExpectedState(false) |
||||
tooltip.handleClosePopper() |
||||
} |
||||
}, |
||||
|
||||
//附件移除时,仅当非本次上传时触发remove事件 |
||||
remove(file) { |
||||
//删除上传文件时隐藏tooltip |
||||
this.handleBlockMouseLeave() |
||||
|
||||
//区分是否为本次新上传,以及是否上传成功 |
||||
//若不是新上传的 |
||||
if (!file.raw) this.$emit('remove', {url: file.downloadUrl.replace(fileConfig.storePrefix, '')}) |
||||
//判断是否上传成功(有可能是正在上传) |
||||
else if (file.response && !file.response.err) deleteUpload.request(file.raw.key) |
||||
|
||||
this.$refs.upload.handleRemove(file) |
||||
const index = this.data.findIndex(i => i.downloadUrl === file.downloadUrl) |
||||
if (index > -1) this.data.splice(index, 1) |
||||
}, |
||||
|
||||
//预览 |
||||
preview(file) { |
||||
if (!isImage(file.name)) return preview(file.downloadUrl) |
||||
|
||||
const index = this.data.findIndex(i => i.downloadUrl === file.downloadUrl) |
||||
this.$image({index, urlList: this.previewUrlList}) |
||||
}, |
||||
|
||||
//下载 |
||||
download(file) { |
||||
download(file.downloadUrl, file.name) |
||||
}, |
||||
|
||||
//数量超限 |
||||
exceed() { |
||||
elError(`最多只能上传${this.limit}个文件`) |
||||
}, |
||||
|
||||
//新附件上传成功,触发success事件,记录文件key |
||||
success(res, file) { |
||||
file.url = getCoverImage(file.url, file.name) |
||||
file.raw.key = res.key |
||||
file.downloadUrl = autoCompleteUrl(res.key) |
||||
this.data.push(file) |
||||
this.$emit('success', file, res) |
||||
}, |
||||
|
||||
//上传失败时移除文件 |
||||
error(err, file) { |
||||
elError('上传文件失败,' + err) |
||||
file.response.err = err |
||||
this.remove(file) |
||||
}, |
||||
|
||||
//上传前过滤文件 |
||||
beforeUpload(file) { |
||||
if (!typeMapper.some(({test}) => test(file.name))) { |
||||
elError('只支持图片、word、pdf、ppt、rar、excel、txt、zip类型的文件') |
||||
return false |
||||
} |
||||
const maxSize = parseMaxSize(this.maxSize) |
||||
if (file.size > maxSize) { |
||||
elError(`${file.name}的大小超出${this.$options.filters.number2StorageUnit(maxSize)}`) |
||||
return false |
||||
} |
||||
}, |
||||
|
||||
httpRequest({file, onProgress}) { |
||||
const source = axios.CancelToken.source() |
||||
const promise = upload(file, file.name, { |
||||
onUploadProgress(e) { |
||||
if (e.total > 0) { |
||||
e.percent = (Number)((e.loaded / e.total * 100).toFixed(2)) |
||||
} |
||||
onProgress(e) |
||||
}, |
||||
cancelToken: source.token |
||||
}) |
||||
//由于ele原有的上传方法是原生ajax,所以取消上传时会调用一次abort方法 |
||||
promise.abort = source.cancel |
||||
return promise |
||||
} |
||||
}, |
||||
|
||||
created() { |
||||
this.activateTooltip = debounce(tooltip => tooltip.handleShowPopper(), 200) |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
<style lang="scss" src="./style.scss"></style> |
||||
@ -1,34 +0,0 @@ |
||||
@import "~@/asset/style/variables.scss"; |
||||
|
||||
.disabled .el-upload--picture-card { |
||||
display: none; |
||||
} |
||||
|
||||
.el-upload-list--picture-card { |
||||
.el-progress { |
||||
width: 100%; |
||||
height: 100%; |
||||
|
||||
.el-progress-circle { |
||||
height: 100% !important; |
||||
width: 100% !important; |
||||
} |
||||
} |
||||
|
||||
.progress-mask { |
||||
position: absolute; |
||||
top: 0; |
||||
height: 100%; |
||||
width: 100%; |
||||
background-color: #ffffff; |
||||
opacity: 0.9; |
||||
|
||||
.el-progress__text { |
||||
color: $--color-primary; |
||||
} |
||||
} |
||||
} |
||||
|
||||
.upload-tooltip { |
||||
max-width: 146px; |
||||
} |
||||
@ -1,42 +0,0 @@ |
||||
<template> |
||||
<el-tree |
||||
ref="tree" |
||||
:data="data" |
||||
:expand-on-click-node="false" |
||||
:props="{label:'name'}" |
||||
:filter-node-method="filterNodeMethod" |
||||
highlight-current |
||||
node-key="id" |
||||
v-on="$listeners" |
||||
/> |
||||
</template> |
||||
|
||||
<script> |
||||
import {getAll} from "@/api/system/category" |
||||
|
||||
export default { |
||||
name: "CategoryTree", |
||||
|
||||
props: {filterNodeMethod: Function}, |
||||
|
||||
computed: { |
||||
data() { |
||||
return this.$store.state.dataCache.categoryTree |
||||
} |
||||
}, |
||||
|
||||
methods: { |
||||
filter(v) { |
||||
this.$refs.tree.filter(v) |
||||
} |
||||
}, |
||||
|
||||
mounted() { |
||||
if (this.data.length > 0) return |
||||
getAll |
||||
.request() |
||||
.then(({data}) => this.$store.commit('dataCache/categories', data)) |
||||
.finally(() => this.loading = false) |
||||
} |
||||
} |
||||
</script> |
||||
@ -1,34 +0,0 @@ |
||||
<template> |
||||
<el-select :value="value" :disabled="disabled" multiple collapse-tags size="small" @input="emit"> |
||||
<el-option |
||||
v-for="user in data" |
||||
:key="user.id" |
||||
:label="user.nickName" |
||||
:value="user.id" |
||||
/> |
||||
</el-select> |
||||
</template> |
||||
|
||||
<script> |
||||
import {search} from '@/api/system/user' |
||||
|
||||
export default { |
||||
name: "SimpleMultipleUserSelector", |
||||
|
||||
props: {value: Array, disabled: Boolean}, |
||||
|
||||
data: () => ({data: []}), |
||||
|
||||
methods: { |
||||
emit(v) { |
||||
this.$emit('input', v) |
||||
} |
||||
}, |
||||
|
||||
mounted() { |
||||
search |
||||
.request({page: 1, pageSize: 9999}) |
||||
.then(({data: {list}}) => this.data = list) |
||||
} |
||||
} |
||||
</script> |
||||
@ -1,5 +0,0 @@ |
||||
import SimpleMultipleUserSelector from "./SimpleMultipleUserSelector" |
||||
|
||||
export { |
||||
SimpleMultipleUserSelector |
||||
} |
||||
@ -1,89 +0,0 @@ |
||||
<template> |
||||
<div> |
||||
<abstract-table v-loading="loading" :data="data" :highlight-current-row="false"> |
||||
<el-table-column align="center" label="#" type="index" width="80"/> |
||||
<el-table-column align="center" label="操作人" prop="uname"/> |
||||
<el-table-column align="center" label="操作类型" prop="type"/> |
||||
<el-table-column align="center" label="时 间" prop="time"/> |
||||
<el-table-column align="center" label="备注" prop="info"/> |
||||
</abstract-table> |
||||
|
||||
<abstract-pagination :model="searchForm" @current-change="pageChange"/> |
||||
</div> |
||||
</template> |
||||
|
||||
<script> |
||||
import AbstractPagination from '@/component/abstract/Pagination' |
||||
import AbstractTable from "@/component/abstract/Table" |
||||
import {search} from "@/api/doc/history" |
||||
import {isEmpty, timeFormat} from "@/util" |
||||
|
||||
export default { |
||||
name: "DocHistory", |
||||
|
||||
components: {AbstractPagination, AbstractTable}, |
||||
|
||||
props: {id: String}, |
||||
|
||||
data() { |
||||
return { |
||||
loading: false, |
||||
searchForm: { |
||||
page: 1, |
||||
pageSize: 15, |
||||
total: 0 |
||||
}, |
||||
data: [] |
||||
} |
||||
}, |
||||
|
||||
watch: { |
||||
id: { |
||||
immediate: true, |
||||
handler() { |
||||
this.search() |
||||
} |
||||
} |
||||
}, |
||||
|
||||
methods: { |
||||
search() { |
||||
if (isEmpty(this.id)) return |
||||
search |
||||
.request({pid: this.id, ...this.searchForm}) |
||||
.then(({data: {list, total}}) => { |
||||
this.transformData(list) |
||||
this.data = list |
||||
this.total = total |
||||
}) |
||||
}, |
||||
|
||||
pageChange(v) { |
||||
this.searchForm.page = v |
||||
this.search() |
||||
}, |
||||
|
||||
transformData(data) { |
||||
data.forEach(i => { |
||||
i.time = timeFormat(null, new Date(i.time)) |
||||
let type = null |
||||
switch (i.type) { |
||||
case 0: |
||||
type = '撤回' |
||||
break |
||||
case 1: |
||||
type = '提交' |
||||
break |
||||
case 2: |
||||
type = '通过' |
||||
break |
||||
case 3: |
||||
type = '驳回' |
||||
break |
||||
} |
||||
i.type = type |
||||
}) |
||||
} |
||||
} |
||||
} |
||||
</script> |
||||
@ -1,22 +0,0 @@ |
||||
<script type="text/jsx"> |
||||
export default { |
||||
name: "DocSteps", |
||||
|
||||
functional: true, |
||||
|
||||
props: {status: Number, finish: Number}, |
||||
|
||||
render(h, context) { |
||||
const {status, finish} = context.props |
||||
const active = status === 2 && (finish === undefined || finish === 2) ? 4 : status |
||||
return ( |
||||
<el-steps align-center active={active === 2 ? 3 : active}> |
||||
<el-step title="填写单据"/> |
||||
<el-step title="提交单据,等待审核"/> |
||||
<el-step title="审核通过"/> |
||||
<el-step title="单据完成"/> |
||||
</el-steps> |
||||
) |
||||
} |
||||
} |
||||
</script> |
||||
@ -1,46 +0,0 @@ |
||||
<template> |
||||
<textarea/> |
||||
</template> |
||||
|
||||
<script> |
||||
import CodeMirror from 'codemirror' |
||||
import 'codemirror/lib/codemirror.css' |
||||
import 'codemirror/theme/rubyblue.css' |
||||
import 'codemirror/mode/javascript/javascript' |
||||
import {isEmpty} from "@/util" |
||||
|
||||
export default { |
||||
name: "JsonEditor", |
||||
|
||||
props: ['value'], |
||||
|
||||
watch: { |
||||
value(value) { |
||||
const editorValue = this.jsonEditor.getValue() |
||||
if (value !== editorValue) { |
||||
this.jsonEditor.setValue(this.value) |
||||
} |
||||
} |
||||
}, |
||||
|
||||
mounted() { |
||||
this.jsonEditor = CodeMirror.fromTextArea( |
||||
this.$el, |
||||
{ |
||||
mode: 'application/json', |
||||
theme: 'rubyblue' |
||||
} |
||||
) |
||||
|
||||
!isEmpty(this.value) && this.jsonEditor.setValue(this.value) |
||||
this.jsonEditor.on('change', cm => { |
||||
this.$emit('input', cm.getValue()) |
||||
}) |
||||
}, |
||||
|
||||
beforeDestroy() { |
||||
const element = this.jsonEditor.doc.cm.getWrapperElement() |
||||
element && element.remove && element.remove() |
||||
} |
||||
} |
||||
</script> |
||||
@ -1,121 +0,0 @@ |
||||
<template> |
||||
<div v-loading="!hasInit" element-loading-text="正在加载编辑器..." class="tinymce-container" :style="{width}"> |
||||
<div class="tinymce-wrapper"> |
||||
<textarea :id="id" class="tinymce-textarea"/> |
||||
</div> |
||||
</div> |
||||
</template> |
||||
|
||||
<script> |
||||
import {waitUntilSuccess} from "@/util" |
||||
import {elError} from "@/util/message" |
||||
|
||||
const DEFAULT_OPTIONS = { |
||||
language: 'zh_CN', |
||||
language_url: `${process.env.BASE_URL}static/tinymce/langs/zh_CN.js`, |
||||
object_resizing: false, |
||||
end_container_on_empty_block: true, |
||||
default_link_target: '_blank', |
||||
convert_urls: false |
||||
} |
||||
|
||||
export default { |
||||
name: "RichTextEditor", |
||||
|
||||
props: { |
||||
value: String, |
||||
readonly: Boolean, |
||||
height: {type: String, default: '400px'}, |
||||
width: {type: String, default: '100%'} |
||||
}, |
||||
|
||||
data() { |
||||
return { |
||||
id: 'tinymce-' + Date.now(), |
||||
fullscreen: false, |
||||
hasInit: false, |
||||
manualChange: false, |
||||
} |
||||
}, |
||||
|
||||
watch: { |
||||
value(value) { |
||||
if (!this.hasInit) return |
||||
if (this.manualChange) this.manualChange = false |
||||
else this.set(value) |
||||
}, |
||||
readonly(value) { |
||||
this.hasInit && this.getInstance().mode.set(value ? 'readonly' : 'design') |
||||
} |
||||
}, |
||||
|
||||
methods: { |
||||
getInstance() { |
||||
return window.tinymce.get(this.id) |
||||
}, |
||||
get() { |
||||
return this.getInstance().getContent() |
||||
}, |
||||
set(content) { |
||||
this.getInstance().setContent(content || '') |
||||
}, |
||||
init() { |
||||
const ctx = this |
||||
waitUntilSuccess(() => window.tinymce) |
||||
.then(() => { |
||||
window.tinymce.init({ |
||||
...DEFAULT_OPTIONS, |
||||
selector: `#${ctx.id}`, |
||||
height: ctx.height, |
||||
init_instance_callback(editor) { |
||||
ctx.hasInit = true |
||||
editor.setContent(ctx.value || '') |
||||
editor.mode.set(ctx.readonly ? 'readonly' : 'design') |
||||
}, |
||||
setup(editor) { |
||||
editor.on('keyup undo redo', () => { |
||||
ctx.manualChange = true |
||||
ctx.$emit('input', editor.getContent()) |
||||
}) |
||||
editor.on('FullscreenStateChanged', e => { |
||||
ctx.fullscreen = e.state |
||||
}) |
||||
} |
||||
}) |
||||
}) |
||||
.catch(() => elError('tinymce初始化失败,请检查js是否引入成功')) |
||||
}, |
||||
destroy() { |
||||
const tinymce = this.getInstance() |
||||
if (!tinymce) return |
||||
|
||||
this.fullscreen && tinymce.execCommand('mceFullScreen') |
||||
|
||||
tinymce.destroy() |
||||
} |
||||
}, |
||||
|
||||
mounted() { |
||||
this.init() |
||||
this.$once('hook:beforeDestroy', this.destroy) |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
<style lang="scss"> |
||||
.tinymce-container { |
||||
position: relative; |
||||
|
||||
.tinymce-wrapper { |
||||
width: 100%; |
||||
|
||||
.tinymce-textarea { |
||||
visibility: hidden; |
||||
} |
||||
} |
||||
} |
||||
|
||||
.tox-tinymce-aux { |
||||
z-index: 10000 !important; |
||||
} |
||||
</style> |
||||
@ -1,157 +0,0 @@ |
||||
<template> |
||||
<div class="form-anchor"> |
||||
<div |
||||
v-for="i in data" |
||||
:key="i.ref" |
||||
:class="{'form-anchor-item': true,'active': i.ref === cur}" |
||||
@click="() => click(i.ref)" |
||||
> |
||||
{{ i.label }} |
||||
</div> |
||||
<div class="form-anchor-item" @click="close">关闭导航</div> |
||||
</div> |
||||
</template> |
||||
|
||||
<script> |
||||
import {debounce} from "@/util" |
||||
import {getScroll, getTopDistance, scrollTo} from "@/util/browser" |
||||
|
||||
export default { |
||||
name: "FormAnchor", |
||||
|
||||
props: { |
||||
reference: Function, |
||||
|
||||
//锚点链接数组,{ref:链接对应的ref,使用reference()[ref]取得对应的dom, label:链接名称} |
||||
data: {type: Array, default: () => []}, |
||||
|
||||
//距离窗口顶部达到指定偏移量后触发,单位px |
||||
offsetTop: {type: Number, default: 15}, |
||||
|
||||
//锚点区域边界,单位px |
||||
bounds: {type: Number, default: 0}, |
||||
|
||||
//指定滚动的容器 |
||||
getContainer: { |
||||
type: Function, |
||||
default: () => document.querySelector('#app .page-main>.scroll-container') |
||||
}, |
||||
}, |
||||
|
||||
data: () => ({cur: null}), |
||||
|
||||
methods: { |
||||
//彻底销毁本组件 |
||||
close() { |
||||
this.$el.remove() |
||||
this.$destroy() |
||||
}, |
||||
|
||||
//获取当前激活的锚点链接的ref值 |
||||
getCur(offsetTop, bounds) { |
||||
const sections = [] |
||||
const container = this.getContainer() |
||||
for (const {ref} of this.data) { |
||||
const target = this.getDomFromRef(ref) |
||||
if (!target) continue |
||||
|
||||
const top = getTopDistance(target, container) |
||||
if (top <= offsetTop + bounds) { |
||||
sections.push({ref, top}) |
||||
} |
||||
} |
||||
|
||||
//可能有多个满足条件的dom,取距滚动容器最远的那个 |
||||
if (sections.length) { |
||||
const max = sections.reduce((prev, curr) => (curr.top > prev.top ? curr : prev)) |
||||
return max.ref |
||||
} |
||||
|
||||
return null |
||||
}, |
||||
|
||||
getDomFromRef(ref) { |
||||
const refs = this.reference() |
||||
|
||||
if (!refs) return null |
||||
|
||||
const target = Array.isArray(refs[ref]) ? refs[ref][0] : refs[ref] |
||||
|
||||
//可能是vue组件实例,也可能是dom |
||||
return target.$el || target |
||||
}, |
||||
|
||||
click(ref) { |
||||
if (!ref || this.animating || !this.reference) return |
||||
|
||||
this.cur = ref |
||||
|
||||
const targetElement = this.getDomFromRef(ref) |
||||
if (!targetElement) return |
||||
|
||||
this.animating = true |
||||
|
||||
const container = this.getContainer() |
||||
const scrollTop = getScroll(container, true) |
||||
const elOffsetTop = getTopDistance(targetElement, container) |
||||
const y = scrollTop + elOffsetTop - this.offsetTop |
||||
|
||||
//由于滚动监听函数的防抖阈值是100ms,所以延迟设置滚动标识 |
||||
scrollTo(container, y, {callback: () => window.setTimeout(() => this.animating = false, 110)}) |
||||
}, |
||||
|
||||
scroll() { |
||||
if (this.animating || !this.reference) return |
||||
this.cur = this.getCur(this.offsetTop, this.bounds) |
||||
} |
||||
}, |
||||
|
||||
mounted() { |
||||
this.scroll = debounce(this.scroll) |
||||
|
||||
this.scroll() |
||||
|
||||
const container = this.getContainer() |
||||
|
||||
container.addEventListener('scroll', this.scroll) |
||||
this.$once('hook:beforeDestroy', () => { |
||||
container.removeEventListener('scroll', this.scroll) |
||||
}) |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
<style lang="scss"> |
||||
@import "~@/asset/style/variables.scss"; |
||||
|
||||
.form-anchor { |
||||
position: fixed; |
||||
right: 0; |
||||
top: 200px; |
||||
min-width: 72px; |
||||
border-radius: 4px 0 0 4px; |
||||
background: #ffffff; |
||||
box-shadow: 0 2px 12px 0 rgba(166, 167, 173, 0.5); |
||||
z-index: 100; |
||||
|
||||
&-item { |
||||
width: inherit; |
||||
height: 28px; |
||||
line-height: 28px; |
||||
text-align: center; |
||||
color: #909399; |
||||
padding: 0 10px; |
||||
font-size: 12px; |
||||
cursor: pointer; |
||||
|
||||
&:hover, |
||||
&.active { |
||||
color: $--color-primary |
||||
} |
||||
|
||||
&:not(:last-child) { |
||||
border-bottom: 1px solid rgba(166, 167, 173, 0.1); |
||||
} |
||||
} |
||||
} |
||||
</style> |
||||
@ -1,156 +0,0 @@ |
||||
<script type="text/jsx"> |
||||
import {deepClone} from "@/util" |
||||
import {getElementInnerWidth} from '@/util/browser' |
||||
|
||||
export default { |
||||
name: "SearchForm", |
||||
|
||||
model: { |
||||
prop: 'model', |
||||
event: 'reset' |
||||
}, |
||||
|
||||
props: { |
||||
model: Object, |
||||
labelWidth: {type: String, default: '120px'}, |
||||
|
||||
/*每个宽度下,一行能有多少个控件,需要是24的因数*/ |
||||
xs: {type: Number, default: 1}, // <768px |
||||
sm: {type: Number, default: 2}, // >=768px |
||||
md: {type: Number, default: 3}, // >=998px |
||||
lg: {type: Number, default: 4} // >=1200px |
||||
}, |
||||
|
||||
data() { |
||||
return { |
||||
showCollapse: false, //是否需要折叠控制 |
||||
collapse: true, //是否处于折叠状态,默认折叠 |
||||
num: 0, //从第几个控件开始需要隐藏 |
||||
span: 8 //24等分下,每个栅格的宽度 |
||||
} |
||||
}, |
||||
|
||||
methods: { |
||||
handleCollapse() { |
||||
this.collapse = !this.collapse |
||||
}, |
||||
handleSearch() { |
||||
this.$emit('search') |
||||
}, |
||||
handleReset() { |
||||
if (!this.initialModel || !this.model) { |
||||
return |
||||
} |
||||
|
||||
this.$emit('reset', deepClone(this.initialModel)) |
||||
}, |
||||
|
||||
getElementNumInRow() { |
||||
const vw = getElementInnerWidth(this.$el.parentNode) |
||||
|
||||
if (vw < 768) return this.xs |
||||
if (vw < 998) return this.sm |
||||
if (vw < 1200) return this.md |
||||
return this.lg |
||||
}, |
||||
|
||||
resize() { |
||||
const num = this.getElementNumInRow() |
||||
|
||||
this.span = 24 / num |
||||
|
||||
//考虑后面的按钮组的占位 |
||||
this.num = num === 1 ? num : num - 1 |
||||
|
||||
this.showCollapse = this.num < this.$slots.default.length |
||||
}, |
||||
|
||||
renderChildren(children, hide) { |
||||
return children.map(child => ( |
||||
<el-col span={this.span} class={{hide}}>{child}</el-col> |
||||
)) |
||||
}, |
||||
|
||||
renderAction() { |
||||
const ctrl = this.collapse |
||||
? {i: 'el-icon-arrow-down', t: '展开'} |
||||
: {i: 'el-icon-arrow-up', t: '收起'} |
||||
|
||||
return ( |
||||
<div class="search-form-action"> |
||||
<el-button type="primary" size="small" on-click={this.handleSearch}>查 询</el-button> |
||||
<el-button type="dashed" size="small" plain on-click={this.handleReset}>重 置</el-button> |
||||
{this.showCollapse && ( |
||||
<el-button |
||||
type="text" |
||||
size="small" |
||||
style="padding-left: 0" |
||||
on-click={this.handleCollapse} |
||||
> |
||||
{ctrl.t} |
||||
<v-icon icon={ctrl.i} style="margin-left: 0.5em"/> |
||||
</el-button> |
||||
)} |
||||
</div> |
||||
) |
||||
} |
||||
}, |
||||
|
||||
mounted() { |
||||
//记录初始条件 |
||||
this.initialModel = deepClone(this.model) |
||||
|
||||
this.resize() |
||||
this.resizeObserver = new ResizeObserver(this.resize) |
||||
this.resizeObserver.observe(this.$el.parentNode) |
||||
|
||||
this.$once('hook:beforeDestroy', () => { |
||||
if (this.resizeObserver) { |
||||
this.resizeObserver.disconnect() |
||||
this.resizeObserver = null |
||||
} |
||||
}) |
||||
}, |
||||
|
||||
render() { |
||||
const slots = this.$slots.default, collapse = this.showCollapse && this.collapse |
||||
|
||||
const display = collapse ? slots.slice(0, this.num) : slots |
||||
const hidden = collapse ? slots.slice(this.num) : [] |
||||
|
||||
return ( |
||||
<el-form |
||||
class="search-form" |
||||
label-position="right" |
||||
label-width={this.labelWidth} |
||||
label-suffix=":" |
||||
size="small" |
||||
> |
||||
<el-row gutter={20}> |
||||
{this.renderChildren(display)} |
||||
{this.renderChildren(hidden, true)} |
||||
{this.renderAction()} |
||||
</el-row> |
||||
</el-form> |
||||
) |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
<style lang="scss"> |
||||
.search-form { |
||||
&-action { |
||||
margin-left: auto; |
||||
} |
||||
|
||||
> .el-row { |
||||
display: flex; |
||||
flex-direction: row; |
||||
flex-wrap: wrap; |
||||
} |
||||
|
||||
.hide { |
||||
display: none; |
||||
} |
||||
} |
||||
</style> |
||||
@ -1,86 +0,0 @@ |
||||
<template> |
||||
<el-popover |
||||
ref="popover" |
||||
:disabled="error.length === 0" |
||||
placement="top" |
||||
width="256" |
||||
trigger="click" |
||||
popper-class="form-validate-info" |
||||
> |
||||
<div class="form-validate-info-title">表单校验信息</div> |
||||
<div class="form-validate-info-content"> |
||||
<div v-for="i in error" :key="i.prop" class="form-validate-info-item" @click="() => onClick(i)"> |
||||
<i class="el-icon-circle-close"/> |
||||
<div class="form-validate-info-item-msg">{{ i.msg }}</div> |
||||
<div class="form-validate-info-item-label">{{ i.label }}</div> |
||||
</div> |
||||
</div> |
||||
<template slot="reference"> |
||||
<span v-show="error.length > 0" class="form-validate-info-ref"> |
||||
<i class="el-icon-circle-close"/> |
||||
{{ error.length }} |
||||
</span> |
||||
</template> |
||||
</el-popover> |
||||
</template> |
||||
|
||||
<script> |
||||
export default { |
||||
name: "FormValidateInfo", |
||||
|
||||
props: {form: [Function, Object]}, |
||||
|
||||
data() { |
||||
return { |
||||
visible: false, |
||||
error: [] |
||||
} |
||||
}, |
||||
|
||||
methods: { |
||||
//滚动到验证不通过的表单项 |
||||
onClick({prop}) { |
||||
const formItem = this.getFormItem(prop) |
||||
formItem && formItem.$el.scrollIntoView({block: 'center'}) |
||||
this.$refs.popover.doClose() |
||||
}, |
||||
|
||||
//获取el-form实例 |
||||
getForm() { |
||||
return typeof this.form === 'function' ? this.form() : this.form |
||||
}, |
||||
|
||||
//根据prop获取el-form-item实例 |
||||
getFormItem(prop) { |
||||
const form = this.getForm() |
||||
return form && form.fields.find(i => i.prop === prop) |
||||
}, |
||||
|
||||
//el-form触发validate事件时调用 |
||||
onValidate(prop, success, msg) { |
||||
const alreadyIndex = this.error.findIndex(i => i.prop === prop) |
||||
if (alreadyIndex !== -1) { |
||||
if (success) this.error.splice(alreadyIndex, 1) |
||||
else this.$set(this.error, alreadyIndex, {...this.error[alreadyIndex], msg}) |
||||
} |
||||
else { |
||||
if (success) return |
||||
const formItem = this.getFormItem(prop) |
||||
this.error.push({prop, msg, label: formItem.label}) |
||||
} |
||||
} |
||||
}, |
||||
|
||||
mounted() { |
||||
const form = this.getForm() |
||||
if (!form) return |
||||
|
||||
form.$on('validate', this.onValidate) |
||||
this.$once('hook:beforeDestroy', () => { |
||||
form.$off('validate', this.onValidate) |
||||
}) |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
<style lang="scss" src="./style.scss"></style> |
||||
@ -1,50 +0,0 @@ |
||||
@import "~@/asset/style/variables.scss"; |
||||
|
||||
.form-validate-info { |
||||
&.el-popper { |
||||
padding: 0; |
||||
} |
||||
|
||||
&-ref { |
||||
padding: 0 10px; |
||||
cursor: pointer; |
||||
} |
||||
|
||||
&-ref, |
||||
&-item .el-icon-circle-close { |
||||
color: $--color-danger; |
||||
} |
||||
|
||||
&-title { |
||||
text-align: center; |
||||
padding: 5px 16px 4px; |
||||
} |
||||
|
||||
&-content { |
||||
max-height: 300px; |
||||
overflow: auto; |
||||
} |
||||
|
||||
&-item { |
||||
padding: 8px 16px; |
||||
border-top: 1px solid $border-color-light; |
||||
cursor: pointer; |
||||
|
||||
&:hover { |
||||
background-color: rgba($--color-primary, .1); |
||||
} |
||||
|
||||
.el-icon-circle-close { |
||||
float: left; |
||||
margin-top: 4px; |
||||
margin-right: 12px; |
||||
padding-bottom: 22px; |
||||
} |
||||
|
||||
&-label { |
||||
margin-top: 2px; |
||||
color: #909399; |
||||
font-size: 12px; |
||||
} |
||||
} |
||||
} |
||||
@ -1,24 +0,0 @@ |
||||
import Vue from 'vue' |
||||
import Main from './main.vue' |
||||
|
||||
const BottomTipConstructor = Vue.extend(Main) |
||||
|
||||
let instance |
||||
|
||||
const bottomTip = function (message) { |
||||
instance && instance.close() |
||||
instance = new BottomTipConstructor({data: {message}}) |
||||
instance.$mount() |
||||
document.body.appendChild(instance.$el) |
||||
instance.value = true |
||||
return instance |
||||
} |
||||
|
||||
bottomTip.close = function () { |
||||
if (instance) { |
||||
instance.close() |
||||
instance = null |
||||
} |
||||
} |
||||
|
||||
export default bottomTip |
||||
@ -1,49 +0,0 @@ |
||||
<template> |
||||
<transition name="el-zoom-in-bottom" @after-leave="handleAfterLeave"> |
||||
<div v-show="value" class="bottom-tip el-alert--error is-dark"> |
||||
<i class="el-icon-warning tip-icon"/> |
||||
<span class="tip-content">{{ message }}</span> |
||||
</div> |
||||
</transition> |
||||
</template> |
||||
|
||||
<script> |
||||
export default { |
||||
name: "BottomTip", |
||||
|
||||
data() { |
||||
return { |
||||
value: false, |
||||
message: null |
||||
} |
||||
}, |
||||
|
||||
methods: { |
||||
handleAfterLeave() { |
||||
this.$destroy(true) |
||||
this.$el.parentNode.removeChild(this.$el) |
||||
}, |
||||
close() { |
||||
this.value = false |
||||
}, |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
<style lang="scss"> |
||||
.bottom-tip { |
||||
position: absolute; |
||||
width: 100%; |
||||
bottom: 0; |
||||
z-index: 100; |
||||
display: flex; |
||||
justify-content: center; |
||||
align-items: center; |
||||
font-size: 20px; |
||||
|
||||
.tip-content { |
||||
font-weight: bold; |
||||
margin: 10px; |
||||
} |
||||
} |
||||
</style> |
||||
@ -1,17 +0,0 @@ |
||||
import {isEmpty} from "@/util" |
||||
|
||||
export default { |
||||
computed: { |
||||
showOfflineTip() { |
||||
const state = this.$store.state |
||||
const isLogin = !isEmpty(state.user.id, state.user.token) |
||||
return !state.socket.online && isLogin |
||||
}, |
||||
}, |
||||
|
||||
watch: { |
||||
showOfflineTip(v) { |
||||
v ? this.$bottomTip('与服务器失去连接') : this.$bottomTip.close() |
||||
} |
||||
} |
||||
} |
||||
@ -1,39 +0,0 @@ |
||||
# 布局设计 |
||||
|
||||
## 导航模式 |
||||
|
||||
### 桌面端 |
||||
|
||||
1.侧边栏导航:侧边栏菜单为完整菜单,顶部菜单不渲染 |
||||
|
||||
2.顶部导航:侧边栏菜单不渲染,顶部菜单为完整菜单 |
||||
|
||||
3.混合导航:顶部菜单为完整菜单的所有根节点,侧边栏菜单为当前激活的顶部菜单的子级 |
||||
|
||||
4.双层侧边栏导航:侧边栏分为最左侧的根节点栏、二级子菜单,顶部菜单不渲染 |
||||
|
||||
### 移动端 |
||||
|
||||
1.侧边栏导航:侧边栏菜单为完整菜单,顶部菜单不渲染,侧边栏为抽屉模式 |
||||
|
||||
2.顶部导航:设置无效,效果等同于侧边栏导航 |
||||
|
||||
3.混合导航:设置无效,效果等同于侧边栏导航 |
||||
|
||||
4.双层侧边栏导航:设置无效,效果等同于侧边栏导航 |
||||
|
||||
## 侧边栏 |
||||
|
||||
### 桌面端 |
||||
|
||||
未设置自动隐藏时,折叠状态依据用户设置 |
||||
设置了自动隐藏时,折叠状态无效,侧边栏为抽屉模式 |
||||
|
||||
当导航模式为*双层侧边栏导航*时,以下设置无效: |
||||
- 折叠 |
||||
- 折叠时显示父级 |
||||
- 自动隐藏 |
||||
|
||||
### 移动端 |
||||
|
||||
侧边栏只能为抽屉模式 |
||||
@ -1,4 +0,0 @@ |
||||
import logic from "./logic" |
||||
import resize from "./resize" |
||||
|
||||
export {logic, resize} |
||||
@ -1,42 +0,0 @@ |
||||
import {isEmpty, waitUntilSuccess} from "@/util" |
||||
|
||||
export default { |
||||
props: { |
||||
width: { |
||||
type: String, |
||||
default: '100%' |
||||
}, |
||||
height: { |
||||
type: String, |
||||
default: '350px' |
||||
} |
||||
}, |
||||
|
||||
data() { |
||||
return { |
||||
loading: false |
||||
} |
||||
}, |
||||
|
||||
methods: { |
||||
$_getChartInstance() { |
||||
return waitUntilSuccess( |
||||
() => !isEmpty(window.echarts), |
||||
() => this.chart = window.echarts.init(this.$_getChartDom(), 'macarons') |
||||
) |
||||
}, |
||||
$_getChartDom() { |
||||
return this.$el |
||||
} |
||||
}, |
||||
|
||||
mounted() { |
||||
this.$_getChartInstance().then(() => this.init()) |
||||
}, |
||||
|
||||
beforeDestroy() { |
||||
if (!this.chart) return |
||||
this.chart.dispose() |
||||
this.chart = null |
||||
} |
||||
} |
||||
@ -1,29 +0,0 @@ |
||||
import {debounce} from '@/util' |
||||
|
||||
export default { |
||||
methods: { |
||||
$_initResizeEvent() { |
||||
this.resizeObserver = new ResizeObserver(this.$_resizeHandler) |
||||
this.resizeObserver.observe(this.$el) |
||||
}, |
||||
$_destroyResizeEvent() { |
||||
if (this.resizeObserver) { |
||||
this.resizeObserver.disconnect() |
||||
this.resizeObserver = null |
||||
} |
||||
} |
||||
}, |
||||
|
||||
mounted() { |
||||
this.$_resizeHandler = debounce(() => this.chart && this.chart.resize()) |
||||
this.$_initResizeEvent() |
||||
this.$nextTick(() => this.$_resizeHandler()) |
||||
this.$once('hook:deactivated', this.$_destroyResizeEvent) |
||||
this.$once('hook:beforeDestroy', this.$_destroyResizeEvent) |
||||
}, |
||||
|
||||
activated() { |
||||
this.$_initResizeEvent() |
||||
this.$nextTick(() => this.$_resizeHandler()) |
||||
} |
||||
} |
||||
@ -1,7 +0,0 @@ |
||||
export default { |
||||
methods: { |
||||
closeDialog() { |
||||
this.$emit('input', false) |
||||
} |
||||
} |
||||
} |
||||
@ -1,286 +0,0 @@ |
||||
import {commonMethods} from "@/mixin/docTableMixin" |
||||
import AbstractForm from '@/component/abstract/Form' |
||||
import AbstractFormItem from "@/component/abstract/Form/item" |
||||
import AbstractTable from "@/component/abstract/Table" |
||||
import CollapseCard from '@/component/CollapseCard' |
||||
import DetailPage from "@/view/_common/DetailPage" |
||||
import DocHistory from '@/component/biz/doc/DocHistory' |
||||
import DocSteps from '@/component/biz/doc/DocSteps' |
||||
import UploadFile from '@/component/UploadFile' |
||||
import {isEmpty, mergeObj} from '@/util' |
||||
import {auth} from "@/util/auth" |
||||
import {deleteUpload} from "@/api/file" |
||||
import {elAlert, elConfirm, elPrompt, elSuccess} from "@/util/message" |
||||
import {closeCurrentPage} from "@/util/route" |
||||
|
||||
export default { |
||||
components: { |
||||
AbstractForm, |
||||
AbstractFormItem, |
||||
AbstractTable, |
||||
CollapseCard, |
||||
DetailPage, |
||||
DocHistory, |
||||
DocSteps, |
||||
UploadFile |
||||
}, |
||||
|
||||
props: { |
||||
//单据id
|
||||
id: String, |
||||
//编辑模式,see,add,edit
|
||||
type: String |
||||
}, |
||||
|
||||
data() { |
||||
return { |
||||
attachmentSortSeed: 0, |
||||
loading: true, |
||||
//单据提交历史
|
||||
history: [], |
||||
form: { |
||||
id: null, |
||||
cid: null, |
||||
cname: null, |
||||
ctime: null, |
||||
vid: null, |
||||
vname: null, |
||||
vtime: null, |
||||
status: 0, |
||||
remark: null, |
||||
data: [], |
||||
imageList: [], |
||||
uploadImageList: [], |
||||
deleteImageList: [] |
||||
}, |
||||
rules: {} |
||||
} |
||||
}, |
||||
|
||||
computed: { |
||||
title() { |
||||
if (isEmpty(this.type)) return '' |
||||
switch (this.type) { |
||||
case 'see': |
||||
return `查看${this.docName}:${this.form.id}` |
||||
case 'add': |
||||
return `添加${this.docName}` |
||||
case 'edit': |
||||
return `编辑${this.docName}:${this.form.id}` |
||||
} |
||||
}, |
||||
user() { |
||||
return this.$store.state.user |
||||
}, |
||||
|
||||
//权限判断以及根据状态控制是否可编辑
|
||||
canSave() { |
||||
//add模式有添加权限、edit模式有编辑权限且status=0
|
||||
return this.type === 'add' && auth(this.api.add.url) |
||||
|| this.form.status === 0 && this.type === 'edit' && auth(this.api.update.url) |
||||
}, |
||||
canCommit() { |
||||
//有提交权限、add模式或edit模式且status=0
|
||||
return auth(this.api.commit.url) |
||||
&& (this.type === 'add' || this.type === 'edit' && this.form.status === 0) |
||||
}, |
||||
canWithdraw() { |
||||
//有撤回权限、当前用户是创建人、edit模式且status=1
|
||||
return auth(this.api.withdraw.url) |
||||
&& this.type === 'edit' |
||||
&& this.user.id === this.form.cid |
||||
&& this.form.status === 1 |
||||
}, |
||||
canPass() { |
||||
//有通过权限、edit模式且status=1
|
||||
return auth(this.api.pass.url) && this.type === 'edit' && this.form.status === 1 |
||||
}, |
||||
canReject() { |
||||
//有驳回权限、edit模式且status=1
|
||||
return auth(this.api.reject.url) && this.type === 'edit' && this.form.status === 1 |
||||
}, |
||||
|
||||
//底部按钮
|
||||
buttons() { |
||||
return [ |
||||
{_if: 'canSave', type: 'primary', content: '保 存', e: this.save}, |
||||
{_if: 'canCommit', type: 'primary', content: '提 交', e: this.commit}, |
||||
{_if: 'canWithdraw', type: 'danger', content: '撤 回', e: this.withdraw}, |
||||
{_if: 'canPass', type: 'success', content: '通 过', e: this.pass}, |
||||
{_if: 'canReject', type: 'danger', content: '驳 回', e: this.reject}, |
||||
] |
||||
.filter(i => this[i._if]) |
||||
} |
||||
}, |
||||
|
||||
methods: { |
||||
...commonMethods, |
||||
|
||||
//保存,分为添加或修改
|
||||
save() { |
||||
if (this.loading) return |
||||
this.$refs.form.validate(v => { |
||||
if (!v) return |
||||
if (this.validate) { |
||||
const valid = this.validate() |
||||
if (!isEmpty(valid)) return elAlert(valid) |
||||
} |
||||
this.loading = true |
||||
const method = this.type === 'add' ? this.api.add : this.api.update |
||||
method |
||||
.request(this.form) |
||||
.then(({data, msg}) => { |
||||
this.needSearch() |
||||
elSuccess(msg) |
||||
this.afterSaveOrCommit(data) |
||||
}) |
||||
.catch(() => this.loading = false) |
||||
}) |
||||
}, |
||||
|
||||
//提交,状态由拟定->待审核
|
||||
commit() { |
||||
if (this.loading) return |
||||
this.$refs.form.validate(v => { |
||||
if (!v) return |
||||
if (this.validate) { |
||||
const valid = this.validate() |
||||
if (!isEmpty(valid)) return elAlert(valid) |
||||
} |
||||
elConfirm('确认提交审核?') |
||||
.then(() => this.loading = true) |
||||
.then(() => this.api.commit.request(this.form)) |
||||
.then(({data, msg}) => { |
||||
elSuccess(msg) |
||||
this.needSearch() |
||||
this.afterSaveOrCommit(data) |
||||
}) |
||||
.catch(() => this.loading = false) |
||||
}) |
||||
}, |
||||
|
||||
//撤回自己的单据,状态由待审核->拟定
|
||||
withdraw() { |
||||
if (this.loading) return |
||||
elConfirm('确认撤回?') |
||||
.then(() => { |
||||
this.loading = true |
||||
return this.api.withdraw.request({id: this.form.id, pid: this.form.pid}) |
||||
}) |
||||
.then(({msg}) => { |
||||
elSuccess(msg) |
||||
this.needSearch() |
||||
this.init(this.form.id) |
||||
}) |
||||
.catch(() => this.loading = false) |
||||
}, |
||||
|
||||
//通过单据,状态由待审核->已审核
|
||||
pass() { |
||||
if (this.loading) return |
||||
elConfirm('确认通过审核?') |
||||
.then(() => { |
||||
this.loading = true |
||||
return this.api.pass.request({id: this.form.id, pid: this.form.pid}) |
||||
}) |
||||
.then(({msg}) => { |
||||
elSuccess(msg) |
||||
this.needSearch() |
||||
return this.close() |
||||
}) |
||||
.catch(() => this.loading = false) |
||||
}, |
||||
|
||||
//驳回单据,状态由待审核->拟定
|
||||
reject() { |
||||
if (this.loading) return |
||||
elPrompt('请输入驳回理由') |
||||
.then(info => { |
||||
this.loading = true |
||||
return this.api.reject.request({id: this.form.id, pid: this.form.pid, info}) |
||||
}) |
||||
.then(({msg}) => { |
||||
elSuccess(msg) |
||||
this.needSearch() |
||||
return this.close() |
||||
}) |
||||
.catch(() => this.loading = false) |
||||
}, |
||||
|
||||
//初始化单据信息
|
||||
init(id) { |
||||
this.loading = true |
||||
if (isEmpty(id)) { |
||||
return elAlert(`获取${this.docName}数据失败,请传入id`, this.close) |
||||
} |
||||
this.api.getById |
||||
.request(id) |
||||
.then(({data}) => { |
||||
if (!data || id !== data.id) return Promise.reject() |
||||
this.modifyDataBeforeMerge && this.modifyDataBeforeMerge(data) |
||||
return Promise.resolve(mergeObj(this.form, data)) |
||||
}) |
||||
.then(() => this.afterInit ? this.afterInit() : Promise.resolve()) |
||||
.catch(e => { |
||||
console.error(e) |
||||
return elAlert(`获取${this.docName}数据失败,请重试`, this.close) |
||||
}) |
||||
.finally(() => this.loading = false) |
||||
}, |
||||
|
||||
//保存、提交成功后需要判断后续动作
|
||||
afterSaveOrCommit(id) { |
||||
if (this.type === 'add') { |
||||
const editUrl = this.$route.path.replace('/add', `/edit/${id}`) |
||||
return closeCurrentPage(editUrl) |
||||
} |
||||
return this.init(this.form.id) |
||||
}, |
||||
|
||||
//关闭页面
|
||||
close() { |
||||
//删除未保存的上传附件
|
||||
const deleteArr = [] |
||||
if (this.form.uploadImageList.length > 0) { |
||||
deleteArr.push(...this.form.uploadImageList.map(i => i.url)) |
||||
} |
||||
if (deleteArr.length > 0) { |
||||
deleteUpload.request(deleteArr).catch(e => ({})) |
||||
} |
||||
|
||||
return closeCurrentPage(this.getTablePageUrl()) |
||||
}, |
||||
//获取列表页的地址
|
||||
getTablePageUrl() { |
||||
const url = this.$route.path |
||||
const i = url.indexOf('/detail') |
||||
const tablePageUrl = url.substring(0, i) |
||||
return [...tablePageUrl].join('') |
||||
}, |
||||
//列表页是否需要刷新数据
|
||||
needSearch() { |
||||
this.$store.commit('needSearch/emit', this.getTablePageUrl()) |
||||
}, |
||||
|
||||
//附件操作
|
||||
uploadSuccess(file, res) { |
||||
this.form.uploadImageList.push({ |
||||
url: res.key, |
||||
name: file.name, |
||||
sort: this.attachmentSortSeed, |
||||
size: file.size |
||||
}) |
||||
this.attachmentSortSeed++ |
||||
}, |
||||
removeUpload(file) { |
||||
this.form.deleteImageList.push(file.url) |
||||
const index = this.form.uploadImageList.findIndex(i => i.url === file.url) |
||||
if (index > -1) this.form.uploadImageList.splice(index, 1) |
||||
} |
||||
}, |
||||
|
||||
mounted() { |
||||
this.loading = false |
||||
if (this.type !== 'add') return this.init(this.id) |
||||
} |
||||
} |
||||
@ -1,168 +0,0 @@ |
||||
import tableMixin from '@/mixin/tablePageMixin' |
||||
import LinerProgress from '@/component/LinerProgress' |
||||
import ListPage from '@/view/_common/ListPage' |
||||
import {isEmpty} from "@/util" |
||||
import {elConfirm, elError, elSuccess} from "@/util/message" |
||||
import {auth} from "@/util/auth" |
||||
import {exportExcel} from "@/util/excel" |
||||
|
||||
export const commonMethods = { |
||||
getStatus(status) { |
||||
switch (status) { |
||||
case 0: |
||||
return '拟定' |
||||
case 1: |
||||
return '待审核' |
||||
case 2: |
||||
return '已审核' |
||||
} |
||||
return null |
||||
}, |
||||
getFinish(finish) { |
||||
switch (finish) { |
||||
case 0: |
||||
return '未开始' |
||||
case 1: |
||||
return '进行中' |
||||
case 2: |
||||
return '已完成' |
||||
} |
||||
return null |
||||
} |
||||
} |
||||
|
||||
export default { |
||||
mixins: [tableMixin], |
||||
|
||||
components: {LinerProgress, ListPage}, |
||||
|
||||
data() { |
||||
return { |
||||
searchForm: { |
||||
idFuzzy: null, |
||||
cname: null, |
||||
vname: null |
||||
}, |
||||
temp: { |
||||
status: [], |
||||
ctime: [], |
||||
vtime: [] |
||||
} |
||||
} |
||||
}, |
||||
|
||||
computed: { |
||||
canAdd() { |
||||
return auth(this.api.add.url) |
||||
}, |
||||
canUpdate() { |
||||
return auth(this.api.update.url) |
||||
|| auth(this.api.withdraw.url) |
||||
|| auth(this.api.pass.url) |
||||
|| auth(this.api.reject.url) |
||||
}, |
||||
canDel() { |
||||
return auth(this.api.del.url) |
||||
}, |
||||
canExport() { |
||||
return auth(this.api.exportExcel.url) |
||||
}, |
||||
|
||||
listPageConfig() { |
||||
return { |
||||
pageLoading: this.config.operating, |
||||
buttons: [ |
||||
this.canAdd && {icon: 'el-icon-plus', e: this.add, content: '添 加'}, |
||||
{icon: 'el-icon-view', e: this.see, content: '查 看'}, |
||||
this.canUpdate && {icon: 'el-icon-edit', e: this.edit, content: '编 辑'}, |
||||
this.canDel && {icon: 'el-icon-delete', e: this.del, content: '删 除'}, |
||||
this.canExport && {icon: 'el-icon-download', e: this.downloadExcel, content: '导 出'} |
||||
], |
||||
dataLoading: this.config.loading, |
||||
search: { |
||||
props: {model: this.searchForm}, |
||||
on: {search: this.search, reset: v => this.searchForm = v} |
||||
}, |
||||
table: { |
||||
props: {data: this.tableData}, |
||||
on: {'row-click': this.rowClick, 'expand-change': this.getSubList} |
||||
}, |
||||
pagination: { |
||||
props: {model: this.searchForm}, |
||||
on: {'current-change': this.pageChange} |
||||
} |
||||
} |
||||
} |
||||
}, |
||||
|
||||
methods: { |
||||
...commonMethods, |
||||
|
||||
search() { |
||||
if (this.config.loading) return |
||||
this.config.loading = true |
||||
//折叠所有行
|
||||
this.tableData.forEach(row => this.$children[0].$refs.table.toggleRowExpansion(row, false)) |
||||
this.row = null |
||||
this.api.search |
||||
.request(this.mergeSearchForm()) |
||||
.then(({data: {list, total}}) => { |
||||
list.forEach(i => { |
||||
i._loading = false //加载状态
|
||||
i._loaded = false //是否已经加载完成
|
||||
}) |
||||
this.searchForm.total = total |
||||
this.tableData = list |
||||
}) |
||||
.finally(() => this.config.loading = false) |
||||
}, |
||||
getSubList(row) { |
||||
if (row._loaded || row._loading) return |
||||
row._loading = true |
||||
this.api.getSubById |
||||
.request(row.id) |
||||
.then(({data}) => { |
||||
row.data = data |
||||
row._loaded = true |
||||
}) |
||||
.finally(() => row._loading = false) |
||||
}, |
||||
|
||||
add() { |
||||
this.row = null |
||||
this.$router.push(`${this.$route.path}/detail/add`) |
||||
}, |
||||
see() { |
||||
if (isEmpty(this.row)) return elError('请选择要查看的单据') |
||||
this.$router.push(`${this.$route.path}/detail/see/${this.row.id}`) |
||||
}, |
||||
edit() { |
||||
if (isEmpty(this.row)) return elError('请选择要编辑的单据') |
||||
this.$router.push(`${this.$route.path}/detail/edit/${this.row.id}`) |
||||
}, |
||||
del() { |
||||
if (isEmpty(this.row)) { |
||||
return elError('请选择要删除的单据') |
||||
} |
||||
if (this.row.status !== 0) { |
||||
return elError('只有状态为【拟定】时才能删除') |
||||
} |
||||
if (this.config.operating) return |
||||
|
||||
elConfirm(`确定删除单据【${this.row.id}】?`) |
||||
.then(() => { |
||||
this.config.operating = true |
||||
return this.api.del.request(this.row.id) |
||||
}) |
||||
.then(() => { |
||||
elSuccess('删除成功') |
||||
this.search() |
||||
}) |
||||
.finally(() => this.config.operating = false) |
||||
}, |
||||
|
||||
downloadExcel() { |
||||
exportExcel(this.api.exportExcel.url, this.mergeSearchForm(), this.excel) |
||||
} |
||||
} |
||||
} |
||||
@ -1,94 +0,0 @@ |
||||
/* |
||||
* 列表页通用混入 |
||||
* 引用者必须要有search方法 |
||||
* */ |
||||
import AbstractPagination from '@/component/abstract/Pagination' |
||||
import AbstractTable from '@/component/abstract/Table' |
||||
import {findComponentByTag} from "@/util/vue" |
||||
|
||||
const mixin = { |
||||
components: {AbstractPagination, AbstractTable}, |
||||
|
||||
data() { |
||||
return { |
||||
searchForm: { |
||||
page: 1, |
||||
pageSize: 15, |
||||
total: 0 |
||||
}, |
||||
config: { |
||||
loading: false, |
||||
operating: false |
||||
}, |
||||
tableData: [], |
||||
row: null, |
||||
type: 'see' |
||||
} |
||||
}, |
||||
|
||||
computed: { |
||||
tablePageNeedSearchMap() { |
||||
return this.$store.state.needSearch.map |
||||
} |
||||
}, |
||||
|
||||
watch: { |
||||
row(v) { |
||||
!v && this.$_getElTableInstance().setCurrentRow() |
||||
}, |
||||
|
||||
tablePageNeedSearchMap: { |
||||
deep: true, |
||||
handler(v) { |
||||
if (this._routePath === this.$route.path) { |
||||
this.reSearch(v) |
||||
} |
||||
} |
||||
} |
||||
}, |
||||
|
||||
methods: { |
||||
$_getElTableInstance() { |
||||
if (!this.$_elTableInstance) { |
||||
this.$_elTableInstance = findComponentByTag(this, 'el-table') |
||||
} |
||||
|
||||
return this.$_elTableInstance |
||||
}, |
||||
|
||||
rowClick(row) { |
||||
if (this.row === row) { |
||||
this.$_getElTableInstance().setCurrentRow() |
||||
this.row = null |
||||
} |
||||
else this.row = row |
||||
}, |
||||
|
||||
pageChange(v) { |
||||
this.searchForm.page = v |
||||
this.search() |
||||
}, |
||||
|
||||
reSearch(map) { |
||||
const path = this._routePath |
||||
if (map.hasOwnProperty(path) && map[path]) { |
||||
this.search() |
||||
this.$store.commit('needSearch/renew', path) |
||||
} |
||||
} |
||||
}, |
||||
|
||||
activated() { |
||||
this.reSearch(this.tablePageNeedSearchMap) |
||||
}, |
||||
|
||||
mounted() { |
||||
//注册needSearch事件
|
||||
this._routePath = this.$route.path |
||||
this.$store.commit('needSearch/init', this._routePath) |
||||
|
||||
this.search() |
||||
} |
||||
} |
||||
|
||||
export default mixin |
||||
@ -1,34 +0,0 @@ |
||||
/* |
||||
* 树形控件页通用混入 |
||||
* */ |
||||
|
||||
const mixin = { |
||||
data() { |
||||
return { |
||||
curNode: null |
||||
} |
||||
}, |
||||
|
||||
watch: { |
||||
curNode(v) { |
||||
const ref = this.$refs.tree |
||||
!v && ref && ref.setCurrentKey() |
||||
} |
||||
}, |
||||
|
||||
methods: { |
||||
nodeClick(data, node, ref) { |
||||
if (this.curNode === node) { |
||||
this.$refs.tree.setCurrentKey() |
||||
this.curNode = null |
||||
} |
||||
else this.curNode = node |
||||
|
||||
if (this.afterNodeClick) { |
||||
this.afterNodeClick(data, node, ref, !this.curNode) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
export default mixin |
||||
@ -1,22 +0,0 @@ |
||||
import {createWorker} from "@/util/worker" |
||||
|
||||
export default function (uint8Array, quality = 75) { |
||||
const {protocol, host} = document.location |
||||
const url = `${protocol}//${host}${process.env.BASE_URL}static/js/cjpeg.min.js` |
||||
return new Promise(resolve => { |
||||
let worker = createWorker(handler, {url, data: uint8Array, quality}, ({data}) => { |
||||
resolve(data) |
||||
worker.terminate() |
||||
}) |
||||
}) |
||||
} |
||||
|
||||
function handler() { |
||||
addEventListener('message', e => { |
||||
const {url, data, quality} = e.data |
||||
importScripts(url) |
||||
const start = performance.now() |
||||
const result = cjpeg(data, ['-quality', quality]) |
||||
postMessage({time: performance.now() - start, data: result.data}) |
||||
}) |
||||
} |
||||
@ -1,22 +0,0 @@ |
||||
import {createWorker} from "@/util/worker" |
||||
|
||||
export default function (uint8Array, quality = 75, speed = 4) { |
||||
const {protocol, host} = document.location |
||||
const url = `${protocol}//${host}${process.env.BASE_URL}static/js/pngquant.min.js` |
||||
return new Promise(resolve => { |
||||
let worker = createWorker(handler, {url, data: uint8Array, quality, speed}, ({data}) => { |
||||
resolve(data) |
||||
worker.terminate() |
||||
}) |
||||
}) |
||||
} |
||||
|
||||
function handler() { |
||||
addEventListener('message', e => { |
||||
const {url, data, quality, speed} = e.data |
||||
importScripts(url) |
||||
const start = performance.now() |
||||
const result = pngquant(data, {quality: quality.join('-'), speed: speed + ''}, () => ({})) |
||||
postMessage({time: performance.now() - start, data: result.data}) |
||||
}) |
||||
} |
||||
@ -1,42 +0,0 @@ |
||||
export function base64ToUint8(base64) { |
||||
let bstr = atob(base64.split(',')[1]), |
||||
n = bstr.length, |
||||
u8arr = new Uint8Array(n) |
||||
while (n--) { |
||||
u8arr[n] = bstr.charCodeAt(n) |
||||
} |
||||
return u8arr |
||||
} |
||||
|
||||
export function uint8Array2base64(o) { |
||||
let g = "" |
||||
let j = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" |
||||
let r = new Uint8Array(o) |
||||
let p = r.byteLength |
||||
let f = p % 3 |
||||
let q = p - f |
||||
let n, l, k, h |
||||
let m |
||||
for (let e = 0; e < q; e = e + 3) { |
||||
m = (r[e] << 16) | (r[e + 1] << 8) | r[e + 2] |
||||
n = (m & 16515072) >> 18 |
||||
l = (m & 258048) >> 12 |
||||
k = (m & 4032) >> 6 |
||||
h = m & 63 |
||||
g += j[n] + j[l] + j[k] + j[h] |
||||
} |
||||
if (f === 1) { |
||||
m = r[q] |
||||
n = (m & 252) >> 2 |
||||
l = (m & 3) << 4 |
||||
g += j[n] + j[l] + "==" |
||||
} |
||||
else if (f === 2) { |
||||
m = (r[q] << 8) | r[q + 1] |
||||
n = (m & 16128) >> 8 |
||||
l = (m & 1008) >> 4 |
||||
k = (m & 15) << 2 |
||||
g += j[n] + j[l] + j[k] + "=" |
||||
} |
||||
return "data:image/jpeg;base64," + g |
||||
} |
||||
@ -1,291 +0,0 @@ |
||||
/* |
||||
* Live2D Widget |
||||
* https://github.com/stevenjoezhang/live2d-widget
|
||||
* |
||||
* API 后端可自行搭建,参考 https://github.com/fghrsh/live2d_api
|
||||
*/ |
||||
|
||||
export default class Waifu { |
||||
constructor(tipsPath, apiPath) { |
||||
this.tipsPath = tipsPath |
||||
this.apiPath = apiPath |
||||
|
||||
this.setUserAction = this.setUserAction.bind(this) |
||||
this.visibilityChange = this.visibilityChange.bind(this) |
||||
|
||||
this.userAction = false |
||||
this.userActionTimer = null |
||||
this.messageTimer = null |
||||
this.messageArray = [ |
||||
"好久不见,日子过得好快呢……", |
||||
"大坏蛋!你都多久没理人家了呀,嘤嘤嘤~", |
||||
"嗨~快来逗我玩吧!", |
||||
"拿小拳拳锤你胸口!", |
||||
"记得把小家加入 Adblock 白名单哦!" |
||||
] |
||||
|
||||
sessionStorage.removeItem("waifu-text") |
||||
|
||||
this.insertWaifu() |
||||
|
||||
this.addEventListener() |
||||
|
||||
this.welcomeMessage() |
||||
|
||||
this.detectUserAction() |
||||
|
||||
this.initModel() |
||||
|
||||
this.extra() |
||||
} |
||||
|
||||
insertWaifu() { |
||||
document.body.insertAdjacentHTML("beforeend", |
||||
` |
||||
<div id="waifu"> |
||||
<div id="waifu-tips"></div> |
||||
<canvas id="live2d" width="300" height="300"></canvas> |
||||
<div id="waifu-tool"> |
||||
<span class="el-icon-s-promotion" title="打飞机"></span> |
||||
<span class="el-icon-refresh" title="换人"></span> |
||||
<span class="el-icon-s-operation" title="换装"></span> |
||||
<span class="el-icon-camera" title="拍照"></span> |
||||
<span class="el-icon-switch-button" title="关闭"></span> |
||||
</div> |
||||
</div> |
||||
` |
||||
) |
||||
|
||||
window.setTimeout(() => document.getElementById("waifu").style.bottom = '0', 0) |
||||
} |
||||
|
||||
addEventListener() { |
||||
document.querySelector("#waifu-tool .el-icon-s-promotion").addEventListener("click", this.startAsteroids) |
||||
document.querySelector("#waifu-tool .el-icon-refresh").addEventListener("click", this.loadOtherModel.bind(this)) |
||||
document.querySelector("#waifu-tool .el-icon-s-operation").addEventListener("click", this.loadRandModel.bind(this)) |
||||
document.querySelector("#waifu-tool .el-icon-camera").addEventListener("click", this.takePicture.bind(this)) |
||||
document.querySelector("#waifu-tool .el-icon-switch-button").addEventListener("click", this.exit.bind(this)) |
||||
|
||||
window.addEventListener("mousemove", this.setUserAction) |
||||
window.addEventListener("keydown", this.setUserAction) |
||||
window.addEventListener("visibilitychange", this.visibilityChange) |
||||
} |
||||
|
||||
removeEventListener() { |
||||
window.removeEventListener("mousemove", this.setUserAction) |
||||
window.removeEventListener("keydown", this.setUserAction) |
||||
window.removeEventListener("visibilitychange", this.visibilityChange) |
||||
} |
||||
|
||||
detectUserAction() { |
||||
window.setInterval(() => { |
||||
if (this.userAction) { |
||||
this.userAction = false |
||||
window.clearInterval(this.userActionTimer) |
||||
this.userActionTimer = null |
||||
} |
||||
else if (!this.userActionTimer) { |
||||
this.userActionTimer = window.setInterval(() => { |
||||
this.showMessage(this.randomSelection(this.messageArray), 6000, 9) |
||||
}, 20000) |
||||
} |
||||
}, 1000) |
||||
} |
||||
|
||||
setUserAction() { |
||||
this.userAction = true |
||||
} |
||||
|
||||
startAsteroids() { |
||||
if (window.Asteroids) { |
||||
if (!window.ASTEROIDSPLAYERS) window.ASTEROIDSPLAYERS = [] |
||||
window.ASTEROIDSPLAYERS.push(new window.Asteroids()) |
||||
} |
||||
else { |
||||
const script = document.createElement("script") |
||||
script.src = "https://cdn.jsdelivr.net/gh/GalaxyMimi/CDN/asteroids.js" |
||||
document.head.appendChild(script) |
||||
} |
||||
} |
||||
|
||||
takePicture() { |
||||
this.showMessage("照好了嘛,是不是很可爱呢?", 6000, 9) |
||||
window.Live2D.captureName = "photo.png" |
||||
window.Live2D.captureFrame = true |
||||
} |
||||
|
||||
welcomeMessage() { |
||||
let text, now = new Date().getHours() |
||||
|
||||
if (now > 5 && now <= 7) text = "早上好!一日之计在于晨,美好的一天就要开始了。" |
||||
else if (now > 7 && now <= 11) text = "上午好!工作顺利嘛,不要久坐,多起来走动走动哦!" |
||||
else if (now > 11 && now <= 13) text = "中午了,工作了一个上午,现在是午餐时间!" |
||||
else if (now > 13 && now <= 17) text = "午后很容易犯困呢,今天的运动目标完成了吗?" |
||||
else if (now > 17 && now <= 19) text = "傍晚了!窗外夕阳的景色很美丽呢,最美不过夕阳红~" |
||||
else if (now > 19 && now <= 21) text = "晚上好,今天过得怎么样?" |
||||
else if (now > 21 && now <= 23) text = ["已经这么晚了呀,早点休息吧,晚安~", "深夜时要爱护眼睛呀!"] |
||||
else text = "你是夜猫子呀?这么晚还不睡觉,明天起的来嘛?" |
||||
|
||||
this.showMessage(text, 7000, 8) |
||||
} |
||||
|
||||
visibilityChange() { |
||||
!document.hidden && this.showMessage("哇,你终于回来了~", 6000, 9) |
||||
} |
||||
|
||||
randomSelection(array) { |
||||
return Array.isArray(array) ? array[Math.floor(Math.random() * array.length)] : array |
||||
} |
||||
|
||||
initModel() { |
||||
let modelId = localStorage.getItem("modelId"), |
||||
modelTexturesId = localStorage.getItem("modelTexturesId") |
||||
|
||||
if (modelId == null) { |
||||
// 首次访问加载 指定模型 的 指定材质
|
||||
modelId = 1 // 模型 ID
|
||||
modelTexturesId = 53 // 材质 ID
|
||||
} |
||||
|
||||
this.loadModel(modelId, modelTexturesId) |
||||
|
||||
fetch(this.tipsPath) |
||||
.then(response => response.json()) |
||||
.then(result => { |
||||
for (const key of ['mouseover', 'click']) { |
||||
window.addEventListener(key, event => { |
||||
for (const tips of result[key]) { |
||||
if (!event.target.matches(tips.selector)) continue |
||||
|
||||
const text = this.randomSelection(tips.text).replace("{text}", event.target.innerText) |
||||
|
||||
return this.showMessage(text, 4000, 8) |
||||
} |
||||
}) |
||||
} |
||||
}) |
||||
} |
||||
|
||||
loadModel(modelId, modelTexturesId) { |
||||
localStorage.setItem("modelId", modelId) |
||||
|
||||
if (modelTexturesId === undefined) modelTexturesId = 0 |
||||
|
||||
localStorage.setItem("modelTexturesId", modelTexturesId) |
||||
|
||||
window.loadlive2d( |
||||
"live2d", |
||||
`${this.apiPath}/get/?id=${modelId}-${modelTexturesId}`, |
||||
console.log(`Live2D 模型 ${modelId}-${modelTexturesId} 加载完成`) |
||||
) |
||||
} |
||||
|
||||
//调用一言api
|
||||
showHitokoto() { |
||||
// 增加 hitokoto.cn 的 API
|
||||
fetch("https://v1.hitokoto.cn") |
||||
.then(response => response.json()) |
||||
.then(result => { |
||||
const text = `这句一言来自 <span>「${result.from}」</span>,是 <span>${result.creator}</span> 在 hitokoto.cn 投稿的。` |
||||
|
||||
this.showMessage(result.hitokoto, 6000, 9) |
||||
|
||||
window.setTimeout(() => this.showMessage(text, 4000, 9), 6000) |
||||
}) |
||||
} |
||||
|
||||
showMessage(text, timeout, priority) { |
||||
if (!text) return |
||||
|
||||
if (!sessionStorage.getItem("waifu-text") || |
||||
sessionStorage.getItem("waifu-text") <= priority) { |
||||
if (this.messageTimer) { |
||||
window.clearTimeout(this.messageTimer) |
||||
this.messageTimer = null |
||||
} |
||||
|
||||
if (Array.isArray(text)) text = this.randomSelection(text) |
||||
|
||||
sessionStorage.setItem("waifu-text", priority) |
||||
|
||||
const tips = document.getElementById("waifu-tips") |
||||
if (!tips) return |
||||
|
||||
tips.innerHTML = text |
||||
tips.classList.add("waifu-tips-active") |
||||
|
||||
this.messageTimer = window.setTimeout(() => { |
||||
sessionStorage.removeItem("waifu-text") |
||||
tips.classList.remove("waifu-tips-active") |
||||
}, timeout) |
||||
} |
||||
} |
||||
|
||||
//换装
|
||||
loadRandModel() { |
||||
const modelId = localStorage.getItem("modelId"), |
||||
modelTexturesId = localStorage.getItem("modelTexturesId") |
||||
|
||||
// 可选 "rand"(随机), "switch"(顺序)
|
||||
fetch(`${this.apiPath}/rand_textures/?id=${modelId}-${modelTexturesId}`) |
||||
.then(response => response.json()) |
||||
.then(result => { |
||||
if (result.textures.id === 1 && |
||||
(modelTexturesId === '1' || modelTexturesId === '0')) { |
||||
this.showMessage("我还没有其他衣服呢!", 4000, 10) |
||||
} |
||||
else this.showMessage("我的新衣服好看嘛?", 4000, 10) |
||||
|
||||
this.loadModel(modelId, result.textures.id) |
||||
}) |
||||
} |
||||
|
||||
//切换其他模型
|
||||
loadOtherModel() { |
||||
const modelId = localStorage.getItem("modelId") |
||||
|
||||
fetch(`${this.apiPath}/switch/?id=${modelId}`) |
||||
.then(response => response.json()) |
||||
.then(result => { |
||||
this.loadModel(result.model.id) |
||||
this.showMessage(result.model.message, 4000, 10) |
||||
}) |
||||
} |
||||
|
||||
extra() { |
||||
const devtools = () => ({}) |
||||
console.log("%c", devtools) |
||||
devtools.toString = () => this.showMessage("哈哈,你打开了控制台,是想要看看我的小秘密吗?", 6000, 9) |
||||
|
||||
console.log(` |
||||
く__,.ヘヽ. / ,ー、 〉 |
||||
\ ', !-─‐-i / /´ |
||||
/`ー' L//`ヽ、 |
||||
/ /, /| , , ', |
||||
イ / /-‐/ i L_ ハ ヽ! i |
||||
レ ヘ 7イ`ト レ'ァ-ト、!ハ| | |
||||
!,/7 '0' ´0iソ| | |
||||
|.从" _ ,,,, / |./ | |
||||
レ'| i>.、,,__ _,.イ / .i | |
||||
レ'| | / k_7_/レ'ヽ, ハ. | |
||||
| |/i 〈|/ i ,.ヘ | i | |
||||
.|/ / i: ヘ! \ | |
||||
kヽ>、ハ _,.ヘ、 /、! |
||||
!'〈//`T´', \ `'7'ーr' |
||||
レ'ヽL__|___i,___,ンレ|ノ |
||||
ト-,/ |___./ |
||||
'ー' !_,.: |
||||
`)
|
||||
} |
||||
|
||||
exit() { |
||||
this.showMessage("愿你有一天能与重要的人重逢。", 2000, 11) |
||||
|
||||
this.removeEventListener() |
||||
|
||||
const dom = document.getElementById("waifu") |
||||
dom.style.bottom = "-500px" |
||||
|
||||
window.setTimeout(() => dom.remove && dom.remove(), 3000) |
||||
} |
||||
} |
||||
@ -1,44 +0,0 @@ |
||||
import {compileShader, createProgram, getUniforms} from "./util" |
||||
|
||||
class Material { |
||||
constructor(vertexShader, fragmentShaderSource) { |
||||
this.vertexShader = vertexShader |
||||
this.fragmentShaderSource = fragmentShaderSource |
||||
this.programs = [] |
||||
this.activeProgram = null |
||||
this.uniforms = [] |
||||
} |
||||
|
||||
setKeywords(gl, keywords) { |
||||
let hash = 0 |
||||
for (let i = 0; i < keywords.length; i++) hash += hashCode(keywords[i]) |
||||
|
||||
let program = this.programs[hash] |
||||
if (program == null) { |
||||
let fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, this.fragmentShaderSource, keywords) |
||||
program = createProgram(gl, this.vertexShader, fragmentShader) |
||||
this.programs[hash] = program |
||||
} |
||||
|
||||
if (program === this.activeProgram) return |
||||
|
||||
this.uniforms = getUniforms(gl, program) |
||||
this.activeProgram = program |
||||
} |
||||
|
||||
bind(gl) { |
||||
gl.useProgram(this.activeProgram) |
||||
} |
||||
} |
||||
|
||||
function hashCode(s) { |
||||
if (s.length === 0) return 0 |
||||
let hash = 0 |
||||
for (let i = 0; i < s.length; i++) { |
||||
hash = (hash << 5) - hash + s.charCodeAt(i) |
||||
hash |= 0 // Convert to 32bit integer
|
||||
} |
||||
return hash |
||||
} |
||||
|
||||
export default Material |
||||
@ -1,16 +0,0 @@ |
||||
class Pointer { |
||||
constructor() { |
||||
this.id = -1 |
||||
this.texcoordX = 0 |
||||
this.texcoordY = 0 |
||||
this.prevTexcoordX = 0 |
||||
this.prevTexcoordY = 0 |
||||
this.deltaX = 0 |
||||
this.deltaY = 0 |
||||
this.down = false |
||||
this.moved = false |
||||
this.color = [30, 0, 300] |
||||
} |
||||
} |
||||
|
||||
export default Pointer |
||||
@ -1,15 +0,0 @@ |
||||
import {createProgram, getUniforms} from "./util" |
||||
|
||||
class Program { |
||||
constructor(gl, vertexShader, fragmentShader) { |
||||
this.uniforms = {} |
||||
this.program = createProgram(gl, vertexShader, fragmentShader) |
||||
this.uniforms = getUniforms(gl, this.program) |
||||
} |
||||
|
||||
bind(gl) { |
||||
gl.useProgram(this.program) |
||||
} |
||||
} |
||||
|
||||
export default Program |
||||
@ -1,27 +0,0 @@ |
||||
export default { |
||||
SIM_RESOLUTION: 128, |
||||
DYE_RESOLUTION: 1024, |
||||
CAPTURE_RESOLUTION: 512, |
||||
DENSITY_DISSIPATION: 1, |
||||
VELOCITY_DISSIPATION: 0.2, |
||||
PRESSURE: 0.8, |
||||
PRESSURE_ITERATIONS: 20, |
||||
CURL: 30, |
||||
SPLAT_RADIUS: 0.25, |
||||
SPLAT_FORCE: 6000, |
||||
SHADING: true, |
||||
COLORFUL: true, |
||||
COLOR_UPDATE_SPEED: 10, |
||||
PAUSED: false, |
||||
BACK_COLOR: {r: 0, g: 0, b: 0}, |
||||
TRANSPARENT: false, |
||||
BLOOM: true, |
||||
BLOOM_ITERATIONS: 8, |
||||
BLOOM_RESOLUTION: 256, |
||||
BLOOM_INTENSITY: 0.8, |
||||
BLOOM_THRESHOLD: 0.6, |
||||
BLOOM_SOFT_KNEE: 0.7, |
||||
SUNRAYS: true, |
||||
SUNRAYS_RESOLUTION: 196, |
||||
SUNRAYS_WEIGHT: 1.0 |
||||
} |
||||
@ -1,50 +0,0 @@ |
||||
import Material from './Material' |
||||
import Program from './Program' |
||||
import { |
||||
advectionShader, |
||||
baseVertexShader, |
||||
bloomBlurShader, |
||||
bloomFinalShader, |
||||
bloomPrefilterShader, |
||||
blurShader, |
||||
blurVertexShader, |
||||
checkerboardShader, |
||||
clearShader, |
||||
colorShader, |
||||
copyShader, |
||||
curlShader, |
||||
displayShaderSource, |
||||
divergenceShader, |
||||
gradientSubtractShader, |
||||
pressureShader, |
||||
splatShader, |
||||
sunraysMaskShader, |
||||
sunraysShader, |
||||
vorticityShader |
||||
} from './shader' |
||||
|
||||
export default function (gl, ext) { |
||||
const blurProgram = new Program(gl, blurVertexShader(gl), blurShader(gl)) |
||||
const copyProgram = new Program(gl, baseVertexShader(gl), copyShader(gl)) |
||||
const clearProgram = new Program(gl, baseVertexShader(gl), clearShader(gl)) |
||||
const colorProgram = new Program(gl, baseVertexShader(gl), colorShader(gl)) |
||||
const checkerboardProgram = new Program(gl, baseVertexShader(gl), checkerboardShader(gl)) |
||||
const bloomPrefilterProgram = new Program(gl, baseVertexShader(gl), bloomPrefilterShader(gl)) |
||||
const bloomBlurProgram = new Program(gl, baseVertexShader(gl), bloomBlurShader(gl)) |
||||
const bloomFinalProgram = new Program(gl, baseVertexShader(gl), bloomFinalShader(gl)) |
||||
const sunraysMaskProgram = new Program(gl, baseVertexShader(gl), sunraysMaskShader(gl)) |
||||
const sunraysProgram = new Program(gl, baseVertexShader(gl), sunraysShader(gl)) |
||||
const splatProgram = new Program(gl, baseVertexShader(gl), splatShader(gl)) |
||||
const advectionProgram = new Program(gl, baseVertexShader(gl), advectionShader(gl, ext)) |
||||
const divergenceProgram = new Program(gl, baseVertexShader(gl), divergenceShader(gl)) |
||||
const curlProgram = new Program(gl, baseVertexShader(gl), curlShader(gl)) |
||||
const vorticityProgram = new Program(gl, baseVertexShader(gl), vorticityShader(gl)) |
||||
const pressureProgram = new Program(gl, baseVertexShader(gl), pressureShader(gl)) |
||||
const gradienSubtractProgram = new Program(gl, baseVertexShader(gl), gradientSubtractShader(gl)) |
||||
const displayMaterial = new Material(baseVertexShader(gl), displayShaderSource(gl)) |
||||
return { |
||||
blurProgram, copyProgram, clearProgram, colorProgram, checkerboardProgram, bloomPrefilterProgram, |
||||
bloomBlurProgram, bloomFinalProgram, sunraysMaskProgram, sunraysProgram, splatProgram, advectionProgram, |
||||
divergenceProgram, curlProgram, vorticityProgram, pressureProgram, gradienSubtractProgram, displayMaterial |
||||
} |
||||
} |
||||
@ -1,564 +0,0 @@ |
||||
'use strict' |
||||
import { |
||||
createDoubleFBO, |
||||
createFBO, |
||||
downloadURI, |
||||
framebufferToTexture, |
||||
generateColor, |
||||
getResolution, |
||||
getWebGLContext, |
||||
isMobile, |
||||
normalizeColor, |
||||
normalizeTexture, |
||||
resizeCanvas, |
||||
scaleByPixelRatio, |
||||
textureToCanvas, |
||||
updatePointerDownData, |
||||
updatePointerMoveData, |
||||
updatePointerUpData, |
||||
wrap |
||||
} from "./util" |
||||
import factory from './factory' |
||||
import config from './config' |
||||
import Pointer from './Pointer' |
||||
|
||||
const WebGLFluid = canvas => { |
||||
resizeCanvas(canvas) |
||||
|
||||
let pointers = [] |
||||
let splatStack = [] |
||||
let bloomFrameBuffers = [] |
||||
let lastUpdateTime = Date.now() |
||||
let colorUpdateTimer = 0.0 |
||||
pointers.push(new Pointer()) |
||||
|
||||
const {gl, ext} = getWebGLContext(canvas) |
||||
|
||||
if (isMobile()) { |
||||
config.DYE_RESOLUTION = 512 |
||||
} |
||||
if (!ext.supportLinearFiltering) { |
||||
config.DYE_RESOLUTION = 512 |
||||
config.SHADING = false |
||||
config.BLOOM = false |
||||
config.SUNRAYS = false |
||||
} |
||||
|
||||
let dye |
||||
let velocity |
||||
let divergence |
||||
let curl |
||||
let pressure |
||||
let bloom |
||||
let sunrays |
||||
let sunraysTemp |
||||
|
||||
const blit = (() => { |
||||
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()) |
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, -1, 1, 1, 1, 1, -1]), gl.STATIC_DRAW) |
||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, gl.createBuffer()) |
||||
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array([0, 1, 2, 0, 2, 3]), gl.STATIC_DRAW) |
||||
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0) |
||||
gl.enableVertexAttribArray(0) |
||||
|
||||
return destination => { |
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, destination) |
||||
gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0) |
||||
} |
||||
})() |
||||
|
||||
const { |
||||
blurProgram, copyProgram, clearProgram, colorProgram, checkerboardProgram, bloomPrefilterProgram, |
||||
bloomBlurProgram, bloomFinalProgram, sunraysMaskProgram, sunraysProgram, splatProgram, advectionProgram, |
||||
divergenceProgram, curlProgram, vorticityProgram, pressureProgram, gradienSubtractProgram, displayMaterial |
||||
} = factory(gl, ext) |
||||
|
||||
function startGUI() { |
||||
if (isMobile()) return |
||||
let gui = new dat.GUI({width: 300}) |
||||
gui |
||||
.add(config, 'DYE_RESOLUTION', {high: 1024, medium: 512, low: 256, 'very low': 128}) |
||||
.name('quality') |
||||
.onFinishChange(initFrameBuffers()) |
||||
gui |
||||
.add(config, 'SIM_RESOLUTION', {'32': 32, '64': 64, '128': 128, '256': 256}) |
||||
.name('sim resolution') |
||||
.onFinishChange(initFrameBuffers()) |
||||
gui.add(config, 'DENSITY_DISSIPATION', 0, 4.0).name('density diffusion') |
||||
gui.add(config, 'VELOCITY_DISSIPATION', 0, 4.0).name('velocity diffusion') |
||||
gui.add(config, 'PRESSURE', 0.0, 1.0).name('pressure') |
||||
gui |
||||
.add(config, 'CURL', 0, 50) |
||||
.name('vorticity') |
||||
.step(1) |
||||
gui.add(config, 'SPLAT_RADIUS', 0.01, 1.0).name('splat radius') |
||||
gui |
||||
.add(config, 'SHADING') |
||||
.name('shading') |
||||
.onFinishChange(updateKeywords) |
||||
gui.add(config, 'COLORFUL').name('colorful') |
||||
gui |
||||
.add(config, 'PAUSED') |
||||
.name('paused') |
||||
.listen() |
||||
gui |
||||
.add({fun: () => splatStack.push(parseInt(Math.random() * 20) + 5)}, 'fun') |
||||
.name('Random splats') |
||||
|
||||
let bloomFolder = gui.addFolder('Bloom') |
||||
bloomFolder |
||||
.add(config, 'BLOOM') |
||||
.name('enabled') |
||||
.onFinishChange(updateKeywords) |
||||
bloomFolder.add(config, 'BLOOM_INTENSITY', 0.1, 2.0).name('intensity') |
||||
bloomFolder.add(config, 'BLOOM_THRESHOLD', 0.0, 1.0).name('threshold') |
||||
|
||||
let sunraysFolder = gui.addFolder('Sunrays') |
||||
sunraysFolder |
||||
.add(config, 'SUNRAYS') |
||||
.name('enabled') |
||||
.onFinishChange(updateKeywords) |
||||
sunraysFolder.add(config, 'SUNRAYS_WEIGHT', 0.3, 1.0).name('weight') |
||||
|
||||
let captureFolder = gui.addFolder('Capture') |
||||
captureFolder.addColor(config, 'BACK_COLOR').name('background color') |
||||
captureFolder.add(config, 'TRANSPARENT').name('transparent') |
||||
captureFolder.add({fun: captureScreenShot}, 'fun').name('take screenshot') |
||||
} |
||||
|
||||
function initFrameBuffers() { |
||||
let simRes = getResolution(gl, config.SIM_RESOLUTION) |
||||
let dyeRes = getResolution(gl, config.DYE_RESOLUTION) |
||||
|
||||
const texType = ext.halfFloatTexType |
||||
const rgba = ext.formatRGBA |
||||
const rg = ext.formatRG |
||||
const r = ext.formatR |
||||
const filtering = ext.supportLinearFiltering ? gl.LINEAR : gl.NEAREST |
||||
|
||||
if (dye == null) dye = createDoubleFBO(gl, dyeRes.width, dyeRes.height, rgba.internalFormat, rgba.format, texType, filtering) |
||||
else dye = resizeDoubleFBO(dye, dyeRes.width, dyeRes.height, rgba.internalFormat, rgba.format, texType, filtering) |
||||
|
||||
if (velocity == null) velocity = createDoubleFBO(gl, simRes.width, simRes.height, rg.internalFormat, rg.format, texType, filtering) |
||||
else velocity = resizeDoubleFBO(velocity, simRes.width, simRes.height, rg.internalFormat, rg.format, texType, filtering) |
||||
|
||||
divergence = createFBO(gl, simRes.width, simRes.height, r.internalFormat, r.format, texType, gl.NEAREST) |
||||
curl = createFBO(gl, simRes.width, simRes.height, r.internalFormat, r.format, texType, gl.NEAREST) |
||||
pressure = createDoubleFBO(gl, simRes.width, simRes.height, r.internalFormat, r.format, texType, gl.NEAREST) |
||||
|
||||
initBloomFrameBuffers() |
||||
initSunraysFrameBuffers() |
||||
} |
||||
|
||||
function initBloomFrameBuffers() { |
||||
let res = getResolution(gl, config.BLOOM_RESOLUTION) |
||||
|
||||
const texType = ext.halfFloatTexType |
||||
const rgba = ext.formatRGBA |
||||
const filtering = ext.supportLinearFiltering ? gl.LINEAR : gl.NEAREST |
||||
|
||||
bloom = createFBO(gl, res.width, res.height, rgba.internalFormat, rgba.format, texType, filtering) |
||||
|
||||
bloomFrameBuffers.length = 0 |
||||
for (let i = 0; i < config.BLOOM_ITERATIONS; i++) { |
||||
let width = res.width >> (i + 1) |
||||
let height = res.height >> (i + 1) |
||||
|
||||
if (width < 2 || height < 2) break |
||||
|
||||
let fbo = createFBO(gl, width, height, rgba.internalFormat, rgba.format, texType, filtering) |
||||
bloomFrameBuffers.push(fbo) |
||||
} |
||||
} |
||||
|
||||
function initSunraysFrameBuffers() { |
||||
let res = getResolution(gl, config.SUNRAYS_RESOLUTION) |
||||
|
||||
const texType = ext.halfFloatTexType |
||||
const r = ext.formatR |
||||
const filtering = ext.supportLinearFiltering ? gl.LINEAR : gl.NEAREST |
||||
|
||||
sunrays = createFBO(gl, res.width, res.height, r.internalFormat, r.format, texType, filtering) |
||||
sunraysTemp = createFBO(gl, res.width, res.height, r.internalFormat, r.format, texType, filtering) |
||||
} |
||||
|
||||
function resizeFBO(target, w, h, internalFormat, format, type, param) { |
||||
let newFBO = createFBO(gl, w, h, internalFormat, format, type, param) |
||||
copyProgram.bind(gl) |
||||
gl.uniform1i(copyProgram.uniforms.uTexture, target.attach(0)) |
||||
blit(newFBO.fbo) |
||||
return newFBO |
||||
} |
||||
|
||||
function resizeDoubleFBO(target, w, h, internalFormat, format, type, param) { |
||||
if (target.width === w && target.height === h) return target |
||||
target.read = resizeFBO(target.read, w, h, internalFormat, format, type, param) |
||||
target.write = createFBO(gl, w, h, internalFormat, format, type, param) |
||||
target.width = w |
||||
target.height = h |
||||
target.texelSizeX = 1.0 / w |
||||
target.texelSizeY = 1.0 / h |
||||
return target |
||||
} |
||||
|
||||
function updateKeywords() { |
||||
let displayKeywords = [] |
||||
if (config.SHADING) displayKeywords.push('SHADING') |
||||
if (config.BLOOM) displayKeywords.push('BLOOM') |
||||
if (config.SUNRAYS) displayKeywords.push('SUNRAYS') |
||||
displayMaterial.setKeywords(gl, displayKeywords) |
||||
} |
||||
|
||||
function update() { |
||||
const dt = calcDeltaTime() |
||||
if (resizeCanvas(canvas)) initFrameBuffers() |
||||
updateColors(dt) |
||||
applyInputs() |
||||
if (!config.PAUSED) step(dt) |
||||
render(null) |
||||
window.requestAnimationFrame(update) |
||||
} |
||||
|
||||
function calcDeltaTime() { |
||||
let now = Date.now() |
||||
let dt = (now - lastUpdateTime) / 1000 |
||||
dt = Math.min(dt, 0.016666) |
||||
lastUpdateTime = now |
||||
return dt |
||||
} |
||||
|
||||
function updateColors(dt) { |
||||
if (!config.COLORFUL) return |
||||
|
||||
colorUpdateTimer += dt * config.COLOR_UPDATE_SPEED |
||||
if (colorUpdateTimer >= 1) { |
||||
colorUpdateTimer = wrap(colorUpdateTimer, 0, 1) |
||||
pointers.forEach(p => { |
||||
p.color = generateColor() |
||||
}) |
||||
} |
||||
} |
||||
|
||||
function applyInputs() { |
||||
if (splatStack.length > 0) multipleSplats(splatStack.pop()) |
||||
|
||||
pointers.forEach(p => { |
||||
if (p.moved) { |
||||
p.moved = false |
||||
splatPointer(p) |
||||
} |
||||
}) |
||||
} |
||||
|
||||
function step(dt) { |
||||
gl.disable(gl.BLEND) |
||||
gl.viewport(0, 0, velocity.width, velocity.height) |
||||
|
||||
curlProgram.bind(gl) |
||||
gl.uniform2f(curlProgram.uniforms.texelSize, velocity.texelSizeX, velocity.texelSizeY) |
||||
gl.uniform1i(curlProgram.uniforms.uVelocity, velocity.read.attach(0)) |
||||
blit(curl.fbo) |
||||
|
||||
vorticityProgram.bind(gl) |
||||
gl.uniform2f(vorticityProgram.uniforms.texelSize, velocity.texelSizeX, velocity.texelSizeY) |
||||
gl.uniform1i(vorticityProgram.uniforms.uVelocity, velocity.read.attach(0)) |
||||
gl.uniform1i(vorticityProgram.uniforms.uCurl, curl.attach(1)) |
||||
gl.uniform1f(vorticityProgram.uniforms.curl, config.CURL) |
||||
gl.uniform1f(vorticityProgram.uniforms.dt, dt) |
||||
blit(velocity.write.fbo) |
||||
velocity.swap() |
||||
|
||||
divergenceProgram.bind(gl) |
||||
gl.uniform2f(divergenceProgram.uniforms.texelSize, velocity.texelSizeX, velocity.texelSizeY) |
||||
gl.uniform1i(divergenceProgram.uniforms.uVelocity, velocity.read.attach(0)) |
||||
blit(divergence.fbo) |
||||
|
||||
clearProgram.bind(gl) |
||||
gl.uniform1i(clearProgram.uniforms.uTexture, pressure.read.attach(0)) |
||||
gl.uniform1f(clearProgram.uniforms.value, config.PRESSURE) |
||||
blit(pressure.write.fbo) |
||||
pressure.swap() |
||||
|
||||
pressureProgram.bind(gl) |
||||
gl.uniform2f(pressureProgram.uniforms.texelSize, velocity.texelSizeX, velocity.texelSizeY) |
||||
gl.uniform1i(pressureProgram.uniforms.uDivergence, divergence.attach(0)) |
||||
for (let i = 0; i < config.PRESSURE_ITERATIONS; i++) { |
||||
gl.uniform1i(pressureProgram.uniforms.uPressure, pressure.read.attach(1)) |
||||
blit(pressure.write.fbo) |
||||
pressure.swap() |
||||
} |
||||
|
||||
gradienSubtractProgram.bind(gl) |
||||
gl.uniform2f(gradienSubtractProgram.uniforms.texelSize, velocity.texelSizeX, velocity.texelSizeY) |
||||
gl.uniform1i(gradienSubtractProgram.uniforms.uPressure, pressure.read.attach(0)) |
||||
gl.uniform1i(gradienSubtractProgram.uniforms.uVelocity, velocity.read.attach(1)) |
||||
blit(velocity.write.fbo) |
||||
velocity.swap() |
||||
|
||||
advectionProgram.bind(gl) |
||||
gl.uniform2f(advectionProgram.uniforms.texelSize, velocity.texelSizeX, velocity.texelSizeY) |
||||
if (!ext.supportLinearFiltering) gl.uniform2f(advectionProgram.uniforms.dyeTexelSize, velocity.texelSizeX, velocity.texelSizeY) |
||||
let velocityId = velocity.read.attach(0) |
||||
gl.uniform1i(advectionProgram.uniforms.uVelocity, velocityId) |
||||
gl.uniform1i(advectionProgram.uniforms.uSource, velocityId) |
||||
gl.uniform1f(advectionProgram.uniforms.dt, dt) |
||||
gl.uniform1f(advectionProgram.uniforms.dissipation, config.VELOCITY_DISSIPATION) |
||||
blit(velocity.write.fbo) |
||||
velocity.swap() |
||||
|
||||
gl.viewport(0, 0, dye.width, dye.height) |
||||
|
||||
if (!ext.supportLinearFiltering) gl.uniform2f(advectionProgram.uniforms.dyeTexelSize, dye.texelSizeX, dye.texelSizeY) |
||||
gl.uniform1i(advectionProgram.uniforms.uVelocity, velocity.read.attach(0)) |
||||
gl.uniform1i(advectionProgram.uniforms.uSource, dye.read.attach(1)) |
||||
gl.uniform1f(advectionProgram.uniforms.dissipation, config.DENSITY_DISSIPATION) |
||||
blit(dye.write.fbo) |
||||
dye.swap() |
||||
} |
||||
|
||||
function render(target) { |
||||
if (config.BLOOM) { |
||||
applyBloom(dye.read, bloom) |
||||
} |
||||
if (config.SUNRAYS) { |
||||
applySunrays(dye.read, dye.write, sunrays) |
||||
blur(sunrays, sunraysTemp, 1) |
||||
} |
||||
|
||||
if (target == null || !config.TRANSPARENT) { |
||||
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA) |
||||
gl.enable(gl.BLEND) |
||||
} |
||||
else { |
||||
gl.disable(gl.BLEND) |
||||
} |
||||
|
||||
let width = target == null ? gl.drawingBufferWidth : target.width |
||||
let height = target == null ? gl.drawingBufferHeight : target.height |
||||
gl.viewport(0, 0, width, height) |
||||
|
||||
let fbo = target == null ? null : target.fbo |
||||
if (!config.TRANSPARENT) drawColor(fbo, normalizeColor(config.BACK_COLOR)) |
||||
if (target == null && config.TRANSPARENT) drawCheckerboard(fbo) |
||||
drawDisplay(fbo, width, height) |
||||
} |
||||
|
||||
function drawColor(fbo, color) { |
||||
colorProgram.bind(gl) |
||||
gl.uniform4f(colorProgram.uniforms.color, color.r, color.g, color.b, 1) |
||||
blit(fbo) |
||||
} |
||||
|
||||
function drawCheckerboard(fbo) { |
||||
checkerboardProgram.bind(gl) |
||||
gl.uniform1f(checkerboardProgram.uniforms.aspectRatio, canvas.width / canvas.height) |
||||
blit(fbo) |
||||
} |
||||
|
||||
function drawDisplay(fbo, width, height) { |
||||
displayMaterial.bind(gl) |
||||
if (config.SHADING) gl.uniform2f(displayMaterial.uniforms.texelSize, 1.0 / width, 1.0 / height) |
||||
gl.uniform1i(displayMaterial.uniforms.uTexture, dye.read.attach(0)) |
||||
if (config.BLOOM) { |
||||
gl.uniform1i(displayMaterial.uniforms.uBloom, bloom.attach(1)) |
||||
} |
||||
if (config.SUNRAYS) gl.uniform1i(displayMaterial.uniforms.uSunrays, sunrays.attach(3)) |
||||
blit(fbo) |
||||
} |
||||
|
||||
function applyBloom(source, destination) { |
||||
if (bloomFrameBuffers.length < 2) return |
||||
|
||||
let last = destination |
||||
|
||||
gl.disable(gl.BLEND) |
||||
bloomPrefilterProgram.bind(gl) |
||||
let knee = config.BLOOM_THRESHOLD * config.BLOOM_SOFT_KNEE + 0.0001 |
||||
let curve0 = config.BLOOM_THRESHOLD - knee |
||||
let curve1 = knee * 2 |
||||
let curve2 = 0.25 / knee |
||||
gl.uniform3f(bloomPrefilterProgram.uniforms.curve, curve0, curve1, curve2) |
||||
gl.uniform1f(bloomPrefilterProgram.uniforms.threshold, config.BLOOM_THRESHOLD) |
||||
gl.uniform1i(bloomPrefilterProgram.uniforms.uTexture, source.attach(0)) |
||||
gl.viewport(0, 0, last.width, last.height) |
||||
blit(last.fbo) |
||||
|
||||
bloomBlurProgram.bind(gl) |
||||
for (let i = 0; i < bloomFrameBuffers.length; i++) { |
||||
let dest = bloomFrameBuffers[i] |
||||
gl.uniform2f(bloomBlurProgram.uniforms.texelSize, last.texelSizeX, last.texelSizeY) |
||||
gl.uniform1i(bloomBlurProgram.uniforms.uTexture, last.attach(0)) |
||||
gl.viewport(0, 0, dest.width, dest.height) |
||||
blit(dest.fbo) |
||||
last = dest |
||||
} |
||||
|
||||
gl.blendFunc(gl.ONE, gl.ONE) |
||||
gl.enable(gl.BLEND) |
||||
|
||||
for (let i = bloomFrameBuffers.length - 2; i >= 0; i--) { |
||||
let baseTex = bloomFrameBuffers[i] |
||||
gl.uniform2f(bloomBlurProgram.uniforms.texelSize, last.texelSizeX, last.texelSizeY) |
||||
gl.uniform1i(bloomBlurProgram.uniforms.uTexture, last.attach(0)) |
||||
gl.viewport(0, 0, baseTex.width, baseTex.height) |
||||
blit(baseTex.fbo) |
||||
last = baseTex |
||||
} |
||||
|
||||
gl.disable(gl.BLEND) |
||||
bloomFinalProgram.bind(gl) |
||||
gl.uniform2f(bloomFinalProgram.uniforms.texelSize, last.texelSizeX, last.texelSizeY) |
||||
gl.uniform1i(bloomFinalProgram.uniforms.uTexture, last.attach(0)) |
||||
gl.uniform1f(bloomFinalProgram.uniforms.intensity, config.BLOOM_INTENSITY) |
||||
gl.viewport(0, 0, destination.width, destination.height) |
||||
blit(destination.fbo) |
||||
} |
||||
|
||||
function applySunrays(source, mask, destination) { |
||||
gl.disable(gl.BLEND) |
||||
sunraysMaskProgram.bind(gl) |
||||
gl.uniform1i(sunraysMaskProgram.uniforms.uTexture, source.attach(0)) |
||||
gl.viewport(0, 0, mask.width, mask.height) |
||||
blit(mask.fbo) |
||||
|
||||
sunraysProgram.bind(gl) |
||||
gl.uniform1f(sunraysProgram.uniforms.weight, config.SUNRAYS_WEIGHT) |
||||
gl.uniform1i(sunraysProgram.uniforms.uTexture, mask.attach(0)) |
||||
gl.viewport(0, 0, destination.width, destination.height) |
||||
blit(destination.fbo) |
||||
} |
||||
|
||||
function blur(target, temp, iterations) { |
||||
blurProgram.bind(gl) |
||||
for (let i = 0; i < iterations; i++) { |
||||
gl.uniform2f(blurProgram.uniforms.texelSize, target.texelSizeX, 0.0) |
||||
gl.uniform1i(blurProgram.uniforms.uTexture, target.attach(0)) |
||||
blit(temp.fbo) |
||||
|
||||
gl.uniform2f(blurProgram.uniforms.texelSize, 0.0, target.texelSizeY) |
||||
gl.uniform1i(blurProgram.uniforms.uTexture, temp.attach(0)) |
||||
blit(target.fbo) |
||||
} |
||||
} |
||||
|
||||
function splatPointer(pointer) { |
||||
let dx = pointer.deltaX * config.SPLAT_FORCE |
||||
let dy = pointer.deltaY * config.SPLAT_FORCE |
||||
splat(pointer.texcoordX, pointer.texcoordY, dx, dy, pointer.color) |
||||
} |
||||
|
||||
function multipleSplats(amount) { |
||||
for (let i = 0; i < amount; i++) { |
||||
const color = generateColor() |
||||
color.r *= 10.0 |
||||
color.g *= 10.0 |
||||
color.b *= 10.0 |
||||
const x = Math.random() |
||||
const y = Math.random() |
||||
const dx = 1000 * (Math.random() - 0.5) |
||||
const dy = 1000 * (Math.random() - 0.5) |
||||
splat(x, y, dx, dy, color) |
||||
} |
||||
} |
||||
|
||||
function splat(x, y, dx, dy, color) { |
||||
gl.viewport(0, 0, velocity.width, velocity.height) |
||||
splatProgram.bind(gl) |
||||
gl.uniform1i(splatProgram.uniforms.uTarget, velocity.read.attach(0)) |
||||
gl.uniform1f(splatProgram.uniforms.aspectRatio, canvas.width / canvas.height) |
||||
gl.uniform2f(splatProgram.uniforms.point, x, y) |
||||
gl.uniform3f(splatProgram.uniforms.color, dx, dy, 0.0) |
||||
gl.uniform1f(splatProgram.uniforms.radius, correctRadius(config.SPLAT_RADIUS / 100.0)) |
||||
blit(velocity.write.fbo) |
||||
velocity.swap() |
||||
|
||||
gl.viewport(0, 0, dye.width, dye.height) |
||||
gl.uniform1i(splatProgram.uniforms.uTarget, dye.read.attach(0)) |
||||
gl.uniform3f(splatProgram.uniforms.color, color.r, color.g, color.b) |
||||
blit(dye.write.fbo) |
||||
dye.swap() |
||||
} |
||||
|
||||
function correctRadius(radius) { |
||||
let aspectRatio = canvas.width / canvas.height |
||||
if (aspectRatio > 1) radius *= aspectRatio |
||||
return radius |
||||
} |
||||
|
||||
function captureScreenShot() { |
||||
let res = getResolution(gl, config.CAPTURE_RESOLUTION) |
||||
let target = createFBO(gl, res.width, res.height, ext.formatRGBA.internalFormat, ext.formatRGBA.format, ext.halfFloatTexType, gl.NEAREST) |
||||
render(target) |
||||
|
||||
let texture = framebufferToTexture(gl, target) |
||||
texture = normalizeTexture(texture, target.width, target.height) |
||||
|
||||
let captureCanvas = textureToCanvas(texture, target.width, target.height) |
||||
let dataUri = captureCanvas.toDataURL() |
||||
downloadURI('fluid.png', dataUri) |
||||
URL.revokeObjectURL(dataUri) |
||||
} |
||||
|
||||
//startGUI();
|
||||
updateKeywords() |
||||
initFrameBuffers() |
||||
multipleSplats(parseInt(Math.random() * 20) + 5) |
||||
update() |
||||
|
||||
canvas.addEventListener('mousedown', e => { |
||||
let posX = scaleByPixelRatio(e.offsetX) |
||||
let posY = scaleByPixelRatio(e.offsetY) |
||||
let pointer = pointers.find(p => p.id === -1) |
||||
if (pointer == null) pointer = new Pointer() |
||||
updatePointerDownData(canvas, pointer, -1, posX, posY) |
||||
}) |
||||
|
||||
canvas.addEventListener('mousemove', e => { |
||||
let posX = scaleByPixelRatio(e.offsetX) |
||||
let posY = scaleByPixelRatio(e.offsetY) |
||||
updatePointerMoveData(canvas, pointers[0], posX, posY) |
||||
}) |
||||
|
||||
canvas.addEventListener('touchstart', e => { |
||||
e.preventDefault() |
||||
const touches = e.targetTouches |
||||
while (touches.length >= pointers.length) pointers.push(new Pointer()) |
||||
for (let i = 0; i < touches.length; i++) { |
||||
let posX = scaleByPixelRatio(touches[i].pageX) |
||||
let posY = scaleByPixelRatio(touches[i].pageY) |
||||
updatePointerDownData(canvas, pointers[i + 1], touches[i].identifier, posX, posY) |
||||
} |
||||
}) |
||||
|
||||
canvas.addEventListener('touchmove', e => { |
||||
e.preventDefault() |
||||
const touches = e.targetTouches |
||||
for (let i = 0; i < touches.length; i++) { |
||||
let posX = scaleByPixelRatio(touches[i].pageX) |
||||
let posY = scaleByPixelRatio(touches[i].pageY) |
||||
updatePointerMoveData(canvas, pointers[i + 1], posX, posY) |
||||
} |
||||
}) |
||||
|
||||
window.addEventListener('mouseup', () => { |
||||
updatePointerUpData(pointers[0]) |
||||
}) |
||||
|
||||
window.addEventListener('touchend', e => { |
||||
const touches = e.changedTouches |
||||
for (let i = 0; i < touches.length; i++) { |
||||
let pointer = pointers.find(p => p.id === touches[i].identifier) |
||||
updatePointerUpData(pointer) |
||||
} |
||||
}) |
||||
|
||||
window.addEventListener('keydown', e => { |
||||
if (e.code === 'KeyP') config.PAUSED = !config.PAUSED |
||||
if (e.key === ' ') splatStack.push(parseInt(Math.random() * 20) + 5) |
||||
if (e.key === 's') { |
||||
captureScreenShot() |
||||
} |
||||
}) |
||||
} |
||||
|
||||
export default WebGLFluid |
||||
@ -1,547 +0,0 @@ |
||||
'use strict' |
||||
import { |
||||
createDoubleFBO, |
||||
createFBO, |
||||
downloadURI, |
||||
framebufferToTexture, |
||||
generateColor, |
||||
getResolution, |
||||
getWebGLContext, |
||||
isMobile, |
||||
normalizeColor, |
||||
normalizeTexture, |
||||
resizeCanvas, |
||||
scaleByPixelRatio, |
||||
textureToCanvas, |
||||
updatePointerDownData, |
||||
updatePointerMoveData, |
||||
updatePointerUpData, |
||||
wrap |
||||
} from "./util" |
||||
import factory from './factory' |
||||
import config from './config' |
||||
import Pointer from './Pointer' |
||||
|
||||
export default class WebGlFluid { |
||||
constructor(canvas) { |
||||
this.pointers = [new Pointer()] |
||||
this.splatStack = [] |
||||
this.bloomFrameBuffers = [] |
||||
this.lastUpdateTime = Date.now() |
||||
this.colorUpdateTimer = 0.0 |
||||
|
||||
this.canvas = canvas |
||||
const {gl, ext} = getWebGLContext(canvas) |
||||
this.gl = gl |
||||
this.ext = ext |
||||
|
||||
if (isMobile()) { |
||||
config.DYE_RESOLUTION = 512 |
||||
} |
||||
if (!ext.supportLinearFiltering) { |
||||
config.DYE_RESOLUTION = 512 |
||||
config.SHADING = false |
||||
config.BLOOM = false |
||||
config.SUNRAYS = false |
||||
} |
||||
|
||||
this.blit = (() => { |
||||
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()) |
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, -1, 1, 1, 1, 1, -1]), gl.STATIC_DRAW) |
||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, gl.createBuffer()) |
||||
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array([0, 1, 2, 0, 2, 3]), gl.STATIC_DRAW) |
||||
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0) |
||||
gl.enableVertexAttribArray(0) |
||||
|
||||
return destination => { |
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, destination) |
||||
gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0) |
||||
} |
||||
})() |
||||
|
||||
this.factory = factory(gl, ext) |
||||
|
||||
this.rAF = null |
||||
this.stopSign = false |
||||
|
||||
this.loop = this.loop.bind(this) |
||||
this.onMousedown = this.onMousedown.bind(this) |
||||
this.onMousemove = this.onMousemove.bind(this) |
||||
this.onMouseup = this.onMouseup.bind(this) |
||||
this.onTouchstart = this.onTouchstart.bind(this) |
||||
this.onTouchmove = this.onTouchmove.bind(this) |
||||
this.onTouchend = this.onTouchend.bind(this) |
||||
this.onKeydown = this.onKeydown.bind(this) |
||||
|
||||
this.start() |
||||
} |
||||
|
||||
start() { |
||||
this.resize() |
||||
this.updateKeywords() |
||||
this.initFrameBuffers() |
||||
this.multipleSplats(~~(Math.random() * 20) + 5) |
||||
this.loop() |
||||
|
||||
this.canvas.addEventListener('mousedown', this.onMousedown) |
||||
this.canvas.addEventListener('mousemove', this.onMousemove) |
||||
window.addEventListener('mouseup', this.onMouseup) |
||||
|
||||
this.canvas.addEventListener('touchstart', this.onTouchstart) |
||||
this.canvas.addEventListener('touchmove', this.onTouchmove) |
||||
window.addEventListener('touchend', this.onTouchend) |
||||
|
||||
window.addEventListener('keydown', this.onKeydown) |
||||
} |
||||
|
||||
stop() { |
||||
this.canvas.removeAllListeners() |
||||
window.removeListener('mouseup', this.onMouseup) |
||||
window.removeListener('touchend', this.onTouchend) |
||||
window.removeListener('keydown', this.onKeydown) |
||||
this.stopSign = true |
||||
this.gl.getExtension('WEBGL_lose_context').loseContext() |
||||
} |
||||
|
||||
loop() { |
||||
if (this.stopSign) { |
||||
this.rAF && window.cancelAnimationFrame(this.rAF) |
||||
return |
||||
} |
||||
const dt = this.calcDeltaTime() |
||||
if (this.resize()) this.initFrameBuffers() |
||||
this.updateColors(dt) |
||||
this.applyInputs() |
||||
if (!config.PAUSED) this.step(dt) |
||||
this.render(null) |
||||
this.rAF = window.requestAnimationFrame(this.loop) |
||||
} |
||||
|
||||
resize() { |
||||
return resizeCanvas(this.canvas) |
||||
} |
||||
|
||||
onMousedown(e) { |
||||
const posX = scaleByPixelRatio(e.offsetX) |
||||
const posY = scaleByPixelRatio(e.offsetY) |
||||
let pointer = this.pointers.find(p => p.id === -1) |
||||
if (!pointer) pointer = new Pointer() |
||||
updatePointerDownData(this.canvas, pointer, -1, posX, posY) |
||||
} |
||||
|
||||
onMousemove(e) { |
||||
const posX = scaleByPixelRatio(e.offsetX) |
||||
const posY = scaleByPixelRatio(e.offsetY) |
||||
updatePointerMoveData(this.canvas, this.pointers[0], posX, posY) |
||||
} |
||||
|
||||
onMouseup() { |
||||
updatePointerUpData(this.pointers[0]) |
||||
} |
||||
|
||||
onTouchstart(e) { |
||||
e.preventDefault() |
||||
const touches = e.targetTouches |
||||
while (touches.length >= this.pointers.length) this.pointers.push(new Pointer()) |
||||
for (let i = 0; i < touches.length; i++) { |
||||
let posX = scaleByPixelRatio(touches[i].pageX) |
||||
let posY = scaleByPixelRatio(touches[i].pageY) |
||||
updatePointerDownData(this.canvas, this.pointers[i + 1], touches[i].identifier, posX, posY) |
||||
} |
||||
} |
||||
|
||||
onTouchmove(e) { |
||||
e.preventDefault() |
||||
const touches = e.targetTouches |
||||
for (let i = 0; i < touches.length; i++) { |
||||
const posX = scaleByPixelRatio(touches[i].pageX) |
||||
const posY = scaleByPixelRatio(touches[i].pageY) |
||||
updatePointerMoveData(this.canvas, this.pointers[i + 1], posX, posY) |
||||
} |
||||
} |
||||
|
||||
onTouchend(e) { |
||||
for (const {identifier} of e.changedTouches) { |
||||
const pointer = this.pointers.find(p => p.id === identifier) |
||||
updatePointerUpData(pointer) |
||||
} |
||||
} |
||||
|
||||
onKeydown(e) { |
||||
if (e.code === 'KeyP') config.PAUSED = !config.PAUSED |
||||
if (e.key === ' ') this.splatStack.push(~~(Math.random() * 20) + 5) |
||||
if (e.key === 's') this.captureScreenShot() |
||||
} |
||||
|
||||
initFrameBuffers() { |
||||
const simRes = getResolution(this.gl, config.SIM_RESOLUTION) |
||||
const dyeRes = getResolution(this.gl, config.DYE_RESOLUTION) |
||||
|
||||
const texType = this.ext.halfFloatTexType |
||||
const rgba = this.ext.formatRGBA |
||||
const rg = this.ext.formatRG |
||||
const r = this.ext.formatR |
||||
const filtering = this.ext.supportLinearFiltering ? this.gl.LINEAR : this.gl.NEAREST |
||||
|
||||
if (this.dye == null) this.dye = createDoubleFBO(this.gl, dyeRes.width, dyeRes.height, rgba.internalFormat, rgba.format, texType, filtering) |
||||
else this.dye = this.resizeDoubleFBO(this.dye, dyeRes.width, dyeRes.height, rgba.internalFormat, rgba.format, texType, filtering) |
||||
|
||||
if (this.velocity == null) this.velocity = createDoubleFBO(this.gl, simRes.width, simRes.height, rg.internalFormat, rg.format, texType, filtering) |
||||
else this.velocity = this.resizeDoubleFBO(this.velocity, simRes.width, simRes.height, rg.internalFormat, rg.format, texType, filtering) |
||||
|
||||
this.divergence = createFBO(this.gl, simRes.width, simRes.height, r.internalFormat, r.format, texType, this.gl.NEAREST) |
||||
this.curl = createFBO(this.gl, simRes.width, simRes.height, r.internalFormat, r.format, texType, this.gl.NEAREST) |
||||
this.pressure = createDoubleFBO(this.gl, simRes.width, simRes.height, r.internalFormat, r.format, texType, this.gl.NEAREST) |
||||
|
||||
this.initBloomFrameBuffers() |
||||
this.initSunraysFrameBuffers() |
||||
} |
||||
|
||||
initBloomFrameBuffers() { |
||||
let res = getResolution(this.gl, config.BLOOM_RESOLUTION) |
||||
|
||||
const texType = this.ext.halfFloatTexType |
||||
const rgba = this.ext.formatRGBA |
||||
const filtering = this.ext.supportLinearFiltering ? this.gl.LINEAR : this.gl.NEAREST |
||||
|
||||
this.bloom = createFBO(this.gl, res.width, res.height, rgba.internalFormat, rgba.format, texType, filtering) |
||||
|
||||
this.bloomFrameBuffers.length = 0 |
||||
for (let i = 0; i < config.BLOOM_ITERATIONS; i++) { |
||||
let width = res.width >> (i + 1) |
||||
let height = res.height >> (i + 1) |
||||
|
||||
if (width < 2 || height < 2) break |
||||
|
||||
let fbo = createFBO(this.gl, width, height, rgba.internalFormat, rgba.format, texType, filtering) |
||||
this.bloomFrameBuffers.push(fbo) |
||||
} |
||||
} |
||||
|
||||
initSunraysFrameBuffers() { |
||||
let res = getResolution(this.gl, config.SUNRAYS_RESOLUTION) |
||||
|
||||
const texType = this.ext.halfFloatTexType |
||||
const r = this.ext.formatR |
||||
const filtering = this.ext.supportLinearFiltering ? this.gl.LINEAR : this.gl.NEAREST |
||||
|
||||
this.sunrays = createFBO(this.gl, res.width, res.height, r.internalFormat, r.format, texType, filtering) |
||||
this.sunraysTemp = createFBO(this.gl, res.width, res.height, r.internalFormat, r.format, texType, filtering) |
||||
} |
||||
|
||||
resizeFBO(target, w, h, internalFormat, format, type, param) { |
||||
let newFBO = createFBO(this.gl, w, h, internalFormat, format, type, param) |
||||
this.factory.copyProgram.bind(this.gl) |
||||
this.gl.uniform1i(this.factory.copyProgram.uniforms.uTexture, target.attach(0)) |
||||
this.blit(newFBO.fbo) |
||||
return newFBO |
||||
} |
||||
|
||||
resizeDoubleFBO(target, w, h, internalFormat, format, type, param) { |
||||
if (target.width === w && target.height === h) return target |
||||
target.read = this.resizeFBO(target.read, w, h, internalFormat, format, type, param) |
||||
target.write = createFBO(this.gl, w, h, internalFormat, format, type, param) |
||||
target.width = w |
||||
target.height = h |
||||
target.texelSizeX = 1.0 / w |
||||
target.texelSizeY = 1.0 / h |
||||
return target |
||||
} |
||||
|
||||
updateKeywords() { |
||||
const displayKeywords = [] |
||||
config.SHADING && displayKeywords.push('SHADING') |
||||
config.BLOOM && displayKeywords.push('BLOOM') |
||||
config.SUNRAYS && displayKeywords.push('SUNRAYS') |
||||
this.factory.displayMaterial.setKeywords(this.gl, displayKeywords) |
||||
} |
||||
|
||||
calcDeltaTime() { |
||||
const now = Date.now() |
||||
this.lastUpdateTime = now |
||||
return Math.min((now - this.lastUpdateTime) / 1000, 0.016666) |
||||
} |
||||
|
||||
updateColors(dt) { |
||||
if (!config.COLORFUL) return |
||||
|
||||
this.colorUpdateTimer += dt * config.COLOR_UPDATE_SPEED |
||||
if (this.colorUpdateTimer >= 1) { |
||||
this.colorUpdateTimer = wrap(this.colorUpdateTimer, 0, 1) |
||||
this.pointers.forEach(p => p.color = generateColor()) |
||||
} |
||||
} |
||||
|
||||
applyInputs() { |
||||
if (this.splatStack.length > 0) this.multipleSplats(this.splatStack.pop()) |
||||
|
||||
this.pointers.forEach(p => { |
||||
if (p.moved) { |
||||
p.moved = false |
||||
this.splatPointer(p) |
||||
} |
||||
}) |
||||
} |
||||
|
||||
step(dt) { |
||||
const gl = this.gl |
||||
|
||||
gl.disable(gl.BLEND) |
||||
gl.viewport(0, 0, this.velocity.width, this.velocity.height) |
||||
|
||||
this.factory.curlProgram.bind(gl) |
||||
gl.uniform2f(this.factory.curlProgram.uniforms.texelSize, this.velocity.texelSizeX, this.velocity.texelSizeY) |
||||
gl.uniform1i(this.factory.curlProgram.uniforms.uVelocity, this.velocity.read.attach(0)) |
||||
this.blit(this.curl.fbo) |
||||
|
||||
this.factory.vorticityProgram.bind(gl) |
||||
gl.uniform2f(this.factory.vorticityProgram.uniforms.texelSize, this.velocity.texelSizeX, this.velocity.texelSizeY) |
||||
gl.uniform1i(this.factory.vorticityProgram.uniforms.uVelocity, this.velocity.read.attach(0)) |
||||
gl.uniform1i(this.factory.vorticityProgram.uniforms.uCurl, this.curl.attach(1)) |
||||
gl.uniform1f(this.factory.vorticityProgram.uniforms.curl, config.CURL) |
||||
gl.uniform1f(this.factory.vorticityProgram.uniforms.dt, dt) |
||||
this.blit(this.velocity.write.fbo) |
||||
this.velocity.swap() |
||||
|
||||
this.factory.divergenceProgram.bind(gl) |
||||
gl.uniform2f(this.factory.divergenceProgram.uniforms.texelSize, this.velocity.texelSizeX, this.velocity.texelSizeY) |
||||
gl.uniform1i(this.factory.divergenceProgram.uniforms.uVelocity, this.velocity.read.attach(0)) |
||||
this.blit(this.divergence.fbo) |
||||
|
||||
this.factory.clearProgram.bind(gl) |
||||
gl.uniform1i(this.factory.clearProgram.uniforms.uTexture, this.pressure.read.attach(0)) |
||||
gl.uniform1f(this.factory.clearProgram.uniforms.value, config.PRESSURE) |
||||
this.blit(this.pressure.write.fbo) |
||||
this.pressure.swap() |
||||
|
||||
this.factory.pressureProgram.bind(gl) |
||||
gl.uniform2f(this.factory.pressureProgram.uniforms.texelSize, this.velocity.texelSizeX, this.velocity.texelSizeY) |
||||
gl.uniform1i(this.factory.pressureProgram.uniforms.uDivergence, this.divergence.attach(0)) |
||||
for (let i = 0; i < config.PRESSURE_ITERATIONS; i++) { |
||||
gl.uniform1i(this.factory.pressureProgram.uniforms.uPressure, this.pressure.read.attach(1)) |
||||
this.blit(this.pressure.write.fbo) |
||||
this.pressure.swap() |
||||
} |
||||
|
||||
this.factory.gradienSubtractProgram.bind(gl) |
||||
gl.uniform2f(this.factory.gradienSubtractProgram.uniforms.texelSize, this.velocity.texelSizeX, this.velocity.texelSizeY) |
||||
gl.uniform1i(this.factory.gradienSubtractProgram.uniforms.uPressure, this.pressure.read.attach(0)) |
||||
gl.uniform1i(this.factory.gradienSubtractProgram.uniforms.uVelocity, this.velocity.read.attach(1)) |
||||
this.blit(this.velocity.write.fbo) |
||||
this.velocity.swap() |
||||
|
||||
this.factory.advectionProgram.bind(gl) |
||||
gl.uniform2f(this.factory.advectionProgram.uniforms.texelSize, this.velocity.texelSizeX, this.velocity.texelSizeY) |
||||
if (!this.ext.supportLinearFiltering) { |
||||
gl.uniform2f(this.factory.advectionProgram.uniforms.dyeTexelSize, this.velocity.texelSizeX, this.velocity.texelSizeY) |
||||
} |
||||
let velocityId = this.velocity.read.attach(0) |
||||
gl.uniform1i(this.factory.advectionProgram.uniforms.uVelocity, velocityId) |
||||
gl.uniform1i(this.factory.advectionProgram.uniforms.uSource, velocityId) |
||||
gl.uniform1f(this.factory.advectionProgram.uniforms.dt, dt) |
||||
gl.uniform1f(this.factory.advectionProgram.uniforms.dissipation, config.VELOCITY_DISSIPATION) |
||||
this.blit(this.velocity.write.fbo) |
||||
this.velocity.swap() |
||||
|
||||
gl.viewport(0, 0, this.dye.width, this.dye.height) |
||||
|
||||
if (!this.ext.supportLinearFiltering) { |
||||
gl.uniform2f(this.factory.advectionProgram.uniforms.dyeTexelSize, this.dye.texelSizeX, this.dye.texelSizeY) |
||||
} |
||||
gl.uniform1i(this.factory.advectionProgram.uniforms.uVelocity, this.velocity.read.attach(0)) |
||||
gl.uniform1i(this.factory.advectionProgram.uniforms.uSource, this.dye.read.attach(1)) |
||||
gl.uniform1f(this.factory.advectionProgram.uniforms.dissipation, config.DENSITY_DISSIPATION) |
||||
this.blit(this.dye.write.fbo) |
||||
this.dye.swap() |
||||
} |
||||
|
||||
render(target) { |
||||
const gl = this.gl |
||||
|
||||
if (config.BLOOM) { |
||||
this.applyBloom(this.dye.read, this.bloom) |
||||
} |
||||
if (config.SUNRAYS) { |
||||
this.applySunrays(this.dye.read, this.dye.write, this.sunrays) |
||||
this.blur(this.sunrays, this.sunraysTemp, 1) |
||||
} |
||||
|
||||
if (target == null || !config.TRANSPARENT) { |
||||
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA) |
||||
gl.enable(gl.BLEND) |
||||
} |
||||
else gl.disable(gl.BLEND) |
||||
|
||||
let width = target == null ? gl.drawingBufferWidth : target.width |
||||
let height = target == null ? gl.drawingBufferHeight : target.height |
||||
gl.viewport(0, 0, width, height) |
||||
|
||||
let fbo = target == null ? null : target.fbo |
||||
if (!config.TRANSPARENT) this.drawColor(fbo, normalizeColor(config.BACK_COLOR)) |
||||
if (target == null && config.TRANSPARENT) this.drawCheckerboard(fbo) |
||||
this.drawDisplay(fbo, width, height) |
||||
} |
||||
|
||||
drawColor(fbo, color) { |
||||
this.factory.colorProgram.bind(this.gl) |
||||
this.gl.uniform4f(this.factory.colorProgram.uniforms.color, color.r, color.g, color.b, 1) |
||||
this.blit(fbo) |
||||
} |
||||
|
||||
drawCheckerboard(fbo) { |
||||
this.factory.checkerboardProgram.bind(this.gl) |
||||
this.gl.uniform1f(this.factory.checkerboardProgram.uniforms.aspectRatio, this.canvas.width / this.canvas.height) |
||||
this.blit(fbo) |
||||
} |
||||
|
||||
drawDisplay(fbo, width, height) { |
||||
const gl = this.gl |
||||
|
||||
this.factory.displayMaterial.bind(gl) |
||||
if (config.SHADING) gl.uniform2f(this.factory.displayMaterial.uniforms.texelSize, 1.0 / width, 1.0 / height) |
||||
gl.uniform1i(this.factory.displayMaterial.uniforms.uTexture, this.dye.read.attach(0)) |
||||
if (config.BLOOM) { |
||||
gl.uniform1i(this.factory.displayMaterial.uniforms.uBloom, this.bloom.attach(1)) |
||||
} |
||||
if (config.SUNRAYS) gl.uniform1i(this.factory.displayMaterial.uniforms.uSunrays, this.sunrays.attach(3)) |
||||
this.blit(fbo) |
||||
} |
||||
|
||||
applyBloom(source, destination) { |
||||
if (this.bloomFrameBuffers.length < 2) return |
||||
|
||||
const gl = this.gl |
||||
|
||||
let last = destination |
||||
|
||||
gl.disable(gl.BLEND) |
||||
this.factory.bloomPrefilterProgram.bind(gl) |
||||
let knee = config.BLOOM_THRESHOLD * config.BLOOM_SOFT_KNEE + 0.0001 |
||||
let curve0 = config.BLOOM_THRESHOLD - knee |
||||
let curve1 = knee * 2 |
||||
let curve2 = 0.25 / knee |
||||
gl.uniform3f(this.factory.bloomPrefilterProgram.uniforms.curve, curve0, curve1, curve2) |
||||
gl.uniform1f(this.factory.bloomPrefilterProgram.uniforms.threshold, config.BLOOM_THRESHOLD) |
||||
gl.uniform1i(this.factory.bloomPrefilterProgram.uniforms.uTexture, source.attach(0)) |
||||
gl.viewport(0, 0, last.width, last.height) |
||||
this.blit(last.fbo) |
||||
|
||||
this.factory.bloomBlurProgram.bind(gl) |
||||
for (let i = 0; i < this.bloomFrameBuffers.length; i++) { |
||||
let dest = this.bloomFrameBuffers[i] |
||||
gl.uniform2f(this.factory.bloomBlurProgram.uniforms.texelSize, last.texelSizeX, last.texelSizeY) |
||||
gl.uniform1i(this.factory.bloomBlurProgram.uniforms.uTexture, last.attach(0)) |
||||
gl.viewport(0, 0, dest.width, dest.height) |
||||
this.blit(dest.fbo) |
||||
last = dest |
||||
} |
||||
|
||||
gl.blendFunc(gl.ONE, gl.ONE) |
||||
gl.enable(gl.BLEND) |
||||
|
||||
for (let i = this.bloomFrameBuffers.length - 2; i >= 0; i--) { |
||||
let baseTex = this.bloomFrameBuffers[i] |
||||
gl.uniform2f(this.factory.bloomBlurProgram.uniforms.texelSize, last.texelSizeX, last.texelSizeY) |
||||
gl.uniform1i(this.factory.bloomBlurProgram.uniforms.uTexture, last.attach(0)) |
||||
gl.viewport(0, 0, baseTex.width, baseTex.height) |
||||
this.blit(baseTex.fbo) |
||||
last = baseTex |
||||
} |
||||
|
||||
gl.disable(gl.BLEND) |
||||
this.factory.bloomFinalProgram.bind(gl) |
||||
gl.uniform2f(this.factory.bloomFinalProgram.uniforms.texelSize, last.texelSizeX, last.texelSizeY) |
||||
gl.uniform1i(this.factory.bloomFinalProgram.uniforms.uTexture, last.attach(0)) |
||||
gl.uniform1f(this.factory.bloomFinalProgram.uniforms.intensity, config.BLOOM_INTENSITY) |
||||
gl.viewport(0, 0, destination.width, destination.height) |
||||
this.blit(destination.fbo) |
||||
} |
||||
|
||||
applySunrays(source, mask, destination) { |
||||
const gl = this.gl |
||||
gl.disable(gl.BLEND) |
||||
this.factory.sunraysMaskProgram.bind(gl) |
||||
gl.uniform1i(this.factory.sunraysMaskProgram.uniforms.uTexture, source.attach(0)) |
||||
gl.viewport(0, 0, mask.width, mask.height) |
||||
this.blit(mask.fbo) |
||||
|
||||
this.factory.sunraysProgram.bind(gl) |
||||
gl.uniform1f(this.factory.sunraysProgram.uniforms.weight, config.SUNRAYS_WEIGHT) |
||||
gl.uniform1i(this.factory.sunraysProgram.uniforms.uTexture, mask.attach(0)) |
||||
gl.viewport(0, 0, destination.width, destination.height) |
||||
this.blit(destination.fbo) |
||||
} |
||||
|
||||
blur(target, temp, iterations) { |
||||
const gl = this.gl |
||||
this.factory.blurProgram.bind(gl) |
||||
for (let i = 0; i < iterations; i++) { |
||||
gl.uniform2f(this.factory.blurProgram.uniforms.texelSize, target.texelSizeX, 0.0) |
||||
gl.uniform1i(this.factory.blurProgram.uniforms.uTexture, target.attach(0)) |
||||
this.blit(temp.fbo) |
||||
|
||||
gl.uniform2f(this.factory.blurProgram.uniforms.texelSize, 0.0, target.texelSizeY) |
||||
gl.uniform1i(this.factory.blurProgram.uniforms.uTexture, temp.attach(0)) |
||||
this.blit(target.fbo) |
||||
} |
||||
} |
||||
|
||||
splatPointer(pointer) { |
||||
let dx = pointer.deltaX * config.SPLAT_FORCE |
||||
let dy = pointer.deltaY * config.SPLAT_FORCE |
||||
this.splat(pointer.texcoordX, pointer.texcoordY, dx, dy, pointer.color) |
||||
} |
||||
|
||||
multipleSplats(amount) { |
||||
for (let i = 0; i < amount; i++) { |
||||
const color = generateColor() |
||||
color.r *= 10.0 |
||||
color.g *= 10.0 |
||||
color.b *= 10.0 |
||||
const x = Math.random() |
||||
const y = Math.random() |
||||
const dx = 1000 * (Math.random() - 0.5) |
||||
const dy = 1000 * (Math.random() - 0.5) |
||||
this.splat(x, y, dx, dy, color) |
||||
} |
||||
} |
||||
|
||||
splat(x, y, dx, dy, color) { |
||||
const gl = this.gl |
||||
gl.viewport(0, 0, this.velocity.width, this.velocity.height) |
||||
this.factory.splatProgram.bind(gl) |
||||
gl.uniform1i(this.factory.splatProgram.uniforms.uTarget, this.velocity.read.attach(0)) |
||||
gl.uniform1f(this.factory.splatProgram.uniforms.aspectRatio, this.canvas.width / this.canvas.height) |
||||
gl.uniform2f(this.factory.splatProgram.uniforms.point, x, y) |
||||
gl.uniform3f(this.factory.splatProgram.uniforms.color, dx, dy, 0.0) |
||||
gl.uniform1f(this.factory.splatProgram.uniforms.radius, this.correctRadius(config.SPLAT_RADIUS / 100.0)) |
||||
this.blit(this.velocity.write.fbo) |
||||
this.velocity.swap() |
||||
|
||||
gl.viewport(0, 0, this.dye.width, this.dye.height) |
||||
gl.uniform1i(this.factory.splatProgram.uniforms.uTarget, this.dye.read.attach(0)) |
||||
gl.uniform3f(this.factory.splatProgram.uniforms.color, color.r, color.g, color.b) |
||||
this.blit(this.dye.write.fbo) |
||||
this.dye.swap() |
||||
} |
||||
|
||||
correctRadius(radius) { |
||||
const aspectRatio = this.canvas.width / this.canvas.height |
||||
if (aspectRatio > 1) radius *= aspectRatio |
||||
return radius |
||||
} |
||||
|
||||
captureScreenShot() { |
||||
const gl = this.gl |
||||
let res = getResolution(gl, config.CAPTURE_RESOLUTION) |
||||
let target = createFBO(gl, res.width, res.height, this.ext.formatRGBA.internalFormat, this.ext.formatRGBA.format, this.ext.halfFloatTexType, gl.NEAREST) |
||||
this.render(target) |
||||
|
||||
let texture = framebufferToTexture(gl, target) |
||||
texture = normalizeTexture(texture, target.width, target.height) |
||||
|
||||
const dataUri = textureToCanvas(texture, target.width, target.height).toDataURL() |
||||
downloadURI('fluid.png', dataUri) |
||||
URL.revokeObjectURL(dataUri) |
||||
} |
||||
} |
||||
@ -1,575 +0,0 @@ |
||||
import {compileShader} from "./util" |
||||
|
||||
const baseVertexShader = (gl) => { |
||||
return compileShader(gl, |
||||
gl.VERTEX_SHADER, |
||||
` |
||||
precision highp float; |
||||
|
||||
attribute vec2 aPosition; |
||||
varying vec2 vUv; |
||||
varying vec2 vL; |
||||
varying vec2 vR; |
||||
varying vec2 vT; |
||||
varying vec2 vB; |
||||
uniform vec2 texelSize; |
||||
|
||||
void main () { |
||||
vUv = aPosition * 0.5 + 0.5; |
||||
vL = vUv - vec2(texelSize.x, 0.0); |
||||
vR = vUv + vec2(texelSize.x, 0.0); |
||||
vT = vUv + vec2(0.0, texelSize.y); |
||||
vB = vUv - vec2(0.0, texelSize.y); |
||||
gl_Position = vec4(aPosition, 0.0, 1.0); |
||||
}` |
||||
) |
||||
} |
||||
|
||||
const blurVertexShader = (gl) => { |
||||
return compileShader(gl, |
||||
gl.VERTEX_SHADER, |
||||
` |
||||
precision highp float; |
||||
|
||||
attribute vec2 aPosition; |
||||
varying vec2 vUv; |
||||
varying vec2 vL; |
||||
varying vec2 vR; |
||||
uniform vec2 texelSize; |
||||
|
||||
void main () { |
||||
vUv = aPosition * 0.5 + 0.5; |
||||
float offset = 1.33333333; |
||||
vL = vUv - texelSize * offset; |
||||
vR = vUv + texelSize * offset; |
||||
gl_Position = vec4(aPosition, 0.0, 1.0); |
||||
}` |
||||
) |
||||
} |
||||
|
||||
const blurShader = (gl) => { |
||||
return compileShader(gl, |
||||
gl.FRAGMENT_SHADER, |
||||
` |
||||
precision mediump float; |
||||
precision mediump sampler2D; |
||||
|
||||
varying vec2 vUv; |
||||
varying vec2 vL; |
||||
varying vec2 vR; |
||||
uniform sampler2D uTexture; |
||||
|
||||
void main () { |
||||
vec4 sum = texture2D(uTexture, vUv) * 0.29411764; |
||||
sum += texture2D(uTexture, vL) * 0.35294117; |
||||
sum += texture2D(uTexture, vR) * 0.35294117; |
||||
gl_FragColor = sum; |
||||
}` |
||||
) |
||||
} |
||||
|
||||
const copyShader = (gl) => { |
||||
return compileShader(gl, |
||||
gl.FRAGMENT_SHADER, |
||||
` |
||||
precision mediump float; |
||||
precision mediump sampler2D; |
||||
|
||||
varying highp vec2 vUv; |
||||
uniform sampler2D uTexture; |
||||
|
||||
void main () { |
||||
gl_FragColor = texture2D(uTexture, vUv); |
||||
}` |
||||
) |
||||
} |
||||
|
||||
const clearShader = (gl) => { |
||||
return compileShader(gl, |
||||
gl.FRAGMENT_SHADER, |
||||
` |
||||
precision mediump float; |
||||
precision mediump sampler2D; |
||||
|
||||
varying highp vec2 vUv; |
||||
uniform sampler2D uTexture; |
||||
uniform float value; |
||||
|
||||
void main () { |
||||
gl_FragColor = value * texture2D(uTexture, vUv); |
||||
}` |
||||
) |
||||
} |
||||
|
||||
const colorShader = (gl) => { |
||||
return compileShader(gl, |
||||
gl.FRAGMENT_SHADER, |
||||
` |
||||
precision mediump float; |
||||
|
||||
uniform vec4 color; |
||||
|
||||
void main () { |
||||
gl_FragColor = color; |
||||
}` |
||||
) |
||||
} |
||||
|
||||
const checkerboardShader = (gl) => { |
||||
return compileShader(gl, |
||||
gl.FRAGMENT_SHADER, |
||||
` |
||||
precision highp float; |
||||
precision highp sampler2D; |
||||
|
||||
varying vec2 vUv; |
||||
uniform sampler2D uTexture; |
||||
uniform float aspectRatio; |
||||
|
||||
#define SCALE 25.0 |
||||
|
||||
void main () { |
||||
vec2 uv = floor(vUv * SCALE * vec2(aspectRatio, 1.0)); |
||||
float v = mod(uv.x + uv.y, 2.0); |
||||
v = v * 0.1 + 0.8; |
||||
gl_FragColor = vec4(vec3(v), 1.0); |
||||
} |
||||
` |
||||
) |
||||
} |
||||
|
||||
const displayShaderSource = (gl) => { |
||||
return ` |
||||
precision highp float; |
||||
precision highp sampler2D; |
||||
|
||||
varying vec2 vUv; |
||||
varying vec2 vL; |
||||
varying vec2 vR; |
||||
varying vec2 vT; |
||||
varying vec2 vB; |
||||
uniform sampler2D uTexture; |
||||
uniform sampler2D uBloom; |
||||
uniform sampler2D uSunrays; |
||||
uniform sampler2D uDithering; |
||||
uniform vec2 ditherScale; |
||||
uniform vec2 texelSize; |
||||
|
||||
vec3 linearToGamma (vec3 color) { |
||||
color = max(color, vec3(0)); |
||||
return max(1.055 * pow(color, vec3(0.416666667)) - 0.055, vec3(0)); |
||||
} |
||||
|
||||
void main () { |
||||
vec3 c = texture2D(uTexture, vUv).rgb; |
||||
|
||||
#ifdef SHADING |
||||
vec3 lc = texture2D(uTexture, vL).rgb; |
||||
vec3 rc = texture2D(uTexture, vR).rgb; |
||||
vec3 tc = texture2D(uTexture, vT).rgb; |
||||
vec3 bc = texture2D(uTexture, vB).rgb; |
||||
|
||||
float dx = length(rc) - length(lc); |
||||
float dy = length(tc) - length(bc); |
||||
|
||||
vec3 n = normalize(vec3(dx, dy, length(texelSize))); |
||||
vec3 l = vec3(0.0, 0.0, 1.0); |
||||
|
||||
float diffuse = clamp(dot(n, l) + 0.7, 0.7, 1.0); |
||||
c *= diffuse; |
||||
#endif |
||||
|
||||
#ifdef BLOOM |
||||
vec3 bloom = texture2D(uBloom, vUv).rgb; |
||||
#endif |
||||
|
||||
#ifdef SUNRAYS |
||||
float sunrays = texture2D(uSunrays, vUv).r; |
||||
c *= sunrays; |
||||
#ifdef BLOOM |
||||
bloom *= sunrays; |
||||
#endif |
||||
#endif |
||||
|
||||
#ifdef BLOOM |
||||
float noise = texture2D(uDithering, vUv * ditherScale).r; |
||||
noise = noise * 2.0 - 1.0; |
||||
bloom += noise / 255.0; |
||||
bloom = linearToGamma(bloom); |
||||
c += bloom; |
||||
#endif |
||||
|
||||
float a = max(c.r, max(c.g, c.b)); |
||||
gl_FragColor = vec4(c, a); |
||||
}` |
||||
} |
||||
|
||||
const bloomPrefilterShader = (gl) => { |
||||
return compileShader(gl, |
||||
gl.FRAGMENT_SHADER, |
||||
` |
||||
precision mediump float; |
||||
precision mediump sampler2D; |
||||
|
||||
varying vec2 vUv; |
||||
uniform sampler2D uTexture; |
||||
uniform vec3 curve; |
||||
uniform float threshold; |
||||
|
||||
void main () { |
||||
vec3 c = texture2D(uTexture, vUv).rgb; |
||||
float br = max(c.r, max(c.g, c.b)); |
||||
float rq = clamp(br - curve.x, 0.0, curve.y); |
||||
rq = curve.z * rq * rq; |
||||
c *= max(rq, br - threshold) / max(br, 0.0001); |
||||
gl_FragColor = vec4(c, 0.0); |
||||
}` |
||||
) |
||||
} |
||||
|
||||
const bloomBlurShader = (gl) => { |
||||
return compileShader(gl, |
||||
gl.FRAGMENT_SHADER, |
||||
` |
||||
precision mediump float; |
||||
precision mediump sampler2D; |
||||
|
||||
varying vec2 vL; |
||||
varying vec2 vR; |
||||
varying vec2 vT; |
||||
varying vec2 vB; |
||||
uniform sampler2D uTexture; |
||||
|
||||
void main () { |
||||
vec4 sum = vec4(0.0); |
||||
sum += texture2D(uTexture, vL); |
||||
sum += texture2D(uTexture, vR); |
||||
sum += texture2D(uTexture, vT); |
||||
sum += texture2D(uTexture, vB); |
||||
sum *= 0.25; |
||||
gl_FragColor = sum; |
||||
}` |
||||
) |
||||
} |
||||
|
||||
const bloomFinalShader = (gl) => { |
||||
return compileShader(gl, |
||||
gl.FRAGMENT_SHADER, |
||||
` |
||||
precision mediump float; |
||||
precision mediump sampler2D; |
||||
|
||||
varying vec2 vL; |
||||
varying vec2 vR; |
||||
varying vec2 vT; |
||||
varying vec2 vB; |
||||
uniform sampler2D uTexture; |
||||
uniform float intensity; |
||||
|
||||
void main () { |
||||
vec4 sum = vec4(0.0); |
||||
sum += texture2D(uTexture, vL); |
||||
sum += texture2D(uTexture, vR); |
||||
sum += texture2D(uTexture, vT); |
||||
sum += texture2D(uTexture, vB); |
||||
sum *= 0.25; |
||||
gl_FragColor = sum * intensity; |
||||
}` |
||||
) |
||||
} |
||||
|
||||
const sunraysMaskShader = (gl) => { |
||||
return compileShader(gl, |
||||
gl.FRAGMENT_SHADER, |
||||
` |
||||
precision highp float; |
||||
precision highp sampler2D; |
||||
|
||||
varying vec2 vUv; |
||||
uniform sampler2D uTexture; |
||||
|
||||
void main () { |
||||
vec4 c = texture2D(uTexture, vUv); |
||||
float br = max(c.r, max(c.g, c.b)); |
||||
c.a = 1.0 - min(max(br * 20.0, 0.0), 0.8); |
||||
gl_FragColor = c; |
||||
}` |
||||
) |
||||
} |
||||
|
||||
const sunraysShader = (gl) => { |
||||
return compileShader(gl, |
||||
gl.FRAGMENT_SHADER, |
||||
` |
||||
precision highp float; |
||||
precision highp sampler2D; |
||||
|
||||
varying vec2 vUv; |
||||
uniform sampler2D uTexture; |
||||
uniform float weight; |
||||
|
||||
#define ITERATIONS 16 |
||||
|
||||
void main () { |
||||
float Density = 0.3; |
||||
float Decay = 0.95; |
||||
float Exposure = 0.7; |
||||
|
||||
vec2 coord = vUv; |
||||
vec2 dir = vUv - 0.5; |
||||
|
||||
dir *= 1.0 / float(ITERATIONS) * Density; |
||||
float illuminationDecay = 1.0; |
||||
|
||||
float color = texture2D(uTexture, vUv).a; |
||||
|
||||
for (int i = 0; i < ITERATIONS; i++) |
||||
{ |
||||
coord -= dir; |
||||
float col = texture2D(uTexture, coord).a; |
||||
color += col * illuminationDecay * weight; |
||||
illuminationDecay *= Decay; |
||||
} |
||||
|
||||
gl_FragColor = vec4(color * Exposure, 0.0, 0.0, 1.0); |
||||
}` |
||||
) |
||||
} |
||||
|
||||
const splatShader = (gl) => { |
||||
return compileShader(gl, |
||||
gl.FRAGMENT_SHADER, |
||||
` |
||||
precision highp float; |
||||
precision highp sampler2D; |
||||
|
||||
varying vec2 vUv; |
||||
uniform sampler2D uTarget; |
||||
uniform float aspectRatio; |
||||
uniform vec3 color; |
||||
uniform vec2 point; |
||||
uniform float radius; |
||||
|
||||
void main () { |
||||
vec2 p = vUv - point.xy; |
||||
p.x *= aspectRatio; |
||||
vec3 splat = exp(-dot(p, p) / radius) * color; |
||||
vec3 base = texture2D(uTarget, vUv).xyz; |
||||
gl_FragColor = vec4(base + splat, 1.0); |
||||
}` |
||||
) |
||||
} |
||||
|
||||
const advectionShader = (gl, ext) => { |
||||
return compileShader(gl, |
||||
gl.FRAGMENT_SHADER, |
||||
` |
||||
precision highp float; |
||||
precision highp sampler2D; |
||||
|
||||
varying vec2 vUv; |
||||
uniform sampler2D uVelocity; |
||||
uniform sampler2D uSource; |
||||
uniform vec2 texelSize; |
||||
uniform vec2 dyeTexelSize; |
||||
uniform float dt; |
||||
uniform float dissipation; |
||||
|
||||
vec4 bilerp (sampler2D sam, vec2 uv, vec2 tsize) { |
||||
vec2 st = uv / tsize - 0.5; |
||||
|
||||
vec2 iuv = floor(st); |
||||
vec2 fuv = fract(st); |
||||
|
||||
vec4 a = texture2D(sam, (iuv + vec2(0.5, 0.5)) * tsize); |
||||
vec4 b = texture2D(sam, (iuv + vec2(1.5, 0.5)) * tsize); |
||||
vec4 c = texture2D(sam, (iuv + vec2(0.5, 1.5)) * tsize); |
||||
vec4 d = texture2D(sam, (iuv + vec2(1.5, 1.5)) * tsize); |
||||
|
||||
return mix(mix(a, b, fuv.x), mix(c, d, fuv.x), fuv.y); |
||||
} |
||||
|
||||
void main () { |
||||
#ifdef MANUAL_FILTERING |
||||
vec2 coord = vUv - dt * bilerp(uVelocity, vUv, texelSize).xy * texelSize; |
||||
vec4 result = bilerp(uSource, coord, dyeTexelSize); |
||||
#else |
||||
vec2 coord = vUv - dt * texture2D(uVelocity, vUv).xy * texelSize; |
||||
vec4 result = texture2D(uSource, coord); |
||||
#endif |
||||
float decay = 1.0 + dissipation * dt; |
||||
gl_FragColor = result / decay; |
||||
}`,
|
||||
ext.supportLinearFiltering ? null : ['MANUAL_FILTERING'] |
||||
) |
||||
} |
||||
|
||||
const divergenceShader = (gl) => { |
||||
return compileShader(gl, |
||||
gl.FRAGMENT_SHADER, |
||||
` |
||||
precision mediump float; |
||||
precision mediump sampler2D; |
||||
|
||||
varying highp vec2 vUv; |
||||
varying highp vec2 vL; |
||||
varying highp vec2 vR; |
||||
varying highp vec2 vT; |
||||
varying highp vec2 vB; |
||||
uniform sampler2D uVelocity; |
||||
|
||||
void main () { |
||||
float L = texture2D(uVelocity, vL).x; |
||||
float R = texture2D(uVelocity, vR).x; |
||||
float T = texture2D(uVelocity, vT).y; |
||||
float B = texture2D(uVelocity, vB).y; |
||||
|
||||
vec2 C = texture2D(uVelocity, vUv).xy; |
||||
if (vL.x < 0.0) { L = -C.x; } |
||||
if (vR.x > 1.0) { R = -C.x; } |
||||
if (vT.y > 1.0) { T = -C.y; } |
||||
if (vB.y < 0.0) { B = -C.y; } |
||||
|
||||
float div = 0.5 * (R - L + T - B); |
||||
gl_FragColor = vec4(div, 0.0, 0.0, 1.0); |
||||
}` |
||||
) |
||||
} |
||||
|
||||
const curlShader = (gl) => { |
||||
return compileShader(gl, |
||||
gl.FRAGMENT_SHADER, |
||||
` |
||||
precision mediump float; |
||||
precision mediump sampler2D; |
||||
|
||||
varying highp vec2 vUv; |
||||
varying highp vec2 vL; |
||||
varying highp vec2 vR; |
||||
varying highp vec2 vT; |
||||
varying highp vec2 vB; |
||||
uniform sampler2D uVelocity; |
||||
|
||||
void main () { |
||||
float L = texture2D(uVelocity, vL).y; |
||||
float R = texture2D(uVelocity, vR).y; |
||||
float T = texture2D(uVelocity, vT).x; |
||||
float B = texture2D(uVelocity, vB).x; |
||||
float vorticity = R - L - T + B; |
||||
gl_FragColor = vec4(0.5 * vorticity, 0.0, 0.0, 1.0); |
||||
}` |
||||
) |
||||
} |
||||
|
||||
const vorticityShader = (gl) => { |
||||
return compileShader(gl, |
||||
gl.FRAGMENT_SHADER, |
||||
` |
||||
precision highp float; |
||||
precision highp sampler2D; |
||||
|
||||
varying vec2 vUv; |
||||
varying vec2 vL; |
||||
varying vec2 vR; |
||||
varying vec2 vT; |
||||
varying vec2 vB; |
||||
uniform sampler2D uVelocity; |
||||
uniform sampler2D uCurl; |
||||
uniform float curl; |
||||
uniform float dt; |
||||
|
||||
void main () { |
||||
float L = texture2D(uCurl, vL).x; |
||||
float R = texture2D(uCurl, vR).x; |
||||
float T = texture2D(uCurl, vT).x; |
||||
float B = texture2D(uCurl, vB).x; |
||||
float C = texture2D(uCurl, vUv).x; |
||||
|
||||
vec2 force = 0.5 * vec2(abs(T) - abs(B), abs(R) - abs(L)); |
||||
force /= length(force) + 0.0001; |
||||
force *= curl * C; |
||||
force.y *= -1.0; |
||||
|
||||
vec2 vel = texture2D(uVelocity, vUv).xy; |
||||
gl_FragColor = vec4(vel + force * dt, 0.0, 1.0); |
||||
}` |
||||
) |
||||
} |
||||
|
||||
const pressureShader = (gl) => { |
||||
return compileShader(gl, |
||||
gl.FRAGMENT_SHADER, |
||||
` |
||||
precision mediump float; |
||||
precision mediump sampler2D; |
||||
|
||||
varying highp vec2 vUv; |
||||
varying highp vec2 vL; |
||||
varying highp vec2 vR; |
||||
varying highp vec2 vT; |
||||
varying highp vec2 vB; |
||||
uniform sampler2D uPressure; |
||||
uniform sampler2D uDivergence; |
||||
|
||||
void main () { |
||||
float L = texture2D(uPressure, vL).x; |
||||
float R = texture2D(uPressure, vR).x; |
||||
float T = texture2D(uPressure, vT).x; |
||||
float B = texture2D(uPressure, vB).x; |
||||
float C = texture2D(uPressure, vUv).x; |
||||
float divergence = texture2D(uDivergence, vUv).x; |
||||
float pressure = (L + R + B + T - divergence) * 0.25; |
||||
gl_FragColor = vec4(pressure, 0.0, 0.0, 1.0); |
||||
}` |
||||
) |
||||
} |
||||
|
||||
const gradientSubtractShader = (gl) => { |
||||
return compileShader(gl, |
||||
gl.FRAGMENT_SHADER, |
||||
` |
||||
precision mediump float; |
||||
precision mediump sampler2D; |
||||
|
||||
varying highp vec2 vUv; |
||||
varying highp vec2 vL; |
||||
varying highp vec2 vR; |
||||
varying highp vec2 vT; |
||||
varying highp vec2 vB; |
||||
uniform sampler2D uPressure; |
||||
uniform sampler2D uVelocity; |
||||
|
||||
void main () { |
||||
float L = texture2D(uPressure, vL).x; |
||||
float R = texture2D(uPressure, vR).x; |
||||
float T = texture2D(uPressure, vT).x; |
||||
float B = texture2D(uPressure, vB).x; |
||||
vec2 velocity = texture2D(uVelocity, vUv).xy; |
||||
velocity.xy -= vec2(R - L, T - B); |
||||
gl_FragColor = vec4(velocity, 0.0, 1.0); |
||||
}` |
||||
) |
||||
} |
||||
|
||||
export { |
||||
baseVertexShader, |
||||
blurVertexShader, |
||||
blurShader, |
||||
copyShader, |
||||
clearShader, |
||||
colorShader, |
||||
checkerboardShader, |
||||
displayShaderSource, |
||||
bloomPrefilterShader, |
||||
bloomBlurShader, |
||||
bloomFinalShader, |
||||
sunraysMaskShader, |
||||
sunraysShader, |
||||
splatShader, |
||||
advectionShader, |
||||
divergenceShader, |
||||
curlShader, |
||||
vorticityShader, |
||||
pressureShader, |
||||
gradientSubtractShader |
||||
} |
||||
@ -1,387 +0,0 @@ |
||||
function getWebGLContext(canvas) { |
||||
const params = {alpha: true, depth: false, stencil: false, antialias: false, preserveDrawingBuffer: false} |
||||
|
||||
let gl = canvas.getContext('webgl2', params) |
||||
const isWebGL2 = !!gl |
||||
if (!isWebGL2) gl = canvas.getContext('webgl', params) || canvas.getContext('experimental-webgl', params) |
||||
|
||||
let halfFloat |
||||
let supportLinearFiltering |
||||
if (isWebGL2) { |
||||
gl.getExtension('EXT_color_buffer_float') |
||||
supportLinearFiltering = gl.getExtension('OES_texture_float_linear') |
||||
} |
||||
else { |
||||
halfFloat = gl.getExtension('OES_texture_half_float') |
||||
supportLinearFiltering = gl.getExtension('OES_texture_half_float_linear') |
||||
} |
||||
|
||||
gl.clearColor(0.0, 0.0, 0.0, 1.0) |
||||
|
||||
const halfFloatTexType = isWebGL2 ? gl.HALF_FLOAT : halfFloat.HALF_FLOAT_OES |
||||
let formatRGBA |
||||
let formatRG |
||||
let formatR |
||||
|
||||
if (isWebGL2) { |
||||
formatRGBA = getSupportedFormat(gl, gl.RGBA16F, gl.RGBA, halfFloatTexType) |
||||
formatRG = getSupportedFormat(gl, gl.RG16F, gl.RG, halfFloatTexType) |
||||
formatR = getSupportedFormat(gl, gl.R16F, gl.RED, halfFloatTexType) |
||||
} |
||||
else { |
||||
formatRGBA = getSupportedFormat(gl, gl.RGBA, gl.RGBA, halfFloatTexType) |
||||
formatRG = getSupportedFormat(gl, gl.RGBA, gl.RGBA, halfFloatTexType) |
||||
formatR = getSupportedFormat(gl, gl.RGBA, gl.RGBA, halfFloatTexType) |
||||
} |
||||
|
||||
return { |
||||
gl, |
||||
ext: { |
||||
formatRGBA, |
||||
formatRG, |
||||
formatR, |
||||
halfFloatTexType, |
||||
supportLinearFiltering |
||||
} |
||||
} |
||||
} |
||||
|
||||
function getSupportedFormat(gl, internalFormat, format, type) { |
||||
if (!supportRenderTextureFormat(gl, internalFormat, format, type)) { |
||||
switch (internalFormat) { |
||||
case gl.R16F: |
||||
return getSupportedFormat(gl, gl.RG16F, gl.RG, type) |
||||
case gl.RG16F: |
||||
return getSupportedFormat(gl, gl.RGBA16F, gl.RGBA, type) |
||||
default: |
||||
return null |
||||
} |
||||
} |
||||
|
||||
return {internalFormat, format} |
||||
} |
||||
|
||||
function supportRenderTextureFormat(gl, internalFormat, format, type) { |
||||
let texture = gl.createTexture() |
||||
gl.bindTexture(gl.TEXTURE_2D, texture) |
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST) |
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST) |
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) |
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) |
||||
gl.texImage2D(gl.TEXTURE_2D, 0, internalFormat, 4, 4, 0, format, type, null) |
||||
|
||||
let fbo = gl.createFramebuffer() |
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo) |
||||
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0) |
||||
|
||||
const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER) |
||||
return status === gl.FRAMEBUFFER_COMPLETE |
||||
} |
||||
|
||||
function isMobile() { |
||||
return /Mobi|Android/i.test(navigator.userAgent) |
||||
} |
||||
|
||||
function addKeywords(source, keywords) { |
||||
if (keywords == null) return source |
||||
let keywordsString = '' |
||||
keywords.forEach(keyword => { |
||||
keywordsString += '#define ' + keyword + '\n' |
||||
}) |
||||
return keywordsString + source |
||||
} |
||||
|
||||
function compileShader(gl, type, source, keywords) { |
||||
source = addKeywords(source, keywords) |
||||
|
||||
const shader = gl.createShader(type) |
||||
gl.shaderSource(shader, source) |
||||
gl.compileShader(shader) |
||||
|
||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) throw gl.getShaderInfoLog(shader) |
||||
|
||||
return shader |
||||
} |
||||
|
||||
function createProgram(gl, vertexShader, fragmentShader) { |
||||
let program = gl.createProgram() |
||||
gl.attachShader(program, vertexShader) |
||||
gl.attachShader(program, fragmentShader) |
||||
gl.linkProgram(program) |
||||
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw gl.getProgramInfoLog(program) |
||||
|
||||
return program |
||||
} |
||||
|
||||
function getUniforms(gl, program) { |
||||
let uniforms = [] |
||||
let uniformCount = gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS) |
||||
for (let i = 0; i < uniformCount; i++) { |
||||
let uniformName = gl.getActiveUniform(program, i).name |
||||
uniforms[uniformName] = gl.getUniformLocation(program, uniformName) |
||||
} |
||||
return uniforms |
||||
} |
||||
|
||||
function getResolution(gl, resolution) { |
||||
let aspectRatio = gl.drawingBufferWidth / gl.drawingBufferHeight |
||||
if (aspectRatio < 1) aspectRatio = 1.0 / aspectRatio |
||||
|
||||
let min = Math.round(resolution) |
||||
let max = Math.round(resolution * aspectRatio) |
||||
|
||||
if (gl.drawingBufferWidth > gl.drawingBufferHeight) return {width: max, height: min} |
||||
else return {width: min, height: max} |
||||
} |
||||
|
||||
function clamp01(input) { |
||||
return Math.min(Math.max(input, 0), 1) |
||||
} |
||||
|
||||
function normalizeTexture(texture, width, height) { |
||||
let result = new Uint8Array(texture.length) |
||||
let id = 0 |
||||
for (let i = height - 1; i >= 0; i--) { |
||||
for (let j = 0; j < width; j++) { |
||||
let nid = i * width * 4 + j * 4 |
||||
result[nid] = clamp01(texture[id]) * 255 |
||||
result[nid + 1] = clamp01(texture[id + 1]) * 255 |
||||
result[nid + 2] = clamp01(texture[id + 2]) * 255 |
||||
result[nid + 3] = clamp01(texture[id + 3]) * 255 |
||||
id += 4 |
||||
} |
||||
} |
||||
return result |
||||
} |
||||
|
||||
function framebufferToTexture(gl, target) { |
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, target.fbo) |
||||
let length = target.width * target.height * 4 |
||||
let texture = new Float32Array(length) |
||||
gl.readPixels(0, 0, target.width, target.height, gl.RGBA, gl.FLOAT, texture) |
||||
return texture |
||||
} |
||||
|
||||
function textureToCanvas(texture, width, height) { |
||||
let captureCanvas = document.createElement('canvas') |
||||
let ctx = captureCanvas.getContext('2d') |
||||
captureCanvas.width = width |
||||
captureCanvas.height = height |
||||
|
||||
let imageData = ctx.createImageData(width, height) |
||||
imageData.data.set(texture) |
||||
ctx.putImageData(imageData, 0, 0) |
||||
|
||||
return captureCanvas |
||||
} |
||||
|
||||
function downloadURI(filename, uri) { |
||||
let link = document.createElement('a') |
||||
link.download = filename |
||||
link.href = uri |
||||
document.body.appendChild(link) |
||||
link.click() |
||||
document.body.removeChild(link) |
||||
} |
||||
|
||||
function createFBO(gl, w, h, internalFormat, format, type, param) { |
||||
gl.activeTexture(gl.TEXTURE0) |
||||
let texture = gl.createTexture() |
||||
gl.bindTexture(gl.TEXTURE_2D, texture) |
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, param) |
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, param) |
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) |
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) |
||||
gl.texImage2D(gl.TEXTURE_2D, 0, internalFormat, w, h, 0, format, type, null) |
||||
|
||||
let fbo = gl.createFramebuffer() |
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo) |
||||
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0) |
||||
gl.viewport(0, 0, w, h) |
||||
gl.clear(gl.COLOR_BUFFER_BIT) |
||||
|
||||
let texelSizeX = 1.0 / w |
||||
let texelSizeY = 1.0 / h |
||||
|
||||
return { |
||||
texture, fbo, width: w, height: h, texelSizeX, texelSizeY, attach(id) { |
||||
gl.activeTexture(gl.TEXTURE0 + id) |
||||
gl.bindTexture(gl.TEXTURE_2D, texture) |
||||
return id |
||||
} |
||||
} |
||||
} |
||||
|
||||
function createDoubleFBO(gl, w, h, internalFormat, format, type, param) { |
||||
let fbo1 = createFBO(gl, w, h, internalFormat, format, type, param) |
||||
let fbo2 = createFBO(gl, w, h, internalFormat, format, type, param) |
||||
|
||||
return { |
||||
width: w, |
||||
height: h, |
||||
texelSizeX: fbo1.texelSizeX, |
||||
texelSizeY: fbo1.texelSizeY, |
||||
get read() { |
||||
return fbo1 |
||||
}, |
||||
set read(value) { |
||||
fbo1 = value |
||||
}, |
||||
get write() { |
||||
return fbo2 |
||||
}, |
||||
set write(value) { |
||||
fbo2 = value |
||||
}, |
||||
swap() { |
||||
let temp = fbo1 |
||||
fbo1 = fbo2 |
||||
fbo2 = temp |
||||
} |
||||
} |
||||
} |
||||
|
||||
function HSVtoRGB(h, s, v) { |
||||
let r, g, b, i, f, p, q, t |
||||
i = Math.floor(h * 6) |
||||
f = h * 6 - i |
||||
p = v * (1 - s) |
||||
q = v * (1 - f * s) |
||||
t = v * (1 - (1 - f) * s) |
||||
|
||||
switch (i % 6) { |
||||
case 0: |
||||
r = v |
||||
g = t |
||||
b = p |
||||
break |
||||
case 1: |
||||
r = q |
||||
g = v |
||||
b = p |
||||
break |
||||
case 2: |
||||
r = p |
||||
g = v |
||||
b = t |
||||
break |
||||
case 3: |
||||
r = p |
||||
g = q |
||||
b = v |
||||
break |
||||
case 4: |
||||
r = t |
||||
g = p |
||||
b = v |
||||
break |
||||
case 5: |
||||
r = v |
||||
g = p |
||||
b = q |
||||
break |
||||
} |
||||
|
||||
return {r, g, b} |
||||
} |
||||
|
||||
function generateColor() { |
||||
let c = HSVtoRGB(Math.random(), 1.0, 1.0) |
||||
c.r *= 0.15 |
||||
c.g *= 0.15 |
||||
c.b *= 0.15 |
||||
return c |
||||
} |
||||
|
||||
function scaleByPixelRatio(input) { |
||||
let pixelRatio = window.devicePixelRatio || 1 |
||||
return Math.floor(input * pixelRatio) |
||||
} |
||||
|
||||
function wrap(value, min, max) { |
||||
let range = max - min |
||||
if (range === 0) return min |
||||
return ((value - min) % range) + min |
||||
} |
||||
|
||||
function normalizeColor(input) { |
||||
return { |
||||
r: input.r / 255, |
||||
g: input.g / 255, |
||||
b: input.b / 255 |
||||
} |
||||
} |
||||
|
||||
function correctDeltaX(canvas, delta) { |
||||
let aspectRatio = canvas.width / canvas.height |
||||
if (aspectRatio < 1) delta *= aspectRatio |
||||
return delta |
||||
} |
||||
|
||||
function correctDeltaY(canvas, delta) { |
||||
let aspectRatio = canvas.width / canvas.height |
||||
if (aspectRatio > 1) delta /= aspectRatio |
||||
return delta |
||||
} |
||||
|
||||
function updatePointerDownData(canvas, pointer, id, posX, posY) { |
||||
pointer.id = id |
||||
pointer.down = true |
||||
pointer.moved = false |
||||
pointer.texcoordX = posX / canvas.width |
||||
pointer.texcoordY = 1.0 - posY / canvas.height |
||||
pointer.prevTexcoordX = pointer.texcoordX |
||||
pointer.prevTexcoordY = pointer.texcoordY |
||||
pointer.deltaX = 0 |
||||
pointer.deltaY = 0 |
||||
pointer.color = generateColor() |
||||
} |
||||
|
||||
function updatePointerMoveData(canvas, pointer, posX, posY) { |
||||
pointer.moved = pointer.down |
||||
pointer.prevTexcoordX = pointer.texcoordX |
||||
pointer.prevTexcoordY = pointer.texcoordY |
||||
pointer.texcoordX = posX / canvas.width |
||||
pointer.texcoordY = 1.0 - posY / canvas.height |
||||
pointer.deltaX = correctDeltaX(canvas, pointer.texcoordX - pointer.prevTexcoordX) |
||||
pointer.deltaY = correctDeltaY(canvas, pointer.texcoordY - pointer.prevTexcoordY) |
||||
} |
||||
|
||||
function updatePointerUpData(pointer) { |
||||
pointer.down = false |
||||
} |
||||
|
||||
function resizeCanvas(canvas) { |
||||
let width = scaleByPixelRatio(canvas.clientWidth) |
||||
let height = scaleByPixelRatio(canvas.clientHeight) |
||||
if (canvas.width !== width || canvas.height !== height) { |
||||
canvas.width = width |
||||
canvas.height = height |
||||
return true |
||||
} |
||||
return false |
||||
} |
||||
|
||||
export { |
||||
getWebGLContext, |
||||
isMobile, |
||||
compileShader, |
||||
createProgram, |
||||
getUniforms, |
||||
getResolution, |
||||
normalizeTexture, |
||||
framebufferToTexture, |
||||
textureToCanvas, |
||||
downloadURI, |
||||
createFBO, |
||||
createDoubleFBO, |
||||
generateColor, |
||||
scaleByPixelRatio, |
||||
wrap, |
||||
normalizeColor, |
||||
updatePointerDownData, |
||||
updatePointerMoveData, |
||||
updatePointerUpData, |
||||
resizeCanvas, |
||||
} |
||||
@ -1,22 +0,0 @@ |
||||
/*路由表:消息中心*/ |
||||
|
||||
const router = { |
||||
path: 'message', |
||||
meta: {title: '消息中心', icon: 'svg-message', alwaysShow: true}, |
||||
children: [ |
||||
{ |
||||
path: 'manage', |
||||
name: 'messageManagement', |
||||
component: 'admin/message/manage/', |
||||
meta: {title: '消息管理'} |
||||
}, |
||||
{ |
||||
path: 'user', |
||||
name: 'userMessage', |
||||
component: 'admin/message/user/', |
||||
meta: {title: '个人消息', noAuth: true, noCache: true} |
||||
} |
||||
] |
||||
} |
||||
|
||||
export default router |
||||
@ -1,68 +0,0 @@ |
||||
/*路由表:采购管理*/ |
||||
|
||||
const router = { |
||||
path: 'purchase', |
||||
meta: {title: '采购管理', icon: 'svg-shopping', alwaysShow: true}, |
||||
children: [ |
||||
{ |
||||
path: 'order', |
||||
name: 'purchaseOrder', |
||||
component: 'admin/purchase/order/', |
||||
meta: {title: '采购订单'} |
||||
}, |
||||
{ |
||||
path: 'order/detail/:type(see|add|edit)/:id?', |
||||
component: 'admin/purchase/order/detail', |
||||
meta: { |
||||
title: '采购订单详情页', |
||||
hidden: true, |
||||
pageHeader: false, |
||||
dynamicTitle(to) { |
||||
const {type, id} = to.params |
||||
switch (type) { |
||||
case 'add': |
||||
return '添加采购订单' |
||||
case 'edit': |
||||
return `编辑采购订单${id}` |
||||
case 'see': |
||||
return `查看采购订单${id}` |
||||
} |
||||
}, |
||||
activeMenu: '/purchase/order', |
||||
usePathKey: true, |
||||
commonModule: '@/view/admin/purchase/order/detail' |
||||
} |
||||
}, |
||||
{ |
||||
path: 'inbound', |
||||
name: 'purchaseInbound', |
||||
component: 'admin/purchase/inbound/', |
||||
meta: {title: '采购入库'} |
||||
}, |
||||
{ |
||||
path: 'inbound/detail/:type(see|add|edit)/:id?', |
||||
component: 'admin/purchase/inbound/detail', |
||||
meta: { |
||||
title: '采购入库详情页', |
||||
hidden: true, |
||||
pageHeader: false, |
||||
dynamicTitle(to) { |
||||
const {type, id} = to.params |
||||
switch (type) { |
||||
case 'add': |
||||
return '添加采购入库单' |
||||
case 'edit': |
||||
return `编辑采购入库单${id}` |
||||
case 'see': |
||||
return `查看采购入库单${id}` |
||||
} |
||||
}, |
||||
activeMenu: '/purchase/inbound', |
||||
usePathKey: true, |
||||
commonModule: '@/view/admin/purchase/inbound/detail' |
||||
} |
||||
} |
||||
] |
||||
} |
||||
|
||||
export default router |
||||
@ -1,68 +0,0 @@ |
||||
/*路由表:销售管理*/ |
||||
|
||||
const router = { |
||||
path: 'sell', |
||||
meta: {title: '销售管理', icon: 'svg-sell', alwaysShow: true}, |
||||
children: [ |
||||
{ |
||||
path: 'order', |
||||
name: 'sellOrder', |
||||
component: 'admin/sell/order/', |
||||
meta: {title: '销售订单'} |
||||
}, |
||||
{ |
||||
path: 'order/detail/:type(see|add|edit)/:id?', |
||||
component: 'admin/sell/order/detail', |
||||
meta: { |
||||
title: '销售订单详情页', |
||||
hidden: true, |
||||
pageHeader: false, |
||||
dynamicTitle(to) { |
||||
const {type, id} = to.params |
||||
switch (type) { |
||||
case 'add': |
||||
return '添加销售订单' |
||||
case 'edit': |
||||
return `编辑销售订单${id}` |
||||
case 'see': |
||||
return `查看销售订单${id}` |
||||
} |
||||
}, |
||||
activeMenu: '/sell/order', |
||||
usePathKey: true, |
||||
commonModule: '@/view/admin/sell/order/detail' |
||||
} |
||||
}, |
||||
{ |
||||
path: 'outbound', |
||||
name: 'sellOutbound', |
||||
component: 'admin/sell/outbound/', |
||||
meta: {title: '销售出库'} |
||||
}, |
||||
{ |
||||
path: 'outbound/detail/:type(see|add|edit)/:id?', |
||||
component: 'admin/sell/outbound/detail', |
||||
meta: { |
||||
title: '销售出库详情页', |
||||
hidden: true, |
||||
pageHeader: false, |
||||
dynamicTitle(to) { |
||||
const {type, id} = to.params |
||||
switch (type) { |
||||
case 'add': |
||||
return '添加销售出库单' |
||||
case 'edit': |
||||
return `编辑销售出库单${id}` |
||||
case 'see': |
||||
return `查看销售出库单${id}` |
||||
} |
||||
}, |
||||
activeMenu: '/sell/outbound', |
||||
usePathKey: true, |
||||
commonModule: '@/view/admin/sell/outbound/detail' |
||||
} |
||||
} |
||||
] |
||||
} |
||||
|
||||
export default router |
||||
@ -1,16 +0,0 @@ |
||||
/*路由表:库存管理*/ |
||||
|
||||
const router = { |
||||
path: 'stock', |
||||
meta: {title: '库存管理', icon: 'svg-stock', alwaysShow: true}, |
||||
children: [ |
||||
{ |
||||
path: 'current', |
||||
name: 'currentStock', |
||||
component: 'admin/stock/current/', |
||||
meta: {title: '当前库存'} |
||||
} |
||||
] |
||||
} |
||||
|
||||
export default router |
||||
@ -1,52 +0,0 @@ |
||||
/*路由表:系统管理*/ |
||||
|
||||
const router = { |
||||
path: 'system', |
||||
meta: {title: '系统管理', icon: 'svg-system', alwaysShow: true}, |
||||
children: [ |
||||
{ |
||||
path: 'department', |
||||
name: 'departmentManagement', |
||||
component: 'admin/system/department/', |
||||
meta: {title: '部门管理'} |
||||
}, |
||||
{ |
||||
path: 'menu', |
||||
name: 'menuManagement', |
||||
component: 'admin/system/menu/', |
||||
meta: {title: '菜单管理', noCache: true} |
||||
}, |
||||
{ |
||||
path: 'role', |
||||
name: 'roleManagement', |
||||
component: 'admin/system/role/', |
||||
meta: {title: '角色管理'} |
||||
}, |
||||
{ |
||||
path: 'user', |
||||
name: 'userManagement', |
||||
component: 'admin/system/user/', |
||||
meta: {title: '用户管理'} |
||||
}, |
||||
{ |
||||
path: 'category', |
||||
name: 'categorySetting', |
||||
component: 'admin/system/category/', |
||||
meta: {title: '商品分类'} |
||||
}, |
||||
{ |
||||
path: 'customer', |
||||
name: 'customerManagement', |
||||
component: 'admin/system/customer/', |
||||
meta: {title: '客户管理'} |
||||
}, |
||||
{ |
||||
path: 'supplier', |
||||
name: 'supplierManagement', |
||||
component: 'admin/system/supplier/', |
||||
meta: {title: '供应商管理'} |
||||
} |
||||
] |
||||
} |
||||
|
||||
export default router |
||||
@ -1,7 +0,0 @@ |
||||
import component from '@/view/dev/uploadFileDev' |
||||
|
||||
export default { |
||||
path: 'uploadFile', |
||||
component, |
||||
meta: {title: '上传'} |
||||
} |
||||
@ -1,11 +0,0 @@ |
||||
/*顶级菜单:开发用的页面*/ |
||||
import {context2array} from '@/router/util' |
||||
|
||||
const files = require.context('./child', false, /\.js$/) |
||||
|
||||
export default { |
||||
path: '/dev', |
||||
component: {template: `<div><div>开发专用页面</div><router-view/></div>`}, |
||||
meta: {title: '开发专用页面', icon: 'svg-develop', noAuth: true, noCache: true, alwaysShow: true}, |
||||
children: context2array(files) |
||||
} |
||||
@ -1,26 +1,11 @@ |
||||
export default { |
||||
path: 'component', |
||||
meta: {title: '组件', icon: 'el-icon-s-grid'}, |
||||
meta: {title: '组件', icon: 'el-icon-s-grid', alwaysShow: true}, |
||||
children: [ |
||||
{ |
||||
path: 'upload', |
||||
component: 'example/component/upload', |
||||
meta: {title: '上传文件'} |
||||
}, |
||||
{ |
||||
path: 'skeleton', |
||||
component: 'example/component/skeleton', |
||||
meta: {title: '骨架屏'} |
||||
}, |
||||
{ |
||||
path: 'regionSelector', |
||||
component: 'example/component/regionSelector', |
||||
meta: {title: '行政区划选择'} |
||||
}, |
||||
{ |
||||
path: 'treeSelect', |
||||
component: 'example/component/treeSelect', |
||||
meta: {title: '树选择'} |
||||
} |
||||
] |
||||
} |
||||
|
||||
@ -1,16 +0,0 @@ |
||||
export default { |
||||
path: 'cool', |
||||
meta: {title: '好玩的东东', icon: 'el-icon-s-opportunity'}, |
||||
children: [ |
||||
{ |
||||
path: 'fluid', |
||||
component: 'example/cool/fluid', |
||||
meta: {title: '流体动画'} |
||||
}, |
||||
{ |
||||
path: 'l2d', |
||||
component: 'example/cool/l2d', |
||||
meta: {title: '看板娘'} |
||||
}, |
||||
] |
||||
} |
||||
@ -1,6 +1,5 @@ |
||||
import admin from './admin' |
||||
import example from './example' |
||||
import dev from './dev' |
||||
import doc from './doc' |
||||
|
||||
export default [admin, example, dev, doc] |
||||
export default [admin, example, doc] |
||||
|
||||
@ -1,24 +0,0 @@ |
||||
/* |
||||
* 数据缓存 |
||||
* 比如商品分类信息等等 |
||||
* */ |
||||
import {createTree} from '@/util/tree' |
||||
|
||||
const state = { |
||||
categories: [], |
||||
categoryTree: [] |
||||
} |
||||
|
||||
const mutations = { |
||||
categories(state, categories) { |
||||
state.categories = categories || [] |
||||
state.categoryTree = createTree(state.categories) |
||||
} |
||||
} |
||||
|
||||
|
||||
export default { |
||||
namespaced: true, |
||||
state, |
||||
mutations |
||||
} |
||||
@ -1,23 +0,0 @@ |
||||
import {createMutations} from "@/store/util" |
||||
import {search} from '@/api/message/user' |
||||
|
||||
const state = { |
||||
unreadCount: 0 |
||||
} |
||||
|
||||
const mutations = createMutations(state) |
||||
|
||||
const actions = { |
||||
refresh({commit}) { |
||||
search |
||||
.request({page: 1, pageSize: 1, unread: true}) |
||||
.then(({data}) => commit('unreadCount', data.total)) |
||||
} |
||||
} |
||||
|
||||
export default { |
||||
namespaced: true, |
||||
state, |
||||
mutations, |
||||
actions |
||||
} |
||||
@ -1,28 +0,0 @@ |
||||
import Vue from "vue" |
||||
|
||||
const state = { |
||||
map: {} |
||||
} |
||||
|
||||
const mutations = { |
||||
init(state, key) { |
||||
if (state.map.hasOwnProperty(key)) { |
||||
state.map[key] = false |
||||
} |
||||
else Vue.set(state.map, key, false) |
||||
}, |
||||
|
||||
emit(state, key) { |
||||
state.map[key] = true |
||||
}, |
||||
|
||||
renew(state, key) { |
||||
state.map[key] = false |
||||
} |
||||
} |
||||
|
||||
export default { |
||||
namespaced: true, |
||||
state, |
||||
mutations |
||||
} |
||||
@ -1,94 +0,0 @@ |
||||
import {MessageBox} from "element-ui" |
||||
import {isEmpty} from "@/util" |
||||
import {useMock, socketUrl} from '@/config' |
||||
import SocketIO from 'socket.io-client' |
||||
import {createMutations} from "@/store/util" |
||||
|
||||
let socket |
||||
|
||||
const state = { |
||||
online: true |
||||
} |
||||
|
||||
const mutations = createMutations(state) |
||||
|
||||
const actions = { |
||||
init(context) { |
||||
const {id, token} = context.rootState.user |
||||
|
||||
if (useMock || isEmpty(id, token)) return |
||||
|
||||
socket = initSocket({id, token}) |
||||
|
||||
bindDefaultEvent(socket, context) |
||||
bindCustomEvent(socket, context) |
||||
}, |
||||
|
||||
close() { |
||||
socket && socket.close() |
||||
} |
||||
} |
||||
|
||||
function initSocket({id, token}) { |
||||
return new SocketIO(socketUrl, {query: {id, token}, transports: ['websocket']}) |
||||
} |
||||
|
||||
function bindDefaultEvent(socket, {state, commit, dispatch}) { |
||||
socket.on('connect', () => { |
||||
commit('online', true) |
||||
console.log('socket连接成功') |
||||
}) |
||||
|
||||
socket.on('disconnect', (reason) => { |
||||
commit('online', false) |
||||
if (reason === 'io server disconnect') { |
||||
return console.log('服务端关闭了socket连接') |
||||
} |
||||
console.log('socket失去连接') |
||||
}) |
||||
|
||||
socket.on('reconnecting', (attemptNumber) => { |
||||
commit('online', false) |
||||
console.log(`socket第${attemptNumber}次重连中...`) |
||||
}) |
||||
|
||||
socket.on('reconnect', (attemptNumber) => { |
||||
commit('online', true) |
||||
console.log(`socket第${attemptNumber}次重连成功`) |
||||
}) |
||||
|
||||
socket.on('reconnect_failed', () => { |
||||
// ...
|
||||
}) |
||||
|
||||
socket.on('reconnect_error', (error) => { |
||||
// ...
|
||||
}) |
||||
|
||||
socket.on('error', (error) => { |
||||
// ...
|
||||
}) |
||||
} |
||||
|
||||
function bindCustomEvent(socket, {state, commit, dispatch, rootState}) { |
||||
socket.on('logout', msg => { |
||||
if (rootState.user.prepareLogout) return |
||||
MessageBox.alert(msg || '你已被强制下线,请重新登录', { |
||||
type: 'warning', |
||||
beforeClose: (action, instance, done) => { |
||||
dispatch('user/logout', null, {root: true}).finally(done) |
||||
} |
||||
}) |
||||
}) |
||||
|
||||
socket.on('new-message', () => { |
||||
dispatch('message/refresh', null, {root: true}) |
||||
}) |
||||
} |
||||
|
||||
export default { |
||||
namespaced: true, |
||||
state, |
||||
mutations, |
||||
actions |
||||
} |
||||
@ -1,240 +0,0 @@ |
||||
import request from "@/api/request" |
||||
import {isEmpty} from "@/util" |
||||
import {elError} from "@/util/message" |
||||
import {download} from "@/util/file" |
||||
import {flatTree} from "@/util/tree" |
||||
|
||||
/** |
||||
* 导出excel的抽象模板 |
||||
* 响应头为json时进行前端导出,否则直接下载文件 |
||||
* |
||||
* @param url {string} 搜索的请求地址 |
||||
* @param params {*} 向后台发送请求时携带的参数 |
||||
* @param options 前端导出时需要的配置项 |
||||
* @param options.columns {array} json2workbook所需参数 |
||||
* @param options.merge {object} json2workbook所需参数 |
||||
* @param json2workbook {function} 将json数据转换为workbook的函数,参考./exceljs.js #json2workbook |
||||
* @param workbook2excel {function} workbook对象转换为excel文件,参考./exceljs.js #workbook2excel |
||||
*/ |
||||
export function abstractExportExcel(url, params, options, json2workbook, workbook2excel) { |
||||
request({url, method: 'post', responseType: 'blob', data: params}) |
||||
.then(({headers, data}) => { |
||||
const contentType = headers['content-type'] |
||||
const contentDisposition = headers['content-disposition'] |
||||
const filename = window.decodeURI(contentDisposition.split('=')[1]) |
||||
|
||||
if (contentType.includes('json')) { |
||||
const reader = new FileReader() |
||||
reader.onload = () => { |
||||
const response = JSON.parse(reader.result) |
||||
const {columns, merge} = options |
||||
const workbook = json2workbook(response.data, columns, merge) |
||||
workbook2excel(workbook, filename) |
||||
} |
||||
reader.readAsText(data) |
||||
} |
||||
else download(data, filename) |
||||
}) |
||||
.catch(e => elError(e)) |
||||
} |
||||
|
||||
/** |
||||
* 合并excel |
||||
* 此方法会修改原始json数据(重写序号) |
||||
* |
||||
* @param props {array} 需要合并的字段名数组,用于映射json |
||||
* @param data {array} json数组 |
||||
* @param primaryKey {string} 该行json的唯一标识字段名 |
||||
* @param orderKey {string} 该行json的序号字段名 |
||||
* @param ignoreRows {number} 忽略的行数,默认为1(忽略表头) |
||||
* @return {array} 包含合并信息的数组,形如['A1:A10',...] |
||||
*/ |
||||
export function mergeExcel(props, data, primaryKey, orderKey, ignoreRows = 1) { |
||||
const result = [], merge = [], temp = [] |
||||
|
||||
props.forEach((prop, index) => { |
||||
merge[index] = prop ? [1] : undefined |
||||
temp[index] = prop ? 1 : undefined |
||||
}) |
||||
|
||||
//序号从1开始
|
||||
let indexAfterMerge = data[0][orderKey] = 1 |
||||
|
||||
function compare(currentRow, lastRow, nextRow, colIndex) { |
||||
const attr = props[colIndex] |
||||
|
||||
//若与上一行的primaryKey相同且属性相同,则temp对应项+1
|
||||
if (currentRow[primaryKey] === lastRow[primaryKey] |
||||
&& currentRow[attr] === lastRow[attr]) { |
||||
temp[colIndex]++ |
||||
//最后一行特殊处理
|
||||
if (!nextRow) return merge[colIndex].push(temp[colIndex]) |
||||
|
||||
//若与下一行相同
|
||||
if (currentRow[primaryKey] === nextRow[primaryKey] |
||||
&& currentRow[attr] === nextRow[attr]) { |
||||
merge[colIndex].push(1) |
||||
} |
||||
//否则存入
|
||||
else merge[colIndex].push(temp[colIndex]) |
||||
} |
||||
//否则清零
|
||||
else { |
||||
temp[colIndex] = 1 |
||||
merge[colIndex].push(1) |
||||
} |
||||
} |
||||
|
||||
for (let i = 1; i < data.length; i++) { |
||||
const currentRow = data[i] |
||||
const lastRow = data[i - 1] |
||||
const nextRow = data[i + 1] |
||||
|
||||
//重写序号
|
||||
if (currentRow[primaryKey] === lastRow[primaryKey]) { |
||||
currentRow[orderKey] = lastRow[orderKey] |
||||
} |
||||
else currentRow[orderKey] = ++indexAfterMerge |
||||
if (nextRow && nextRow[primaryKey] === currentRow[primaryKey]) { |
||||
nextRow[orderKey] = currentRow[orderKey] |
||||
} |
||||
|
||||
props.forEach((prop, colIndex) => !isEmpty(prop) && compare(currentRow, lastRow, nextRow, colIndex)) |
||||
} |
||||
|
||||
function mergeResultConstructor(arr, colIndex) { |
||||
const colHeader = number2excelColumnHeader(colIndex) |
||||
const startRows = ignoreRows + 1 |
||||
|
||||
arr.forEach((rowspan, rowIndex) => { |
||||
if (rowspan > 1) { |
||||
const start = colHeader + (startRows + rowIndex - (rowspan - 1)) |
||||
const end = colHeader + (startRows + rowIndex) |
||||
result.push(`${start}:${end}`) |
||||
} |
||||
}) |
||||
} |
||||
|
||||
merge.forEach((arr, index) => arr && mergeResultConstructor(arr, index)) |
||||
|
||||
return result |
||||
} |
||||
|
||||
/** |
||||
* 生成表头的二维数组以及合并结果 |
||||
* 参考element的table-header |
||||
* |
||||
* @param columns {array} 列配置 |
||||
* @param separator {string} 分隔符 |
||||
* @return {{header: [], mergeCells: []}} |
||||
*/ |
||||
export function generateHeader(columns, separator = '-') { |
||||
const tree = [] |
||||
let maxDepth = 1 |
||||
|
||||
for (const col of columns) { |
||||
let header = col.header |
||||
|
||||
if (typeof header === 'string') { |
||||
header = header.split(separator) |
||||
} |
||||
|
||||
let arr = tree, depth = header.length |
||||
|
||||
if (depth > maxDepth) maxDepth = depth |
||||
|
||||
for (let i = 0; i < depth; i++) { |
||||
const value = header[i] |
||||
|
||||
let obj = arr.find(item => item.value === value) |
||||
|
||||
if (!obj) { |
||||
obj = {value, depth: i + 1} |
||||
if (i !== depth - 1) obj.children = [] |
||||
|
||||
arr.push(obj) |
||||
} |
||||
|
||||
arr = obj.children |
||||
} |
||||
} |
||||
|
||||
const traverse = column => { |
||||
if (column.children) { |
||||
let colSpan = 0 |
||||
column.children.forEach(subColumn => { |
||||
traverse(subColumn) |
||||
colSpan += subColumn.colSpan |
||||
}) |
||||
column.rowSpan = 1 |
||||
column.colSpan = colSpan |
||||
} |
||||
else { |
||||
column.rowSpan = maxDepth - column.depth + 1 |
||||
column.colSpan = 1 |
||||
} |
||||
} |
||||
|
||||
tree.forEach(node => traverse(node)) |
||||
|
||||
const header = [] |
||||
for (let i = 0; i < maxDepth; i++) header.push([]) |
||||
|
||||
const mergeCells = [] |
||||
|
||||
let lastDepth = 0, colIndex = 0 //当前是第几列,从0开始
|
||||
|
||||
for (const node of flatTree(tree)) { |
||||
//判断是否是下一列,节点深度从1开始
|
||||
if (node.depth <= lastDepth) colIndex++ |
||||
lastDepth = node.depth |
||||
|
||||
//填充表头
|
||||
header[node.depth - 1][colIndex] = node.value |
||||
|
||||
//获取合并结果,要么rowSpan大于1要么colSpan大于1,两者不可能同时大于1
|
||||
const startColHeader = number2excelColumnHeader(colIndex) |
||||
const startCell = startColHeader + node.depth |
||||
if (node.rowSpan > 1) { |
||||
const endCell = startColHeader + (node.depth + node.rowSpan - 1) |
||||
mergeCells.push(`${startCell}:${endCell}`) |
||||
} |
||||
if (node.colSpan > 1) { |
||||
const endCell = number2excelColumnHeader(colIndex + node.colSpan - 1) + node.depth |
||||
mergeCells.push(`${startCell}:${endCell}`) |
||||
} |
||||
} |
||||
|
||||
return {header, mergeCells} |
||||
} |
||||
|
||||
/** |
||||
* 生成二维数组,例如json为[{id:1,name:'x'}],结果为[[1,'x']] |
||||
* |
||||
* @param data {array} json数组 |
||||
* @param propMap {object} 表头和字段名的映射表,propMap[字段名]=对应的表头的数组下标 |
||||
* @return {array} 基数组为excel中每一行的二维数组 |
||||
*/ |
||||
export function jsonArray2rowArray(data, propMap) { |
||||
return data.map(i => Object.keys(i) |
||||
.reduce((arr, key) => { |
||||
arr[propMap[key]] = i[key] |
||||
return arr |
||||
}, [])) |
||||
} |
||||
|
||||
/** |
||||
* 数字转excel列名 |
||||
* 1 -> A 27 -> AB |
||||
* |
||||
* @param n {number} |
||||
* @return {string} |
||||
*/ |
||||
export function number2excelColumnHeader(n) { |
||||
let s = "" |
||||
while (n >= 0) { |
||||
s = String.fromCharCode(n % 26 + 65) + s |
||||
n = Math.floor(n / 26) - 1 |
||||
} |
||||
return s |
||||
} |
||||
@ -1,109 +0,0 @@ |
||||
import {isEmpty} from "@/util" |
||||
import {download} from "@/util/file" |
||||
import { |
||||
abstractExportExcel, |
||||
jsonArray2rowArray, |
||||
mergeExcel, |
||||
generateHeader, |
||||
number2excelColumnHeader |
||||
} from "./common" |
||||
|
||||
//默认的excel单元格样式
|
||||
const defaultCellStyle = { |
||||
font: {name: '宋体'}, |
||||
alignment: { |
||||
wrapText: true,// 自动换行
|
||||
horizontal: "center", |
||||
vertical: "middle", |
||||
indent: 0 |
||||
} |
||||
} |
||||
|
||||
//默认的头部样式
|
||||
const defaultHeaderStyle = { |
||||
...defaultCellStyle, |
||||
fill: {type: 'pattern', pattern: 'solid', fgColor: {argb: "FFC0C0C0"}}, |
||||
font: {name: '宋体', size: 14, bold: true}, |
||||
border: {top: {style: 'thin'}, left: {style: 'thin'}, bottom: {style: 'thin'}, right: {style: 'thin'}} |
||||
} |
||||
|
||||
/** |
||||
* excel导出的具体实现 |
||||
*/ |
||||
export function exportExcel(url, params, options) { |
||||
abstractExportExcel(url, params, options, json2workbook, workbook2excel) |
||||
} |
||||
|
||||
export function workbook2excel(workbook, filename) { |
||||
return workbook.xlsx.writeBuffer() |
||||
.then(buffer => download(new Blob([buffer], {type: "application/octet-stream"}), filename)) |
||||
} |
||||
|
||||
/** |
||||
* 通过json数组生成workbook对象 |
||||
* |
||||
* @param data {array} json数组 |
||||
* @param columns {array} 列配置 |
||||
* @param mergeOption 合并配置项 |
||||
* @param mergeOption.primaryKey {string} ./common.js #mergeExcel所需参数 |
||||
* @param mergeOption.orderKey {string} ./common.js #mergeExcel所需参数 |
||||
* @return workbook exceljs的workbook对象 |
||||
*/ |
||||
export function json2workbook(data, columns, mergeOption) { |
||||
const columnStyle = [] //列设置
|
||||
const propMap = {} //表头和字段名的映射表,propMap[字段名]=对应的表头的数组下标
|
||||
const needMerge = [] //合并行配置,[i]=字段名
|
||||
|
||||
columns.forEach((col, i) => { |
||||
if (!isEmpty(col.prop)) propMap[col.prop] = i |
||||
if (col.merge) needMerge[i] = col.prop |
||||
columnStyle[i] = {width: col.width || 20, style: defaultCellStyle} |
||||
}) |
||||
|
||||
const {header, mergeCells} = generateHeader(columns) |
||||
|
||||
if (mergeOption && needMerge.length > 0) { |
||||
const {primaryKey, orderKey} = mergeOption |
||||
mergeCells.push(...mergeExcel(needMerge, data, primaryKey, orderKey, header.length)) |
||||
} |
||||
|
||||
const body = jsonArray2rowArray(data, propMap) |
||||
|
||||
return generateWorkbook('Sheet1', columnStyle, header, body, mergeCells) |
||||
} |
||||
|
||||
/** |
||||
* 生成workbook的代码抽取 |
||||
* |
||||
* @param sheetName {string} 第一个sheet的名称 |
||||
* @param columnStyle {array} 列样式 |
||||
* @param header {array} 头部,二维数组 |
||||
* @param body {array} 内容,二维数组 |
||||
* @param mergeCells {array} 需要合并的单元格,形如['A1:B1'...] |
||||
* @return workbook |
||||
*/ |
||||
function generateWorkbook(sheetName, columnStyle, header, body, mergeCells) { |
||||
const workbook = new window.ExcelJS.Workbook() |
||||
const sheet = workbook.addWorksheet(sheetName) |
||||
|
||||
if (header) { |
||||
sheet.addRows(header) |
||||
//设置头部单元格样式
|
||||
header.forEach((rows, rowIndex) => { |
||||
rows.forEach((cellValue, colIndex) => { |
||||
if (cellValue !== undefined) { |
||||
const cell = sheet.getCell(number2excelColumnHeader(colIndex) + (rowIndex + 1)) |
||||
Object.keys(defaultHeaderStyle).forEach(key => { |
||||
cell[key] = defaultHeaderStyle[key] |
||||
}) |
||||
} |
||||
}) |
||||
}) |
||||
} |
||||
body && sheet.addRows(body) |
||||
|
||||
//exceljs的合并行必须在数据添加后进行
|
||||
mergeCells && mergeCells.forEach(i => sheet.mergeCells(i)) |
||||
|
||||
return workbook |
||||
} |
||||
@ -1 +0,0 @@ |
||||
export {exportExcel, json2workbook, workbook2excel} from "./exceljs" |
||||
@ -1,68 +0,0 @@ |
||||
import request from "@/api/request" |
||||
import {getToken} from "@/api/file" |
||||
import {file as fileConfig} from '@/config' |
||||
import {isEmpty, timeFormat} from "@/util" |
||||
import {isTxt} from "@/util/validate" |
||||
|
||||
const defaultOptions = { |
||||
headers: {"Content-Type": "multipart/form-data"}, |
||||
generateKey(filename) { |
||||
return timeFormat('yyyy/MM/dd/') + Date.now() + '/' + filename |
||||
}, |
||||
onUploadProgress(e) { |
||||
|
||||
} |
||||
} |
||||
|
||||
//文件预览
|
||||
export function preview(url) { |
||||
if (isTxt(url)) return window.open(url) |
||||
|
||||
const connectChar = url.includes('?') ? '&' : '?' |
||||
url = url + connectChar + 'fullfilename=' + url.replace(fileConfig.storePrefix, '') |
||||
const anchor = document.createElement('a') |
||||
anchor.style.opacity = '0' |
||||
anchor.href = `${fileConfig.previewPrefix}/onlinePreview?url=${encodeURIComponent(url)}` |
||||
anchor.target = '_blank' |
||||
document.body.appendChild(anchor) |
||||
anchor.click() |
||||
document.body.removeChild(anchor) |
||||
} |
||||
|
||||
//下载文件
|
||||
export function download(url, name) { |
||||
const href = typeof url === 'object' ? window.URL.createObjectURL(url) : url |
||||
const anchor = document.createElement('a') |
||||
|
||||
anchor.style.opacity = '0' |
||||
anchor.href = href |
||||
anchor.download = name |
||||
document.body.appendChild(anchor) |
||||
anchor.click() |
||||
document.body.removeChild(anchor) |
||||
|
||||
if (typeof url === 'object') window.URL.revokeObjectURL(url) |
||||
} |
||||
|
||||
//七牛云直传
|
||||
export function upload(blob, filename, options = {}) { |
||||
return getToken |
||||
.request() |
||||
.then(token => { |
||||
if (!options.generateKey) { |
||||
options.generateKey = defaultOptions.generateKey |
||||
} |
||||
const param = new FormData() |
||||
param.append('token', token) |
||||
param.append('key', options.generateKey(filename)) |
||||
param.append('file', blob, filename) |
||||
return request.post(fileConfig.uploadUrl, param, {...defaultOptions, ...options}) |
||||
}) |
||||
} |
||||
|
||||
//自动补全附件链接前缀
|
||||
export function autoCompleteUrl(url) { |
||||
if (isEmpty(url)) return '' |
||||
if (url.startsWith('http')) return url |
||||
return fileConfig.storePrefix + url |
||||
} |
||||
@ -1,56 +0,0 @@ |
||||
/*基于decimal.js的运算封装*/ |
||||
import Decimal from 'decimal.js' |
||||
|
||||
/** |
||||
* 依次相加 |
||||
* |
||||
* @param val {number} |
||||
* @return {number} |
||||
*/ |
||||
export function plus(...val) { |
||||
const result = val.reduce((total, cur) => total.add(new Decimal(cur)), new Decimal(0)) |
||||
return result.toNumber() |
||||
} |
||||
|
||||
/** |
||||
* 依次相减 |
||||
* |
||||
* @param val {number} |
||||
* @return {number} |
||||
*/ |
||||
export function sub(...val) { |
||||
if (val.length === 0) return 0 |
||||
|
||||
let result = new Decimal(val[0]) |
||||
for (let i = 1; i < val.length; i++) { |
||||
result = result.sub(new Decimal(val[i])) |
||||
} |
||||
return result.toNumber() |
||||
} |
||||
|
||||
/** |
||||
* 依次相乘 |
||||
* |
||||
* @param val {number} |
||||
* @return {number} |
||||
*/ |
||||
export function mul(...val) { |
||||
const result = val.reduce((total, cur) => total.mul(new Decimal(cur)), new Decimal(1)) |
||||
return result.toNumber() |
||||
} |
||||
|
||||
/** |
||||
* 依次相除 |
||||
* |
||||
* @param val {number} |
||||
* @return {number} |
||||
*/ |
||||
export function div(...val) { |
||||
if (val.length === 0) return 0 |
||||
|
||||
let f = new Decimal(val[0]) |
||||
for (let i = 1; i < val.length; i++) { |
||||
f = f.div(new Decimal(val[i])) |
||||
} |
||||
return f.toNumber() |
||||
} |
||||
@ -1,94 +0,0 @@ |
||||
<script type="jsx"> |
||||
import FormAnchor from "@/component/form/Anchor" |
||||
import FormValidateInfo from "@/component/form/ValidateInfo" |
||||
|
||||
const renderHeader = (h, title, description, extra, close) => ( |
||||
<div class="detail-page-header"> |
||||
<el-page-header on-back={close} content={title}/> |
||||
|
||||
<el-row> |
||||
<el-row class="detail-page-header-left"> |
||||
{description.map(({label, content}) => ( |
||||
<el-col key={label} xs={24} sm={12}> |
||||
<span class="detail-page-header-description-label">{label}</span> |
||||
<span class="detail-page-header-description-content">{content}</span> |
||||
</el-col> |
||||
))} |
||||
</el-row> |
||||
|
||||
<div class="detail-page-header-extra"> |
||||
<div> |
||||
{extra.map(({title, content}) => ( |
||||
<div key={title}> |
||||
<div class="detail-page-header-extra-title">{title}</div> |
||||
<div class="detail-page-header-extra-content">{content}</div> |
||||
</div> |
||||
))} |
||||
</div> |
||||
</div> |
||||
</el-row> |
||||
</div> |
||||
) |
||||
|
||||
const renderFooter = (h, form, buttons, close) => ( |
||||
<div class="detail-page-footer"> |
||||
<div class="detail-page-footer-left"> |
||||
<slot name="left"/> |
||||
</div> |
||||
<div class="detail-page-footer-right"> |
||||
<FormValidateInfo form={form}/> |
||||
{renderButtons(h, buttons, close)} |
||||
</div> |
||||
</div> |
||||
) |
||||
|
||||
const renderButtons = (h, buttons, close) => { |
||||
const list = buttons.map(({type, content, e}) => |
||||
<el-button size="small" type={type} on-click={e}>{content}</el-button> |
||||
) |
||||
list.unshift(<el-button plain size="small" on-click={close}>关 闭</el-button>) |
||||
return list |
||||
} |
||||
|
||||
export default { |
||||
name: "DetailPage", |
||||
|
||||
functional: true, |
||||
|
||||
props: { |
||||
loading: Boolean, |
||||
reference: Function, |
||||
anchors: {type: Array, default: () => []}, |
||||
title: String, |
||||
description: {type: Array, default: () => []}, |
||||
extra: {type: Array, default: () => []}, |
||||
buttons: {type: Array, default: () => []}, |
||||
}, |
||||
|
||||
render(h, context) { |
||||
const { |
||||
props: {loading, reference, anchors, title, description, extra, buttons}, |
||||
listeners: {close}, |
||||
children |
||||
} = context |
||||
|
||||
const getFormRef = () => { |
||||
if (!reference) return null |
||||
const ref = reference() |
||||
if (!ref) return null |
||||
return ref.form |
||||
} |
||||
|
||||
return ( |
||||
<div v-loading={loading} class="detail-page"> |
||||
{renderHeader(h, title, description, extra, close)} |
||||
<FormAnchor reference={reference} data={anchors}/> |
||||
{children} |
||||
{renderFooter(h, getFormRef, buttons, close)} |
||||
</div> |
||||
) |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
<style lang="scss" src="./style.scss"></style> |
||||
@ -1,125 +0,0 @@ |
||||
@import "~@/asset/style/variables.scss"; |
||||
|
||||
.detail-page { |
||||
&.el-loading-parent--relative { |
||||
position: static !important; |
||||
} |
||||
|
||||
&-header { |
||||
margin: -#{$page-view-margin} -#{$page-view-margin} 0 -#{$page-view-margin}; |
||||
padding: 16px 32px 0 32px; |
||||
background: #ffffff; |
||||
border-bottom: 1px solid $border-color-light; |
||||
|
||||
> .el-page-header { |
||||
padding: 0 0 24px 0; |
||||
|
||||
.el-page-header__left:hover { |
||||
color: $--color-primary; |
||||
} |
||||
} |
||||
|
||||
> .el-row { |
||||
display: flex; |
||||
width: 100%; |
||||
margin-bottom: 12px; |
||||
|
||||
@include mobile { |
||||
display: block; |
||||
} |
||||
} |
||||
|
||||
&-left { |
||||
width: 100%; |
||||
overflow: hidden; |
||||
|
||||
.el-col { |
||||
margin-bottom: 16px; |
||||
} |
||||
} |
||||
|
||||
&-extra { |
||||
min-width: 242px; |
||||
margin-left: 88px; |
||||
|
||||
@media (max-width: 1200px) { |
||||
margin-left: 44px; |
||||
} |
||||
|
||||
@media (max-width: 992px) { |
||||
margin-left: 20px; |
||||
} |
||||
|
||||
@media (max-width: 768px) { |
||||
margin-left: 0; |
||||
} |
||||
|
||||
> div { |
||||
display: flex; |
||||
justify-content: space-between; |
||||
width: 200px; |
||||
} |
||||
|
||||
&-title { |
||||
margin-bottom: 4px; |
||||
color: rgba(0, 0, 0, .45); |
||||
font-size: 14px; |
||||
} |
||||
|
||||
&-content { |
||||
margin-top: 12px; |
||||
color: rgba(0, 0, 0, .85); |
||||
font-size: 24px; |
||||
} |
||||
} |
||||
|
||||
&-description { |
||||
span { |
||||
display: inline-flex; |
||||
align-items: baseline; |
||||
} |
||||
|
||||
&-label { |
||||
color: rgba(0, 0, 0, .85); |
||||
font-weight: 400; |
||||
font-size: 14px; |
||||
line-height: 1.5715; |
||||
text-align: start; |
||||
} |
||||
|
||||
&-content { |
||||
color: rgba(0, 0, 0, .65); |
||||
font-size: 14px; |
||||
line-height: 1.5715; |
||||
} |
||||
} |
||||
} |
||||
|
||||
> .el-form > .el-row > .el-card { |
||||
margin-top: 16px; |
||||
} |
||||
|
||||
&-footer { |
||||
@include clearfix; |
||||
position: absolute; |
||||
width: 100%; |
||||
right: 0; |
||||
bottom: 0; |
||||
z-index: 1; |
||||
transition: all 0.3s ease 0s; |
||||
height: 56px; |
||||
padding: 0 24px; |
||||
line-height: 56px; |
||||
border-top: 1px solid #f0f0f0; |
||||
box-shadow: 0 3px 6px -4px rgba(0, 0, 0, .12), 0 6px 16px 0 rgba(0, 0, 0, .08), 0 9px 28px 8px rgba(0, 0, 0, .05); |
||||
backdrop-filter: blur(3px); |
||||
|
||||
&-left { |
||||
float: left; |
||||
} |
||||
|
||||
&-right { |
||||
float: right; |
||||
} |
||||
} |
||||
} |
||||
@ -1,55 +0,0 @@ |
||||
/** |
||||
* 列表页自适应混入 |
||||
* 监听el-table,动态设置max-height属性 |
||||
*/ |
||||
|
||||
import cssVariables from "@/asset/style/variables.scss" |
||||
import {debounce} from "@/util" |
||||
|
||||
export default { |
||||
data() { |
||||
return { |
||||
tableMaxHeight: undefined, |
||||
desiredDistance: undefined |
||||
} |
||||
}, |
||||
|
||||
methods: { |
||||
//设置表格距离视窗底部的理想距离(分页高度 + el-card的1px边距 + el-card padding + 页面margin)
|
||||
calcDesiredDistance() { |
||||
const pageViewMargin = parseFloat(cssVariables.pageViewMargin) |
||||
const paginationHeight = this.$el.querySelector('.table-container > .el-pagination') ? 50 : 0 |
||||
this.desiredDistance = paginationHeight + 1 + 20 + pageViewMargin |
||||
}, |
||||
|
||||
resize() { |
||||
const clientHeight = window.innerHeight |
||||
|
||||
//计算表格底部距离视窗底部的距离
|
||||
const tableRect = this.$refs.table.$el.getBoundingClientRect() |
||||
const distance = clientHeight - tableRect.top - tableRect.height |
||||
|
||||
if (this.desiredDistance === undefined) { |
||||
this.calcDesiredDistance() |
||||
} |
||||
|
||||
const overHeight = this.desiredDistance - distance |
||||
this.tableMaxHeight = tableRect.height - overHeight |
||||
}, |
||||
createResizeObserver() { |
||||
if (this.resizeObserver) return |
||||
|
||||
this.resizeObserver = new ResizeObserver(this.resize) |
||||
this.resizeObserver.observe(this.$el) |
||||
|
||||
this.$once('hook:beforeDestroy', () => { |
||||
this.resizeObserver && this.resizeObserver.disconnect() |
||||
}) |
||||
}, |
||||
}, |
||||
|
||||
mounted() { |
||||
this.resize = debounce(this.resize, 300) |
||||
this.createResizeObserver() |
||||
} |
||||
} |
||||
@ -1,73 +0,0 @@ |
||||
<script type="text/jsx"> |
||||
import AbstractTable from "@/component/abstract/Table" |
||||
import AbstractPagination from "@/component/abstract/Pagination" |
||||
import SearchForm from "@/component/form/Search" |
||||
import autoAdaptHeightMixin from "./autoAdaptHeightMixin" |
||||
import {isEmpty} from "@/util" |
||||
|
||||
export default { |
||||
name: "ListPage", |
||||
|
||||
mixins: [autoAdaptHeightMixin], |
||||
|
||||
components: {AbstractTable, AbstractPagination, SearchForm}, |
||||
|
||||
props: {data: Object}, |
||||
|
||||
methods: { |
||||
renderSearchForm(config, slot) { |
||||
return slot && <search-form {...config}>{slot()}</search-form> |
||||
}, |
||||
renderButtons(buttons) { |
||||
if (buttons.length > 0 && isEmpty(buttons[0].type)) { |
||||
buttons[0].type = 'primary' |
||||
} |
||||
return ( |
||||
<el-row class="button-group"> |
||||
{buttons.map(button => { |
||||
if (!button) return |
||||
const {icon, type, content, e} = button |
||||
return ( |
||||
<el-button |
||||
icon={icon} |
||||
size="small" |
||||
plain={isEmpty(type)} |
||||
type={type || 'dashed'} |
||||
on-click={e} |
||||
> |
||||
{content} |
||||
</el-button> |
||||
) |
||||
})} |
||||
</el-row> |
||||
) |
||||
}, |
||||
renderTableAndPagination({loading, table, pagination}) { |
||||
return ( |
||||
<el-row v-loading={loading} class="table-container"> |
||||
<abstract-table max-height={this.tableMaxHeight} {...table}/> |
||||
<abstract-pagination {...pagination}/> |
||||
</el-row> |
||||
) |
||||
} |
||||
}, |
||||
|
||||
render() { |
||||
const {data: {pageLoading, buttons, dataLoading, search, table, pagination}, $scopedSlots} = this |
||||
|
||||
if (!table.scopedSlots) { |
||||
table.scopedSlots = {} |
||||
} |
||||
table.scopedSlots.default = $scopedSlots.tableColumn |
||||
|
||||
return ( |
||||
<el-card v-loading={pageLoading} class="max-view-height"> |
||||
{this.renderSearchForm(search, $scopedSlots.searchForm)} |
||||
{this.renderButtons(buttons)} |
||||
{this.renderTableAndPagination({loading: dataLoading, table, pagination})} |
||||
{$scopedSlots.default && $scopedSlots.default()} |
||||
</el-card> |
||||
) |
||||
} |
||||
} |
||||
</script> |
||||
@ -1,89 +0,0 @@ |
||||
<template> |
||||
<div :style="{height,width}"/> |
||||
</template> |
||||
|
||||
<script> |
||||
import {logic, resize} from "@/mixin/chart" |
||||
import {getDailyFinishOrder} from '@/api/statistic' |
||||
import {timeFormat} from "@/util" |
||||
|
||||
export default { |
||||
name: "DailyFinishOrderStat", |
||||
|
||||
mixins: [resize, logic], |
||||
|
||||
methods: { |
||||
init() { |
||||
if (this.loading) return |
||||
this.loading = true |
||||
getDailyFinishOrder |
||||
.request() |
||||
.then(({data}) => { |
||||
const time = [] |
||||
const purchase = [] |
||||
const sell = [] |
||||
data.forEach(i => { |
||||
time.push(timeFormat('MM 月 dd日', new Date(i.time))) |
||||
purchase.push(i.purchase) |
||||
sell.push(i.sell) |
||||
}) |
||||
this.setOptions({time, purchase, sell}) |
||||
}) |
||||
.finally(() => this.loading = false) |
||||
}, |
||||
|
||||
setOptions({time, purchase, sell}) { |
||||
this.chart.setOption({ |
||||
title: { |
||||
text: '历史七天订单完成情况统计', |
||||
left: 'center', |
||||
align: 'right' |
||||
}, |
||||
xAxis: { |
||||
data: time, |
||||
axisTick: { |
||||
show: false |
||||
} |
||||
}, |
||||
grid: { |
||||
left: 10, |
||||
right: 10, |
||||
bottom: 20, |
||||
top: 30, |
||||
containLabel: true |
||||
}, |
||||
tooltip: { |
||||
trigger: 'axis', |
||||
axisPointer: { |
||||
type: 'cross' |
||||
}, |
||||
padding: [5, 10] |
||||
}, |
||||
yAxis: { |
||||
axisTick: { |
||||
show: false |
||||
} |
||||
}, |
||||
legend: { |
||||
data: ['采购订单', '销售订单'], |
||||
right: 10 |
||||
}, |
||||
series: [ |
||||
{ |
||||
name: '采购订单', |
||||
smooth: true, |
||||
type: 'line', |
||||
data: purchase |
||||
}, |
||||
{ |
||||
name: '销售订单', |
||||
smooth: true, |
||||
type: 'line', |
||||
data: sell |
||||
} |
||||
] |
||||
}) |
||||
} |
||||
} |
||||
} |
||||
</script> |
||||
@ -1,97 +0,0 @@ |
||||
<template> |
||||
<div :style="{height,width}"/> |
||||
</template> |
||||
|
||||
<script> |
||||
import {logic, resize} from "@/mixin/chart" |
||||
import {getDailyProfitStat} from '@/api/statistic' |
||||
import {timeFormat} from "@/util" |
||||
|
||||
export default { |
||||
name: "DailyProfitStat", |
||||
|
||||
mixins: [resize, logic], |
||||
|
||||
methods: { |
||||
init() { |
||||
if (this.loading) return |
||||
this.loading = true |
||||
getDailyProfitStat |
||||
.request() |
||||
.then(({data}) => { |
||||
const time = [] |
||||
const purchase = [] |
||||
const sell = [] |
||||
const profit = [] |
||||
data.forEach(i => { |
||||
time.push(timeFormat('MM 月 dd日', new Date(i.time))) |
||||
purchase.push(i.purchase) |
||||
sell.push(i.sell) |
||||
profit.push(i.profit) |
||||
}) |
||||
this.setOptions({time, purchase, sell, profit}) |
||||
}) |
||||
.finally(() => this.loading = false) |
||||
}, |
||||
|
||||
setOptions({time, purchase, sell, profit}) { |
||||
this.chart.setOption({ |
||||
title: { |
||||
text: '历史七天利润情况统计', |
||||
left: 'center', |
||||
align: 'right' |
||||
}, |
||||
xAxis: { |
||||
data: time, |
||||
axisTick: { |
||||
show: false |
||||
} |
||||
}, |
||||
grid: { |
||||
left: 10, |
||||
right: 10, |
||||
bottom: 20, |
||||
top: 30, |
||||
containLabel: true |
||||
}, |
||||
tooltip: { |
||||
trigger: 'axis', |
||||
axisPointer: { |
||||
type: 'cross' |
||||
}, |
||||
padding: [5, 10] |
||||
}, |
||||
yAxis: { |
||||
axisTick: { |
||||
show: false |
||||
} |
||||
}, |
||||
legend: { |
||||
data: ['采购额', '销售额', '毛利润'], |
||||
right: 10 |
||||
}, |
||||
series: [ |
||||
{ |
||||
name: '采购额', |
||||
smooth: true, |
||||
type: 'line', |
||||
data: purchase |
||||
}, |
||||
{ |
||||
name: '销售额', |
||||
smooth: true, |
||||
type: 'line', |
||||
data: sell |
||||
}, |
||||
{ |
||||
name: '毛利润', |
||||
smooth: true, |
||||
type: 'line', |
||||
data: profit |
||||
} |
||||
] |
||||
}) |
||||
} |
||||
} |
||||
} |
||||
</script> |
||||
@ -1,99 +0,0 @@ |
||||
<script type="text/jsx"> |
||||
import CountTo from 'vue-count-to' |
||||
|
||||
export default { |
||||
name: "PanelGroupItem", |
||||
|
||||
functional: true, |
||||
|
||||
props: { |
||||
icon: String, |
||||
color: String, |
||||
text: String, |
||||
value: Number |
||||
}, |
||||
|
||||
render(h, context) { |
||||
const {icon, color, text, value} = context.props |
||||
return ( |
||||
<el-row class="card-panel" type="flex" justify="space-between" {...context.data}> |
||||
<div class="card-panel-icon-wrapper" style={'color:' + color}> |
||||
<v-icon icon={icon} class="card-panel-icon"/> |
||||
</div> |
||||
<div class="card-panel-description"> |
||||
<div class="card-panel-text">{text}</div> |
||||
<CountTo class="card-panel-num" end-val={value} start-val={0}/> |
||||
</div> |
||||
</el-row> |
||||
) |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
<style lang="scss"> |
||||
@import "~@/asset/style/variables.scss"; |
||||
|
||||
.card-panel { |
||||
height: 108px; |
||||
cursor: pointer; |
||||
font-size: 12px; |
||||
position: relative; |
||||
overflow: hidden; |
||||
color: #666; |
||||
background: #ffffff; |
||||
border-radius: 4px; |
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, .1); |
||||
border-color: rgba(0, 0, 0, .05); |
||||
|
||||
&:hover .card-panel-icon-wrapper { |
||||
color: #ffffff; |
||||
} |
||||
|
||||
.card-panel-icon-wrapper { |
||||
margin: 14px 0 14px 14px; |
||||
padding: 16px; |
||||
transition: all 0.38s ease-out; |
||||
border-radius: 6px; |
||||
} |
||||
|
||||
.card-panel-icon { |
||||
font-size: 48px; |
||||
} |
||||
|
||||
.card-panel-description { |
||||
font-weight: bold; |
||||
margin: 26px 20px 0 26px; |
||||
|
||||
.card-panel-text { |
||||
line-height: 18px; |
||||
color: rgba(0, 0, 0, 0.45); |
||||
font-size: 16px; |
||||
margin-bottom: 12px; |
||||
} |
||||
|
||||
.card-panel-num { |
||||
font-size: 20px; |
||||
} |
||||
} |
||||
} |
||||
|
||||
@include mobile { |
||||
.card-panel-description { |
||||
display: none; |
||||
} |
||||
|
||||
.card-panel-icon-wrapper { |
||||
float: none !important; |
||||
width: 100%; |
||||
height: 100%; |
||||
margin: 0 !important; |
||||
|
||||
.icon { |
||||
display: block; |
||||
margin: 14px auto !important; |
||||
float: none !important; |
||||
} |
||||
} |
||||
} |
||||
|
||||
</style> |
||||
@ -1,89 +0,0 @@ |
||||
<template> |
||||
<el-row :gutter="40" class="panel-group"> |
||||
<el-col v-for="i in list" :key="i.id" :xs="12" :sm="12" :lg="6"> |
||||
<panel-group-item v-bind="i" @click.native="jump(i)"/> |
||||
</el-col> |
||||
</el-row> |
||||
</template> |
||||
|
||||
<script> |
||||
import PanelGroupItem from './PanelGroupItem' |
||||
import cssVariables from '@/asset/style/variables.scss' |
||||
import {getFourBlock} from '@/api/statistic' |
||||
import {isEmpty} from "@/util" |
||||
import {auth} from "@/util/auth" |
||||
|
||||
export default { |
||||
name: 'panelGroup', |
||||
|
||||
components: {PanelGroupItem}, |
||||
|
||||
data() { |
||||
return { |
||||
loading: false, |
||||
list: [ |
||||
{ |
||||
id: 'online', |
||||
path: '/system/user', |
||||
icon: 'svg-user', |
||||
color: cssVariables.info, |
||||
value: 0, |
||||
text: '在线用户' |
||||
}, |
||||
{ |
||||
id: 'purchase', |
||||
path: '/purchase/order', |
||||
icon: 'svg-shopping', |
||||
color: cssVariables.primary, |
||||
value: 0, |
||||
text: '今日采购额' |
||||
}, |
||||
{ |
||||
id: 'sell', |
||||
path: '/sell/order', |
||||
icon: 'svg-sell', |
||||
color: cssVariables.warning, |
||||
value: 0, |
||||
text: '今日销售额' |
||||
}, |
||||
{ |
||||
id: 'profit', |
||||
icon: 'svg-money', |
||||
color: cssVariables.success, |
||||
value: 0, |
||||
text: '今日毛利润' |
||||
}, |
||||
] |
||||
} |
||||
}, |
||||
|
||||
methods: { |
||||
init() { |
||||
if (this.loading) return |
||||
this.loading = true |
||||
getFourBlock |
||||
.request() |
||||
.then(({data}) => { |
||||
this.list.forEach(i => { |
||||
if (i.id in data) i.value = data[i.id] |
||||
}) |
||||
}) |
||||
.finally(() => this.loading = false) |
||||
}, |
||||
|
||||
jump({path}) { |
||||
if (!isEmpty(path) && auth(path)) this.$router.push(path) |
||||
} |
||||
}, |
||||
|
||||
mounted() { |
||||
this.init() |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
<style> |
||||
.panel-group > .el-col { |
||||
margin-bottom: 32px; |
||||
} |
||||
</style> |
||||
@ -1,73 +0,0 @@ |
||||
<template> |
||||
<el-row :gutter="32"> |
||||
<el-col v-for="i in ['purchase','sell','profit']" :key="i" :lg="8" :sm="24"> |
||||
<el-card style="margin-bottom: 32px;"> |
||||
<pie-chart :title="getTitle(i)" :data="getData(i)"/> |
||||
</el-card> |
||||
</el-col> |
||||
</el-row> |
||||
</template> |
||||
|
||||
<script> |
||||
import PieChart from "@/component/Charts/PieChart" |
||||
import {getTotalProfitGoods} from '@/api/statistic' |
||||
|
||||
export default { |
||||
name: "TotalProfitGoods", |
||||
|
||||
components: {PieChart}, |
||||
|
||||
data() { |
||||
return { |
||||
loading: false, |
||||
purchase: { |
||||
title: '各个商品总采购额', |
||||
data: [] |
||||
}, |
||||
sell: { |
||||
title: '各个商品总销售额', |
||||
data: [] |
||||
}, |
||||
profit: { |
||||
title: '各个商品总毛利润', |
||||
data: [] |
||||
} |
||||
} |
||||
}, |
||||
|
||||
methods: { |
||||
getTitle(type) { |
||||
return this[type].title |
||||
}, |
||||
|
||||
getData(type) { |
||||
return this[type].data |
||||
}, |
||||
|
||||
init() { |
||||
if (this.loading) return |
||||
this.loading = true |
||||
getTotalProfitGoods |
||||
.request() |
||||
.then(({data}) => { |
||||
const purchase = [] |
||||
const sell = [] |
||||
const profit = [] |
||||
data.forEach(i => { |
||||
if (i.purchase) purchase.push({name: i.cname, value: i.purchase}) |
||||
if (i.sell) sell.push({name: i.cname, value: i.sell}) |
||||
if (i.profit) profit.push({name: i.cname, value: i.profit}) |
||||
}) |
||||
this.purchase.data = purchase |
||||
this.sell.data = sell |
||||
this.profit.data = profit |
||||
}) |
||||
.finally(() => this.loading = false) |
||||
} |
||||
}, |
||||
|
||||
mounted() { |
||||
this.init() |
||||
} |
||||
} |
||||
</script> |
||||
@ -1,30 +1,9 @@ |
||||
<script type="text/jsx"> |
||||
import PanelGroup from './component/PanelGroup' |
||||
import DailyProfitStat from "./component/DailyProfitStat" |
||||
import DailyFinishOrderStat from "./component/DailyFinishOrderStat" |
||||
import TotalProfitGoods from "./component/TotalProfitGoods" |
||||
<template functional> |
||||
<div>我是首页</div> |
||||
</template> |
||||
|
||||
<script> |
||||
export default { |
||||
name: 'index', |
||||
|
||||
functional: true, |
||||
|
||||
render() { |
||||
return ( |
||||
<div> |
||||
<PanelGroup/> |
||||
|
||||
<el-card> |
||||
<DailyProfitStat/> |
||||
</el-card> |
||||
|
||||
<el-card style="margin: 32px 0"> |
||||
<DailyFinishOrderStat/> |
||||
</el-card> |
||||
|
||||
<TotalProfitGoods/> |
||||
</div> |
||||
) |
||||
} |
||||
name: 'index' |
||||
} |
||||
</script> |
||||
|
||||
@ -1,234 +0,0 @@ |
||||
<template> |
||||
<abstract-dialog :loading="loading" :title="title" :value="value" width="50%" @close="cancel" @open="open"> |
||||
<abstract-form :model="form" :rules="rules"> |
||||
<abstract-form-item label="标 题" prop="title" thin> |
||||
<el-input v-model="form.title" maxlength="100" :readonly="!canSave"/> |
||||
</abstract-form-item> |
||||
<abstract-form-item label="消息类型" prop="type" thin> |
||||
<el-select v-model="form.type" :disabled="!canSave"> |
||||
<el-option :value="0" label="通知提醒"/> |
||||
<el-option :value="1" label="系统公告"/> |
||||
</el-select> |
||||
</abstract-form-item> |
||||
<abstract-form-item v-if="type!=='add'" label="创建人" thin> |
||||
<el-input :value="form.cname" readonly/> |
||||
</abstract-form-item> |
||||
<abstract-form-item v-if="type!=='add'" label="创建时间" thin> |
||||
<el-date-picker :value="form.ctime" format="yyyy-MM-dd HH:mm:ss" readonly type="date"/> |
||||
</abstract-form-item> |
||||
<abstract-form-item label="通知对象" prop="broadcast" thin> |
||||
<el-select v-model="form.broadcast" :disabled="!canSave"> |
||||
<el-option :value="false" label="指定用户"/> |
||||
<el-option :value="true" label="全体用户"/> |
||||
</el-select> |
||||
</abstract-form-item> |
||||
<abstract-form-item v-if="!form.broadcast" label="选择用户" prop="recipient" thin> |
||||
<user-selector v-model="form.recipient" :disabled="!canSave"/> |
||||
</abstract-form-item> |
||||
<abstract-form-item v-if="form.pname" label="发布人" thin> |
||||
<el-input :value="form.pname" readonly/> |
||||
</abstract-form-item> |
||||
<abstract-form-item v-if="form.ptime" label="发布时间" thin> |
||||
<el-date-picker :value="form.ptime" format="yyyy-MM-dd HH:mm:ss" readonly type="date"/> |
||||
</abstract-form-item> |
||||
<abstract-form-item v-if="form.status===2" label="撤回人" thin> |
||||
<el-input :value="form.wname" readonly/> |
||||
</abstract-form-item> |
||||
<abstract-form-item v-if="form.status===2" label="撤回时间" thin> |
||||
<el-date-picker :value="form.wtime" format="yyyy-MM-dd HH:mm:ss" readonly type="date"/> |
||||
</abstract-form-item> |
||||
<abstract-form-item label="内 容" full> |
||||
<rich-text-editor v-model="form.content" :readonly="!canSave"/> |
||||
</abstract-form-item> |
||||
</abstract-form> |
||||
|
||||
<template v-slot:footer> |
||||
<el-button plain size="small" @click="closeDialog">取 消</el-button> |
||||
<el-button v-if="canSave" size="small" type="primary" @click="save">保 存</el-button> |
||||
<el-button v-if="canPublish" size="small" type="primary" @click="publish">发 布</el-button> |
||||
<el-button v-if="canWithdraw" size="small" type="danger" @click="withdraw">撤 回</el-button> |
||||
</template> |
||||
</abstract-dialog> |
||||
</template> |
||||
|
||||
<script> |
||||
import dialogMixin from "@/mixin/dialogMixin" |
||||
import AbstractForm from "@/component/abstract/Form" |
||||
import AbstractFormItem from "@/component/abstract/Form/item" |
||||
import AbstractDialog from '@/component/abstract/Dialog' |
||||
import RichTextEditor from "@/component/editor/RichTextEditor" |
||||
import UserSelector from '@/component/biz/UserSelector/SimpleMultipleUserSelector' |
||||
import {add, update, publish, withdraw} from "@/api/message/manage" |
||||
import {isEmpty, mergeObj, resetObj} from '@/util' |
||||
import {auth} from "@/util/auth" |
||||
import {elAlert, elConfirm, elSuccess} from "@/util/message" |
||||
|
||||
export default { |
||||
name: "EditDialog", |
||||
|
||||
mixins: [dialogMixin], |
||||
|
||||
components: {AbstractForm, AbstractFormItem, AbstractDialog, RichTextEditor, UserSelector}, |
||||
|
||||
props: { |
||||
value: Boolean, |
||||
type: {type: String, default: 'see'}, |
||||
data: {type: Object, default: () => ({})} |
||||
}, |
||||
|
||||
data() { |
||||
return { |
||||
loading: false, |
||||
needSearch: false, |
||||
form: { |
||||
id: null, |
||||
title: null, |
||||
resume: null, |
||||
content: null, |
||||
type: 0, |
||||
cname: null, |
||||
ctime: null, |
||||
pname: null, |
||||
ptime: null, |
||||
wname: null, |
||||
wtime: null, |
||||
status: 0, |
||||
broadcast: false, |
||||
recipient: [] |
||||
}, |
||||
rules: { |
||||
title: [{required: true, message: '请输入标题', trigger: 'change'}], |
||||
type: [{required: true, message: '请选择消息类型', trigger: 'change'}], |
||||
broadcast: [{required: true, message: '请选择通知对象', trigger: 'change'}] |
||||
} |
||||
} |
||||
}, |
||||
|
||||
computed: { |
||||
title() { |
||||
if (isEmpty(this.type)) return '' |
||||
switch (this.type) { |
||||
case 'see': |
||||
return '查看消息' |
||||
case 'add': |
||||
return '添加消息' |
||||
case 'edit': |
||||
return '编辑消息' |
||||
} |
||||
}, |
||||
|
||||
canSave() { |
||||
//add模式有添加权限、edit模式有编辑权限且status=0 |
||||
return this.type === 'add' && auth(add.url) |
||||
|| this.form.status === 0 && this.type === 'edit' && auth(update.url) |
||||
}, |
||||
canPublish() { |
||||
//有发布权限、add模式或edit模式且status=0 |
||||
return auth(publish.url) && (this.type === 'add' || this.type === 'edit' && this.form.status === 0) |
||||
}, |
||||
canWithdraw() { |
||||
//有撤回权限、edit模式且status=1 |
||||
return auth(withdraw.url) |
||||
&& this.type === 'edit' |
||||
&& this.form.status === 1 |
||||
}, |
||||
}, |
||||
|
||||
methods: { |
||||
open() { |
||||
if (this.type === 'add') return |
||||
const recipient = (this.data.recipient || '').split(',').filter(Boolean).map(i => parseInt(i)) |
||||
mergeObj(this.form, {...this.data, recipient}) |
||||
}, |
||||
|
||||
clearForm() { |
||||
resetObj(this.form) |
||||
this.needSearch = false |
||||
this.loading = false |
||||
this.$nextTick(() => this.$refs.form.clearValidate()) |
||||
}, |
||||
|
||||
cancel() { |
||||
this.closeDialog() |
||||
this.needSearch && this.$emit('search') |
||||
|
||||
window.setTimeout(() => this.clearForm(), 200) |
||||
this.loading = false |
||||
}, |
||||
|
||||
save() { |
||||
if (this.loading) return |
||||
this.$refs.form.validate(v => { |
||||
if (!v) return |
||||
this.loading = true |
||||
const data = this.transformForm() |
||||
const promise = this.type === 'add' ? add.request(data) : update.request(data) |
||||
promise |
||||
.then(({data, msg}) => { |
||||
this.needSearch = true |
||||
elSuccess(msg) |
||||
if (this.type === 'add') { |
||||
this.form.id = data.id |
||||
this.form.cname = data.cname |
||||
this.form.ctime = data.ctime |
||||
} |
||||
this.$emit('update:type', 'edit') |
||||
}) |
||||
.finally(() => this.loading = false) |
||||
}) |
||||
}, |
||||
|
||||
publish() { |
||||
if (this.loading) return |
||||
this.$refs.form.validate(v => { |
||||
if (!v) return |
||||
if (this.validate) { |
||||
let valid = this.validate() |
||||
if (!isEmpty(valid)) return elAlert(valid) |
||||
} |
||||
elConfirm('确认发布?') |
||||
.then(() => this.loading = true) |
||||
.then(() => publish.request(this.transformForm())) |
||||
.then(({data, msg}) => { |
||||
elSuccess(msg) |
||||
this.needSearch = true |
||||
|
||||
this.form.id = data.id |
||||
this.form.cname = data.cname |
||||
this.form.ctime = data.ctime |
||||
this.form.pid = data.pid |
||||
this.form.pname = data.pname |
||||
this.form.ptime = data.ptime |
||||
this.form.status = data.status |
||||
|
||||
this.$emit('update:type', 'edit') |
||||
}) |
||||
.finally(() => this.loading = false) |
||||
}) |
||||
}, |
||||
|
||||
withdraw() { |
||||
if (this.loading) return |
||||
elConfirm('确认撤回?') |
||||
.then(() => { |
||||
this.loading = true |
||||
return withdraw.request({id: this.form.id, title: this.form.title}) |
||||
}) |
||||
.then(({msg}) => { |
||||
elSuccess(msg) |
||||
this.needSearch = true |
||||
this.closeDialog() |
||||
}) |
||||
.finally(() => this.loading = false) |
||||
}, |
||||
|
||||
transformForm() { |
||||
const recipient = |
||||
Array.isArray(this.form.recipient) && this.form.recipient.length > 0 |
||||
? this.form.recipient.join(',') |
||||
: null |
||||
return {...this.form, recipient} |
||||
} |
||||
} |
||||
} |
||||
</script> |
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue