## 阻断性缺陷 - list.vue 是 0 字节空文件、slist.vue 与 search/search.vue 从未存在, 而前者是 tabBar 首页、后者是 tabBar「搜索」页 —— 开屏即白屏。 按 .nvue 原型与详情页契约重建三页(CSS 渐变主视觉、分类筛选、搜索历史/热搜/联想)。 - parse-image-url.js 对空封面调 undefined.startsWith 直接抛错,列表页整页崩。 - 云函数目录缺 uni-cms-articles / uni-cms-categories / uni-cms-unlock-record schema 与 schema.ext.js,线上内容渲染与解锁逻辑无配置可用。 ## 越权与数据一致性 - uni-cms-articles:del/update/add 全部无鉴权,未登录即可删任意文章、 改他人文章作者与阅读量。补 login + 作者归属校验,作者与计数改为服务端取值。 - comments.likeComment/relikeComment:直接采信客户端传入的 user_id, 可冒名点赞刷计数。改为以令牌为准,并纳入事务。 - comments.updateComment:对数组取 .author_id,权限判断恒失败; 字段名 updateTime 与 ip_location 类型与 schema 不符。 - comments.deleteComment:`!root_id === 0` 优先级错误导致计数恒不减; 且误更新 uni-cms-articles、按不存在的 type 字段删点赞明细产生孤儿数据。 - cms-articles-like/collect:查重条件混入本次请求时间戳,防重永远失效, 可无限重复刷计数;补唯一索引并事务化。 - cms-vote:读-改-写票数导致并发丢票,记录与统计非原子;改为事务 + 原子自增。 - cms-articles-log:忽略传入 user_id 直接返回全表,泄露全站浏览记录。 - article_info:get() 使用未定义变量必崩;读接口全部无鉴权。 - user-info:公开资料接口可查任意用户 last_login_ip。 ## 资源与数据 - 全项目清空失效的签名外链(expire_at 均为 2025-03,必然 403), 改为本地生成资源:6 套文章模板、8 个编辑器图标、2 张文章配图。 - 新增分类 / 模板 / 礼物 / 热搜词种子数据,并在 db_init.json 登记, 同时补上点赞、收藏、投票、浏览日志的唯一索引。 ## 功能 - 草稿箱:预览页拆出「发布」与「存为草稿」,作品列表按状态筛选并显示徽标。 原实现有 4 个 tab 但只有 1 个有内容,且 article_status 在 UI 上无体现。 - 编辑中断恢复:接上原本空实现的「编辑草稿」回调,区分新建与编辑已有文章。 ## 工具 - tools/audit-project.js:编码 / 页面路由 / 云调用 / 云函数鉴权 / 敏感信息检查 - tools/check-vue.js:SFC 脚本语法(词法扫描处理 import·export 与条件编译) - tools/verify.js:一键验证;两个检查器各带自测,防止"永远通过" - tools/gen-*.py:模板与图标资源生成脚本
1397 lines
36 KiB
Vue
1397 lines
36 KiB
Vue
<template>
|
|
<view class="editor-container">
|
|
<view id="fixed-top" class="fixed-top" :class="{ isFixed: isFixed }">
|
|
<button class="btn btn-primary" @click="save">保存</button>
|
|
</view>
|
|
<view class="fixed-top__place"></view>
|
|
<scroll-view scroll-y :style="{ height: scrollViewHeight + 'px' }">
|
|
<editor
|
|
id="editor"
|
|
class="cu-editor"
|
|
:placeholder="placeholder"
|
|
:read-only="readOnly"
|
|
:show-img-size="showImgSize"
|
|
:show-img-toolbar="showImgToolbar"
|
|
:show-img-resize="showImgResize"
|
|
@statuschange="onStatusChange"
|
|
@ready="onEditorReady"
|
|
@input="onEditorInput"
|
|
@focus="onEditorFocus"
|
|
@blur="onEditorBlur"
|
|
></editor>
|
|
</scroll-view>
|
|
<view
|
|
class="fixed-bottom"
|
|
:hidden="!toolbarShow"
|
|
:style="{ bottom: fixedBottom + 'px' }"
|
|
@touchstart.stop=""
|
|
>
|
|
<view class="toolbar selector" :style="{ height: toolBarHeight + 'rpx' }">
|
|
<view class="toolbar-item-header" @touchend.stop="changeKeyBoard"
|
|
><i class="iconfont icon-keyboard"></i>
|
|
</view>
|
|
<view
|
|
v-for="(icon, index) in [
|
|
'icon-add',
|
|
'icon-textformat',
|
|
'icon-align-left',
|
|
]"
|
|
class="toolbar-item"
|
|
@touchend.stop="changeSwiper(index)"
|
|
>
|
|
<i
|
|
class="iconfont"
|
|
:class="[
|
|
icon,
|
|
{ active: toolBarContentShow && swiperCurrent == index },
|
|
]"
|
|
></i>
|
|
</view>
|
|
|
|
<view class="toolbar-item" @touchend.stop="formatformat('check')"
|
|
><i class="iconfont icon-list-check"></i
|
|
></view>
|
|
<view class="toolbar-item" @touchend.stop="formatformat('undo')"
|
|
><i class="iconfont icon-undo"></i>
|
|
</view>
|
|
<view class="toolbar-item" @touchend.stop="formatformat('redo')"
|
|
><i class="iconfont icon-redo"></i>
|
|
</view>
|
|
<view class="toolbar-item-footer" @touchend.stop="hideToolbar"
|
|
><i class="iconfont icon-check"></i>
|
|
</view>
|
|
</view>
|
|
<swiper
|
|
:hidden="!toolBarContentShow"
|
|
class="toolbar-content swiper-box"
|
|
:style="{ height: toolBarContentHeight + 'rpx' }"
|
|
:current="swiperCurrent"
|
|
duration="300"
|
|
>
|
|
<!-- @touchstart.stop="" 禁用手动滑动 -->
|
|
<swiper-item
|
|
v-for="(page, i) in formatArray"
|
|
:key="i"
|
|
class="swiper-item"
|
|
@touchstart.stop=""
|
|
>
|
|
<template v-if="swiperCurrent === 0">
|
|
<view class="feature-items flex">
|
|
<view
|
|
v-for="(pitem, pindex) in page.array"
|
|
:key="pindex"
|
|
class="feature"
|
|
@touchend.stop="formatformat(pitem.name)"
|
|
>
|
|
<view class="icon">
|
|
<text class="iconfont" :class="'icon-' + pitem.icon"></text>
|
|
<text class="iconfont">{{ pitem.title }}</text>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</template>
|
|
|
|
<template v-else-if="swiperCurrent === 1 || swiperCurrent === 2">
|
|
<view
|
|
v-for="(pitem, pindex) in page.array"
|
|
:key="pindex"
|
|
class="tool-items flex"
|
|
>
|
|
<view
|
|
v-for="(item, index) in pitem.items"
|
|
:key="index"
|
|
class="tool-item"
|
|
@touchend.stop="formatformat('format', item, pitem)"
|
|
:class="{
|
|
'ql-active': isActive(item, pitem),
|
|
noBgColor: pitem.name == 'color',
|
|
}"
|
|
>
|
|
<view
|
|
v-if="pitem.name == 'color'"
|
|
class="color-circle"
|
|
:style="{ 'background-color': item.value }"
|
|
></view>
|
|
<i
|
|
v-else-if="pitem.label == 'icon'"
|
|
class="iconfont"
|
|
:class="'icon-' + item.icon"
|
|
></i>
|
|
<text
|
|
v-else
|
|
class="txt"
|
|
:style="[
|
|
{ fontSize: pitem.name == 'fontSize' ? item.value : '' },
|
|
item.style,
|
|
]"
|
|
>{{ item.title || item.value }}</text
|
|
>
|
|
</view>
|
|
</view>
|
|
</template>
|
|
|
|
<template v-if="swiperCurrent === 3">
|
|
<view class="temps">
|
|
<view class="title">背景模板</view>
|
|
<radio-group>
|
|
<label
|
|
v-for="(t, ti) in templateConfig"
|
|
:key="ti"
|
|
class="label"
|
|
@touchend.stop="templateChange(t.value)"
|
|
>
|
|
<view
|
|
:class="[
|
|
'img-area',
|
|
currentTemplate === t.value && 'radio-checked',
|
|
]"
|
|
>
|
|
<image
|
|
:src="t.src"
|
|
alt=""
|
|
class="img_item"
|
|
mode="widthFix"
|
|
/>
|
|
</view>
|
|
<view class="content">
|
|
<radio
|
|
:value="t.value"
|
|
:checked="t.checked"
|
|
class="radio"
|
|
activeBackgroundColor="red"
|
|
/>
|
|
默认模板
|
|
</view>
|
|
</label>
|
|
</radio-group>
|
|
<view class="btn-area">
|
|
<button
|
|
class="btn red-btn"
|
|
@touchend.stop="toPreview"
|
|
|
|
>
|
|
<!-- v-if="musicConfig.show" -->
|
|
去预览
|
|
</button>
|
|
</view>
|
|
</view>
|
|
</template>
|
|
|
|
<template v-if="swiperCurrent === 4">
|
|
<view class="music">
|
|
<audio
|
|
:src="musicConfig.src"
|
|
:poster="musicConfig.poster"
|
|
:name="musicConfig.name"
|
|
:author="musicConfig.author"
|
|
controls
|
|
v-if="musicConfig.show"
|
|
/>
|
|
<view class="btn-area">
|
|
<button class="btn" @touchend.stop="chooseAudio">
|
|
选择音乐
|
|
</button>
|
|
<button
|
|
class="btn red-btn"
|
|
@touchend.stop="toPreview"
|
|
v-if="musicConfig.show"
|
|
>
|
|
去预览
|
|
</button>
|
|
</view>
|
|
</view>
|
|
</template>
|
|
</swiper-item>
|
|
</swiper>
|
|
</view>
|
|
</view>
|
|
</template>
|
|
|
|
<script>
|
|
import { handleHtmlImage } from "./util";
|
|
|
|
export default {
|
|
name: "cuEditor",
|
|
props: {
|
|
//editor属性,提示信息
|
|
placeholder: {
|
|
type: String,
|
|
default: "请输入内容",
|
|
},
|
|
//editor属性,点击图片时显示图片大小控件
|
|
showImgSize: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
//editor属性,点击图片时显示工具栏控件
|
|
showImgToolbar: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
//editor属性,点击图片时显示修改尺寸控件
|
|
showImgResize: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
//编辑器内容,必填
|
|
content: {
|
|
type: String,
|
|
default: "",
|
|
},
|
|
//chooseImage参数,最大文件大小,上传文件前校验是否符合规则,单位MB
|
|
maxSize: {
|
|
type: Number,
|
|
default: 5,
|
|
},
|
|
//chooseImage参数,最多可以选择的图片张数
|
|
count: {
|
|
type: Number,
|
|
default: 5,
|
|
},
|
|
//chooseImage参数,所选的图片的尺寸
|
|
sizeType: {
|
|
type: Array,
|
|
default() {
|
|
return ["original", "compressed"]; //['original', 'compressed']
|
|
},
|
|
},
|
|
//chooseImage参数,选择图片的来源
|
|
sourceType: {
|
|
type: Array,
|
|
default() {
|
|
return ["album", "camera"];
|
|
},
|
|
},
|
|
//不允许上传的图片类型
|
|
noAllowType: {
|
|
type: Array,
|
|
default() {
|
|
return []; //['gif']
|
|
},
|
|
},
|
|
//uploadFile参数,必填
|
|
url: {
|
|
type: String,
|
|
default: "",
|
|
},
|
|
//uploadFile参数
|
|
header: {
|
|
type: Object,
|
|
default() {
|
|
return {};
|
|
},
|
|
},
|
|
//uploadFile参数
|
|
formData: {
|
|
type: Object,
|
|
default() {
|
|
return {};
|
|
},
|
|
},
|
|
//uploadFile参数
|
|
name: {
|
|
type: String,
|
|
default: "file",
|
|
},
|
|
},
|
|
data() {
|
|
return {
|
|
isSelect: null,
|
|
isFixed: true,
|
|
iphoneXBottomH: 0,
|
|
scrollHeightDefault: 0,
|
|
keyboardHeight: 0,
|
|
readOnly: true,
|
|
isDefaultFormat: true, // 首次聚集时设置默认格式
|
|
isIos: false,
|
|
inputFocus: false,
|
|
formats: {},
|
|
formatArray: [
|
|
{
|
|
type: "feature",
|
|
array: [
|
|
{
|
|
name: "chooseImage",
|
|
icon: "image",
|
|
},
|
|
{
|
|
name: "chooseImagebyCamera",
|
|
icon: "photo",
|
|
},
|
|
{
|
|
name: "chooseMusic",
|
|
icon: "music",
|
|
title: "音乐",
|
|
},
|
|
{
|
|
name: "choosArticleTemplate",
|
|
icon: "articleTemplate",
|
|
title: "模板",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "tool",
|
|
array: [
|
|
{
|
|
name: "text",
|
|
label: "icon",
|
|
items: [
|
|
{
|
|
name: "bold",
|
|
icon: "bold",
|
|
},
|
|
{
|
|
name: "italic",
|
|
icon: "italic",
|
|
},
|
|
{
|
|
name: "underline",
|
|
icon: "underline",
|
|
},
|
|
{
|
|
name: "strike",
|
|
icon: "strikethrough",
|
|
},
|
|
{
|
|
name: "backgroundColor",
|
|
value: "yellow",
|
|
icon: "fontbgcolor",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
name: "defaultFormat",
|
|
items: [
|
|
{
|
|
title: "标题",
|
|
format: {
|
|
fontSize: "18px",
|
|
bold: "strong",
|
|
},
|
|
style: {
|
|
fontSize: "18px",
|
|
fontWeight: "bold",
|
|
},
|
|
},
|
|
{
|
|
title: "小标题",
|
|
format: {
|
|
fontSize: "16px",
|
|
bold: "strong",
|
|
},
|
|
style: {
|
|
fontSize: "16px",
|
|
fontWeight: "bold",
|
|
},
|
|
},
|
|
{
|
|
title: "正文",
|
|
format: {
|
|
fontSize: "14px",
|
|
},
|
|
style: {
|
|
fontSize: "14px",
|
|
},
|
|
},
|
|
{
|
|
title: "注释",
|
|
format: {
|
|
fontSize: "12px",
|
|
color: "#888888",
|
|
},
|
|
style: {
|
|
fontSize: "12px",
|
|
color: "#888888",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
name: "fontSize",
|
|
items: [
|
|
{
|
|
title: "18",
|
|
value: "18px",
|
|
},
|
|
{
|
|
title: "16",
|
|
value: "16px",
|
|
},
|
|
{
|
|
title: "14",
|
|
value: "14px",
|
|
},
|
|
{
|
|
title: "12",
|
|
value: "12px",
|
|
},
|
|
{
|
|
title: "11",
|
|
value: "11px",
|
|
},
|
|
{
|
|
title: "10",
|
|
value: "10px",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
name: "color",
|
|
items: [
|
|
{
|
|
value: "#000000",
|
|
},
|
|
{
|
|
value: "#888888",
|
|
},
|
|
{
|
|
value: "#ffffff",
|
|
},
|
|
{
|
|
value: "#f6de41",
|
|
},
|
|
{
|
|
value: "#f68c41",
|
|
},
|
|
{
|
|
value: "#fd3136",
|
|
},
|
|
{
|
|
value: "#5ad8a6",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "tool",
|
|
array: [
|
|
{
|
|
name: "align",
|
|
label: "icon",
|
|
items: [
|
|
{
|
|
value: "left",
|
|
icon: "align-left",
|
|
},
|
|
{
|
|
value: "center",
|
|
icon: "align-center",
|
|
},
|
|
{
|
|
value: "right",
|
|
icon: "align-right",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
name: "text",
|
|
label: "icon",
|
|
items: [
|
|
{
|
|
name: "list",
|
|
value: "ordered",
|
|
icon: "orderedlist",
|
|
},
|
|
{
|
|
name: "list",
|
|
value: "bullet",
|
|
icon: "unorderedlist",
|
|
},
|
|
{
|
|
name: "indent",
|
|
icon: "outdent",
|
|
value: "+1",
|
|
},
|
|
{
|
|
name: "indent",
|
|
icon: "indent",
|
|
value: "-1",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
name: "lineHeight",
|
|
items: [
|
|
{
|
|
value: 1,
|
|
},
|
|
{
|
|
value: 1.3,
|
|
},
|
|
{
|
|
value: 1.5,
|
|
},
|
|
{
|
|
value: 2,
|
|
},
|
|
{
|
|
value: 3,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "articleTemplate",
|
|
array: [],
|
|
},
|
|
{
|
|
type: "music",
|
|
array: [],
|
|
},
|
|
],
|
|
curLength: 0,
|
|
swiperCurrent: 0,
|
|
toolbarShow: true,
|
|
toolBarContentShow: false,
|
|
fixedTopHeight: 0, // 顶部工具栏高度
|
|
toolBarHeight: 100, // 工具栏高度
|
|
toolBarContentHeight: 800, // 工具栏内容高度
|
|
progress: true, //判断是否监听上传进度变化
|
|
// 音乐
|
|
musicConfig: {
|
|
poster: "https://qiniu-web-assets.dcloud.net.cn/unidoc/zh/music-a.png",
|
|
name: "音乐",
|
|
author: "暂无",
|
|
src: "",
|
|
show: false,
|
|
},
|
|
// 当前模板
|
|
currentTemplate: 0,
|
|
// 模板列表
|
|
templateConfig: [
|
|
{
|
|
value: 0,
|
|
src: "/static/template/default.png",
|
|
checked: true,
|
|
},
|
|
],
|
|
};
|
|
},
|
|
computed: {
|
|
fullToolBarHeight() {
|
|
let height = 0;
|
|
this.toolbarShow ? (height += this.toolBarHeight) : "";
|
|
this.toolBarContentShow ? (height += this.toolBarContentHeight) : "";
|
|
return uni.upx2px(height);
|
|
},
|
|
scrollHeight() {
|
|
return (
|
|
this.scrollHeightDefault - this.fixedTopHeight - this.fullToolBarHeight
|
|
);
|
|
},
|
|
scrollViewHeight() {
|
|
let scrollViewHeight = this.scrollHeight - this.keyboardHeight;
|
|
return this.keyboardHeight > 0
|
|
? scrollViewHeight + this.iphoneXBottomH
|
|
: scrollViewHeight;
|
|
},
|
|
fixedBottom() {
|
|
return this.isIos || this.iphoneXBottomH > 0
|
|
? this.keyboardHeight > 0
|
|
? this.keyboardHeight
|
|
: this.iphoneXBottomH
|
|
: 0;
|
|
},
|
|
},
|
|
watch: {
|
|
keyboardHeight(newVal, oldVal) {
|
|
if (newVal > 0) {
|
|
this.toolBarContentShow = false;
|
|
}
|
|
// this.updatePosition(newVal)
|
|
},
|
|
toolbarShow(val) {
|
|
if (!val) this.toolBarContentShow = val;
|
|
},
|
|
},
|
|
created() {
|
|
this.index = 0;
|
|
this.createdAt = Date.now();
|
|
this.getUid = () => `wux-upload--${this.createdAt}-${++this.index}`;
|
|
this.uploadTask = {};
|
|
this.tempFilePaths = [];
|
|
},
|
|
mounted() {
|
|
const query = wx.createSelectorQuery().in(this);
|
|
query
|
|
.select("#fixed-top")
|
|
.boundingClientRect((res) => {
|
|
this.fixedTopHeight = res.height;
|
|
})
|
|
.exec();
|
|
|
|
const system = uni.getSystemInfo({
|
|
success: (e) => {
|
|
this.isIos = e.platform == "ios";
|
|
let isIphoneX =
|
|
(e.platform == "devtools" || this.isIos) && e.safeArea.top == 44;
|
|
this.iphoneXBottomH = isIphoneX ? 34 : 0;
|
|
this.scrollHeightDefault = e.windowHeight - 34;
|
|
},
|
|
});
|
|
|
|
uni.onKeyboardHeightChange((res) => {
|
|
let keyboardHeight = this.keyboardHeight;
|
|
if (res.height === keyboardHeight) return;
|
|
|
|
this.keyboardHeight = res.height;
|
|
const duration = res.height > 0 ? res.duration * 1000 : 0;
|
|
keyboardHeight = res.height;
|
|
setTimeout(() => {
|
|
uni.pageScrollTo({
|
|
scrollTop: 0,
|
|
success: () => {
|
|
this.updatePosition(keyboardHeight);
|
|
this.editorCtx.scrollIntoView(); //使得编辑器光标处滚动到窗口可视区域内
|
|
},
|
|
});
|
|
}, duration);
|
|
});
|
|
},
|
|
beforeDestroy() {
|
|
console.log("editor beforeDestroy");
|
|
},
|
|
methods: {
|
|
isActive(item, pitem) {
|
|
let { name, value, format } = item;
|
|
!name ? (name = pitem.name) : "";
|
|
if (format) {
|
|
for (let name in format) {
|
|
if (this.formats[name] !== format[name]) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
} else {
|
|
return value ? this.formats[name] === value : this.formats[name];
|
|
}
|
|
},
|
|
|
|
hideKeyboard() {
|
|
// uni.hideKeyboard() //uni-app提供了隐藏软键盘的api,但是没有生效
|
|
this.editorCtx.blur();
|
|
},
|
|
changeSwiper(current) {
|
|
console.log(current);
|
|
this.toolBarContentShow = true;
|
|
this.swiperCurrent = current;
|
|
this.hideKeyboard();
|
|
},
|
|
updatePosition(keyboardHeight) {
|
|
this.keyboardHeight = keyboardHeight;
|
|
},
|
|
onEditorReady() {
|
|
const that = this;
|
|
uni
|
|
.createSelectorQuery()
|
|
.in(this)
|
|
.select("#editor")
|
|
.context(function (res) {
|
|
that.editorCtx = res.context;
|
|
that.setValue(that.content);
|
|
// //设置默认格式
|
|
// // that.editorCtx.format('header', '4')
|
|
// that.editorCtx.format('fontSize', '14px')
|
|
// that.editorCtx.format('align', 'left')
|
|
// that.editorCtx.format('lineHeight', '1.3')
|
|
//setContents设置内容后editor会自动聚焦,解决:先设置read_only为true,赋值后再把read_only属性设置为false
|
|
that.readOnly = false;
|
|
})
|
|
.exec();
|
|
},
|
|
onEditorInput(e) {
|
|
let { html, text } = e.detail;
|
|
this.curLength = text.length - 1;
|
|
},
|
|
onEditorFocus(e) {
|
|
this.toolbarShow = true;
|
|
// this.inputFocus = true;
|
|
// if (this.isDefaultFormat) {
|
|
// //设置默认格式
|
|
// this.editorCtx.format("fontSize", "14px");
|
|
// this.editorCtx.format("align", "left");
|
|
// this.isDefaultFormat = false;
|
|
// }
|
|
},
|
|
onEditorBlur() {
|
|
// this.editorCtx.blur();
|
|
// this.updatePosition(0);
|
|
// this.inputFocus = false;
|
|
},
|
|
changeKeyBoard() {
|
|
this.toolBarContentShow = false;
|
|
this.hideKeyboard();
|
|
},
|
|
hideToolbar() {
|
|
this.hideKeyboard();
|
|
this.toolbarShow = false;
|
|
},
|
|
// 修改默认样式
|
|
formatDefault(format) {
|
|
for (let name in format) {
|
|
this.editorCtx.format(name, format[name]);
|
|
}
|
|
if (format.bold) {
|
|
this.editorCtx.format("bold", true);
|
|
} else if (this.formats.bold) {
|
|
this.editorCtx.format("bold", "");
|
|
}
|
|
this.editorCtx.format("lineHeight", ""); //选择默认样式时,取消当前行高的选择
|
|
},
|
|
formatformat(bind, item = {}, pitem = {}) {
|
|
item.name = item.name || pitem.name || "";
|
|
let { name, value } = item;
|
|
switch (bind) {
|
|
case "format": //改变文本样式
|
|
if (!name) return;
|
|
if (name == "defaultFormat") {
|
|
//选择标题样式时,取消当前字号的选择
|
|
this.formatDefault(item.format);
|
|
} else {
|
|
this.editorCtx.format(name, value);
|
|
}
|
|
break;
|
|
case "removeFormat": //删除字体样式
|
|
this.editorCtx.removeFormat();
|
|
break;
|
|
case "insertDate": //插入时间
|
|
var date = new Date();
|
|
var formatDate = `${date.getFullYear()} 年${
|
|
date.getMonth() + 1
|
|
} 月${date.getDate()} 日`;
|
|
this.editorCtx.insertText({
|
|
text: formatDate,
|
|
});
|
|
break;
|
|
case "check": //设置当前行为待办列表格式
|
|
this.editorCtx.format("list", "check");
|
|
break;
|
|
case "undo": //撤销操作
|
|
this.editorCtx.undo();
|
|
break;
|
|
case "redo": //恢复操作
|
|
this.editorCtx.redo();
|
|
break;
|
|
case "insertDivider": //添加分割线
|
|
this.editorCtx.insertDivider();
|
|
break;
|
|
case "clear": //清除内容
|
|
this.editorCtx.clear();
|
|
break;
|
|
case "chooseImage": //插入相册图片
|
|
this.chooseImage();
|
|
break;
|
|
case "chooseImagebyCamera": //拍摄
|
|
this.chooseImage(true);
|
|
break;
|
|
case "chooseMusic":
|
|
this.chooseMusic();
|
|
break;
|
|
case "choosArticleTemplate":
|
|
this.choosArticleTemplate();
|
|
break;
|
|
}
|
|
},
|
|
choosArticleTemplate() {
|
|
this.changeSwiper(3);
|
|
},
|
|
|
|
chooseMusic() {
|
|
this.changeSwiper(4);
|
|
},
|
|
|
|
onStatusChange(e) {
|
|
this.formats = e.detail;
|
|
console.log(this.formats);
|
|
},
|
|
chooseImage(onlyCamera) {
|
|
const success = (res) => {
|
|
this.tempFilePaths = res.tempFiles.map((item) => ({
|
|
url: item.path,
|
|
size: item.size,
|
|
type: item.path.substring(
|
|
item.path.lastIndexOf(".") + 1,
|
|
item.path.length
|
|
),
|
|
uid: this.getUid(),
|
|
}));
|
|
// 当前插入图片src地址直接使用临时路径,如果对接接口上传,更改为使用【上传文件】代码片段:
|
|
|
|
/* 直接插入临时图片地址 start */
|
|
|
|
this.tempFilePaths.forEach((file) => {
|
|
this.insertImage(file.url, file);
|
|
});
|
|
|
|
/* 直接插入临时图片地址 end */
|
|
|
|
/* 上传文件 start */
|
|
|
|
// this.$emit('before', res)
|
|
// this.verifyFile()
|
|
// this.$nextTick(() => {
|
|
// this.uploadFile(this.tempFilePaths.length)
|
|
// })
|
|
|
|
/* 上传文件 end */
|
|
};
|
|
|
|
const { count, sizeType } = this;
|
|
setTimeout(() => {
|
|
uni.chooseImage({
|
|
count,
|
|
sizeType,
|
|
sourceType: onlyCamera ? ["camera"] : this.sourceType,
|
|
success,
|
|
});
|
|
}, 100);
|
|
},
|
|
insertImage(src, file) {
|
|
var that = this;
|
|
that.editorCtx.insertImage({
|
|
src,
|
|
data: {
|
|
id: file.uid,
|
|
},
|
|
// extClass:'editor-img',
|
|
extClass: "editor--editor-img", //添加到图片 img标签上的类名为editor-img,设置前缀editor--才生效。部分机型点击图片右边的光标时不灵敏,需将样式editor-img宽度调小 max-width:98%;从而在图片右侧中留出部分位置供用户点击聚集。
|
|
success(e) {
|
|
//真机会自动插入一行空格
|
|
},
|
|
});
|
|
},
|
|
/**
|
|
* 上传文件,支持多图递归上传
|
|
*/
|
|
uploadFile(uploadCount, curIndex) {
|
|
if (!this.tempFilePaths.length) return;
|
|
const { url, name, header, formData, progress } = this;
|
|
const file = this.tempFilePaths.shift();
|
|
curIndex ? (file.index = curIndex + 1) : (file.index = 1);
|
|
let { uid, url: filePath } = file;
|
|
if (!url || !filePath) return;
|
|
|
|
this.uploadTask[uid] = uni.uploadFile({
|
|
url,
|
|
filePath,
|
|
name,
|
|
header,
|
|
formData,
|
|
success: (res) => this.onSuccess(file, res),
|
|
fail: (res) => this.onFail(file, res),
|
|
complete: (res) => {
|
|
delete this.uploadTask[uid];
|
|
this.$emit("complete", res);
|
|
//同时选择多图上传时,只校验第一张图片大小,多图递归上传需逐一校验
|
|
if (!this.tempFilePaths.length) return;
|
|
this.verifyFile();
|
|
this.uploadFile(uploadCount, file.index);
|
|
},
|
|
});
|
|
// 判断是否监听上传进度变化
|
|
if (progress) {
|
|
this.uploadTask[uid].onProgressUpdate((res) =>
|
|
this.onProgress(file, res, uploadCount)
|
|
);
|
|
}
|
|
},
|
|
/**校验图片格式和大小是否符合规则 */
|
|
verifyFile() {
|
|
var { size: tempFilesSize, type } = this.tempFilePaths[0]; //获取图片的大小,单位B
|
|
this.noAllowType.map((item) => {
|
|
if (type == item) {
|
|
uni.showToast({
|
|
title: `不支持上传${item}图片`,
|
|
icon: "none",
|
|
});
|
|
this.tempFilePaths.shift();
|
|
}
|
|
});
|
|
|
|
if (tempFilesSize / 1024 > this.maxSize * 1024) {
|
|
uni.showToast({
|
|
title:
|
|
"上传图片不能大于" +
|
|
this.bytesToSize(this.maxSize * 1024 * 1024) +
|
|
"!",
|
|
icon: "none",
|
|
});
|
|
this.tempFilePaths.shift();
|
|
}
|
|
},
|
|
onSuccess(file, res) {
|
|
//按照接口自行处理数据,insertImage的src参数为接口返回的图片地址
|
|
/**
|
|
* 示例数据:
|
|
res = {
|
|
data: '{"code":0,"msg":"上传成功","data":{"path":"https://xxx.com/images/upload/1.png"}}'
|
|
}
|
|
*/
|
|
let json = JSON.parse(res.data);
|
|
if (json.code == 0) {
|
|
this.insertImage(json.data.path, file);
|
|
} else {
|
|
uni.showToast({
|
|
title: "图片上传失败",
|
|
icon: "none",
|
|
});
|
|
}
|
|
},
|
|
onFail(file, res) {
|
|
uni.showToast({
|
|
title: "图片上传失败!",
|
|
icon: "none",
|
|
});
|
|
},
|
|
/**
|
|
* 监听上传进度变化的回调函数
|
|
* @param {Object} file 文件对象
|
|
* @param {Object} res 请求响应对象
|
|
* @param {Number} uploadCount 选择图片总数量
|
|
*/
|
|
onProgress(file, res, uploadCount) {
|
|
if (res.progress != 100) {
|
|
uni.showToast({
|
|
title: `正在上传图片${file.index}/${uploadCount}`,
|
|
icon: "none",
|
|
});
|
|
}
|
|
|
|
const targetItem = {
|
|
...file,
|
|
progress: res.progress,
|
|
res,
|
|
};
|
|
const info = {
|
|
file: targetItem,
|
|
};
|
|
|
|
this.$emit("progress", info);
|
|
},
|
|
bytesToSize: function (bytes) {
|
|
if (bytes === 0) return "0 B";
|
|
var k = 1024,
|
|
sizes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"],
|
|
i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
|
|
return (bytes / Math.pow(k, i)).toPrecision(3) + " " + sizes[i];
|
|
},
|
|
setValue(value) {
|
|
if (this.editorCtx) {
|
|
this.editorCtx.setContents({
|
|
html: value,
|
|
success: () => {
|
|
this.getContents((res) => {
|
|
this.onEditorInput({
|
|
detail: {
|
|
html: res.html,
|
|
text: res.text,
|
|
},
|
|
});
|
|
this.$emit("update", res);
|
|
});
|
|
},
|
|
});
|
|
}
|
|
},
|
|
getContents(callback) {
|
|
//由于获取编辑器内容getContents为异步,因此需要使用callback回调
|
|
this.editorCtx.getContents({
|
|
success: (res) => {
|
|
callback(res);
|
|
},
|
|
});
|
|
},
|
|
// 选择音乐
|
|
chooseAudio() {
|
|
console.log(0);
|
|
uni.chooseMessageFile({
|
|
count: 1, // 默认9
|
|
type: "file",
|
|
extension: [".mp3", ".m4a", ".wav"], // 根据文件拓展名过滤,每一项都不能是空字符串。默认不过滤。
|
|
success: (res) => {
|
|
console.log(res);
|
|
this.musicConfig.src = res.tempFiles[0].path;
|
|
this.musicConfig.name = res.tempFiles[0].name;
|
|
this.musicConfig.show = true;
|
|
// this.playAudioFile(res.tempFiles[0].path); // 播放音频文件
|
|
},
|
|
});
|
|
},
|
|
// 播放音乐
|
|
playAudioFile(filePath) {
|
|
const innerAudioContext = uni.createInnerAudioContext();
|
|
innerAudioContext.src = filePath;
|
|
innerAudioContext.autoplay = true; // 自动播放
|
|
innerAudioContext.play();
|
|
},
|
|
// 选择模板
|
|
templateChange(value) {
|
|
this.currentTemplate = value;
|
|
for (let i = 0; i < this.templateConfig.length; i++) {
|
|
if (this.templateConfig[i].value === value) {
|
|
this.templateConfig[i].checked = true;
|
|
} else {
|
|
this.templateConfig[i].checked = false;
|
|
}
|
|
}
|
|
},
|
|
// 去预览
|
|
// 预览页(pages2/editCms/preview)读取的是 storage 里的 "cms" 对象,
|
|
// 因此这里必须写入同一份结构,否则预览是空白页。
|
|
toPreview() {
|
|
const musicSrc = (this.musicConfig && this.musicConfig.src) || '';
|
|
const templateSrc = this.templateConfig[this.currentTemplate]
|
|
? this.templateConfig[this.currentTemplate].src
|
|
: '';
|
|
|
|
const finish = (html) => {
|
|
const cms = uni.getStorageSync("cms") || {};
|
|
uni.setStorageSync("cms", {
|
|
...cms,
|
|
music_url_id: musicSrc ? { src: musicSrc } : cms.music_url_id,
|
|
temptele_src: templateSrc,
|
|
cmsLst: [{ type: "text", html, text: "", delta: {} }],
|
|
});
|
|
uni.navigateTo({ url: "/pages2/editCms/preview" });
|
|
};
|
|
|
|
// 有音乐才需要落盘,没配音乐时直接进预览
|
|
if (!musicSrc) {
|
|
this.editorCtx.getContents({
|
|
success: (res) => finish(handleHtmlImage(res.html, true)),
|
|
fail: () => finish(""),
|
|
});
|
|
return;
|
|
}
|
|
|
|
uni.getFileSystemManager().saveFile({
|
|
tempFilePath: musicSrc,
|
|
success: (saveRes) => {
|
|
const savedFilePath = saveRes.savedFilePath; // 获取持久化存储的路径
|
|
this.editorCtx.getContents({
|
|
success: (res) => {
|
|
const cms = uni.getStorageSync("cms") || {};
|
|
uni.setStorageSync("cms", {
|
|
...cms,
|
|
music_url_id: { src: savedFilePath },
|
|
temptele_src: templateSrc,
|
|
cmsLst: [{ type: "text", html: handleHtmlImage(res.html, true), text: "", delta: {} }],
|
|
});
|
|
},
|
|
complete: () => {
|
|
uni.navigateTo({ url: "/pages2/editCms/preview" });
|
|
},
|
|
});
|
|
},
|
|
fail: (e) => {
|
|
console.error('音乐文件保存失败,跳过音乐直接预览:', e);
|
|
this.editorCtx.getContents({
|
|
success: (res) => finish(handleHtmlImage(res.html, true)),
|
|
fail: () => finish(""),
|
|
});
|
|
},
|
|
});
|
|
},
|
|
save() {
|
|
this.editorCtx.getContents({
|
|
success: (res) => {
|
|
console.log(res);
|
|
res.html = handleHtmlImage(res.html, true);
|
|
this.$emit("save", res);
|
|
uni.showToast({
|
|
title: "保存成功",
|
|
icon: "none",
|
|
});
|
|
},
|
|
complete: (res) => {
|
|
console.log("getContents complete");
|
|
},
|
|
});
|
|
},
|
|
},
|
|
};
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
@import "iconfont.wxss";
|
|
|
|
$bg-color: #f7f7f7;
|
|
$bg-color-hover: #eaeaea;
|
|
$main-color: #5b8ff9;
|
|
|
|
.fixed-top {
|
|
position: fixed;
|
|
top: -88rpx;
|
|
right: 0;
|
|
line-height: 88rpx;
|
|
// border-bottom: solid 1rpx #f1f1f1;
|
|
padding: 0 30rpx;
|
|
box-sizing: border-box;
|
|
transition: all 0.3s ease;
|
|
// background-color: #ffffff;
|
|
z-index: 999;
|
|
|
|
&.isFixed {
|
|
top: 0;
|
|
}
|
|
|
|
.btn {
|
|
width: 100%;
|
|
position: relative;
|
|
border: 0 !important;
|
|
border-radius: 6rpx;
|
|
padding-left: 0;
|
|
padding-right: 0;
|
|
overflow: visible;
|
|
float: right;
|
|
width: 100rpx;
|
|
height: 60rpx;
|
|
line-height: 60rpx;
|
|
font-size: 24rpx;
|
|
margin: 14rpx 0;
|
|
text-align: center;
|
|
}
|
|
|
|
.btn-primary {
|
|
background: #5677fc !important;
|
|
color: #fff;
|
|
}
|
|
|
|
.btn-primary:hover {
|
|
opacity: 0.8;
|
|
}
|
|
}
|
|
|
|
// .fixed-top,
|
|
// .fixed-top__place {
|
|
// width: 100%;
|
|
// height: 88rpx;
|
|
// }
|
|
|
|
.flex {
|
|
display: flex;
|
|
}
|
|
|
|
.editor-container {
|
|
}
|
|
|
|
.cu-editor {
|
|
box-sizing: border-box;
|
|
width: 100%;
|
|
height: 100%;
|
|
font-size: 28rpx;
|
|
line-height: 1.5;
|
|
overflow: auto;
|
|
padding: 40rpx 35rpx;
|
|
min-height: unset !important;
|
|
}
|
|
|
|
.editor-img {
|
|
max-width: 98% !important;
|
|
}
|
|
|
|
.ql-active {
|
|
background-color: $bg-color-hover;
|
|
|
|
.color-circle {
|
|
border: solid 1px;
|
|
}
|
|
}
|
|
|
|
.noBgColor {
|
|
background-color: none !important;
|
|
}
|
|
|
|
.fixed-bottom {
|
|
position: fixed;
|
|
left: 0;
|
|
width: 100%;
|
|
right: 100%;
|
|
bottom: 0;
|
|
z-index: 99999;
|
|
}
|
|
|
|
.toolbar {
|
|
box-sizing: border-box;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
border: 1rpx solid #e5e5e5;
|
|
border-left: none;
|
|
border-right: none;
|
|
background: #fff;
|
|
|
|
.iconfont {
|
|
display: inline-block;
|
|
cursor: pointer;
|
|
font-size: 40rpx;
|
|
text-align: center;
|
|
position: relative;
|
|
|
|
&.active::after {
|
|
content: "";
|
|
left: 0;
|
|
bottom: 0;
|
|
width: 100%;
|
|
position: absolute;
|
|
height: 6rpx;
|
|
border-bottom: solid 6rpx #000000;
|
|
}
|
|
}
|
|
|
|
.toolbar-item {
|
|
height: 100rpx;
|
|
line-height: 100rpx;
|
|
flex: 1;
|
|
text-align: center;
|
|
|
|
&:active {
|
|
opacity: 0.4;
|
|
}
|
|
}
|
|
|
|
.toolbar-item-header,
|
|
.toolbar-item-footer {
|
|
width: 108rpx;
|
|
text-align: center;
|
|
}
|
|
|
|
.toolbar-item-header {
|
|
border-right: solid 1rpx $uni-border-color;
|
|
}
|
|
|
|
.toolbar-item-footer {
|
|
border-left: solid 1rpx $uni-border-color;
|
|
color: #5b8ff9;
|
|
font-weight: bold;
|
|
}
|
|
}
|
|
|
|
.toolbar-content {
|
|
background-color: #ffffff;
|
|
}
|
|
|
|
.swiper-item {
|
|
box-sizing: border-box;
|
|
padding: 0 30rpx;
|
|
}
|
|
|
|
.tool-items {
|
|
background-color: $bg-color;
|
|
color: #323232;
|
|
height: 80rpx;
|
|
line-height: 80rpx;
|
|
margin: 32rpx 0;
|
|
border-radius: 16rpx;
|
|
overflow: hidden;
|
|
|
|
.tool-item {
|
|
flex: 1;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
|
|
&:active {
|
|
background-color: $bg-color-hover;
|
|
}
|
|
}
|
|
|
|
.iconfont {
|
|
display: inline-block;
|
|
width: 80rpx;
|
|
height: 80rpx;
|
|
line-height: 80rpx;
|
|
cursor: pointer;
|
|
font-size: 40rpx;
|
|
text-align: center;
|
|
}
|
|
|
|
.color-circle {
|
|
width: 40rpx;
|
|
height: 40rpx;
|
|
border-radius: 50%;
|
|
}
|
|
|
|
.txt {
|
|
font-size: 14px;
|
|
}
|
|
}
|
|
|
|
.feature {
|
|
margin: 30rpx;
|
|
text-align: center;
|
|
|
|
.icon {
|
|
background-color: $bg-color;
|
|
width: 120rpx;
|
|
height: 120rpx;
|
|
line-height: 120rpx;
|
|
color: #323232;
|
|
margin-bottom: 10rpx;
|
|
|
|
&:active {
|
|
background-color: $bg-color-hover;
|
|
}
|
|
|
|
.iconfont {
|
|
font-size: 42rpx;
|
|
}
|
|
}
|
|
}
|
|
.music {
|
|
padding: 16px 0;
|
|
text-align: center;
|
|
display: flex;
|
|
flex-direction: column;
|
|
.btn {
|
|
margin-top: 16px;
|
|
}
|
|
audio {
|
|
width: 100%;
|
|
}
|
|
}
|
|
|
|
.temps {
|
|
padding: 16px 0;
|
|
.title {
|
|
margin-bottom: 16px;
|
|
font-size: 32rpx;
|
|
font-weight: 600;
|
|
}
|
|
.img-area {
|
|
border-radius: 5px;
|
|
}
|
|
.radio-checked {
|
|
border: 1px solid red;
|
|
}
|
|
.content {
|
|
margin: 10px auto 20px auto;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.radio {
|
|
zoom: 0.7;
|
|
}
|
|
}
|
|
.btn-area {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.btn {
|
|
margin: 16px 0 0 0;
|
|
+ .btn {
|
|
margin-left: 16px;
|
|
}
|
|
}
|
|
.red-btn {
|
|
background-color: #f44336c7;
|
|
color: #fff;
|
|
}
|
|
</style>
|