This commit is contained in:
zhang zhuo 2025-10-21 18:08:41 +08:00
parent 3ff4fa97a6
commit 89e792dcca
40 changed files with 3983 additions and 114 deletions

View File

@ -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"
}

View File

@ -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);
}
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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}`,
}
},
},
}
},
})

View File

@ -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>&nbsp;</template>
</el-input>
</el-col>
<el-col :span="12">
<el-input v-model="form.height">
<template #suffix>&nbsp;</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>

View File

@ -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)
},
})

View File

@ -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
},
}
},
})

View File

@ -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
},
}
},
})

View File

@ -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>

View File

@ -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)
}
})

View File

@ -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>&nbsp;</template>
</el-input>
</el-col>
<el-col :span="12">
<el-input v-model="form.height">
<template #suffix>&nbsp;</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>

View File

@ -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)
},
})

View File

@ -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>&nbsp;</template>
</el-input>
</el-col>
<el-col :span="12">
<el-input v-model="tableCol">
<template #suffix>&nbsp;</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>

View File

@ -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="{}">

View File

@ -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>

View File

@ -1,11 +0,0 @@
<template>
</template>
<script setup>
</script>
<style scoped>
</style>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

122
src/config/asset.ts Normal file
View File

@ -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'
}
}
}

42
src/config/select.ts Normal file
View File

@ -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值字段
}
}

View File

@ -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 {

View File

@ -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)
//注册全局指令

View File

@ -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)

View File

@ -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
}

View File

@ -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>

View File

@ -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>

View File

@ -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)

View File

@ -113,6 +113,6 @@ function selectionChange(e) {
//
function upsearch() {
tableRef.value.upData(search)
tableRef.value.upData(search.value)
}
</script>

View File

@ -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 }

View File

@ -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'});
}
},
},
]
}
})