# Conflicts:
#	components-diy/diy-image-nav.vue
This commit is contained in:
2026-09-22 15:44:27 +08:00
3 changed files with 2894 additions and 77 deletions

325
common/js/media-upload.js Normal file
View File

@@ -0,0 +1,325 @@
// common/js/media-upload.js
// ai-chat / ai-agent 媒体上传: 图片 (PHP 中转 + STS 直传) / 音视频 / 其他文件
//
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 之前 ai-chat-tools-panel.vue 引用了
// uploadImage / uploadMedia, 但本文件从未落库, 选图 / 选文件立即抛
// "Cannot find module '@/common/js/media-upload.js'". 此次按
// docs/ai-agent-integration.md §3.4 Step 1 落地, 顺手补 store import (设计稿漏).
//
// 调用方:
// ai-chat-tools-panel.vue → selectImage() / _uploadOneFile()
// ai-chat-message.vue → 直接用 aiService.sendMessage 传 attachments (本模块不出场)
//
// 上传策略:
// 图片: 优先 PHP 中转 (≤2MB 走 backend), 超过或商家选了 oss_sts/auto → STS 直传
// 音视频/文件: 一律 STS 直传 (体积大概率 >2MB)
//
// 端点:
// PHP 中转: POST /<addon>/api/upload/<image|file> multipart/form-data
// STS 凭证: POST /<addon>/api/upload/ossSts application/json
// STS 直传: PUT <provider>/<key> 云存储协议 (4-field STS 或 single-URL)
import aiService from './ai-service.js'
import Config from './config.js'
import store from '@/store/index.js'
const SIZE_LIMIT_PHP_PASSTHROUGH = 2 * 1024 * 1024 // 2MB
/**
* 上传图片 (type=image)
* @param {string} filePath - uni.chooseImage 返回的 tempFilePaths[0]
* @returns {Promise<{url, mime, size, name, kind, used_strategy}>}
*/
export async function uploadImage(filePath) {
const addon = aiService.getAddonName() // 'aikefu' | 'aiagent'
const config = await aiService.getServiceConfig()
const strategy = config?.upload_strategy || 'backend'
// H5 端: >10MB 先压
let compressedPath = filePath
// #ifdef H5
const fileSize = await getFileSize(filePath)
if (fileSize > 10 * 1024 * 1024) {
try {
const compressed = await uni.compressImage({
src: filePath,
quality: 80,
compressedWidth: 2048,
compressedHeight: 2048,
})
compressedPath = compressed.tempFilePath || filePath
} catch (e) {
console.warn('[media-upload] compressImage failed, use original:', e)
}
}
// #endif
// 检查是否需要走 STS (>2MB 或商家选 oss_sts)
const fileSizeFinal = await getFileSize(compressedPath)
const useSts = strategy === 'oss_sts'
|| (strategy === 'auto' && fileSizeFinal > SIZE_LIMIT_PHP_PASSTHROUGH)
if (useSts) {
return await uploadViaSts(compressedPath, 'image', addon, aiService.getAgentId(), config)
} else {
return await uploadViaPhp(compressedPath, 'image', addon, aiService.getAgentId())
}
}
/**
* 上传 audio / video / file (一律 STS, 因为体积大概率 >2MB)
* @param {string} filePath
* @param {'audio'|'video'|'file'} kind
*/
export async function uploadMedia(filePath, kind) {
if (!['audio', 'video', 'file'].includes(kind)) {
throw new Error(`invalid kind: ${kind}`)
}
const addon = aiService.getAddonName()
const config = await aiService.getServiceConfig()
return await uploadViaSts(filePath, kind, addon, aiService.getAgentId(), config)
}
/**
* PHP 中转 (multipart/form-data)
* 端点必须用 addon 模式 URL: /<addon>/api/upload/<image|file>
* 注释: InitRoute 注册 aikefu/api/upload 时挂在 addon namespace 下,
* /api/aikefu/upload/* 才是命中路径; /api/kefu/upload/* 不命中
* (那是普通接口命名空间, 上传走的是 addon 专属路由)
*/
async function uploadViaPhp(filePath, kind, addon, agentId) {
const url = (addon === 'aikefu' ? '/aikefu/api/upload/' : '/aiagent/api/upload/')
+ (kind === 'image' ? 'image' : 'file')
// #ifdef MP-WEIXIN
return new Promise((resolve, reject) => {
uni.uploadFile({
url: Config.baseUrl + url,
filePath,
name: 'file',
formData: {
kind,
agent_id: addon === 'aiagent' ? agentId : undefined,
config_uuid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
uniacid: aiService.getUniacid(),
user_id: aiService.getUserId?.() || '',
},
success: (res) => {
try {
const data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
if (data.code === 0 || data.success) {
resolve({
url: data.data.url,
mime: data.data.mime,
size: data.data.size,
name: data.data.name,
kind: data.data.kind || kind,
used_strategy: data.data.used_strategy || 'backend',
oss_sts_required: data.data.oss_sts_required || false,
})
} else {
reject(new Error(data.msg || data.message || '上传失败'))
}
} catch (e) {
reject(new Error('解析响应失败: ' + e.message))
}
},
fail: (err) => reject(new Error('网络错误: ' + (err.errMsg || err.message))),
})
})
// #endif
// #ifdef H5
const form = new FormData()
const file = await pathToFile(filePath, kind)
form.append('file', file)
form.append('kind', kind)
if (addon === 'aiagent') form.append('agent_id', agentId)
else form.append('config_uuid', aiService.getCurrentConfigUuid?.() || '')
form.append('uniacid', aiService.getUniacid())
form.append('user_id', aiService.getUserId?.() || '')
const res = await fetch(Config.baseUrl + url, {
method: 'POST',
headers: { 'token': store.state.token || '' },
body: form,
})
const data = await res.json()
if (data.code === 0 || data.success) {
return {
url: data.data.url,
mime: data.data.mime,
size: data.data.size,
name: data.data.name,
kind: data.data.kind || kind,
used_strategy: data.data.used_strategy || 'backend',
oss_sts_required: data.data.oss_sts_required || false,
}
}
throw new Error(data.msg || data.message || '上传失败')
// #endif
}
/**
* STS 直传 (拿临时凭证 → 直传云存储)
*
* PR-OSS-REGISTRY (2026-09-15) 后端支持 12 个 provider, 协议层分两类:
* A) 4-field STS (8 个: alioss/qiniu/txcos/baidubos/qingstor/jdoss/ucloud/wyoss)
* → 前端拼 PUT URL, Authorization header = STS <ak>:<sk>
* B) single-URL (4 个: huaweiobs/volcengine/awsoss/minio)
* → sts.accessKeyId 已是完整预签 PUT URL, sts.securityToken='<PROVIDER>-SIGNED'
* 标记, 不带 Authorization
*
* 后端 stsResp.data.data 可能字段:
* { provider, bucket, region, accessKeyId, accessKeySecret, securityToken,
* key_prefix_tpl, site_id, host? (single-URL 模式), endpoint? (qiniu 7 参兼容) }
*/
async function uploadViaSts(filePath, kind, addon, agentId, config) {
// 1. 拿 STS 凭证 (addon 模式 URL)
const stsUrl = (addon === 'aikefu' ? '/aikefu/api/upload/' : '/aiagent/api/upload/') + 'ossSts'
const stsResp = await uni.request({
url: Config.baseUrl + stsUrl,
method: 'POST',
header: {
'Content-Type': 'application/json',
'token': store.state.token || ''
},
data: {
agent_id: addon === 'aiagent' ? agentId : undefined,
config_uuid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
uniacid: aiService.getUniacid(),
user_id: aiService.getUserId?.() || '',
},
})
if (stsResp.statusCode !== 200 || (stsResp.data.code !== 0 && !stsResp.data.success)) {
throw new Error(stsResp.data?.msg || stsResp.data?.message || 'STS 凭证获取失败')
}
const sts = stsResp.data.data
const provider = sts.provider || 'alioss' // 老后端默认 alioss
// 2. 拼 PUT URL (用后端返回的 key_prefix_tpl 替换占位符)
const ext = (filePath.split('.').pop() || 'bin').toLowerCase()
const uuid = 'xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = Math.random() * 16 | 0
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16)
})
const now = new Date()
const key = (sts.key_prefix_tpl || 'site/{site_id}/{yyyy}/{mm}/{dd}/{rand}')
.replace('{site_id}', sts.site_id || '0')
.replace('{agent_id}', agentId || '0')
.replace('{yyyy}', now.getFullYear().toString())
.replace('{mm}', String(now.getMonth() + 1).padStart(2, '0'))
.replace('{dd}', String(now.getDate()).padStart(2, '0'))
.replace('{rand}', uuid)
+ '.' + ext
// ★ PR-OSS-REGISTRY (2026-09-15): provider 分流
// single-URL 模式: securityToken 末尾是 -SIGNED (OBS-SIGNED / TOS-SIGNED / AWS-SIGNED / MINIO-SIGNED)
const isSingleUrlMode = typeof sts.securityToken === 'string'
&& /-(SIGNED)$/.test(sts.securityToken)
let ossUrl, headers
if (isSingleUrlMode) {
// B) single-URL: sts.accessKeyId 是完整预签 PUT URL, 不需要 Authorization header
ossUrl = sts.accessKeyId
headers = { 'Content-Type': guessMime(ext) }
} else if (provider === 'qiniu') {
// A) qiniu 兼容模式: sts.accessKeyId = uptoken, endpoint 来自 sts.endpoint 或 sts.host
ossUrl = (sts.endpoint || sts.host || `https://upload-${sts.region}.qiniup.com`) + '/' + key
headers = {
'Authorization': 'UpToken ' + sts.accessKeyId,
'Content-Type': guessMime(ext),
}
} else {
// A) 4-field STS 默认 (alioss/txcos/baidubos/qingstor/jdoss/ucloud/wyoss)
// endpoint 模板按 provider 区分, 后端未返 endpoint 时按 OSS 模板拼
const tpl = (provider === 'alioss') ? `https://${sts.bucket}.${sts.region}.aliyuncs.com`
: (provider === 'txcos') ? `https://${sts.bucket}-${sts.region}.cos.${sts.region}.myqcloud.com`
: (provider === 'baidubos') ? `https://${sts.bucket}.${sts.region}.bcebos.com`
: (provider === 'jdoss') ? `https://${sts.bucket}.${sts.region}.jdcloud-oss.com`
: (provider === 'wyoss') ? `https://${sts.bucket}.${sts.host || sts.region}`
: (sts.endpoint ? sts.endpoint.replace('{bucket}', sts.bucket) : `https://${sts.bucket}.${sts.region}`)
ossUrl = `${tpl}/${key}`
headers = {
'Authorization': `STS ${sts.accessKeyId}:${sts.accessKeySecret}`,
'x-oss-security-token': sts.securityToken,
'Content-Type': guessMime(ext),
}
}
// 3. 直传云存储
// #ifdef MP-WEIXIN
await new Promise((resolve, reject) => {
uni.uploadFile({
url: ossUrl,
filePath,
name: 'file',
formData: {},
header: headers,
success: (res) => {
if (res.statusCode >= 200 && res.statusCode < 300) resolve()
else reject(new Error(`${provider} PUT ${res.statusCode}`))
},
fail: reject,
})
})
// #endif
// #ifdef H5
const file = await pathToFile(filePath, kind)
const ossRes = await fetch(ossUrl, {
method: 'PUT',
headers,
body: file,
})
if (!ossRes.ok) throw new Error(`${provider} PUT ${ossRes.status}`)
// #endif
// 4. 返回公网 URL
return {
url: ossUrl.split('?')[0], // single-URL 模式 URL 带 ?<签名参数>, 拼 chat attachment 用 base URL
mime: guessMime(ext),
size: file.size || 0,
name: key.split('/').pop(),
kind,
used_strategy: 'oss_sts',
provider,
}
}
async function getFileSize(filePath) {
// #ifdef MP-WEIXIN
return new Promise((resolve) => {
uni.getFileInfo({
filePath,
success: (res) => resolve(res.size),
fail: () => resolve(0),
})
})
// #endif
// #ifdef H5
try {
const res = await fetch(filePath)
const blob = await res.blob()
return blob.size
} catch (e) { return 0 }
// #endif
}
async function pathToFile(filePath, kind) {
// #ifdef H5
const res = await fetch(filePath)
const blob = await res.blob()
return new File([blob], filePath.split('/').pop() || `${kind}.bin`, { type: blob.type })
// #endif
}
function guessMime(ext) {
const map = {
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp',
mp3: 'audio/mpeg', wav: 'audio/wav', aac: 'audio/aac',
mp4: 'video/mp4', mov: 'video/quicktime',
pdf: 'application/pdf', txt: 'text/plain',
}
return map[ext] || 'application/octet-stream'
}
export default { uploadImage, uploadMedia }

View File

@@ -2,43 +2,51 @@
<view :style="componentStyle" data-component-name="diy-image-nav">
<!-- PR-FRONTEND-DIY-SCROLLBAR (2026-09-18): 水平滚动条显隐三态(后台配置)
slide=滑动时显示(默认) / always=始终显示 / never=永不显示
小程序端: show-scrollbar 仅在 enhanced=true 时生效, 所以两者同步绑定 -->
<scroll-view :class="['image-nav', value.showStyle == 'fixed' ? 'fixed-layout' : value.showStyle, scrollBarClass]"
:style="imageSpacingStyle"
:scroll-x="value.showStyle == 'singleSlide'"
:show-scrollbar="scrollBarMode != 'never'"
:enhanced="scrollBarMode != 'never'">
<view class="uni-scroll-view-content">
<view v-for="(item, index) in value.list" :key="index" :class="['image-nav-item', value.mode]"
:style="imageItemStyle">
<!-- 图片部分 -->
<view v-if="value.mode != 'text'" class="image-img" :style="{
'font-size': (value.imageSize * 2 + 'rpx') + ';',
'width': (item.imgWidth / 2 + 'rpx') + ';',
'height': (item.imgHeight / 2 + 'rpx') + ';'
}">
<image v-if="item.link.wap_url" :style="{
'width': (item.imgWidth / 2 + 'rpx') + ';',
'height': (item.imgHeight / 2 + 'rpx') + ';'
}" :src="$util.img(item.imageUrl) || $util.img('public/uniapp/default_img/goods.png')"
show-menu-by-longpress @tap.stop="redirectTo(item.link)"></image>
<image v-else :style="{
'width': (item.imgWidth / 2 + 'rpx') + ';',
'height': (item.imgHeight / 2 + 'rpx') + ';'
}" :src="$util.img(item.imageUrl) || $util.img('public/uniapp/default_img/goods.png')"
show-menu-by-longpress @tap.stop="previewImg(item.imageUrl)"></image>
</view>
注意: 微信小程序端原生 show-scrollbar 属性被忽略(仅在 App 端生效),
无法保证"始终显示/滑动时显示", 因此小程序端改用【自定义滚动条】(.img-nav-scrollbar),
H5 端用 CSS 伪元素(见下方 #ifdef H5 样式) —— 两端视觉/逻辑保持一致。
fix (2026-09-22): 小程序端"始终显示"失效 —— 见 measureScrollbar() 注释(内容宽度测错)。 -->
<view class="image-nav-wrap">
<scroll-view :class="['image-nav', value.showStyle == 'fixed' ? 'fixed-layout' : value.showStyle, scrollBarClass]"
:style="imageSpacingStyle"
:scroll-x="value.showStyle == 'singleSlide'"
:show-scrollbar="showScrollbarAttr"
:enhanced="enhancedScrollbar"
@scroll="onSlideScroll">
<view class="uni-scroll-view-content">
<view v-for="(item, index) in value.list" :key="index" :class="['image-nav-item', value.mode]"
:style="imageItemStyle">
<!-- 图片部分 -->
<!-- PR-FRONTEND-DIY-IMAGE-NAV-IMG-SIZE (2026-09-22):
图片按后台上传/配置的尺寸渲染,避免 uni <image> 取不到尺寸时
回退到浏览器默认 320x240 而被"撑到最大";mode=aspectFit 保证不拉伸变形 -->
<view v-if="value.mode != 'text'" class="image-img" :style="imageBoxStyle(item)">
<image v-if="item.link.wap_url" class="image-content" :style="imageInnerStyle(item)"
mode="aspectFit"
:src="$util.img(item.imageUrl) || $util.img('public/uniapp/default_img/goods.png')"
show-menu-by-longpress @tap.stop="redirectTo(item.link)"></image>
<image v-else class="image-content" :style="imageInnerStyle(item)" mode="aspectFit"
:src="$util.img(item.imageUrl) || $util.img('public/uniapp/default_img/goods.png')"
show-menu-by-longpress @tap.stop="previewImg(item.imageUrl)"></image>
</view>
<!-- 文字部分 -->
<text class="image-text" :style="{
'width': (item.imgWidth / 2 + 'rpx') + ';',
'font-size': (value.font.size * 2 + 'rpx') + ';',
'font-weight': value.font.weight + ';',
'color': value.font.color + ';'
}">{{ item.title }}</text>
<!-- 文字部分 -->
<text class="image-text" :style="{
'width': (item.imgWidth / 2 + 'rpx') + ';',
'font-size': (value.font.size * 2 + 'rpx') + ';',
'font-weight': value.font.weight + ';',
'color': value.font.color + ';'
}">{{ item.title }}</text>
</view>
</view>
</scroll-view>
<!-- #ifdef MP -->
<!-- 小程序端自定义水平滚动条: 原生 show-scrollbar 被忽略, 这里自绘以对齐后台/ H5 -->
<view v-if="showCustomScrollbar" class="img-nav-scrollbar">
<view class="img-nav-scrollbar-thumb" :style="scrollbarThumbStyle"></view>
</view>
</scroll-view>
<!-- #endif -->
</view>
<ns-login ref="login"></ns-login>
</view>
</template>
@@ -69,7 +77,9 @@ export default {
_suppressNextClick: false, // 拖拽结束后拦截一次 click,防止误触发跳转
// PR-FRONTEND-DIY-SCROLLBAR (2026-09-18): "滑动时显示"模式专用
// 滚动中为 true → 给 scroll-view 加 is-scrolling 类展示滚动条, 停止 800ms 后隐藏
slideScrolling: false
slideScrolling: false,
// 小程序端自定义滚动条度量(原生 show-scrollbar 被忽略, 改用自绘)
mpScroll: { viewW: 0, contentW: 0, left: 0 }
}
},
created() {
@@ -87,16 +97,47 @@ export default {
self.bindSlideScrollbar();
});
// #endif
// 小程序端:测量滚动条所需的视口/内容宽度(图片尺寸由内联样式决定, 渲染后稳定)
// 多次(0/300/1000ms)重测: 首次渲染时节点可能尚未布局完成, 单次测量容易拿不到宽度
// → 滚动条不显示; 之后的测量只覆盖有效值, 不会把已测得的宽度清零。
// #ifdef MP
var selfMp = this;
this.$nextTick(function() {
selfMp.measureScrollbar();
});
this._mpMeasureTimers = [300, 1000].map(function(delay) {
return setTimeout(function() {
selfMp.measureScrollbar();
}, delay);
});
// #endif
},
beforeDestroy() {
// #ifdef H5
this.unbindSlideMouseEvents();
this.unbindSlideScrollbar();
// #endif
// #ifdef MP
if (this._mpMeasureTimers) {
this._mpMeasureTimers.forEach(function(t) { clearTimeout(t); });
this._mpMeasureTimers = null;
}
// #endif
},
watch: {
componentRefresh(newValue) {
// 监听组件刷新
},
// 小程序端: 列表/数量变化后, 内容宽度会变, 重新测量自定义滚动条
'value.list'(newVal, oldVal) {
// #ifdef MP
if (newVal !== oldVal) {
var self = this;
this.$nextTick(function() {
self.measureScrollbar();
});
}
// #endif
}
},
computed: {
@@ -240,6 +281,55 @@ export default {
return cls;
},
// 原生 show-scrollbar / enhanced 仅在 App 端生效, 小程序端被忽略且会干扰原生行为,
// 故小程序端一律关闭, 改用自定义滚动条; H5 端这两个属性为 no-op, 不影响。
enhancedScrollbar() {
// #ifndef MP
return this.scrollBarMode != 'never';
// #endif
// #ifdef MP
return false;
// #endif
},
showScrollbarAttr() {
// #ifndef MP
return this.scrollBarMode != 'never';
// #endif
// #ifdef MP
return false;
// #endif
},
// 小程序端自定义滚动条的"是否显示"
showCustomScrollbar() {
// #ifdef MP
if (this.scrollBarMode === 'never') return false;
// 只有 singleSlide(横向可滚动)才有滚动条, 与 H5 的 .singleSlide 样式作用域保持一致
if (this.value.showStyle != 'singleSlide') return false;
// 内容没有溢出(无需横向滚动)则不显示
if (!(this.mpScroll.contentW > this.mpScroll.viewW)) return false;
// 滑动时显示: 仅在滚动中显示
if (this.scrollBarMode === 'slide') return this.slideScrolling;
// 始终显示: 一直显示
return true;
// #endif
},
// 小程序端自定义滚动条 thumb 的内联样式(width + 位移)
scrollbarThumbStyle() {
// #ifdef MP
const viewW = this.mpScroll.viewW;
const contentW = this.mpScroll.contentW;
const left = this.mpScroll.left;
if (!viewW || !contentW || contentW <= viewW) return 'display:none;';
const thumbW = Math.max(20, Math.floor(viewW * viewW / contentW));
const maxLeft = contentW - viewW;
const ratio = maxLeft > 0 ? Math.min(1, Math.max(0, left / maxLeft)) : 0;
const thumbLeft = ratio * (viewW - thumbW);
return 'width:' + thumbW + 'px;transform:translateX(' + thumbLeft + 'px);';
// #endif
},
// ========== PR-FRONTEND-DIY-IMAGE-NAV-SPACING (2026-09-18): 图片自定义间距 ==========
// 后台"滑动图片"组件(componentName = ImageNav)的 3 项间距配置。
// 字段名已用线上接口 POST /api/diyview/info(uniacid=312, DIY_VIEW_INDEX) 核对, 实际为:
@@ -349,6 +439,75 @@ export default {
}
},
methods: {
// ========== PR-FRONTEND-DIY-IMAGE-NAV-IMG-SIZE (2026-09-22): 图片展示尺寸 ==========
// 目标: 图片按后台"上传/配置的尺寸"渲染, 不再被撑到最大(小程序/H5 表现一致)。
// 背景: 原写法直接把 item.imgWidth / item.imgHeight(每项字段)当成内联尺寸,
// 后台往往不下发这两个字段(或下发 "") → 生成 'NaNrpx' 这类非法值被忽略,
// <image> 于是回退到 uni-app 默认尺寸 320px x 240px, 视觉上就是"被撑到最大"。
// 取值优先级(后台数值单位按 rpx 约定, 经 $util.rpx() 转成当前端可用单位):
// ① 每一项下发的 imgWidth / imgHeight —— 沿用历史约定: 数值 ÷ 2
// ② 组件级 imgWidth / imgHeight —— 兜底, 同样 ÷ 2
// ③ 组件级"图片尺寸"imageSize —— 与 diy-graphic-nav / diy-merch-list 一致, × 2
// ④ 都没有 → 120 正方形, 只做最小安全兜底(远小于 320x240 默认值)
//
// 落地结构: 外层 .image-img(view) 与 <image> 都按解析出的尺寸渲染, 两端一致;
// 样式统一由下面的方法输出"字符串"(见 imageBoxStyle 的说明, 对象在小程序端会失效)。
rpxVal(val) {
// 项目约定: 小程序/App 输出 'Nrpx', H5 输出 'Npx'(uni.upx2px)
if (this.$util && typeof this.$util.rpx === 'function') return this.$util.rpx(val);
return val + 'rpx';
},
resolveItemImgSize(item) {
const valid = v => {
if (v === undefined || v === null || v === '') return null;
const n = Number(v);
return (!isNaN(n) && n > 0) ? n : null;
};
// ① 每一项图片自身尺寸
const iw = valid(item && item.imgWidth);
const ih = valid(item && item.imgHeight);
if (iw !== null && ih !== null) return { width: iw / 2, height: ih / 2 };
if (iw !== null) return { width: iw / 2, height: iw / 2 };
if (ih !== null) return { width: ih / 2, height: ih / 2 };
// ② 组件级 imgWidth / imgHeight
const v = this.value || {};
const ciw = valid(v.imgWidth);
const cih = valid(v.imgHeight);
if (ciw !== null && cih !== null) return { width: ciw / 2, height: cih / 2 };
if (ciw !== null) return { width: ciw / 2, height: ciw / 2 };
if (cih !== null) return { width: cih / 2, height: cih / 2 };
// ③ 组件级"图片尺寸"
const is = valid(v.imageSize);
if (is !== null) return { width: is * 2, height: is * 2 };
// ④ 安全兜底
return { width: 120, height: 120 };
},
// 图片外层占位盒样式(决定图片最终尺寸)
//
// ⚠️ 必须返回"样式字符串", 不能返回对象!
// 返回对象时 mp-weixin 的编译产物是 `style="{{(item.m0)}}"`(m0 是对象),
// 小程序 WXML 不能把对象转成 style 字符串 → 整条样式直接失效:
// · 只有 <image> 时 → 图片回退到默认 320x240(看起来"被撑到最大")
// · 外层盒没尺寸 + 图片 width:100% 时 → 高度塌成 0(图片完全不显示)
// diy-graphic-nav.vue 能在小程序正常显示, 正是因为它的 style 编译出来是字符串。
imageBoxStyle(item) {
const size = this.resolveItemImgSize(item);
const imageSize = Number(this.value && this.value.imageSize);
const font = (!isNaN(imageSize) && imageSize > 0) ? imageSize * 2 : size.height;
return 'font-size:' + this.rpxVal(font) + ';'
+ 'width:' + this.rpxVal(size.width) + ';'
+ 'height:' + this.rpxVal(size.height) + ';';
},
// 图片自身样式(与占位盒同尺寸, 配合 mode="aspectFit" 等比完整显示)
// max-width / max-height 与 diy-graphic-nav 的写法保持一致, 用于兜底夹住尺寸,
// 保证图片不会超出后台配置的大小。
imageInnerStyle(item) {
const size = this.resolveItemImgSize(item);
const w = this.rpxVal(size.width);
const h = this.rpxVal(size.height);
return 'width:' + w + ';height:' + h + ';max-width:' + w + ';max-height:' + h + ';';
},
// ========== PR-FRONTEND-DIY-SCROLLBAR (2026-09-18): H5 "滑动时显示"滚动条控制 ==========
// 背景: H5 端 scroll-view 的横向滚动条默认被 CSS 隐藏; "滑动时显示"需要监听 scroll,
// 滚动中给容器加 is-scrolling 类展示滚动条, 停止滚动 800ms 后移除类再隐藏。
@@ -356,6 +515,8 @@ export default {
bindSlideScrollbar() {
// #ifdef H5
if (this._slideScrollBound) return;
// 挂载瞬间若还没拿到滚动容器,直接 return 不标记已绑定;
// ensureSlideEls() 会在用户首次交互时重试(见 emit 处),不会永久失效。
var el = this.getSlideContainer();
if (!el) return;
this._slideScrollEl = el;
@@ -385,6 +546,113 @@ export default {
// #endif
},
// 统一的 scroll 事件处理(模板 @scroll 绑定, 小程序/H5 共用)
// · 两端:"滑动时显示"模式 → 维护 slideScrolling(滚动中标记, H5 用于 is-scrolling 类)
// · 小程序端: 驱动自定义滚动条 thumb 位置(原生 show-scrollbar 被忽略, 改用自绘)
onSlideScroll(e) {
if (this.scrollBarMode === 'slide') {
if (!this.slideScrolling) this.slideScrolling = true;
clearTimeout(this._slideScrollTimer);
this._slideScrollTimer = setTimeout(() => {
this.slideScrolling = false;
}, 800);
}
// #ifdef MP
// 首次滚动时若还没拿到尺寸, 先测量一次(图片尺寸由内联样式决定, 渲染后即稳定)
if (!this.mpScroll.viewW || !this.mpScroll.contentW) this.measureScrollbar();
// scroll-view 的 scroll 事件 detail 自带真实 scrollWidth(小程序原生返回),
// 只要它比当前记录的更大(内容更长), 就更新 —— 保证滚动条长度比例准确
if (e && e.detail && typeof e.detail.scrollWidth === 'number'
&& e.detail.scrollWidth > this.mpScroll.contentW) {
this.mpScroll.contentW = e.detail.scrollWidth;
}
let left = 0;
if (e && e.detail && typeof e.detail.scrollLeft === 'number') left = e.detail.scrollLeft;
this.mpScroll.left = left;
// #endif
},
// 小程序端: 测量 scroll-view 视口宽度与内容宽度(供自定义滚动条计算 thumb)
//
// ⚠️ fix (2026-09-22) 「始终显示水平滚动条」在小程序端不生效的根因:
// 原实现只取 .uni-scroll-view-content 的 width 当"内容宽度"。但在 mp-weixin 中,
// 该 view 是块级/flex 容器, 宽度恒等于 scroll-view 宽度(子项只是溢出它而不是撑开它),
// 于是 contentW 永远 == viewW → showCustomScrollbar / scrollbarThumbStyle 判定
// "内容没有溢出" → 滚动条永不渲染(后台选"始终显示"也无效)。
// 现改为三级取样, 取到即可用, 且已被测得的有效值不会被后续空测量覆盖:
// ① scroll-view 的 scrollOffset.scrollWidth —— WeChat 原生返回的真实滚动区宽度(首选, 含内边距)
// ② 全部 .image-nav-item 的 rect 跨度 max(right) - min(left) + 左右内边距 —— 最可靠的 DOM 兜底
// ③ 按后台下发的图片/文本尺寸累加推算(rpx → px) —— 极端情况(查询全部失败)下兜底
measureScrollbar() {
// #ifdef MP
if (!uni || !uni.createSelectorQuery) return;
var self = this;
var query = uni.createSelectorQuery().in(this);
query.select('.image-nav').fields({ size: true, scrollOffset: true });
query.selectAll('.image-nav-item').boundingClientRect();
query.exec(function(res) {
var view = res && res[0];
if (!view) return;
var viewW = view.width || 0;
var items = res && res[1];
// ① 原生 scrollWidth
var contentW = view.scrollWidth || 0;
// ② 首尾 item 的 rect 跨度(与当前滚动位置无关: 两端同步平移, 差值稳定)
if (contentW <= viewW && items && items.length) {
var minLeft = Infinity;
var maxRight = -Infinity;
for (var i = 0; i < items.length; i++) {
var r = items[i];
if (!r) continue;
if (r.left < minLeft) minLeft = r.left;
if (r.right > maxRight) maxRight = r.right;
}
if (maxRight > minLeft) {
var padPx = (uni.upx2px ? uni.upx2px(16) : 8) * 2; // .image-nav 左右各 16rpx
var spanW = (maxRight - minLeft) + padPx;
if (spanW > contentW) contentW = spanW;
}
}
// ③ 数据推算兜底
if (!contentW) contentW = self.estimateContentWidthPx();
self.mpScroll.viewW = viewW;
// 有效值只增不减: 避免后一次空测量把已测得的宽度清零(会导致滚动条突然消失)
if (contentW > 0) self.mpScroll.contentW = contentW;
else if (!self.mpScroll.contentW) self.mpScroll.contentW = 0;
self.mpScroll.left = view.scrollLeft || 0;
});
// #endif
},
// 小程序端: 按后台下发的尺寸推算内容总宽度(rpx → px), 仅在 DOM 测量失败时兜底
estimateContentWidthPx() {
// #ifdef MP
const list = (this.value && this.value.list) || [];
if (!list.length) return 0;
const cfg = this.imageSpacingConfig;
const gap = cfg.horizontal === null ? 28 : cfg.horizontal;
let totalRpx = 32; // 容器左右 padding(16rpx × 2)
for (let i = 0; i < list.length; i++) {
if (this.value.mode == 'text') {
const w = Number(list[i] && list[i].imgWidth);
totalRpx += (!isNaN(w) && w > 0) ? w / 2 : 0;
} else {
totalRpx += this.resolveItemImgSize(list[i]).width;
}
totalRpx += gap;
}
let winW = 375;
try {
if (uni && uni.getSystemInfoSync) winW = uni.getSystemInfoSync().windowWidth || 375;
} catch (e) {}
return totalRpx * winW / 750;
// #endif
},
// 页面跳转
redirectTo(link) {
if (!link.wap_url || this.$util.getCurrRoute() != this.$util.MEMBER_PAGE_URL || this.storeToken) {
@@ -412,14 +680,10 @@ export default {
// #ifdef H5
// 防止重复绑定
if (this._slideEventsBound) return;
var outerEl = this.getSlideOuterEl();
var innerEl = this.getSlideContainer();
if (!outerEl || !innerEl) return;
// 关键发现:uni-app 的 <uni-scroll-view> web component 内部用了 BetterScroll,
// 它会拦截 touch/mouse 事件;绑到 scroll-view 自身的 mousedown 收不到(目标被重定向到 HTML)。
// 解决:document 级别 capture 阶段监听 + 用 clientX/clientY 落在容器 bounding box 内才处理。
this._slideOuterEl = outerEl;
this._slideEl = innerEl;
// 注意:这里不再要求"挂载那一刻就能拿到滚动容器"再去绑定监听。
// 之前只要 getSlideContainer() 返回 null(mounted 时 DOM/样式尚未就绪就会发生),
// 就直接 return 且不再重试 → 整个拖拽能力静默失效(鼠标怎么拖都不动)。
// 现在:监听一律先绑上,元素在每次事件里按需解析(见 ensureSlideEls)。
this._slideEventsBound = true;
// document 级别 capture 阶段,先于 uni-app 内部的滚动库触发
document.addEventListener('mousedown', this.onSlideMouseDown, true);
@@ -442,50 +706,79 @@ export default {
this._slideEventsBound = false;
// #endif
},
getSlideContainer() {
// 本组件实例的根元素(<view data-component-name="diy-image-nav">)
//
// ⚠️ 必须用 this.$el 限定在"本实例"内查找元素!
// 历史实现用 document.querySelector('[data-component-name="diy-image-nav"]'),
// 而同一个页面可以放多个滑动图组件(如 首页的 合作伙伴 + 证书资质 + 服务资质),
// document.querySelector 永远只返回页面上的**第一个**实例 ——
// 于是拖拽/滚轮第 2、3 个组件时,"指针是否在容器内"用的是第 1 个组件的位置,
// 判断不通过直接 return → 表现就是"鼠标拖不动、滚轮也没反应"。
getSlideRootEl() {
// #ifdef H5
// uni-app 的 scroll-view 在 H5 端编译为 <uni-scroll-view> web component,
// 嵌套结构:
// <uni-scroll-view> ← 自定义元素
// <div class="uni-scroll-view"> ← 第1层,visible
// <div class="uni-scroll-view"> ← 第2层,overflowX=auto (真滚动容器)
// 真正能响应 scrollLeft 的是第 2 层 div。
// 通过 data-component-name 找到本组件根 view,然后向下找 .uni-scroll-view。
var componentRoot = document.querySelector('[data-component-name="diy-image-nav"]');
if (!componentRoot) return null;
// 找 overflowX:auto 的 div.uni-scroll-view
var candidates = componentRoot.querySelectorAll('.uni-scroll-view');
for (var i = 0; i < candidates.length; i++) {
var s = getComputedStyle(candidates[i]).overflowX;
if (s === 'auto' || s === 'scroll') {
return candidates[i];
}
}
// 兜底:取最后一个 .uni-scroll-view(经验上最后一个就是真滚动容器)
return candidates.length > 0 ? candidates[candidates.length - 1] : null;
var el = this.$el;
if (el && el.nodeType === 1) return el;
return null;
// #endif
},
// 用于判断 mousedown/wheel 是否落在容器内(bounding box)
getSlideOuterEl() {
// #ifdef H5
// 用于判断 mousedown/wheel 是否落在容器内(用最外层 <uni-scroll-view> 的 bounding box)
var componentRoot = document.querySelector('[data-component-name="diy-image-nav"]');
if (!componentRoot) return null;
var sc = componentRoot.querySelector('uni-scroll-view');
return sc || null;
return this.getSlideRootEl();
// #endif
},
getSlideContainer() {
// #ifdef H5
// uni-app 的 scroll-view 在 H5 端编译为 <uni-scroll-view> 自定义元素,
// 真正的滚动 div 是内部的 .uni-scroll-view。这里在**本实例范围内**查找:
// ① 优先"内容宽度 > 可视宽度"判定真滚动容器(与 overflow 写法无关, 最可靠)
// ② 其次 overflow-x: auto/scroll
// ③ 再退化为最后一个 .uni-scroll-view
// ④ 最后退化到 .uni-scroll-view-content 的父元素
var root = this.getSlideRootEl();
if (!root) return null;
var candidates = root.querySelectorAll('.uni-scroll-view');
var i;
for (i = 0; i < candidates.length; i++) {
if (candidates[i].scrollWidth > candidates[i].clientWidth + 1) return candidates[i];
}
for (i = 0; i < candidates.length; i++) {
var s = getComputedStyle(candidates[i]).overflowX;
if (s === 'auto' || s === 'scroll') return candidates[i];
}
if (candidates.length > 0) return candidates[candidates.length - 1];
var content = root.querySelector('.uni-scroll-view-content');
if (content && content.parentElement) return content.parentElement;
return root.querySelector('uni-scroll-view');
// #endif
},
// 按需解析本实例的"根元素 + 真滚动容器"(挂载时机不确定, 统一走这里)
ensureSlideEls() {
// #ifdef H5
var root = this.getSlideRootEl();
if (!root) return false;
this._slideOuterEl = root;
var el = this.getSlideContainer();
if (!el) return false;
this._slideEl = el;
// 滚动条显隐依赖真滚动容器的 scroll 事件:mounted 时若还没拿到容器,
// 这里补绑一次(否则"滑动时显示滚动条"也会失效)。
if (!this._slideScrollBound) this.bindSlideScrollbar();
return true;
// #endif
},
onSlideMouseDown(e) {
// #ifdef H5
if (this.value.showStyle != 'singleSlide') return;
// document 级别监听,只处理落在容器内的事件
// 关键:e.target 可能是 HTML 本身(BetterScroll 内部 stopPropagation 导致),
// document 级别监听,只处理落在"本实例"容器内的事件
// 关键:e.target 可能是 HTML 本身(uni 内部事件重定向导致),
// 所以用 clientX/clientY 判断是否落在容器 bounding box 内。
if (!this._slideOuterEl) return;
if (!this.ensureSlideEls()) return;
var rect = this._slideOuterEl.getBoundingClientRect();
if (!rect) return;
var x = e.clientX, y = e.clientY;
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return;
var el = this.getSlideContainer();
var el = this._slideEl;
if (!el) return;
this.slideIsDragging = true;
this.slideStartX = x;
@@ -499,7 +792,7 @@ export default {
onSlideMouseMove(e) {
// #ifdef H5
if (!this.slideIsDragging) return;
var el = this.getSlideContainer();
var el = this._slideEl;
if (!el) return;
var x = (e.clientX != null) ? e.clientX : 0;
var deltaX = x - this.slideStartX;
@@ -526,7 +819,7 @@ export default {
// #ifdef H5
if (!this._suppressNextClick) return;
if (!e.target) return;
var outer = this._slideOuterEl;
var outer = this._slideOuterEl || this.getSlideOuterEl();
if (!outer || !outer.contains(e.target)) return;
this._suppressNextClick = false;
if (e.preventDefault) e.preventDefault();
@@ -537,13 +830,13 @@ export default {
onSlideWheel(e) {
// #ifdef H5
if (this.value.showStyle != 'singleSlide') return;
if (!this._slideOuterEl) return;
// 同 mouseDown:用 clientX/clientY 判断 wheel 是否落在容器内
// 同 mouseDown:用 clientX/clientY 判断 wheel 是否落在"本实例"容器内
if (!this.ensureSlideEls()) return;
var rect = this._slideOuterEl.getBoundingClientRect();
if (!rect) return;
var x = e.clientX, y = e.clientY;
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return;
var el = this.getSlideContainer();
var el = this._slideEl;
if (!el) return;
// 浏览器 wheel 事件的 deltaY 是垂直方向;此处把垂直滚轮映射到横向滚动
var dx = e.deltaX || 0;
@@ -560,6 +853,36 @@ export default {
</script>
<style lang="scss" scoped>
// 包裹 scroll-view 的容器(相对定位, 自定义滚动条绝对定位于其底部)
.image-nav-wrap {
position: relative;
width: 100%;
}
// 小程序端自定义水平滚动条(MP 原生 show-scrollbar 被忽略, 改用自绘,
// 与 H5 的 CSS 伪元素滚动条视觉一致; 元素仅在 #ifdef MP 下渲染)
.img-nav-scrollbar {
position: absolute;
left: 0;
right: 0;
bottom: 2rpx;
height: 4rpx;
background-color: rgba(0, 0, 0, 0.12);
border-radius: 2rpx;
z-index: 2;
pointer-events: none;
overflow: hidden;
}
.img-nav-scrollbar-thumb {
position: absolute;
left: 0;
top: 0;
height: 4rpx;
background-color: rgba(0, 0, 0, 0.4);
border-radius: 2rpx;
transition: transform 0.08s linear;
}
.image-nav {
padding: 16rpx;
box-sizing: border-box;
@@ -586,6 +909,17 @@ export default {
// #ifdef H5
overflow-x: auto;
overflow-y: hidden;
// 拖拽手势提示(桌面端鼠标用户能看出这一行是可以拖动的)
cursor: grab;
&:active {
cursor: grabbing;
}
// 本行是 <uni-scroll-view> 自定义元素,真正产生滚动的是内部 div.uni-scroll-view,
// 这里同步声明,保证内层容器一定是"可横向滚动"的(鼠标拖拽/滚轮/触屏滑动都依赖它)
::v-deep .uni-scroll-view {
overflow-x: auto;
overflow-y: hidden;
}
// 禁止鼠标选中文字(拖拽时选中会显得很别扭)
user-select: none;
-webkit-user-select: none;
@@ -705,6 +1039,15 @@ export default {
align-items: center;
justify-content: center;
font-size: 90rpx;
overflow: hidden;
// PR-FRONTEND-DIY-IMAGE-NAV-IMG-SIZE (2026-09-22):
// 兜底夹住图片尺寸(不设 width/height:100%, 避免外层盒尺寸异常时高度塌成 0
// 导致图片完全不显示)。正常尺寸由 imageInnerStyle 输出的内联样式决定。
.image-content {
max-width: 100%;
max-height: 100%;
}
.tag {
position: absolute;

2149
docs/ai-agent-integration.md Normal file

File diff suppressed because it is too large Load Diff