This commit is contained in:
parent
3ff4fa97a6
commit
89e792dcca
22
package.json
22
package.json
|
|
@ -10,27 +10,31 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"axios": "^1.9.0",
|
||||
"cropperjs": "1.5.13",
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"axios": "1.12.0",
|
||||
"cropperjs": "^1.6.2",
|
||||
"crypto-js": "^4.2.0",
|
||||
"element-plus": "^2.9.10",
|
||||
"echarts": "^6.0.0",
|
||||
"element-plus": "2.11.3",
|
||||
"image-conversion": "^2.1.1",
|
||||
"nprogress": "^0.2.0",
|
||||
"pinia": "^3.0.3",
|
||||
"pinia-plugin-persistedstate": "^4.3.0",
|
||||
"sortablejs": "^1.15.6",
|
||||
"vue": "^3.5.14",
|
||||
"vue-i18n": "^11.1.5",
|
||||
"vue": "^3.5.22",
|
||||
"vue-i18n": "^11.1.12",
|
||||
"vue-router": "^4.5.1",
|
||||
"vuedraggable": "^2.24.3"
|
||||
"vuedraggable": "^4.1.0",
|
||||
"xgplayer": "^3.0.22",
|
||||
"xgplayer-hls": "^3.0.22"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.15.21",
|
||||
"@types/node": "^24.9.1",
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"eslint": "^9.27.0",
|
||||
"sass": "^1.89.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.5"
|
||||
"vite": "7.1.5"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,4 +89,17 @@ export default {
|
|||
upload: async function (data, config = {}) {
|
||||
return await http.post("upload", data, config);
|
||||
},
|
||||
monitor: {
|
||||
server: async function (data, config = {}) {
|
||||
return await http.get("monitor/server", data, config);
|
||||
}
|
||||
},
|
||||
online: {
|
||||
list: async function (data, config = {}) {
|
||||
return await http.get("online/list", data, config);
|
||||
},
|
||||
quit: async function (data, config = {}) {
|
||||
return await http.get("online/quit", data, config);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
<template>
|
||||
<svg t="1761031307486" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8433" width="256" height="256"><path d="M512 800m-96 0a96 96 0 1 0 192 0 96 96 0 1 0-192 0Z" p-id="8434"></path><path d="M748.096 501.44a324.608 324.608 0 0 0-230.272-95.36c-87.52 0-169.6 34.72-230.272 95.36a326.72 326.72 0 0 0-42.464 52.256 32 32 0 1 0 53.568 35.008 263.616 263.616 0 0 1 34.144-41.984 260.704 260.704 0 0 1 185.024-76.64c70.368 0 136.224 27.872 184.992 76.64 12.8 12.8 24.256 26.88 34.144 42.016a32 32 0 0 0 53.568-35.008 325.024 325.024 0 0 0-42.432-52.288z" p-id="8435"></path><path d="M991.776 384.576a569.824 569.824 0 0 0-73.792-90.816C812.544 188.32 669.92 128 517.824 128S223.072 188.32 117.664 293.76a568.416 568.416 0 0 0-73.792 90.816 32 32 0 0 0 53.568 35.008 505.76 505.76 0 0 1 65.472-80.608C256.448 245.472 382.88 192 517.824 192s261.344 53.472 354.912 147.008a502.944 502.944 0 0 1 65.472 80.608 32 32 0 1 0 53.568-35.04z" p-id="8436"></path></svg>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
<template>
|
||||
<div class="pi-asset">
|
||||
<div class="file-item" v-for="(item, index) in value" :key="index" :style="style">
|
||||
<el-image v-if="obj.isImg(item)" :src="item" :style="style" :preview-src-list="[item]" fit="cover"
|
||||
preview-teleported :z-index="9999"></el-image>
|
||||
<pi-player v-else-if="obj.isVideo(item)" :src="item" :options="videoOptions"></pi-player>
|
||||
<el-icon v-else :style="style" style="color: #409eff;" :size="32">
|
||||
<component :is="'pi-icon-task'"/>
|
||||
</el-icon>
|
||||
<div class="file-item-delete" @click="del(index)">
|
||||
<el-icon>
|
||||
<component :is="'el-icon-delete'" color="#ffffff"/>
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<span class="pi-upload" :style="style" v-if="limit > value.length" @click="openDialog">
|
||||
<el-icon><component :is="'el-icon-plus'"/></el-icon>
|
||||
</span>
|
||||
<picker-dialog v-if="dialogVisible" ref="pickerRef" @success="saveSuccess" @closed="dialogVisible=false"
|
||||
:max="limit-value.length" :multiple="limit>1" :type="type"></picker-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import pickerDialog from "./picker.vue";
|
||||
import assetConfig from "@/config/asset"
|
||||
import piPlayer from '@/components/piPlayer'
|
||||
import {nextTick, ref, watch} from "vue";
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const props = defineProps({
|
||||
modelValue: null,
|
||||
limit: {type: Number, default: 1},
|
||||
height: {type: Number, default: 148},
|
||||
width: {type: Number, default: 148},
|
||||
type: {type: String, default: "image"}
|
||||
})
|
||||
|
||||
let dialogVisible = ref(false)
|
||||
const pickerRef = ref(null)
|
||||
let value = ref([])
|
||||
let style = ref({
|
||||
width: props.width + "px",
|
||||
height: props.height + "px"
|
||||
})
|
||||
let obj = ref(assetConfig)
|
||||
let videoOptions = ref({
|
||||
pip: true
|
||||
})
|
||||
|
||||
watch(value, (val) => {
|
||||
if (props.limit > 1) {
|
||||
emit('update:modelValue', val)
|
||||
} else {
|
||||
emit('update:modelValue', val[0])
|
||||
}
|
||||
}, {deep: true, immediate: false})
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
if (props.limit > 1) {
|
||||
if (!val || val.length <= 0) return;
|
||||
value.value = val
|
||||
} else {
|
||||
if (!val) return;
|
||||
value.value = val ? [val] : []
|
||||
}
|
||||
}, {deep: true, immediate: true})
|
||||
|
||||
function saveSuccess(val) {
|
||||
if (props.limit > 1) {
|
||||
value.value = value.value.concat(val)
|
||||
} else {
|
||||
value.value = [val]
|
||||
}
|
||||
}
|
||||
|
||||
function del(index) {
|
||||
value.value.splice(index, 1)
|
||||
}
|
||||
|
||||
function openDialog() {
|
||||
dialogVisible.value = true
|
||||
nextTick(() => {
|
||||
pickerRef.value.open()
|
||||
})
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.pi-asset {
|
||||
width: 100%;
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.pi-asset .file-item {
|
||||
margin-right: 10px;
|
||||
margin-bottom: 10px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pi-asset .file-item:hover .file-item-delete {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pi-asset .file-item .file-item-delete {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
background: #F56C6C;
|
||||
line-height: initial;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
display: none;
|
||||
cursor: pointer;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.pi-asset .pi-upload {
|
||||
border: 1px dashed var(--el-border-color-darker);
|
||||
background-color: var(--el-fill-color-lighter);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-right: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.pi-asset .pi-upload:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
<template>
|
||||
<el-dialog title="移动文件" v-model="visible" :width="500" destroy-on-close @closed="$emit('closed')">
|
||||
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
|
||||
<el-form-item label="选择" prop="category_id">
|
||||
<el-cascader ref="categoryRef" v-model="form.category_id" :options="menu" :props="menuProps" clearable
|
||||
style="width: 100%;"></el-cascader>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="visible=false">取 消</el-button>
|
||||
<el-button type="primary" :loading="isSaveing" @click="submit()">保 存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {ref} from "vue";
|
||||
|
||||
defineExpose({
|
||||
open
|
||||
})
|
||||
|
||||
const emit = defineEmits(['success', 'closed'])
|
||||
const categoryRef = ref(null)
|
||||
const formRef = ref(null)
|
||||
|
||||
let visible = ref(false)
|
||||
let isSaveing = ref(false)
|
||||
let form = ref({
|
||||
category_id: 0
|
||||
})
|
||||
let rules = ref({
|
||||
category_name: [
|
||||
{required: true, message: '请输入分类名称'}
|
||||
]
|
||||
})
|
||||
let menu = ref([])
|
||||
let menuProps = ref({
|
||||
value: "category_id",
|
||||
label: "category_name",
|
||||
checkStrictly: true
|
||||
})
|
||||
|
||||
function open(data) {
|
||||
visible.value = true
|
||||
if (data) {
|
||||
menu.value = data
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (categoryRef.value.getCheckedNodes().length > 0) {
|
||||
form.value.category_id = categoryRef.value.getCheckedNodes()[0].value
|
||||
}
|
||||
emit('success', form.value.category_id)
|
||||
visible.value = false;
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.el-form-item {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,646 @@
|
|||
<template>
|
||||
<el-dialog title="选择文件" v-model="visible" :width="900" destroy-on-close @closed="$emit('closed')"
|
||||
append-to-body>
|
||||
<div class="pi-file-select">
|
||||
<div class="pi-file-select__side" v-loading="menuLoading">
|
||||
<div class="pi-file-select__side-menu">
|
||||
<el-tree ref="menuRef" class="menu" :data="menu" :node-key="treeProps.key" :props="treeProps"
|
||||
:expand-on-click-node="false" check-strictly show-checkbox
|
||||
:current-node-key="menu.length>0?menu[0][treeProps.key]:''" highlight-current
|
||||
@node-click="groupClick" :check-on-click-leaf="false">
|
||||
<template #default="{ node, data }">
|
||||
<span class="custom-tree-node el-tree-node__label">
|
||||
<span class="label">
|
||||
{{ node.label }}
|
||||
</span>
|
||||
<span class="do">
|
||||
<el-icon @click.stop="editCategory(data)"><el-icon-edit/></el-icon>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
</el-tree>
|
||||
</div>
|
||||
<div class="pi-file-select__side-msg">
|
||||
<el-button type="primary" size="small" icon="el-icon-plus" @click="addCategory"></el-button>
|
||||
<el-button type="danger" size="small" plain icon="el-icon-delete" @click="delCategory"></el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pi-file-select__files" v-loading="listLoading">
|
||||
<div class="pi-file-select__top">
|
||||
<div class="upload" v-if="!hideUpload">
|
||||
<el-upload class="pi-file-select__upload" action="" multiple :show-file-list="false"
|
||||
:accept="accept" :on-change="uploadChange" :before-upload="uploadBefore"
|
||||
:on-progress="uploadProcess" :on-success="uploadSuccess" :on-error="uploadError"
|
||||
:http-request="uploadRequest">
|
||||
<el-button type="primary" icon="el-icon-upload">本地上传</el-button>
|
||||
</el-upload>
|
||||
<el-button type="danger" icon="el-icon-delete" @click="delFile">删除</el-button>
|
||||
<el-button type="primary" icon="el-icon-rank" @click="moveFile">移动</el-button>
|
||||
</div>
|
||||
<div class="keyword">
|
||||
<el-input v-model="file_name" style="width: 220px;" prefix-icon="el-icon-search"
|
||||
placeholder="文件名搜索" clearable @keyup.enter="search" @input="search"
|
||||
@clear="search"></el-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pi-file-select__list">
|
||||
<el-scrollbar ref="scrollbarRef">
|
||||
<el-empty v-if="fileList.length==0 && data.length==0" description="无数据"
|
||||
:image-size="80"></el-empty>
|
||||
<div v-for="(file, index) in fileList" :key="index" class="pi-file-select__item">
|
||||
<div class="pi-file-select__item__file">
|
||||
<div class="pi-file-select__item__upload">
|
||||
<el-progress type="circle" :percentage="file.progress" :width="70"></el-progress>
|
||||
</div>
|
||||
<el-image :src="file.tempImg" fit="contain"></el-image>
|
||||
</div>
|
||||
<p>{{ file.name }}</p>
|
||||
</div>
|
||||
<div v-for="item in data" :key="item[fileProps.fileName]" class="pi-file-select__item"
|
||||
:class="{active: value.includes(item[fileProps.url]) }" @click="select(item)">
|
||||
<div class="pi-file-select__item__file">
|
||||
<div class="pi-file-select__item__checkbox" v-if="multiple">
|
||||
<el-icon>
|
||||
<el-icon-check/>
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="pi-file-select__item__select" v-else>
|
||||
<el-icon>
|
||||
<el-icon-check/>
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="pi-file-select__item__box"></div>
|
||||
<el-image v-if="obj.isImg(item[fileProps.url])" :src="item[fileProps.url]" fit="contain"
|
||||
lazy></el-image>
|
||||
<div v-else-if="obj.isVideo(item[fileProps.url])" class="item-video">
|
||||
<el-icon size="32px">
|
||||
<component :is="'pi-icon-video'"/>
|
||||
</el-icon>
|
||||
</div>
|
||||
<div v-else class="item-file item-file-doc">
|
||||
<el-icon size="32px">
|
||||
<component :is="'pi-icon-task'"/>
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<p :title="item[fileProps.fileName]">{{ item[fileProps.fileName] }}</p>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
<div class="pi-file-select__pagination">
|
||||
<el-pagination size="small" background layout="prev, pager, next" :total="total" :page-size="pageSize"
|
||||
v-model:currentPage="currentPage" @current-change="reload"></el-pagination>
|
||||
</div>
|
||||
<div class="pi-file-select__do">
|
||||
<div><span v-if="multiple">已选择 <b>{{ value.length }}</b> / <b>{{ max }}</b> 项</span></div>
|
||||
<el-button type="primary" :disabled="value.length<=0" @click="submit">确 定</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<save-dialog v-if="dialog.save" ref="saveRef" @success="getMenu" @closed="dialog.save=false"></save-dialog>
|
||||
<move-dialog v-if="dialog.move" ref="moveRef" @success="moveSuccess"
|
||||
@closed="dialog.move=false"></move-dialog>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import assetConfig from "@/config/asset"
|
||||
import saveDialog from "./save.vue";
|
||||
import moveDialog from "./move.vue";
|
||||
import * as imageConversion from 'image-conversion';
|
||||
import {ref, onMounted, nextTick, getCurrentInstance} from "vue"
|
||||
import tools from "@/utils/tools"
|
||||
|
||||
defineExpose({
|
||||
open
|
||||
})
|
||||
|
||||
const emit = defineEmits(['success', 'closed'])
|
||||
const props = defineProps({
|
||||
hideUpload: {type: Boolean, default: false},
|
||||
multiple: {type: Boolean, default: false},
|
||||
max: {type: Number, default: assetConfig.max},
|
||||
maxSize: {type: Number, default: assetConfig.maxSize},
|
||||
type: {type: String, default: "image"}
|
||||
})
|
||||
const {proxy} = getCurrentInstance()
|
||||
const saveRef = ref(null)
|
||||
const moveRef = ref(null)
|
||||
const scrollbarRef = ref(null)
|
||||
const menuRef = ref(null)
|
||||
|
||||
let dialog = ref({
|
||||
save: false,
|
||||
move: false
|
||||
})
|
||||
let file_name = ref(null)
|
||||
let pageSize = ref(20)
|
||||
let total = ref(0)
|
||||
let currentPage = ref(1)
|
||||
let data = ref([])
|
||||
let menu = ref([])
|
||||
let category_id = ref(0)
|
||||
let value = ref(props.multiple ? [] : '')
|
||||
let fileList = ref([])
|
||||
let accept = ref(assetConfig.fileType(props.type)['accept'])
|
||||
let listLoading = ref(false)
|
||||
let menuLoading = ref(false)
|
||||
let treeProps = ref(assetConfig.menuProps)
|
||||
let fileProps = ref(assetConfig.fileProps)
|
||||
let visible = ref(false)
|
||||
let obj = ref(assetConfig)
|
||||
|
||||
onMounted(() => {
|
||||
getMenu()
|
||||
getData()
|
||||
})
|
||||
|
||||
async function open() {
|
||||
visible.value = true;
|
||||
}
|
||||
|
||||
async function getMenu() {
|
||||
menuLoading.value = true
|
||||
const res = await assetConfig.menuListObj();
|
||||
menu.value = tools.makeTreeData(res.data, 0, "category_id")
|
||||
menu.value.unshift({
|
||||
'category_id': '',
|
||||
'category_name': '未分类',
|
||||
'pid': 0
|
||||
})
|
||||
menuLoading.value = false
|
||||
}
|
||||
|
||||
async function getData() {
|
||||
listLoading.value = true
|
||||
const reqData = {
|
||||
[assetConfig.request.menuKey]: category_id.value,
|
||||
[assetConfig.request.page]: currentPage.value,
|
||||
[assetConfig.request.pageSize]: pageSize.value,
|
||||
};
|
||||
reqData.type = props.type
|
||||
reqData.file_name = file_name.value
|
||||
const res = await assetConfig.fileListObj(reqData);
|
||||
const parseData = assetConfig.listParseData(res);
|
||||
data.value = parseData.rows
|
||||
total.value = parseData.total
|
||||
listLoading.value = false
|
||||
}
|
||||
|
||||
function groupClick(data) {
|
||||
category_id.value = data.category_id
|
||||
currentPage.value = 1
|
||||
file_name.value = null
|
||||
getData()
|
||||
}
|
||||
|
||||
function reload() {
|
||||
getData()
|
||||
}
|
||||
|
||||
function search() {
|
||||
currentPage.value = 1
|
||||
getData()
|
||||
}
|
||||
|
||||
function select(item) {
|
||||
const itemUrl = item[fileProps.value.url]
|
||||
if (props.multiple) {
|
||||
if (value.value.length >= props.max) {
|
||||
proxy.$message.error("选择文件过多")
|
||||
return;
|
||||
}
|
||||
if (value.value.includes(itemUrl)) {
|
||||
value.value.splice(value.value.findIndex(f => f == itemUrl), 1)
|
||||
} else {
|
||||
value.value.push(itemUrl)
|
||||
}
|
||||
} else {
|
||||
if (value.value.includes(itemUrl)) {
|
||||
value.value = ''
|
||||
} else {
|
||||
value.value = itemUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function submit() {
|
||||
emit('success', value.value);
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
async function delFile() {
|
||||
const _value = JSON.parse(JSON.stringify(value.value))
|
||||
const url = props.multiple ? _value.toString() : _value;
|
||||
if (!url) return;
|
||||
const res = await assetConfig.delFileObj({url: url});
|
||||
proxy.$message.success(res.msg)
|
||||
getData()
|
||||
}
|
||||
|
||||
function moveFile() {
|
||||
dialog.value.move = true
|
||||
nextTick(() => {
|
||||
moveRef.value.open(menu.value)
|
||||
})
|
||||
}
|
||||
|
||||
async function moveSuccess(category_id) {
|
||||
console.log(category_id)
|
||||
const _value = JSON.parse(JSON.stringify(value.value))
|
||||
const url = props.multiple ? _value.toString() : _value;
|
||||
if (!url) return;
|
||||
const res = await assetConfig.moveFileObj({url: url, category_id: category_id});
|
||||
proxy.$message.success(res.msg)
|
||||
getData()
|
||||
}
|
||||
|
||||
function uploadChange(file, _fileList) {
|
||||
file.tempImg = URL.createObjectURL(file.raw);
|
||||
fileList.value = _fileList
|
||||
}
|
||||
|
||||
function uploadBefore(file) {
|
||||
const fileSize = file.size / 1024;
|
||||
if (assetConfig.isImg(file.name) && fileSize > 800) {
|
||||
return new Promise((resolve) => {
|
||||
imageConversion.compressAccurately(file, 800).then((res) => {
|
||||
resolve(res);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
if (fileSize > props.maxSize * 1024) {
|
||||
proxy.$message.warning(`上传文件大小不能超过 ${props.maxSize}MB!`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function uploadRequest(param) {
|
||||
// 获取文件类型
|
||||
const _type = assetConfig.getType(param.file.name)
|
||||
if (props.type !== "" && proxy.type !== _type) {
|
||||
proxy.$message.warning(`不允许的文件类型!`);
|
||||
clearFiles(param.file)
|
||||
return false
|
||||
}
|
||||
const data = new FormData();
|
||||
data.append("file", param.file);
|
||||
if (category_id.value) {
|
||||
data.append(assetConfig.request.menuKey, category_id.value);
|
||||
}
|
||||
assetConfig.uploadObj(data, {
|
||||
onUploadProgress: e => {
|
||||
param.onProgress(e)
|
||||
}
|
||||
}).then(res => {
|
||||
param.onSuccess(res)
|
||||
}).catch(err => {
|
||||
param.onError(err)
|
||||
})
|
||||
}
|
||||
|
||||
function uploadProcess(event, file) {
|
||||
file.progress = Number((event.loaded / event.total * 100).toFixed(2))
|
||||
}
|
||||
|
||||
function clearFiles(file) {
|
||||
fileList.value.splice(fileList.value.findIndex(f => f.uid == file.uid), 1)
|
||||
}
|
||||
|
||||
function uploadSuccess(res, file) {
|
||||
fileList.value.splice(fileList.value.findIndex(f => f.uid == file.uid), 1)
|
||||
var response = assetConfig.uploadParseData(res);
|
||||
data.value.unshift({
|
||||
[fileProps.value.fileName]: response.fileName,
|
||||
[fileProps.value.url]: response.url
|
||||
})
|
||||
if (!props.multiple) {
|
||||
value.value = response.url
|
||||
}
|
||||
}
|
||||
|
||||
function uploadError(err) {
|
||||
proxy.$notify.error({
|
||||
title: '上传文件错误',
|
||||
message: err
|
||||
})
|
||||
}
|
||||
|
||||
function editCategory(data) {
|
||||
dialog.value.save = true
|
||||
nextTick(() => {
|
||||
saveRef.value.open('edit', menu.value, data)
|
||||
})
|
||||
}
|
||||
|
||||
function addCategory() {
|
||||
dialog.value.save = true
|
||||
nextTick(() => {
|
||||
saveRef.value.open('add',menu.value)
|
||||
})
|
||||
}
|
||||
|
||||
async function delCategory() {
|
||||
var CheckedNodes = menuRef.value.getCheckedNodes()
|
||||
if (CheckedNodes.length == 0) {
|
||||
proxy.$message.warning("请选择需要删除的项")
|
||||
return false;
|
||||
}
|
||||
var confirm = await proxy.$confirm('确认删除已选择的菜单吗?', '提示', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
confirmButtonClass: 'el-button--danger'
|
||||
}).catch(() => {
|
||||
})
|
||||
if (confirm != 'confirm') {
|
||||
return false
|
||||
}
|
||||
menuLoading.value = true
|
||||
var ids = CheckedNodes.map(item => item.category_id)
|
||||
var res = await assetConfig.menuDelObj({ids: ids.toString()})
|
||||
menuLoading.value = false
|
||||
proxy.$message.success(res.msg)
|
||||
CheckedNodes.forEach(item => {
|
||||
var node = menuRef.value.getNode(item)
|
||||
if (node == null) {
|
||||
reload()
|
||||
} else if (node.isCurrent) {
|
||||
saveRef.value.setData({})
|
||||
}
|
||||
menuRef.value.remove(item)
|
||||
})
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<style scoped>
|
||||
.pi-file-select {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.pi-file-select__files {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pi-file-select__list {
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.pi-file-select__item {
|
||||
display: inline-block;
|
||||
float: left;
|
||||
margin: 0 15px 25px 0;
|
||||
width: 110px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pi-file-select__item__file {
|
||||
width: 110px;
|
||||
height: 110px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pi-file-select__item__file .el-image {
|
||||
width: 110px;
|
||||
height: 110px;
|
||||
}
|
||||
|
||||
.pi-file-select__item__box {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
border: 2px solid var(--el-color-success);
|
||||
z-index: 1;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pi-file-select__item__box::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background: var(--el-color-success);
|
||||
opacity: 0.2;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pi-file-select__item:hover .pi-file-select__item__box {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.pi-file-select__item.active .pi-file-select__item__box {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.pi-file-select__item.active .pi-file-select__item__box::before {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.pi-file-select__item p {
|
||||
margin-top: 10px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
-webkit-text-overflow: ellipsis;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pi-file-select__item__checkbox {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
top: 7px;
|
||||
right: 7px;
|
||||
z-index: 2;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pi-file-select__item__checkbox i {
|
||||
font-size: 14px;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pi-file-select__item__select {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
top: 0px;
|
||||
right: 0px;
|
||||
z-index: 2;
|
||||
background: var(--el-color-success);
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pi-file-select__item__select i {
|
||||
font-size: 14px;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.pi-file-select__item.active .pi-file-select__item__checkbox {
|
||||
background: var(--el-color-success);
|
||||
}
|
||||
|
||||
.pi-file-select__item.active .pi-file-select__item__checkbox i {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.pi-file-select__item.active .pi-file-select__item__select {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.pi-file-select__item__file .item-file {
|
||||
width: 110px;
|
||||
height: 110px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pi-file-select__item__file .item-file i {
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
.pi-file-select__item__file .item-file.item-file-doc {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.pi-file-select__item .item-video {
|
||||
width: 110px;
|
||||
height: 110px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pi-file-select__item .item-video video {
|
||||
width: 110px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.pi-file-select__item__upload {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pi-file-select__side {
|
||||
width: 200px;
|
||||
margin-right: 15px;
|
||||
border-right: 1px solid rgba(128, 128, 128, 0.2);
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
}
|
||||
|
||||
.pi-file-select__side-menu {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pi-file-select__side-msg {
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.pi-file-select__top {
|
||||
margin-bottom: 15px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.pi-file-select__upload {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.pi-file-select__top .tips {
|
||||
font-size: 12px;
|
||||
margin-left: 10px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.pi-file-select__top .tips i {
|
||||
font-size: 14px;
|
||||
margin-right: 5px;
|
||||
position: relative;
|
||||
bottom: -0.125em;
|
||||
}
|
||||
|
||||
.pi-file-select__pagination {
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.pi-file-select__do {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.custom-tree-node {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 14px;
|
||||
padding-right: 24px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.custom-tree-node .label {
|
||||
display: flex;
|
||||
align-items: center;;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.custom-tree-node .label .el-tag {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.custom-tree-node .do {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.custom-tree-node .do i {
|
||||
margin-left: 5px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.custom-tree-node .do i:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.custom-tree-node:hover .do {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
:deep(.el-upload) {
|
||||
display: block;
|
||||
margin-right: 12px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
<template>
|
||||
<el-dialog :title="titleMap[mode]" v-model="visible" :width="500" destroy-on-close @closed="$emit('closed')">
|
||||
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
|
||||
<el-form-item label="上级" prop="pid">
|
||||
<el-cascader ref="categoryRef" v-model="form.pid" :options="menu" :props="menuProps" clearable
|
||||
style="width: 100%;"></el-cascader>
|
||||
</el-form-item>
|
||||
<el-form-item label="名称" prop="category_name">
|
||||
<el-input v-model="form.category_name" placeholder="请输入分类名称" clearable></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序" prop="rank">
|
||||
<el-input-number v-model="form.rank" controls-position="right" :min="1" :max="9999"
|
||||
style="width: 100%;"></el-input-number>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="visible=false">取 消</el-button>
|
||||
<el-button type="primary" :loading="isSaveing" @click="submit()">保 存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import assetConfig from "@/config/asset"
|
||||
import {getCurrentInstance, ref} from "vue"
|
||||
|
||||
defineExpose({
|
||||
open
|
||||
})
|
||||
|
||||
const emit = defineEmits(['success', 'closed'])
|
||||
const formRef = ref(null)
|
||||
const categoryRef = ref(null)
|
||||
const {proxy} = getCurrentInstance()
|
||||
|
||||
let mode = ref("add")
|
||||
const titleMap = ref({
|
||||
add: '新增',
|
||||
edit: '编辑'
|
||||
})
|
||||
let visible = ref(false)
|
||||
let isSaveing = ref(false)
|
||||
|
||||
let form = ref({
|
||||
category_id: null,
|
||||
category_name: "",
|
||||
pid: 0,
|
||||
rank: 1
|
||||
})
|
||||
let rules = ref({
|
||||
category_name: [
|
||||
{required: true, message: '请输入分类名称'}
|
||||
]
|
||||
})
|
||||
let menu = ref([])
|
||||
let menuProps = ref({
|
||||
value: "category_id",
|
||||
label: "category_name",
|
||||
checkStrictly: true
|
||||
})
|
||||
|
||||
function open(_mode = 'add', _menu = null, data = null) {
|
||||
mode.value = _mode;
|
||||
visible.value = true;
|
||||
if (_menu) {
|
||||
menu.value = _menu
|
||||
}
|
||||
if (data) {
|
||||
Object.assign(form.value, data)
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
// 校验登录
|
||||
const validate = await formRef.value.validate().catch(() => {
|
||||
});
|
||||
if (!validate) {
|
||||
return false
|
||||
}
|
||||
let data = Object.assign({}, form.value)
|
||||
// 部门获取最后一项
|
||||
if (categoryRef.value.getCheckedNodes().length > 0) {
|
||||
data.pid = categoryRef.value.getCheckedNodes()[0].value
|
||||
}
|
||||
isSaveing.value = true;
|
||||
const res = data.category_id ? await assetConfig.menuEditObj(data) : await assetConfig.menuAddObj(data);
|
||||
isSaveing.value = false;
|
||||
emit('success', data, mode.value)
|
||||
visible.value = false;
|
||||
proxy.$message.success(res.msg)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.el-form-item {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
const T = {
|
||||
"color": [
|
||||
"#409EFF",
|
||||
"#36CE9E",
|
||||
"#f56e6a",
|
||||
"#626c91",
|
||||
"#edb00d",
|
||||
"#909399"
|
||||
],
|
||||
'grid': {
|
||||
'left': '3%',
|
||||
'right': '3%',
|
||||
'bottom': '10',
|
||||
'top': '40',
|
||||
'containLabel': true
|
||||
},
|
||||
"legend": {
|
||||
"textStyle": {
|
||||
"color": "#999"
|
||||
},
|
||||
"inactiveColor": "rgba(128,128,128,0.4)"
|
||||
},
|
||||
"categoryAxis": {
|
||||
"axisLine": {
|
||||
"show": true,
|
||||
"lineStyle": {
|
||||
"color": "rgba(128,128,128,0.2)",
|
||||
"width": 1
|
||||
}
|
||||
},
|
||||
"axisTick": {
|
||||
"show": false,
|
||||
"lineStyle": {
|
||||
"color": "#333"
|
||||
}
|
||||
},
|
||||
"axisLabel": {
|
||||
"color": "#999"
|
||||
},
|
||||
"splitLine": {
|
||||
"show": false,
|
||||
"lineStyle": {
|
||||
"color": [
|
||||
"#eee"
|
||||
]
|
||||
}
|
||||
},
|
||||
"splitArea": {
|
||||
"show": false,
|
||||
"areaStyle": {
|
||||
"color": [
|
||||
"rgba(255,255,255,0.01)",
|
||||
"rgba(0,0,0,0.01)"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"valueAxis": {
|
||||
"axisLine": {
|
||||
"show": false,
|
||||
"lineStyle": {
|
||||
"color": "#999"
|
||||
}
|
||||
},
|
||||
"splitLine": {
|
||||
"show": true,
|
||||
"lineStyle": {
|
||||
"color": "rgba(128,128,128,0.2)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default T
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
<template>
|
||||
<div ref="chartsRef" :style="{height:height, width:width}"></div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import * as echarts from 'echarts';
|
||||
import T from './echarts-theme-T.js';
|
||||
import {ref, watch, computed, onActivated, onDeactivated, nextTick, onMounted} from "vue";
|
||||
echarts.registerTheme('T', T);
|
||||
|
||||
const unwarp = (obj) => obj && (obj.__v_raw || obj.valueOf() || obj);
|
||||
const chartsRef = ref(null)
|
||||
const props = defineProps({
|
||||
height: {type: String, default: "100%"},
|
||||
width: {type: String, default: "100%"},
|
||||
nodata: {type: Boolean, default: false},
|
||||
option: {
|
||||
type: Object, default: () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
let isActive = ref(false)
|
||||
let chart = ref(null)
|
||||
|
||||
watch(() => props.option, (v) => {
|
||||
unwarp(chart.value).setOption(v);
|
||||
}, {deep: true})
|
||||
|
||||
const options = computed(() => {
|
||||
return props.option || {}
|
||||
})
|
||||
|
||||
|
||||
onActivated(() => {
|
||||
if (!isActive.value) {
|
||||
nextTick(() => {
|
||||
chart.value.resize()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
onDeactivated(() => {
|
||||
isActive.value = false
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
isActive.value = true
|
||||
nextTick(() => {
|
||||
draw()
|
||||
})
|
||||
})
|
||||
|
||||
function draw() {
|
||||
var myChart = echarts.init(chartsRef.value, 'T');
|
||||
myChart.setOption(options.value);
|
||||
chart.value = myChart;
|
||||
window.addEventListener('resize', () => myChart.resize());
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
<template>
|
||||
<el-dialog title="请选择" v-model="visible" :width="800" destroy-on-close @closed="$emit('closed')"
|
||||
:close-on-click-modal="false" top="8vh">
|
||||
<div v-loading="loading">
|
||||
<div class="pi-table-select__header">
|
||||
<slot name="header" :form="formData" :submit="formSubmit"></slot>
|
||||
</div>
|
||||
<el-table ref="tableRef" :data="tableData" :height="480" :highlight-current-row="!multiple"
|
||||
@row-click="click"
|
||||
@select="select" @select-all="selectAll">
|
||||
<el-table-column v-if="multiple" type="selection" width="45"></el-table-column>
|
||||
<el-table-column v-else type="index" width="45">
|
||||
<template #default="scope"><span>{{ scope.$index + (currentPage - 1) * pageSize + 1 }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<slot></slot>
|
||||
</el-table>
|
||||
<div class="pi-table-select__page">
|
||||
<el-pagination size="small" background layout="prev, pager, next" :total="total" :page-size="pageSize"
|
||||
v-model:currentPage="formData.page" @current-change="reload"></el-pagination>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer v-if="multiple">
|
||||
<el-button @click="visible=false">取 消</el-button>
|
||||
<el-button @click="confirm()">确 定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import config from "@/config/select"
|
||||
import {ref, onMounted, nextTick} from "vue"
|
||||
|
||||
defineExpose({
|
||||
open
|
||||
})
|
||||
|
||||
const emit = defineEmits(["success", "closed"])
|
||||
|
||||
const props = defineProps({
|
||||
title: {type: String, default: '请选择'},
|
||||
apiObj: {
|
||||
type: Function, default: () => {
|
||||
}
|
||||
},
|
||||
limit: {type: Number, default: 0},
|
||||
multiple: {type: Boolean, default: false},
|
||||
params: {
|
||||
type: Object, default: () => {
|
||||
}
|
||||
},
|
||||
props: {
|
||||
type: Object, default: () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
const tableRef = ref(null)
|
||||
|
||||
let loading = ref(false)
|
||||
let visible = ref(false)
|
||||
let tableData = ref([])
|
||||
let pageSize = ref(config.pageSize)
|
||||
let currentPage = ref(1)
|
||||
let total = ref(0)
|
||||
let formData = ref({})
|
||||
let checkData = ref([])
|
||||
let defaultProps = ref({
|
||||
label: config.props.label,
|
||||
value: config.props.value,
|
||||
page: config.request.page,
|
||||
pageSize: config.request.pageSize,
|
||||
keyword: config.request.keyword,
|
||||
fields: []
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
defaultProps.value = Object.assign(defaultProps.value, props.props)
|
||||
})
|
||||
|
||||
//显示
|
||||
function open() {
|
||||
visible.value = true;
|
||||
getData()
|
||||
}
|
||||
|
||||
async function getData() {
|
||||
var reqData = {
|
||||
[config.request.page]: currentPage.value,
|
||||
[config.request.pageSize]: pageSize.value,
|
||||
}
|
||||
Object.assign(reqData, props.params, formData.value)
|
||||
const res = await props.apiObj(reqData)
|
||||
tableData.value = res.data
|
||||
total.value = res.count
|
||||
initCheck()
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
visible.value = false
|
||||
emit('success', checkData.value);
|
||||
}
|
||||
|
||||
function formSubmit() {
|
||||
currentPage.value = 1
|
||||
getData()
|
||||
}
|
||||
|
||||
//分页刷新表格
|
||||
function reload() {
|
||||
getData()
|
||||
}
|
||||
|
||||
//表格勾选事件
|
||||
function select(rows, row) {
|
||||
var isSelect = rows.length && rows.indexOf(row) !== -1
|
||||
if (isSelect) {
|
||||
checkData.value.push(row)
|
||||
} else {
|
||||
checkData.value.splice(checkData.value.findIndex(item => item[props.value] == row[props.value]), 1)
|
||||
}
|
||||
}
|
||||
|
||||
//表格全选事件
|
||||
function selectAll(rows) {
|
||||
var isAllSelect = rows.length > 0
|
||||
if (isAllSelect) {
|
||||
rows.forEach(row => {
|
||||
var isHas = false
|
||||
isHas = checkData.value.find(item => item[props.value] == row[props.value])
|
||||
if (!isHas) {
|
||||
checkData.value.push(row)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
tableData.value.forEach(row => {
|
||||
var isHas = false
|
||||
isHas = checkData.value.find(item => item[props.value] == row[props.value])
|
||||
if (isHas) {
|
||||
checkData.value.splice(checkData.value.findIndex(item => item[props.value] == row[props.value]), 1)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function click(row) {
|
||||
if (!props.multiple) {
|
||||
visible.value = false
|
||||
emit('success', row);
|
||||
}
|
||||
var isSelect = checkData.value.length && checkData.value.indexOf(row) !== -1
|
||||
tableRef.value.toggleRowSelection(row)
|
||||
if (!isSelect) {
|
||||
checkData.value.push(row)
|
||||
} else {
|
||||
checkData.value.splice(checkData.value.findIndex(item => item[props.value] == row[props.value]), 1)
|
||||
}
|
||||
}
|
||||
|
||||
function initCheck() {
|
||||
if (!props.multiple) return;
|
||||
nextTick(() => {
|
||||
tableData.value.forEach((row) => {
|
||||
var isHas = false
|
||||
isHas = checkData.value.find(item => item[props.value] == row[props.value])
|
||||
if (isHas) {
|
||||
tableRef.value.toggleRowSelection(row, true)
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pi-table-select__table {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.pi-table-select__page {
|
||||
padding-top: 12px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import {TextStyle} from '@tiptap/extension-text-style'
|
||||
|
||||
export default TextStyle.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
fontSize: {
|
||||
default: null,
|
||||
parseHTML: element => element.style.fontSize || null,
|
||||
renderHTML: attributes => {
|
||||
if (!attributes.fontSize) return {}
|
||||
return {
|
||||
style: `font-size: ${attributes.fontSize}`,
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
<template>
|
||||
<NodeViewWrapper as="p" class="image-wrapper">
|
||||
<div class="image-box" ref="boxRef" @click="handleClick">
|
||||
<img :src="node.attrs.src" :alt="node.attrs.alt" :width="node.attrs.width" :height="node.attrs.height" :draggable="false"/>
|
||||
<!-- 操作菜单,仅在选中时显示 -->
|
||||
<div v-if="selected" class="image-menu">
|
||||
<button @click="deleteImage">删除</button>
|
||||
<button @click="replaceImage">配置</button>
|
||||
</div>
|
||||
</div>
|
||||
<el-dialog v-model="settingShow" title="配置" width="500" align-center>
|
||||
<el-form v-model="form" label-position="top">
|
||||
<el-form-item label="地址" prop="src">
|
||||
<el-input v-model="form.src"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="描述" prop="alt">
|
||||
<el-input v-model="form.alt"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="宽高" prop="width">
|
||||
<el-row :gutter="10" style="margin: 10px 0; width: 100%;">
|
||||
<el-col :span="12">
|
||||
<el-input v-model="form.width">
|
||||
<template #suffix>宽 </template>
|
||||
</el-input>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-input v-model="form.height">
|
||||
<template #suffix>高 </template>
|
||||
</el-input>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="settingShow = false">取消</el-button>
|
||||
<el-button type="primary" @click="submit">确定</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</NodeViewWrapper>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {NodeViewWrapper, NodeViewProps} from '@tiptap/vue-3'
|
||||
import {computed, ref} from "vue"
|
||||
|
||||
const props = defineProps<NodeViewProps>();
|
||||
const selected = computed(() => props.selected)
|
||||
|
||||
let settingShow = ref(false)
|
||||
let form = ref({
|
||||
src: '',
|
||||
width: '',
|
||||
height: '',
|
||||
alt: ''
|
||||
})
|
||||
|
||||
function deleteImage() {
|
||||
props.deleteNode()
|
||||
}
|
||||
|
||||
function replaceImage() {
|
||||
form.value.src = props.node.attrs.src
|
||||
form.value.width = props.node.attrs.width
|
||||
form.value.height = props.node.attrs.height
|
||||
form.value.alt = props.node.attrs.alt
|
||||
settingShow.value = true
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
props.editor.commands.focus()
|
||||
}
|
||||
|
||||
function submit() {
|
||||
settingShow.value = false
|
||||
props.updateAttributes(form.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.image-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.image-box img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 操作菜单 */
|
||||
.image-menu {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.image-menu button {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
border: none;
|
||||
padding: 4px 6px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import Action from "./Action.vue"
|
||||
import {VueNodeViewRenderer} from "@tiptap/vue-3"
|
||||
import { Image } from '@tiptap/extension-image'
|
||||
|
||||
export default Image.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
class: {
|
||||
default: null,
|
||||
},
|
||||
style: {
|
||||
default: null,
|
||||
},
|
||||
width: {
|
||||
default: null,
|
||||
},
|
||||
height: {
|
||||
default: null
|
||||
},
|
||||
alt: {
|
||||
default: null
|
||||
}
|
||||
}
|
||||
},
|
||||
addNodeView() {
|
||||
return VueNodeViewRenderer(Action)
|
||||
},
|
||||
})
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
import {Extension, Command} from '@tiptap/core'
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
indent: {
|
||||
increaseIndent: () => ReturnType
|
||||
decreaseIndent: () => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Extension.create({
|
||||
name: 'indent',
|
||||
addOptions() {
|
||||
return {
|
||||
maxIndent: 50,
|
||||
types: ['paragraph', 'heading', 'code_block', 'bullet_list', 'ordered_list'],
|
||||
}
|
||||
},
|
||||
addGlobalAttributes() {
|
||||
return [
|
||||
{
|
||||
types: this.options.types,
|
||||
attributes: {
|
||||
'indent': {
|
||||
default: 0,
|
||||
parseHTML: element => parseInt(element.getAttribute('indent') || '0', 10),
|
||||
renderHTML: attributes => {
|
||||
const level = attributes['indent']
|
||||
return level > 0 && {
|
||||
'indent': level,
|
||||
style: `text-indent: ${level * 2}em;`
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
addCommands() {
|
||||
return {
|
||||
increaseIndent:
|
||||
(): Command =>
|
||||
({tr, state, dispatch}) => {
|
||||
const {from, to} = state.selection
|
||||
let modified = false
|
||||
state.doc.nodesBetween(from, to, (node, pos) => {
|
||||
if (this.options.types.includes(node.type.name)) {
|
||||
const indent = node.attrs['indent'] || 0
|
||||
if (indent + 1 > this.options.maxIndent) return
|
||||
tr.setNodeMarkup(pos, undefined, {
|
||||
...node.attrs,
|
||||
'indent': indent + 1,
|
||||
})
|
||||
modified = true
|
||||
}
|
||||
})
|
||||
if (modified) {
|
||||
dispatch && dispatch(tr)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
|
||||
decreaseIndent:
|
||||
(): Command =>
|
||||
({tr, state, dispatch}) => {
|
||||
const {from, to} = state.selection
|
||||
let modified = false
|
||||
state.doc.nodesBetween(from, to, (node, pos) => {
|
||||
if (this.options.types.includes(node.type.name)) {
|
||||
const indent = node.attrs['indent'] || 0
|
||||
if (indent - 1 < 0) return
|
||||
const newIndent = Math.max(indent - 1, 0)
|
||||
tr.setNodeMarkup(pos, undefined, {
|
||||
...node.attrs,
|
||||
'indent': newIndent,
|
||||
})
|
||||
modified = true
|
||||
}
|
||||
})
|
||||
if (modified) {
|
||||
dispatch && dispatch(tr)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
}
|
||||
},
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Tab: () => this.editor.commands.increaseIndent(),
|
||||
'Shift-Tab': () => this.editor.commands.decreaseIndent(),
|
||||
Backspace: () => {
|
||||
const {state} = this.editor
|
||||
const {selection} = state
|
||||
const {$from} = selection
|
||||
if ($from.parentOffset !== 0) return false
|
||||
const node = $from.node()
|
||||
if (!this.options.types.includes(node.type.name)) return false
|
||||
const indent = node.attrs['indent'] || 0
|
||||
if (indent > 0) {
|
||||
return this.editor.commands.decreaseIndent()
|
||||
}
|
||||
return false
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import {Extension} from '@tiptap/core'
|
||||
import {Plugin, PluginKey} from '@tiptap/pm/state'
|
||||
import {Decoration, DecorationSet} from '@tiptap/pm/view'
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
selection: {
|
||||
setSelection: () => ReturnType
|
||||
clearSelection: () => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Extension.create({
|
||||
name: 'selection',
|
||||
addOptions() {
|
||||
return {
|
||||
class: 'selection',
|
||||
}
|
||||
},
|
||||
addStorage() {
|
||||
return {
|
||||
fakeRange: null as { from: number, to: number } | null,
|
||||
}
|
||||
},
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey('selection'),
|
||||
props: {
|
||||
decorations: (state) => {
|
||||
const {fakeRange} = this.storage
|
||||
if (!fakeRange) return null
|
||||
const deco = Decoration.inline(
|
||||
fakeRange.from,
|
||||
fakeRange.to,
|
||||
{class: this.options.class},
|
||||
)
|
||||
return DecorationSet.create(state.doc, [deco])
|
||||
},
|
||||
handleClick: (view, pos, event) => {
|
||||
const { fakeRange } = this.storage
|
||||
if (!fakeRange) return false
|
||||
|
||||
const clickedInsideFake =
|
||||
pos >= fakeRange.from && pos <= fakeRange.to
|
||||
|
||||
if (!clickedInsideFake) {
|
||||
// 点击了 fake selection 外部,清除它
|
||||
this.storage.fakeRange = null
|
||||
view.dispatch(view.state.tr)
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
addCommands() {
|
||||
return {
|
||||
setSelection: () => ({state, view}) => {
|
||||
const {from, to, empty} = state.selection
|
||||
if (!empty && from !== to) {
|
||||
this.storage.fakeRange = {from, to}
|
||||
view.dispatch(state.tr)
|
||||
}
|
||||
return true
|
||||
},
|
||||
clearSelection: () => ({state, view}) => {
|
||||
if (this.storage.fakeRange) {
|
||||
this.storage.fakeRange = null
|
||||
view.dispatch(state.tr)
|
||||
}
|
||||
return true
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
<template>
|
||||
<NodeViewWrapper class="table-wrapper" @contextmenu.prevent="showMenu($event)">
|
||||
<NodeViewContent as="table" v-bind="props.node.attrs" :class="isSelected ? 'ProseMirror-selectednode' : ''"/>
|
||||
<el-dropdown ref="tableDropdownRef" trigger="contextmenu" :teleported="false">
|
||||
<span></span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu ref="tableMenu" class="dropdown">
|
||||
<el-dropdown-item @click="editor.commands.mergeCells()" :disabled="!editor.can().mergeCells()">
|
||||
合并单元格
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item @click="editor.commands.addRowBefore()" :disabled="!editor.can().addRowBefore()" divided>
|
||||
上加一行
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item @click="editor.commands.addRowAfter()" :disabled="!editor.can().addRowAfter()">
|
||||
下加一行
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item @click="editor.commands.addColumnBefore()"
|
||||
:disabled="!editor.can().addColumnBefore()">左加一列
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item @click="editor.commands.addColumnAfter()"
|
||||
:disabled="!editor.can().addColumnAfter()">右加一列
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item @click="editor.commands.deleteRow()" :disabled="!editor.can().deleteRow()" divided>删除行
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item @click="editor.commands.deleteColumn()" :disabled="!editor.can().deleteColumn()">
|
||||
删除列
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</NodeViewWrapper>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {NodeViewWrapper, NodeViewContent, NodeViewProps} from '@tiptap/vue-3'
|
||||
import {ref, computed} from 'vue'
|
||||
|
||||
const props = defineProps<NodeViewProps>()
|
||||
import {NodeSelection} from 'prosemirror-state'
|
||||
|
||||
const tableDropdownRef = ref(null)
|
||||
const tableMenu = ref(null)
|
||||
|
||||
const isSelected = computed(() => {
|
||||
const {selection} = props.editor.state
|
||||
|
||||
if (selection instanceof NodeSelection && selection.node.type.name === 'table') {
|
||||
return true
|
||||
}
|
||||
|
||||
const $from = selection.$from
|
||||
let inTable = false
|
||||
for (let depth = $from.depth; depth > 0; depth--) {
|
||||
const node = $from.node(depth)
|
||||
if (node.type.name === 'table') {
|
||||
inTable = true
|
||||
}
|
||||
}
|
||||
return inTable
|
||||
})
|
||||
|
||||
function showMenu(e) {
|
||||
const _menu = tableDropdownRef.value.$el
|
||||
_menu.style.position = 'absolute';
|
||||
// 点位置+边框
|
||||
_menu.style.left = e.layerX + 'px'
|
||||
// 组件高度 + 工具栏高度 + 偏移
|
||||
_menu.style.top = e.layerY + 'px'
|
||||
tableDropdownRef.value.handleOpen()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.table-wrapper {
|
||||
position: relative;
|
||||
:deep(.el-dropdown-menu) {
|
||||
list-style-type: disc;
|
||||
padding-left: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
:deep(.el-popper) {
|
||||
line-height: 0 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
import {VueNodeViewRenderer} from "@tiptap/vue-3";
|
||||
import {NodeSelection} from 'prosemirror-state'
|
||||
import {Table} from '@tiptap/extension-table'
|
||||
import Action from "./Action.vue"
|
||||
|
||||
export default Table.extend({
|
||||
selectable: true,
|
||||
atom: true,
|
||||
// 添加属性配置
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
width: {
|
||||
default: '100%',
|
||||
renderHTML: attributes => {
|
||||
return {
|
||||
width: attributes.width,
|
||||
}
|
||||
}
|
||||
},
|
||||
class: {
|
||||
default: null,
|
||||
renderHTML: attributes => {
|
||||
return {
|
||||
class: attributes.class,
|
||||
}
|
||||
}
|
||||
},
|
||||
style: {
|
||||
default: 'table-layout: fixed;border-collapse: collapse;',
|
||||
renderHTML: attributes => {
|
||||
if (!attributes.style) return {}
|
||||
return {
|
||||
style: attributes.style,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
renderHTML({node, HTMLAttributes}) {
|
||||
return ['table', HTMLAttributes, ['tbody', 0]]
|
||||
},
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Backspace: ({editor}) => {
|
||||
const {state, view} = editor
|
||||
const {selection} = state
|
||||
|
||||
// 没有深度直接删除
|
||||
const {$from} = selection
|
||||
if ($from.depth == 0) {
|
||||
editor.commands.deleteSelection()
|
||||
return true
|
||||
}
|
||||
|
||||
// 选中整个表格删除
|
||||
if (selection instanceof NodeSelection && selection.node.type.name === 'table') {
|
||||
const tr = state.tr.delete(selection.from, selection.to)
|
||||
view.dispatch(tr)
|
||||
return true
|
||||
}
|
||||
const pos = $from.before($from.depth) // 当前段落的开始位置
|
||||
const index = $from.index($from.depth - 1)
|
||||
|
||||
if (index == 0) return false
|
||||
const parent = $from.node($from.depth - 1)
|
||||
|
||||
// 查前一个节点是不是 table
|
||||
const beforeNode = parent.child(index - 1)
|
||||
|
||||
if (beforeNode?.type.name === 'table') {
|
||||
const deletePos = pos - beforeNode.nodeSize
|
||||
view.dispatch(
|
||||
state.tr
|
||||
.setSelection(NodeSelection.create(state.doc, deletePos))
|
||||
.deleteSelection()
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
Delete: ({editor}) => {
|
||||
const {state, view} = editor
|
||||
const {selection} = state
|
||||
|
||||
// 没有深度直接删除
|
||||
const {$from} = selection
|
||||
if ($from.depth == 0) {
|
||||
editor.commands.deleteSelection()
|
||||
return true
|
||||
}
|
||||
|
||||
//选中整个表格删除
|
||||
if (selection instanceof NodeSelection && selection.node.type.name === 'table') {
|
||||
const tr = state.tr.delete(selection.from, selection.to)
|
||||
view.dispatch(tr)
|
||||
return true
|
||||
}
|
||||
|
||||
// 在表格前删除
|
||||
const pos = $from.before($from.depth) // 当前段落的开始位置
|
||||
const index = $from.index($from.depth - 1)
|
||||
const parent = $from.node($from.depth - 1)
|
||||
|
||||
if (index < parent.childCount - 1) {
|
||||
const afterNode = parent.child(index + 1)
|
||||
if (afterNode?.type.name === 'table') {
|
||||
const deletePos = pos + $from.node().nodeSize
|
||||
view.dispatch(
|
||||
state.tr
|
||||
.setSelection(NodeSelection.create(state.doc, deletePos))
|
||||
.deleteSelection()
|
||||
)
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
if (index == 0) return false
|
||||
const beforeNode = parent.child(index - 1)
|
||||
if (beforeNode?.type.name === 'table') {
|
||||
const deletePos = pos - beforeNode.nodeSize
|
||||
view.dispatch(
|
||||
state.tr
|
||||
.setSelection(NodeSelection.create(state.doc, deletePos))
|
||||
.deleteSelection()
|
||||
)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
}
|
||||
},
|
||||
addNodeView() {
|
||||
return VueNodeViewRenderer(Action)
|
||||
}
|
||||
})
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
<template>
|
||||
<NodeViewWrapper as="p" class="video-wrapper">
|
||||
<div class="video-box" ref="boxRef" @click="handleClick">
|
||||
<NodeViewContent as="video" v-bind="props.node.attrs" controls/>
|
||||
<div v-if="selected" class="video-menu">
|
||||
<button @click="deleteVideo">删除</button>
|
||||
<button @click="replaceVideo">配置</button>
|
||||
</div>
|
||||
</div>
|
||||
<el-dialog v-model="settingShow" title="配置" width="500" align-center>
|
||||
<el-form v-model="form" label-position="top">
|
||||
<el-form-item label="地址" prop="src">
|
||||
<el-input v-model="form.src"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="宽高" prop="width">
|
||||
<el-row :gutter="10" style="margin: 10px 0; width: 100%;">
|
||||
<el-col :span="12">
|
||||
<el-input v-model="form.width">
|
||||
<template #suffix>宽 </template>
|
||||
</el-input>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-input v-model="form.height">
|
||||
<template #suffix>高 </template>
|
||||
</el-input>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="settingShow = false">取消</el-button>
|
||||
<el-button type="primary" @click="submit">确定</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</NodeViewWrapper>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {NodeViewWrapper, NodeViewProps, NodeViewContent} from '@tiptap/vue-3'
|
||||
import {computed, ref} from "vue"
|
||||
|
||||
const props = defineProps<NodeViewProps>();
|
||||
const selected = computed(() => props.selected)
|
||||
|
||||
let settingShow = ref(false)
|
||||
let form = ref({
|
||||
src: '',
|
||||
width: '',
|
||||
height: ''
|
||||
})
|
||||
|
||||
function deleteVideo() {
|
||||
props.deleteNode()
|
||||
}
|
||||
|
||||
function replaceVideo() {
|
||||
form.value.src = props.node.attrs.src
|
||||
form.value.width = props.node.attrs.width
|
||||
form.value.height = props.node.attrs.height
|
||||
settingShow.value = true
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
props.editor.commands.focus()
|
||||
}
|
||||
|
||||
function submit() {
|
||||
settingShow.value = false
|
||||
props.updateAttributes(form.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.video-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.video-box video {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 操作菜单 */
|
||||
.video-menu {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.video-menu button {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
border: none;
|
||||
padding: 4px 6px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
// extensions/Video.ts
|
||||
import {Node, mergeAttributes, Command, CommandProps} from '@tiptap/core'
|
||||
import {VueNodeViewRenderer} from "@tiptap/vue-3";
|
||||
import Action from "./Action.vue";
|
||||
|
||||
export interface VideoOptions {
|
||||
HTMLAttributes: Record<string, any>
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
video: {
|
||||
setVideo: (options: { src: string }) => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Node.create<VideoOptions>({
|
||||
name: 'video',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
}
|
||||
},
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: null,
|
||||
},
|
||||
width: {
|
||||
default: '100%',
|
||||
renderHTML: attributes => {
|
||||
return {
|
||||
width: attributes.width,
|
||||
}
|
||||
}
|
||||
},
|
||||
height: {
|
||||
default: null
|
||||
},
|
||||
class: {
|
||||
default: null,
|
||||
renderHTML: attributes => {
|
||||
return {
|
||||
class: attributes.class,
|
||||
}
|
||||
}
|
||||
},
|
||||
style: {
|
||||
default: '',
|
||||
renderHTML: attributes => {
|
||||
if (!attributes.style) return {}
|
||||
return {
|
||||
style: attributes.style,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'video',
|
||||
},
|
||||
]
|
||||
},
|
||||
renderHTML({HTMLAttributes}) {
|
||||
return [
|
||||
'video',
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||
controls: true,
|
||||
}),
|
||||
['source', {src: HTMLAttributes.src, type: 'video/mp4'}],
|
||||
]
|
||||
},
|
||||
addCommands() {
|
||||
return {
|
||||
setVideo: (attrs: { src: string }): Command => ({commands}: CommandProps) => {
|
||||
return commands.insertContent({
|
||||
type: this.name,
|
||||
attrs,
|
||||
})
|
||||
},
|
||||
}
|
||||
},
|
||||
addNodeView() {
|
||||
return VueNodeViewRenderer(Action)
|
||||
},
|
||||
})
|
||||
|
|
@ -0,0 +1,569 @@
|
|||
<template>
|
||||
<div class="pi-editor">
|
||||
<div class="toolbar" ref="toolbarRef">
|
||||
<div class="box">
|
||||
<div class="group">
|
||||
<el-button text circle title="撤销" class="item"
|
||||
@click="editor.chain().focus().undo().run()"
|
||||
:disabled="!editor.can().undo()">
|
||||
<el-icon size="20px">
|
||||
<component :is="'pi-icon-arrow-go-back'"/>
|
||||
</el-icon>
|
||||
</el-button>
|
||||
<el-button text circle title="重做" class="item"
|
||||
@click="editor.chain().focus().redo().run()"
|
||||
:disabled="!editor.can().redo()">
|
||||
<el-icon size="20px">
|
||||
<component :is="'pi-icon-arrow-go-forward'"/>
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="group">
|
||||
<el-color-picker v-model="fontColor" ref="colorPickerRef"
|
||||
@change="editor.chain().focus().setColor(fontColor).run()"
|
||||
:predefine="predefineColors" :teleported="false" :show-alpha="true"
|
||||
class="color-picker"></el-color-picker>
|
||||
<el-color-picker v-model="bgColor" ref="bgPickerRef"
|
||||
@change="bgColor ? editor.chain().focus().toggleHighlight({ color: bgColor }).run() : editor.chain().focus().unsetHighlight().run()"
|
||||
:predefine="predefineColors" :teleported="false" :show-alpha="true"
|
||||
class="color-picker"></el-color-picker>
|
||||
<el-button text circle title="文字颜色" class="item"
|
||||
@click="colorPickerRef.show()">
|
||||
<el-icon size="20px">
|
||||
<component :is="'pi-icon-font-color'"/>
|
||||
</el-icon>
|
||||
</el-button>
|
||||
<el-button text circle title="背景色" class="item"
|
||||
@click="bgPickerRef.show()">
|
||||
<el-icon size="20px">
|
||||
<component :is="'pi-icon-background-color'"/>
|
||||
</el-icon>
|
||||
</el-button>
|
||||
<el-button text circle title="粗体" class="item"
|
||||
@click="editor.chain().focus().toggleBold().run()"
|
||||
:class="{ 'active': editor.isActive('bold') }">
|
||||
<el-icon size="20px">
|
||||
<component :is="'pi-icon-bold'"/>
|
||||
</el-icon>
|
||||
</el-button>
|
||||
<el-button text circle title="斜体" class="item"
|
||||
@click="editor.chain().focus().toggleItalic().run()"
|
||||
:class="{ 'active': editor.isActive('italic') }">
|
||||
<el-icon size="20px">
|
||||
<component :is="'pi-icon-italic'"/>
|
||||
</el-icon>
|
||||
</el-button>
|
||||
<el-button text circle title="下划线" class="item"
|
||||
@click="editor.chain().focus().toggleUnderline().run()"
|
||||
:class="{ 'active': editor.isActive('underline') }">
|
||||
<el-icon size="20px">
|
||||
<component :is="'pi-icon-underline'"/>
|
||||
</el-icon>
|
||||
</el-button>
|
||||
<el-button text circle title="删除线" class="item"
|
||||
@click="editor.chain().focus().toggleStrike().run()"
|
||||
:class="{ 'active': editor.isActive('strike') }">
|
||||
<el-icon size="20px">
|
||||
<component :is="'pi-icon-strikethrough'"/>
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="group">
|
||||
<el-button text circle title="左对齐" class="item"
|
||||
:class="{ 'active': editor.isActive({ textAlign: 'left' }) }"
|
||||
@click="editor.isActive({ textAlign: 'left' }) ? editor.chain().focus().unsetTextAlign().run() : editor.chain().focus().setTextAlign('left').run()">
|
||||
<el-icon size="20px">
|
||||
<component :is="'pi-icon-align-left'"/>
|
||||
</el-icon>
|
||||
</el-button>
|
||||
<el-button text circle title="居中对齐" class="item"
|
||||
:class="{ 'active': editor.isActive({ textAlign: 'center' }) }"
|
||||
@click="editor.isActive({ textAlign: 'center' }) ? editor.chain().focus().unsetTextAlign().run() : editor.chain().focus().setTextAlign('center').run()">
|
||||
<el-icon size="20px">
|
||||
<component :is="'pi-icon-align-center'"/>
|
||||
</el-icon>
|
||||
</el-button>
|
||||
<el-button text circle title="右对齐" class="item"
|
||||
:class="{ 'active': editor.isActive({ textAlign: 'right' }) }"
|
||||
@click="editor.isActive({ textAlign: 'right' }) ? editor.chain().focus().unsetTextAlign().run() : editor.chain().focus().setTextAlign('right').run()">
|
||||
<el-icon size="20px">
|
||||
<component :is="'pi-icon-align-right'"/>
|
||||
</el-icon>
|
||||
</el-button>
|
||||
<el-button text circle title="两端对齐" class="item"
|
||||
:class="{ 'active': editor.isActive({ textAlign: 'justify' }) }"
|
||||
@click="editor.isActive({ textAlign: 'justify' }) ? editor.chain().focus().unsetTextAlign().run() : editor.chain().focus().setTextAlign('justify').run()">
|
||||
<el-icon size="20px">
|
||||
<component :is="'pi-icon-align-justify'"/>
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="group">
|
||||
<el-button text circle title="减少缩进" class="item"
|
||||
@click="editor.chain().focus().decreaseIndent().run()"
|
||||
:disabled="editor.can().decreaseIndent()">
|
||||
<el-icon size="20px">
|
||||
<component :is="'pi-icon-indent-decrease'"/>
|
||||
</el-icon>
|
||||
</el-button>
|
||||
<el-button text circle title="增加缩进" class="item"
|
||||
@click="editor.chain().focus().increaseIndent().run()"
|
||||
:disabled="editor.can().increaseIndent()">
|
||||
<el-icon size="20px">
|
||||
<component :is="'pi-icon-indent-increase'"/>
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="group">
|
||||
<el-button text circle title="有序列表" class="item"
|
||||
@click="editor.chain().focus().toggleOrderedList().run()"
|
||||
:class="{ 'active': editor.isActive('orderedList') }">
|
||||
<el-icon size="20px">
|
||||
<component :is="'pi-icon-list-ordered'"/>
|
||||
</el-icon>
|
||||
</el-button>
|
||||
<el-button text circle title="无序列表" class="item"
|
||||
@click="editor.chain().focus().toggleBulletList().run()"
|
||||
:class="{ 'active': editor.isActive('bulletList') }">
|
||||
<el-icon size="20px">
|
||||
<component :is="'pi-icon-list-unordered'"/>
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="group">
|
||||
<el-popover :visible="tableVisible" :width="220">
|
||||
<el-row style="margin: 10px 0;" :gutter="10">
|
||||
<el-col :span="12">
|
||||
<el-input v-model="tableRow">
|
||||
<template #suffix>行 </template>
|
||||
</el-input>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-input v-model="tableCol">
|
||||
<template #suffix>列 </template>
|
||||
</el-input>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<div style="text-align: right; margin: 0">
|
||||
<el-button size="small" text @click="tableVisible = false">取消</el-button>
|
||||
<el-button size="small" type="primary" @click="insertTable">
|
||||
确定
|
||||
</el-button>
|
||||
</div>
|
||||
<template #reference>
|
||||
<el-button text circle title="表格" class="item"
|
||||
@click="tableVisible = true">
|
||||
<el-icon size="20px">
|
||||
<component :is="'pi-icon-table'"/>
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-popover>
|
||||
<el-button text circle title="图片" class="item"
|
||||
@click="showPicker1">
|
||||
<el-icon size="20px">
|
||||
<component :is="'pi-icon-image'"/>
|
||||
</el-icon>
|
||||
</el-button>
|
||||
<el-button text circle title="视频" class="item"
|
||||
@click="showPicker2">
|
||||
<el-icon size="20px">
|
||||
<component :is="'pi-icon-video'"/>
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="group">
|
||||
<el-select v-model="tag" class="item" style="width:130px" clearable @change="tagChange">
|
||||
<template #prefix>
|
||||
<el-icon size="20px">
|
||||
<component :is="'pi-icon-heading'"/>
|
||||
</el-icon>
|
||||
</template>
|
||||
<el-option label="段落" value="p">
|
||||
<template #default>
|
||||
<p>段落</p>
|
||||
</template>
|
||||
</el-option>
|
||||
<el-option label="H1" value="h1">
|
||||
<template #default>
|
||||
<h1>H1</h1>
|
||||
</template>
|
||||
</el-option>
|
||||
<el-option label="H2" value="h2">
|
||||
<template #default>
|
||||
<h2>H2</h2>
|
||||
</template>
|
||||
</el-option>
|
||||
<el-option label="H3" value="h3">
|
||||
<template #default>
|
||||
<h3>H3</h3>
|
||||
</template>
|
||||
</el-option>
|
||||
<el-option label="H4" value="h4">
|
||||
<template #default>
|
||||
<h4>H4</h4>
|
||||
</template>
|
||||
</el-option>
|
||||
<el-option label="H5" value="h5">
|
||||
<template #default>
|
||||
<h5>H5</h5>
|
||||
</template>
|
||||
</el-option>
|
||||
<el-option label="H6" value="h6">
|
||||
<template #default>
|
||||
<h6>H6</h6>
|
||||
</template>
|
||||
</el-option>
|
||||
<el-option label="预格式化" value="pre">
|
||||
<template #default>
|
||||
<pre>预格式化</pre>
|
||||
</template>
|
||||
</el-option>
|
||||
</el-select>
|
||||
<el-select v-model="fontSize" class="item" style="width:130px" clearable allow-create filterable
|
||||
@change="editor.chain().focus().setMark('textStyle', { fontSize: fontSize }).run()">
|
||||
<template #prefix>
|
||||
<el-icon size="20px">
|
||||
<component :is="'pi-icon-font-size'"/>
|
||||
</el-icon>
|
||||
</template>
|
||||
<el-option label="12px" value="12px"/>
|
||||
<el-option label="14px" value="14px"/>
|
||||
<el-option label="16px" value="16px"/>
|
||||
<el-option label="18px" value="18px"/>
|
||||
<el-option label="22px" value="22px"/>
|
||||
<el-option label="24px" value="24px"/>
|
||||
<el-option label="36px" value="36px"/>
|
||||
<el-option label="72px" value="72px"/>
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<editor-content class="body" :editor="editor"/>
|
||||
<pi-asset-picker ref="pickerRef1" v-if="pickerVisible1" @success="saveSuccess1" @closed="pickerVisible1=false"
|
||||
type="image" :max="30" :multiple="true"></pi-asset-picker>
|
||||
<pi-asset-picker ref="pickerRef2" v-if="pickerVisible2" @success="saveSuccess2" @closed="pickerVisible2=false"
|
||||
type="video" :max="1"></pi-asset-picker>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import piAssetPicker from '@/components/piAsset/picker'
|
||||
import {Editor, EditorContent} from "@tiptap/vue-3";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import TextAlign from "@tiptap/extension-text-align"
|
||||
import Underline from '@tiptap/extension-underline'
|
||||
import Highlight from '@tiptap/extension-highlight'
|
||||
import Color from '@tiptap/extension-color'
|
||||
import TableRow from '@tiptap/extension-table-row'
|
||||
import TableCell from '@tiptap/extension-table-cell'
|
||||
import TableHeader from '@tiptap/extension-table-header'
|
||||
import Image from './ext/Image'
|
||||
import Video from './ext/Video'
|
||||
import Indent from './ext/Indent'
|
||||
import FontSize from './ext/FontSize'
|
||||
import Table from './ext/Table'
|
||||
import Selection from "./ext/Selection";
|
||||
|
||||
import {ref, onUnmounted, watch, nextTick, onMounted} from "vue";
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {type: String, default: ''},
|
||||
})
|
||||
|
||||
const pickerRef1 = ref(null)
|
||||
const pickerRef2 = ref(null)
|
||||
const colorPickerRef = ref(null)
|
||||
const bgPickerRef = ref(null)
|
||||
const toolbarRef = ref(null)
|
||||
const pickerVisible1 = ref(false)
|
||||
const pickerVisible2 = ref(false)
|
||||
|
||||
const predefineColors = ref([
|
||||
'#FFFFFF',
|
||||
'#000000',
|
||||
'#409EFF',
|
||||
'#67C23A',
|
||||
'#E6A23C',
|
||||
'#F56C6C',
|
||||
'#909399',
|
||||
'#303133',
|
||||
'#CDD0D6',
|
||||
'#E6E8EB',
|
||||
'#606266',
|
||||
'#EBEDF0'
|
||||
])
|
||||
let editor = ref(null)
|
||||
let fontSize = ref('12px')
|
||||
let fontColor = ref('')
|
||||
let bgColor = ref('')
|
||||
let tag = ref('')
|
||||
let tableVisible = ref(false)
|
||||
let tableRow = ref(2)
|
||||
let tableCol = ref(3)
|
||||
|
||||
watch(() => props.modelValue, (value) => {
|
||||
const isSame = editor.value.getHTML() === value
|
||||
if (isSame) {
|
||||
return
|
||||
}
|
||||
editor.value.commands.setContent(value, false)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// 保存 fakeSelection
|
||||
editor.value?.on('selectionUpdate', () => {
|
||||
editor.value?.commands.setSelection()
|
||||
})
|
||||
|
||||
// 点击外部仍保留背景
|
||||
document.addEventListener('click', (e) => {
|
||||
const el = document.querySelector('.ProseMirror')
|
||||
if (!el?.contains(e.target)){
|
||||
// 只是触发 view 更新,让装饰生效
|
||||
editor.value?.view.dispatch(editor.value.state.tr)
|
||||
}
|
||||
})
|
||||
|
||||
// 点击内部清除假选中
|
||||
document.querySelector('.ProseMirror')?.addEventListener('click', () => {
|
||||
editor.value?.commands.clearSelection()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
onUnmounted(() => {
|
||||
editor.value.destroy()
|
||||
})
|
||||
|
||||
editor.value = new Editor({
|
||||
content: props.modelValue,
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Underline,
|
||||
TextAlign.configure({
|
||||
types: ['heading', 'paragraph'],
|
||||
}),
|
||||
Indent,
|
||||
Highlight.configure({
|
||||
multicolor: true, // 支持多种背景色
|
||||
}),
|
||||
Color,
|
||||
FontSize,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell.configure({
|
||||
HTMLAttributes: {
|
||||
style: 'border: 1px solid #ccc;padding: 0.4rem;'
|
||||
},
|
||||
}),
|
||||
TableHeader.configure({
|
||||
HTMLAttributes: {
|
||||
style: 'border: 1px solid #ccc;padding: 0.4rem;'
|
||||
},
|
||||
}),
|
||||
Image.configure({
|
||||
inline: false,
|
||||
allowBase64: true,
|
||||
}),
|
||||
Video.configure({
|
||||
inline: false,
|
||||
allowBase64: true,
|
||||
}),
|
||||
Selection
|
||||
],
|
||||
onUpdate: () => {
|
||||
emit('update:modelValue', editor.value.getHTML())
|
||||
},
|
||||
onSelectionUpdate: () => {
|
||||
// 字体大小回显
|
||||
let _fontSize = editor.value.getAttributes('textStyle').fontSize
|
||||
fontSize.value = _fontSize ? _fontSize : '12px'
|
||||
|
||||
// 标签回显
|
||||
if (editor.value.isActive('paragraph')) {
|
||||
tag.value = 'p'
|
||||
} else if (editor.value.isActive('heading', {level: 1})) {
|
||||
tag.value = 'h1'
|
||||
} else if (editor.value.isActive('heading', {level: 2})) {
|
||||
tag.value = 'h2'
|
||||
} else if (editor.value.isActive('heading', {level: 3})) {
|
||||
tag.value = 'h3'
|
||||
} else if (editor.value.isActive('heading', {level: 4})) {
|
||||
tag.value = 'h4'
|
||||
} else if (editor.value.isActive('heading', {level: 5})) {
|
||||
tag.value = 'h5'
|
||||
} else if (editor.value.isActive('heading', {level: 6})) {
|
||||
tag.value = 'h6'
|
||||
} else if (editor.value.isActive('codeBlock')) {
|
||||
tag.value = 'pre'
|
||||
}
|
||||
},
|
||||
onCreate: () => {
|
||||
setTimeout(() => {
|
||||
// 初始化光标
|
||||
const firstNode = editor.value.state.doc.content.firstChild
|
||||
const from1 = firstNode?.content?.content?.[0]?.nodeSize ? 1 : 0
|
||||
const to1 = from1 + (firstNode?.nodeSize || 0) - 2
|
||||
editor.value.commands.setTextSelection({from1, to1})
|
||||
}, 300)
|
||||
}
|
||||
})
|
||||
|
||||
function tagChange() {
|
||||
if (tag.value === 'p') {
|
||||
editor.value.chain().focus().setParagraph().run()
|
||||
} else if (tag.value === 'h1') {
|
||||
editor.value.chain().focus().setHeading({level: 1}).run()
|
||||
} else if (tag.value === 'h2') {
|
||||
editor.value.chain().focus().setHeading({level: 2}).run()
|
||||
} else if (tag.value === 'h3') {
|
||||
editor.value.chain().focus().setHeading({level: 3}).run()
|
||||
} else if (tag.value === 'h4') {
|
||||
editor.value.chain().focus().setHeading({level: 4}).run()
|
||||
} else if (tag.value === 'h5') {
|
||||
editor.value.chain().focus().setHeading({level: 5}).run()
|
||||
} else if (tag.value === 'h6') {
|
||||
editor.value.chain().focus().setHeading({level: 6}).run()
|
||||
} else if (tag.value === 'pre') {
|
||||
editor.value.chain().focus().setCodeBlock().run()
|
||||
}
|
||||
}
|
||||
|
||||
function insertTable() {
|
||||
editor.value.chain().focus().insertTable({rows: tableRow.value, cols: tableCol.value}).run()
|
||||
tableVisible.value = false
|
||||
}
|
||||
|
||||
function showPicker1() {
|
||||
pickerVisible1.value = true
|
||||
nextTick(() => {
|
||||
pickerRef1.value.open()
|
||||
})
|
||||
}
|
||||
|
||||
function showPicker2() {
|
||||
pickerVisible2.value = true
|
||||
nextTick(() => {
|
||||
pickerRef2.value.open()
|
||||
})
|
||||
}
|
||||
|
||||
function saveSuccess1(val) {
|
||||
val.forEach(src => {
|
||||
editor.value.chain().focus().setImage({
|
||||
src: src,
|
||||
alt: ''
|
||||
}).run()
|
||||
})
|
||||
}
|
||||
|
||||
function saveSuccess2(val) {
|
||||
editor.value.chain().focus().setVideo({src: val}).run()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.pi-editor {
|
||||
border: 2px solid #eee;
|
||||
border-radius: 10px;
|
||||
box-shadow: none;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
visibility: inherit !important;
|
||||
}
|
||||
|
||||
.pi-editor .toolbar .box {
|
||||
background-color: #fff;
|
||||
border-bottom: none;
|
||||
box-shadow: 0 2px 2px -2px rgba(34, 47, 62, .1), 0 8px 8px -4px rgba(34, 47, 62, .07);
|
||||
padding: 4px 0;
|
||||
transition: box-shadow .5s;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pi-editor .toolbar .box .group {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin: 8px;
|
||||
}
|
||||
|
||||
.pi-editor .toolbar .box .group .item + .item {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
:deep(.toolbar .box .group .item.color .el-color-picker__trigger) {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
:deep(.group .color-picker) {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
opacity: 0;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.pi-editor .toolbar .box .group .el-button.active {
|
||||
background: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.pi-editor .toolbar .box .group .el-button.active .el-icon {
|
||||
color: var(--el-fill-color-blank);
|
||||
}
|
||||
|
||||
.pi-editor .toolbar .box .group .el-button .el-icon {
|
||||
color: var(--el-color-black);
|
||||
}
|
||||
|
||||
.pi-editor .toolbar .box .group .el-button.is-disabled .el-icon {
|
||||
color: var(--el-button-disabled-text-color);
|
||||
}
|
||||
|
||||
.pi-editor .body {
|
||||
margin: 1em;
|
||||
height: 300px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
resize: vertical;
|
||||
|
||||
:deep(ul) {
|
||||
list-style-type: disc;
|
||||
padding-left: 1.5em;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
:deep(ol) {
|
||||
list-style-type: decimal;
|
||||
padding-left: 1.5em;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
:deep(li) {
|
||||
margin-bottom: 0.3em;
|
||||
}
|
||||
|
||||
:deep(.ProseMirror-selectednode) {
|
||||
border: 3px solid #b4d7ff;
|
||||
}
|
||||
|
||||
:deep(.selectedCell) {
|
||||
background-color: rgba(66, 185, 131, 0.2);
|
||||
}
|
||||
|
||||
:deep(.selection) {
|
||||
background-color: rgba(180, 213, 255, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
<div class="pi-icon-select__wrapper" :class="{'hasValue':value}" @click="open">
|
||||
<el-input :prefix-icon="value||'el-icon-plus'" v-model="value" :disabled="disabled" readonly></el-input>
|
||||
</div>
|
||||
<el-text style="margin-left: 5px;">{{value}}</el-text>
|
||||
<el-dialog title="图标选择器" v-model="dialogVisible" :width="760" destroy-on-close append-to-body>
|
||||
<div class="pi-icon-select__dialog">
|
||||
<el-form :rules="{}">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,124 @@
|
|||
<template>
|
||||
<el-dialog title="经纬度选择" v-model="visible" :width="850" destroy-on-close @closed="$emit('closed')" :close-on-click-modal="true">
|
||||
<div class="map">
|
||||
<el-input v-model="address" placeholder="搜索地名" @change="searchMapHandle" style="width: 300px;"/>
|
||||
<el-button type="primary" style="margin-left: 20px" @click="searchMapHandle">搜索</el-button>
|
||||
<el-button type="primary" style="margin-left: 10px" @click="submit">确定</el-button>
|
||||
<div id="map-container">
|
||||
<div class="input">
|
||||
<el-input v-model="location.lng" size="small" placeholder="经度"></el-input>
|
||||
<el-input v-model="location.lat" size="small" placeholder="纬度"></el-input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import AMapLoader from '@amap/amap-jsapi-loader';
|
||||
import {ref} from "vue";
|
||||
|
||||
defineExpose({
|
||||
open,
|
||||
setData
|
||||
})
|
||||
|
||||
defineOptions({
|
||||
name: "piMap"
|
||||
})
|
||||
|
||||
const emit = defineEmits(['success', 'closed'])
|
||||
let AMap = ref(null)
|
||||
let location = ref({
|
||||
lat: '',
|
||||
lng: ''
|
||||
})
|
||||
let markers = ref([])
|
||||
let map = ref(null)
|
||||
let visible = ref(false)
|
||||
let address = ref("")
|
||||
|
||||
//显示
|
||||
function open(){
|
||||
visible.value = true;
|
||||
initMap()
|
||||
}
|
||||
|
||||
function initMap(){
|
||||
window._AMapSecurityConfig = {
|
||||
securityJsCode: "1d4ec5360562473b31ffd4925995fe5c"
|
||||
}
|
||||
AMapLoader.load({
|
||||
key: "90960616a710a55eebb2cdfb0a959ede",
|
||||
version:"2.0",
|
||||
plugins: ['AMap.Scale', 'AMap.PlaceSearch', 'AMap.AutoComplete']
|
||||
}).then((a)=>{
|
||||
map.value = new a.Map("map-container",{
|
||||
viewMode: "3D",
|
||||
zoom: 15
|
||||
})
|
||||
// 地图点击事件
|
||||
map.value.on('click', clickMapHandler)
|
||||
AMap.value = a
|
||||
searchMapHandle()
|
||||
})
|
||||
}
|
||||
|
||||
// 点击地图事件
|
||||
function clickMapHandler(e) {
|
||||
location.value.lng = e.lnglat.getLng()
|
||||
location.value.lat = e.lnglat.getLat()
|
||||
map.value.remove(markers.value)
|
||||
let marker = new AMap.value.Marker({
|
||||
position: [location.value.lng, location.value.lat],
|
||||
});
|
||||
marker.setMap(map.value)
|
||||
markers.value.push(marker)
|
||||
}
|
||||
|
||||
function searchMapHandle() {
|
||||
AMap.value.plugin('AMap.PlaceSearch', function () {
|
||||
const autoOptions = {
|
||||
map: map.value,
|
||||
pageSize: 1,
|
||||
pageIndex: 1,
|
||||
autoFitView: true
|
||||
};
|
||||
const placeSearch = new AMap.value.PlaceSearch(autoOptions);
|
||||
placeSearch.search(address.value, function (status, result) {
|
||||
if (status == 'complete') {
|
||||
if (result.poiList.pois.length > 0) {
|
||||
location.value.lng = result.poiList.pois[0].location.lng;
|
||||
location.value.lat = result.poiList.pois[0].location.lat;
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function submit() {
|
||||
emit('success', location.value)
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
function setData(a) {
|
||||
address.value = a
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.map #map-container {
|
||||
padding: 0;
|
||||
margin: 20px auto;
|
||||
width: 100%;
|
||||
height: 500px;
|
||||
}
|
||||
|
||||
.el-input {
|
||||
width: 150px;
|
||||
margin: 10px 0 0 10px;
|
||||
z-index: 5;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
<template>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
<template>
|
||||
<div class="pi-video" ref="videoRef"></div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Player from 'xgplayer'
|
||||
import HlsPlayer from 'xgplayer-hls'
|
||||
import {ref, watch, onMounted} from "vue";
|
||||
|
||||
const videoRef = ref(null)
|
||||
const props = defineProps({
|
||||
src: {type: String, required: true, default: ""},
|
||||
autoplay: {type: Boolean, default: false},
|
||||
controls: {type: Boolean, default: true},
|
||||
loop: {type: Boolean, default: false},
|
||||
isLive: {type: Boolean, default: false},
|
||||
options: {
|
||||
type: Object, default: () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
let player = ref(null)
|
||||
|
||||
watch(() => props.src, (val) => {
|
||||
if (player.value.hasStart) {
|
||||
player.value.src = val
|
||||
} else {
|
||||
player.value.start(val)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
props.isLive ? initHls() : init()
|
||||
})
|
||||
|
||||
function init() {
|
||||
player.value = new Player({
|
||||
el: videoRef.value,
|
||||
url: props.src,
|
||||
autoplay: props.autoplay,
|
||||
loop: props.loop,
|
||||
controls: props.controls,
|
||||
fluid: true,
|
||||
lang: 'zh-cn',
|
||||
...props.options
|
||||
})
|
||||
}
|
||||
|
||||
function initHls() {
|
||||
player.value = new HlsPlayer({
|
||||
el: videoRef.value,
|
||||
url: props.src,
|
||||
autoplay: props.autoplay,
|
||||
loop: props.loop,
|
||||
controls: props.controls,
|
||||
fluid: true,
|
||||
isLive: true,
|
||||
ignores: ['time', 'progress'],
|
||||
lang: 'zh-cn',
|
||||
...props.options
|
||||
})
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pi-video:deep(.danmu) > * {
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
text-shadow: 1px 1px 0 #000, -1px -1px 0 #000, -1px 1px 0 #000, 1px -1px 0 #000;
|
||||
}
|
||||
|
||||
.pi-video:deep(.xgplayer-controls) {
|
||||
background-image: linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.3));
|
||||
}
|
||||
|
||||
.pi-video:deep(.xgplayer-progress-tip) {
|
||||
border: 0;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, .5);
|
||||
line-height: 25px;
|
||||
padding: 0 10px;
|
||||
border-radius: 25px;
|
||||
}
|
||||
|
||||
.pi-video:deep(.xgplayer-enter-spinner) {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<template>
|
||||
<div class="pi-section">
|
||||
<div class="title"><el-text size="large">{{title}}</el-text></div>
|
||||
<el-alert v-if="tip" class="tip" show-icon type="info" :title="tip" :closable="false"></el-alert>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const prop = defineProps({
|
||||
title: { type: String, required: true },
|
||||
tip: { type: String}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pi-section {margin-bottom: 20px;}
|
||||
.pi-section .title {color: #333;font-weight: 700;font-size: 18px;position: relative;padding-left: 12px;margin-bottom: 20px;}
|
||||
.pi-section .title:before { content: ""; width: 4px; height: 80%; background-color: var(--el-color-primary); position: absolute; left: 0; top: 50%; transform: translateY(-50%);}
|
||||
.pi-section .tip {margin-bottom: 20px;}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,254 @@
|
|||
<template>
|
||||
<el-select ref="selectRef" v-model="defaultValue" :size="size" :clearable="clearable" :multiple="multiple"
|
||||
:collapse-tags="collapseTags" :collapse-tags-tooltip="collapseTagsTooltip" :filterable="filterable"
|
||||
:placeholder="placeholder" :disabled="disabled" :filter-method="filterMethod" @remove-tag="removeTag"
|
||||
@visible-change="visibleChange" @clear="clear">
|
||||
<template #empty>
|
||||
<div class="pi-table-select__table" :style="{width: tableWidth+'px'}" v-loading="loading">
|
||||
<div class="pi-table-select__header">
|
||||
<slot name="header" :form="formData" :submit="formSubmit"></slot>
|
||||
</div>
|
||||
<el-table ref="tableRef" :data="tableData" :height="245" :highlight-current-row="!multiple"
|
||||
@row-click="click" @select="select" @select-all="selectAll">
|
||||
<el-table-column v-if="multiple" type="selection" width="45"></el-table-column>
|
||||
<el-table-column v-else type="index" width="45">
|
||||
<template #default="scope"><span>{{ scope.$index + (currentPage - 1) * pageSize + 1 }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<slot></slot>
|
||||
</el-table>
|
||||
<div class="pi-table-select__page">
|
||||
<el-pagination size="small" background layout="prev, pager, next" :total="total" :page-size="pageSize"
|
||||
v-model:currentPage="currentPage" @current-change="reload"></el-pagination>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-select>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import config from '@/config/select'
|
||||
import {ref, watch, onMounted, nextTick} from 'vue'
|
||||
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: null,
|
||||
apiObj: {
|
||||
type: Function, default: () => {
|
||||
}
|
||||
},
|
||||
params: {
|
||||
type: Object, default: () => {
|
||||
}
|
||||
},
|
||||
placeholder: {type: String, default: "请选择"},
|
||||
size: {type: String, default: "default"},
|
||||
clearable: {type: Boolean, default: false},
|
||||
multiple: {type: Boolean, default: false},
|
||||
filterable: {type: Boolean, default: false},
|
||||
collapseTags: {type: Boolean, default: false},
|
||||
collapseTagsTooltip: {type: Boolean, default: false},
|
||||
disabled: {type: Boolean, default: false},
|
||||
tableWidth: {type: Number, default: 400},
|
||||
mode: {type: String, default: "popover"},
|
||||
props: {
|
||||
type: Object, default: () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const tableRef = ref(null)
|
||||
const selectRef = ref(null)
|
||||
let loading = ref(false)
|
||||
let keyword = ref(null)
|
||||
let defaultValue = ref([])
|
||||
let tableData = ref([])
|
||||
let pageSize = ref(config.pageSize)
|
||||
let total = ref(0)
|
||||
let currentPage = ref(1)
|
||||
let defaultProps = ref({
|
||||
label: config.props.label,
|
||||
value: config.props.value,
|
||||
page: config.request.page,
|
||||
pageSize: config.request.pageSize,
|
||||
keyword: config.request.keyword,
|
||||
fields: []
|
||||
})
|
||||
let formData = ref({})
|
||||
|
||||
watch(() => props.modelValue, () => {
|
||||
defaultValue.value = props.modelValue
|
||||
autoCurrentLabel()
|
||||
}, {deep: true})
|
||||
|
||||
onMounted(() => {
|
||||
defaultProps.value = Object.assign(defaultProps.value, props.props);
|
||||
defaultValue.value = props.modelValue
|
||||
autoCurrentLabel()
|
||||
})
|
||||
|
||||
//表格显示隐藏回调
|
||||
function visibleChange(visible) {
|
||||
if (visible) {
|
||||
currentPage.value = 1
|
||||
keyword.value = null
|
||||
formData.value = {}
|
||||
getData()
|
||||
} else {
|
||||
autoCurrentLabel()
|
||||
}
|
||||
}
|
||||
|
||||
async function getData() {
|
||||
loading.value = true;
|
||||
var reqData = {
|
||||
[defaultProps.value.page]: currentPage.value,
|
||||
[defaultProps.value.pageSize]: pageSize.value,
|
||||
[defaultProps.value.keyword]: keyword.value
|
||||
}
|
||||
Object.assign(reqData, props.params, formData.value)
|
||||
var res = await props.apiObj(reqData);
|
||||
var parseData = config.parseData(res)
|
||||
tableData.value = parseData.rows;
|
||||
total.value = parseData.total;
|
||||
loading.value = false;
|
||||
//表格默认赋值
|
||||
nextTick(() => {
|
||||
if (props.multiple) {
|
||||
defaultValue.value.forEach(row => {
|
||||
var setrow = tableData.value.filter(item => item[defaultProps.value.value] === row[defaultProps.value.value])
|
||||
if (setrow.length > 0) {
|
||||
tableRef.value.toggleRowSelection(setrow[0], true);
|
||||
}
|
||||
})
|
||||
} else {
|
||||
var setrow = tableData.value.filter(item => item[defaultProps.value.value] === defaultValue.value[defaultProps.value.value])
|
||||
tableRef.value.setCurrentRow(setrow[0]);
|
||||
}
|
||||
tableRef.value.setScrollTop(0)
|
||||
})
|
||||
}
|
||||
|
||||
//插糟表单提交
|
||||
function formSubmit() {
|
||||
currentPage.value = 1
|
||||
keyword.value = null
|
||||
getData()
|
||||
}
|
||||
|
||||
//分页刷新表格
|
||||
function reload() {
|
||||
getData()
|
||||
}
|
||||
|
||||
//自动模拟options赋值
|
||||
function autoCurrentLabel() {
|
||||
// nextTick(() => {
|
||||
// if (props.multiple) {
|
||||
// selectRef.value.selected.forEach(item => {
|
||||
// item.currentLabel = item.value[defaultProps.value.label]
|
||||
// })
|
||||
// } else {
|
||||
// selectRef.value.selectedLabel = defaultValue.value[defaultProps.value.label]
|
||||
// }
|
||||
// })
|
||||
}
|
||||
|
||||
//表格勾选事件
|
||||
function select(rows, row) {
|
||||
var isSelect = rows.length && rows.indexOf(row) !== -1
|
||||
if (isSelect) {
|
||||
defaultValue.value.push(row)
|
||||
} else {
|
||||
defaultValue.value.splice(defaultValue.value.findIndex(item => item[defaultProps.value.value] == row[defaultProps.value.value]), 1)
|
||||
}
|
||||
autoCurrentLabel()
|
||||
// 解析数据
|
||||
emit('update:modelValue', config.filter(defaultValue.value, defaultProps.value.fields, props.multiple));
|
||||
emit('change', config.filter(defaultValue.value, defaultProps.value.fields, props.multiple));
|
||||
}
|
||||
|
||||
//表格全选事件
|
||||
function selectAll(rows) {
|
||||
var isAllSelect = rows.length > 0
|
||||
if (isAllSelect) {
|
||||
rows.forEach(row => {
|
||||
var isHas = defaultValue.value.find(item => item[defaultProps.value.value] == row[defaultProps.value.value])
|
||||
if (!isHas) {
|
||||
defaultValue.value.push(row)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
this.tableData.forEach(row => {
|
||||
var isHas = defaultValue.value.find(item => item[defaultProps.value.value] == row[defaultProps.value.value])
|
||||
if (isHas) {
|
||||
defaultValue.value.splice(defaultValue.value.findIndex(item => item[defaultProps.value.value] == row[defaultProps.value.value]), 1)
|
||||
}
|
||||
})
|
||||
}
|
||||
autoCurrentLabel()
|
||||
emit('update:modelValue', config.filter(defaultValue.value, defaultProps.value.fields, props.multiple));
|
||||
emit('change', config.filter(defaultValue.value, defaultProps.value.fields, props.multiple));
|
||||
}
|
||||
|
||||
function click(row) {
|
||||
if (props.multiple) {
|
||||
//处理多选点击行
|
||||
} else {
|
||||
defaultValue.value = row
|
||||
selectRef.value.blur()
|
||||
autoCurrentLabel()
|
||||
emit('update:modelValue', config.filter(defaultValue.value, defaultProps.value.fields, props.multiple));
|
||||
emit('change', config.filter(defaultValue.value, defaultProps.value.fields, props.multiple));
|
||||
}
|
||||
}
|
||||
|
||||
//tags删除后回调
|
||||
function removeTag(tag) {
|
||||
var row = findRowByKey(tag[defaultProps.value.value])
|
||||
tableRef.value.toggleRowSelection(row, false);
|
||||
emit('update:modelValue', config.filter(defaultValue.value, defaultProps.value.fields, props.multiple));
|
||||
}
|
||||
|
||||
//清空后的回调
|
||||
function clear() {
|
||||
emit('update:modelValue', config.filter(defaultValue.value, defaultProps.value.fields, props.multiple));
|
||||
}
|
||||
|
||||
// 关键值查询表格数据行
|
||||
function findRowByKey(value) {
|
||||
return tableData.value.find(item => item[defaultProps.value.value] === value)
|
||||
}
|
||||
|
||||
function filterMethod(k) {
|
||||
if (!k) {
|
||||
keyword.value = null;
|
||||
return false;
|
||||
}
|
||||
keyword.value = k;
|
||||
getData()
|
||||
}
|
||||
|
||||
// 触发select隐藏
|
||||
function blur() {
|
||||
selectRef.value.blur();
|
||||
}
|
||||
|
||||
// 触发select显示
|
||||
function focus() {
|
||||
selectRef.value.focus();
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pi-table-select__table {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.pi-table-select__page {
|
||||
padding-top: 12px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,11 +1,19 @@
|
|||
<template>
|
||||
<el-container>
|
||||
<el-header v-if="!hideAct">
|
||||
<div v-if="(hasSearchSlot || hasExtendSearchSlot) && show" class="extend">
|
||||
<el-collapse-transition>
|
||||
<div class="extend-panel">
|
||||
<slot name="extend"></slot>
|
||||
</div>
|
||||
</el-collapse-transition>
|
||||
</div>
|
||||
<el-header v-if="hasSearchSlot || hasExtendSearchSlot || hasDoSlot">
|
||||
<div class="left-panel">
|
||||
<slot name="do"></slot>
|
||||
</div>
|
||||
<div class="right-panel">
|
||||
<slot name="search"></slot>
|
||||
<el-button v-if="hasExtendSearchSlot" type="primary" icon="el-icon-filter" @click="show = !show" :plain="show"/>
|
||||
</div>
|
||||
</el-header>
|
||||
<el-main class="nopadding">
|
||||
|
|
@ -50,7 +58,8 @@
|
|||
<template #reference>
|
||||
<el-button icon="el-icon-set-up" circle style="margin-left:15px"></el-button>
|
||||
</template>
|
||||
<columnSetting v-if="customColumnShow" ref="columnSettingRef" @userChange="columnSettingChange"
|
||||
<columnSetting v-if="customColumnShow" ref="columnSettingRef"
|
||||
@userChange="columnSettingChange"
|
||||
@save="columnSettingSave" @back="columnSettingBack"
|
||||
:column="userColumn"></columnSetting>
|
||||
</el-popover>
|
||||
|
|
@ -83,7 +92,7 @@
|
|||
<script setup>
|
||||
import columnSetting from './columnSetting'
|
||||
import config from "@/config/table"
|
||||
import {ref, watch, computed, onMounted, onActivated, onDeactivated, getCurrentInstance} from "vue"
|
||||
import {ref, watch, computed, onMounted, onActivated, onDeactivated, getCurrentInstance, useSlots} from "vue"
|
||||
import tools from "@/utils/tools.js";
|
||||
|
||||
defineOptions({
|
||||
|
|
@ -99,15 +108,22 @@ defineExpose({
|
|||
const {proxy} = getCurrentInstance()
|
||||
const emit = defineEmits(['dataChange'])
|
||||
|
||||
const slots = useSlots()
|
||||
const hasSearchSlot = computed(() => !!slots.search)
|
||||
const hasExtendSearchSlot = computed(() => !!slots.extend)
|
||||
const hasDoSlot = computed(() => !!slots.do)
|
||||
|
||||
const props = defineProps({
|
||||
tableName: {type: String, default: ""},
|
||||
apiObj: {
|
||||
type: Function, default: () => {}
|
||||
type: Function, default: () => {
|
||||
}
|
||||
},
|
||||
workbench: {type: Boolean, default: false},
|
||||
params: {type: Object, default: () => ({})},
|
||||
data: {
|
||||
type: Object, default: () => {}
|
||||
type: Object, default: () => {
|
||||
}
|
||||
},
|
||||
height: {type: [String, Number], default: "100%"},
|
||||
size: {type: String, default: "default"},
|
||||
|
|
@ -118,14 +134,14 @@ const props = defineProps({
|
|||
rowKey: {type: String, default: ""},
|
||||
summaryMethod: {type: Function, default: null},
|
||||
column: {
|
||||
type: Object, default: () => {}
|
||||
type: Object, default: () => {
|
||||
}
|
||||
},
|
||||
remoteSort: {type: Boolean, default: false},
|
||||
remoteFilter: {type: Boolean, default: false},
|
||||
remoteSummary: {type: Boolean, default: false},
|
||||
hidePagination: {type: Boolean, default: false},
|
||||
hideDo: {type: Boolean, default: false},
|
||||
hideAct: {type: Boolean, default: false},
|
||||
hideRefresh: {type: Boolean, default: false},
|
||||
hideSetting: {type: Boolean, default: false},
|
||||
paginationLayout: {type: String, default: config.paginationLayout},
|
||||
|
|
@ -140,7 +156,7 @@ let emptyText = ref("暂无数据")
|
|||
let toggleIndex = ref(0)
|
||||
let tableData = ref([])
|
||||
let total = ref(0)
|
||||
let currentPage = ref(0)
|
||||
let currentPage = ref(1)
|
||||
let prop = ref(null)
|
||||
let order = ref(null)
|
||||
let loading = ref(false)
|
||||
|
|
@ -155,6 +171,7 @@ let _config = ref({
|
|||
stripe: props.stripe
|
||||
})
|
||||
let nowWork = ref(null)
|
||||
let show = ref(false)
|
||||
|
||||
watch(() => props.data, () => {
|
||||
tableData.value = props.data;
|
||||
|
|
@ -268,6 +285,7 @@ function refresh(){
|
|||
piTableRef.value.clearSelection()
|
||||
getData()
|
||||
}
|
||||
|
||||
//更新数据 合并上一次params
|
||||
function upData(params, page = 1) {
|
||||
currentPage.value = page;
|
||||
|
|
@ -275,6 +293,7 @@ function upData(params, page=1){
|
|||
Object.assign(tableParams.value, params || {})
|
||||
getData()
|
||||
}
|
||||
|
||||
//重载数据 替换params
|
||||
function reload(params, page = 1) {
|
||||
currentPage.value = page;
|
||||
|
|
@ -284,11 +303,13 @@ function reload(params, page=1){
|
|||
piTableRef.value.clearFilter()
|
||||
getData()
|
||||
}
|
||||
|
||||
//自定义变化事件
|
||||
function columnSettingChange(column) {
|
||||
userColumn.value = column;
|
||||
toggleIndex.value += 1;
|
||||
}
|
||||
|
||||
//自定义列保存
|
||||
async function columnSettingSave(column) {
|
||||
columnSettingRef.value.isSave = true
|
||||
|
|
@ -301,6 +322,7 @@ async function columnSettingSave(column){
|
|||
proxy.$message.success('保存成功')
|
||||
columnSettingRef.value.isSave = false
|
||||
}
|
||||
|
||||
//自定义列重置
|
||||
async function columnSettingBack() {
|
||||
columnSettingRef.value.isSave = true
|
||||
|
|
@ -314,6 +336,7 @@ async function columnSettingBack(){
|
|||
}
|
||||
columnSettingRef.value.isSave = false
|
||||
}
|
||||
|
||||
//排序事件
|
||||
function sortChange(obj) {
|
||||
if (!proxy.remoteSort) {
|
||||
|
|
@ -328,11 +351,13 @@ function sortChange(obj){
|
|||
}
|
||||
getData()
|
||||
}
|
||||
|
||||
//本地过滤
|
||||
function filterHandler(value, row, column) {
|
||||
const property = column.property;
|
||||
return row[property] === value;
|
||||
}
|
||||
|
||||
//过滤事件
|
||||
function filterChange(filters) {
|
||||
if (!props.remoteFilter) {
|
||||
|
|
@ -343,6 +368,7 @@ function filterChange(filters){
|
|||
})
|
||||
upData(filters)
|
||||
}
|
||||
|
||||
//远程合计行处理
|
||||
function remoteSummaryMethod(param) {
|
||||
const {columns} = param
|
||||
|
|
@ -361,51 +387,62 @@ function remoteSummaryMethod(param){
|
|||
})
|
||||
return sums
|
||||
}
|
||||
|
||||
function configSizeChange() {
|
||||
piTableRef.value.doLayout()
|
||||
}
|
||||
|
||||
//插入行 unshiftRow
|
||||
function unshiftRow(row) {
|
||||
tableData.value.unshift(row)
|
||||
}
|
||||
|
||||
//插入行 pushRow
|
||||
function pushRow(row) {
|
||||
tableData.value.push(row)
|
||||
}
|
||||
|
||||
//根据key覆盖数据
|
||||
function updateKey(row, rowKey = props.rowKey) {
|
||||
tableData.value.filter(item => item[rowKey] === row[rowKey]).forEach(item => {
|
||||
Object.assign(item, row)
|
||||
})
|
||||
}
|
||||
|
||||
//根据index覆盖数据
|
||||
function updateIndex(row, index) {
|
||||
Object.assign(tableData.value[index], row)
|
||||
}
|
||||
|
||||
//根据index删除
|
||||
function removeIndex(index) {
|
||||
tableData.value.splice(index, 1)
|
||||
}
|
||||
|
||||
//根据index批量删除
|
||||
function removeIndexes(indexes = []) {
|
||||
indexes.forEach(index => {
|
||||
tableData.value.splice(index, 1)
|
||||
})
|
||||
}
|
||||
|
||||
//根据key删除
|
||||
function removeKey(key, rowKey = props.rowKey) {
|
||||
tableData.value.splice(tableData.value.findIndex(item => item[rowKey] === key), 1)
|
||||
}
|
||||
|
||||
//根据keys批量删除
|
||||
function removeKeys(keys = [], rowKey = props.rowKey) {
|
||||
keys.forEach(key => {
|
||||
tableData.value.splice(tableData.value.findIndex(item => item[rowKey] === key), 1)
|
||||
})
|
||||
}
|
||||
|
||||
//原生方法转发
|
||||
function clearSelection() {
|
||||
piTableRef.value.clearSelection()
|
||||
}
|
||||
|
||||
function toggleRowSelection(row, selected) {
|
||||
piTableRef.value.toggleRowSelection(row, selected)
|
||||
}
|
||||
|
|
@ -440,7 +477,7 @@ function sort(prop, order){
|
|||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
<style lang="scss" scoped>
|
||||
.pi-table {
|
||||
height: calc(100% - 50px);
|
||||
}
|
||||
|
|
@ -470,4 +507,31 @@ function sort(prop, order){
|
|||
width: 12px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.pi-extend {
|
||||
background: var(--el-bg-color-overlay);
|
||||
border-color: var(--el-border-color-light);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
::v-deep(.el-header) {
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.extend {
|
||||
background: var(--el-bg-color-overlay);
|
||||
border-color: var(--el-border-color-light);
|
||||
padding: 15px 15px 0 15px;
|
||||
}
|
||||
|
||||
.extend-panel {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.right-panel > * + *, .left-panel > * + *, .extend-panel > * + * {
|
||||
margin-left: 10px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,131 @@
|
|||
<template>
|
||||
<div class="sc-form-table">
|
||||
<el-table :data="data" ref="tableRef" :key="toggleIndex" border stripe>
|
||||
<el-table-column type="index" width="50" fixed="left">
|
||||
<template #header>
|
||||
<el-button :disabled="(limit > 0 && data.length >= limit) || disabledAdd" type="primary"
|
||||
icon="el-icon-plus" size="small" circle @click="rowAdd"></el-button>
|
||||
</template>
|
||||
<template #default="scope">
|
||||
<div class="sc-form-table-handle">
|
||||
<span>{{ scope.$index + 1 }}</span>
|
||||
<el-button type="danger" :disabled="scope.$index == 0 && !delFirst" icon="el-icon-delete"
|
||||
size="small" plain circle @click="rowDel(scope.row, scope.$index)"></el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="" width="58" v-if="dragSort">
|
||||
<template #default>
|
||||
<el-tag class="move" style="cursor: move;">
|
||||
<el-icon-d-caret style="width: 1em; height: 1em;"/>
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<slot></slot>
|
||||
<template #empty>
|
||||
{{ placeholder }}
|
||||
</template>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Sortable from 'sortablejs'
|
||||
import {ref, onMounted, watch, nextTick} from 'vue'
|
||||
|
||||
const emit = defineEmits(['add', 'del', 'update:modelValue'])
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {type: Array, default: () => []},
|
||||
addTemplate: {type: Object, default: () => {}},
|
||||
placeholder: {type: String, default: "暂无数据"},
|
||||
dragSort: {type: Boolean, default: false},
|
||||
delFirst: {type: Boolean, default: true},
|
||||
limit: {type: Number, default: 0},
|
||||
disabledAdd: {type: Boolean, default: false},
|
||||
// 魔术模式 使用将仅触发添加事件
|
||||
magic: {type: Boolean, default: false},
|
||||
})
|
||||
|
||||
const tableRef = ref(null)
|
||||
|
||||
let data = ref([])
|
||||
let toggleIndex = ref(0)
|
||||
|
||||
onMounted(() => {
|
||||
if (props.modelValue.length > 0) {
|
||||
data.value = props.modelValue
|
||||
}
|
||||
if (props.dragSort) {
|
||||
rowDrop()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, () => {
|
||||
data.value = props.modelValue
|
||||
}, {deep: true})
|
||||
|
||||
watch(data, () => {
|
||||
emit('update:modelValue', data.value)
|
||||
}, {deep: true})
|
||||
|
||||
function rowDrop() {
|
||||
const tbody = tableRef.value.$el.querySelector('.el-table__body-wrapper tbody')
|
||||
Sortable.create(tbody, {
|
||||
handle: ".move",
|
||||
animation: 300,
|
||||
ghostClass: "ghost",
|
||||
onEnd({newIndex, oldIndex}) {
|
||||
const tableData = data.value
|
||||
const currRow = tableData.splice(oldIndex, 1)[0]
|
||||
tableData.splice(newIndex, 0, currRow)
|
||||
toggleIndex.value += 1
|
||||
nextTick(() => {
|
||||
rowDrop()
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function rowAdd() {
|
||||
if (props.magic) {
|
||||
emit('add')
|
||||
return
|
||||
}
|
||||
const temp = JSON.parse(JSON.stringify(props.addTemplate))
|
||||
data.value.push(temp)
|
||||
emit('add', data.value.length - 1)
|
||||
}
|
||||
|
||||
function rowDel(row, index) {
|
||||
data.value.splice(index, 1)
|
||||
emit('del')
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sc-form-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sc-form-table .sc-form-table-handle {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sc-form-table .sc-form-table-handle span {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.sc-form-table .sc-form-table-handle button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sc-form-table .hover-row .sc-form-table-handle span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sc-form-table .hover-row .sc-form-table-handle button {
|
||||
display: inline-block;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
import api from "@/api";
|
||||
|
||||
export default {
|
||||
uploadObj: api.system.file.upload,
|
||||
fileListObj: api.system.file.list,
|
||||
moveFileObj: api.system.file.move,
|
||||
delFileObj: api.system.file.del,
|
||||
menuListObj: api.system.category.list,
|
||||
menuAddObj: api.system.category.add,
|
||||
menuEditObj: api.system.category.edit,
|
||||
menuDelObj: api.system.category.del,
|
||||
successCode: 200,
|
||||
maxSize: 20,
|
||||
max: 99,
|
||||
uploadParseData: function (res) {
|
||||
return {
|
||||
fileName: res.data.fileName,
|
||||
url: res.data.filePath
|
||||
}
|
||||
},
|
||||
listParseData: function (res) {
|
||||
return {
|
||||
rows: res.data,
|
||||
total: res.count,
|
||||
msg: res.msg,
|
||||
code: res.code
|
||||
}
|
||||
},
|
||||
request: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
keyword: 'file_name',
|
||||
menuKey: 'category_id'
|
||||
},
|
||||
menuProps: {
|
||||
key: 'category_id',
|
||||
label: 'category_name',
|
||||
children: 'children'
|
||||
},
|
||||
fileProps: {
|
||||
fileName: 'file_name',
|
||||
url: 'url'
|
||||
},
|
||||
fileType(type: string) {
|
||||
switch (type) {
|
||||
case 'word':
|
||||
return {
|
||||
"accept": "application/msword, application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"ext": ['doc', 'docx']
|
||||
}
|
||||
case 'excel':
|
||||
return {
|
||||
"accept": "application/vnd.ms-excel, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"ext": ['xls', 'xlsx']
|
||||
}
|
||||
case 'ppt':
|
||||
return {
|
||||
"accept": "application/vnd.ms-powerpoint, application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"ext": ['ppt', 'pptx']
|
||||
}
|
||||
case 'image':
|
||||
return {
|
||||
"accept": "image/x-ms-bmp, image/jpeg, image/gif, image/png",
|
||||
"ext": ['png', 'jpg', 'bmp', 'jpeg', 'gif']
|
||||
}
|
||||
case 'video':
|
||||
return {
|
||||
"accept": "video/mp4",
|
||||
"ext": ['mp4']
|
||||
}
|
||||
case 'audio':
|
||||
return {
|
||||
"accept": "audio/mpeg, audio/wav",
|
||||
"ext": ['mp3', 'wav']
|
||||
}
|
||||
default:
|
||||
return {
|
||||
"accept": "",
|
||||
"ext": []
|
||||
}
|
||||
}
|
||||
},
|
||||
// 图片
|
||||
isImg(fileUrl: string) {
|
||||
const fileExt = fileUrl.substring(fileUrl.lastIndexOf(".") + 1)
|
||||
return this.fileType('image')['ext'].indexOf(fileExt) != -1
|
||||
},
|
||||
// 视频
|
||||
isVideo(fileUrl: string) {
|
||||
const fileExt = fileUrl.substring(fileUrl.lastIndexOf(".") + 1)
|
||||
return this.fileType('video')['ext'].indexOf(fileExt) != -1
|
||||
},
|
||||
getExt(fileUrl: string) {
|
||||
return fileUrl.substring(fileUrl.lastIndexOf(".") + 1)
|
||||
},
|
||||
getType(name: string) {
|
||||
switch (this.getExt(name)) {
|
||||
case 'mp3':
|
||||
case 'wav':
|
||||
return 'audio';
|
||||
case 'mp4':
|
||||
return 'video';
|
||||
case 'png':
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
case 'bmp':
|
||||
case 'gif':
|
||||
return 'image';
|
||||
case 'ppt':
|
||||
case 'pptx':
|
||||
return 'ppt';
|
||||
case 'xls':
|
||||
case 'xlsx':
|
||||
return 'excel';
|
||||
case 'doc':
|
||||
case 'docx':
|
||||
return 'word';
|
||||
default:
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
export default {
|
||||
pageSize: 10, //表格每一页条数
|
||||
parseData: function (res) {
|
||||
return {
|
||||
data: res,
|
||||
rows: res.data, //分析行数据字段结构
|
||||
total: res.count, //分析总数字段结构
|
||||
msg: res.msg, //分析描述字段结构
|
||||
code: res.code //分析状态字段结构
|
||||
}
|
||||
},
|
||||
filter: function (data, fields, multiple = true) {
|
||||
if (multiple) {
|
||||
const value = [];
|
||||
if (data.length > 0 && fields.length > 0) {
|
||||
for (const key in data) {
|
||||
const item = {};
|
||||
for (const field of fields) {
|
||||
item[field] = data[key][field]
|
||||
}
|
||||
value.push(item)
|
||||
}
|
||||
}
|
||||
return value
|
||||
}else {
|
||||
const item = {};
|
||||
for (const field of fields) {
|
||||
item[field] = data[field]
|
||||
}
|
||||
return item;
|
||||
}
|
||||
},
|
||||
request: {
|
||||
page: 'page', //规定当前分页字段
|
||||
pageSize: 'limit', //规定一页条数字段
|
||||
keyword: 'keyword' //规定搜索字段
|
||||
},
|
||||
props: {
|
||||
label: 'label', //映射label显示字段
|
||||
value: 'value', //映射value值字段
|
||||
}
|
||||
}
|
||||
|
|
@ -46,10 +46,9 @@
|
|||
</div>
|
||||
<div v-else>
|
||||
<el-scrollbar height="220px" :view-style="{ 'overflow-x': 'hidden' }">
|
||||
<el-link v-for="item in msgList" :key="item" href="/#/message" underline="never" :title="item.time + ' ' + item.title">
|
||||
<el-link v-for="item in msgList" :key="item" href="/#/message" underline="never" :title="item.time + ' ' + item.title" style="display: block; width: 100%;">
|
||||
<template #default>
|
||||
<el-text type="primary" v-time.tip="item.time"></el-text>
|
||||
<div>{{ item.title }}</div>
|
||||
<el-text truncated style="width: 100%;"><el-text type="primary" v-time.tip="item.time" style="margin-right: 5px;"></el-text>{{ item.title }}</el-text>
|
||||
</template>
|
||||
</el-link>
|
||||
</el-scrollbar>
|
||||
|
|
@ -372,14 +371,10 @@ async function loadData() {
|
|||
|
||||
.msg-content .el-link {
|
||||
line-height: 30px;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.msg-content .el-link .el-text{
|
||||
margin-right: 5px;
|
||||
width: 48px;
|
||||
:deep(.el-link__inner) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.msg-more {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import errorHandler from "@/utils/errorHandler";
|
|||
|
||||
import piDialog from "@/components/piDialog"
|
||||
import piTable from "@/components/piTable"
|
||||
import piPage from "@/components/piPage"
|
||||
import piUpload from "@/components/piUpload"
|
||||
|
||||
export default {
|
||||
|
|
@ -19,7 +18,6 @@ export default {
|
|||
// 注册全局组件
|
||||
app.component('piDialog', piDialog)
|
||||
app.component('piTable', piTable)
|
||||
app.component('piPage', piPage)
|
||||
app.component('piUpload', piUpload)
|
||||
|
||||
//注册全局指令
|
||||
|
|
|
|||
|
|
@ -23,6 +23,12 @@ axios.interceptors.request.use((config) => {
|
|||
})
|
||||
//响应拦截
|
||||
axios.interceptors.response.use((response) => {
|
||||
// 续签逻辑
|
||||
const newToken = response.headers['x-token-refresh']
|
||||
const ttl = response.headers['x-token-expire']
|
||||
if (newToken && ttl > 0) {
|
||||
tools.data.set("TOKEN", newToken, ttl)
|
||||
}
|
||||
let res = response.data
|
||||
if (res.code == 0) {
|
||||
return Promise.resolve(res)
|
||||
|
|
|
|||
|
|
@ -129,13 +129,13 @@ const tools = {
|
|||
}
|
||||
return fmt;
|
||||
},
|
||||
makeTreeData: function (data, pid = 0, key = "id") {
|
||||
makeTreeData: function (data, pid = 0, key = "id", parent = "pid") {
|
||||
const arr = [];
|
||||
for (let item of data) {
|
||||
if(item.pid == pid){
|
||||
if (item[parent] == pid) {
|
||||
// 数据格式处理
|
||||
const tmp = item;
|
||||
const children = tools.makeTreeData(data, item[key], key);
|
||||
const children = tools.makeTreeData(data, item[key], key, parent);
|
||||
if (children.length > 0) {
|
||||
tmp['children'] = children
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
<template>
|
||||
<pi-table ref="tableRef" :apiObj="api.system.online.list" @selection-change="selectionChange">
|
||||
<template #do>
|
||||
<el-input v-model="search.username" placeholder="用户名" clearable style="width: 200px;" @keyup.enter="upsearch"></el-input>
|
||||
<el-button type="primary" icon="el-icon-search" @click="upsearch"></el-button>
|
||||
</template>
|
||||
<el-table-column type="selection" width="50"></el-table-column>
|
||||
<el-table-column label="#" type="index" width="50"></el-table-column>
|
||||
<el-table-column label="会话编号" prop="session_id"></el-table-column>
|
||||
<el-table-column label="用户名" prop="username"></el-table-column>
|
||||
<el-table-column label="部门名称" prop="account.dept.dept_name"></el-table-column>
|
||||
<el-table-column label="主机" prop="ip"></el-table-column>
|
||||
<el-table-column label="登录地点" prop="location"></el-table-column>
|
||||
<el-table-column label="浏览器" prop="os.browser"></el-table-column>
|
||||
<el-table-column label="操作系统" prop="os.os"></el-table-column>
|
||||
<el-table-column label="登录时间" prop="online_time"></el-table-column>
|
||||
<el-table-column label="操作" fixed="right" align="right" width="100">
|
||||
<template #default="scope">
|
||||
<el-button-group>
|
||||
<el-popconfirm title="确定删除吗?" @confirm="table_del(scope.row, scope.$index)">
|
||||
<template #reference>
|
||||
<el-button v-auth="'online:quit'" text type="primary" size="small">强退</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</el-button-group>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</pi-table>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import api from "@/api/index";
|
||||
import {getCurrentInstance, nextTick, ref} from "vue";
|
||||
|
||||
defineOptions({
|
||||
name: "monitorOnline"
|
||||
})
|
||||
|
||||
const {proxy} = getCurrentInstance()
|
||||
const tableRef = ref(null)
|
||||
const dialogRef = ref(null)
|
||||
|
||||
let dialogShow = ref(false)
|
||||
let selection = ref([])
|
||||
let search = ref({
|
||||
username: null
|
||||
})
|
||||
|
||||
//删除
|
||||
async function table_del(row) {
|
||||
const loading = proxy.$loading();
|
||||
const res = await api.system.post.del({ids: row.post_id});
|
||||
tableRef.value.refresh()
|
||||
loading.close();
|
||||
proxy.$message.success(res.msg)
|
||||
}
|
||||
|
||||
//批量删除
|
||||
async function batch_del() {
|
||||
proxy.$confirm(`确定删除选中的 ${selection.value.length} 项吗?如果删除项中含有子集将会被一并删除`, '提示', {
|
||||
type: 'warning'
|
||||
}).then(async () => {
|
||||
const loading = proxy.$loading();
|
||||
const res = await api.system.post.del({ids: selection.value.map(item => item.post_id).toString()});
|
||||
tableRef.value.refresh()
|
||||
loading.close();
|
||||
proxy.$message.success(res.msg)
|
||||
})
|
||||
}
|
||||
|
||||
//表格选择后回调事件
|
||||
function selectionChange(e) {
|
||||
selection.value = e;
|
||||
}
|
||||
|
||||
//搜索
|
||||
function upsearch() {
|
||||
tableRef.value.upData(search.value)
|
||||
}
|
||||
</script>
|
||||
|
|
@ -0,0 +1,328 @@
|
|||
<template>
|
||||
<el-main>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-text>
|
||||
<el-icon>
|
||||
<component :is="'el-icon-cpu'"/>
|
||||
</el-icon>
|
||||
<span>CPU</span>
|
||||
</el-text>
|
||||
</div>
|
||||
</template>
|
||||
<el-text class="pi-title">
|
||||
<el-text type="primary">核心数</el-text>
|
||||
<el-text>{{ cpu.cpu_cores }}</el-text>
|
||||
</el-text>
|
||||
<el-divider></el-divider>
|
||||
<el-text class="pi-title">
|
||||
<el-text type="primary">用户使用率</el-text>
|
||||
<el-text>{{ cpu.user_usage }}</el-text>
|
||||
</el-text>
|
||||
<el-divider></el-divider>
|
||||
<el-text class="pi-title">
|
||||
<el-text type="primary">系统使用率</el-text>
|
||||
<el-text>{{ cpu.system_usage }}</el-text>
|
||||
</el-text>
|
||||
<el-divider></el-divider>
|
||||
<el-text class="pi-title">
|
||||
<el-text type="primary">当前空闲率</el-text>
|
||||
<el-text>{{ cpu.idle }}</el-text>
|
||||
</el-text>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-text>
|
||||
<el-icon>
|
||||
<component :is="'el-icon-document'"/>
|
||||
</el-icon>
|
||||
<span>内存</span>
|
||||
</el-text>
|
||||
</div>
|
||||
</template>
|
||||
<el-text class="pi-title">
|
||||
<el-text type="primary">总内存</el-text>
|
||||
<el-text>{{ mem.total }}</el-text>
|
||||
</el-text>
|
||||
<el-divider></el-divider>
|
||||
<el-text class="pi-title">
|
||||
<el-text type="primary">已用内存</el-text>
|
||||
<el-text>{{ mem.used }}</el-text>
|
||||
</el-text>
|
||||
<el-divider></el-divider>
|
||||
<el-text class="pi-title">
|
||||
<el-text type="primary">剩余内存</el-text>
|
||||
<el-text>{{ mem.free }}</el-text>
|
||||
</el-text>
|
||||
<el-divider></el-divider>
|
||||
<el-text class="pi-title">
|
||||
<el-text type="primary">使用率</el-text>
|
||||
<el-text>{{ mem.usage }}</el-text>
|
||||
</el-text>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-text>
|
||||
<el-icon>
|
||||
<component :is="'el-icon-monitor'"/>
|
||||
</el-icon>
|
||||
<span>服务器信息</span>
|
||||
</el-text>
|
||||
</div>
|
||||
</template>
|
||||
<el-text class="pi-title">
|
||||
<el-text type="primary">服务器名称</el-text>
|
||||
<el-text>{{ sys.server_name }}</el-text>
|
||||
</el-text>
|
||||
<el-divider></el-divider>
|
||||
<el-text class="pi-title">
|
||||
<el-text type="primary">操作系统</el-text>
|
||||
<el-text>{{ sys.os }}</el-text>
|
||||
</el-text>
|
||||
<el-divider></el-divider>
|
||||
<el-text class="pi-title">
|
||||
<el-text type="primary">服务器IP</el-text>
|
||||
<el-text>{{ sys.server_ip }}</el-text>
|
||||
</el-text>
|
||||
<el-divider></el-divider>
|
||||
<el-text class="pi-title">
|
||||
<el-text type="primary">系统架构</el-text>
|
||||
<el-text>{{ sys.architecture }}</el-text>
|
||||
</el-text>
|
||||
<el-divider></el-divider>
|
||||
<el-text class="pi-title">
|
||||
<el-text type="primary">PHP版本</el-text>
|
||||
<el-text>{{ sys.php_version }}</el-text>
|
||||
</el-text>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-text>
|
||||
<el-icon>
|
||||
<component :is="'el-icon-mostly-cloudy'"/>
|
||||
</el-icon>
|
||||
<span>环境信息</span>
|
||||
</el-text>
|
||||
</div>
|
||||
</template>
|
||||
<el-text class="pi-title">
|
||||
<el-text type="primary">SWOOLE版本</el-text>
|
||||
<el-text>{{ app.swoole_version }}</el-text>
|
||||
</el-text>
|
||||
<el-divider></el-divider>
|
||||
<el-text class="pi-title">
|
||||
<el-text type="primary">HYPERF版本</el-text>
|
||||
<el-text>{{ app.hyperf_version }}</el-text>
|
||||
</el-text>
|
||||
<el-divider></el-divider>
|
||||
<el-text class="pi-title">
|
||||
<el-text type="primary">项目路径</el-text>
|
||||
<el-text>{{ app.project_path }}</el-text>
|
||||
</el-text>
|
||||
<el-divider></el-divider>
|
||||
<el-text class="pi-title">
|
||||
<el-text type="primary">启动时间</el-text>
|
||||
<el-text>{{ app.start_time }}</el-text>
|
||||
</el-text>
|
||||
<el-divider></el-divider>
|
||||
<el-text class="pi-title">
|
||||
<el-text type="primary">运行时长</el-text>
|
||||
<el-text>{{ app.uptime }}</el-text>
|
||||
</el-text>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-text>
|
||||
<el-icon>
|
||||
<component :is="'el-icon-box'"/>
|
||||
</el-icon>
|
||||
<span>缓存信息</span>
|
||||
</el-text>
|
||||
</div>
|
||||
</template>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="6">
|
||||
<el-text class="pi-title">
|
||||
<el-text type="primary">Redis版本</el-text>
|
||||
<el-text>{{ redis.version }}</el-text>
|
||||
</el-text>
|
||||
<el-divider></el-divider>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-text class="pi-title">
|
||||
<el-text type="primary">运行模式</el-text>
|
||||
<el-text>{{ redis.mode }}</el-text>
|
||||
</el-text>
|
||||
<el-divider></el-divider>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-text class="pi-title">
|
||||
<el-text type="primary">端口</el-text>
|
||||
<el-text>{{ redis.port }}</el-text>
|
||||
</el-text>
|
||||
<el-divider></el-divider>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-text class="pi-title">
|
||||
<el-text type="primary">客户端数</el-text>
|
||||
<el-text>{{ redis.clients }}</el-text>
|
||||
</el-text>
|
||||
<el-divider></el-divider>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-text class="pi-title">
|
||||
<el-text type="primary">运行时间(天)</el-text>
|
||||
<el-text>{{ redis.uptime_days }}</el-text>
|
||||
</el-text>
|
||||
<el-divider></el-divider>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-text class="pi-title">
|
||||
<el-text type="primary">使用内存</el-text>
|
||||
<el-text>{{ redis.used_memory }}</el-text>
|
||||
</el-text>
|
||||
<el-divider></el-divider>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-text class="pi-title">
|
||||
<el-text type="primary">使用CPU</el-text>
|
||||
<el-text>{{ redis.used_cpu }}</el-text>
|
||||
</el-text>
|
||||
<el-divider></el-divider>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-text class="pi-title">
|
||||
<el-text type="primary">内存配置</el-text>
|
||||
<el-text>{{ redis.maxmemory }}</el-text>
|
||||
</el-text>
|
||||
<el-divider></el-divider>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-text class="pi-title">
|
||||
<el-text type="primary">AOF是否开启</el-text>
|
||||
<el-text>{{ redis.aof_enabled }}</el-text>
|
||||
</el-text>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-text class="pi-title">
|
||||
<el-text type="primary">RDB是否成功</el-text>
|
||||
<el-text>{{ redis.rdb_last_bgsave_status }}</el-text>
|
||||
</el-text>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-text class="pi-title">
|
||||
<el-text type="primary">Key数量</el-text>
|
||||
<el-text>{{ redis.keys }}</el-text>
|
||||
</el-text>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-text class="pi-title">
|
||||
<el-text type="primary">网络入口/出口</el-text>
|
||||
<el-text>{{ redis.net_input_bytes }}/{{redis.net_output_bytes}}</el-text>
|
||||
</el-text>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-text>
|
||||
<el-icon>
|
||||
<component :is="'el-icon-message-box'"/>
|
||||
</el-icon>
|
||||
<span>磁盘信息</span>
|
||||
</el-text>
|
||||
</div>
|
||||
</template>
|
||||
<pi-table :data="disks" hide-pagination hide-do hide-refresh>
|
||||
<el-table-column label="盘符路径" prop="path"></el-table-column>
|
||||
<el-table-column label="文件系统" prop="filesystem"></el-table-column>
|
||||
<el-table-column label="盘符类型" prop="type"></el-table-column>
|
||||
<el-table-column label="总大小" prop="total"></el-table-column>
|
||||
<el-table-column label="可用大小" prop="used"></el-table-column>
|
||||
<el-table-column label="已用大小" prop="available"></el-table-column>
|
||||
<el-table-column label="已用百分比" prop="used_percent"></el-table-column>
|
||||
</pi-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import api from "@/api"
|
||||
import {ref, onMounted} from "vue";
|
||||
import PiTable from "@/components/piTable/index.vue";
|
||||
|
||||
defineOptions({
|
||||
name: "monitorServer"
|
||||
})
|
||||
|
||||
let cpu = ref({
|
||||
'cpu_cores': 0,
|
||||
'user_usage': '0%',
|
||||
'system_usage': '0%',
|
||||
'idle': '0%'
|
||||
})
|
||||
|
||||
let mem = ref({
|
||||
'total': 0,
|
||||
'used': '0',
|
||||
'free': '0',
|
||||
'usage': '0%'
|
||||
})
|
||||
|
||||
let sys = ref({
|
||||
'server_name': '',
|
||||
'server_ip': '',
|
||||
'os': '',
|
||||
'architecture': '',
|
||||
'php_version': ''
|
||||
})
|
||||
|
||||
let app = ref({
|
||||
'hyperf_version': '',
|
||||
'swoole_version': '',
|
||||
'project_path': '',
|
||||
'start_time': '',
|
||||
'uptime': ''
|
||||
})
|
||||
|
||||
let disks = ref([])
|
||||
let redis = ref([])
|
||||
|
||||
onMounted(async () => {
|
||||
const res = await api.system.monitor.server()
|
||||
cpu.value = res.data.cpu
|
||||
mem.value = res.data.mem
|
||||
sys.value = res.data.sys
|
||||
app.value = res.data.app
|
||||
redis.value = res.data.redis
|
||||
disks.value = res.data.disks
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.pi-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,20 +1,20 @@
|
|||
<template>
|
||||
<el-dialog :title="title" v-model="visible" :width="500" destroy-on-close @closed="emit('closed')" :close-on-click-modal="false">
|
||||
<el-dialog title="快速添加页面和权限" v-model="visible" :width="500" destroy-on-close @closed="emit('closed')" :close-on-click-modal="false">
|
||||
<el-form :model="form" ref="dialogForm" label-width="100px" label-position="right">
|
||||
<el-form-item label="菜单名称" prop="title">
|
||||
<el-input v-model="form.title" clearable></el-input>
|
||||
<el-input v-model="form.title" placeholder="测试页面" clearable></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="菜单图标" prop="icon">
|
||||
<pi-icon v-model="form.icon" clearable></pi-icon>
|
||||
</el-form-item>
|
||||
<el-form-item label="页面名称" prop="name">
|
||||
<el-input v-model="form.name" clearable></el-input>
|
||||
<el-input v-model="form.name" placeholder="systemDemo" clearable></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="路由地址" prop="path">
|
||||
<el-input v-model="form.path" clearable></el-input>
|
||||
<el-input v-model="form.path" placeholder="system/demo" clearable></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="权限标识" prop="flag">
|
||||
<el-input v-model="form.flag" clearable></el-input>
|
||||
<el-input v-model="form.flag" placeholder="demo" clearable></el-input>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
<script setup>
|
||||
import piIcon from '@/components/piIcon'
|
||||
import {ref, getCurrentInstance, defineEmits} from "vue"
|
||||
import {ref, getCurrentInstance} from "vue"
|
||||
import api from "@/api"
|
||||
const emit = defineEmits(['closed', 'success']);
|
||||
|
||||
|
|
@ -35,7 +35,6 @@ defineExpose({
|
|||
})
|
||||
|
||||
const {proxy} = getCurrentInstance()
|
||||
const title = ref("快速添加")
|
||||
let visible = ref(false)
|
||||
let isSaveing = ref(false)
|
||||
|
||||
|
|
|
|||
|
|
@ -113,6 +113,6 @@ function selectionChange(e) {
|
|||
|
||||
//搜索
|
||||
function upsearch() {
|
||||
tableRef.value.upData(search)
|
||||
tableRef.value.upData(search.value)
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -242,10 +242,6 @@ async function saveInfo() {
|
|||
isSaveing.value = false
|
||||
}
|
||||
|
||||
function sexOption(v) {
|
||||
console.log(v)
|
||||
}
|
||||
|
||||
async function savePass() {
|
||||
const validate = await passRef.value.validate().catch(() => {});
|
||||
if(!validate){ return false }
|
||||
|
|
|
|||
|
|
@ -23,14 +23,14 @@ export default defineConfig(({mode, command}) => {
|
|||
server: {
|
||||
port: 8611,
|
||||
host: true,
|
||||
// open: true,
|
||||
open: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: VITE_API_BASE,
|
||||
changeOrigin: true,
|
||||
rewrite: (p) => p.replace(/^\/api/, '')
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
|
|
@ -47,6 +47,16 @@ export default defineConfig(({mode, command}) => {
|
|||
}
|
||||
}
|
||||
},
|
||||
plugins: [vue()]
|
||||
plugins: [
|
||||
vue(),
|
||||
{
|
||||
name: 'full-reload',
|
||||
handleHotUpdate({file, server}) {
|
||||
if (file.endsWith('.vue')) {
|
||||
server.ws.send({type: 'full-reload'});
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue