This commit is contained in:
张成
2025-10-08 15:10:33 +08:00
commit 2e1cd65b07
161 changed files with 19936 additions and 0 deletions

View File

@@ -0,0 +1,49 @@
<template>
<Modal v-model="showModal" :width="width||80" :title="title" v-bind="$attrs">
<div class="asyncModal-box">
<slot></slot>
</div>
<div slot="footer">
<Button @click="hide">取消</Button>
<Button class="ml30" type="primary" @click="saveInfo">确定</Button>
</div>
</Modal>
</template>
<script>
export default {
props: ['title', 'width'],
data() {
return {
showModal: false,
callback: null
}
},
methods: {
show(callback) {
this.showModal = true
this.callback = callback
},
hide() {
this.showModal = false
},
async saveInfo() {
if (this.callback) {
await this.callback()
}
this.$emit('on-ok')
this.hide()
}
}
}
</script>
<style lang="less" scoped>
.asyncModal-box {
max-height: 90vh;
min-height: 20vh;
overflow: auto;
}
</style>

View File

@@ -0,0 +1,42 @@
<template>
<component :is="iconType" :type="iconName" :color="iconColor" :size="iconSize" />
</template>
<script>
import Icons from '@component/md-icons'
export default {
name: 'CommonIcon',
components: { Icons },
props: {
type: {
type: String,
},
color: String,
size: Number,
},
computed: {
iconType() {
if (this.type) {
return this.type.indexOf('_') === 0 ? 'Icons' : 'Icon'
}
},
iconName() {
return this.iconType === 'Icons' ? this.getCustomIconName(this.type) : this.type
},
iconSize() {
return this.size || (this.iconType === 'Icons' ? 12 : undefined)
},
iconColor() {
return this.color || ''
},
},
methods: {
getCustomIconName(iconName) {
return iconName.slice(1)
},
},
}
</script>
<style>
</style>

View File

@@ -0,0 +1,2 @@
import CommonIcon from './common-icon.vue'
export default CommonIcon

View File

@@ -0,0 +1,105 @@
<template>
<Dropdown trigger="click" style="width:100%" @on-visible-change="showDrop">
<Input :value="value" readonly placeholder="请选择" />
<Dropdown-menu slot="list">
<Form :model="cronForm" ref="cronForm" label-position="left" :label-width="160" class="cron-form">
<!-- Minute Input -->
<FormItem label="Minute">
<Select v-model="cronForm.minute" placeholder="Minute" class="scrollable-select">
<Option v-for="minute in minutes" :value="minute" :key="minute">{{ minute }}</Option>
</Select>
</FormItem>
<!-- Hour Input -->
<FormItem label="Hour">
<Select v-model="cronForm.hour" placeholder="Hour" class="scrollable-select">
<Option v-for="hour in hours" :value="hour" :key="hour">{{ hour }}</Option>
</Select>
</FormItem>
<!-- Day of Month Input -->
<FormItem label="Day">
<Select v-model="cronForm.day" placeholder="Day" class="scrollable-select">
<Option v-for="day in days" :value="day" :key="day">{{ day }}</Option>
</Select>
</FormItem>
<!-- Month Input -->
<FormItem label="Month">
<Select v-model="cronForm.month" placeholder="Month" class="scrollable-select">
<Option v-for="month in months" :value="month" :key="month">{{ month }}</Option>
</Select>
</FormItem>
<!-- Day of Week Input -->
<FormItem label="Week">
<Select v-model="cronForm.dayOfWeek" placeholder="Day of Week" class="scrollable-select">
<Option v-for="dayOfWeek in daysOfWeek" :value="dayOfWeek" :key="dayOfWeek">{{ dayOfWeek }}</Option>
</Select>
</FormItem>
<Button type="primary" @click="applyCron">确定</Button>
</Form>
</Dropdown-menu>
</Dropdown>
</template>
<script>
export default {
props: ['value'],
data() {
return {
cronForm: {
minute: '*',
hour: '*',
day: '*',
month: '*',
dayOfWeek: '*'
},
minutes: ['*', ...Array.from({ length: 60 }, (_, i) => i.toString())],
hours: ['*', ...Array.from({ length: 24 }, (_, i) => i.toString())],
days: ['*', ...Array.from({ length: 31 }, (_, i) => (i + 1).toString())],
months: ['*', ...Array.from({ length: 12 }, (_, i) => (i + 1).toString())],
daysOfWeek: ['*', '0', '1', '2', '3', '4', '5', '6']
}
},
methods: {
showDrop(visible) {
if (visible && this.value) {
let timeArray = this.value.split(' ')
this.cronForm = {
minute: timeArray[0],
hour: timeArray[1],
day: timeArray[2],
month: timeArray[3],
dayOfWeek: timeArray[4]
}
}
},
applyCron() {
const { minute, hour, day, month, dayOfWeek } = this.cronForm
this.$emit('input', `${minute} ${hour} ${day} ${month} ${dayOfWeek}`)
}
}
}
</script>
<style lang="less" scoped>
.ivu-dropdown-menu {
padding: 10px;
}
.scrollable-select {
.ivu-select-dropdown {
max-height: 200px; /* Adjust this value to your needs */
overflow-y: auto; /* Enable vertical scrollbar */
}
}
</style>

View File

@@ -0,0 +1,2 @@
import Cropper from './index.vue'
export default Cropper

View File

@@ -0,0 +1,36 @@
.bg{
background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC")
}
.cropper-wrapper{
width:100%;
height: 100%;
.img-box{
height: 600px;
width: 600px;
border: 1px solid #ebebeb;
display: inline-block;
.bg;
img{
max-width: 100%;
display: block;
}
}
.right-con{
display: inline-block;
width: 170px;
vertical-align: top;
box-sizing: border-box;
padding: 0 10px;
margin-left: 30px;
.preview-box{
height: 150px !important;
width: 100% !important;
overflow: hidden;
border: 1px solid #ebebeb;
.bg;
}
.button-box{
padding: 10px 0 0;
}
}
}

View File

@@ -0,0 +1,155 @@
<template>
<div class="cropper-wrapper">
<div class="img-box">
<img class="cropper-image" :id="imgId" alt="">
</div>
<div class="right-con">
<div v-if="preview" class="preview-box" :id="previewId"></div>
<div class="button-box">
<slot>
<CustomUpload @chage="beforeUpload"> </CustomUpload>
</slot>
<div v-show="insideSrc">
<Button type="primary" @click="rotate">
<Icon type="md-refresh" :size="18" />
</Button>
</div>
</div>
</div>
<div style="text-align: right;margin-top: 20px;">
<Button style="width: 150px;margin-top: 10px;" type="primary" @click="crop">{{ cropButtonText }}</Button>
</div>
</div>
</template>
<script>
import Cropper from 'cropperjs'
import './index.less'
import 'cropperjs/dist/cropper.min.css'
export default {
name: 'Cropper',
props: {
src: {
type: String,
default: '',
},
preview: {
type: Boolean,
default: true,
},
moveStep: {
type: Number,
default: 4,
},
cropButtonText: {
type: String,
default: '裁剪',
},
},
data() {
return {
actionUrl: window.rootVue.$config.apiUrl + 'sys_file/upload_oos_img',
cropper: null,
insideSrc: '',
file: null,
}
},
computed: {
imgId() {
return `cropper${this._uid}`
},
previewId() {
return `cropper_preview${this._uid}`
},
},
watch: {
src(src) {
this.replace(src)
},
insideSrc(src) {
this.replace(src)
},
},
methods: {
beforeUpload(file) {
this.file = file
const reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = async (event) => {
this.getSize(event.srcElement.result).then(({ width, height }) => {
if (width < 500 || height < 500) {
rootVue.$Message.error('图片尺寸小于 500*500 ,请更换图片')
return false
}
this.insideSrc = event.srcElement.result
})
}
return false
},
getSize(src) {
let pro = new Promise((resolve, reject) => {
let img = new Image()
img.onload = function () {
let { width, height } = img
resolve({ width, height })
}
img.src = src
})
return pro
},
replace(src) {
this.cropper.replace(src)
this.insideSrc = src
},
rotate() {
this.cropper.rotate(90)
},
shrink() {
this.cropper.zoom(-0.1)
},
magnify() {
this.cropper.zoom(0.1)
},
scale(d) {
this.cropper[`scale${d}`](-this.cropper.getData()[`scale${d}`])
},
move(...argu) {
this.cropper.move(...argu)
},
crop() {
this.cropper.getCroppedCanvas({ fillColor: '#fff' }).toBlob(
(blob) => {
let file = new File([blob], this.file.name, {
type: this.file.type,
lastModified: Date.now(),
})
this.$emit('on-crop', file)
this.insideSrc = ''
this.filefile = null
},
'image/jpeg',
0.8
)
},
},
mounted() {
this.$nextTick(() => {
let dom = document.getElementById(this.imgId)
this.cropper = new Cropper(dom, {
preview: `#${this.previewId}`,
dragMode: 'move',
checkCrossOrigin: true,
aspectRatio: 5 / 5,
cropBoxResizable: false,
cropBoxMovable: false,
outputSize: 0.8,
minContainerWidth: 600, // 容器的最小宽度
minContainerHeight: 600, // 容器的最小高度
})
})
},
}
</script>

View File

@@ -0,0 +1,95 @@
<template>
<div :id="id" class="edit-box"> </div>
</template>
<script>
import WangEditor from 'wangeditor'
export default {
props: ['value'],
data() {
return {
id: '',
editor: null,
}
},
created() {
let date = new Date()
this.id = 'wangeditor_' + date.getTime()
},
mounted() {
this.init()
},
methods: {
init() {
let domId = `#${this.id}`
this.editor = new WangEditor(domId)
this.editor.config.uploadImgShowBase64 = true
this.editor.config.uploadImgServer = window.rootVue.$config.apiUrl + 'sys_file/upload_oos_img'
this.editor.config.uploadImgHooks = {
customInsert: (insertImg, result, editor) => {
var url = result.data.path
insertImg(url)
},
}
this.editor.config.menus = [
'head', // 标题
'bold', // 粗体
'fontSize', // 字号
'fontName', // 字体
'italic', // 斜体
'underline', // 下划线
'strikeThrough', // 删除线
'foreColor', // 文字颜色
'backColor', // 背景颜色
'link', // 插入链接
'list', // 列表
'justify', // 对齐方式
'image', // 插入图片
'fullscreen', // 全屏
]
this.editor.config.pasteTextHandle = function (content) {
if (content === '' && !content) return ''
var str = content
str = str.replace(/<xml>[\s\S]*?<\/xml>/gi, '')
str = str.replace(/<style>[\s\S]*?<\/style>/gi, '')
str = str.replace(/<\/?[^>]*>/g, '')
str = str.replace(/[ | ]*\n/g, '\n')
str = str.replace(/&nbsp;/gi, '')
str = str.replace(/spanyes'/gi, 'div')
str = str.replace(/spanyes'/gi, '')
str = str.replace(/';/gi, '')
str = str.replace(/spanyes'/gi, 'span')
str = str.replace(/<\/font>/gi, '')
return str
}
this.editor.config.onchange = (newHtml) => {
this.$emit('input', newHtml)
}
this.editor.create()
this.editor.$textElem.css('text-align', 'left')
// 初始化设置值
if (this.value) {
this.setHtml(this.value)
}
},
getHtml() {
return this.editor.txt.html()
},
setHtml(text) {
this.editor.txt.html(text)
},
},
}
</script>
<style lang="less" scoped>
.edit-box {
width: 100%;
}
</style>

View File

@@ -0,0 +1,2 @@
import InforCard from './infor-card.vue'
export default InforCard

View File

@@ -0,0 +1,94 @@
<template>
<Card :shadow="shadow" class="info-card-wrapper" :padding="0">
<div class="content-con">
<div class="left-area" :style="{background: color, width: leftWidth}">
<common-icon class="icon" :type="icon" :size="iconSize" color="#fff" />
</div>
<div class="right-area" :style="{width: rightWidth}">
<div>
<slot></slot>
</div>
</div>
</div>
</Card>
</template>
<script>
import CommonIcon from '@component/common-icon'
export default {
name: 'InforCard',
components: {
CommonIcon,
},
props: {
left: {
type: Number,
default: 36,
},
color: {
type: String,
default: '#2d8cf0',
},
icon: {
type: String,
default: '',
},
iconSize: {
type: Number,
default: 20,
},
shadow: {
type: Boolean,
default: false,
},
},
computed: {
leftWidth() {
return `${this.left}%`
},
rightWidth() {
return `${100 - this.left}%`
},
},
}
</script>
<style lang="less" scoped>
.common {
float: left;
height: 100%;
display: table;
text-align: center;
}
.size {
width: 100%;
height: 100%;
}
.middle-center {
display: table-cell;
vertical-align: middle;
}
.info-card-wrapper {
.size;
overflow: hidden;
.ivu-card-body {
.size;
}
.content-con {
.size;
position: relative;
.left-area {
.common;
& > .icon {
.middle-center;
}
}
.right-area {
.common;
& > div {
.middle-center;
}
}
}
}
</style>

View File

@@ -0,0 +1,32 @@
<template>
<div id="load-warp">
<div class="m-load"> </div>
</div>
</template>
<script>
export default {}
</script>
<style lang="less" scoped>
#load-warp {
position: absolute;
height: 100%;
line-height: 100%;
width: 100%;
background: rgba(255, 255, 255, 0.8);
z-index: 99999;
display: none;
text-align: center;
}
.m-load {
position: relative;
top: 50%;
margin-top: -18px;
width: 36px;
height: 36px;
background: url('../../assets/images/load.gif') center center no-repeat;
display: inline-block;
}
</style>

View File

@@ -0,0 +1,2 @@
import LoginForm from './login-form.vue'
export default LoginForm

View File

@@ -0,0 +1,68 @@
<template>
<Form ref="loginForm" :model="form" :rules="rules" @keydown.enter.native="handleSubmit">
<FormItem prop="userName">
<Input v-model="form.userName" placeholder="请输入用户名">
<span slot="prepend">
<Icon :size="16" type="ios-person"></Icon>
</span>
</Input>
</FormItem>
<FormItem prop="password">
<Input type="password" v-model="form.password" placeholder="请输入密码">
<span slot="prepend">
<Icon :size="14" type="md-lock"></Icon>
</span>
</Input>
</FormItem>
<FormItem>
<Button style="margin-top:20px;" size="large" @click="handleSubmit" type="primary" long>登录</Button>
</FormItem>
</Form>
</template>
<script>
export default {
name: 'LoginForm',
props: {
userNameRules: {
type: Array,
default: () => {
return [{ required: true, message: '账号不能为空', trigger: 'blur' }]
}
},
passwordRules: {
type: Array,
default: () => {
return [{ required: true, message: '密码不能为空', trigger: 'blur' }]
}
}
},
data() {
return {
form: {
userName: '',
password: ''
}
}
},
computed: {
rules() {
return {
userName: this.userNameRules,
password: this.passwordRules
}
}
},
methods: {
handleSubmit() {
this.$refs.loginForm.validate(valid => {
if (valid) {
this.$emit('on-success-valid', {
userName: this.form.userName,
password: this.form.password
})
}
})
}
}
}
</script>

View File

@@ -0,0 +1,2 @@
import ABackTop from './index.vue'
export default ABackTop

View File

@@ -0,0 +1,94 @@
<template>
<div :class="classes" :style="styles" @click="back">
<slot>
<div :class="innerClasses">
<i class="ivu-icon ivu-icon-ios-arrow-up"></i>
</div>
</slot>
</div>
</template>
<script>
import { scrollTop, on, off } from '@/utils/tools'
const prefixCls = 'ivu-back-top'
export default {
name: 'ABackTop',
props: {
height: {
type: Number,
default: 400
},
bottom: {
type: Number,
default: 30
},
right: {
type: Number,
default: 30
},
duration: {
type: Number,
default: 1000
},
container: {
type: null,
default: window
}
},
data() {
return {
backTop: false
}
},
mounted() {
// window.addEventListener('scroll', this.handleScroll, false)
// window.addEventListener('resize', this.handleScroll, false)
on(this.containerEle, 'scroll', this.handleScroll)
on(this.containerEle, 'resize', this.handleScroll)
},
beforeDestroy() {
// window.removeEventListener('scroll', this.handleScroll, false)
// window.removeEventListener('resize', this.handleScroll, false)
off(this.containerEle, 'scroll', this.handleScroll)
off(this.containerEle, 'resize', this.handleScroll)
},
computed: {
classes() {
return [
`${prefixCls}`,
{
[`${prefixCls}-show`]: this.backTop
}
]
},
styles() {
return {
bottom: `${this.bottom}px`,
right: `${this.right}px`
}
},
innerClasses() {
return `${prefixCls}-inner`
},
containerEle() {
return this.container === window
? window
: document.querySelector(this.container)
}
},
methods: {
handleScroll() {
this.backTop = this.containerEle.scrollTop >= this.height
},
back() {
let target =
typeof this.container === 'string'
? this.containerEle
: document.documentElement || document.body
const sTop = target.scrollTop
scrollTop(this.containerEle, sTop, 0, this.duration)
this.$emit('on-click')
}
}
}
</script>

View File

@@ -0,0 +1,90 @@
<template>
<div v-if="showFullScreenBtn" class="full-screen-btn-con">
<Tooltip :content="value ? '退出全屏' : '全屏'" placement="bottom">
<Icon @click.native="handleChange" :type="value ? 'md-contract' : 'md-expand'" :size="23"></Icon>
</Tooltip>
</div>
</template>
<script>
export default {
name: 'Fullscreen',
computed: {
showFullScreenBtn() {
return window.navigator.userAgent.indexOf('MSIE') < 0
}
},
props: {
value: {
type: Boolean,
default: false
}
},
methods: {
handleFullscreen() {
let main = document.body
if (this.value) {
if (document.exitFullscreen) {
document.exitFullscreen()
} else if (document.mozCancelFullScreen) {
document.mozCancelFullScreen()
} else if (document.webkitCancelFullScreen) {
document.webkitCancelFullScreen()
} else if (document.msExitFullscreen) {
document.msExitFullscreen()
}
} else {
if (main.requestFullscreen) {
main.requestFullscreen()
} else if (main.mozRequestFullScreen) {
main.mozRequestFullScreen()
} else if (main.webkitRequestFullScreen) {
main.webkitRequestFullScreen()
} else if (main.msRequestFullscreen) {
main.msRequestFullscreen()
}
}
},
handleChange() {
this.handleFullscreen()
}
},
mounted() {
let isFullscreen =
document.fullscreenElement ||
document.mozFullScreenElement ||
document.webkitFullscreenElement ||
document.fullScreen ||
document.mozFullScreen ||
document.webkitIsFullScreen
isFullscreen = !!isFullscreen
document.addEventListener('fullscreenchange', () => {
this.$emit('input', !this.value)
this.$emit('on-change', !this.value)
})
document.addEventListener('mozfullscreenchange', () => {
this.$emit('input', !this.value)
this.$emit('on-change', !this.value)
})
document.addEventListener('webkitfullscreenchange', () => {
this.$emit('input', !this.value)
this.$emit('on-change', !this.value)
})
document.addEventListener('msfullscreenchange', () => {
this.$emit('input', !this.value)
this.$emit('on-change', !this.value)
})
this.$emit('input', isFullscreen)
}
}
</script>
<style lang="less">
.full-screen-btn-con .ivu-tooltip-rel {
height: 64px;
line-height: 56px;
i {
cursor: pointer;
}
}
</style>

View File

@@ -0,0 +1,2 @@
import Fullscreen from './fullscreen.vue'
export default Fullscreen

View File

@@ -0,0 +1,4 @@
.custom-bread-crumb{
display: inline-block;
vertical-align: top;
}

View File

@@ -0,0 +1,46 @@
<template>
<div class="custom-bread-crumb">
<Breadcrumb :style="{fontSize: `${fontSize}px`}">
<BreadcrumbItem v-for="item in list" :to="item.to" :key="`bread-crumb-${item.name}`">
<common-icon style="margin-right: 4px;" :type="item.icon || ''" />
{{ showTitle(item) }}
</BreadcrumbItem>
</Breadcrumb>
</div>
</template>
<script>
import { showTitle } from '@/utils/tools'
import CommonIcon from '@component/common-icon/common-icon'
import './custom-bread-crumb.less'
export default {
name: 'customBreadCrumb',
components: {
CommonIcon,
},
props: {
list: {
type: Array,
default: () => [],
},
fontSize: {
type: Number,
default: 14,
},
showIcon: {
type: Boolean,
default: false,
},
},
methods: {
showTitle(item) {
return showTitle(item, this)
},
isCustomIcon(iconName) {
return iconName.indexOf('_') === 0
},
getCustomIconName(iconName) {
return iconName.slice(1)
},
},
}
</script>

View File

@@ -0,0 +1,2 @@
import customBreadCrumb from './custom-bread-crumb.vue'
export default customBreadCrumb

View File

@@ -0,0 +1,18 @@
.header-bar {
width: 100%;
height: 100%;
position: relative;
height: 64px;
display: flex;
flex-direction: row;
.custom-content-con {
height: 64px;
padding-right: 120px;
line-height: 64px;
display: inline-block;
vertical-align: top;
flex: 1;
}
}

View File

@@ -0,0 +1,39 @@
<template>
<div class="header-bar">
<sider-trigger :collapsed="collapsed" icon="md-menu" @on-change="handleCollpasedChange"></sider-trigger>
<custom-bread-crumb show-icon style="margin-left: 30px;" :list="breadCrumbList"></custom-bread-crumb>
<div class="custom-content-con">
<slot></slot>
</div>
</div>
</template>
<script>
import siderTrigger from './sider-trigger'
import customBreadCrumb from './custom-bread-crumb'
import './header-bar.less'
export default {
name: 'HeaderBar',
components: {
siderTrigger,
customBreadCrumb,
},
props: {
collapsed: Boolean,
},
computed: {
breadCrumbList() {
let route = this.$route.matched.map((p) => {
let { name, path, meta } = p
return { name, path, meta }
})
route.shift()
return route
},
},
methods: {
handleCollpasedChange(state) {
this.$emit('on-coll-change', state)
},
},
}
</script>

View File

@@ -0,0 +1,2 @@
import HeaderBar from './header-bar'
export default HeaderBar

View File

@@ -0,0 +1,2 @@
import siderTrigger from './sider-trigger.vue'
export default siderTrigger

View File

@@ -0,0 +1,21 @@
.trans{
transition: transform .2s ease;
}
@size: 40px;
.sider-trigger-a{
padding: 6px;
width: @size;
height: @size;
display: inline-block;
text-align: center;
color: #5c6b77;
margin-top: 12px;
i{
.trans;
vertical-align: top;
}
&.collapsed i{
transform: rotateZ(90deg);
.trans;
}
}

View File

@@ -0,0 +1,27 @@
<template>
<a @click="handleChange" type="text" :class="['sider-trigger-a', collapsed ? 'collapsed' : '']"><Icon :type="icon" :size="size" /></a>
</template>
<script>
export default {
name: 'siderTrigger',
props: {
collapsed: Boolean,
icon: {
type: String,
default: 'navicon-round'
},
size: {
type: Number,
default: 26
}
},
methods: {
handleChange() {
this.$emit('on-change', !this.collapsed)
}
}
}
</script>
<style lang="less">
@import './sider-trigger.less';
</style>

View File

@@ -0,0 +1,2 @@
import Language from './language.vue'
export default Language

View File

@@ -0,0 +1,51 @@
<template>
<div>
<Dropdown trigger="click" @on-click="selectLang">
<a href="javascript:void(0)">
{{ title }}
<Icon :size="18" type="md-arrow-dropdown" />
</a>
<DropdownMenu slot="list">
<DropdownItem v-for="(value, key) in localList" :name="key" :key="`lang-${key}`">{{ value }}</DropdownItem>
</DropdownMenu>
</Dropdown>
</div>
</template>
<script>
export default {
name: 'Language',
props: {
lang: String
},
data() {
return {
langList: {
'zh-CN': '语言',
'zh-TW': '語言',
'en-US': 'Lang'
},
localList: {
'zh-CN': '中文简体',
'zh-TW': '中文繁体',
'en-US': 'English'
}
}
},
watch: {
lang(lang) {
this.$i18n.locale = lang
}
},
computed: {
title() {
return this.langList[this.lang]
}
},
methods: {
selectLang(name) {
this.$emit('on-lang-change', name)
}
}
}
</script>

View File

@@ -0,0 +1,58 @@
<template>
<Dropdown ref="dropdown" @on-click="handleClick" :class="hideTitle ? '' : 'collased-menu-dropdown'" :transfer="hideTitle" :placement="placement">
<a class="drop-menu-a" type="text" @mouseover="handleMousemove($event, children)" :style="{textAlign: !hideTitle ? 'left' : ''}">
<common-icon :size="rootIconSize" :color="textColor" :type="parentItem.meta.icon" /><span class="menu-title" v-if="!hideTitle">{{
showTitle(parentItem) }}</span>
<Icon style="float: right;" v-if="!hideTitle" type="ios-arrow-forward" :size="16" />
</a>
<DropdownMenu ref="dropdown" slot="list">
<template v-for="child in children">
<collapsed-menu v-if="showChildren(child)" :icon-size="iconSize" :parent-item="child" :key="`drop-${child.name}`"></collapsed-menu>
<DropdownItem v-else :key="`drop-${child.name}`" :name="child.name">
<common-icon :size="iconSize" :type="child.meta.icon" /><span class="menu-title">{{ showTitle(child) }}</span>
</DropdownItem>
</template>
</DropdownMenu>
</Dropdown>
</template>
<script>
import mixin from './mixin'
import itemMixin from './item-mixin'
import { findNodeUpperByClasses } from '@/utils/tools'
export default {
name: 'CollapsedMenu',
mixins: [mixin, itemMixin],
props: {
hideTitle: {
type: Boolean,
default: false,
},
rootIconSize: {
type: Number,
default: 16,
},
},
data() {
return {
placement: 'right-end',
}
},
methods: {
handleClick(name) {
this.$emit('on-click', name)
},
handleMousemove(event, children) {
const { pageY } = event
const height = children.length * 38
const isOverflow = pageY + height < window.innerHeight
this.placement = isOverflow ? 'right-start' : 'right-end'
},
},
mounted() {
let dropdown = findNodeUpperByClasses(this.$refs.dropdown.$el, ['ivu-select-dropdown', 'ivu-dropdown-transfer'])
if (dropdown) dropdown.style.overflow = 'visible'
},
}
</script>

View File

@@ -0,0 +1,2 @@
import SideMenu from './side-menu.vue'
export default SideMenu

View File

@@ -0,0 +1,21 @@
export default {
props: {
parentItem: {
type: Object,
default: () => {}
},
theme: String,
iconSize: Number
},
computed: {
parentName() {
return this.parentItem.name
},
children() {
return this.parentItem.children
},
textColor() {
return this.theme === 'dark' ? '#fff' : '#495060'
}
}
}

View File

@@ -0,0 +1,18 @@
import CommonIcon from "@component/common-icon";
import { showTitle } from "@/utils/tools";
export default {
components: {
CommonIcon
},
methods: {
showTitle(item) {
return showTitle(item, this);
},
showChildren(item) {
return item.type === "菜单";
},
getNameOrHref(item, children0) {
return item.href ? `isTurnByHref_${item.href}` : item.name;
}
}
};

View File

@@ -0,0 +1,25 @@
<template>
<Submenu :name="`${parentName}`">
<template slot="title">
<common-icon :type="parentItem.meta.icon || ''" />
<span>{{ showTitle(parentItem) }}</span>
</template>
<template v-for="item in children">
<template v-if="item.is_show_menu">
<side-menu-item v-if="showChildren(item)" :key="`menu-${item.name}`" :parent-item="item"></side-menu-item>
<menu-item v-else :name="getNameOrHref(item)" :key="`menu-${item.name}`">
<common-icon :type="item.meta.icon || ''" /><span>{{ showTitle(item) }}</span>
</menu-item>
</template>
</template>
</Submenu>
</template>
<script>
import mixin from './mixin'
import itemMixin from './item-mixin'
export default {
name: 'SideMenuItem',
mixins: [mixin, itemMixin],
mounted() {},
}
</script>

View File

@@ -0,0 +1,39 @@
.side-menu-wrapper {
user-select: none;
.menu-collapsed {
padding-top: 10px;
.ivu-dropdown {
width: 100%;
.ivu-dropdown-rel a {
width: 100%;
}
}
.ivu-tooltip {
width: 100%;
.ivu-tooltip-rel {
width: 100%;
}
.ivu-tooltip-popper .ivu-tooltip-content {
.ivu-tooltip-arrow {
border-right-color: #fff;
}
.ivu-tooltip-inner {
background: #fff;
color: #495060;
}
}
}
}
a.drop-menu-a {
display: inline-block;
padding: 6px 15px;
width: 100%;
text-align: center;
color: #495060;
}
}
.menu-title {
padding-left: 6px;
}

View File

@@ -0,0 +1,112 @@
<template>
<div class="side-menu-wrapper">
<slot></slot>
<Menu ref="menu" v-show="!collapsed" :active-name="activeName" :open-names="openedNames" :accordion="true" :theme="theme" width="auto" @on-select="handleSelect">
<template v-for="item in menuList">
<template>
<side-menu-item v-if="showChildren(item)" :key="`menu-${item.name}`" :parent-item="item"></side-menu-item>
<menu-item v-else :name="getNameOrHref(item)" :key="`menu-${item.name}`">
<common-icon :type="item.meta.icon || ''" /><span>{{ showTitle(item) }}</span>
</menu-item>
</template>
</template>
</Menu>
<div class="menu-collapsed" v-show="collapsed" :list="menuList">
<template v-for="item in menuList">
<collapsed-menu v-if="item.type==='菜单'" @on-click="handleSelect" hide-title :root-icon-size="rootIconSize" :icon-size="iconSize" :theme="theme" :parent-item="item" :key="`drop-menu-${item.name}`">
</collapsed-menu>
<Tooltip transfer v-else :content="showTitle(item.children && item.children[0] ? item.children[0] : item)" placement="right" :key="`drop-menu-${item.name}`">
<a @click="handleSelect(getNameOrHref(item, true))" class="drop-menu-a" :style="{textAlign: 'center'}">
<common-icon :size="rootIconSize" :color="textColor" :type="item.meta.icon" />
</a>
</Tooltip>
</template>
</div>
</div>
</template>
<script>
import SideMenuItem from './side-menu-item.vue'
import CollapsedMenu from './collapsed-menu.vue'
import { getUnion } from '@/utils/tools'
import mixin from './mixin'
export default {
name: 'SideMenu',
mixins: [mixin],
components: {
SideMenuItem,
CollapsedMenu,
},
props: {
menuList: {
type: Array,
default() {
return []
},
},
collapsed: {
type: Boolean,
},
theme: {
type: String,
default: 'light',
},
rootIconSize: {
type: Number,
default: 20,
},
iconSize: {
type: Number,
default: 16,
},
activeName: {
type: String,
default: '',
},
openNames: {
type: Array,
default: () => [],
},
},
data() {
return {
openedNames: [],
}
},
methods: {
handleSelect(name) {
this.$emit('on-select', name)
},
getOpenedNamesByActiveName(name) {
let names = this.$route.matched.map((item) => item.name).filter((item) => item !== name)
return names
},
},
computed: {
textColor() {
return this.theme === 'dark' ? '#fff' : '#495060'
},
},
watch: {
activeName(name) {
this.openedNames = this.getOpenedNamesByActiveName(name)
},
openNames(newNames) {
this.openedNames = newNames
},
openedNames() {
this.$nextTick(() => {
this.$refs.menu.updateOpened()
})
},
},
mounted() {
let openedNames = getUnion(this.openedNames, this.getOpenedNamesByActiveName(this.activeName))
this.openedNames = openedNames
},
}
</script>
<style lang="less">
@import './side-menu.less';
</style>

View File

@@ -0,0 +1,63 @@
<template>
<div class="terminal-box">
<Icon :class="iconclass" type="ios-radio" @click="show" />
<asyncModal ref="asyncModal" title="终端" top="30" :footer-hide="true">
<Terminal></Terminal>
</asyncModal>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import Terminal from './terminal.vue'
export default {
components: {
Terminal
},
computed: {
...mapGetters({
isServerRun: 'isServerRun'
}),
iconclass() {
let curClass = 'terminal-icon ml10 '
if (this.isServerRun) {
curClass += ' run'
}
return curClass
}
},
methods: {
show() {
this.$refs['asyncModal'].show()
}
}
}
</script>
<style lang="less" scoped>
.terminal-box {
margin-top: 3px;
}
.terminal-icon {
color: #c5c8ce;
font-size: 22px;
cursor: pointer;
&.run {
animation: colorChange 0.4s infinite; /* 调用颜色变化动画 */
}
}
@keyframes colorChange {
0% {
color: #19be6b; /* 初始颜色 */
}
100% {
color: red; /* 返回到初始颜色 */
}
}
</style>

View File

@@ -0,0 +1,62 @@
<template>
<div class="content-view">
<div class="log-box">
<div class="pa5 flex" style="align-items: flex-end; justify-content: flex-end;">
<Button class="ml10" size="small" @click="reloadLog">重新加载日志</Button>
<Button type="error" size="small" @click="clearLog">清空日志</Button>
</div>
<div class="view-log-info" id="view-log-info" style="height:70vh;width: 100%;">{{ infoMsg }}</div>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import uiTool from '@/utils/uiTool'
export default {
data() {
return {}
},
mounted() {},
computed: {
...mapGetters({
infoMsg: 'infoMsg'
})
},
watch: {
infoMsg: {
handler() {
this.scrollEnd()
}
}
},
methods: {
clearLog() {
this.$store.commit('clearInfoMsg')
},
reloadLog() {
this.$store.dispatch('setInteverLog')
},
scrollEnd() {
setTimeout(() => {
let outerDiv = document.querySelector('#view-log-info')
if (outerDiv) {
outerDiv.scrollTop = outerDiv.scrollHeight + 5
}
}, 300)
}
}
}
</script>
<style lang="less" scoped>
.log-box {
display: flex;
align-items: flex-start;
justify-content: flex-start;
flex-direction: column;
width: 100%;
}
</style>

View File

@@ -0,0 +1,2 @@
import User from './user.vue'
export default User

View File

@@ -0,0 +1,12 @@
.user{
&-avator-dropdown{
cursor: pointer;
display: inline-block;
// height: 64px;
vertical-align: middle;
// line-height: 64px;
.ivu-badge-dot{
top: 16px;
}
}
}

View File

@@ -0,0 +1,68 @@
<template>
<div class="user-avator-dropdown">
<label class="loginName-box">{{userName}}</label>
<Dropdown>
<Avatar :src="typeof userAvator === 'string' ? userAvator : userIcon" />
<Icon :size="18" type="md-arrow-dropdown"></Icon>
<DropdownMenu slot="list">
<a @click="logout">
<DropdownItem name="logout">
<Icon type="md-exit" />
退出登录
</DropdownItem>
</a>
</DropdownMenu>
</Dropdown>
</div>
</template>
<script>
import './user.less'
import { mapMutations, mapActions } from 'vuex'
const userIcon = require("@/assets/images/administrato.png").default
export default {
name: 'User',
props: {
userName: String,
userAvator: {
type: [String, Object],
default: '',
},
},
data(){
return {
userIcon: userIcon
}
},
methods: {
...mapActions(['handleLogOut']),
logout() {
this.handleLogOut(this)
},
message() {
this.$router.push({
name: 'message_page',
})
},
},
}
</script>
<style lang="less" scoped>
.user-avator-dropdown {
position: absolute;
top: 0px;
right: 10px;
}
.loginName-box {
font-weight: bold;
color: #4b81d3;
margin-right: 10px;
}
</style>

View File

@@ -0,0 +1,2 @@
import Main from './main.vue'
export default Main

View File

@@ -0,0 +1,109 @@
.main {
.logo-con {
height: 64px;
padding: 10px;
padding-left: 0px;
color: white;
img {
width: auto;
display: block;
margin: 0 auto;
}
}
.header-con {
padding: 0px 10px;
width: 100%;
background: #fff;
box-shadow: 0 2px 2px rgba(0, 0, 0, 0.1);
margin-bottom: 5px;
}
.main-content-con {
height: 100%;
overflow: hidden;
color: darkgray;
display: flex;
}
.main-layout-con {
display: flex;
display: -webkit-flex;
overflow: hidden;
}
.tag-nav-wrapper {
padding: 0;
height: 40px;
background: #f0f0f0;
}
.content-wrapper {
padding: 0px 0.05rem;
height: ~"calc(100% - 40px)";
display: flex;
display: -webkit-flex;
flex-direction: column;
overflow: auto;
}
.left-sider {
.ivu-layout-sider-children {
overflow-y: scroll;
margin-right: -10px;
}
}
}
.ivu-menu-item > i {
margin-right: 12px !important;
}
.ivu-menu-submenu > .ivu-menu > .ivu-menu-item > i {
margin-right: 8px !important;
}
.collased-menu-dropdown {
width: 100%;
margin: 0;
line-height: normal;
padding: 7px 0 6px 16px;
clear: both;
font-size: 12px !important;
white-space: nowrap;
list-style: none;
cursor: pointer;
transition: background 0.2s ease-in-out;
&:hover {
background: rgba(100, 100, 100, 0.1);
}
& * {
color: #515a6e;
}
.ivu-menu-item > i {
margin-right: 12px !important;
}
.ivu-menu-submenu > .ivu-menu > .ivu-menu-item > i {
margin-right: 8px !important;
}
}
.ivu-select-dropdown.ivu-dropdown-transfer {
max-height: 400px;
}
.police-box {
margin-left: 10px;
color: darkgoldenrod;
overflow: hidden;
height: 100%;
span {
margin: 0px 5px;
}
}

View File

@@ -0,0 +1,145 @@
<template>
<Layout style="height: 100%" class="main">
<Sider hide-trigger collapsible :width="256" :collapsed-width="64" v-model="collapsed" class="left-sider" :style="{overflow: 'hidden'}">
<div class="sidebar-brand" @click="goHome" v-if="!collapsed">
<img style="width:50px" :src="sysFormModel.logoUrl" />
<span class="ml10">{{sysFormModel.title}}</span>
</div>
<side-menu class="mt30" theme="dark" ref="sideMenu" :active-name="$route.name" :collapsed="collapsed" @on-select="turnToPage" :menu-list="menuList"> </side-menu>
</Sider>
<Content class="main-content-con">
<Layout class="main-layout-con">
<Header>
<headerBar class="header-con" @on-coll-change="collpasedChange" :collapsed="collapsed">
<Terminal></Terminal>
<user :userName="userName" :user-avator="userAvator || ''" />
</headerBar>
</Header>
<Layout>
<Content class="content-wrapper">
<router-view />
<loadFlower> </loadFlower>
<ABackTop :height="100" :bottom="80" :right="50" container=".content-wrapper"></ABackTop>
</Content>
</Layout>
</Layout>
</Content>
</Layout>
</template>
<script>
const { title } = require('../../config')
import SideMenu from './components/side-menu'
import HeaderBar from './components/header-bar'
import User from './components/user'
import ABackTop from './components/a-back-top'
import Fullscreen from './components/fullscreen'
import Language from './components/language'
import { mapGetters } from 'vuex'
import headerBar from './components/header-bar'
import loadFlower from '../load-flower/index'
import Terminal from './components/terminal'
import './main.less'
export default {
name: 'Main',
components: {
SideMenu,
HeaderBar,
Language,
Fullscreen,
User,
Terminal,
ABackTop,
loadFlower,
headerBar
},
data() {
return {
title,
collapsed: false,
isFullscreen: false
}
},
computed: {
...mapGetters({
sysFormModel: 'sysFormModel',
menuList: 'menuList',
tagRouter: 'tagRouter',
userName: 'userName',
userAvator: 'avatorImgPath'
}),
cacheList() {
return ['ParentView']
}
},
watch: {},
created() {
this.init()
},
methods: {
async init() {
await this.$store.dispatch('getSysTitle')
},
collpasedChange(collapsed) {
this.collapsed = collapsed
},
goHome() {
this.$router.push({ path: '/' })
},
turnToPage(route) {
let { name, params, query } = {}
if (typeof route === 'string') name = route
else {
name = route.name
params = route.params
query = route.query
}
if (name.indexOf('isTurnByHref_') > -1) {
window.open(name.split('_')[1])
return
}
this.$router.push({
name,
params,
query
})
},
handleCollapsedChange(state) {
this.collapsed = state
},
handleClick(item) {
this.turnToPage(item)
}
}
}
</script>
<style lang="less" scoped>
.shop-select {
z-index: 999999;
}
.sidebar-brand {
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
text-align: center;
height: 60px;
line-height: 25px;
color: #fff;
font-size: 18px;
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,23 @@
<template>
<div class="table-head-tool ss">
<Button shape="circle" v-if="$route.meta.type==='功能'" icon="ios-undo" @click="goBack">返回</Button>
<slot></slot>
</div>
</template>
<script>
export default {
data() {
return {}
},
created() {},
methods: {
goBack() {
this.$router.go(-1)
},
},
}
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,2 @@
import MarkdownEditor from './markdown.vue'
export default MarkdownEditor

View File

@@ -0,0 +1,78 @@
<template>
<div class="markdown-wrapper">
<textarea ref="editor"></textarea>
</div>
</template>
<script>
import Simplemde from 'simplemde'
import 'simplemde/dist/simplemde.min.css'
export default {
name: 'MarkdownEditor',
props: {
value: {
type: String,
default: ''
},
options: {
type: Object,
default: () => {
return {}
}
},
localCache: {
type: Boolean,
default: true
}
},
data() {
return {
editor: null
}
},
methods: {
addEvents() {
this.editor.codemirror.on('change', () => {
let value = this.editor.value()
if (this.localCache) localStorage.markdownContent = value
this.$emit('input', value)
this.$emit('on-change', value)
})
this.editor.codemirror.on('focus', () => {
this.$emit('on-focus', this.editor.value())
})
this.editor.codemirror.on('blur', () => {
this.$emit('on-blur', this.editor.value())
})
}
},
mounted() {
this.editor = new Simplemde(
Object.assign(this.options, {
element: this.$refs.editor
})
)
/**
* 事件列表为Codemirror编辑器的事件更多事件类型请参考
* https://codemirror.net/doc/manual.html#events
*/
this.addEvents()
let content = localStorage.markdownContent
if (content) this.editor.value(content)
}
}
</script>
<style lang="less">
.markdown-wrapper {
.editor-toolbar.fullscreen {
z-index: 9999;
}
.CodeMirror-fullscreen {
z-index: 9999;
}
.CodeMirror-fullscreen ~ .editor-preview-side {
z-index: 9999;
}
}
</style>

View File

@@ -0,0 +1,34 @@
<template>
<i :class="`iconfont icon-${type}`" :style="styles"></i>
</template>
<script>
export default {
name: 'Icons',
props: {
type: {
type: String,
required: true
},
color: {
type: String,
default: '#5c6b77'
},
size: {
type: Number,
default: 16
}
},
computed: {
styles() {
return {
fontSize: `${this.size}px`,
color: this.color
}
}
}
}
</script>
<style>
</style>

View File

@@ -0,0 +1,2 @@
import Icons from './icons.vue'
export default Icons

View File

@@ -0,0 +1,2 @@
import ParentView from './parent-view.vue'
export default ParentView

View File

@@ -0,0 +1,18 @@
<template>
<!-- <keep-alive :include="cacheList" :exclude="notCacheName"> -->
<router-view ref="child" />
<!-- </keep-alive> -->
</template>
<script>
export default {
name: 'ParentView',
computed: {
notCacheName() {
return [this.$route.meta && this.$route.meta.notCache ? this.$route.name : '']
},
cacheList() {
return ['ParentView']
},
},
}
</script>

View File

@@ -0,0 +1,2 @@
import PasteEditor from './paste-editor.vue'
export default PasteEditor

View File

@@ -0,0 +1,26 @@
.paste-editor-wrapper{
width: 100%;
height: 100%;
border: 1px dashed gainsboro;
textarea.textarea-el{
width: 100%;
height: 100%;
}
.CodeMirror{
height: 100%;
padding: 0;
.CodeMirror-code div .CodeMirror-line > span > span.cm-tab{
&::after{
content: '→';
color: #BFBFBF;
}
}
}
.first-row{
font-weight: 700;
font-size: 14px;
}
.incorrect-row{
background: #F5CBD1;
}
}

View File

@@ -0,0 +1,120 @@
<template>
<div class="paste-editor-wrapper">
<textarea ref="codemirror" class="textarea-el"></textarea>
</div>
</template>
<script>
import CodeMirror from 'codemirror'
import 'codemirror/lib/codemirror.css'
import { forEach } from '@/utils/tools'
import createPlaceholder from './plugins/placeholder'
export default {
name: 'PasteEditor',
props: {
value: Array,
pasteData: {
type: String,
default: ''
},
placeholder: {
type: String,
default:
'从网页或其他应用软件复制表格数据,粘贴到这里 。默认第一行是表头使用回车键添加新行使用Tab键区分列。'
}
},
data() {
return {
pasteDataArr: [],
rowArrLength: 0,
editor: null
}
},
watch: {
pasteData(val) {
if (val === '') {
this.editor.setValue('')
}
}
},
computed: {
rowNum() {
return this.pasteDataArr.length
},
colNum() {
return this.pasteDataArr[0] ? this.pasteDataArr[0].length : 0
}
},
methods: {
handleKeyup(e) {
this.handleAreaData()
},
/**
* @description 处理粘贴操作
*/
handleContentChanged(content) {
let pasteData = content.trim()
this.$emit('on-content-change', pasteData)
let rows = pasteData.split(/[\n\u0085\u2028\u2029]|\r\n?/g).map(row => {
return row.split('\t')
})
if (content === '') rows = []
this.pasteDataArr = rows
this.clearLineClass()
this.checkColNumInEveryRow()
this.$emit('input', this.pasteDataArr)
},
/**
* @description 检查除第一行的每一行列数是否与第一行相同
*/
checkColNumInEveryRow() {
let i = 0
const len = this.rowNum
if (len === 0) return
while (++i < len) {
let item = this.pasteDataArr[i]
if (
item.length !== this.colNum &&
(!(i === len - 1 && item.length === 1 && item[0] === '') ||
i !== len - 1)
) {
this.markIncorrectRow(i)
this.$emit('on-error', i)
return false
}
}
this.$emit('on-success', this.pasteDataArr)
return true
},
/**
* @description 标记不符合格式的一行
*/
markIncorrectRow(index) {
this.editor.addLineClass(index, 'text', 'incorrect-row')
},
/**
* @description 标记不符合格式的一行
*/
clearLineClass() {
forEach(this.pasteDataArr, (item, index) => {
this.editor.removeLineClass(index, 'text', 'incorrect-row')
})
}
},
mounted() {
createPlaceholder(CodeMirror)
this.editor = CodeMirror.fromTextArea(this.$refs.codemirror, {
lineNumbers: true,
tabSize: 1,
lineWrapping: true,
placeholder: this.placeholder
})
this.editor.on('change', editor => {
this.handleContentChanged(editor.getValue())
})
this.editor.addLineClass(0, 'text', 'first-row')
}
}
</script>
<style lang="less">
@import './paste-editor.less';
</style>

View File

@@ -0,0 +1,61 @@
export default codemirror => {
;(function(mod) {
mod(codemirror)
})(function(CodeMirror) {
CodeMirror.defineOption('placeholder', '', function(cm, val, old) {
var prev = old && old !== CodeMirror.Init
if (val && !prev) {
cm.on('blur', onBlur)
cm.on('change', onChange)
cm.on('swapDoc', onChange)
onChange(cm)
} else if (!val && prev) {
cm.off('blur', onBlur)
cm.off('change', onChange)
cm.off('swapDoc', onChange)
clearPlaceholder(cm)
var wrapper = cm.getWrapperElement()
wrapper.className = wrapper.className.replace(' CodeMirror-empty', '')
}
if (val && !cm.hasFocus()) onBlur(cm)
})
function clearPlaceholder(cm) {
if (cm.state.placeholder) {
cm.state.placeholder.parentNode.removeChild(cm.state.placeholder)
cm.state.placeholder = null
}
}
function setPlaceholder(cm) {
clearPlaceholder(cm)
var elt = (cm.state.placeholder = document.createElement('pre'))
elt.style.cssText = 'height: 0; overflow: visible; color: #80848f;'
elt.style.direction = cm.getOption('direction')
elt.className = 'CodeMirror-placeholder'
var placeHolder = cm.getOption('placeholder')
if (typeof placeHolder === 'string')
placeHolder = document.createTextNode(placeHolder)
elt.appendChild(placeHolder)
cm.display.lineSpace.insertBefore(elt, cm.display.lineSpace.firstChild)
}
function onBlur(cm) {
if (isEmpty(cm)) setPlaceholder(cm)
}
function onChange(cm) {
let wrapper = cm.getWrapperElement()
let empty = isEmpty(cm)
wrapper.className =
wrapper.className.replace(' CodeMirror-empty', '') +
(empty ? ' CodeMirror-empty' : '')
if (empty) setPlaceholder(cm)
else clearPlaceholder(cm)
}
function isEmpty(cm) {
return cm.lineCount() === 1 && cm.getLine(0) === ''
}
})
}

View File

@@ -0,0 +1,2 @@
import Split from './split.vue'
export default Split

View File

@@ -0,0 +1,114 @@
@split-prefix-cls: ~"ivu-split";
@box-shadow: 0 0 4px 0 rgba(28, 36, 56, 0.4);
@trigger-bar-background: rgba(23, 35, 61, 0.25);
@trigger-background: #F8F8F9;
@trigger-width: 6px;
@trigger-bar-width: 4px;
@trigger-bar-offset: (@trigger-width - @trigger-bar-width) / 2;
@trigger-bar-interval: 3px;
@trigger-bar-weight: 1px;
@trigger-bar-con-height: (@trigger-bar-weight + @trigger-bar-interval) * 8;
.@{split-prefix-cls}{
&-wrapper{
position: relative;
width: 100%;
height: 100%;
}
&-pane{
position: absolute;
&.left-pane, &.right-pane{
top: 0px;
bottom: 0px;
}
&.left-pane{
left: 0px;
}
&.right-pane{
right: 0px;
}
&.top-pane, &.bottom-pane{
left: 0px;
right: 0px;
}
&.top-pane{
top: 0px;
}
&.bottom-pane{
bottom: 0px;
}
}
&-trigger{
&-con{
position: absolute;
transform: translate(-50%, -50%);
z-index: 10;
}
&-bar-con{
position: absolute;
overflow: hidden;
&.vertical{
left: @trigger-bar-offset;
top: 50%;
height: @trigger-bar-con-height;
transform: translate(0, -50%);
}
&.horizontal{
left: 50%;
top: @trigger-bar-offset;
width: @trigger-bar-con-height;
transform: translate(-50%, 0);
}
}
&-vertical{
width: @trigger-width;
height: 100%;
background: @trigger-background;
box-shadow: @box-shadow;
cursor: col-resize;
.@{split-prefix-cls}-trigger-bar{
width: @trigger-bar-width;
height: 1px;
background: @trigger-bar-background;
float: left;
margin-top: @trigger-bar-interval;
}
}
&-horizontal{
height: @trigger-width;
width: 100%;
background: @trigger-background;
box-shadow: @box-shadow;
cursor: row-resize;
.@{split-prefix-cls}-trigger-bar{
height: @trigger-bar-width;
width: 1px;
background: @trigger-bar-background;
float: left;
margin-right: @trigger-bar-interval;
}
}
}
&-horizontal{
.@{split-prefix-cls}-trigger-con{
top: 50%;
height: 100%;
width: 0;
}
}
&-vertical{
.@{split-prefix-cls}-trigger-con{
left: 50%;
height: 0;
width: 100%;
}
}
.no-select{
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
}

View File

@@ -0,0 +1,188 @@
<template>
<div ref="outerWrapper" :class="wrapperClasses">
<div v-if="isHorizontal" :class="`${prefix}-horizontal`">
<div :style="{right: `${anotherOffset}%`}" :class="[`${prefix}-pane`, 'left-pane']"><slot name="left"/></div>
<div :class="`${prefix}-trigger-con`" :style="{left: `${offset}%`}" @mousedown="handleMousedown">
<slot name="trigger">
<trigger mode="vertical"/>
</slot>
</div>
<div :style="{left: `${offset}%`}" :class="[`${prefix}-pane`, 'right-pane']"><slot name="right"/></div>
</div>
<div v-else :class="`${prefix}-vertical`">
<div :style="{bottom: `${anotherOffset}%`}" :class="[`${prefix}-pane`, 'top-pane']"><slot name="top"/></div>
<div :class="`${prefix}-trigger-con`" :style="{top: `${offset}%`}" @mousedown="handleMousedown">
<slot name="trigger">
<trigger mode="horizontal"/>
</slot>
</div>
<div :style="{top: `${offset}%`}" :class="[`${prefix}-pane`, 'bottom-pane']"><slot name="bottom"/></div>
</div>
</div>
</template>
<script>
import { oneOf, on, off } from '@/utils/tools'
import Trigger from './trigger.vue'
export default {
name: 'SplitPane',
components: {
Trigger
},
props: {
value: {
type: [Number, String],
default: 0.5
},
mode: {
validator(value) {
return oneOf(value, ['horizontal', 'vertical'])
},
default: 'horizontal'
},
min: {
type: [Number, String],
default: '40px'
},
max: {
type: [Number, String],
default: '40px'
}
},
/**
* Events
* @on-move-start
* @on-moving 返回值事件对象但是在事件对象中加入了两个参数atMin(当前是否在最小值处), atMax(当前是否在最大值处)
* @on-move-end
*/
data() {
return {
prefix: 'ivu-split',
offset: 0,
oldOffset: 0,
isMoving: false
}
},
computed: {
wrapperClasses() {
return [`${this.prefix}-wrapper`, this.isMoving ? 'no-select' : '']
},
isHorizontal() {
return this.mode === 'horizontal'
},
anotherOffset() {
return 100 - this.offset
},
valueIsPx() {
return typeof this.value === 'string'
},
offsetSize() {
return this.isHorizontal ? 'offsetWidth' : 'offsetHeight'
},
computedMin() {
return this.getComputedThresholdValue('min')
},
computedMax() {
return this.getComputedThresholdValue('max')
}
},
methods: {
px2percent(numerator, denominator) {
return parseFloat(numerator) / parseFloat(denominator)
},
getComputedThresholdValue(type) {
let size = this.$refs.outerWrapper[this.offsetSize]
if (this.valueIsPx)
return typeof this[type] === 'string' ? this[type] : size * this[type]
else
return typeof this[type] === 'string'
? this.px2percent(this[type], size)
: this[type]
},
getMin(value1, value2) {
if (this.valueIsPx)
return `${Math.min(parseFloat(value1), parseFloat(value2))}px`
else return Math.min(value1, value2)
},
getMax(value1, value2) {
if (this.valueIsPx)
return `${Math.max(parseFloat(value1), parseFloat(value2))}px`
else return Math.max(value1, value2)
},
getAnotherOffset(value) {
let res = 0
if (this.valueIsPx)
res = `${this.$refs.outerWrapper[this.offsetSize] -
parseFloat(value)}px`
else res = 1 - value
return res
},
handleMove(e) {
let pageOffset = this.isHorizontal ? e.pageX : e.pageY
let offset = pageOffset - this.initOffset
let outerWidth = this.$refs.outerWrapper[this.offsetSize]
let value = this.valueIsPx
? `${parseFloat(this.oldOffset) + offset}px`
: this.px2percent(outerWidth * this.oldOffset + offset, outerWidth)
let anotherValue = this.getAnotherOffset(value)
if (parseFloat(value) <= parseFloat(this.computedMin))
value = this.getMax(value, this.computedMin)
if (parseFloat(anotherValue) <= parseFloat(this.computedMax))
value = this.getAnotherOffset(
this.getMax(anotherValue, this.computedMax)
)
e.atMin = this.value === this.computedMin
e.atMax = this.valueIsPx
? this.getAnotherOffset(this.value) === this.computedMax
: this.getAnotherOffset(this.value).toFixed(5) ===
this.computedMax.toFixed(5)
this.$emit('input', value)
this.$emit('on-moving', e)
},
handleUp() {
this.isMoving = false
off(document, 'mousemove', this.handleMove)
off(document, 'mouseup', this.handleUp)
this.$emit('on-move-end')
},
handleMousedown(e) {
this.initOffset = this.isHorizontal ? e.pageX : e.pageY
this.oldOffset = this.value
this.isMoving = true
on(document, 'mousemove', this.handleMove)
on(document, 'mouseup', this.handleUp)
this.$emit('on-move-start')
}
},
watch: {
value() {
this.offset =
((this.valueIsPx
? this.px2percent(
this.value,
this.$refs.outerWrapper[this.offsetSize]
)
: this.value) *
10000) /
100
}
},
mounted() {
this.$nextTick(() => {
this.offset =
((this.valueIsPx
? this.px2percent(
this.value,
this.$refs.outerWrapper[this.offsetSize]
)
: this.value) *
10000) /
100
})
}
}
</script>
<style lang="less">
@import './index.less';
</style>

View File

@@ -0,0 +1,45 @@
<template>
<div :class="classes">
<div :class="barConClasses">
<i :class="`${prefix}-bar`" v-once v-for="i in 8" :key="`trigger-${i}`"></i>
</div>
</div>
</template>
<script>
export default {
name: 'Trigger',
props: {
mode: String
},
data() {
return {
prefix: 'ivu-split-trigger',
initOffset: 0
}
},
computed: {
isVertical() {
return this.mode === 'vertical'
},
classes() {
return [
this.prefix,
this.isVertical
? `${this.prefix}-vertical`
: `${this.prefix}-horizontal`
]
},
barConClasses() {
return [
`${this.prefix}-bar-con`,
this.isVertical ? 'vertical' : 'horizontal'
]
}
}
}
</script>
<style lang="less">
@import './index.less';
</style>

View File

@@ -0,0 +1,279 @@
<template>
<Modal v-model="isOPen" :title="title||'编辑'" :width="curWidth" @on-cancel="cancel" :scrollable='true' @on-visible-change='visibleChange' :mask-closable='false'>
<Form v-if="isRefresh" class="modal-form" ref="From" :model="row" :label-width="120" inline>
<div class="line-row">
<FormItem>
<slot name="top">
</slot>
</FormItem>
</div>
<template v-for="col in curColumns">
<div :class="col.inLine?'inline-row':'line-row'" :key="col.name" v-if="col.key&&!col.display">
<FormItem :label="col.title" :prop="col.key" :rules="curRules[col.key]" :style=" col.rowStyle">
<Row>
<Select class="text-left" filterable v-if="col.com==='Select'" v-model='row[col.key]' v-bind="col" :disabled="getDisabled(col)">
<Option :value="item.key" :key="item.key" v-for="item in col.source">{{item.value }}</Option>
</Select>
<RadioGroup v-else-if="col.com==='Radio'" v-model="row[col.key]" v-bind="col" :disabled="getDisabled(col)">
<Radio :label="item.key" :key="item.key" v-for="item in col.source">
{{item.value}}
</Radio>
</RadioGroup>
<AutoComplete v-else-if="col.com==='SelectIcon'" icon="ios-search" v-model="row[col.key]" v-bind="col" :disabled="getDisabled(col)">
<Option v-for="(icon,index) in icons" :value="icon" :key="index">
<Icon size="20" :type="icon" />
{{ icon }}
</Option>
</AutoComplete>
<UploadSingle v-else-if="col.com==='upload_Img'" v-model="row[col.key]" v-bind="col" :disabled="getDisabled(col)">
</UploadSingle>
<TextArea v-else-if="col.com==='TextArea'" v-model="row[col.key]" v-bind="col" :disabled="getDisabled(col)">
</TextArea>
<templateRender v-else-if="col.editRender" :value="row[col.key]" :render='col.editRender'></templateRender>
<Input v-else-if="!col.com" v-model="row[col.key]" v-bind="col" style="width:100%;" :disabled="getDisabled(col)" />
<component v-else v-bind:is="col.com" v-model="row[col.key]" v-bind="col" :disabled="getDisabled(col)"></component>
</Row>
</FormItem>
</div>
</template>
<slot name='bottom'></slot>
</Form>
<div slot="footer">
<div v-if="isFooter">
<Button type="primary" @click="save">确定</Button>
</div>
</div>
</Modal>
</template>
<script>
import Vue from 'vue'
import dayjs from 'dayjs'
import templateRender from './templateRender'
const icons = require('@/config/icons')
export default {
props: ['columns', 'width'],
components: {
templateRender
},
data() {
return {
title: '新增',
isFooter: true,
isRefresh: true,
isEdit: false,
isOPen: false,
icons: [],
row: {},
callback: null
}
},
computed: {
curWidth() {
return this.width || '80'
},
curColumns() {
if (this.columns) {
return this.columns.filter((p) => p.type !== 'template' && p.key !== 'id')
}
return []
},
curRules() {
let ruleObj = {}
let ruleCols = this.columns.filter((p) => p.required)
if (ruleCols) {
ruleCols.forEach((col) => {
let com = col.com || 'Input'
let trigger = com === 'Input' ? 'blur' : 'change'
if (com === 'Input') {
ruleObj[col.key] = [{ required: true, message: `${col.title}不能为空`, trigger }]
} else if (com === 'Select') {
ruleObj[col.key] = [{ required: true, type: 'number', message: `${col.title}不能为空`, trigger }]
}
})
}
return this.$attrs.rules || ruleObj
}
},
methods: {
// 判断字段是否禁用
getDisabled(col) {
// 如果是编辑模式且字段设置了disabled则禁用
if (this.isEdit && col.disabled === true) {
return true
}
// 如果是新增模式且字段设置了disabledOnAdd则禁用
if (!this.isEdit && col.disabledOnAdd === true) {
return true
}
return false
},
refresh() {
this.isRefresh = false
this.$nextTick(() => {
this.isRefresh = true
})
},
setFooter(bol) {
this.isFooter = bol
},
visibleChange(res) {
this.$emit('on-visible-change', res)
},
// 兼容方法:统一的 showModal 方法
async showModal(row) {
if (row && row.id) {
// 编辑模式
await this.editShow(row, async (formData) => {
await this.handleSave(formData, true)
})
} else {
// 新增模式
await this.addShow(row || {}, async (formData) => {
await this.handleSave(formData, false)
})
}
},
// 处理保存逻辑
async handleSave(formData, isEdit) {
this.$emit('on-save', { data: formData, isEdit })
},
editShow(row, callback) {
this.title = '编辑'
this.isEdit = true
this.init(row)
this.show()
this.callback = callback
},
addShow(row, callback) {
this.title = '新增'
this.isEdit = false
this.init(row)
this.show()
this.callback = callback
},
show() {
this.refresh()
this.isOPen = true
},
hide() {
this.isOPen = false
},
async init(row, isgl) {
this.icons = icons.map((item) => item.trim())
row = row || {}
let rules = this.curRules
if (isgl) {
this.row = JSON.parse(JSON.stringify(row))
} else {
this.row = row
}
let keys = Object.keys(rules)
if (keys && keys.length > 0) {
this.$refs['From'].resetFields()
}
this.curColumns
.filter((p) => p.key !== undefined)
.forEach((col) => {
let defaultVal = row[col.key]
if (col.data_type === 'number') {
defaultVal = parseFloat(defaultVal) || 0
Vue.set(this.row, col.key, defaultVal)
} else if (col.data_type === 'date') {
Vue.set(this.row, col.key, dayjs(defaultVal).toDate())
} else if (col.data_type === 'boolean') {
Vue.set(this.row, col.key, defaultVal === 1 || defaultVal === true)
} else {
defaultVal = defaultVal !== undefined ? defaultVal : ''
Vue.set(this.row, col.key, defaultVal)
}
})
},
async save() {
let rules = this.curRules
let keys = Object.keys(rules)
if (keys && keys.length > 0) {
let validate = await this.$refs['From'].validate()
if (!validate) {
Promise.reject(new Error('请检查表单'))
this.$Message.error('请检查表单')
return
}
}
let row = JSON.parse(JSON.stringify(this.row))
let dateCols = this.curColumns.filter((p) => p.type === 'date')
if (dateCols && dateCols.length > 0) {
dateCols.forEach((col) => {
let format = col.type === 'date' ? 'YYYY-MM-DD' : 'YYYY-MM-DD HH:mm:ss'
row[col.key] = dayjs(row[col.key]).format(format)
})
}
if (this.callback) {
await this.callback(row)
this.hide()
}
},
cancel() {}
}
}
</script>
<style lang="less" scoped>
.inline-row {
display: inline-block;
width: 50%;
.ivu-form-item {
width: 100%;
margin-bottom: 10px;
}
}
.line-row {
display: block;
text-align: left;
margin-top: 0.1rem;
.ivu-form-item {
width: 90%;
}
}
.ivu-radio-wrapper {
margin-left: 20px;
}
.ivu-date-picker {
width: 100%;
}
.text-left {
text-align: left;
}
</style>

View File

@@ -0,0 +1,17 @@
<template>
<FormItem :label="name" :required="required">
<slot></slot>
</FormItem>
</template>
<script>
export default {
props: ['name', 'required'],
}
</script>
<style lang="less" scoped>
.ivu-form-item {
width: 100%;
}
</style>

View File

@@ -0,0 +1,270 @@
<template>
<div class="table-warp">
<div class="page-box " v-if="pageOption.page">
<Page :total="pageOption.total" :current="pageOption.page" :page-size="pageOption.pageSize" show-total @on-change="onChangePage"></Page>
</div>
<div class="table-content" ref="table_content" :style="{ maxHeight: tableMaxHeight }">
<div class="table-title" v-if="title||isDown">
<label>{{title}}
<span class="gray font12 ml10" v-if="tip"> {{tip}}</span>
</label>
<slot name="header"></slot>
<a v-if="isDown&&value&&value.length>0" class="link right-link" @click="downExecl">下载</a>
</div>
<Table :class="tbClass" ref="tablesMain" border :data="insideTableData" :width='defaultWidth' :height='defaultHeight' :columns="insideColumns" v-bind="$attrs" @on-selection-change='onSelect'>
<slot></slot>
<slot name="footer" slot="footer"></slot>
<slot name="loading" slot="loading"></slot>
</Table>
</div>
</div>
</template>
<script>
import { v4 as uuidv4 } from 'uuid'
export default {
name: 'Tables',
props: {
isDown: Boolean,
title: String,
tip: '',
value: {
type: Array,
default() {
return []
},
},
columns: {
type: Array,
default() {
return []
},
},
pageOption: {
type: Object,
default() {
return {}
},
},
width: {
type: [Number, String],
},
height: {
type: [Number, String],
},
// 表格容器最大高度偏移量(从视窗高度减去的值)
// 设置为 null 或 'auto' 时自动计算,也可手动指定数字
maxHeightOffset: {
type: [Number, String],
default: 'auto'
},
},
/**
* Events
* @on-start-edit 返回值 {Object} 同iview中render函数中的params对象 { row, index, column }
* @on-cancel-edit 返回值 {Object} 同上
* @on-save-edit 返回值 {Object} 除上面三个参数外还有一个value: 修改后的数据
*/
data() {
return {
defaultHeight: undefined,
defaultWidth: undefined,
insideTableData: [],
editOption: {
index: 0,
key: '',
},
// 动态计算的偏移量
calculatedOffset: null,
}
},
computed: {
tbClass() {
return 'tb_' + uuidv4()
},
insideColumns() {
let columns = this.columns.map((item, index) => {
// 设置默认对齐方式
if (!item.align) {
item.align = 'center'
if (item.children && item.children.length > 0) {
item.children = item.children.map((sonItem) => {
if (!sonItem.align) {
sonItem.align = 'center'
}
return sonItem
})
}
}
// 确保列宽设置正确,优先使用 width其次使用 minWidth
// 如果都没有设置,则设置默认 minWidth
if (!item.width && !item.minWidth) {
item.minWidth = 120
}
return item
})
return columns
},
editColumns() {
let columns = this.columns.filter((p) => p.editOption && p.editOption.inEdit)
return columns
},
// 动态计算表格容器最大高度
tableMaxHeight() {
// 如果设置为 auto则使用动态计算的值
if (this.maxHeightOffset === 'auto' || this.maxHeightOffset === null) {
return this.calculatedOffset !== null ? `calc(100vh - ${this.calculatedOffset}px)` : '100vh'
}
// 使用手动指定的偏移量
return `calc(100vh - ${this.maxHeightOffset}px)`
},
},
methods: {
downExecl() {
this.$emit('downExecl', { value: this.value, columns: this.columns })
},
handleTableData() {
let values = this.value || []
let data = values.filter((p) => p)
this.insideTableData = data.map((item, index) => {
let res = item
return res
})
},
onChangePage(page) {
this.pageOption.page = page
this.$emit('changePage', this.pageOption.page)
},
onSelect(selection, row) {
this.$emit('on-select', selection, row)
},
// 动态计算表格容器上方所有元素的高度总和
calculateOffset() {
this.$nextTick(() => {
const tableContent = this.$refs.table_content
if (!tableContent) return
// 获取表格容器距离视窗顶部的距离
const rect = tableContent.getBoundingClientRect()
const topOffset = rect.top
// 获取分页器高度(如果存在)
const pageBox = this.$el.querySelector('.page-box')
const pageBoxHeight = pageBox ? pageBox.offsetHeight : 0
// 底部预留空间(避免滚动条贴边)
const bottomPadding = 20
// 计算总偏移量
this.calculatedOffset = topOffset + pageBoxHeight + bottomPadding
})
},
},
watch: {
value(val) {
this.handleTableData()
},
},
mounted() {
this.handleTableData()
// 自适应高度
this.$nextTick(() => {
if (this.height) {
this.defaultHeight = this.height
}
if (this.width) {
this.defaultWidth = this.width
}
// 动态计算偏移量
this.calculateOffset()
})
// 监听窗口大小变化,重新计算偏移量
window.addEventListener('resize', this.calculateOffset)
},
beforeDestroy() {
// 移除窗口大小监听
window.removeEventListener('resize', this.calculateOffset)
},
}
</script>
<style lang="less" scoped>
.table-warp {
width: 100%;
.table-content {
overflow-y: auto;
width: 100%;
.table-title {
font-weight: bold;
color: #515a6e;
font-size: 14px;
background-color: #e8eaec;
padding: 0rem 0.15rem;
height: 50px;
line-height: 50px;
.right-link {
float: right;
}
}
}
.page-box {
padding: 2px;
text-align: right;
margin: 5px 0px;
}
.search-con {
padding: 10px 0;
.search {
&-col {
display: inline-block;
width: 200px;
}
&-input {
display: inline-block;
width: 200px;
margin-left: 2px;
}
&-btn {
margin-left: 2px;
}
}
}
}
/* 确保表格列对齐 */
::v-deep .ivu-table {
th, td {
text-align: center;
}
/* 确保表格单元格内容正确显示 */
.ivu-table-cell {
padding-left: 8px;
padding-right: 8px;
}
}
</style>

View File

@@ -0,0 +1,9 @@
export default {
name: 'templateRender',
props: ['render', 'value'],
functional: true,
render: (h, ctx) => {
const params = ctx.props.value
return ctx.props.render(h, params)
},
}

View File

@@ -0,0 +1,21 @@
<template>
<Input :value="value" type="textarea" :rows="4" placeholder="请输入内容" @on-blur="changeInput" />
</template>
<script>
export default {
props: ['value'],
data() {
return {}
},
methods: {
changeInput(e) {
let value = e.currentTarget._value
this.$emit('input', value)
}
}
}
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,24 @@
<script>
export default {
name: 'RenderCol',
props: {
col: {
type: Function,
required: true
},
param: {
type: Object,
default: () => ({})
}
},
render(h) {
try {
const content = this.col(h, this.param)
return <div class="text-center">{content}</div>
} catch (e) {
// 渲染失败时提供一个容错显示
return <div class="text-center">-</div>
}
}
}
</script>

View File

@@ -0,0 +1,23 @@
<template>
<colgroup>
<col v-for="(col, index) in columns" :key="col.key || index" :style="colStyle(col)" />
</colgroup>
</template>
<script>
export default {
name: 'SubColmns',
props: {
columns: {
type: Array,
default: () => []
}
},
methods: {
colStyle(col) {
const width = col && col.width ? col.width : '150px'
return { width }
}
}
}
</script>

View File

@@ -0,0 +1,31 @@
<template>
<thead class="ivu-table-header">
<tr>
<th v-for="(col, index) in columns" :key="col.key || index" :style="headerStyle(col)">
<div class="ivu-table-cell">
<span>{{ col.title }}</span>
</div>
</th>
</tr>
</thead>
</template>
<script>
export default {
name: 'SubThead',
props: {
columns: {
type: Array,
default: () => []
}
},
methods: {
headerStyle(col) {
const align = col && col.align ? col.align : 'center'
return { textAlign: align }
}
}
}
</script>

View File

@@ -0,0 +1,341 @@
<template>
<table cellspacing="0" width="100%" cellpadding="0" border="0">
<SubColmns :columns="columns"></SubColmns>
<tbody class="endTbody" v-for="(row, index) in subData" :key="row.id">
<tr class="tr-row" v-if="row">
<td v-for="(col, sonColIndex) in columns"
:key="sonColIndex"
:class="[
{'td-expand': sonColIndex === 0},
{'td-operations': col.type === 'operation'},
col.className
]"
:style="{
width: col.width || 'auto',
minWidth: col.minWidth || (sonColIndex === 0 ? '200px' : '100px')
}"
>
<RenderCol v-if="col.render" :col="col.render" :param="{ row, index, col }" />
<template v-else>
<div class="first-box" v-if="sonColIndex == 1">
<div class="col-row expand-row" v-if="row.children && row.children.length > 0" @click="expander(row)">
<span class="indent-span" :style="{width:`${row.indentWidth}px`}"></span>
<i :class="['ivu-icon', 'link', 'ivu-icon-' + row.indexIcon]" aria-hidden="true"></i>
<label>{{ row[col.key] }}</label>
</div>
<div class="col-row" v-else>
<span class="indent-span" :style="{width:`${row.indentWidth}px`}"></span>
<label>{{ row[col.key] }}</label>
</div>
</div>
<label v-else>{{ row[col.key] }}</label>
</template>
</td>
</tr>
<tr v-show="row._expand" class="treegrid-tr-tree ivu-table-row">
<th :colspan="columns.length" class="non-right ">
<SubTreeGrid :columns="columns" :data="row['children']" :grade="grade + 1" />
</th>
</tr>
</tbody>
</table>
</template>
<script>
import SubTreeGrid from './subTreeGrid.vue'
import SubColmns from './subColmns.vue'
import RenderCol from './renderCol.vue'
export default {
name: 'SubTreeGrid',
props: ['columns', 'data', 'grade'],
components: {
RenderCol,
SubTreeGrid,
SubColmns
},
data() {
return {
subData: []
}
},
created() {
this.setData()
},
watch: {
data: {
handler() {
this.setData()
},
deep: true
}
},
methods: {
// 本地存储 key用于持久化展开状态
_expandStoreKey() {
return 'treegrid_expand_map_v1'
},
loadExpandMap() {
try {
const raw = localStorage.getItem(this._expandStoreKey())
return raw ? JSON.parse(raw) : {}
} catch (e) {
return {}
}
},
saveExpandMap(map) {
try {
localStorage.setItem(this._expandStoreKey(), JSON.stringify(map))
} catch (e) {
// ignore
}
},
// 递归收集当前 subData 的展开状态(用于在 data 更新时保留)
collectExpandMapFromList(list, map) {
if (!list || !list.length) return
list.forEach((r) => {
const id = r.id || r.timeKey
if (id !== undefined) map[id] = !!r._expand
if (r.children && r.children.length) this.collectExpandMapFromList(r.children, map)
})
},
setData() {
if (this.data && this.data.length > 0) {
// 优先从本地存储读取上次的展开状态
const storedMap = this.loadExpandMap()
// 也从当前内存 subData 中读取(以便在快速编辑时保留)
const currentMap = {}
this.collectExpandMapFromList(this.subData, currentMap)
let { expand } = this.$attrs
const subData = this.data.map((p) => {
// 不直接修改传入对象,复制一份以避免副作用
const row = Object.assign({}, p)
const id = row.id || row.timeKey
// 优先使用 currentMap内存其次使用本地存储最后使用传入的默认 expand
if (id !== undefined && currentMap.hasOwnProperty(id)) {
row._expand = !!currentMap[id]
} else if (id !== undefined && storedMap.hasOwnProperty(id)) {
row._expand = !!storedMap[id]
} else {
row._expand = expand
}
// 保留已有 timeKey若不存在则创建
row.timeKey = row.timeKey || new Date().getTime()
row.indexIcon = row._expand ? 'md-remove' : 'md-add'
row.indentWidth = (this.grade - 1) * 30
// 递归处理 children保留结构不展开子项的数据填充在子组件中
if (row.children && row.children.length) {
// children 内容将在子组件中处理,不在此处深拷贝以节约性能
}
return row
})
// 保存当前展开状态
const newMap = {}
this.collectExpandMapFromList(subData, newMap)
// 合并之前的存储(优先保留 newMap 中的值)
const merged = Object.assign({}, storedMap, newMap)
this.saveExpandMap(merged)
this.subData = subData
} else {
this.subData = []
}
},
expander(row) {
console.log('expander called:', {
id: row.id,
timeKey: row.timeKey,
children: row.children,
currentExpand: row._expand,
icon: row.indexIcon
});
// use Vue.set for reactivity
const newExpand = !row._expand;
this.$set(row, '_expand', newExpand);
this.$set(row, 'indexIcon', newExpand ? 'md-remove' : 'md-add');
// 更新本地存储状态
const map = this.loadExpandMap();
const id = row.id || row.timeKey;
if (id !== undefined) {
map[id] = newExpand;
this.saveExpandMap(map);
}
// 打印展开后的状态
console.log('expander updated:', {
id: id,
newExpand: newExpand,
newIcon: row.indexIcon,
storedState: map[id]
});
// 确保视图更新
this.$nextTick(() => {
console.log('After nextTick:', {
element: document.querySelector(`.tr-row[data-id="${id}"] .expand-row`),
expandState: row._expand,
icon: row.indexIcon
});
});
}
}
}
</script>
<style lang="less" scoped>
/* 表格容器样式 */
table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
}
/* 确保表格可以在容器内滚动 */
.endTbody {
max-height: calc(100vh - 200px); /* 预留头部和其他元素的空间 */
overflow-y: auto;
}
.link {
color: #2d8cf0;
font-weight: bold;
font-size: 16px;
line-height: 1;
margin-right: 6px;
cursor: pointer;
display: inline-block;
vertical-align: middle;
}
.expand-row {
display: flex;
align-items: center;
cursor: pointer;
padding: 4px 8px;
border-radius: 3px;
transition: background-color 0.2s;
&:hover {
background-color: rgba(45,140,240,0.05);
}
}
.fallback-icon {
display: inline-block;
width: 18px;
height: 18px;
line-height: 18px;
text-align: center;
border-radius: 2px;
background: rgba(45,140,240,0.08);
color: #2d8cf0;
margin-right: 6px;
cursor: pointer;
user-select: none;
}
.tr-row {
padding: 0px 5px;
td {
text-align: center;
word-wrap: break-word;
font-weight: normal;
}
}
.first-box {
padding: 0px 10px;
.col-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
.indent-span {
display: inline-block;
height: 2px;
}
}
}
.non-right {
border: none !important;
}
/* 表格基础样式 */
.tr-row {
&:hover {
background-color: #f5f7fa;
}
td {
position: relative;
padding: 12px 8px;
line-height: 1.5;
text-align: left;
border-bottom: 1px solid #e8eaec;
/* 基础文本溢出处理 */
&:not(.td-operations) {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
/* 展开列样式 */
.td-expand {
width: auto;
min-width: 200px; /* 给展开图标和文本预留足够空间 */
}
/* 操作列样式 */
.td-operations {
text-align: right;
white-space: nowrap;
width: auto;
min-width: 180px; /* 增加按钮空间 */
padding-right: 16px;
overflow: visible !important; /* 强制确保内容可见 */
position: relative;
z-index: 1; /* 确保按钮在最上层 */
}
/* 确保操作列中的按钮组样式 */
.td-operations .ivu-btn-group {
display: inline-flex;
visibility: visible;
opacity: 1;
}
/* 确保单个按钮样式 */
.td-operations .ivu-btn {
display: inline-block;
visibility: visible;
opacity: 1;
margin: 0 2px;
}
}
</style>

View File

@@ -0,0 +1,54 @@
<template>
<div class="ivu-table-wrapper">
<div class="ivu-table ivu-table-border">
<table cellspacing="0" width="100%" cellpadding="0" border="0">
<SubColmns :columns="columns"></SubColmns>
<subTheads :columns="columns"></subTheads>
<tr class="ivu-table-tbody">
<td style="width: 100%;" :colspan="columns.length" class="non-right">
<SubTreeGrid :columns="columns" :data="data" :grade="1" v-bind="$attrs" />
</td>
</tr>
</table>
</div>
</div>
</template>
<script>
import SubTreeGrid from './component/subTreeGrid.vue'
import SubColmns from './component/subColmns.vue'
import subTheads from './component/subThead.vue'
export default {
props: ['columns', 'data'],
data() {
return {}
},
components: {
SubTreeGrid,
SubColmns,
subTheads
},
methods: {},
mounted() {}
}
</script>
<style lang="less" scoped>
.ivu-table {
/* 限制最大宽度,超出显示横向滚动 */
overflow: auto !important;
max-width: 100%;
box-sizing: border-box;
.ivu-table-tbody {
width: 100%;
}
.non-right {
border: none !important;
}
}
</style>

View File

@@ -0,0 +1,108 @@
<template>
<div class="custom-file-box">
<Input :value="value" disabled />
<FileBtn class="mt10" ref="fileBtn" accept="*" @change="changeFile" :text="btnText"></FileBtn>
<p class="link">{{ ossFileUrl }}</p>
<div class="file-box" v-if="file">
<div class="name ml5">{{ file.name }}</div>
<div class="slider-box">
<Progress :percent="uploadProcess"></Progress>
</div>
</div>
</div>
</template>
<script>
import tools from '@/utils/tools'
import FileBtn from './mod/fileBtn.vue'
import uploadOssMixin from '@/mixin/uploadOssMixin.js'
export default {
components: { FileBtn },
mixins: [uploadOssMixin],
props: {
value: {
type: String,
default: ''
},
accept: {
type: String,
default: '*'
},
btnText: {
type: String,
default: '上传文件'
}
},
data() {
return {
file: null,
uploadProcess: 0,
ossFileUrl: ''
}
},
methods: {
clear() {
this.clearFile()
},
clearFile() {
if (this.$refs['fileBtn']) {
this.$refs['fileBtn'].clear()
}
},
async changeFile(e) {
if (e.target.files && e.target.files.length > 0) {
let file = e.target.files[0]
this.file = file
let ossFileUrl = await this.singlePartUpload(file, ({ uploadProcess, fileName }) => {
this.uploadProcess = uploadProcess
console.log('singlePartUpload', { uploadProcess, fileName })
})
this.ossFileUrl = ossFileUrl
this.$emit('input', ossFileUrl)
}
}
}
}
</script>
<style lang="less" scoped>
.custom-file-box {
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
.file-box {
width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
.name {
font-weight: bold;
}
.slider-box {
display: flex;
justify-content: flex-start;
align-items: flex-start;
text-align: left;
width: 100%;
}
}
}
.input-file {
width: 0px;
height: 0px;
position: absolute;
opacity: 0;
}
</style>

View File

@@ -0,0 +1,93 @@
<template>
<div>
<div class="upload-list" v-for="(item,index) in uploadList" :key='index'>
<template>
<img :src="item.url">
<div class="upload-list-cover">
<Icon type="ios-eye-outline" @click.native="handleView(item)"></Icon>
<Icon type="ios-trash-outline" @click.native="handleRemove(item)"></Icon>
</div>
</template>
</div>
<Upload ref="upload" :show-upload-list="false" :on-success="handleSuccess" :headers="headers" :format="['jpg','jpeg','png']" :max-size="2048" :on-exceeded-size="handleMaxSize" :on-format-error="handleFormatError" :before-upload="handleBeforeUpload" multiple type="drag" :action="actionUrl" style="display: inline-block;width:100px;">
<div style="width: 100px;height:100px;line-height: 100px;">
<Icon type="ios-camera" size="20"></Icon>
</div>
</Upload>
<Modal title="查看图片" v-model="visible" footer-hide>
<img :src="imgSrc" v-if="visible" style="width: 100%">
</Modal>
</div>
</template>
<script>
import './upload.less'
import store from '@/store'
let headers = {
'admin-token': store.state.user.token,
}
export default {
name: 'UoloadImg',
props: { isOss: { default: true } },
data() {
return {
headers,
uploadList: [],
actionUrl: window.rootVue.$config.apiUrl + 'sys_file/upload_oos_img',
imgSrc: '',
visible: false,
}
},
mounted() {},
computed: {},
methods: {
setList(list) {
this.uploadList = list
},
getList() {
return this.uploadList
},
handleView(item) {
this.imgSrc = item.url
this.visible = true
},
handleSuccess(res, file) {
let row = res.data
file.url = row.path
file.name = row.name
this.uploadList.push({ name: row.name, url: row.path })
this.$emit('handleSuccess', { file, uploadList: this.uploadList })
},
handleRemove(file) {
const fileList = this.uploadList
this.uploadList.splice(fileList.indexOf(file), 1)
},
handleFormatError(file) {
this.$Notice.warning({
title: '提示',
desc: '文件格式 ' + file.name + ' 错误, 请上传png,jpg格式',
})
},
handleMaxSize(file) {
this.$Notice.warning({
title: '提示',
desc: '文件 ' + file.name + ' 太大, 请上传文件不超过 2M.',
})
},
handleBeforeUpload() {
const check = this.uploadList.length < 4
if (!check) {
this.$Notice.warning({
title: '文件数量不能超过4个',
})
}
return check
},
},
}
</script>

View File

@@ -0,0 +1,75 @@
<template>
<div class="single-box">
<div class="upload-list" v-if="value">
<template>
<img :src="value" />
<div class="upload-list-cover">
<Icon type="ios-eye-outline" @click="handleView(value)"></Icon>
<Icon type="ios-trash-outline" @click="handleRemove(value)"></Icon>
</div>
</template>
</div>
<Upload ref="upload" v-else :show-upload-list="false" :before-upload="handleBeforeUpload" :on-success="handleSuccess" :max-size="2048" :on-exceeded-size="handleMaxSize" :on-format-error="handleFormatError" :headers="headers" :format="['jpg','jpeg','png']" type="drag" :action="actionUrl" style="display: inline-block;width:100px;">
<div style="width: 100px;height:100px;line-height: 100px;">
<Icon type="ios-camera" size="20"></Icon>
</div>
</Upload>
<Modal title="查看大图" v-model="visible" footer-hide>
<img :src="imgUrl" style="width: 100%" />
</Modal>
</div>
</template>
<script>
import './upload.less'
import store from '@/store'
let headers = {
'admin-token': store.state.user.token,
}
export default {
props: ['value'],
computed: {},
data() {
return {
headers,
actionUrl: window.rootVue.$config.apiUrl + 'sys_file/upload_oos_img',
imgUrl: '',
visible: false,
}
},
mounted() {},
methods: {
handleFormatError(file) {
this.$Message.warning('文件格式 ' + file.name + ' 错误, 请上传png,jpg格式')
},
handleMaxSize(file) {
this.$Message.warning('文件 ' + file.name + ' 太大, 请上传文件不超过 2M.')
},
handleBeforeUpload() {},
handleView(url) {
this.imgUrl = url
this.visible = true
},
handleRemove(url) {
this.$emit('input', '')
},
handleSuccess(res, file) {
let row = res.data
file.url = row.path
file.name = row.name
setTimeout(() => {
this.$emit('input', row.path)
this.$emit('change', row.path)
}, 200)
},
},
}
</script>
<style lang="less" scoped>
.single-box {
text-align: left;
}
</style>

View File

@@ -0,0 +1,51 @@
<template>
<div class="file-upload-box">
<input ref="file" class="file" type="file" :multiple="multiple" :accept="accept" @change="changeFiles" />
<slot>
<Button type="primary" icon="md-arrow-round-up" round>{{text}} </Button>
</slot>
</div>
</template>
<script>
export default {
props: {
text: {
type: String,
default: '选择文件'
},
multiple: {
type: Boolean,
default: false
},
accept: {
type: String,
default: `.mp4,.flv,.avi,.mkv,.mov,.webm`
}
},
data() {
return {}
},
methods: {
clear() {
this.$refs['file'].value = ''
},
changeFiles(e) {
this.$emit('change', e)
}
}
}
</script>
<style lang="less" scoped>
.file-upload-box {
position: relative;
display: inline-block;
.file {
height: 100%;
width: 100%;
position: absolute;
opacity: 0;
}
}
</style>

View File

@@ -0,0 +1,177 @@
<template>
<asyncModal ref="fileListModal" v-bind="$attrs">
<div class="upload-local-multiple-file-box w100 ">
<div class="top-box">
<div class="tip">
<p>上传过程中请勿关闭窗口</p>
</div>
<div class="upload-tool-box">
<FileBtn ref="fileBtn" :multiple="true" @change="changeFiles" text="批量选择文件" :accept="accept"></FileBtn>
<Button round class="ml10" size="mini" @click="clear">清空列表</Button>
</div>
</div>
<div class="fileListBox mt20">
<Tables ref="table" :data="curRows" :columns="columns"></Tables>
</div>
</div>
</asyncModal>
</template>
<script>
import uiTool from '@/utils/uiTool'
import uploadOssMixin from '@/mixin/uploadOssMixin.js'
import FileBtn from './fileBtn.vue'
import uploadServer from '@/api/system/uploadServer'
export default {
mixins: [uploadOssMixin],
components: { FileBtn },
props: {
isUpload: {
type: Boolean,
default: false
},
formatRow: {
type: Function,
default: null
},
columns: {
type: Array,
default: [
{ key: 'name', title: '名称' },
{ key: 'infoSize', title: '文件大小' },
{ key: 'resolutionRatio', title: '分辨率' },
{ key: 'preViewUrl', title: '预览图' }
]
}
},
data() {
return {
callback: null,
gridOption: {
data: []
}
}
},
computed: {
curRows() {
let rows = this.gridOption.data
return rows
}
},
methods: {
clear() {
this.gridOption.data = []
this.clearFile()
},
clearFile() {
if (this.$refs['fileBtn']) {
this.$refs['fileBtn'].clear()
}
},
hide() {
this.$refs['fileListModal'].hide()
},
async show(callback) {
this.clear()
this.$refs['fileListModal'].setBtnState(true)
this.callback = callback
this.$refs['fileListModal'].show(async () => {
let { data } = this.gridOption
// 立马开始上传
if (this.isUpload) {
await this.startHandUpload()
}
if (this.callback) {
await this.callback(data)
}
})
},
async changeFiles(e) {
let files = e.currentTarget.files
this.gridOption.data = []
for (let i = 0; i < files.length; i++) {
let file = files[i]
let { name, size, type } = file
const url = window.URL.createObjectURL(file)
let infoSize = uiTool.fileSizeTransfer(size / 1024 / 1024)
let row = { name, size, type, infoSize, url, file, uploadProcess: 0 }
// 需要外层处理 数据
if (this.formatRow) {
let tempRow = await this.formatRow(row)
row = Object.assign({}, row, tempRow)
}
this.gridOption.data.push(row)
}
this.$emit('changeFileComplete', this.gridOption.data)
this.$refs['fileListModal'].setBtnState(false)
this.clearFile()
},
async startHandUpload() {
let data = this.gridOption.data
for (let i = 0; i < data.length; i++) {
let { file } = data[i]
// 大于100m 走 分片上传
if (file.size / 1024 / 1024 > 50) {
let ossUrl = await this.singlePartUpload(file, ({ uploadProcess }) => {
this.$set(data[i], 'uploadProcess', uploadProcess)
})
this.$set(data[i], 'url', ossUrl)
} else {
let ossUrl = await this.uploadFile(file)
this.$set(data[i], 'uploadProcess', 100)
this.$set(data[i], 'url', ossUrl)
}
}
},
async uploadFile(file) {
let res = await uploadServer.uploadFile({ file })
let url = res.data
return url
}
}
}
</script>
<style lang="less" scoped>
.top-box {
display: flex;
align-items: center;
justify-content: space-between;
}
.upload-tool-box {
display: flex;
align-items: center;
justify-content: flex-end;
}
.fileListBox {
.img {
height: 60px;
margin: 5px;
}
}
</style>

View File

@@ -0,0 +1,40 @@
.upload-list {
display: inline-block;
width: 100px;
height: 100px;
text-align: center;
line-height: 100px;
border: 1px solid transparent;
border-radius: 4px;
overflow: hidden;
background: #fff;
position: relative;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2);
margin-right: 4px;
img {
width: 100%;
height: 100%;
}
}
.upload-list-cover {
display: none;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.6);
}
.upload-list:hover .upload-list-cover {
display: block;
}
.upload-list-cover i {
color: #fff;
font-size: 20px;
cursor: pointer;
margin: 0 2px;
}