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

713 lines
17 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="body">
<uni-nav-bar
title="预览"
leftText="取消"
left-icon="left"
right-text="发布"
backgroundColor="#ffffff"
color="#000000"
@clickRight="saveArticle(1)"
@clickLeft="back()">
</uni-nav-bar>
<!-- 存草稿不发布:草稿仅在「我的作品」中可见 -->
<view class="draft-bar">
<button class="draft-btn" :loading="saving" :disabled="saving" @click="saveArticle(0)">
存为草稿
</button>
</view>
<view class="v-preview" v-if="temp && temp.temptele_src">
<image :src="temp.temptele_src" alt="" class="img" mode="widthFix" />
</view>
<view class="" :style="backgrond_color">
<u-parse :content="titleContent"></u-parse>
<view class="">
<view class="meta">
<view class="author">
<cloud-image
height="64rpx" width="64rpx" borderRadius="50%"
:src="userInfo.avatar_file.url"></cloud-image>
<text class="at" :style="color">{{ userInfo.nickname || '' }}</text>
<text class="split">·</text>
<text class="date" :style="color">刚刚</text>
</view>
</view>
<view class="">
<view class="" v-for="(item, index) in (cms.cmsLst || [])" :key="index">
<u-parse
:content="itemHtml(item)"
v-if="item.type === 'text' || item.type === 'title'">
</u-parse>
<view class="" v-if="item.type === 'image'"
style="width: 100vw;margin-top: 30rpx;margin-bottom: 30rpx;">
<cloud-image
width="100%"
height="500rpx"
mode="aspectFit"
:src="item.image.src">
</cloud-image>
</view>
<view class="" v-if="item.type === 'video'"
style="width: 100vw;margin-top: 30rpx;margin-bottom: 30rpx;">
<video
style="width: 100%; height: 500rpx;"
:src="item.video.src"
enable-danmu
danmu-btn
controls>
</video>
</view>
<view class="" v-if="item.type === 'vote'">
<uni-card>
<view class="vote-container">
<text class="vote-title">{{ item.vote.voteTitle }}</text>
<text class="deadline">截止时间:{{ item.vote.datetime }}</text>
<view class="" v-if="item.vote.type === voteType[0]">
<u-radio-group
v-model="item.vote.selectValue"
placement="column"
@change="groupChange"
wrap
>
<u-radio
:customStyle="{marginBottom: '8px'}"
v-for="(radio, radioIndex) in item.vote.voteLst"
:key="radioIndex"
:label="radio.value"
:name="radio.value"
@change="radioChange"
style="width: 100%;"
>
</u-radio>
</u-radio-group>
</view>
<view class="" v-if="item.vote.type === voteType[1]">
<u-checkbox-group
v-model="item.vote.selectValue"
placement="column"
@change="checkboxChange"
>
<u-checkbox
:customStyle="{marginBottom: '8px'}"
v-for="(checkbox, checkboxIndex) in item.vote.voteLst"
:key="checkboxIndex"
:label="checkbox.value"
:name="checkbox.value"
>
</u-checkbox>
</u-checkbox-group>
</view>
<view class="" v-if="item.vote.type === voteType[2]">
<u-checkbox-group
v-model="item.vote.selectValue"
placement="column"
@change="checkboxChange"
>
<view class="">
<u-checkbox
:customStyle="{marginBottom: '8px'}"
v-for="(checkbox, checkboxIndex) in item.vote.voteLst"
:key="checkboxIndex"
:label="checkbox.value"
:name="checkbox.value"
:disabled="isCheckboxDisabled(item.vote.selectValue, checkbox.value)"
>
</u-checkbox>
</view>
</u-checkbox-group>
</view>
<view class=""
v-if="isExpired"
:disabled="isExpired || !selectedValue"
style="border-top: 3rpx solid #ccc;padding-top: 20rpx;margin-top: 20rpx;
text-align: center;;">
已截止
</view>
<view class=""
v-else
:disabled="isExpired || !selectedValue"
style="border-top: 3rpx solid #ccc;padding-top: 20rpx;margin-top: 20rpx;
text-align: center;;">
{{ isExpired ? '已截止' : '点击登录后投票' }}
</view>
</view>
</uni-card>
</view>
</view>
</view>
</view>
</view>
<u-tabbar :value="tabbarValue"
>
<u-tabbar-item
v-for="(item, index) in tabbarText"
:key="index"
@click="tabbarClick"
:text="item.text">
</u-tabbar-item>
</u-tabbar>
<hqs-popup :title="tabbarText[tabbarValue].text" :from="popFrom" :mask-click="maskClick"
@back="onBack"
v-model="showPop">
<view class="page-container" v-if="tabbarText[tabbarValue].text === '模板'">
<media-list
:dataType="'template'"
:pageSize="12"
:currentTemplate="currentTemplate"
@update="updateTemplate"
/>
</view>
<view class="page-container" v-if="tabbarText[tabbarValue].text === '音乐'">
<media-list
:dataType="'music'"
:pageSize="12"
:currentTemplate="currentTemplate"
@update="updateTemplate"
/>
</view>
<view class="page-container" v-if="tabbarText[tabbarValue].text === '排版'">
</view>
</hqs-popup>
<uni-grid :column="3" :highlight="true" @change="change" :show-border="false">
<uni-grid-item v-for="(item, index) in gridLst" :index="index" :key="index">
<view class="grid-item-box">
<uni-icons :type="item.icon" :size="30"
:color="item.select_flag?select_icon_color:icon_color" />
<text class="text" :style="item.select_flag?select_font_color:icon_font_color">
{{item.value?item.value:0}}
</text>
</view>
</uni-grid-item>
</uni-grid>
</view>
</template>
<script>
import { ref, computed } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { store } from '@/pages3/uni_modules/uni-id-pages/common/store.js'
import uRadioGroup from "@/uni_modules/uview-ui/components/u-radio-group/u-radio-group.vue"
import uRadio from "@/uni_modules/uview-ui/components/u-radio/u-radio.vue"
import uParse from "@/uni_modules/uview-ui/components/u-parse/u-parse.vue"
import uTabbar from "@/uni_modules/uview-ui/components/u-tabbar/u-tabbar.vue"
import uTabbarItem from "@/uni_modules/uview-ui/components/u-tabbar-item/u-tabbar-item.vue"
import mediaList from "@/components/image-grid/media-list.vue"
import cloudImage from "@/components/cloud-image.vue"
import uCheckboxGroup from "@/uni_modules/uview-ui/components/u-checkbox-group/u-checkbox-group.vue"
import uCheckbox from "@/uni_modules/uview-ui/components/u-checkbox/u-checkbox.vue"
export default {
components: {
uTabbar,
uTabbarItem,
mediaList,
uParse,
cloudImage,
uRadioGroup,
uRadio,
uCheckboxGroup,
uCheckbox
},
setup() {
const voteType = ref(["单选(默认)", "多选,无限制", "多选, 最多两项"])
const voteEndTime = ref('2025-04-20 23:59:59')
const cms = ref({})
const temp = ref({})
const titleContent = ref("")
const tabbarValue = ref(0)
const showPop = ref(false)
const maskClick = ref(true)
const popFrom = ref('bottom')
const list = ref(new Array(20).fill(0))
const currentTemplate = ref("")
const saving = ref(false)
function itemHtml(item) {
return item && typeof item.html === 'string' ? item.html : ''
}
function cmsLstToDelta(list) {
const ops = []
for (const item of Array.isArray(list) ? list : []) {
if (item.type === 'text' || item.type === 'title') {
const itemOps = item.delta && Array.isArray(item.delta.ops) ? item.delta.ops : (item.text ? [{ insert: item.text }] : [])
ops.push(...itemOps)
if (!ops.length || !String(ops[ops.length - 1].insert || '').endsWith('\n')) ops.push({ insert: '\n' })
} else if (item.type === 'image' && item.image?.src) {
ops.push({ insert: { image: item.image.src } }, { insert: '\n' })
} else if (item.type === 'video' && item.video?.src) {
ops.push({ insert: { video: item.video.src } }, { insert: '\n' })
} else if (item.type === 'vote' && item.vote?.voteTitle) {
ops.push({ insert: `${item.vote.voteTitle}\n` })
}
}
return { ops }
}
const tabbarText = ref([
{ text: "模板" },
{ text: "音乐" },
{ text: "排版" }
])
const userInfo = computed(() => store.userInfo)
const isExpired = computed(() => new Date() > new Date(voteEndTime.value))
const btn_send_commends_color = computed(() => {
if (!temp.value) return ""
return `background-color: ${temp.value.btn_send_commends_color};`
})
const backgrond_color = computed(() => {
if (!temp.value) return ""
return `background-color: ${temp.value.backgrond_color};`
})
const color = computed(() => {
if (!temp.value) return ""
return `color: ${temp.value.font_color};`
})
const select_icon_color = computed(() => {
if (!temp.value) return ""
return `${temp.value.select_icon_color};`
})
const icon_color = computed(() => {
if (!temp.value) return ""
return `${temp.value.icon_color};`
})
onShow(async () => {
const storedCms = uni.getStorageSync("cms") || { cmsLst: [], title_html: '' }
titleContent.value = storedCms.title_html || ''
cms.value = storedCms
currentTemplate.value = storedCms.temp_id || ''
await getTemp()
})
async function getTemp() {
if (currentTemplate.value === "not_image") {
temp.value = {}
}
if (!currentTemplate.value) {
temp.value = {}
return
}
const res = await uniCloud.importObject("uni-cms-articles").getTemp({
temp_id: currentTemplate.value
})
if (res.code === 200) {
temp.value = res.data
}
}
async function updateTemplate(e) {
currentTemplate.value = e
await getTemp()
}
function onBack() {
uni.showToast({ title: 'test' })
}
function tabbarClick(e) {
tabbarValue.value = e
showPopupFrom()
}
function showPopupFrom() {
showPop.value = true
}
function isCheckboxDisabled(arr, name) {
if (arr.length < 2 || arr.includes(name)) {
return false
}
return true
}
function checkboxChange(e) {
console.log(e)
}
function groupChange(n) {
console.log('groupChange', n)
}
function radioChange(n) {
console.log('radioChange', n)
}
function formatDate(date, format) {
const map = {
'yyyy': date.getFullYear(),
'MM': String(date.getMonth() + 1).padStart(2, '0'),
'dd': String(date.getDate()).padStart(2, '0'),
'HH': String(date.getHours()).padStart(2, '0'),
'mm': String(date.getMinutes()).padStart(2, '0'),
'ss': String(date.getSeconds()).padStart(2, '0')
}
return format.replace(/yyyy|MM|dd|HH|mm|ss/g, matched => map[matched])
}
function formatTime() {
return formatDate(new Date(), 'yyyy-MM-dd HH:mm')
}
/**
* 保存文章
* @param {Number} articleStatus 1 发布,0 存草稿
*/
async function saveArticle(articleStatus) {
if (saving.value) return
const storedCms = uni.getStorageSync("cms")
if (!storedCms) {
return uni.showToast({ title: '内容已丢失,请重新编辑', icon: 'none' })
}
if (!storedCms.title || !storedCms.title.trim()) {
return uni.showToast({ title: '请先填写标题', icon: 'none' })
}
// 草稿允许只有标题,发布必须有正文
const hasContent = Array.isArray(storedCms.cmsLst) && storedCms.cmsLst.length > 0
if (articleStatus === 1 && !hasContent) {
return uni.showToast({ title: '正文不能为空', icon: 'none' })
}
saving.value = true
try {
// 归属与 IP 由服务端从登录态与请求上下文获取,这里不再传 user_id
const query = {
title: storedCms.title,
title_html: storedCms.title_html,
title_delta: storedCms.title_delta,
thumbnail: storedCms.thumbnail,
p_type: storedCms.p_type,
category_id: storedCms.category_id,
temp_id: currentTemplate.value || storedCms.temp_id || "",
music_url_id: storedCms.music_url_id || {},
edit_type: "mobile",
cmsLst: storedCms.cmsLst || [],
content: storedCms.content && Array.isArray(storedCms.content.ops)
? storedCms.content
: cmsLstToDelta(storedCms.cmsLst),
excerpt: storedCms.excerpt || "",
article_status: articleStatus,
}
let res
if (storedCms._id) {
res = await uniCloud.importObject("uni-cms-articles")
.update_cms_articles({ ...query, id: storedCms._id })
} else {
res = await uniCloud.importObject("uni-cms-articles").add_cms_articles(query)
}
if (!res || res.code !== 200) {
throw new Error((res && res.msg) || '保存失败')
}
uni.setStorageSync("cms", "")
uni.showToast({
title: articleStatus === 1 ? '发布成功' : '已存为草稿',
icon: 'none'
})
setTimeout(() => {
uni.switchTab({ url: "/pages/my/my" })
}, 800)
} catch (e) {
console.error('保存文章失败:', e)
uni.showToast({ title: e.message || '保存失败,请重试', icon: 'none' })
} finally {
saving.value = false
}
}
async function back() {
uni.navigateBack({
delta: 1,
fail: (err) => {
uni.navigateTo({ url: "/pages2/editCms/editCms" })
}
})
}
return {
voteType,
voteEndTime,
cms,
temp,
titleContent,
tabbarValue,
showPop,
maskClick,
popFrom,
list,
currentTemplate,
tabbarText,
userInfo,
isExpired,
btn_send_commends_color,
backgrond_color,
color,
select_icon_color,
icon_color,
getTemp,
updateTemplate,
onBack,
tabbarClick,
showPopupFrom,
isCheckboxDisabled,
checkboxChange,
groupChange,
radioChange,
formatDate,
formatTime,
saving,
saveArticle,
back,
}
}
}
</script>
<style lang="scss" scoped>
.draft-bar {
padding: 16rpx 20rpx;
background-color: #fff;
border-bottom: 1rpx solid #eee;
}
.draft-btn {
font-size: 28rpx;
line-height: 72rpx;
height: 72rpx;
color: #c11c1f;
background-color: #fff;
border: 1rpx solid #c11c1f;
border-radius: 36rpx;
&::after {
border: none;
}
}
.grid-container {
display: flex;
gap: 10px;
padding: 15px;
background: #f5f5f5;
}
.grid-item {
flex: 1;
min-width: 0;
aspect-ratio: 1;
background: white;
border-radius: 8px;
overflow: hidden;
}
.page-container {
padding: 20rpx 0;
}
.rectangle-img {
width: 100%;
height: 100%;
object-fit: cover;
aspect-ratio: 16/9;
}
.meta {
position: relative;
z-index: 1;
padding-top: 20rpx;
.title {
.text {
font-size: 40rpx;
line-height: 66rpx;
font-weight: bold;
color: #333;
}
}
.excerpt {
margin-top: 10rpx;
.text {
font-size: 26rpx;
line-height: 40rpx;
color: #999;
}
}
.author {
display: flex;
align-items: center;
justify-content: flex-start;
flex-direction: row;
margin-top: 20rpx;
.at,
.split,
.date {
font-size: 26rpx;
color: #ccc;
}
.split {
margin: 0 10rpx;
}
}
}
.body {
padding: 20rpx;
.cms-body {
display: flex;
min-height: 60vh;
width: 100vw;
}
}
.v-preview {
.img {
width: 100%;
}
.btn-area {
position: relative;
top: -50rpx;
}
.red-btn-area {
display: flex;
justify-content: center;
padding: 10rpx 0;
margin: 0 40rpx 20rpx 40rpx;
font-size: 20rpx;
.red-btn {
position: relative;
color: #fff;
font-weight: 700;
font-size: 24rpx;
height: 40rpx;
line-height: 40rpx;
margin: 0;
background-image: linear-gradient(to bottom, #c11c1f, #5c0201);
+.red-btn {
margin-left: 20rpx;
}
}
}
.red-btn-plain-area {
display: flex;
justify-content: center;
padding: 10rpx 0;
margin: 0 40rpx;
font-size: 21rpx;
border-top: 2px solid;
border-bottom: 2px solid;
border-image: linear-gradient(to right,
#fff,
#f44336bf,
#b13636,
#f44336bf,
#fff) 1;
.red-btn-plain {
position: relative;
color: #b13636;
font-weight: 700;
padding-left: 15rpx;
+.red-btn-plain {
margin-left: 10rpx;
&::before {
content: "";
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 2px;
height: 20rpx;
background-color: #b13636;
}
}
}
}
}
.vote-container {
padding: 20rpx;
}
.vote-title {
font-size: 16px;
font-weight: bold;
color: #333;
display: block;
margin-bottom: 10rpx;
}
.deadline {
font-size: 12px;
color: #999;
margin-bottom: 30rpx;
display: block;
}
.radio-group {
margin-bottom: 40rpx;
}
.option-item {
display: flex;
align-items: center;
margin: 15rpx 0;
}
.option-label {
margin-left: 20rpx;
font-size: 14px;
}
.vote-btn {
background-color: #2979FF;
color: white;
border-radius: 8rpx;
font-size: 14px;
margin-top: 30rpx;
&[disabled] {
background-color: #CCCCCC;
}
}
</style>