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 构建失败
This commit is contained in:
2026-09-12 01:02:45 +08:00
parent 4f5893f87a
commit 3117146281
26 changed files with 2043 additions and 337 deletions
@@ -1,9 +1,59 @@
const db = uniCloud.database()
const collection = db.collection('uni-cms-articles')
const uniID = require('uni-id-common')
const {
normalizeCmsList,
cmsLstToDelta,
createVoteId,
hasRenderableContent
} = require('./content-adapter')
const isAdmin = (payload) => (payload.role || []).includes('admin')
function normalizeArticlePayload(query = {}, existing = {}) {
const edit_type = query.edit_type !== undefined ? query.edit_type : existing.edit_type
const mobile = edit_type === 'mobile'
const cmsLst = query.cmsLst !== undefined
? normalizeCmsList(query.cmsLst)
: (Array.isArray(existing.cmsLst) ? normalizeCmsList(existing.cmsLst) : [])
const content = mobile
? cmsLstToDelta(cmsLst)
: (query.content && Array.isArray(query.content.ops) ? query.content : existing.content || { ops: [] })
if (query.article_status === 1 && !hasRenderableContent(content, cmsLst)) {
throw new Error('正文不能为空')
}
const normalizedList = cmsLst.map(item => {
if (item.type !== 'vote' || !item.vote) return item
return {
...item,
vote: {
...item.vote,
vote_id: item.vote.vote_id || createVoteId()
}
}
})
return { edit_type, cmsLst: normalizedList, content }
}
function valueOrExisting(query, existing, key, fallback) {
return query[key] !== undefined ? query[key] : (existing[key] !== undefined ? existing[key] : fallback)
}
/** 转义正则元字符,避免用户输入被当作正则执行 */
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function toPositiveInt(value, fallback, max) {
const n = Number(value)
if (!Number.isFinite(n) || n < 0) return fallback
const i = Math.floor(n)
return max !== undefined ? Math.min(i, max) : i
}
module.exports = {
_before: function () { // 通用预处理器
@@ -63,44 +113,36 @@ module.exports = {
}
},
async update_cms_articles(query) {
try {
const payload = await this._requireLogin()
const { id } = query || {}
await this._requireOwner(id, payload)
async update_cms_articles(query = {}) {
try {
const payload = await this._requireLogin()
const { id } = query
await this._requireOwner(id, payload)
const oldRes = await collection.doc(id).get()
const existing = oldRes.data && oldRes.data[0]
if (!existing) throw new Error('文章不存在')
const { edit_type, cmsLst, content } = normalizeArticlePayload(query, existing)
const {
title,
title_html,
title_delta,
thumbnail,
p_type,
category_id,
temp_id,
edit_type,
excerpt,
cmsLst,
article_status
} = query
const now = new Date()
const r = await collection.doc(id).update({
title,
// 作者不可被客户端改写,始终以数据库中的归属为准
title_html,
title_delta,
thumbnail,
p_type,
category_id,
temp_id,
edit_type,
excerpt,
cmsLst,
// 修改不应重置发布时间,只更新最后修改时间
last_modify_date: now,
last_modify_ip: this.getClientInfo().clientIP,
article_status
})
const now = new Date()
const r = await collection.doc(id).update({
title: valueOrExisting(query, existing, 'title', ''),
// 作者不可被客户端改写,始终以数据库中的归属为准
title_html: valueOrExisting(query, existing, 'title_html', ''),
title_delta: valueOrExisting(query, existing, 'title_delta', {}),
thumbnail: valueOrExisting(query, existing, 'thumbnail', []),
p_type: valueOrExisting(query, existing, 'p_type', ''),
category_id: valueOrExisting(query, existing, 'category_id', ''),
temp_id: valueOrExisting(query, existing, 'temp_id', ''),
edit_type,
excerpt: valueOrExisting(query, existing, 'excerpt', ''),
cmsLst,
content,
music_url_id: valueOrExisting(query, existing, 'music_url_id', {}),
// 修改不应重置发布时间,只更新最后修改时间
last_modify_date: now,
last_modify_ip: this.getClientInfo().clientIP,
article_status: valueOrExisting(query, existing, 'article_status', 0)
})
if (!r.updated) {
return { code: 500, msg: '更新失败' }
}
@@ -118,47 +160,38 @@ module.exports = {
}
},
async add_cms_articles(query) {
try {
const payload = await this._requireLogin()
async add_cms_articles(query = {}) {
try {
const payload = await this._requireLogin()
const { edit_type, cmsLst, content } = normalizeArticlePayload(query)
const title = typeof query.title === 'string' ? query.title.trim() : ''
if (!title) throw new Error('标题不能为空')
const {
title,
title_html,
title_delta,
thumbnail,
p_type,
category_id,
temp_id,
edit_type,
excerpt,
cmsLst,
article_status
} = query
const now = new Date()
const r = await collection.add({
title,
// 作者一律取登录态,不接受客户端传入
user_id: payload.uid,
title_html,
title_delta,
thumbnail,
p_type,
category_id,
temp_id,
edit_type,
excerpt,
// 计数类字段由服务端维护,初始为 0
view_count: 0,
like_count: 0,
collect_count: 0,
cmsLst,
publish_date: now,
last_modify_date: now,
last_modify_ip: this.getClientInfo().clientIP,
article_status
})
const now = new Date()
const r = await collection.add({
title,
// 作者一律取登录态,不接受客户端传入
user_id: payload.uid,
title_html: query.title_html || '',
title_delta: query.title_delta || {},
thumbnail: Array.isArray(query.thumbnail) ? query.thumbnail : [],
p_type: query.p_type || '',
category_id: query.category_id || '',
temp_id: query.temp_id || '',
edit_type,
excerpt: query.excerpt || '',
music_url_id: query.music_url_id || {},
// 计数类字段由服务端维护,初始为 0
view_count: 0,
like_count: 0,
collect_count: 0,
cmsLst,
content,
publish_date: now,
last_modify_date: now,
last_modify_ip: this.getClientInfo().clientIP,
article_status: query.article_status === 1 ? 1 : 0
})
const res = await collection.doc(r.id).get()
return {
code: 200,
@@ -175,7 +208,6 @@ module.exports = {
async getTemp(query) {
try {
await this._requireLogin()
const { temp_id } = query || {}
if (!temp_id) return { code: 400, msg: '缺少 temp_id' }
const res = await db.collection("cms-temp").doc(temp_id).get()
@@ -188,6 +220,62 @@ module.exports = {
}
},
/**
* 已发布文章流(公开,无需登录)。
*
* 原生安卓端与游客首页依赖此方法。没有它时匿名用户只能拿到空列表,
* 因为 get_cms_articles_for_userId 强制登录且只返回本人文章。
*
* 只投影列表所需字段:cmsLst / content 体积大且列表页用不到。
* 另外 schema.ext 的 afterRead 在读取 content 字段时会顺带给 view_count 加 1,
* 列表页不该把「出现在首页」算成一次阅读,因此这里不取 content。
*/
async getPublishedArticles(query = {}) {
try {
const offset = toPositiveInt(query.offset, 0)
const limit = toPositiveInt(query.limit, 20, 100)
const where = { article_status: 1 }
if (typeof query.category_id === 'string' && query.category_id) {
where.category_id = query.category_id
}
const keyword = typeof query.keyword === 'string' ? query.keyword.trim() : ''
if (keyword) where.title = new RegExp(escapeRegExp(keyword), 'i')
const res = await collection
.where(where)
.field({
title: true,
title_html: true,
title_delta: true,
user_id: true,
thumbnail: true,
excerpt: true,
category_id: true,
temp_id: true,
edit_type: true,
p_type: true,
music_url_id: true,
article_status: true,
view_count: true,
like_count: true,
collect_count: true,
publish_date: true,
last_modify_date: true
})
.orderBy('publish_date', 'desc')
.skip(offset)
.limit(limit)
.get()
const total = await collection.where(where).count()
return { code: 200, data: res.data, total: total.total, offset, limit }
} catch (e) {
console.error('getPublishedArticles 失败:', e.message)
return { code: 500, msg: e.message }
}
},
async getTotal() {
try {
const res = await collection.where({ article_status: 1 }).count()