一、vue之分页组件(含勾选、过滤)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>勾选和分页组件之vue2.6.10版</title>
<script src="https://cdn.bootcss.com/vue/2.6.10/vue.min.js"></script>
<style>
table{
border-collapse: collapse;
border: 1px solid #cbcbcb;
width: 1000px;
}
table td,table th {
padding: 5px;
border: 1px solid #cbcbcb;
}
table thead {
background-color: #e0e0e0;
color: #000;
text-align: left;
}
.filter{
width:998px;
border:1px solid gray;
padding:10px 0px;
}
.line{
display:flex
}
.group{
width:330px;
}
.label{
display: inline-block;
width:120px;
height: 24px;
line-height: 24px;
text-align: right;
}
.input{
display: inline-block;
width:180px;
height: 24px;
line-height: 24px;
border-radius: 3px;
}
.select{
display: inline-block;
width:188px;
height: 26px;
line-height: 26x;
border-radius: 3px;
}
</style>
</head>
<body>
<div id="app">
<div style="padding-bottom:5px;color:red">
<button style="color:red" @click="checkDatasOne.getResultOfCheckAndFilter(divideDatasOne.isShowFilter,divideDatasOne.filterOptions)">获取勾选和过滤结果</button>
<span>{{checkDatasOne.toServerDatas}}</span>
</div>
<div style="padding-bottom:5px">
<img :src="checkDatasOne.stateAllPages&&checkDatasOne.allExcludedIds.length===0?checkImg.yes:checkImg.no" @click="checkDatasOne.clickAllPages(divideDatasOne.tableDatas)"/>
<span>{{checkDatasOne.textAllPages}}</span>
</div>
<div style="padding-bottom:5px">
<button @click="divideDatasOne.toggleShowFilter()">{{divideDatasOne.isShowFilter?'关闭过滤':'使用过滤'}}</button>
<button @click="divideDatasOne.emptyFilterOptions({value5:10})">清空过滤</button>
<button @click="divideDatasOne.request(1,divideDatasOne.eachPageItemsNum)">刷新</button>
</div>
<div style="margin-bottom:5px" class="filter" v-show="divideDatasOne.isShowFilter">
<div class="line">
<div class="group">
<label class="label">标签</label>
<input class="input" type="text" v-model="divideDatasOne.filterOptions.value1" />
</div>
<div class="group">
<label class="label">这就是长标签</label>
<input class="input" type="text" v-model="divideDatasOne.filterOptions.value2" />
</div>
<div class="group">
<label class="label">标签</label>
<input class="input" type="text" v-model="divideDatasOne.filterOptions.value3" />
</div>
</div>
<div class="line" style="padding-top: 10px;">
<div class="group">
<label class="label">这就是长标签</label>
<input class="input" type="text" v-model="divideDatasOne.filterOptions.value4" />
</div>
<div class="group">
<label class="label">下拉框</label>
<select class="select" v-model="divideDatasOne.filterOptions.value5">
<option v-for="item in selectOptions" :value="item.back">{{item.front}}</option>
</select>
</div>
<div class="group">
<label class="label"></label>
<button style="width:188px;height:28px" @click="divideDatasOne.request(1,divideDatasOne.eachPageItemsNum)">过滤</button>
</div>
</div>
</div>
<div style="width: 1000px">
<table>
<thead>
<tr>
<th><img :src="checkDatasOne.stateThisPage?checkImg.yes:checkImg.no"
@click="checkDatasOne.clickThisPage(divideDatasOne.tableDatas,divideDatasOne.allItemsNum)"/></th>
<th>序号</th>
<th>数据1</th>
<th>数据2</th>
<th>数据3</th>
<th>数据4</th>
<th>数据5</th>
<th>数据6</th>
</tr>
</thead>
<tbody>
<tr v-for="(data,index) in divideDatasOne.tableDatas">
<td><img :src="data.state?checkImg.yes:checkImg.no" @click="checkDatasOne.clickSingleItem(data,divideDatasOne.tableDatas,divideDatasOne.allItemsNum)"/></td>
<td>{{(divideDatasOne.nowPageNum-1)*divideDatasOne.eachPageItemsNum + (index+1)}} </td>
<td>{{ data.key1 }}</td>
<td>{{ data.key2 }}</td>
<td>{{ data.key3 }}</td>
<td>{{ data.key4 }}</td>
<td>{{ data.key5 }}</td>
<td>{{ data.key6 }}</td>
</tr>
</tbody>
</table>
</div>
<divide-page :divide-datas="divideDatasOne" :check-datas="checkDatasOne" :fixed-datas="fixedDatas"></divide-page>
</div>
</body>
<script>
new Vue({
el: '#app',
data(){
return {
divideDatasOne:{
nowPageNum:0,
allPagesNum:0,
allItemsNum:0,
eachPageItemsNum:0,
tableDatas:[],
filterOptions:{value5:10},
isShowFilter:false,
otherDatas:{}
},
checkDatasOne:{
idKey: 'id',//每条数据的唯一标志
stateThisPage: false,//当前页所有项是否全选
allIncludedIds: [],//所有被选中数据的ID构成的数组
allExcludedIds: [],//所有没被选中数据的ID构成的数组
textAllPages: '全选未启用,没有选择任何项!',//复选框被点击后的提示文字。
stateAllPages: false,//复选框被点击后的提示文字。
toServerDatas: null,
},
}
},
methods: {
},
created(){
this.fixedDatas = {};
this.selectOptions = [
{ back: 10, front: '来' },
{ back: 20, front: '来自于' },
{ back: 30, front: '来自于国内' },
{ back: 40, front: '来自于国内攻击' },
{ back: 50, front: '来自于国内攻击-2' }
];
this.checkImg = {
yes: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOCAYAAAAfSC3RAAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAADqADAAQAAAABAAAADgAAAAC98Dn6AAAA+UlEQVQoFZWSMU4DMRBF/584G7QSRcIxuAZKEykNEiUVHVTQRaKh4AIcgAvQpkukVDlBOAYNSGSlXXuwpViyYYFdS9aMZ/6bsezh5HZ3T2KhqkfosEhWqnjkyd1u3xWKdQMsfaEAB0Zilf8swfdU0w0klmpGpz1BvpbHcklbPf8Okts0CfJtWBTz/Yc++Jc8S3PZVQfKGwiuvMD6XYsMzm1dT/1jXKdQ8E0asHRrAzOzbC6UGINWHPQp1UQ/6wjF2LpmJSKfhti4Bi8+lhWP4I+gAqV1uqSi8j9WRuF3m3eMWVUJBeKxzUoYn7bEX7HDyPmB7QEHbRjyL+/+VnuXDUFOAAAAAElFTkSuQmCC',
no: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOCAYAAAAfSC3RAAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAADqADAAQAAAABAAAADgAAAAC98Dn6AAAAbklEQVQoFWM8c+ZMLQMDQxUQcwAxMeAHUFEbC5CoYmNj02ZmZn5FjK6/f/+K/fr16ypIIwdIk7a29hdiNF69ehWkjIOJGMXY1IxqxBYqULEhFDiglPMDlIygKQKPryBSILUgPSCNbaC0B6RJSuQAbowizhJuOsAAAAAASUVORK5CYII=',
}
},
components: {
dividePage: {
props: {
divideDatas: {
type: Object,
default: {}
},
checkDatas: {
type: Object,
default: {}
},
fixedDatas: {
type: Object,
default: {}
}
},
template: `
<div v-show="divideDatas.allPagesNum>=1" style="display:flex;width:1000px;margin-top:20px;">
<div>
<button
v-show="divideDatas.allPagesNum>10"
@click="clickDividePage('front') "
:disabled="divideDatas.nowPageNum===1"
>上一页</button>
<button
:disabled="number==='...'"
v-for="number in divideArray"
@click="clickDividePage(number)"
:style="{marginRight:'5px',color:number===divideDatas.nowPageNum?'red':'gray'}"
>{{ number }}</button>
<button
v-show="divideDatas.allPagesNum>10"
@click="clickDividePage('back')"
:disabled="divideDatas.nowPageNum===divideDatas.allPagesNum"
>下一页</button>
</div>
<div style="display:flex; flex:1; justify-content:flex-end;">
<div style="margin-right:20px;">
<span>转到第</span>
<input type="text" v-model="customString" @keydown="clickDividePage('leap',$event)" style="width:30px;">
<span>页</span>
<button @click="clickDividePage('leap',{which:13})">Go</button>
</div>
<div>
<span>每页显示</span>
<select v-model="divideDatas.eachPageItemsNum" @change="selectChange(divideDatas.eachPageItemsNum)">
<option v-for="item in numOptions" :value="item.back">{{item.front}}</option>
</select>
<span>条,</span>
</div>
<div>
<span>{{frontMoreText}}</span>
<span>{{totalText}}</span>
<span>{{divideDatas.allItemsNum}}</span>
<span>{{totalUnit}}</span>
<span>{{backMoreText}}</span>
</div>
</div>
</div
`,
data() {
return {
customString:''
}
},
created(){
var that = this;
//1、请求配置
this.url = this.fixedDatas.url || '';
this.method = this.fixedDatas.method || 'post';
this.isShowParams = this.fixedDatas.isShowParams || false;//显式还是隐式传参。有时需要在请求发出前手动改变。
//2、响应配置(前端通过这个配置,获取后台的数据)
this.nowPageNum = this.fixedDatas.nowPageNum || 'nowPageNum';//来自服务器的当前页码
this.allPagesNum = this.fixedDatas.allPagesNum || 'allPagesNum';//来自服务器的所有页页数
this.allItemsNum = this.fixedDatas.allItemsNum || 'allItemsNum';//来自服务器的所有页数据数
this.eachPageItemsNum = this.fixedDatas.eachPageItemsNum || 'eachPageItemsNum';//来自服务器的每页最多数据数
this.tableDatas = this.fixedDatas.tableDatas || 'tableDatas';//来自服务器的表格数据
//3、以下配置使用哪种转圈方式(前端根据需要决定,不受后台影响)
this.partCircle = this.fixedDatas.partCircle;//局部是否转圈。this.fixedDatas.partCircle=$scope.partCircle={isShow =false}。
this.isUsePartCircle = this.fixedDatas.isUsePartCircle;//局部是否转圈,由当前页的一个变量控制
this.isUseWholeCircle = this.fixedDatas.isUseWholeCircle;//全局是否转圈,由本项目的一个服务控制
//4、初始化以下数据,供页面使用(前端根据需要决定,不受后台影响)
this.frontMoreText = this.fixedDatas.frontMoreText || "";//('文字 ')或者("文字 "+result.numOne+" 文字 ")
this.totalText = this.fixedDatas.totalText || "";//'共'
this.totalUnit = this.fixedDatas.totalUnit || '条';//总数据的单位
this.backMoreText = this.fixedDatas.backMoreText || "";//(' 文字')或者("文字 "+result.numThree+" 文字")
this.numOptions = [
{ back: 10, front: 10 },
{ back: 20, front: 20 },
{ back: 30, front: 30 },
{ back: 40, front: 40 },
{ back: 50, front: 50 }
];
this.request = this.divideDatas.request = function (nowPageNum,eachPageItemsNum) {
var isOnce = true;
//1、向后台发送请求,
//2、返回正确结果result
var data=[];
var allItemsNum = 193;
var nowPageNum = nowPageNum||1;
var eachPageItemsNum = eachPageItemsNum||10;
var allPagesNum = Math.ceil(allItemsNum/eachPageItemsNum);
for(var i=1;i<=allItemsNum;i++){
var obj={
id:'id'+i,
key1:'数据'+(i+0),
key2:'数据'+(i+1),
key3:'数据'+(i+2),
key4:'数据'+(i+3),
key5:'数据'+(i+4),
key6:'数据'+(i+5),
key7:'数据'+(i+6),
};
data.push(obj)
}
var tableDatas = data.slice((nowPageNum-1)*eachPageItemsNum,nowPageNum*eachPageItemsNum);
//3、使用正确结果result
that.customString = nowPageNum;
that.divideDatas.nowPageNum = nowPageNum;
that.divideDatas.allPagesNum = allPagesNum;
that.divideDatas.allItemsNum = allItemsNum;
that.divideDatas.eachPageItemsNum = eachPageItemsNum;
that.divideDatas.tableDatas = tableDatas;//正常逻辑
if(that.divideDatas.once && isOnce){//只使用一次,常用于初始化一些数据,比如过滤条件
that.divideDatas.once();
isOnce = false;
}
if(that.divideDatas.trueCb){//异常逻辑
that.divideDatas.trueCb()
}
if(that.checkDatas && that.checkDatas.signCheckbox){//处理勾选
that.checkDatas.signCheckbox(that.divideDatas.tableDatas)
}
that.createDividePage();//创建分页
//4、处理错误结果
if(that.divideDatas.errorCb){
that.divideDatas.errorCb()
}
};
if (!this.divideDatas.isAbandonInit) {
this.request(1,this.divideDatas.eachPageItemsNum);
};
this.divideDatas.toggleShowFilter = function () {
this.isShowFilter = !this.isShowFilter;
if (!this.isShowFilter) {
this.request(1,that.divideDatas.eachPageItemsNum);
}
};
this.divideDatas.emptyFilterOptions = function (extraObject) {
//清空选项时,所有值恢复成默认
for(var key in this.filterOptions){
this.filterOptions[key] = undefined;
};
if (extraObject) {
//小部分选项的默认值不是undefined
for(var key in extraObject){
this.filterOptions[key] = extraObject[key];
};
};
this.request(1,that.divideDatas.eachPageItemsNum);
};
this.checkDatas.init=function(){//点击“刷新”、“过滤”、“清除过滤”时执行
this.idKey = idKey ? idKey : 'id';
this.allIncludedIds = [];
this.allExcludedIds = [];
this.textAllPages = '全选未启用,没有选择任何项!';
this.stateAllPages = false;
this.stateThisPage = false;
};
this.checkDatas.clickAllPages = function (itemArray) {//所有页所有条目全选复选框被点击时执行的函数
if(this.stateAllPages){
if(this.allExcludedIds.length>0){
this.stateAllPages = true;
this.stateThisPage = true;
this.textAllPages= '全选已启用,没有排除任何项!';
itemArray.forEach(function (item) {
item.state = true;
});
}else if(this.allExcludedIds.length==0){
this.stateAllPages = false;
this.stateThisPage = false;
this.textAllPages= '全选未启用,没有选择任何项!';
itemArray.forEach(function (item) {
item.state = false;
});
}
}else{
this.stateAllPages = true;
this.stateThisPage = true;
this.textAllPages= '全选已启用,没有排除任何项!';
itemArray.forEach(function (item) {
item.state = true;
});
}
this.allExcludedIds = [];
this.allIncludedIds = [];
};
this.checkDatas.clickThisPage = function (itemsArray,allItemsNum) {//当前页所有条目全选复选框被点击时执行的函数
var that = this;
this.stateThisPage = !this.stateThisPage
itemsArray.forEach(function (item) {
item.state = that.stateThisPage;
if (item.state) {
that.delID(item[that.idKey], that.allExcludedIds);
that.addID(item[that.idKey], that.allIncludedIds);
} else {
that.delID(item[that.idKey], that.allIncludedIds);
that.addID(item[that.idKey], that.allExcludedIds);
}
});
if(this.stateAllPages){
if(this.stateThisPage && this.allExcludedIds.length === 0){
this.textAllPages = '全选已启用,没有排除任何项!';
}else{
this.textAllPages = '全选已启用,已排除'+ this.allExcludedIds.length + '项!排除项的ID为:' + this.allExcludedIds;
}
}else{
if(!this.stateThisPage && this.allIncludedIds.length === 0){
this.textAllPages='全选未启用,没有选择任何项!';
}else{
this.textAllPages = '全选未启用,已选择' + this.allIncludedIds.length + '项!选择项的ID为:' + this.allIncludedIds;
}
}
};
this.checkDatas.clickSingleItem = function (item, itemsArray, allItemsNum) {//当前页单个条目复选框被点击时执行的函数
var that = this;
item.state = !item.state;
if (item.state) {
this.stateThisPage = true;
this.addID(item[this.idKey], this.allIncludedIds);
this.delID(item[this.idKey], this.allExcludedIds);
itemsArray.forEach( function (item) {
if (!item.state) {
that.stateThisPage = false;
}
});
} else {
this.stateThisPage = false;
this.addID(item[this.idKey], this.allExcludedIds);
this.delID(item[this.idKey], this.allIncludedIds);
}
if(this.stateAllPages){
if(this.stateThisPage && this.allExcludedIds.length === 0){
this.textAllPages = '全选已启用,没有排除任何项!';
}else{
this.textAllPages = '全选已启用,已排除'+ this.allExcludedIds.length + '项!排除项的ID为:' + this.allExcludedIds;
}
}else{
if(!this.stateThisPage && this.allIncludedIds.length === 0){
this.textAllPages='全选未启用,没有选择任何项!';
}else{
this.textAllPages = '全选未启用,已选择' + this.allIncludedIds.length + '项!选择项的ID为:' + this.allIncludedIds;
}
}
};
this.checkDatas.signCheckbox = function (itemsArray) {//标注当前页被选中的条目,在翻页成功后执行。
var that = this;
if(this.stateAllPages){
this.stateThisPage = true;
itemsArray.forEach(function (item) {
var thisID = item[that.idKey];
var index = that.allExcludedIds.indexOf(thisID);
if (index > -1) {
item.state = false;
that.stateThisPage = false;
} else {
item.state = true;
}
});
}else{
this.stateThisPage = true;
itemsArray.forEach( function (item) {
var thisID = item[that.idKey];
var index = that.allIncludedIds.indexOf(thisID);
if (index === -1) {
item.state = false;
that.stateThisPage = false;
}
});
}
};
this.checkDatas.addID = function (id, idArray) {
var index = idArray.indexOf(id);
if (index === -1) {
idArray.push(id);//如果当前页的单项既有勾选又有非勾选,这时勾选当前页全选,需要这个判断,以免重复添加
}
};
this.checkDatas.delID = function (id, idArray) {
var index = idArray.indexOf(id);
if (index > -1) {
idArray.splice(index, 1)
}
};
this.checkDatas.getResultOfCheckAndFilter = function (isShowFilter,filterOptions) {//获取发送给后台的所有参数。
var toServerDatas;
var allIncludedIds = that.deepClone(this.allIncludedIds);
var allExcludedIds = that.deepClone(this.allExcludedIds);
if (!this.stateAllPages) {
if (allIncludedIds.length === 0) {
//return 弹窗告知:没有勾选项
}
toServerDatas = {
isSelectAll: false,
allIncludedIds: allIncludedIds,
}
}else {
toServerDatas = { //exclude
isSelectAll: true,
allExcludedIds: allExcludedIds,
};
}
if (isShowFilter) {
for(var key in filterOptions){
toServerDatas[key]=filterOptions[key]
}
}
this.toServerDatas=toServerDatas;//这行代码在实际项目中不需要
return toServerDatas;
}
},
methods: {
deepClone : function (arrayOrObject) {
function isArray(value) { return {}.toString.call(value) === "[object Array]"; }
function isObject(value) { return {}.toString.call(value) === "[object Object]"; }
var target = null;
if (isArray(arrayOrObject)) target = [];
if (isObject(arrayOrObject)) target = {};
for (var key in arrayOrObject) {
var value = arrayOrObject[key];
if (isArray(value) || isObject(value)) {
target[key] = deepClone(value);
} else {
target[key] = value;
}
}
return target;
},
selectChange:function(eachPageItemsNum){
this.divideDatas.eachPageItemsNum = eachPageItemsNum;
this.request(1,eachPageItemsNum);
},
createDividePage : function () {
var divideArray = [];
var allPagesNum = this.divideDatas.allPagesNum;
var nowPageNum = this.divideDatas.nowPageNum;
if (allPagesNum >= 1 && allPagesNum <= 10) {
for (var i = 1; i <= allPagesNum; i++) {
divideArray.push(i);
}
} else if (allPagesNum >= 11) {
if (nowPageNum > 6) {
divideArray.push(1);
divideArray.push(2);
divideArray.push(3);
divideArray.push('...');
divideArray.push(nowPageNum - 1);
divideArray.push(nowPageNum);
} else {
for (i = 1; i <= nowPageNum; i++) {
divideArray.push(i);
}
}
//以上当前页的左边,以下当前页的右边
if (allPagesNum - nowPageNum >= 6) {
divideArray.push(nowPageNum + 1);
divideArray.push(nowPageNum + 2);
divideArray.push('...');
divideArray.push(allPagesNum - 2);
divideArray.push(allPagesNum - 1);
divideArray.push(allPagesNum);
} else {
for (var i = nowPageNum + 1; i <= allPagesNum; i++) {
divideArray.push(i);
}
}
}
this.divideArray = divideArray;
},
clickDividePage : function (stringOfNum, event) {
var allPagesNum = this.divideDatas.allPagesNum;
var nowPageNum = this.divideDatas.nowPageNum;
if (stringOfNum === 'front' && nowPageNum != 1) {
nowPageNum--;
} else if (stringOfNum === 'back' && nowPageNum != allPagesNum) {
nowPageNum++;
} else if (stringOfNum === 'leap') {
if (event.which != 13) return;//不拦截情形:(1)聚焦输入框、按“Enter”键时;(2)点击“GO”时
var customNum = Math.ceil(parseFloat(this.customString));
if (customNum < 1 || customNum == 'NaN') {
nowPageNum = 1;//不给提示
} else if(customNum > allPagesNum) {
nowPageNum = allPagesNum;//不给提示
} else {
nowPageNum = customNum;
}
} else {
nowPageNum = Math.ceil(parseFloat(stringOfNum));
}
this.request(nowPageNum,this.divideDatas.eachPageItemsNum);
},
}
}
},
})
</script>
</html>
二、Vue之五级联动组件-省、市、县、乡、村
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Vue之五级联动组件-省、市、县、乡、村</title>
</head>
<style type="text/css">
.edit{
height:40px;
line-height: 40px;
}
.paddingBottom{
padding-bottom: 40px;
}
.question-select {
height: 60px;
}
.question-select select {
border-radius: 5px;
box-shadow: 0 0 5px #666;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
border: none;
outline: none;
height: 40px;
padding: 0 20px;
color: #333;
font-size: 22px;
}
.question-select select.short {
width: 120px;
}
.question-select select.long {
width: 240px;
}
.birth-year{
width:90px;
margin-right: 20px;
height: 100px;
overflow-y: scroll;
}
.birth-month{
width:70px;
margin-right: 20px;
}
.birth-date{
width:70px;
}
</style>
<body>
<div id="component">
<five-grade :all-datas="allDatas"></five-grade>
</div>
</body>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<!-- Vue.js v2.6.10 -->
<script type="text/javascript">
Vue.component('five-grade', {
template: `
<div>
<div class="question-select">
<select v-model="singleProvince" class="short" @change="selectName(singleProvince)">
<option v-for="key in allProvinces" :value="key" v-text="key"></option>
</select>
<select v-model="singleCity" v-show="singleProvince" class="short" @change="selectName(singleProvince,singleCity)">
<option v-for="key in allCitys" :value="key" v-text="key"></option>
</select>
<select v-model="singleCounty" v-show="singleCity" class="short" @change="selectName(singleProvince,singleCity,singleCounty)">
<option v-for="key in allCountys" :value="key" v-text="key"></option>
</select>
<select v-model="singleTown" v-show="singleCounty" class="long" @change="selectName(singleProvince,singleCity,singleCounty,singleTown)">
<option v-for="key in allTowns" :value="key" v-text="key"></option>
</select>
<select v-model="singleVillage" v-show="singleTown" class="long" @change="selectName(singleProvince,singleCity,singleCounty,singleTown,singleVillage)">
<option v-for="key in allVillages" :value="key" v-text="key"></option>
</select>
</div>
<div class="paddingBottom">{{address}}</div>
</div>
`,
props: {
allDatas: {
type: Object
}
},
data: function(){
return {
allProvinces: [],
singleProvince: '',
allCitys: [],
singleCity: '',
allCountys: [],
singleCounty: '',
allTowns: [],
singleTown: '',
allVillages: [],
singleVillage: '',
address: '',
}
},
beforeMount: function () {
this.selectName();
},
methods: {
selectName: function(singleProvince,singleCity,singleCounty,singleTown,singleVillage){
var siteArray = [singleProvince,singleCity,singleCounty,singleTown,singleVillage];
var modelArray = ['singleProvince','singleCity','singleCounty','singleTown','singleVillage'];
var optionsArray = ['allProvinces','allCitys','allCountys','allTowns','allVillages'];
var allDatasNext;
var address = "你选择的地址是:";
for(var i=0;i<siteArray.length;i++){//遍历数组所有项
if(!siteArray[i]) this[modelArray[i]] = '';
}
for(var i=0;i<siteArray.length;i++){//遍历数组至undefined第1次出现时停止
allDatasNext = i == 0 ? this.allDatas : allDatasNext[siteArray[i-1]];
if(!siteArray[i]) {
var array = [];
for (var key in allDatasNext) {
array.push(key)
}
this[optionsArray[i]] = array;
break;
}
}
if(this.singleProvince) address += this.singleProvince;
if(this.singleCity) address += '-' + this.singleCity;
if(this.singleCounty) address += '-' + this.singleCounty;
if(this.singleTown) address += '-' + this.singleTown;
if(this.singleVillage) address += '-' + this.singleVillage;
this.address = address;
}
},
});
new Vue({
el: '#component',
data(){
return {
allDatas : makeallDatas()
}
},
methods:{
}
})
function makeallDatas() {
var allDatas = {
"北京市": {
"区": {
"通州区": {
"中仓街道办事处": {
"滨河社区居委会": "110112001029",
"运河湾社区居委会": "110112001030"
},
"漷县镇": {
"后元化村委会": "110112106260",
"前元化村委会": "110112106261"
}
},
"昌平区": {
"天通苑南街道办事处":{
"东辰社区居委会":"110114009001",
"佳运园社区居委会":"110114009002",
"天通苑第二社区居委会":"110114009003",
"天通西苑第一社区居委会":"110114009004",
"天通东苑第一社区居委会":"110114009005",
"天通东苑第二社区居委会":"110114009006",
"天通苑第一社区居委会":"110114009007",
"嘉诚花园社区居委会":"110114009008",
},
"霍营街道办事处": {
"华龙苑南里社区居委会": "110114010001",
"华龙苑北里社区居委会": "110114010002",
"蓝天园社区居委会": "110114010003",
"天鑫家园社区居委会": "110114010004",
"霍营小区社区居委会": "110114010005",
"上坡佳园社区居委会": "110114010006",
"华龙苑中里社区居委会": "110114010007",
}
}
},
"县": {
"密云县": {
"密云镇": {
"小唐庄社区居委会": "110228100001",
"李各庄社区居委会": "110228100002",
"大唐庄社区居委会": "110228100003",
"季庄村委会": "110228100205",
},
"溪翁庄镇": {
"东智北村委会": "110228101209",
"石墙沟村委会": "110228101210",
"黑山寺村委会": "110228101211",
"立新庄村委会": "110228101212",
},
},
"延庆县": {
"旧县镇": {
"常里营村委会": "110229104215",
"盆窑村委会": "110229104216",
"团山村委会": "110229104217",
"大柏老村委会": "110229104218",
},
"珍珠泉乡": {
"双金草村委会": "110229214209",
"小川村委会": "110229214210",
"小铺村委会": "110229214211",
"仓米道村委会": "110229214212",
}
}
}
},
"河南省": {
"郑州市": {
"金水区": {
"凤凰台街道办事处": {
"凤凰台社区居民委员会": "410105013004",
"王庄社区居民委员会": "410105013005",
"张庄社区居民委员会": "410105013006",
"凤凰城社区居民委员会": "410105013007",
},
"金光路街道办事处": {
"徐庄村委会": "410105564201",
"贾陈村委会": "410105564202",
"柳园口村委会": "410105564203",
"马楼村委会": "410105564204",
}
},
"登封市": {
"嵩阳街道办事处": {
"玉溪路居委会": "410185001014",
"苹果园居委会": "410185001015",
"颖河路居委会": "410185001016",
"守敬路居委会": "410185001017",
},
"少林街道办事处": {
"塔沟居委会": "410185002001",
"少林居委会": "410185002002",
"耿庄村委会": "410185002203",
"王庄村委会": "410185002204",
},
"送表矿区": {
"东送表村委会": "410185400201",
"梁庄村委会": "410185400202",
"安庄村委会": "410185400203",
"刘楼村委会": "410185400204",
}
}
},
"信阳市": {
"浉河区": {
"老城街道办事处": {
"义阳居委会": "411502001001",
"三里店居委会": "411502001003",
"东方红居委会": "411502001004",
"浉河居委会": "411502001007",
},
"民权街道办事处": {
"民权居委会": "411502002001",
"新生居委会": "411502002002",
"成功居委会": "411502002003",
"白果树居委会": "411502002004",
},
"车站街道办事处": {
"工区东居委会": "411502003001",
"工区路居委会": "411502003002",
"六里棚居委会": "411502003003",
"新公房居委会": "411502003004",
}
},
"固始县": {
"段集镇": {
"街道居委会": "411525109001",
"段集村委会": "411525109201",
"棠树岗村委会": "411525109202",
"青峰村委会": "411525109203",
"下楼村委会": "411525109204",
"桂岭村委会": "411525109205",
"窑沟村委会": "411525109206",
"五尖山村委会": "411525109207",
"柳林村委会": "411525109208",
"齐山村委会": "411525109209",
"钓鱼台村委会": "411525109210",
"蒋营村委会": "411525109211",
"庙山村委会": "411525109212",
"汪旱庄村委会": "411525109213",
"乐道村委会": "411525109214",
"姚老家村委会": "411525109215",
"赵营村委会": "411525109216",
"童庙村委会": "411525109217",
"高庙村委会": "411525109218"
},
"武庙集镇": {
"街道居委会": "411525113001",
"汪小庄村委会": "411525113201",
"黄土岭村委会": "411525113202",
"平阳村委会": "411525113203",
"长江河村委会": "411525113204",
"锁口村委会": "411525113205",
"皮冲村委会": "411525113206",
"刘中楼村委会": "411525113207",
"迎水寺村委会": "411525113208",
"余楼村委会": "411525113209",
"钱老楼村委会": "411525113210",
"新店村委会": "411525113211",
"徐小店村委会": "411525113212",
"邓岭村委会": "411525113213",
"太平村委会": "411525113214",
"汪楼村委会": "411525113215",
"李瓦房村委会": "411525113216"
},
"祖师庙镇": {
"祖师庙居委会": "411525117001",
"仰天洼社区居委会": "411525117002",
"小店村委会": "411525117201",
"王行村委会": "411525117202",
"大冲村委会": "411525117203",
"松林村委会": "411525117204",
"刘楼村委会": "411525117205",
"羁马村委会": "411525117206",
"黄楼村委会": "411525117207",
"彭畈村委会": "411525117208",
"童圩村委会": "411525117209",
"仓房村委会": "411525117210",
"毛店村委会": "411525117211",
"万岗村委会": "411525117212",
"七冲村委会": "411525117213",
"三区村委会": "411525117214",
"杨楼村委会": "411525117215"
}
},
"息县": {
"杨店乡": {
"杨店村委会": "411528204200",
"安寨村委会": "411528204201",
"何庄村委会": "411528204202",
"李大庄村委会": "411528204203",
},
"张陶乡": {
"张陶村委会": "411528205200",
"曹林村委会": "411528205201",
"陈圈行村委会": "411528205202",
"大陈庄村委会": "411528205203",
},
"白土店乡": {
"白土店村委会": "411528206200",
"白衣阁村委会": "411528206201",
"大江庄村委会": "411528206202",
"桂庄村委会": "411528206203",
},
"岗李店乡": {
"岗李店村委会": "411528207200",
"大彭庄村委会": "411528207201",
"方老庄村委会": "411528207202",
"贾后寨村委会": "411528207203",
}
}
},
},
};
return allDatas;
}
</script>
</html>
三、2种三层弹窗组件之simple-dialog
1、不可拖拽
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Vue多层弹窗</title>
<script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
<style>
.simpleDialog {
position: fixed;
width: 100%;
height: 100%;
top: 0;
left: 0;
display: flex;
justify-content: center;
align-items: center;
}
.simpleDialog .mask {
position: fixed;
width: 100%;
height: 100%;
top: 0;
left: 0;
background: black;
opacity: 0.5;
}
.simpleDialog .content {
position: fixed;
background: white;
opacity: 1;
display: flex;
flex-direction: column;
}
.simpleDialog .content .title {
display: flex;
background: blue;
color: white;
padding: 10px;
cursor: pointer;
}
.simpleDialog .content .conform {
display: flex;
justify-content: center;
padding: 10px;
background: blue;
}
</style>
</head>
<body>
<div id="el">
<button @click="clickButton()" style="margin-top: 30px;">
点击-出现-弹窗
</button>
<simple-dialog :required-data="requiredDataOut">
插槽一
<simple-dialog :required-data="requiredDataMid">
插槽二
<simple-dialog :required-data="requiredDataIn">
插槽三
</simple-dialog>
</simple-dialog>
</simple-dialog>
</div>
<script>
Vue.component('simple-dialog', {
template: `
<div>
<div class="simpleDialog" v-show="requiredData.isShow">
<div class="mask" v-show="requiredData.isShow"></div>
<div class="content" v-show="requiredData.isShow">
<div class="title">
<span>系统消息</span>
</div>
<div :style="{width:requiredData.width||'800px',height:requiredData.height||'400px'}">
<slot></slot>
</div>
<div class="conform">
<button v-on:click="close()">关闭</button>
<button v-on:click="open()">打开</button>
</div>
<div>
</div>
</div>
`,
props: {
requiredData: {
type: Object
}
},
data: function() {
return {}
},
methods: {
close: function () {
this.requiredData.isShow = false;
if(this.requiredData.closeFn) this.requiredData.closeFn();
},
open: function () {
if(this.requiredData.openFn) this.requiredData.openFn();
}
}
});
new Vue({
el: '#el',
data(){
var that = this;
return {
requiredDataOut : {
isShow : false,
width : '900px',
height : '600px',
openFn : function () {
that.requiredDataMid.isShow = true;
}
},
requiredDataMid : {
isShow : false,
width : '600px',
height : '400px',
openFn : function () {
that.requiredDataIn.isShow = true;
},
},
requiredDataIn : {
isShow : false,
width : '300px',
height : '200px',
},
//一层弹窗可以如下使用
//requiredDataOut = {
// isShow : false,
//};
//clickButton() {
// requiredDataOut.isShow = true;
//};
}
},
methods:{
clickButton() {
this.requiredDataOut.isShow = true;
}
}
})
</script>
</body>
</html>
2、可拖拽(含插槽)
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Vue多层弹窗</title>
<script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
<script>
function drag(wholeTitleId, wholeContentId) {
var wholeTitleId = wholeTitleId||'titleId';
var wholeContentId = wholeContentId||'contentId';
var oDiv = document.getElementById(wholeContentId);
if(!oDiv) return;
oDiv.onmousedown = down;
function processThis(fn, nowThis) {
return function (event) {
fn.call(nowThis, event);
};
}
function down(event) {
event = event || window.event;
if (event.target.id != wholeTitleId) return;
this.initOffsetLeft = this.offsetLeft;
this.initOffsetTop = this.offsetTop;
this.initClientX = event.clientX;
this.initClientY = event.clientY;
this.maxOffsetWidth =
(document.documentElement.clientWidth || document.body.clientWidth) -
this.offsetWidth;
this.maxOffsetHeight =
(document.documentElement.clientHeight ||
document.body.clientHeight) - this.offsetHeight;
if (this.setCapture) {
this.setCapture();
this.onmousemove = processThis(move, this);
this.onmouseup = processThis(up, this);
} else {
document.onmousemove = processThis(move, this);
document.onmouseup = processThis(up, this);
}
}
function move(event) {
var nowLeft = this.initOffsetLeft + (event.clientX - this.initClientX);
var nowTop = this.initOffsetTop + (event.clientY - this.initClientY);
this.style.left = nowLeft + 'px';
this.style.top = nowTop + 'px';
}
function up() {
if (this.releaseCapture) {
this.releaseCapture();
this.onmousemove = null;
this.onmouseup = null;
} else {
document.onmousemove = null;
document.onmouseup = null;
}
}
};
</script>
<style>
.simpleDialog {
position: fixed;
width: 100%;
height: 100%;
top: 0;
left: 0;
display: flex;
justify-content: center;
align-items: center;
}
.simpleDialog .mask {
position: fixed;
width: 100%;
height: 100%;
top: 0;
left: 0;
background: black;
opacity: 0.5;
}
.simpleDialog .content {
position: fixed;
background: white;
opacity: 1;
display: flex;
flex-direction: column;
}
.simpleDialog .content .title {
display: flex;
background: blue;
color: white;
padding: 10px;
cursor: pointer;
}
.simpleDialog .content .conform {
display: flex;
justify-content: center;
padding: 10px;
background: blue;
}
</style>
</head>
<body>
<div id="el">
<button @click="clickButton()" style="margin-top: 30px;">
点击-出现-弹窗
</button>
<simple-dialog :required-data="requiredDataOut">
插槽一
<simple-dialog :required-data="requiredDataMid">
插槽二
<simple-dialog :required-data="requiredDataIn">
插槽三
</simple-dialog>
</simple-dialog>
</simple-dialog>
</div>
<script>
Vue.component('simple-dialog', {
template: `
<div>
<div class="simpleDialog" v-show="requiredData.isShow">
<div class="mask" v-show="requiredData.isShow"></div>
<div class="content" v-show="requiredData.isShow" :id="requiredData.contentId||'contentId'">
<div class="title" :id="requiredData.titleId||'titleId'">
<span>系统消息</span>
</div>
<div :style="{width:requiredData.width||'800px',height:requiredData.height||'400px'}">
<slot></slot>
</div>
<div class="conform">
<button v-on:click="close()">关闭</button>
<button v-on:click="open()">打开</button>
</div>
<div>
</div>
</div>
`,
props: {
requiredData: {
type: Object
}
},
data: function() {
return {}
},
methods: {
close: function () {
this.requiredData.isShow = false;
if(this.requiredData.closeFn) this.requiredData.closeFn();
var content = this.requiredData.contentId;
document.getElementById(content).style.cssText = "position:fixed;display:flex;";
},
open: function () {
if(this.requiredData.openFn) this.requiredData.openFn();
}
},
});
new Vue({
el: '#el',
data(){
var that = this;
return {
requiredDataOut : {
isShow : false,
width : '900px',
height : '600px',
titleId : "titleId1",
contentId : "contentId1",
openFn : function () {
that.requiredDataMid.isShow = true;
drag(that.requiredDataMid.titleId,that.requiredDataMid.contentId);
}
},
requiredDataMid : {
isShow : false,
width : '600px',
height : '400px',
titleId : "titleId2",
contentId : "contentId2",
openFn : function () {
that.requiredDataIn.isShow = true;
drag(that.requiredDataIn.titleId,that.requiredDataIn.contentId);
},
},
requiredDataIn : {
isShow : false,
width : '300px',
height : '200px',
titleId : "titleId3",
contentId : "contentId3",
},
//一层弹窗可以如下使用
//requiredDataOut = {
// isShow : false,
//};
//clickButton() {
// requiredDataOut.isShow = true;
//};
}
},
methods:{
clickButton() {
this.requiredDataOut.isShow = true;
drag(this.requiredDataOut.titleId,this.requiredDataOut.contentId);
}
}
})
</script>
</body>
</html>
四、2种三层弹窗组件之el-dialog
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>vue2.6.10组件之el-dialog多层弹窗</title>
<script src="https://cdn.bootcss.com/vue/2.6.10/vue.min.js"></script>
<script src="https://cdn.bootcss.com/element-ui/2.10.1/index.js"></script>
<link href="https://cdn.bootcss.com/element-ui/2.10.1/theme-chalk/index.css" rel="stylesheet">
<style>
#app{
display: flex;
justify-content: space-between;
}
</style>
</head>
<body>
<div id="app">
<el-button type="text" @click="outerVisible = true">点击打开外层弹窗</el-button>
<el-dialog
width="70%"
title="外层"
:visible.sync="outerVisible"
>
以下是外层弹窗的插槽<br/>
<div style="height:200px;border:1px solid #ccc;padding:20px;">
width="70%",决定弹-窗的宽<br/>
style="height:200px",通过给弹-窗插槽的部分标签设置高,决定弹-窗的高,如此框<br/>
:visible.sync="middleVisible",传递引用,此处改变,别处也改变<br/>
:visible="middleVisible",传递普通值,此处改变,别处不改变<br/>
<el-dialog
width="50%"
title="中层"
:visible.sync="middleVisible"
append-to-body
>
以下是中层弹-窗的插槽<br/>
<el-dialog
width="30%"
title="内层"
:visible.sync="innerVisible"
append-to-body
>
以下是内层弹-窗的插槽<br/>
<div slot="footer" class="dialog-footer">
<el-button @click="innerVisible = false">关闭内层</el-button>
<el-button type="primary" @click="innerVisible = false">关闭内层</el-button>
</div>
以上是内层弹-窗的插槽<br/>
</el-dialog>
<div slot="footer" class="dialog-footer">
<el-button @click="middleVisible = false">关闭中层</el-button>
<el-button type="primary" @click="innerVisible = true">打开内层</el-button>
</div>
以上是中层弹-窗的插槽<br/>
</el-dialog>
</div>
<div slot="footer" class="dialog-footer">
<el-button @click="outerVisible = false">关闭外层</el-button>
<el-button type="primary" @click="middleVisible = true">打开中层</el-button>
</div>
以上是外层弹-窗的插槽(代码中,此句位于按钮下面)<br/>
</el-dialog>
</div>
</body>
<script>
new Vue({
el: '#app',
data() {
return {
outerVisible: false,
middleVisible: false,
innerVisible: false
};
},
methods: {
},
components: {
},
})
</script>
</html>
五、vue问题与解决
1、问题1,用插件vod-js-sdk-v6上传视频时
来源,https://cloud.tencent.com/developer/ask/260695
(1)现象,报错“Error: ugc upload | signature verify forbidden”
(2)原因,
A、前端向后台索要签名,后台用小权限“腾讯账户”向腾讯索要签名,
B、腾讯给后台小权限签名,后台给前端小权限签名,前端将小权限签名发给腾讯,腾讯给前端报错
(3)解决,让后台换用大权限“腾讯账户”
2、问题2
(1)现象,不是第一页的最后一页为空白
(2)原因,最后一页不是第一页,删除最后一页的唯一项,然后请求当前页数据
(3)解决,
delModelTrainings({trainingId: row.trainingId}).then(() => {
ElMessage.success('删除成功');
var index = (page.value.pageNum - 1)*page.value.pageSize + 1;
//当前页第1项的序号>每页条数(当前页不是第1页) && 当前页第1项的序号=数据总数(当前页是最后1页且只有1项)
if(index>page.value.pageSize && index===page.value.count){
page.value.pageNum--;
}
listData();
})
3、问题3,
(1)现象,:src 文件路径错误问题的解决方法
(2)解决,
<template>
<img :src="item.url" alt="logo" />
</template>
import background from '@/assets/image/background.png'
var collect = reactive([
{
url: background, //url: '/src/assets/images/logo.png' 注释这样的写法会出错
teacher: "讲师:李老师",
}
]);
4、问题4
(1)报错信息:[VUE ERROR] Invalid default value for prop "slides":
Props with type Object/Array must use a factory function to return the default value
(2)错误原因:对于数组和对象类型的prop,Vue官方从最初的版本就推荐使用工厂函数返回默认值,而不是直接赋值空数组[]或空对象{}
(3)错误示例
props: {
sizeRadio1: {
type: Array,
default: []
}
sizeRadio2: {
type: Object,
default: {}
}
},
(4)正确示例
props: {
sizeRadio1: {
type: Array,
default: function() {
return []
}
}
sizeRadio2: {
type: Object,
default: function() {
return {}
}
}
},
5、问题5,汉化(汉字、中文、配置)
(1)方案一,在App.vue中
<script >
import { defineComponent } from 'vue'
import { ElConfigProvider } from 'element-plus'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
export default defineComponent({
components: {
ElConfigProvider,
},
setup() {
return {
locale: zhCn,
}
},
})
</script>
<template>
<div style="height:100%">
<el-config-provider :locale="locale">
<router-view v-slot="{ Component }">
<keep-alive>
<component :is="Component" />
</keep-alive>
</router-view>
</el-config-provider>
</div>
</template>
(2)方案二,在main.js中
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
app.use(ElementPlus, { locale: zhCn })
app.mount('#app')
6、问题6,深度选择器(也叫穿透选择器)!!!
(1)>>>(直接穿透),在Vue3中已废弃,
A、适用场景:Vue2的scoped样式中,不支持大多数CSS预处理器(如Sass、Less、Stylus)
B、示例:
<style scoped>
/* 穿透到子组件中类名为.child 的元素 */
.parent >>> .child {
color: red;
}
</style>
(2)/deep/(通用穿透),在Vue3中已废弃,与>>>类似,但兼容性更好
A、适用场景:Vue2的scoped样式,支持大多数CSS预处理器(如Sass、Less、Stylus)
B、示例:
<style scoped lang="scss">
/* 穿透到深层的 .deep-element 元素 */
.container /deep/ .deep-element {
font-size: 16px;
}
</style>
(3)::v-deep(Vue专用穿透),Vue官方推荐的深度选择器,兼容性最佳
A、适用场景:Vue2.7+和Vue3,支持所有预处理器
B、示例:
<style scoped>
/* Vue2.7+语法 */
::v-deep .child-component button {
background: blue;
}
/* Vue3推荐语法 */
:deep(.child-component) button {
background: blue;
}
</style>
六、收缩展开
1、多行收缩为2行,点击则“用动画”展开
(1)隐藏Outer,不能触发点击事件
(2)获取初始数据;内层显示full区域,异步计算full区域的高度并存储、显示fold区域、隐藏full区域
(3)显示Outer
(4)缺少1、3步骤,页面会闪烁
(5)点击箭头,内层“用动画”显示溢出区域,隐藏初始区域
<template>
<div :style="{visibility: isOuterShow ? 'visible':'hidden'}">
<div v-for="(item,index) in allNote" :style="{'background':item.readStatus?'#eaeef5':'#f5f6f9'}" class="mynote" @click="clickNoteItem(item)" :key="index">
<div class="mynote-item">
<div>{{item.title}}</div>
<div>
<span style="padding-right: 30px;">{{millsecondsToDate(item.receiveTime)}}</span>
<el-icon :style="{visibility: item.isShowArrow ? 'visible':'hidden'}" class="cursor">
<ArrowRight style="color: gray" v-show="item.isFoldShow" @click.stop="clickItemArrow(item)" />
<ArrowDown style="color: gray" v-show="!item.isFoldShow" @click.stop="clickItemArrow(item)" />
</el-icon>
</div>
</div>
<div v-show="isFullShow" class="isFullShow">{{item.content}}</div>
<div v-show="item.isFoldShow" class="fold_">{{item.content}}</div>
<!-- 下面,from-height,to-height(过程伴随overflow:hidden,应当与style-height一致) -->
<div v-show="!item.isFoldShow"
:class="!item.isFoldShow? 'divDown' + item.index:''"
:style="{'height':item.height + 'px','overflow':'hidden'}"
>{{item.content}}</div>
</div>
</div>
</template>
<script setup>
var isOuterShow = ref(false);//防止闪烁
var isFullShow = ref(false);//计算行高
var clickItemArrow = function(item){
item.isFoldShow = !item.isFoldShow;
};
var getData = function(){
getAllNotes(data).then(function(result){
isFullShow.value = true;
allNote.length = 0;
total.value = result.data.total;
var list = result.data.list;
for(var i = 0; i<list.length; i++){
list[i].isShowArrow = false;
list[i].isFoldShow = false;
allNote.push(list[i])
}
setTimeout(function(){
var all = document.getElementsByClassName('isFullShow')
for(var i = 0; i<all.length; i++){
if(all[i].clientHeight > 50) {
allNote[i].isShowArrow = true;
allNote[i].isFoldShow = true;
allNote[i].index = i;
allNote[i].height = all[i].clientHeight;
document.styleSheets[0].insertRule(
"@keyframes moveDown" + i +
"{" +
"from { height: 40px; }" +
"to { height: " + all[i].clientHeight + "px; }" +
"}"
)
document.styleSheets[0].insertRule(
".divDown" + i +
"{" +
"animation: moveDown" + i + " 2s ease 0s 1 normal;" + /* 非常重要:2s前面有空格 */
"}"
)
}
}
isFullShow.value = false;
isOuterShow.value = true;
});
})
};
onMounted(function() {
getData();
})
</script>
<style lang="scss">
.cursor{
cursor: pointer;
}
.fold_{
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
</style>
七、各种滚动
1、滚动滚轮,元素底部从视口底部出现
(1)可演示
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>元素底部出现,触发事件</title>
<style>
.div{
height: 500px;
}
#elementToTrack {
height: 400px;
background: gray;
}
</style>
</head>
<body>
<div class="div"></div>
<div class="div"></div>
<div class="div"></div>
<div id="elementToTrack"></div>
<div class="div"></div>
</body>
<script>
var element = document.getElementById('elementToTrack');
window.onscroll = function() {
var viewportHeight = window.innerHeight || document.documentElement.clientHeight;//获取视口高度
var viewportBottom = window.scrollY + viewportHeight;//页面顶部到视口底部的距离
var elementBottom = element.offsetTop + element.offsetHeight;//页面顶部到元素底部的距离
if (elementBottom < viewportBottom) {//如果元素底部高于视口底部(元素完全进入视口)!!!
console.log(viewportBottom, elementBottom);
}
};
</script>
</html>
(2)真实项目
需求:
A、表格底部消失在页面下面,左右滚动条出现在页面底部;
B、表格底部出现,滚动条的位置恢复为默认的表格底部
//src/view/base/search-topical-table.vue;57--59;202--230;233
watch(() => props, (newVal) => {
setTimeout(() => {
checkScroll()
},100)
}, { immediate: true, deep: true })
var tableBody = null;
var container = null;
var isOver = ref(false)
onMounted(() => {
container = document.querySelector('.main-container');
tableBody = document.querySelector('.el-table__body');
container.addEventListener('scroll', checkScroll);
checkScroll();
})
onUnmounted(() => {
container.removeEventListener('scroll', checkScroll)
})
function checkScroll() {
//获取目标元素的底部位置
var tableBottom = tableBody.getBoundingClientRect().bottom;
var documentBottom = document.body.clientHeight;
//表格底部位于页面底部上方
if ( tableBottom < documentBottom ) {
isOver.value = true;
} else {
isOver.value = false;
}
}
<div class="search-topical-table" :class="{'search-topical-table-fixed':!isOver}">
<el-table></el-table>
<el-pagination/>
</div>
<style lang="scss">
.search-topical-table-fixed{
.el-table__body-wrapper .el-scrollbar__bar.is-horizontal{
position: fixed;
bottom: 10px;
left:165px;
}
}
</style>
(3)真实项目
需求:滚动条的位置由默认的表格底部改为页面底部
<template>
<div class="search-topical-table">
<el-table></el-table>
<el-pagination/>
</div>
</template>
<style lang="scss">
.search-topical-table{
.el-table__body-wrapper .el-scrollbar__bar.is-horizontal{
position: fixed;
bottom: 10px;
height: 10px;
}
}
</style>
2、触发按钮,“页面”交替回到顶部底部
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>页面滚动条回到顶部示例</title>
<style>
button {
color: red;
font-size: 50px;
border-radius: 10px;
position: fixed;
bottom: 0;
right: 0;
}
.black {
background: #000;
color: #fff;
}
.div {
height: 2000px;
background-color: gray;
}
</style>
</head>
<body>
<button id="btn">顶部--底部</button>
<div class="black">顶部</div>
<div class="div"></div>
<div class="black">底部</div>
</body>
<script>
var y = 0 ;
var btn = document.getElementById('btn');
btn.addEventListener('click', function() {
y = y == 0 ? 2000 : 0;
window.scrollTo({
left: 0,
top: y,
behavior: 'smooth'
});
});
</script>
</html>
3、触发按钮,“元素”交替回到顶部底部
(1)演示示例
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>元素滚动条回到顶部示例</title>
<style>
button {
color: red;
font-size: 50px;
border-radius: 10px;
position: fixed;
top: 50px;
right: 50px;
}
#myDiv {
height: 500px;
overflow: auto;
}
.black {
background: #000;
color: #fff;
}
#myDiv .myDiv {
height: 1000px;
background: gray;
}
</style>
</head>
<body>
<button onclick="clickBtn()">顶部--底部</button>
<div id="myDiv">
<div class="black">顶部</div>
<div class="myDiv"></div>
<div class="black">底部</div>
</div>
</body>
<script>
var y = 0 ;
function clickBtn() {
y = y == 0? 1000 : 0;
var div = document.getElementById('myDiv');
div.scrollTo(0, y);
}
</script>
</html>
(2)项目示例
.app-main {
/*50 = navbar */
width: 100%;
padding-bottom: 85px;
margin-bottom: 20px;
box-sizing: border-box;
position: relative;
//以下是,新增内容
overflow: auto;
}
method(params).then(res => {
allTableData.value = res.data.rows
page.value.pageNum = res.data.pageNum
page.value.pageSize = res.data.pageSize
page.value.count = res.data.recordCount
loading.value = false
//以下是,新增内容
var height = window.innerHeight - 100
var ele = document.getElementsByClassName('app-main')[0];
ele.style.height = height + 'px'
ele.scrollTo({ top: 0, behavior: 'smooth' })
})
4、初始化时,“元素”滚动条回到底部
(1)html
<div class="training-result-code">
<div class="training-result-title">
训练日志
</div>
<div class="training-result-code_content1" ref="divShow">
<div ref="divSonShow">
{{ modelLogs }}
</div>
</div>
<div class="training-result-code_content2" ref="divHide" >
{{ modelLogs }}
</div>
</div>
/* 以下弹窗中使用 */
<el-dialog title="查看日志" v-model="dialogVisible" width="70%">
<div class="record-detail-code">
<div class="record-detail-code_content1" ref="divShow">
<div ref="divSonShow">
{{ modelLogs }}
<div style="padding-bottom: 100px"></div>
</div>
</div>
<div class="training-result-code_content2" ref="divHide" >
{{ modelLogs }}
</div>
</div>
<template #footer>
<el-button type="primary" @click="dialogVisible = false">确 定</el-button>
</template>
</el-dialog>
(2)css
&-code{
background-color: #fff;
border-radius: 10px;
height: 500px;
padding: 30px;
&_content1{
background-color: #000;
overflow: scroll;
color: #fff;
white-space: pre-wrap;
height: 420px;
}
&_content2{
background-color: #000;
overflow: scroll;
color: #fff;
white-space: pre-wrap;
visibility: hidden;
}
}
/* 以下弹窗中使用 */
.record-detail-code{
background-color: #fff;
border-radius: 10px;
height: 500px;
padding: 10px;
overflow: hidden;
&_content1{
background-color: #000;
overflow: scroll;
color: #fff;
white-space: pre-wrap;
height: 490px;
}
&_content2{
background-color: #000;
overflow: hidden;
color: #fff;
white-space: pre-wrap;
visibility: hidden;
}
}
(3)js
const modelLogs = ref('');
const divShow = ref(null);
const divSonShow = ref(null);
const divHide = ref(null);
const timerId = ref(null);
const getLogs = function () {
getModelTrainingLogs({trainingId: route.query.trainingId}).then(res => {
modelLogs.value = res.data.runningLogs||'没有训练日志';
setTimeout(() => {
divSonShow.value.style.height = divHide.value.clientHeight + 100 + 'px';
divShow.value.scrollTop = divHide.value.clientHeight ;
});
})
}
getLogs()
var textAry = ['训练失败','训练完成'];
if(textAry.indexOf(route.query.text) === -1){
timerId.value = setInterval(() => {
getLogs()
}, 500);
}
/* 以下弹窗中使用 */
const modelLogs = ref('');
const divShow = ref(null);
const divSonShow = ref(null);
const divHide = ref(null);
const showRecord = function(row){
dialogVisible.value = true;
getModelTrainingLogs({trainingId: row.trainingId}).then(res => {
modelLogs.value = res.data.runningLogs||'没有训练日志';
setTimeout(() => {
divSonShow.value.style.height = divHide.value.clientHeight + 'px';
divShow.value.scrollTop = divHide.value.clientHeight*5 ;
});
})
}
八、拖拽
1、真实案例(与el-checkbox-group联合使用,拖拽之勾选表格列表)
<script setup>
import draggable from 'vuedraggable'
const checkList = ref([])
const columns = ref([
{
"label": "序号",
"prop": "processVersionSid",
"canChange": false,
"tip": "数据示例:2346"
},
{
"label": "栏目代码",
"prop": "columnCode",
"canChange": false,
"tip": "数据示例:J875023"
},
{
"label": "详细情况说明",
"prop": "reasonDetail",
"canChange": true,
"tip": "数据示例:数据示例示例示例"
},
{
"label": "系统备案栏目首播时间",
"prop": "planPremiereTime",
"canChange": true,
"tip": "数据示例:每周四 7:00 - 8:00"
},
{
"label": "中心核对栏目首播信息",
"prop": "checkPremiereTime",
"canChange": true,
"tip": "数据示例:每周四 7:00 - 8:00"
},
{
"label": "栏目重播信息",
"prop": "replayTime",
"canChange": true,
"tip": "数据示例:每周四 7:00 - 8:00"
},
{
"label": "完成率",
"prop": "completeRate",
"canChange": true,
"tip": "数据示例:98%"
},
{
"label": "备注",
"prop": "remark",
"canChange": false,
"tip": ""
}
])
</script>
<template>
<div class="column-select-warp">
<el-checkbox-group v-model="checkList">
<draggable
v-model="columns"
:force-fallback="true"
chosen-class="chosen"
draggable=".drag"
animation="300"
item-key="prop"
><!--
draggable=".drag",拖拽项的class;
:class="{'drag': element.canChange}",该项是否添加class -->
<template #item="{ element, index }">
<div class="column-select-item" :class="{'drag': element.canChange}">
<svg-icon iconClass="move" class="move-icon"></svg-icon>
<el-checkbox :disabled="!element.canChange" :value="element.prop" size="large" >
<span>{{ element.label }}</span>
<span class="column-select-item__tip">{{ element.tip }}</span>
</el-checkbox>
</div>
</template>
</draggable>
</el-checkbox-group>
</div>
</template>
2、可运行案例(与table联合使用,可拖动表头栏和表体行)
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>vue.draggable、表格、vue3联合示例</title>
<meta name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, minimal-ui">
<script src="https://www.itxst.com/package/vue3/vue.global.js"></script>
<script src="https://www.itxst.com/package/sortable/Sortable.min.js"></script>
<script src="https://www.itxst.com/package/vuedraggablenext/vuedraggable.umd.min.js"></script>
</head>
<body style="padding:10px;">
<div id="app">
<itxst-component></itxst-component>
</div>
</body>
<script type="x-template" id="itxst">
<div>教程,https://www.itxst.com/vue-draggable-next/tutorial.html</div>
<div>案例,https://debug.itxst.com/js/zfzeny7f</div>
<table class="tb">
<thead>
<draggable
v-model="headers"
animation="200"
tag="tr"
:item-key="(key) => key"
>
<template #item="{ element: header, index: index}">
<th class="move">
{{ header }}
</th>
</template>
</draggable>
</thead>
<draggable
:list="list"
handle=".move"
animation="300"
@start="onStart"
@end="onEnd"
tag="tbody"
item-key="name"
>
<template #item="{ element, index }">
<tr>
<td
class="move"
v-for="(header, index) in headers"
:key="header"
>
{{ element[header] }}
</td>
</tr>
</template>
</draggable>
</table>
</script>
<script>
const app = {
//注册draggable组件
components: {
'itxst-component': {
template: "#itxst",
components: {
"draggable": window.vuedraggable,
},
data() {
return {
//列的名称
headers: ["id", "name", "intro"],
//需要拖拽的数据,拖拽后数据的顺序也会变化
list: [
{ name: "www.itxst.com", id: 0, intro: "慢吞吞的蜗牛" },
{ name: "www.baidu.com", id: 1, intro: "中文搜索引擎" },
{ name: "www.google.com", id: 3, intro: "安卓操作系统" },
],
}
},
methods: {
//开始拖拽事件
onStart() {},
//结束拖拽事件
onEnd() {}
}
}
},
}
Vue.createApp(app).mount('#app')
</script>
<style>
.title {
padding: 3px;
font-size: 13px;
}
.itxst {
width: 600px;
}
.move {
cursor: move;
}
table.tb {
color: #333;
border: solid 1px #999;
font-size: 13px;
border-collapse: collapse;
min-width: 500px;
user-select: none;
}
table.tb th {
background: rgb(168 173 217);
border-width: 1px;
padding: 8px;
border-style: solid;
border-color: #999;
text-align: left;
}
table.tb th:nth-of-type(1) {
text-align: center;
}
table.tb td {
background: #d6c8c8;
border-width: 1px;
padding: 8px;
border-style: solid;
border-color: #999;
}
table.tb td:nth-of-type(1) {
text-align: center;
}
</style>
</html>
3、可运行案例(与vue3联合使用,无序列表)
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>vue.draggable、无序列表、vue3联合示例</title>
<meta name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, minimal-ui">
<script src="https://www.itxst.com/package/vue3/vue.global.js"></script>
<script src="https://www.itxst.com/package/sortable/Sortable.min.js"></script>
<script src="https://www.itxst.com/package/vuedraggablenext/vuedraggable.umd.min.js"></script>
</head>
<body>
<div id="app">
<div class="itxst">
<div class="group">
<draggable
:list="modules.group1" group="group1"
handle=".move" filter=".forbid"
@start="onStart" @end="onEnd" :move="onMove"
ghost-class="ghost" chosen-class="chosenClass" animation="300"
:force-fallback="true" :touch-start-threshold="50"
:fallback-class="true" :fallback-on-body="true" :fallback-tolerance="50"
>
<template #item="{ element, index }">
<div :class="element.disabledMove ? 'forbid item' : 'item'">
<label class="move">{{ element.name }}</label>
<p v-show="element.disabledPark" style="color:red">此处不允许拖拽和停靠</p>
<p v-show="!element.disabledPark">内容......</p>
</div>
</template>
</draggable>
</div>
<div class="group">
<draggable
:list="modules.group2" group="group1"
handle=".move" filter=".forbid"
@start="onStart" @end="onEnd" :move="onMove"
ghost-class="ghost" chosen-class="chosenClass" animation="300"
:force-fallback="true" :touch-start-threshold="50"
:fallback-class="true" :fallback-on-body="true" :fallback-tolerance="50"
>
<template #item="{ element, index }">
<div :class="element.disabledMove ? 'forbid item' : 'item'">
<label class="move">{{ element.name }}</label>
<p>内容....</p>
</div>
</template>
</draggable>
</div>
<div class="group">
<draggable
:list="modules.group3" group="group1"
handle=".move" filter=".forbid"
@start="onStart" @end="onEnd" :move="onMove"
ghost-class="ghost" chosen-class="chosenClass" animation="300"
:force-fallback="true" :touch-start-threshold="50"
:fallback-class="true" :fallback-on-body="true" :fallback-tolerance="50"
>
<template #item="{ element, index }">
<div :class="element.disabledMove ? 'forbid item' : 'item'">
<label class="move">{{ element.name }}</label>
<p>内容....</p>
</div>
</template>
</draggable>
</div>
</div>
<div>
<div>教程,https://www.itxst.com/vue-draggable-next/tutorial.html</div>
<div>案例,https://debug.itxst.com/js/byamn2ja</div>
<div>属性说明:</div>
<div style="display: flex;">
<pre>
animation:拖动时过渡动画持续时间
chosen-class:选中的样式
disabled:是否禁用拖拽组件
delay:鼠标按下多少秒之后可以拖拽元素
drag-class:拖动元素的样式
draggable:通过样式设置拖拽,:draggable=".item",样式类为item的元素才能被拖动
fallback-class:克隆选中元素的样式到跟随鼠标的样式
fallback-on-body:克隆的元素添加到文档的body中
fallback-tolerance:按下鼠标移动多少个像素才能拖动元素
filter:通过样式设置不拖拽,:filter=".unmover",设置了unmover样式的元素不允许拖动
force-fallback:忽略HTML5的拖拽行为
ghost-class:宿主样式
group:相同的组名可以相互拖拽
handle:通过样式设置拖拽,:handle=".mover",只有当鼠标在样式类为mover类的元素上才能触发拖动
</pre>
<pre>
list:数据源
scroll:有滚动区域是否允许拖拽
scroll-fensitivity:距离滚动区域多远时,滚动滚动条
scroll-fn:滚动回调函数
scroll-speed:滚动速度
sort:是否开启排序
touch-start-threshold:鼠标按下移动多少px才能拖动元素交换间隔的大小,可以查看菜单对应的属性说明
@start:开始拖拽事件
:move:拖拽中事件,可以用来控制是否允许停靠
@end:结束拖拽事件
vue-draggable组件的disabled、filter、handle、draggable这几个属性,优先级从高到低的排序是什么
disabled > handle > draggable(也可配置在插槽中) > filter
</pre>
</div>
</div>
</div>
<script>
const app = {
data() {
return {
modules: {
group1: [
{ name: "第1组-1", id: 1, disabledMove: true, disabledPark: true },
{ name: "第1组-2", id: 2, disabledMove: false, disabledPark: false },
{ name: "第1组-3", id: 3, disabledMove: false, disabledPark: false },
],
group2: [
{ name: "第2组-1", id: 5, disabledMove: false, disabledPark: false },
{ name: "第2组-2", id: 6, disabledMove: false, disabledPark: false },
{ name: "第2组-3", id: 7, disabledMove: false, disabledPark: false },
],
group3: [
{ name: "第3组-1", id: 8, disabledMove: false, disabledPark: false },
{ name: "第3组-2", id: 9, disabledMove: false, disabledPark: false },
],
},
}
},
//注册draggable组件
components: {
'draggable': window.vuedraggable
},
methods: {
//拖拽开始的事件
onStart() {
console.log("开始拖拽");
},
//拖拽结束的事件
onEnd() {
console.log("结束拖拽");
},
onMove(e) {
//不允许停靠
if (e.relatedContext.element.disabledPark == true) return false;
return true;
}
}
}
Vue.createApp(app).mount('#app')
</script>
<style>
body {
padding: 0px;
margin: 0px;
background-color: #f1f1f1;
}
.itxst {
background-color: #f1f1f1;
display: flex;
justify-content: space-between;
align-content: space-around;
padding: 20px;
}
.group {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-content: center;
width: 32%;
}
.item {
border: solid 1px #ddd;
padding: 0px;
text-align: left;
background-color: #fff;
margin-bottom: 10px;
display: flex;
flex-direction: column;
height: 100px;
user-select: none;
}
.item>label {
border-bottom: solid 1px #ddd;
padding: 6px 10px;
color: #333;
}
.item>label:hover {
cursor: move;
}
.item>p {
padding: 0 10px;
color: #666;
}
.ghost {
border: solid 1px rgb(19, 41, 239) !important;
}
.chosenClass {
opacity: 1;
border: solid 1px red;
}
.fallbackClass {
background-color: aquamarine;
}
</style>
</body>
</html>
九、vue从虚拟DOM到真实DOM的过程!!!
附、说明:
A、虚拟DOM是用JS对象模拟真实DOM结构的内存数据,它会占用浏览器的内存资源,且其Diff算法会消耗CPU资源
B、但相比频繁操作真实DOM(触发大量重排重绘),虚拟DOM能显著降低浏览器的整体性能开销
C、Slate.js,是编辑器里的虚拟DOM
1、定义虚拟DOM节点构造函数
class VNode {
constructor(tag, data = {}, children = [], text = null) {
this.tag = tag; //标签名
this.data = data; //属性数据(class、style、事件等)
this.children = children; //子节点
this.text = text; //文本内容
this.key = data.key; //用于Diff的key
}
}
2、创建虚拟DOM树(设计图)
const vdomTree = new VNode(
'div',
{ id: 'app', class: 'container' },
[
new VNode('h1', { class: 'title' }, null, '虚拟DOM转真实DOM示例'),
new VNode(
'ul',
{ style: { 'list-style': 'none' } }, //Vue规范:style必须是对象
[
new VNode('li', { key: 1, class: 'item' }, null, '第一项'),
new VNode('li', { key: 2, class: 'item' }, null, '第二项'),
new VNode(
'li',
{ key: 3 },
[new VNode('span', { style: { color: 'blue' } }, null, '带子元素的项')]
)
]
),
new VNode(
'button',
{
class: 'btn',
on: { click: () => alert('按钮被点击') } //事件处理
},
null,
'点击我'
)
]
);
console.log(vdomTree);的执行结果如下
{
tag: 'div',
data: {
id: 'app',
class: 'container',
key: undefined //未指定key时为undefined
},
children: [
//第一个子节点:h1标题
{
tag: 'h1',
data: {
class: 'title',
key: undefined
},
children: null,
text: '虚拟DOM转真实DOM示例',
key: undefined
},
//第二个子节点:ul列表
{
tag: 'ul',
data: {
style: { 'list-style': 'none' },
key: undefined
},
children: [
//ul的第一个子节点:li
{
tag: 'li',
data: {
key: 1,
class: 'item'
},
children: null,
text: '第一项',
key: 1
},
//ul的第二个子节点:li
{
tag: 'li',
data: {
key: 2,
class: 'item'
},
children: null,
text: '第二项',
key: 2
},
//ul的第三个子节点:包含span的li
{
tag: 'li',
data: {
key: 3
},
children: [
//li的子节点:span
{
tag: 'span',
data: {
style: { color: 'blue' },
key: undefined
},
children: null,
text: '带子元素的项',
key: undefined
}
],
text: null,
key: 3
}
],
text: null,
key: undefined
},
//第三个子节点:button按钮
{
tag: 'button',
data: {
class: 'btn',
on: {
click: () => alert('按钮被点击') //绑定的点击事件
},
key: undefined
},
children: null,
text: '点击我',
key: undefined
}
],
text: null,
key: undefined
}
3、实现虚拟DOM转真实DOM的函数(按图施工)
function createElement(vnode) {
//处理文本节点
if (vnode.text !== null) {
return document.createTextNode(vnode.text);
}
//创建元素节点
const el = document.createElement(vnode.tag);
//处理属性
if (vnode.data) {
Object.keys(vnode.data).forEach(key => {
if (key === 'class') {
el.className = vnode.data.class;
}
else if (key === 'style') {
Object.assign(el.style, vnode.data.style); //style必须是对象
}
else if (key === 'on' && vnode.data.on) {
//绑定事件
Object.keys(vnode.data.on).forEach(event => {
el.addEventListener(event, vnode.data.on[event]);
});
}
else if (key !== 'key') { //key不渲染到真实DOM
el.setAttribute(key, vnode.data[key]);
}
});
}
//递归处理子节点
if (vnode.children && vnode.children.length) {
vnode.children.forEach(child => {
el.appendChild(createElement(child));
});
}
return el;
}
4、转换为真实DOM并插入到页面
//转换为真实DOM
const realDOM = createElement(vdomTree);
//插入到页面
document.body.appendChild(realDOM);
console.log('虚拟DOM转换完成,已插入页面');
5、最终渲染结果
<div id="app" class="container">
<h1 class="title">虚拟DOM转真实DOM示例</h1>
<ul style="list-style: none;">
<li class="item">第一项</li>
<li class="item">第二项</li>
<li>
<span style="color: blue;">带子元素的项</span>
</li>
</ul>
<button class="btn">点击我</button>
</div>
十、自定义组件
注、在Vue2、Vue3的单文件组件(SFC)模板中,支持驼峰命名和短横线分隔命名,
A、import CctvEllipsis from '../base/cctv-ellipsis.vue',
B、<CctvEllipsis />,
C、<cctv-ellipsis />
1、自定义组件-图标svg-icon,来源,ai-web
(1)配置
A、package.json,引入依赖
//npm i vite-plugin-svg-icons -D
{
"devDependencies": {
"vite-plugin-svg-icons": "^2.0.1",
}
}
B、vite.config.js,使用插件
import createVitePlugins from './vite/plugins'
export default defineConfig(
({command,mode})=>{
plugins: [
createVitePlugins(),
],
}
)
C、createVitePlugins,存入插件
import vue from '@vitejs/plugin-vue'
import createSvgIcon from './svg-icon'
export default function createVitePlugins(viteEnv, isBuild = false) {
const vitePlugins = [vue()]//插件的存储容器
vitePlugins.push(createSvgIcon(isBuild))
return vitePlugins
}
D、createSvgIcon,生成插件
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
import path from 'path'
export default function createSvgIcon(isBuild) {
return createSvgIconsPlugin({
iconDirs: [path.resolve(process.cwd(), 'src/assets/icons/svg')],
symbolId: 'icon-[dir]-[name]',//与iconName关联,注意iconName的前缀#
svgoOptions: isBuild
})
}
附、B,C,D步骤可以简化为本步骤
来源,https://blog.csdn.net/weixin_53731501/article/details/125478380
//vite.config.js中配置,使用vite-plugin-svg-icons插件显示本地svg图标
import path from 'path'
import {createSvgIconsPlugin} from 'vite-plugin-svg-icons'
export default defineConfig((command) => {
return {
plugins: [
createSvgIconsPlugin({
iconDirs: [path.resolve(process.cwd(), 'src/assets/icons')],// 指定要缓存的文件夹
symbolId: '[name]'// 指定symbolId格式
})
],
}
})
(2)定义
<template>
<svg :class="svgClass">
<use :xlink:href="iconName" :fill="color" />
</svg>
</template>
<script>
export default defineComponent({
props: {
nameIcon: {
type: String,
required: true
},
className: {
type: String,
default: ''
},
color: {
type: String,
default: ''
},
},
setup(props) {
return {
iconName: computed(() => `#icon-${props.nameIcon}`),
svgClass: computed(() => {
if (props.className) {
return `svg-icon ${props.className}`
}
return 'svg-icon'
})
}
}
})
</script>
(3)注入
import { createApp } from 'vue'
import App from './App.vue'
import SvgIcon from '@/components/SvgIcon'
const app = createApp(App)
app.component('svg-icon', SvgIcon)//全局引入使用
app.mount('#app')
(4)使用,
<svg-icon name-icon="clock" />,clock指向clock.svg
2、自定义组件-月季度半年全年,来源:bcbf-web
(1)定义组件,many-date
<script setup>
import {
DArrowLeft,
DArrowRight,
Calendar,
} from '@element-plus/icons-vue'
const props = defineProps({
showPeriod: ''
});
const emit = defineEmits(['dateOk'])
const period = ref(''); //统计周期
var date = new Date;
var initMonth = date.getMonth();
var initYear = date.getFullYear();
var clickYear = ref(initYear);//被使用的年份
var initItem = ref('');//当前所在的季节或半年
var clickItem = ref('');//默认的或被点击的季节或半年
var twoRef = ref('');
var fourRef = ref('');
var dateRange = ref('');
var isShowTwo = ref(false);
var isShowFour = ref(false);
// 以下处理统计周期改变
watch(() => props.showPeriod, (newValue, oldValue) => {
if (newValue !== oldValue) {
period.value = newValue;
changePeriod(period.value)
getInitItem()
if (period.value === '月'|| period.value === '全年') {
changeInput()
}
}
}, { immediate: true, deep: true })
function changePeriod(period) {
if(period === '月'){
if (initMonth == 0) {//如果现在处在今年一月,那么默认为去年十二月
initMonth = 12;
initYear--;
}
dateRange.value = [
initYear + '-' + String(1).padStart(2, '0'),
initYear + '-' + String(initMonth).padStart(2, '0')
];//initMonth可以用于表示上个月
}else if(period === '全年') {
initYear--;
dateRange.value = initYear.toString();
}else if(period === '季度') {
var four = {
3: 'four',
6: 'one',
9: 'two',
12: 'three',
};
for (var key in four) {
if (initMonth < key) {
if (key == 3) initYear--;//如果现在处在今年第一季度,那么默认为去年第四季度
clickFourAndTwo(four[key]);
break;
}
}
}else if(period === '半年') {
var two = {
6: 'down',
12: 'up',
}
for (var key in two) {
if (initMonth < key) {
if (key == 6) initYear--;//如果现在处在今年上半年,那么默认为去年下半年
clickFourAndTwo(two[key]);
break;
}
}
}
}
function getInitItem() {//获取当前所在的季节或半年
var type = clickItem.value;
var four = ['one','two','three','four','one'];
var two = ['up','down','up'];
if(four.indexOf(type) > -1){
initItem.value = four[four.indexOf(type) + 1]
}
if(two.indexOf(type) > -1){
initItem.value = two[two.indexOf(type) + 1]
}
}
function changeInput() {
emit('dateOk', dateRange.value)
}
// 以下处理季度和半年
function focusInput() {
clickYear.value = initYear;
if( period.value == '半年' ){
isShowTwo.value = true;
isShowFour.value = false;
setTimeout(function(){
twoRef.value.focus();
}, 150);
}
if( period.value == '季度' ){
isShowTwo.value = false;
isShowFour.value = true;
setTimeout(function(){
fourRef.value.focus();
}, 150);
}
}
function clickArrow(type) {
if(type === 'left') clickYear.value--;
if(type === 'right') clickYear.value++;
}
function getBlur() {
isShowTwo.value = false;
isShowFour.value = false;
}
function clickFourAndTwo(type) {
clickItem.value = type;
var obj = {
one: {
start: clickYear.value + '-01',
end: clickYear.value + '-03'
},
two: {
start: clickYear.value + '-04',
end: clickYear.value + '-06'
},
three: {
start: clickYear.value + '-07',
end: clickYear.value + '-09'
},
four: {
start: clickYear.value + '-10',
end: clickYear.value + '-12'
},
up: {
start: clickYear.value + '-01',
end: clickYear.value + '-06'
},
down: {
start: clickYear.value + '-07',
end: clickYear.value + '-12'
},
};
var start = obj[type].start;
var end = obj[type].end;
var temp = addSpace(6) + start + addSpace(8) + '-' + addSpace(8) + end;
isShowTwo.value = false;
isShowFour.value = false;
dateRange.value = temp;
emit('dateOk', [start, end])
}
function addSpace(num) { //添加空格
var str = '\xa0';
for(var i = 0; i < num; i++) str += '\xa0'
return str;
}
</script>
<template>
<div style="width: 100%;">
<!-- 以下调用已有组件,实现'月'和'全年'的功能 -->
<el-date-picker
v-if="period === '月'"
v-model="dateRange"
type="monthrange"
:editable="false"
start-placeholder="开始日期"
end-placeholder="结束日期"
style="width: 100%;"
format="YYYY-MM"
value-format="YYYY-MM"
@change="changeInput()"
/>
<el-date-picker
v-if="period === '全年'"
v-model="dateRange"
type="year"
:editable="false"
start-placeholder="开始日期"
end-placeholder="结束日期"
style="width: 100%;"
format="YYYY"
value-format="YYYY"
@change="changeInput()"
/>
<!-- 以下加工已有组件,实现'季度'和'半年'的功能 -->
<el-input
v-model="dateRange"
:disabled="isDisabled"
:readonly="true"
@focus="focusInput()"
v-if="period === '季度'|| period === '半年'"
>
<template #prefix>
<el-icon><Calendar /></el-icon>
</template>
</el-input>
<div class="many-date" tabindex="-1" v-if="isShowFour" ref="fourRef" @blur="getBlur">
<div class="many-date-up">
<el-icon class="many-date-up-content" @click="clickArrow('left')" ><DArrowLeft /></el-icon>
<div class="many-date-up-content" >{{ clickYear }}</div>
<el-icon class="many-date-up-content" @click="clickArrow('right')"><DArrowRight /></el-icon>
</div>
<el-divider class="many-date-divider"/>
<div class="many-date-down">
<div class="many-date-down-content" @click="clickFourAndTwo('one')"
:class="[
(initYear==clickYear-1 && initItem=='one')?'many-date-down-content-bgNow':null,
(initYear==clickYear && clickItem=='one')?'many-date-down-content-bgClick':null,
]"
>第一季度</div>
<div class="many-date-down-content" @click="clickFourAndTwo('two')"
:class="[
(initYear==clickYear && initItem=='two')?'many-date-down-content-bgNow':null,
(initYear==clickYear && clickItem=='two')?'many-date-down-content-bgClick':null,
]"
>第二季度</div>
</div>
<div class="many-date-down">
<div class="many-date-down-content" @click="clickFourAndTwo('three')"
:class="[
(initYear==clickYear && initItem=='three')?'many-date-down-content-bgNow':null,
(initYear==clickYear && clickItem=='three')?'many-date-down-content-bgClick':null,
]"
>第三季度</div>
<div class="many-date-down-content" @click="clickFourAndTwo('four')"
:class="[
(initYear==clickYear && initItem=='four')?'many-date-down-content-bgNow':null,
(initYear==clickYear && clickItem=='four')?'many-date-down-content-bgClick':null,
]"
>第四季度</div>
</div>
</div>
<div class="many-date" tabindex="-1" v-if="isShowTwo" ref="twoRef" @blur="getBlur">
<div class="many-date-up">
<el-icon class="many-date-up-content" @click="clickArrow('left')"><DArrowLeft /></el-icon>
<div class="many-date-up-content" >{{ clickYear }}</div>
<el-icon class="many-date-up-content" @click="clickArrow('right')"><DArrowRight /></el-icon>
</div>
<el-divider class="many-date-divider"/>
<div class="many-date-down">
<div class="many-date-down-content" @click="clickFourAndTwo('up')"
:class="[
(initYear==clickYear-1 && initItem=='up')?'many-date-down-content-bgNow':null,
(initYear==clickYear && clickItem=='up')?'many-date-down-content-bgClick':null,
]"
>上半年</div>
<div class="many-date-down-content" @click="clickFourAndTwo('down')"
:class="[
(initYear==clickYear && initItem=='down')?'many-date-down-content-bgNow':null,
(initYear==clickYear && clickItem=='down')?'many-date-down-content-bgClick':null,
]"
>下半年</div>
</div>
</div>
</div>
</template>
<style lang="scss">
.many-date{
width: 60%;
margin-left: 20%;
border-radius: 4px;
margin-top: 10px;
position: absolute;
z-index: 100;
background: var(--el-bg-color-overlay);
box-shadow: var(--el-box-shadow-light);
&-up {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 10% ;
&-content {
cursor: pointer;
user-select: none;
color: var(--el-text-color-regular);
}
&-content:hover {
color: var(--el-color-primary);
}
}
&-divider {
margin: 0 10%;
width: 80%
}
&-down {
display: flex;
justify-content: space-around;
align-items: center;
padding: 10px 10% ;
&-content {
padding: 4px 20px ;
cursor: pointer;
user-select: none;
color: var(--el-text-color-regular);
&-bgClick {
background: #2162db;
border-radius: 18px;
color: #ffffff !important;
font-weight: bolder;
}
&-bgNow {
color: #2162db;
font-weight: bolder;
}
}
&-content:hover {
color: var(--el-color-primary);
}
}
}
</style>
(2)使用组件!!!
<script setup>
const ruleShowForm = ref(false)
const operateText = ref('')
const ruleVersionForm = reactive({
period: '',
myDate: [],
})
const rules = {
period: [{ required: true, trigger: "blur", message: "统计周期不能为空" }],
myDate: [{ required: true, trigger: "blur", message: "日期范围不能为空" }],
};
const formRef = ref(null)
const showDialogRule = () => {
ruleShowForm.value = true;
operateText.value = '创建新版本'
formRef.value.clearValidate();
}
const createOrEdit = () => {
formRef.value.validate((valid) => {
if (valid) {
var data = {
period: ruleVersionForm.period,
myDate: ruleVersionForm.myDate,
}
updateVersion(data).then((res) => {
ruleShowForm.value = false;
getList();
}).catch(()=>{
ruleShowForm.value = false;
ElMessage("出错了");
});
} else {
ElMessage.error('表单验证失败');
}
});
}
function handleDateOk(data) {
console.log( data );
ruleVersionForm.myDate = data
formRef.value.validateField('myDate');
}
function clearSelect(key) {
formRef.value.clearValidate(key);
}
function changePeriod() {
formRef.value.clearValidate('myDate');
}
function validateSelect(key) {
formRef.value.validateField(key);
}
</script>
<template>
<div class="content-table">
<div class="content-table-header" style="padding-left: 0;">
<el-button type="primary" @click="showDialogRule()">创建新版本</el-button>
</div>
<el-dialog v-model="ruleShowForm" :title="operateText" width="600">
<el-form :model="ruleVersionForm" class="rebut-form" ref="formRef" :rules="rules" label-width="80px">
<el-form-item label="统计周期" prop="period">
<el-select
v-model="ruleVersionForm.period"
placeholder="请选择统计周期"
@focus="clearSelect('period')"
@change="changePeriod"
@blur="validateSelect('period')"
>
<el-option label="月" value="月" />
<el-option label="季度" value="季度" />
<el-option label="半年" value="半年" />
<el-option label="全年" value="全年" />
</el-select>
</el-form-item>
<el-form-item
label="日期范围"
prop="myDate"
v-if="ruleVersionForm.period"
>
<many-date :showPeriod="ruleVersionForm.period" @dateOk="handleDateOk" />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="ruleShowForm = false">取消</el-button>
<el-button type="primary" @click="createOrEdit">确定</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
3、自定义组件-图片水印组件Water1mark,改编自:bcbf-web!!!
附、初始逻辑,监听props.url变化,以默认配置为参数
A、new Image并监听其下载事件,给该实例的url赋值。在下载事件里
B、给canvas标签设置宽高(会清空画布)
C、用标签的drawImage方法把实例绘制成图片,用标签的各种方法绘制文字
附、更新逻辑,监听配置变化,以更新配置为参数
A、给canvas标签设置宽高(会清空画布)
B、用标签的drawImage方法把实例绘制成图片,用标签的各种方法绘制文字
附、为什么vue3的有些逻辑写在watch里,而不写在updated里
A、逻辑写在updated里,watch数据没有改变时,逻辑也会执行
(1)定义组件
<template>
<div class="image-watermark-container">
<div class="image-section">
<div v-if="!imageData" class="empty-state">
<el-empty description="请上传图片" />
</div>
<div v-else class="image-preview">
<div class="thumbnail-item" :class="{
watermarked: hasWatermark
}">
<!-- isPreview为true时:固定尺寸容器 -->
<div v-if="props.isPreview" class="preview-fixed-container">
<img
:src="getThumbnailUrl()"
:alt="`图片预览`"
class="preview-fixed-img"
/>
<div class="preview-btn top-right">
<el-button
size="small"
type="text"
@click.stop="openPreviewDialog()"
>
预览
</el-button>
</div>
</div>
<!-- isPreview为false时:原始大小容器 -->
<div v-else class="original-size-container">
<img
:src="getThumbnailUrl()"
:alt="`图片预览`"
class="original-size-img"
/>
</div>
</div>
</div>
</div>
<!-- 预览大图弹窗 -->
<el-dialog
v-model="previewDialogVisible"
width="35%"
top="5vh"
:show-close="false"
:close-on-click-modal="true"
@close="handlePreviewDialogClose"
>
<div class="preview-dialog-content">
<div class="large-preview-container">
<div v-if="showPreviewLoading" class="loading-container">
<el-icon class="loading-icon"><Loading /></el-icon>
<span class="loading-text">大图加载中...</span>
</div>
<img
v-else
:src="currentPreviewImage"
alt="预览大图"
class="large-preview-image"
/>
</div>
</div>
<template #title>
<div class="position-fixed">
<div class="padding">{{ props.title || '图片预览' }}</div>
<div>
<el-button
type="primary"
@click="downloadPreviewImage"
:loading="previewDownloading"
>下载</el-button>
<el-button @click="previewDialogVisible = false">关闭</el-button>
</div>
</div>
</template>
</el-dialog>
<!-- 添加水印弹窗 -->
<el-dialog
v-model="watermarkDialogVisible"
title="添加水印"
width="1000px"
:close-on-click-modal="false"
>
<!-- 水印设置 -->
<div class="watermark-dialog-content">
<!-- 预览区域 -->
<div class="preview-panel">
<div class="preview-container">
<div v-if="showWatermarkLoading" class="loading-container">
<el-icon class="loading-icon"><Loading /></el-icon>
<span class="loading-text">图片加载中...</span>
</div>
<img
v-else
:src="currentWatermarkImage"
alt="预览图"
class="preview-image"
/>
</div>
</div>
<!-- 设置区域 -->
<div class="settings-panel">
<el-form :model="usedWatermarkSettings" label-width="100px" label-position="top">
<el-form-item label="水印信息">
<el-input
v-model="usedWatermarkSettings.text"
placeholder="请输入水印信息"
type="textarea"
:rows="4"
:maxlength="50"
show-word-limit
/>
</el-form-item>
<el-form-item label="颜色">
<el-color-picker v-model="usedWatermarkSettings.color" show-alpha />
</el-form-item>
<el-form-item label="字号">
<el-slider v-model="usedWatermarkSettings.fontSize" :min="5" :max="120" />
</el-form-item>
<el-form-item label="旋转">
<el-slider v-model="usedWatermarkSettings.rotate" :min="-180" :max="180" />
</el-form-item>
<el-form-item label="行距">
<el-slider v-model="usedWatermarkSettings.lineSpace" :min="1" :max="5" :step="0.1" />
</el-form-item>
<el-form-item label="偏移">
<el-space>
<el-input-number v-model="usedWatermarkSettings.left" placeholder="左偏移" controls-position="right" />
<el-input-number v-model="usedWatermarkSettings.top" placeholder="上偏移" controls-position="right" />
</el-space>
</el-form-item>
</el-form>
<div class="apply-btn">
<el-button
type="primary"
@click="handleSave"
:loading="loadingUp"
>确定</el-button>
</div>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, watch, onUnmounted, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { Loading } from '@element-plus/icons-vue'
import useUserStore from '@/store/modules/user';
/* ==================== 1、响应式数据定义 ==================== */
const user = useUserStore().userInfo
const watermarkText = user.realName + ' ' + user.userName
// 定义属性
const props = defineProps({
url: {
type: String,
required: true
},
name: {
type: String,
default: ''
},
title: {
type: String,
default: ''
},
isPreview: {
type: Boolean,
default: true
},
isText: {
type: Boolean,
default: true
},
settingObj: {
type: Object,
default: () => ({})
}
})
// 事件定义
const emit = defineEmits(['watermarkUpdate'])
// 状态变量
const imageData = ref(null)
const originalImage = ref(null)
const watermarkedCanvas = ref(null)
const hasWatermark = ref(false)
const previewDialogVisible = ref(false)
const watermarkDialogVisible = ref(false)
const largePreviewLoading = ref(false)
const imgLoadingForWatermark = ref(false)
const previewDownloading = ref(false)
const loadingUp = ref(false)
const allPreviewCanvases = ref(new Map())
// 计算属性:默认水印配置
const defaultWatermarkSettings = computed(() => ({
text: props.isText ? watermarkText : (props.settingObj.text || ''),
color: props.settingObj.color || '#999999',
fontSize: props.settingObj.fontSize || 30,
rotate: props.settingObj.rotate || -30,
lineSpace: props.settingObj.lineSpace || 1,
left: props.settingObj.left || 0,
top: props.settingObj.top || 0
}))
// 当前使用的水印配置
const usedWatermarkSettings = reactive({ ...defaultWatermarkSettings.value })
// 参数初始化与恢复初始化!!!:Object.assign(usedWatermarkSettings, defaultWatermarkSettings.value)
/* ==================== 2、工具函数 ==================== */
// 创建图片元素的Promise封装
const createImageElement = (src) => {
return new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => resolve(img)
img.onerror = () => reject(new Error('图片加载失败'))
img.src = src
})
}
// 创建Canvas元素
const createCanvasFromImage = (img, scale = 1) => {
const canvas = document.createElement('canvas')
canvas.width = img.width * scale
canvas.height = img.height * scale
const context = canvas.getContext('2d')
context.drawImage(img, 0, 0, canvas.width, canvas.height)
return canvas
}
// 重置图片状态
const resetImageState = () => {
imageData.value = null
originalImage.value = null
watermarkedCanvas.value = null
hasWatermark.value = false
allPreviewCanvases.value.clear()
}
// 给当前canvas添加水印
const addWatermarkToCanvas = (context, canvasWidth, canvasHeight) => {
const { text, color, fontSize, rotate, lineSpace, left, top } = usedWatermarkSettings
if (!text || text.trim() === '') return
// 设置水印样式
context.fillStyle = color
context.font = `${fontSize}px Arial`
context.textAlign = 'left'
context.textBaseline = 'middle'
// 处理换行文本
const lines = text.split('\n').filter(line => line.trim() !== '')
if (lines.length === 0) return
// 计算每行文本的宽度并找出最长的一行
let maxLineWidth = 0
lines.forEach(line => {
const lineWidth = context.measureText(line.trim()).width
if (lineWidth > maxLineWidth) {
maxLineWidth = lineWidth
}
})
// 计算单行文本高度
const textHeight = fontSize * lineSpace
const lineSpacing = 300
// 保存当前状态
context.save()
// 应用偏移和旋转
context.translate(canvasWidth / 2 + left, canvasHeight / 2 + top)
context.rotate(rotate * Math.PI / 180)
// 绘制网格水印
const horizontalGap = maxLineWidth * 1.5
for (let x = -canvasWidth; x < canvasWidth * 2; x += horizontalGap) {
for (let y = -canvasHeight; y < canvasHeight * 2; y += lineSpacing) {
lines.forEach((line, index) => {
const yOffset = y + index * textHeight
context.fillText(line.trim(), x, yOffset)
})
}
}
// 恢复状态
context.restore()
}
// 更新图片显示
const updateImageDisplay = () => {
if (imageData.value) {
imageData.value = { ...imageData.value }
}
}
/* ==================== 3、核心业务函数 ==================== */
// 加载图片
const loadImage = async () => {
if (!props.url) return
try {
resetImageState()
// 加载图片
const img = await createImageElement(props.url)
// 创建原始图片canvas
const canvas = createCanvasFromImage(img)
// 存储原始图片数据
originalImage.value = canvas
imageData.value = {
width: img.width,
height: img.height,
dataUrl: canvas.toDataURL()
}
// 如果默认有水印文字,为图片添加水印
if (usedWatermarkSettings.text?.trim()) {
await addWatermarkToCurrentImage(canvas)
}
// 如果是非预览模式,预加载全尺寸预览
if (!props.isPreview) {
allPreviewCanvases.value.set('preview', canvas)
}
} catch (error) {
console.error('图片加载失败:', error)
ElMessage.error('图片加载失败')
}
}
// 为图片添加水印
const addWatermarkToCurrentImage = async (sourceCanvas = originalImage.value) => {
const text = usedWatermarkSettings.text?.trim()
if (!text) return
if (!sourceCanvas) {
console.error('原始图片不存在')
return
}
try {
const newCanvas = document.createElement('canvas')
newCanvas.width = sourceCanvas.width
newCanvas.height = sourceCanvas.height
const context = newCanvas.getContext('2d')
// 绘制原始内容
context.drawImage(sourceCanvas, 0, 0)
// 添加水印
addWatermarkToCanvas(context, newCanvas.width, newCanvas.height)
// 更新状态
watermarkedCanvas.value = newCanvas
hasWatermark.value = true
originalImage.value = newCanvas
allPreviewCanvases.value.set('preview', newCanvas)
} catch (error) {
console.error('添加水印失败:', error)
ElMessage.error('添加水印失败')
}
}
// 生成高质量图片(用于水印编辑)
const generateHighQualityImage = async () => {
if (!props.url) return null
try {
const img = await createImageElement(props.url)
return createCanvasFromImage(img)
} catch (error) {
console.error('生成高质量图片失败:', error)
return null
}
}
/* ==================== 4、预览相关函数 ==================== */
// 处理弹窗关闭
const handlePreviewDialogClose = () => {
largePreviewLoading.value = false
}
// 打开预览大图弹窗
const openPreviewDialog = async () => {
try {
previewDialogVisible.value = true
await nextTick()
await generateLargePreview()
} catch (error) {
console.error('打开预览弹窗失败:', error)
ElMessage.error('打开预览失败:' + error.message)
previewDialogVisible.value = false
}
}
// 生成预览大图
const generateLargePreview = async () => {
if (!props.url) {
largePreviewLoading.value = false
ElMessage.error('图片URL为空')
return
}
if (allPreviewCanvases.value.has('preview')) {
return true
}
try {
largePreviewLoading.value = true
const img = await createImageElement(props.url)
const canvas = createCanvasFromImage(img)
// 如果有水印文字并且该图片已经有水印,才添加水印
if (usedWatermarkSettings.text?.trim() && hasWatermark.value) {
addWatermarkToCanvas(canvas.getContext('2d'), canvas.width, canvas.height)
}
allPreviewCanvases.value.set('preview', canvas)
return true
} catch (error) {
console.error('生成预览大图失败:', error)
ElMessage.error('预览大图生成失败:' + error.message)
return false
} finally {
largePreviewLoading.value = false
}
}
// 获取图片URL
const getThumbnailUrl = () => {
if (!imageData.value) return ''
if (hasWatermark.value && watermarkedCanvas.value) {
return watermarkedCanvas.value.toDataURL()
}
if (originalImage.value) {
return originalImage.value.toDataURL()
}
return imageData.value.dataUrl
}
// 下载预览图片
const downloadPreviewImage = async () => {
const canvas = allPreviewCanvases.value.get('preview')
if (!canvas) {
ElMessage.warning('暂无预览图片可下载')
return
}
try {
previewDownloading.value = true
const timestamp = new Date().getTime()
const fileName = `${props.name || 'image'}_${timestamp}.png`
const link = document.createElement('a')
link.download = fileName
link.href = canvas.toDataURL('image/png')
link.style.display = 'none'
document.body.appendChild(link)
link.click()
setTimeout(() => {
document.body.removeChild(link)
URL.revokeObjectURL(link.href)
}, 100)
ElMessage.success('图片下载成功')
} catch (error) {
console.error('下载图片失败:', error)
ElMessage.error(`下载失败: ${error.message}`)
} finally {
previewDownloading.value = false
}
}
/* ==================== 5、水印弹窗相关函数 ==================== */
// 打开添加水印弹窗
const openWatermarkDialog = async () => {
watermarkDialogVisible.value = true
Object.assign(usedWatermarkSettings, defaultWatermarkSettings.value)
await getImgForAddWatermark()
}
// 获取图片为添加水印做准备
const getImgForAddWatermark = async () => {
if (!props.url) return
try {
imgLoadingForWatermark.value = true
// 如果图片原本高清就跳过,否则就重新加载
if (originalImage.value?.width > 800) {
imgLoadingForWatermark.value = false
return
}
const img = await createImageElement(props.url)
const canvas = createCanvasFromImage(img)
// 如果该图片已有水印,先添加水印
if (hasWatermark.value && watermarkedCanvas.value) {
originalImage.value = watermarkedCanvas.value
} else {
originalImage.value = canvas
}
} catch (error) {
console.error('生成高质量预览图失败:', error)
} finally {
imgLoadingForWatermark.value = false
}
}
// 将水印应用到当前图片
const applyWatermarkToCurrentImage = async () => {
try {
// 清除旧水印
hasWatermark.value = false
watermarkedCanvas.value = null
allPreviewCanvases.value.delete('preview')
const currCanvas = await generateHighQualityImage()
if (!currCanvas) return
if (!usedWatermarkSettings.text?.trim()) {
originalImage.value = currCanvas
updateImageDisplay()
return
}
await addWatermarkToCurrentImage(currCanvas)
updateImageDisplay()
} catch (error) {
console.error('应用水印失败:', error)
ElMessage.error('水印应用失败')
}
}
/* ==================== 6、保存和导出函数 ==================== */
const handleSave = async () => {
try {
loadingUp.value = true
const currCanvas = await generateHighQualityImage()
if (!currCanvas) {
throw new Error('无法加载原始图片')
}
// 清除旧水印
hasWatermark.value = false
watermarkedCanvas.value = null
if (!usedWatermarkSettings.text?.trim()) {
originalImage.value = currCanvas
updateImageDisplay()
emit('watermarkUpdate', {
success: true,
dataUrl: currCanvas.toDataURL('image/png'),
hasWatermark: false,
watermarkSettings: { ...usedWatermarkSettings }
})
} else {
await addWatermarkToCurrentImage(currCanvas)
emit('watermarkUpdate', {
success: true,
dataUrl: watermarkedCanvas.value.toDataURL('image/png'),
hasWatermark: true,
watermarkSettings: { ...usedWatermarkSettings }
})
}
ElMessage.success('水印添加成功')
} catch (error) {
console.error('添加水印失败:', error)
const errorResult = {
success: false,
error: error.message,
watermarkSettings: { ...usedWatermarkSettings }
}
emit('watermarkUpdate', errorResult)
ElMessage.error('水印添加失败:' + error.message)
} finally {
loadingUp.value = false
watermarkDialogVisible.value = false
}
}
// 获取带水印的图片数据
const getWatermarkedImageData = () => {
if (!hasWatermark.value || !watermarkedCanvas.value) {
return null
}
return {
dataUrl: watermarkedCanvas.value.toDataURL('image/png'),
width: watermarkedCanvas.value.width,
height: watermarkedCanvas.value.height,
fileName: `已加水印_${props.name || '图片'}.png`
}
}
// 下载带水印的图片
const downloadWatermarkedImage = async () => {
const imageData = getWatermarkedImageData()
if (!imageData) {
ElMessage.warning('没有找到已添加水印的图片')
return
}
try {
const link = document.createElement('a')
link.download = imageData.fileName
link.href = imageData.dataUrl
link.style.display = 'none'
document.body.appendChild(link)
link.click()
setTimeout(() => {
document.body.removeChild(link)
URL.revokeObjectURL(link.href)
}, 100)
ElMessage.success('水印图片下载成功')
} catch (error) {
console.error('下载水印图片失败:', error)
ElMessage.error('水印图片下载失败')
}
}
/* ==================== 7、计算属性 ==================== */
const showPreviewLoading = computed(() => largePreviewLoading.value || !currentPreviewImage.value)
const showWatermarkLoading = computed(() => imgLoadingForWatermark.value || !currentWatermarkImage.value)
const currentPreviewImage = computed(() => {
const canvas = allPreviewCanvases.value.get('preview')
return canvas ? canvas.toDataURL() : getThumbnailUrl()
})
const currentWatermarkImage = computed(() => {
if (watermarkedCanvas.value) {
return watermarkedCanvas.value.toDataURL()
}
return originalImage.value ? originalImage.value.toDataURL() : ''
})
/* ==================== 8、监听与生命周期 ==================== */
// 监听URL变化
watch(() => props.url, (newUrl) => {
if (newUrl) {
loadImage()
}
}, { immediate: true })
// 监听水印设置变化
watch(() => ({ ...usedWatermarkSettings }), () => {
if (watermarkDialogVisible.value) {
applyWatermarkToCurrentImage()
}
}, { deep: true })
// 组件挂载时加载图片
onMounted(() => {
if (props.url) {
loadImage()
}
})
// 组件卸载时清理资源
onUnmounted(() => {
if (originalImage.value) {
originalImage.value = null
}
if (watermarkedCanvas.value) {
watermarkedCanvas.value = null
}
allPreviewCanvases.value.clear()
})
/* ==================== 9、暴露给父组件的方法和属性 ==================== */
defineExpose({
openWatermarkDialog,
getWatermarkedImageData,
downloadWatermarkedImage,
hasWatermark: () => hasWatermark.value,
getWatermarkSettings: () => ({ ...usedWatermarkSettings })
})
</script>
<style scoped>
/* 1. 容器和布局样式 */
.image-watermark-container {
margin: 0 auto;
}
.image-section {
margin-bottom: 30px;
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
}
.empty-state {
text-align: center;
padding: 40px 0;
}
.image-preview {
display: flex;
justify-content: center;
}
/* 2. 缩略图基础样式 */
.thumbnail-item {
cursor: pointer;
transition: all 0.3s ease;
width: 200px;
margin: 20px;
}
.thumbnail-item:hover {
border-color: #409eff;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.thumbnail-item.watermarked {
border-color: #67c23a;
}
/* ========== isPreview为true时的样式 ========== */
/* 预览模式:固定尺寸容器 */
.preview-fixed-container {
width: 300px;
height: 150px;
display: flex;
align-items: center;
justify-content: center;
background: #f8f9fa;
border: 1px solid #e4e7ed;
border-radius: 8px;
overflow: hidden;
position: relative;
}
.preview-fixed-img {
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
object-fit: contain;
}
/* ========== isPreview为false时的样式 ========== */
/* 非预览模式:原始大小容器 */
.original-size-container {
max-width: 100%;
max-height: 80vh;
display: flex;
justify-content: center;
align-items: center;
background: transparent;
}
.original-size-img {
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
object-fit: contain;
}
/* ========== 共用样式 ========== */
/* 预览按钮位置调整 */
.preview-btn.top-right {
position: absolute;
top: 2px;
right: 2px;
z-index: 10;
}
.preview-btn.top-right button {
color: #5321FF !important;
}
/* 3. 缩略图全尺寸模式 */
.thumbnail-item.full-size {
width: auto;
margin: 0 auto;
display: flex;
justify-content: center;
max-width: 100%;
}
/* 4. 固定尺寸容器(isPreview为false时) */
.fixed-size-container {
width: 500px;
height: 250px;
display: flex;
align-items: center;
justify-content: center;
background: #f8f9fa;
border: 1px solid #e4e7ed;
border-radius: 8px;
overflow: hidden;
position: relative;
}
.fixed-size-img {
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
object-fit: contain;
}
/* 5. 预览模式缩略图(isPreview为true时) */
.thumbnail-image {
padding: 24px 12px 12px 12px;
text-align: center;
position: relative;
background: white;
}
.thumbnail-image img {
max-width: 100%;
height: auto;
max-height: 300px;
}
/* 6. 预览按钮 */
.preview-btn.top-right {
position: absolute;
top: 2px;
right: 2px;
}
.preview-btn.top-right button {
color: #5321FF !important;
}
/* 7. 水印弹窗样式 */
.watermark-dialog-content {
display: flex;
gap: 30px;
min-height: 600px;
}
.preview-panel {
flex: 2;
display: flex;
flex-direction: column;
}
.preview-container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
border: 1px solid #e4e7ed;
border-radius: 8px;
margin-bottom: 20px;
background: #f8f9fa;
max-height: 600px;
position: relative;
min-height: 400px;
}
.preview-image {
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
object-fit: contain;
}
.settings-panel {
width: 420px;
display: flex;
flex-direction: column;
}
.apply-btn {
margin-top: auto;
display: flex;
justify-content: flex-start;
gap: 10px;
}
/* 8. 预览大图弹窗样式 */
.preview-dialog-content {
display: flex;
flex-direction: column;
gap: 20px;
}
.large-preview-container {
display: flex;
justify-content: center;
align-items: center;
background: #f8f9fa;
border-radius: 8px;
padding: 20px;
min-height: 500px;
position: relative;
}
.large-preview-image {
max-width: 100%;
width: auto;
height: 70vh;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
object-fit: contain;
cursor: pointer;
transition: transform 0.3s ease;
}
.large-preview-image:hover {
transform: scale(1.01);
}
/* 9. 弹窗标题固定位置样式 */
.position-fixed {
position: fixed;
top: 0;
left: 0;
display: flex;
justify-content: space-between;
width: 100%;
background-color: #ffffff;
z-index: 1000;
padding: 10px;
}
.position-fixed .padding {
padding-top: 6px;
}
/* 10. Loading加载样式 */
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 70vh;
color: #909399;
}
.loading-icon {
font-size: 40px;
margin-bottom: 16px;
animation: rotating 2s linear infinite;
}
.loading-text {
font-size: 14px;
}
@keyframes rotating {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* 11. Element Plus弹窗样式覆盖(深度选择器放在最后) */
:deep(.el-dialog__body) {
padding: 20px;
}
:deep(.el-dialog) {
max-width: 90%;
margin-top: 5vh !important;
}
:deep(.el-dialog__header) {
padding: 20px 20px 10px;
}
:deep(.el-dialog__title) {
font-size: 18p
font-weight: bold;
}
</style>
(2)使用组件
<Water1mark
:ref="el => pdfThumbnailRefs[index] = el"
:url="item.base64Image"
@watermarkUpdate="updateWatermark"
:isText="true"
:isPreview="true"
/>
4、自定义组件-PDF水印组件PdfThumbnail,来源:nm-web!!!
附、PDF文件加载后,
A、页面展示多张缩略图
B、在弹窗预览展示中,展示大图
C、在弹窗分页展示中,给选中的缩略图分别添加水印,暴露方法给父组件,供其获取所有的水印图片、合并所有的水印图片为pdf
D、总:PDF组件,分为页面展示和弹窗展示,隐现弹窗时弹窗没有销毁,以前的弹窗数据仍然保留
附、pdfjs-dist各版本发布时间与功能简介
A、1.0.1040,2015-09,1.x正式版,奠定PDF.js核心功能框架(基础渲染、分页、文本选择),兼容IE9+等旧浏览器
B、1.10.100,2018-05,1.x最后一个稳定版,冻结核心功能,仅保留后续安全补丁支持,兼容IE9+
C、2.0.943,2019-07,2.x正式发布,重构渲染引擎,提升性能,支持更多PDF2.0标准特性,兼容IE11+
D、2.16.105,2022-09,2.x最后一个维护版,保留IE11支持,后续版本(3.x+)放弃IE兼容,仅修复严重安全漏洞
E、3.0.279,2022-01,3.x正式发布版,升级依赖库,重构部分核心模块,首次引入.mjs模块化文件(兼容保留.js)
F、3.11.174,2023-05,3.x最后一个重要维护版,修复安全漏洞,优化PDF表单渲染兼容性
G、4.0.189,2023-07,4.x系列初始测试后的迭代版,主要优化ES模块(.mjs)的加载稳定性
H、4.10.38,2025-01,4.x偏向基础优化的版本,适配后续主流构建工具的兼容性调整
(1)定义组件
<template>
<div class="pdf-thumbnail-container">
<div class="thumbnails-section">
<div v-if="thumbnailList.length === 0" class="empty-state">
<el-empty description="正在上传,请耐心等待......" />
</div>
<div v-else class="thumbnails-flex">
<div
v-for="(thumbnail, index) in thumbnailList"
:key="index"
class="thumbnail-item"
:class="{
selected: selectedPagesNum.includes(thumbnail.pageNum),
watermarked: hasWatermark(thumbnail.pageNum)
}"
@click="togglePageSelection(thumbnail.pageNum)"
>
<div class="thumbnail-image">
<img :src="getThumbnailUrl(thumbnail.pageNum)" :alt="`第${thumbnail.pageNum}页`" />
<div class="preview-btn top-right" v-show='props.isPreview'>
<el-button
size="small"
type="text"
@click.stop="openPreviewDialog(thumbnail.pageNum)"
>
预览
</el-button>
</div>
</div>
<div class="thumbnail-info" v-show='props.isCheckbox'>
<el-checkbox
:model-value="selectedPagesNum.includes(thumbnail.pageNum)"
@click.stop
@change="togglePageSelection(thumbnail.pageNum)"
>
P{{ thumbnail.pageNum }}
</el-checkbox>
</div>
</div>
</div>
</div>
<!-- 预览大图弹窗 -->
<el-dialog
v-model="previewDialogVisible"
width="35%"
top="5vh"
:show-close="false"
:close-on-click-modal="true"
@close="handlePreviewDialogClose"
>
<div class="preview-dialog-content">
<div class="large-preview-container">
<div v-if="showPreviewLoading" class="loading-container">
<el-icon class="loading-icon"><Loading /></el-icon>
<span class="loading-text">大图加载中...</span>
</div>
<img
v-else
:src="currentPreviewImage"
alt="预览大图"
class="large-preview-image"
@click="previewImageClick"
/>
</div>
<!-- 添加翻页按钮 -->
<div class="preview-navigation">
<el-button
:disabled="currentPreviewPageNum <= 1"
@click="showPrevPreviewPage"
class="nav-btn"
type="text"
>
<el-icon><ArrowLeft /></el-icon>
上一页
</el-button>
<div class="page-indicator">
第 {{ currentPreviewPageNum }} 页 / 共 {{ thumbnailList.length }} 页
</div>
<el-button
:disabled="currentPreviewPageNum >= thumbnailList.length"
@click="showNextPreviewPage"
class="nav-btn"
type="text"
>
下一页
<el-icon><ArrowRight /></el-icon>
</el-button>
</div>
</div>
<template #title>
<div class="position-fixed">
<div class="padding">{{ props.title|| ('第'+ currentPreviewPageNum +'页预览') }}</div>
<div>
<el-button
type="primary"
@click="downloadPreviewImage"
:loading="previewDownloading"
>下载</el-button>
<el-button @click="previewDialogVisible = false">关闭</el-button>
</div>
</div>
</template>
</el-dialog>
<!-- 添加水印弹窗 -->
<el-dialog
v-model="watermarkDialogVisible"
title="添加水印"
width="1000px"
:close-on-click-modal="false"
>
<!-- 水印设置 -->
<div class="watermark-dialog-content">
<!-- 预览区域 -->
<div class="preview-panel">
<div class="preview-container">
<div v-if="showWatermarkLoading" class="loading-container">
<el-icon class="loading-icon"><Loading /></el-icon>
<span class="loading-text">图片加载中...</span>
</div>
<img
v-else
:src="currentWatermarkImage"
alt="预览图"
class="preview-image"
/>
</div>
<div class="pagination">
<el-button
:disabled="watermarkSelectedIndex === 0"
@click="showPrevPreview"
class="arrow-btn"
type="text"
>
<el-icon><ArrowLeft /></el-icon>
</el-button>
<div class="page-counter">
{{ watermarkSelectedIndex + 1 }} / {{ selectedPagesNum.length }}
</div>
<el-button
:disabled="watermarkSelectedIndex === selectedPagesNum.length - 1"
@click="showNextPreview"
class="arrow-btn"
type="text"
>
<el-icon><ArrowRight /></el-icon>
</el-button>
</div>
</div>
<!-- 设置区域 -->
<div class="settings-panel">
<el-form :model="usedWatermarkSettings" label-width="100px" label-position="top">
<el-form-item label="水印信息">
<el-input
v-model="usedWatermarkSettings.text"
placeholder="请输入水印信息"
type="textarea"
:rows="4"
:maxlength="50"
show-word-limit
/>
</el-form-item>
<el-form-item label="颜色">
<el-color-picker v-model="usedWatermarkSettings.color" show-alpha />
</el-form-item>
<el-form-item label="字号">
<el-slider v-model="usedWatermarkSettings.fontSize" :min="5" :max="120" />
</el-form-item>
<el-form-item label="旋转">
<el-slider v-model="usedWatermarkSettings.rotate" :min="-180" :max="180" />
</el-form-item>
<el-form-item label="行距">
<el-slider v-model="usedWatermarkSettings.lineSpace" :min="1" :max="5" :step="0.1" />
</el-form-item>
<el-form-item label="偏移">
<el-space>
<el-input-number v-model="usedWatermarkSettings.left" placeholder="左偏移" controls-position="right" />
<el-input-number v-model="usedWatermarkSettings.top" placeholder="上偏移" controls-position="right" />
</el-space>
</el-form-item>
</el-form>
<div class="apply-btn">
<el-button
type="primary"
@click="handleSave"
:loading="loadingUp"
>确定</el-button>
</div>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, watch, nextTick } from 'vue'
import * as pdfjsLib from 'pdfjs-dist'
import { jsPDF } from 'jspdf' //"jspdf": "^3.0.4",
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, ArrowRight, CircleCheck, Loading } from '@element-plus/icons-vue'
import useUserStore from '@/store/modules/user';
const user = useUserStore().userInfo;
const watermarkText = user.realName + ' ' + user.userName;
const http = 'http://10.71.33.88'; //pdf文件的服务器存放地址!!!
/* ==================== 1、配置pdf.js ==================== */
pdfjsLib.GlobalWorkerOptions.workerSrc = import.meta.env.BASE_URL + 'pdfjs/pdf.worker.min.js'
//配置pdf.js说明,我已经把下面(1)处的文件复制到(2)处,你只需拉下代码即可
//目前使用的pdfjs-dist@3.11.174是本项目能运行的最高版本,不要试图使用此后的版本
//(1)C:\Users\Haier\Desktop\nm-web\node_modules\pdfjs-dist\legacy\build\pdf.worker.min.js
//(2)C:\Users\Haier\Desktop\nm-web\public\pdfjs\pdf.worker.min.js
/* ==================== 2、定义全局属性、变量、方法 ==================== */
//定义属性
const props = defineProps({
// PDF的URL
url: {
type: String,
required: true
},
// 下载文件名
name: {
type: String,
default: ''
},
// 弹窗标题
title: {
type: String,
default: ''
},
// 是否可以预览
isPreview: {
type: Boolean,
default: true
},
// 是否添加水印
isText: {
type: Boolean,
default: true
},
// 是否显示复选框
isCheckbox: {
type: Boolean,
default: true
},
})
//pdf文件的基本信息
const pdfInfo = reactive({
name: '', //PDF文件名
totalPages: 0 //PDF总页数
})
//默认水印配置
const defaultWatermarkSettings = {
text: props.isText ? watermarkText : '', //默认水印文字
color: '#999999', //默认水印颜色
fontSize: 30, //默认字体大小
rotate: -30, //默认旋转角度
lineSpace: 1, //默认行间距
left: 0, //默认水平偏移
top: 0 //默认垂直偏移
}
//当前使用的水印配置 - 所有页面都使用这个配置
const usedWatermarkSettings = reactive({ ...defaultWatermarkSettings })
//定义存储器
const thumbnailList = ref([]) //存储所有缩略图(用于页面展示)
const allThumbnails = ref(new Map()) //存储所有原始图片的元数据(用于生成页面缩略图),key为页码,value包含宽、高、dataURL
const allWatermarkImg = ref(new Map()) //存储所有原始图片的副本(用于生成水印弹窗展示图),key为页码,value为canvas对象
const allPreviewCanvases = ref(new Map()) //存储所有缩略图的预览图(用于生成预览弹窗展示图),key为页码,value为canvas对象
const selectedPagesNum = ref([]) //存储已勾选的缩略图页码
const watermarkedCanvases = ref(new Map()) //存储已添加水印的缩略图,key为页码,value为已添加水印的canvas对象
const watermarkedPagesNum = ref(new Set()) //存储已添加水印的缩略图页码
//给当前canvas添加水印 - 始终使用统一的watermarkSettings
const addWatermarkToCanvas = (context, canvasWidth, canvasHeight) => {
const { text, color, fontSize, rotate, lineSpace, left, top } = usedWatermarkSettings
if (!text || text.trim() === '') return //如果没有水印文字,不添加水印
//设置水印样式
context.fillStyle = color
context.font = `${fontSize}px Arial`
context.textAlign = 'left'
context.textBaseline = 'middle'
//处理换行文本 - 将文本按换行符分割成多行
const lines = text.split('\n').filter(line => line.trim() !== '')
if (lines.length === 0) return
//计算每行文本的宽度并找出最长的一行
let maxLineWidth = 0
lines.forEach(line => {
const lineWidth = context.measureText(line.trim()).width
if (lineWidth > maxLineWidth) {
maxLineWidth = lineWidth
}
})
//计算单行文本高度(包括行间距)
const textHeight = fontSize * lineSpace
//const lineSpacing = textHeight * (lines.length+1) //使用配置的行距或默认值
const lineSpacing = 300 //使用配置的行距或默认值
//保存当前状态
context.save()
//应用偏移和旋转
context.translate(canvasWidth / 2 + left, canvasHeight / 2 + top)
context.rotate(rotate * Math.PI / 180)
//绘制网格水印 - 使用最长行宽度的1.5倍作为水平间距
const horizontalGap = maxLineWidth * 1.5
for (let x = -canvasWidth; x < canvasWidth * 2; x += horizontalGap) {
for (let y = -canvasHeight; y < canvasHeight * 2; y += lineSpacing) {
//绘制多行文本
lines.forEach((line, index) => {
const yOffset = y + index * textHeight
context.fillText(line.trim(), x, yOffset)
})
}
}
//恢复状态
context.restore()
}
/* ==================== 3、页面初始化 ==================== */
//加载PDF文档
const loadPdfDocument = async () => {
if (!props.url) return
try {
let pdfUrl = props.url
/* const isDev = import.meta.env.DEV */
const isDev = process.env.NODE_ENV === 'development';
if (isDev && pdfUrl.includes(http)) {
pdfUrl = pdfUrl.replace(http, '/api')
}
const fileName = pdfUrl.split('/').pop() || '证照.pdf'
pdfInfo.name = fileName
pdfInfo.totalPages = 0
// 重置状态
thumbnailList.value = []
allThumbnails.value.clear()
allWatermarkImg.value.clear()
selectedPagesNum.value = []
// 重置水印状态
watermarkedPagesNum.value.clear()
watermarkedCanvases.value.clear()
allPreviewCanvases.value.clear()
const pdfDoc = await pdfjsLib.getDocument(pdfUrl).promise
pdfInfo.totalPages = pdfDoc.numPages
const displayPages = pdfDoc.numPages
// 生成所有缩略图
for (let pageNum = 1; pageNum <= displayPages; pageNum++) {
await generateThumbnail(pdfDoc, pageNum)
}
// 如果默认有水印文字,为所有页面添加水印
if (usedWatermarkSettings.text && usedWatermarkSettings.text.trim() !== '') {
for (let pageNum = 1; pageNum <= displayPages; pageNum++) {
await addWatermarkToPage(pageNum)
}
}
} catch (error) {
console.error('PDF处理错误:', error)
ElMessage.error('PDF文件处理失败')
}
}
//生成缩略图
const generateThumbnail = async (pdfDoc, pageNum) => {
try {
// 获取PDF页面
const page = await pdfDoc.getPage(pageNum)
const viewport = page.getViewport({ scale: 0.2 })
// 创建canvas
const eleCanvas = document.createElement('canvas')
const context = eleCanvas.getContext('2d')
eleCanvas.width = viewport.width
eleCanvas.height = viewport.height
// 渲染页面
await page.render({
canvasContext: context,
viewport: viewport
}).promise
// 保存原始缩略图信息(不带水印)
thumbnailList.value.push({
pageNum,
dataUrl: eleCanvas.toDataURL()
})
allThumbnails.value.set(pageNum, {
width: eleCanvas.width,
height: eleCanvas.height,
dataUrl: eleCanvas.toDataURL()
})
// 创建高质量副本用于水印编辑
const highQualityCanvas = document.createElement('canvas')
const highQualityViewport = page.getViewport({ scale: 1.0 })
highQualityCanvas.width = highQualityViewport.width
highQualityCanvas.height = highQualityViewport.height
const highQualityContext = highQualityCanvas.getContext('2d')
await page.render({
canvasContext: highQualityContext,
viewport: highQualityViewport
}).promise
allWatermarkImg.value.set(pageNum, highQualityCanvas)
} catch (error) {
console.error(`生成第${pageNum}页缩略图失败:`, error)
ElMessage.error(`第${pageNum}页缩略图生成失败`)
}
}
// 为指定页面添加水印
const addWatermarkToPage = async (pageNum) => {
// 如果没有水印文字,直接返回,不添加水印
if (!usedWatermarkSettings.text || usedWatermarkSettings.text.trim() === '') {
return
}
try {
// 获取高质量原始图片
const canvas = allWatermarkImg.value.get(pageNum)
if (!canvas) {
console.error(`第${pageNum}页的高质量图片不存在`)
return
}
// 创建新canvas用于添加水印
const watermarkedCanvas = document.createElement('canvas')
watermarkedCanvas.width = canvas.width
watermarkedCanvas.height = canvas.height
const context = watermarkedCanvas.getContext('2d')
// 先绘制原始内容
context.drawImage(canvas, 0, 0, canvas.width, canvas.height)
// 给当前canvas添加水印
addWatermarkToCanvas(context, watermarkedCanvas.width, watermarkedCanvas.height)
// 存储已添加水印的canvas对象
watermarkedCanvases.value.set(pageNum, watermarkedCanvas)
watermarkedPagesNum.value.add(pageNum)
// 更新高质量图片副本
allWatermarkImg.value.set(pageNum, watermarkedCanvas)
// 更新缩略图显示
updateThumbnailDisplay(pageNum)
} catch (error) {
console.error(`为第${pageNum}页添加水印失败:`, error)
}
}
//点击缩略图
const togglePageSelection = (pageNum) => {
const index = selectedPagesNum.value.indexOf(pageNum)
if (index === -1) {
selectedPagesNum.value.push(pageNum)
} else {
selectedPagesNum.value.splice(index, 1)
}
}
const getCurrentSelectionStatus = () => {
return {
selectedPages: [...selectedPagesNum.value],
selectedCount: selectedPagesNum.value.length
}
}
/* ==================== 4、预览大图弹窗 ==================== */
const previewDialogVisible = ref(false)
const largePreviewLoading = ref(false)
const currentPreviewPageNum = ref(1) //当前预览弹窗图片在缩略图中的页码
// 处理弹窗关闭
const handlePreviewDialogClose = () => {
largePreviewLoading.value = false
}
// 打开预览大图弹窗
const openPreviewDialog = async (pageNum) => {
try {
currentPreviewPageNum.value = pageNum
previewDialogVisible.value = true
// 等待弹窗完全打开
await nextTick()
// 生成高质量预览大图
await navigateToPreviewPage(pageNum)
} catch (error) {
console.error('打开预览弹窗失败:', error)
ElMessage.error('打开预览失败:' + error.message)
previewDialogVisible.value = false
}
}
//生成预览大图
const generateLargePreview = async (pageNum) => {
if (!props.url) {
largePreviewLoading.value = false
ElMessage.error('PDF URL为空')
return
}
// 如果已经生成过,直接返回
if (allPreviewCanvases.value.has(pageNum)) {
return true
}
try {
largePreviewLoading.value = true
let pdfUrl = props.url
/* const isDev = import.meta.env.DEV */
const isDev = process.env.NODE_ENV === 'development';
if (isDev && pdfUrl.includes(http)) {
pdfUrl = pdfUrl.replace(http, '/api')
}
const pdfDoc = await pdfjsLib.getDocument(pdfUrl).promise
const page = await pdfDoc.getPage(pageNum)
const viewport = page.getViewport({ scale: 2.0 })
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')
canvas.width = viewport.width
canvas.height = viewport.height
await page.render({
canvasContext: context,
viewport: viewport
}).promise
// 如果有水印文字,并且该页面已经有水印,才添加水印
if (usedWatermarkSettings.text && usedWatermarkSettings.text.trim()!=='' && watermarkedPagesNum.value.has(pageNum)){
addWatermarkToCanvas(context, canvas.width, canvas.height)
}
allPreviewCanvases.value.set(pageNum, canvas)
return true
} catch (error) {
console.error('生成预览大图失败:', error)
ElMessage.error('预览大图生成失败:' + error.message)
return false
} finally {
largePreviewLoading.value = false
}
}
// 显示上一页预览大图
const showPrevPreviewPage = async () => {
if (currentPreviewPageNum.value > 1) {
const newPageNum = currentPreviewPageNum.value - 1
await navigateToPreviewPage(newPageNum)
}
}
// 显示下一页预览大图
const showNextPreviewPage = async () => {
if (currentPreviewPageNum.value < thumbnailList.value.length) {
const newPageNum = currentPreviewPageNum.value + 1
await navigateToPreviewPage(newPageNum)
}
}
// 导航到指定页码的预览图
const navigateToPreviewPage = async (pageNum) => {
try {
// 更新当前页码
currentPreviewPageNum.value = pageNum
// 检查是否需要生成预览大图
if (!allPreviewCanvases.value.has(pageNum)) {
await generateLargePreview(pageNum)
}
// 等待UI更新
await nextTick()
} catch (error) {
console.error('切换预览页面失败:', error)
ElMessage.error('切换页面失败:' + error.message)
}
}
// 处理图片点击事件(可选:点击图片左右区域翻页)
const previewImageClick = (event) => {
const imageRect = event.target.getBoundingClientRect()
const clickX = event.clientX - imageRect.left
const imageWidth = imageRect.width
// 点击左三分之一区域:上一页
if (clickX < imageWidth / 3) {
showPrevPreviewPage()
}
// 点击右三分之一区域:下一页
else if (clickX > imageWidth * 2 / 3) {
showNextPreviewPage()
}
}
//当前预览大图弹窗图片加载
const showPreviewLoading = computed(() => {
return largePreviewLoading.value || !currentPreviewImage.value
})
//当前预览大图弹窗图片
const currentPreviewImage = computed(() => {
const canvas = allPreviewCanvases.value.get(currentPreviewPageNum.value)
if (canvas) {
return canvas.toDataURL() //返回预览大图的dataURL
}
return getThumbnailUrl(currentPreviewPageNum.value) //如果没有预览大图,返回缩略图URL
})
//当前预览大图弹窗图片尺寸
const currentPreviewSize = computed(() => {
const canvas = allPreviewCanvases.value.get(currentPreviewPageNum.value)
if (canvas) {
return `${canvas.width} × ${canvas.height} 像素` //返回图片尺寸信息
}
const thumbnail = allThumbnails.value.get(currentPreviewPageNum.value)
if (thumbnail) {
return `${thumbnail.width} × ${thumbnail.height} 像素`
}
return '未知尺寸' //默认返回未知尺寸
})
//获取缩略图URL(优先返回带水印的缩略图)
const getThumbnailUrl = (pageNum) => {
const thumbnail = thumbnailList.value.find(t => t.pageNum === pageNum)
if (!thumbnail) return ''
// 如果有水印版本,返回水印版本
if (hasWatermark(pageNum)) {
const watermarkedCanvas = watermarkedCanvases.value.get(pageNum)
if (watermarkedCanvas) {
// 创建缩略图尺寸的canvas
const thumbCanvas = document.createElement('canvas')
const pastImage = allThumbnails.value.get(pageNum)
if (pastImage) {
thumbCanvas.width = pastImage.width
thumbCanvas.height = pastImage.height
const context = thumbCanvas.getContext('2d')
context.drawImage(
watermarkedCanvas,
0, 0, watermarkedCanvas.width, watermarkedCanvas.height,
0, 0, thumbCanvas.width, thumbCanvas.height
)
return thumbCanvas.toDataURL()
}
}
}
//返回原始缩略图
return thumbnail.dataUrl
}
//下载预览图片
const previewDownloading = ref(false) //下载状态,控制下载按钮的loading效果
const downloadPreviewImage = async () => {
const canvas = allPreviewCanvases.value.get(currentPreviewPageNum.value)
if (!canvas) {
ElMessage.warning('暂无预览图片可下载')
return
}
try {
previewDownloading.value = true
const link = document.createElement('a')
link.download = `第${currentPreviewPageNum.value}张图片.png`
link.href = canvas.toDataURL('image/png')
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
ElMessage.success('图片下载成功')
} catch (error) {
console.error('下载图片失败:', error)
ElMessage.error('图片下载失败')
} finally {
previewDownloading.value = false
}
}
/* ==================== 5、添加水印弹窗 ==================== */
const watermarkDialogVisible = ref(false)
const imgLoadingForWatermark = ref(false)
const watermarkSelectedIndex = ref(0) //当前水印弹窗图片在被勾选图中的索引
//打开添加水印弹窗
const openWatermarkDialog = async () => {
if (selectedPagesNum.value.length === 0) return
selectedPagesNum.value.sort((a, b) => a - b)
watermarkSelectedIndex.value = 0
watermarkDialogVisible.value = true
// 重置为默认水印配置
Object.assign(usedWatermarkSettings, defaultWatermarkSettings)
await getImgForAddWatermark()
}
//获取图片为添加水印做准备
const getImgForAddWatermark = async () => {
if (!props.url) return
try {
imgLoadingForWatermark.value = true //开始加载
const pdfDoc = await pdfjsLib.getDocument(props.url).promise
for (const pageNum of selectedPagesNum.value) { //遍历所有已选中的PDF缩略图
//如果图片原本高清就跳过,否则就强化且替掉原图
if (allWatermarkImg.value.get(pageNum)?.width > 800) continue
const page = await pdfDoc.getPage(pageNum)
const viewport = page.getViewport({ scale: 1.5 }) //提高预览质量!!!
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')
canvas.width = viewport.width
canvas.height = viewport.height
await page.render({ //把pdf页渲染到canvas上
canvasContext: context,
viewport: viewport
}).promise
// 如果该页面已有水印,先添加水印
if (watermarkedPagesNum.value.has(pageNum)) {
const watermarkedCanvas = watermarkedCanvases.value.get(pageNum)
if (watermarkedCanvas) {
allWatermarkImg.value.set(pageNum, watermarkedCanvas)
continue
}
}
allWatermarkImg.value.set(pageNum, canvas)
}
} catch (error) {
console.error('生成高质量预览图失败:', error)
} finally {
imgLoadingForWatermark.value = false //加载完成
}
}
//当前添加水印弹窗图片在缩略图中的页码
const watermarkThumbnailPageNum = computed(() => {
return selectedPagesNum.value[watermarkSelectedIndex.value]
})
//当前添加水印弹窗图片加载
const showWatermarkLoading = computed(() => {
return imgLoadingForWatermark.value || !currentWatermarkImage.value
})
//当前添加水印弹窗图片
const currentWatermarkImage = computed(() => {
// 获取当前页面的图片
const currentPageNum = watermarkThumbnailPageNum.value
const watermarkedCanvas = watermarkedCanvases.value.get(currentPageNum)
if (watermarkedCanvas) {
return watermarkedCanvas.toDataURL() //返回带水印图片的dataURL
}
const canvas = allWatermarkImg.value.get(currentPageNum)
return canvas ? canvas.toDataURL() : '' //返回原始图片的dataURL或空字符串
})
//检查页面是否有水印
const hasWatermark = (pageNum) => {
return watermarkedPagesNum.value.has(pageNum)
}
//上一页预览
const showPrevPreview = () => {
if (watermarkSelectedIndex.value > 0) {
watermarkSelectedIndex.value--
// 翻页时直接将最新的水印设置应用到当前图片
applyWatermarkToCurrentPage()
}
}
//下一页预览
const showNextPreview = () => {
if (watermarkSelectedIndex.value < selectedPagesNum.value.length - 1) {
watermarkSelectedIndex.value++
// 翻页时直接将最新的水印设置应用到当前图片
applyWatermarkToCurrentPage()
}
}
//将水印应用到当前页面
const applyWatermarkToCurrentPage = async () => {
const currentPageNum = selectedPagesNum.value[watermarkSelectedIndex.value]
try {
// 清除旧水印(如果存在)
if (hasWatermark(currentPageNum)) {
watermarkedPagesNum.value.delete(currentPageNum)
watermarkedCanvases.value.delete(currentPageNum)
allPreviewCanvases.value.delete(currentPageNum) //清除预览大图缓存
}
// 重新生成高质量原始图片
const currCanvas = await generateHighQualityPage(currentPageNum)
if (!currCanvas) return
// 如果没有水印文字,直接保存原始图片,不添加水印
if (!usedWatermarkSettings.text || usedWatermarkSettings.text.trim() === '') {
allWatermarkImg.value.set(currentPageNum, currCanvas)
// 更新缩略图显示
updateThumbnailDisplay(currentPageNum)
return
}
// 创建新canvas用于添加水印,保持高质量尺寸
const watermarkedCanvas = document.createElement('canvas')
watermarkedCanvas.width = currCanvas.width
watermarkedCanvas.height = currCanvas.height
const context = watermarkedCanvas.getContext('2d')
// 先绘制原始内容
context.drawImage(currCanvas, 0, 0)
// 给当前canvas添加水印
addWatermarkToCanvas(context, watermarkedCanvas.width, watermarkedCanvas.height)
// 存储已添加水印的canvas对象
watermarkedCanvases.value.set(currentPageNum, watermarkedCanvas)
watermarkedPagesNum.value.add(currentPageNum)
// 更新高质量图片副本
allWatermarkImg.value.set(currentPageNum, watermarkedCanvas)
// 更新缩略图显示
updateThumbnailDisplay(currentPageNum)
} catch (error) {
console.error('添加水印失败:', error)
ElMessage.error('水印添加失败')
}
}
//生成高质量页面
const generateHighQualityPage = async (pageNum) => {
if (!props.url) return null
try {
const pdfDoc = await pdfjsLib.getDocument(props.url).promise
const page = await pdfDoc.getPage(pageNum)
const viewport = page.getViewport({ scale: 1.5 }) //使用高质量缩放!!!
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')
canvas.width = viewport.width
canvas.height = viewport.height
await page.render({ //把pdf页渲染到canvas上
canvasContext: context,
viewport: viewport
}).promise
return canvas
} catch (error) {
console.error('生成高质量页面失败:', error)
return null
}
}
//更新缩略图显示
const updateThumbnailDisplay = (pageNum) => {
const thumbnailIndex = thumbnailList.value.findIndex(t => t.pageNum === pageNum)
if (thumbnailIndex !== -1) {
//触发重新渲染
thumbnailList.value = [...thumbnailList.value]
}
}
/* ==================== 6、监听与生命周期 ==================== */
// 修改watch,监听URL变化!!!
watch(() => props.url, (newUrl) => {
if (newUrl) {
loadPdfDocument()
}
})
// 监听水印设置变化,实时应用到当前预览图片
watch(usedWatermarkSettings, () => {
// 只有当水印弹窗打开时才实时应用水印
if (watermarkDialogVisible.value && watermarkSelectedIndex.value >= 0) {
applyWatermarkToCurrentPage()
}
}, { deep: true })
//组件挂载时加载PDF
onMounted(() => {
if (props.url) {
loadPdfDocument()
}
})
/* ==================== 7、暴露给父组件且仅在父组件使用 ==================== */
const selectedPagesCount = computed(() => selectedPagesNum.value.length)
//更新缩略图显示
const getWatermarkedSelectedImages = () => {
//找出既被选中又有水印的页面
const targetPages = selectedPagesNum.value.filter(page => watermarkedPagesNum.value.has(page))
const images = []
for (const pageNum of targetPages) {
const canvas = watermarkedCanvases.value.get(pageNum)
if (canvas) {
images.push({
pageNum,
dataUrl: canvas.toDataURL('image/png'),
width: canvas.width,
height: canvas.height,
fileName: `已加水印_第${pageNum}页.png`
})
}
}
return images
}
//生成并下载PDF
const getWatermarkedPDF = async () => {
try {
let images = []
let fileName = ''
// 判断逻辑:如果usedWatermarkSettings.text存在,下载所有图片,否则走原来的逻辑
if (usedWatermarkSettings.text && usedWatermarkSettings.text.trim() !== '') {
// text属性存在:下载所有图片
ElMessage.info(`正在为所有${thumbnailList.value.length}页生成PDF...`)
// 按页码排序
const sortedPages = thumbnailList.value
.map(t => t.pageNum)
.sort((a, b) => a - b)
// 获取所有页面的高质量图片
const imagePromises = sortedPages.map(async (pageNum) => {
// 尝试获取已存在的水印图片
const watermarkedCanvas = watermarkedCanvases.value.get(pageNum)
if (watermarkedCanvas) {
return {
pageNum,
dataUrl: watermarkedCanvas.toDataURL('image/png'),
width: watermarkedCanvas.width,
height: watermarkedCanvas.height,
fileName: `第${pageNum}页_${usedWatermarkSettings.text}.png`
}
}
// 如果没有水印图片,生成高质量图片并添加水印
const canvas = await generateHighQualityPage(pageNum)
if (canvas) {
// 添加水印
const context = canvas.getContext('2d')
addWatermarkToCanvas(context, canvas.width, canvas.height)
// 存储水印图片
watermarkedCanvases.value.set(pageNum, canvas)
watermarkedPagesNum.value.add(pageNum)
return {
pageNum,
dataUrl: canvas.toDataURL('image/png'),
width: canvas.width,
height: canvas.height,
fileName: `第${pageNum}页_${usedWatermarkSettings.text}.png`
}
}
return null
})
// 等待所有图片准备完成
const imageResults = await Promise.all(imagePromises)
images = imageResults.filter(img => img !== null)
if (images.length === 0) {
ElMessage.warning('没有找到可用的图片数据')
return
}
fileName = `${props.name||'pdf文件'}.pdf`
} else {
// text属性不存在:走原来的逻辑(只下载已勾选且有水印的图片)
const watermarkedSelectedImages = getWatermarkedSelectedImages()
if (watermarkedSelectedImages.length === 0) {
ElMessage.warning('没有找到已勾选且已添加水印的图片')
return
}
ElMessage.info('正在为已选中的水印图片生成PDF...')
images = watermarkedSelectedImages
images.sort((a, b) => a.pageNum - b.pageNum)
fileName = `${props.name||'pdf文件'}.pdf`
}
// 创建新的PDF文档
const pdf = new jsPDF()
// 遍历所有图片,添加到PDF
for (let i = 0; i < images.length; i++) {
const image = images[i]
// 显示进度提示(特别是处理大量页面时)
if (images.length > 5 && (i === 0 || i === images.length - 1 || i % 5 === 0)) {
ElMessage.info(`正在处理第${i + 1}/${images.length}页...`)
}
// 将dataURL转换为Image对象
const img = new Image()
img.src = image.dataUrl
// 等待图片加载
await new Promise((resolve) => {
img.onload = resolve //当图片成功加载时,调用resolve()函数,即await结束,继续执行后面代码
img.onerror = () => {
console.error(`第${image.pageNum}页图片加载失败`)
ElMessage.warning(`第${image.pageNum}页图片加载失败,已跳过`)
resolve()
}
})
// 获取图片尺寸
const imgWidth = img.width
const imgHeight = img.height
// PDF页面尺寸 (A4)
const pdfWidth = pdf.internal.pageSize.getWidth()
const pdfHeight = pdf.internal.pageSize.getHeight()
// 计算缩放比例,保持图片比例
const ratio = Math.min(pdfWidth / imgWidth, pdfHeight / imgHeight)
const width = imgWidth * ratio * 0.95 // 稍微缩小一点,留出边距
const height = imgHeight * ratio * 0.95
// 计算居中位置
const x = (pdfWidth - width) / 2
const y = (pdfHeight - height) / 2
// 添加图片到PDF。!!!非常重要1/3
pdf.addImage(img, 'PNG', x, y, width, height)
// 如果不是最后一页,添加新页面。非常重要2/3
if (i < images.length - 1) {
pdf.addPage()
}
}
// 下载PDF。非常重要3/3
pdf.save(fileName)
// 显示成功信息
if (usedWatermarkSettings.text && usedWatermarkSettings.text.trim() !== '') {
ElMessage.success(`PDF生成成功!共${images.length}页,已全部添加水印`)
} else {
ElMessage.success(`PDF生成成功!共${images.length}张水印图片`)
}
} catch (error) {
console.error('生成PDF失败:', error)
// 更详细的错误提示
if (error.message && error.message.includes('超过最大尺寸')) {
ElMessage.error('图片尺寸过大,请尝试减少页面数量')
} else if (error.message && error.message.includes('内存')) {
ElMessage.error('处理图片时内存不足,请尝试分批处理')
} else {
ElMessage.error('PDF生成失败:' + (error.message || '未知错误'))
}
}
}
//生成并发送PDF的dataURI!!!
const emit = defineEmits(['watermarkUpdate']);
const loadingUp = ref(false)
const handleSave = async () => {
if (selectedPagesNum.value.length === 0) {
ElMessage.warning('请先选择图片')
return
}
try {
loadingUp.value = true
// 为所有选中的页面应用当前水印设置
for (const pageNum of selectedPagesNum.value) {
// 清除旧水印(如果存在)
if (hasWatermark(pageNum)) {
watermarkedPagesNum.value.delete(pageNum)
watermarkedCanvases.value.delete(pageNum)
allPreviewCanvases.value.delete(pageNum)
}
// 重新生成高质量原始图片
const currCanvas = await generateHighQualityPage(pageNum)
if (!currCanvas) continue
// 如果没有水印文字,直接保存原始图片,不添加水印
if (!usedWatermarkSettings.text || usedWatermarkSettings.text.trim() === '') {
allWatermarkImg.value.set(pageNum, currCanvas)
// 更新缩略图显示
updateThumbnailDisplay(pageNum)
continue
}
// 创建新canvas用于添加水印
const watermarkedCanvas = document.createElement('canvas')
watermarkedCanvas.width = currCanvas.width
watermarkedCanvas.height = currCanvas.height
const context = watermarkedCanvas.getContext('2d')
// 先绘制原始内容
context.drawImage(currCanvas, 0, 0)
// 给当前canvas添加水印
addWatermarkToCanvas(context, watermarkedCanvas.width, watermarkedCanvas.height)
// 存储已添加水印的canvas对象
watermarkedCanvases.value.set(pageNum, watermarkedCanvas)
watermarkedPagesNum.value.add(pageNum)
// 更新高质量图片副本
allWatermarkImg.value.set(pageNum, watermarkedCanvas)
// 更新缩略图显示
updateThumbnailDisplay(pageNum)
}
// 更新当前预览图片
const currentPageNum = selectedPagesNum.value[watermarkSelectedIndex.value]
if (watermarkedCanvases.value.has(currentPageNum)) {
// 触发重新渲染
await nextTick()
}
} catch (error) {
console.error('批量添加水印失败:', error)
ElMessage.error('批量添加水印失败')
}
try {
// 1. 获取所有已勾选且已添加水印的页面
// 找出既被选中又有水印的页面
const watermarkedSelectedPages = selectedPagesNum.value.filter(pageNum =>
watermarkedPagesNum.value.has(pageNum)
)
if (watermarkedSelectedPages.length === 0) {
throw new Error('没有找到已勾选且已添加水印的图片')
}
watermarkedSelectedPages.sort((a, b) => a - b)
// 2. 获取所有水印图片的高质量数据
const images = []
for (const pageNum of watermarkedSelectedPages) {
// 尝试获取已存在的水印图片
const canvas = watermarkedCanvases.value.get(pageNum)
if (canvas) {
images.push({
pageNum,
dataUrl: canvas.toDataURL('image/png'),
width: canvas.width,
height: canvas.height,
fileName: `已加水印_第${pageNum}页.png`
})
continue
}
}
if (images.length === 0) {
throw new Error('无法获取水印图片数据')
}
// 3. 创建PDF文档
const pdf = new jsPDF()
for (let i = 0; i < images.length; i++) {
const image = images[i]
// 创建图片对象
const img = new Image()
img.src = image.dataUrl
// 等待图片加载
await new Promise((resolve) => {
img.onload = resolve //当图片成功加载时,调用resolve()函数,即await结束,代码继续执行
img.onerror = () => {
console.error(`第${image.pageNum}页图片加载失败`)
ElMessage.warning(`第${image.pageNum}页图片加载失败,已跳过`)
resolve()
}
})
// PDF页面尺寸 (A4)
const pdfWidth = pdf.internal.pageSize.getWidth()
const pdfHeight = pdf.internal.pageSize.getHeight()
// 计算缩放比例,保持图片比例
const ratio = Math.min(pdfWidth / img.width, pdfHeight / img.height)
const width = img.width * ratio * 0.95 // 稍微缩小一点,留出边距
const height = img.height * ratio * 0.95
// 计算居中位置
const x = (pdfWidth - width) / 2
const y = (pdfHeight - height) / 2
// 添加图片到PDF。!!!非常重要1/3
pdf.addImage(img, 'PNG', x, y, width, height)
// 如果不是最后一页,添加新页面。非常重要2/3
if (i < images.length - 1) {
pdf.addPage()
}
}
// 4. 获取PDF数据。非常重要3/3
const pdfDataUri = pdf.output('datauristring') // data URI
// 5. 发送事件给父组件
const sizeEstimate = Math.ceil(pdfDataUri.split(',')[1].length * 0.75);
console.log(`pdf.vue页面,第1095行,估算上传pdf文件大小为: ${sizeEstimate/1000/1000} M`);
emit('watermarkUpdate', pdfDataUri)
} catch (error) {
// 发送错误信息给父组件
const errorResult = {
success: false,
error: error.message,
selectedPages: [...selectedPagesNum.value],
watermarkSettings: { ...usedWatermarkSettings }
}
emit('watermarkUpdate', errorResult)
} finally {
loadingUp.value = false
watermarkDialogVisible.value = false
}
}
//暴露给父组件的方法和属性
defineExpose({
openWatermarkDialog,
selectedPagesCount,
getSelectedPages: () => selectedPagesNum.value,
getWatermarkedPages: () => Array.from(watermarkedPagesNum.value),
getWatermarkedSelectedImages,
getWatermarkedPDF,
getCurrentSelectionStatus,
})
</script>
<style scoped>
.position-fixed {
position: fixed;
top: 0;
left: 0 ;
display: flex;
justify-content: space-between;
width: 100%;
background-color: #ffffff;
z-index: 1000;
padding: 10px;
.padding{
padding-top: 6px;
}
}
/* 样式保持不变 */
.pdf-thumbnail-container {
margin: 0 auto;
}
.file-section {
margin-bottom: 30px;
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
}
.file-info {
margin-top: 15px;
padding: 15px;
background: #e7f3ff;
border-radius: 6px;
}
.file-info p {
margin: 5px 0;
color: #606266;
}
.page-notice {
margin-top: 15px;
}
.thumbnails-section {
margin-top: -10px;
}
.empty-state {
text-align: center;
padding: 40px 0;
}
.thumbnails-flex {
display: flex;
flex-direction: flex-start;
flex-wrap: wrap;
}
.thumbnail-item {
cursor: pointer;
transition: all 0.3s ease;
width: 130px;
/* height: 210px; */
margin: 20px;
}
.thumbnail-item:hover {
border-color: #409eff;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.thumbnail-item.selected {
border-color: #409eff;
background: #f0f7ff;
}
.thumbnail-item.watermarked {
border-color: #67c23a;
}
.thumbnail-image {
padding: 24px 12px 12px 12px;
text-align: center;
position: relative;
background: white;
}
.thumbnail-image img {
max-width: 100%;
height: auto;
}
.watermark-badge.bottom-left {
position: absolute;
bottom: 5px;
left: 5px;
}
.preview-btn.top-right {
position: absolute;
top: 2px;
right: 2px;
}
.preview-btn.top-right button{
color: #5321FF !important;
}
.thumbnail-info {
text-align: center;
margin-top: 8px;
}
.watermark-dialog-content {
display: flex;
gap: 30px;
min-height: 600px;
}
.preview-panel {
flex: 2;
display: flex;
flex-direction: column;
}
.preview-container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
border: 1px solid #e4e7ed;
border-radius: 8px;
margin-bottom: 20px;
background: #f8f9fa;
max-height: 600px;
position: relative;
}
.preview-image {
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
object-fit: contain;
}
.preview-watermark-badge {
position: absolute;
top: 10px;
left: 10px;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 20px;
}
.arrow-btn {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
color: #606266;
}
.page-counter {
font-size: 16px;
font-weight: 600;
color: #606266;
min-width: 60px;
text-align: center;
}
.settings-panel {
width: 420px;
display: flex;
flex-direction: column;
}
.apply-btn {
margin-top: auto;
display: flex;
justify-content: flex-start;
gap: 10px;
}
/* 预览大图弹窗样式 */
.preview-dialog-content {
display: flex;
flex-direction: column;
gap: 20px;
}
/* 新增:预览导航样式 */
.preview-navigation {
display: flex;
justify-content: center;
align-items: center;
gap: 30px;
margin-bottom: 20px;
padding: 10px;
background: #f8f9fa;
border-radius: 8px;
}
.nav-btn {
display: flex;
align-items: center;
gap: 8px;
color: #606266;
cursor: pointer;
user-select: none;
}
.nav-btn:disabled {
color: #c0c4cc;
cursor: not-allowed;
}
.nav-btn:not(:disabled):hover {
color: #409eff;
}
.page-indicator {
font-size: 16px;
font-weight: 600;
color: #606266;
min-width: 150px;
text-align: center;
}
.large-preview-container {
display: flex;
justify-content: center;
align-items: center;
background: #f8f9fa;
border-radius: 8px;
padding: 20px;
min-height: 500px;
position: relative;
}
.large-preview-image {
max-width: 100%;
/* max-height: 70vh; */
width: auto;
height: 70vh;/* 原为auto */
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
object-fit: contain;
cursor: pointer; /* 添加点击手势 */
transition: transform 0.3s ease;
}
.large-preview-image:hover {
transform: scale(1.01);
}
.large-preview-watermark-badge {
position: absolute;
top: 20px;
left: 20px;
}
.preview-info {
padding: 15px;
background: #e7f3ff;
border-radius: 6px;
}
.preview-info p {
margin: 5px 0;
color: #606266;
}
.preview-dialog-footer {
display: flex;
justify-content: center;
gap: 15px;
}
:deep(.el-dialog__body) {
padding: 20px;
}
/* 优化弹窗样式 */
:deep(.el-dialog) {
max-width: 90%;
margin-top: 5vh !important;
}
:deep(.el-dialog__header) {
padding: 20px 20px 10px;
}
:deep(.el-dialog__title) {
font-size: 18px;
font-weight: bold;
}
/* 添加loading样式 */
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 70vh;
color: #909399;
}
.loading-icon {
font-size: 40px;
margin-bottom: 16px;
animation: rotating 2s linear infinite;
}
.loading-text {
font-size: 14px;
}
@keyframes rotating {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* 确保预览容器有合适的高度 */
.preview-container,
.large-preview-container {
min-height: 400px;
display: flex;
align-items: center;
justify-content: center;
}
</style>
(2)使用组件
A、使用方式1
<PdfThumbnail
:url="uploadForm.base64Image"
:isCheckbox="false"
/>
const isDev = process.env.NODE_ENV === 'development';
const http2 = isDev ? 'http://10.71.33.88/'(也可以是'abcd') : '/';
uploadForm.base64Image = http2 + res.data.fullUrl;
B、使用方式2
<div class="describe" v-show="!item.load">
<PdfThumbnail
:url="item.base64Image"
:ref="(el) => setPdfRef(item.sid, el)"
:name="'第'+(dataInfo.info.length - index)+'次频道换证证照'"
:title=" '广播电视频道许可证-' + item.channelName "
:isCheckbox="false"
/>
<div style="padding-top: 4px">
<el-button type="primary" link @click="getPDF(item.sid)">下载证照</el-button>
</div>
</div>
import PdfThumbnail from '../base/pdf.vue'
//存储所有PDF组件实例的Map
const pdfRefs = ref(new Map())
//设置ref的函数
const setPdfRef = (sid, el) => {
if (el) {
pdfRefs.value.set(sid, el)
} else {
pdfRefs.value.delete(sid)
}
}
//下载PDF
const getPDF = (sid) => {
const pdfComponent = pdfRefs.value.get(sid)
if (pdfComponent && typeof pdfComponent.getWatermarkedPDF === 'function') {
pdfComponent.getWatermarkedPDF()
} else {
console.error('PDF组件未找到或getWatermarkedPDF方法不存在')
}
}
C、使用方式3
<!-- 查看-待审核 -->
<PdfThumbnail
v-if="isDetail && appInfo.status==0"
ref="pdfThumbnailRef"
:url="appInfo.licenses[0].base64Image"
:isCheckbox="false"
/>
<!-- 查看-已批准 -->
<PdfThumbnail
v-else-if="isDetail && appInfo.status==1"
ref="pdfThumbnailRef"
:url="appInfo.licenses[0].base64Image"
:isText="false"
:isCheckbox="false"
/>
<!-- 查看-已拒绝 -->
<PdfThumbnail
v-else-if="isDetail && appInfo.status==2"
ref="pdfThumbnailRef"
:url="appInfo.licenses[0].base64Image"
:isCheckbox="false"
/>
<!-- 审核 -->
<PdfThumbnail
v-else-if="!isDetail && appInfo.status==0"
ref="pdfThumbnailRef"
:url="appInfo.licenses[0].base64Image"
@watermarkUpdate="updateWatermark"
:isText="false"
/>//pdf组件分为页面展示和弹窗展示两部分组成,隐现弹窗时弹窗没有销毁,以前的弹窗数据仍然保留
//以下pdf相关!!!
import PdfThumbnail from '../base/pdf.vue'
//获取子组件实例
const pdfThumbnailRef = ref(null)
//计算属性
const hasSelectedPages = computed(() => {
return pdfThumbnailRef.value?.selectedPagesCount > 0
})
//打开水印弹窗
const openAddWatermark = () => {
if (pdfThumbnailRef.value) {
pdfThumbnailRef.value.openWatermarkDialog()
}
}
const hasWatermarkedSelected = computed(() => {
return watermarkedSelectedCount.value > 0
})
const watermarkedSelectedCount = computed(() => {
if (!pdfThumbnailRef.value) return 0
const selectedPages = pdfThumbnailRef.value.getSelectedPages?.() || []
const watermarkedPages = pdfThumbnailRef.value.getWatermarkedPages?.() || []
// 找出既被选中又有水印的页面
return selectedPages.filter(page => watermarkedPages.includes(page)).length
})
const updateWatermark = (imageURL) => {//更新水印
showEditWatermark.value = false;
let file = { raw: dataURLtoFile(imageURL, '已添加水印.pdf') }
uploadFileTool(file,'license').then(res => { //上传水印图片
checkAppForm.value.licenses[0].fileId = res.data.fileInfoId;
})
}
function dataURLtoFile(dataUrl, filename) {
var arr = dataUrl.split(','),
mime = arr[0].match(/:(.*?);/)[1],
bstr = atob(arr[1]),
n = bstr.length,
u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new File([u8arr], filename, { type: mime });
}