Files
root 3117146281 feat: 打通 HBuilderX CLI 构建 + 修复文章闭环静态盲区
构建:
- 新增 tools/build.js:junction HBuilderX 工具链,CLI 构建 h5/mp-weixin
- vue 指向补丁版 @dcloudio/uni-h5-vue(官方 npm vue 不导出 isInSSRComponentSetup)
- 设 HX_APP_ROOT 避免退化成 H5 空壳产物;产物完整性校验

校验工具:
- 新增 check-cloud-methods.js:acorn 解析云对象方法,比对 94 处调用点
- 新增 check-android-contract.js:Kotlin 侧云对象契约校验
- audit-project.js 修 downloadFile 误报(注释未剥离);tools/ 排除出扫描
- package.json 声明此前隐式依赖的 acorn

功能:
- 补 uni-cms-articles.getPublishedArticles(安卓端依赖但此前不存在)
- 修 u-parse <audio> 引用已移除组件导致 H5 构建失败
2026-09-12 01:02:45 +08:00

876 lines
21 KiB
Vue

<template>
<view class="b_col_eee body">
<uni-nav-bar
leftText="取消"
left-icon="left"
right-text="下一步"
backgroundColor="#ffffff"
color="#000000"
@clickRight="toEditSetting()"
@clickLeft="back()">
</uni-nav-bar>
<uni-popup
ref="alertDialog"
type="dialog">
<uni-popup-dialog
type="info"
cancelText="放弃草稿"
confirmText="编辑草稿"
title="提示"
content="文章有草稿未完成,是否继续编辑草稿?"
@confirm="dialogConfirm"
@close="dialogClose">
</uni-popup-dialog>
</uni-popup>
<uni-popup ref="del_popup" type='bottom'>
<view class="del-popup">
<view class="margin_20_top padding_20 color-666 b_col_fff">
确认要删除当前文章段落
</view>
<view class="margin_4 padding_20 color-red b_col_fff" @click="delItem()">
<uni-icons type="trash" size="30" color="#f00"></uni-icons>
删除
</view>
<view class="text-center padding_20 margin_10_top b_col_fff" @click="close_del_popup()">
取消
</view>
</view>
</uni-popup>
<uni-popup ref="popup" type='bottom'>
<view class="b_col_fff">
<view class="grid-container">
<view v-for="(item, index) in cmsType" :key="index" :class="{
'grid-item': true,
}" @click="addItem(item.type)">
<view class="grid-item-box">
<image :src="item.src" class="image" mode="aspectFill" />
<text class="text">
{{ item.text }}
</text>
</view>
</view>
</view>
</view>
</uni-popup>
<view
@click="toTitleStyle()"
class="margin_50 b_col_fff padding_10"
style="display: flex; align-items: center; gap: 20rpx;">
<uni-easyinput
:inputBorder="false"
disabled
v-model="cms.title"
placeholder="请输入标题"
style="flex: 1;"
></uni-easyinput>
<view class="border_ff0" style="white-space: nowrap;">
<uni-icons type="gear" size="20" color="993300"></uni-icons>
标题样式
</view>
</view>
<view class="container">
<button class="add-button" @click="addEditItem(0)"></button>
</view>
<view v-for="(item, index) in cms.cmsLst" :key="index">
<view class="margin_20 b_col_fff edit_item padding_10">
<view class="part part1">
<view v-if="item.type === 'image'" @click="updateImage(index)">
<cloud-image
mode="scaleToFill"
width="100%"
height="200rpx"
v-if="item.image.src"
:src="item.image.src">
</cloud-image>
<view class="not-image" v-else>
<cloud-image
mode="scaleToFill"
width="100%"
height="150rpx"
:src="not_image">
</cloud-image>
<view class="not-font">
添加图片
</view>
</view>
</view>
<view class="video-container"
v-if="item.type === 'video'"
@click="updateVideoItem(index)">
<cloud-image
v-if="item.video.poster"
mode="scaleToFill"
width="100%"
height="200rpx"
:src="item.video.poster">
</cloud-image>
<cloud-image
class="icon-play"
mode="scaleToFill"
width="100rpx"
height="100rpx"
src="/static/editor-icons/stop.png">
</cloud-image>
</view>
<view
v-if="item.type === 'vote'">
<view class="not-image">
<view class="not-image-icon">
<cloud-image
mode="scaleToFill"
width="100%"
height="150rpx"
:src="is_vote">
</cloud-image>
</view>
<view class="not-font">
投票
</view>
</view>
</view>
</view>
<view class="part part2" @click="to(item.type, index)">
<view class="" v-if="item.type === 'vote'">
<view v-if="item.text">
{{ item.text }}
</view>
<view class="font-info" v-else>
点击添加文字
</view>
</view>
<view class="" v-else>
<view v-if="item.text">
{{ item.text }}
</view>
<view class="font-info" v-else>
点击添加文字
</view>
</view>
</view>
<view class="part part3">
<uni-icons
type="closeempty"
class="sub-area"
@click="open_del_popup(index)">
</uni-icons>
<uni-icons
type="bars"
class="sub-area">
</uni-icons>
<view class="sub-area item-no">
{{ index + 1 }}
</view>
</view>
</view>
<view class="container">
<button
class="add-button"
@click="addEditItem(index + 1)">
</button>
</view>
</view>
</view>
</template>
<script>
import { ref, reactive } from 'vue'
import { onLoad, onShow, onReady, onHide, onUnload } from '@dcloudio/uni-app'
import cloudImage from "@/components/cloud-image.vue"
const uniCloudStorageExtCo = uniCloud.importObject("ext-storage-co")
export default {
components: {
cloudImage
},
setup() {
const alertDialog = ref(null)
const delPopup = ref(null)
const popup = ref(null)
const saveInterval = ref(null)
// 有值表示在编辑一篇已存在的文章(带 id 进入)
const editingId = ref("")
// 图标改为本地静态资源:原先的外链依赖第三方域名与签名,失效即整块空白
const is_vote = ref("/static/editor-icons/is_vote.png")
const not_image = ref("/static/editor-icons/not_image.png")
const selectIndex = ref(0)
const cmsType = ref([
{ type: "text", text: "文字", src: "/static/editor-icons/text.png" },
{ type: "video", text: "视频", src: "/static/editor-icons/video.png" },
{ type: "image", text: "图片", src: "/static/editor-icons/image.png" },
{ type: "vote", text: "投票", src: "/static/editor-icons/vote.png" },
{ type: "title", text: "小标题", src: "/static/editor-icons/title.png" },
])
const cms = reactive({
title_html: "",
title_delta: {},
title: "",
cmsLst: []
})
onHide(() => {
clearInterval(saveInterval.value)
})
onUnload(() => {
clearInterval(saveInterval.value)
clearTimeout(saveInterval.value)
})
onLoad((options) => {
// 从「我的作品」进入时带 id,用于区分"编辑已有文章"与"新建"
editingId.value = (options && options.id) || ""
})
onReady(() => {
const storedCms = uni.getStorageSync("cms")
const hasDraft = storedCms && (
(storedCms.cmsLst && storedCms.cmsLst.length > 0) ||
storedCms.title ||
storedCms.title_html
)
// 从「我的作品」带 id 进来是编辑已有文章,不是未完成的草稿,
// 此时弹"草稿未完成"会误导用户。只有新建流程才提示。
const editingExisting = !!(editingId && editingId.value)
if (hasDraft && !editingExisting) {
alertDialog.value.open()
}
saveInterval.value = setInterval(saveCms, 10000)
})
onShow(() => {
const storedCms = uni.getStorageSync("cms")
if (storedCms) {
Object.assign(cms, storedCms)
}
})
function saveCms() {
// 定时保存只在有实际内容时落盘,避免空编辑器把上一次的草稿覆盖成空
const hasContent = (cms.cmsLst && cms.cmsLst.length > 0) || cms.title || cms.title_html
if (!hasContent) return
uni.setStorageSync("cms", JSON.parse(JSON.stringify(cms)))
}
function open() {
popup.value.open()
}
function close() {
popup.value.close()
}
function confirm(value) {
popup.value.close()
}
function addEditItem(index) {
selectIndex.value = index
uni.setStorageSync("selectIndex", selectIndex.value)
open()
}
function getFileInfo(path) {
const parts = path.split('/').pop()
const [filename, ...extParts] = parts.split('.')
const ext = extParts.length ? extParts.pop() : ''
return { name: filename, extname: ext }
}
function formatBytes(bytes) {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
if (i >= sizes.length) return '> 1 TB'
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
async function updateImage(index) {
uni.chooseImage({
sourceType: ["album", "camera"],
count: 1,
success: async (res) => {
if (!res.tempFilePaths || !res.tempFilePaths.length) return
const oldSrc = cms.cmsLst[index]?.image?.src || ''
try {
const filePath = res.tempFilePaths[0]
const { name } = getFileInfo(filePath)
const options = await uniCloudStorageExtCo.getUploadFileOptions({
cloudPath: `cms/image/${name}_${Math.random().toString(36).substr(2, 6)}`
})
const uploaded = await new Promise((resolve, reject) => uniCloud.uploadFile({
...options.uploadFileOptions,
filePath,
cloudPath: options.cloudPath,
success: resolve,
fail: reject
}))
cms.cmsLst[index].image = { ...cms.cmsLst[index].image, src: uploaded.fileID }
uni.setStorageSync('cms', JSON.parse(JSON.stringify(cms)))
if (oldSrc && editingId.value) {
try { await uniCloudStorageExtCo.deleteFile({ fileList: [oldSrc], article_id: editingId.value }) } catch (e) { console.warn('旧图片清理失败', e) }
}
} catch (err) {
console.error('图片替换失败', err)
uni.showToast({ title: '图片上传失败', icon: 'none' })
}
}
})
}
async function delItem() {
let index = selectIndex.value
if (index >= 0 && index < cms.cmsLst.length) {
if (cms.cmsLst[index].type === "image" && editingId.value && cms.cmsLst[index].image?.src) {
try {
await uniCloudStorageExtCo.deleteFile({ fileList: [cms.cmsLst[index].image.src], article_id: editingId.value })
} catch (e) {
console.warn('旧图片清理失败', e)
}
} else if (cms.cmsLst[index].type === "video" && editingId.value && (cms.cmsLst[index].video?.src || cms.cmsLst[index].video?.poster)) {
try {
await uniCloudStorageExtCo.deleteFile({
fileList: [cms.cmsLst[index].video.src, cms.cmsLst[index].video.poster],
article_id: editingId.value
})
} catch (e) {
console.warn('旧视频清理失败', e)
}
}
cms.cmsLst.splice(index, 1)
uni.setStorageSync('cms', JSON.parse(JSON.stringify(cms)))
}
delPopup.value.close()
}
async function addImageItem() {
uni.chooseImage({
sourceType: ["album", "camera"],
count: 9,
success: async (res) => {
if (res.tempFilePaths.length > 0) {
for (let i = 0; i < res.tempFilePaths.length; i++) {
let filePath = res.tempFilePaths[i]
let { name, extname } = getFileInfo(filePath)
const uploadFileOptionsRes = await uniCloudStorageExtCo.getUploadFileOptions({
cloudPath: `cms/image/${name}_${Math.random().toString(36).substr(2,6)}`
})
await uniCloud.uploadFile({
...uploadFileOptionsRes.uploadFileOptions,
filePath: filePath,
cloudPath: uploadFileOptionsRes.cloudPath,
success: async () => {
const res2 = {
cloudPath: uploadFileOptionsRes.cloudPath,
fileID: uploadFileOptionsRes.fileID,
fileURL: uploadFileOptionsRes.fileURL
}
const selectedMedia = { src: res2.fileID, alt: name }
let item = {
type: "image",
text: "",
image: { src: selectedMedia.src, html: "", delta: {} }
}
cms.cmsLst.splice(selectIndex.value + i, 0, item)
close()
},
fail: (err) => {
console.log("上传失败", err)
}
})
}
}
}
})
}
function back() {
saveCms()
try {
uni.navigateBack({ delta: 1 })
} catch (e) {
uni.switchTab({ url: "/uni_modules/uni-cms-article/pages/list/list" })
}
}
function toeditText(index) {
if (index >= 0) {
uni.setStorageSync("selectIndex", index)
}
saveCms()
uni.navigateTo({ url: "/pages2/editCms/editText" })
}
function open_del_popup(index) {
selectIndex.value = index
delPopup.value.open()
}
function close_del_popup() {
delPopup.value.close()
}
function toTitleStyle() {
saveCms()
uni.navigateTo({ url: "/pages2/editCms/titleStyle" })
}
function toVote(index) {
if (index >= 0) {
uni.setStorageSync("selectIndex", index)
}
saveCms()
uni.navigateTo({ url: "/pages2/editCms/vote?type=edit" })
}
function to(type, index) {
if (type === 'vote') {
toVote(index)
} else if (type === 'title') {
uni.setStorageSync('selectIndex', index)
saveCms()
uni.navigateTo({ url: '/pages2/editCms/smallTitle' })
} else {
toeditText(index)
}
}
function toEditSetting() {
saveCms()
uni.navigateTo({ url: "/pages2/editCms/editSetting" })
}
function addTextItem() {
cms.cmsLst.splice(selectIndex.value, 0, {
type: "text",
text: "",
image: { src: "", html: "", delta: {} },
html: "",
delta: {}
})
toeditText()
}
function addVoteItem() {
saveCms()
uni.navigateTo({ url: "/pages2/editCms/vote?type=add" })
}
function addTitleItem() {
saveCms()
uni.navigateTo({ url: "/pages2/editCms/smallTitle" })
}
// 放弃草稿只清理本地状态;媒体删除由服务端按文章引用关系处理
async function discardDraft() {
uni.setStorageSync("cms", { cmsLst: [], title: "", title_html: "", title_delta: {} })
Object.assign(cms, uni.getStorageSync("cms"))
alertDialog.value?.close()
}
async function dialogClose() {
await discardDraft()
}
// 「编辑草稿」:保留本地草稿(onShow 已把内容灌进 cms),直接进入编辑
async function dialogConfirm() {
alertDialog.value?.close()
}
async function addItem(type) {
if (type === "text") {
addTextItem()
} else if (type === "image") {
await addImageItem()
} else if (type === "video") {
await addVideoItem()
} else if (type === "title") {
addTitleItem()
} else if (type === "vote") {
addVoteItem()
}
}
async function addVideoItem() {
uni.chooseVideo({
sourceType: ['album', 'camera'],
success: async (res) => {
let filePath = res.tempFilePath
let { name, extname } = getFileInfo(filePath)
let duration = res.duration
const uploadFileOptionsRes = await uniCloudStorageExtCo.getUploadFileOptions({
cloudPath: `cms/video/${name}_${Math.random().toString(36).substr(2,6)}`
})
await uniCloud.uploadFile({
...uploadFileOptionsRes.uploadFileOptions,
filePath: filePath,
cloudPath: uploadFileOptionsRes.cloudPath,
success: async () => {
const res2 = {
cloudPath: uploadFileOptionsRes.cloudPath,
fileID: uploadFileOptionsRes.fileID,
fileURL: uploadFileOptionsRes.fileURL
}
const selectedMedia = { src: res2.fileURL, alt: name }
let poster = "vframe/jpg/offset/0/w/440/h/264/"
let cover = res2.fileURL + "?" + poster
let item = {
type: "video",
text: "",
video: { src: selectedMedia.src, poster: cover, duration: duration, html: "", delta: {} }
}
cms.cmsLst.splice(selectIndex.value, 0, item)
close()
},
fail: (err) => {
console.log("上传失败", err)
}
})
}
})
}
async function updateVideoItem(index) {
uni.chooseVideo({
sourceType: ['album', 'camera'],
success: async (res) => {
const oldVideo = { ...(cms.cmsLst[index]?.video || {}) }
let filePath = res.tempFilePath
let { name, extname } = getFileInfo(filePath)
let duration = res.duration
const uploadFileOptionsRes = await uniCloudStorageExtCo.getUploadFileOptions({
cloudPath: `cms/video/${name}_${Math.random().toString(36).substr(2,6)}`
})
await uniCloud.uploadFile({
...uploadFileOptionsRes.uploadFileOptions,
filePath: filePath,
cloudPath: uploadFileOptionsRes.cloudPath,
success: async () => {
const res2 = {
cloudPath: uploadFileOptionsRes.cloudPath,
fileID: uploadFileOptionsRes.fileID,
fileURL: uploadFileOptionsRes.fileURL
}
const selectedMedia = { src: res2.fileURL, alt: name }
let poster = "vframe/jpg/offset/0/w/440/h/264/"
let cover = res2.fileURL + "?" + poster
let { text, html, delta } = {
text: cms.cmsLst[index].text,
html: cms.cmsLst[index].video.html,
delta: cms.cmsLst[index].video.delta
}
let item = {
type: "video",
text: text,
video: { src: selectedMedia.src, poster: cover, duration: duration, html: html, delta: delta }
}
cms.cmsLst[index] = item
uni.setStorageSync('cms', JSON.parse(JSON.stringify(cms)))
if (editingId.value && (oldVideo.src || oldVideo.poster)) {
try {
await uniCloudStorageExtCo.deleteFile({
fileList: [oldVideo.src, oldVideo.poster],
article_id: editingId.value
})
} catch (e) { console.warn('旧视频清理失败', e) }
}
close()
},
fail: (err) => {
console.log("上传失败", err)
}
})
}
})
}
return {
alertDialog,
delPopup,
popup,
saveInterval,
editingId,
is_vote,
not_image,
selectIndex,
cmsType,
cms,
saveCms,
open,
close,
confirm,
addEditItem,
getFileInfo,
formatBytes,
updateImage,
delItem,
addImageItem,
back,
toeditText,
open_del_popup,
close_del_popup,
toTitleStyle,
toVote,
to,
toEditSetting,
addTextItem,
addVoteItem,
addTitleItem,
dialogClose,
dialogConfirm,
addItem,
addVideoItem,
updateVideoItem
}
}
}
</script>
<style lang="scss" scoped>
.b_col_fff {
background-color: #fff;
}
.b_col_eee {
background-color: #eee;
}
.body {
width: 100vw;
min-height: 100vh;
}
.margin_20 {
margin-top: 20rpx;
margin-bottom: 20rpx;
}
.margin_10_top {
margin-top: 10rpx;
}
.margin_10 {
margin-top: 10rpx;
margin-bottom: 10rpx;
}
.container {
position: relative;
display: flex;
justify-content: center;
align-items: center;
}
.add-button {
width: 60rpx;
height: 60rpx;
border-radius: 50%;
border: 2rpx solid #ccc;
background-color: #eee;
cursor: pointer;
position: relative;
transition: transform 0.3s ease;
}
.add-button::before,
.add-button::after {
content: '';
position: absolute;
background-color: #ccc;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
.add-button::before {
width: 24rpx;
height: 4rpx;
}
.color-red {
color: #f00;
}
.add-button::after {
width: 4rpx;
height: 24rpx;
}
.add-button:active {
transform: scale(0.95);
}
.edit_item {
width: 100vw;
height: 200rpx;
display: flex;
.part {
height: 100%;
&.part1 {
flex: 0 0 30%;
}
&.part2 {
flex: 0 0 63%;
display: flex;
flex-direction: column;
}
&.part3 {
flex: 0 0 7%;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
.sub-area {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.item-no {
background-color: #ccc;
color: #fff;
text-align: center;
width: 1.5rem;
height: 1.5rem;
}
}
}
}
.margin_20_top {
margin-top: 20rpx;
}
.padding_10 {
padding: 20rpx;
}
.padding_20 {
padding: 20rpx;
}
.font-info {
color: #666;
}
.grid-container {
.grid-item {
width: calc(98% / 3);
}
display: flex;
flex-wrap: wrap;
box-sizing: border-box;
padding: 10rpx;
width: 100%;
}
.grid-item-box {
flex: 1;
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
flex-direction: column;
align-items: center;
justify-content: center;
padding: 15px 0;
}
.margin_4 {
margin-top: 4rpx;
margin-bottom: 4rpx;
}
.image {
width: 50rpx;
height: 50rpx;
}
.not-image {
background-color: #eee;
width: 100%;
height: 200rpx;
text-align: center;
color: #fff;
flex-direction: column;
justify-content: center;
align-items: center;
.not-image-icon {
justify-content: center;
align-items: center;
text-align: center;
}
}
.not-font {
background-color: #7a7e83;
}
.margin_50 {
margin-top: 50rpx;
margin-bottom: 30rpx;
}
.del-popup {
background-color: #ccc;
border-radius: 20rpx 0rpx;
font-size: 40rpx;
}
.text-center {
text-align: center;
}
.margin_30_left {
margin-left: 30rpx;
}
.color-eee {
color: #eee;
}
.color-666 {
color: #666;
}
.border_ff0 {
border: 2rpx solid #993300;
background-color: #ff0;
border-radius: 10rpx;
padding: 10rpx;
}
.icon-play {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
font-size: 50rpx;
color: rgba(255, 255, 255, 0.8);
}
.video-container {
position: relative;
width: 100%;
height: 200rpx;
}
</style>