Compare commits
11 Commits
8cfc144ba9
...
480a109114
| Author | SHA1 | Date | |
|---|---|---|---|
| 480a109114 | |||
| 509fe203f7 | |||
| 0f9cc1087f | |||
| 0f24f47048 | |||
| a3d2ee562e | |||
| 1b1704f7c6 | |||
| 8b2670bf31 | |||
| e66c91acfc | |||
| 29699a66fd | |||
| 3b4051c775 | |||
| fffb5dae18 |
@@ -68,7 +68,7 @@ const localDevConfig = ({
|
||||
uniacid: 11,
|
||||
domain: 'http://localhost:8050/',
|
||||
},
|
||||
'100': {
|
||||
'100': {// 口译教学
|
||||
uniacid: 100,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
@@ -76,15 +76,15 @@ const localDevConfig = ({
|
||||
uniacid: 1,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'115': {
|
||||
'115': {// 低压断路器
|
||||
uniacid: 115,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'5g-11': {
|
||||
'5g-11': {// 铝箔袋包装
|
||||
uniacid: 11,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'5g-206': {
|
||||
'5g-206': {// 气体探测
|
||||
uniacid: 206,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
@@ -92,19 +92,19 @@ const localDevConfig = ({
|
||||
uniacid: 165,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'47': { // 修改弹窗
|
||||
'47': { // 免疫细胞干细胞外泌体 ,修改弹窗
|
||||
uniacid: 47,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'145': { // 表单链接转义问题
|
||||
'145': { //昆山昆创净化科技有限公司 , 表单链接转义问题
|
||||
uniacid: 145,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'5g-213': { // 单图组点击链接无法跳转
|
||||
'5g-213': { // 碱性康养水天然泡茶水 , 单图组点击链接无法跳转
|
||||
uniacid: 213,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'5g-4': { // 公告点击链接无法跳转
|
||||
'5g-4': { // 高端硅钢制造装备 , 公告点击链接无法跳转
|
||||
uniacid: 4,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
@@ -116,11 +116,11 @@ const localDevConfig = ({
|
||||
uniacid: 50,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'5g-237': { // 商品分类,没有立即咨询文字
|
||||
'5g-237': { //高活性氧化镁 商品分类,没有立即咨询文字
|
||||
uniacid: 237,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'2847': { //
|
||||
'2847': { // 家居装饰品
|
||||
uniacid: 2847,
|
||||
domain: 'https://xcx.aigc-quickapp.com/',
|
||||
},
|
||||
@@ -128,7 +128,7 @@ const localDevConfig = ({
|
||||
uniacid: 2482,
|
||||
domain: 'https://xcx6.aigc-quickapp.com/',
|
||||
},
|
||||
'2485': { // 验证电子表单前后端数据一致性
|
||||
'2485': { // 智能物流仓储 , 验证电子表单前后端数据一致性
|
||||
uniacid: 2485,
|
||||
domain: 'https://xcx6.aigc-quickapp.com/',
|
||||
},
|
||||
@@ -152,11 +152,11 @@ const localDevConfig = ({
|
||||
uniacid: 12,
|
||||
domain: 'https://test.aigc-quickapp.com',
|
||||
},
|
||||
'248': { // 大宗商品贸易,支持自定义字体大小、颜色
|
||||
'248': { // 工业称重系统垃圾吊电子秤,支持自定义字体大小、颜色
|
||||
uniacid: 248,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'228': { // 商品分类,没有立即咨询文字
|
||||
'228': { // 反渗透阻垢剂RO装置 商品分类,没有立即咨询文字
|
||||
uniacid: 228,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
@@ -164,7 +164,7 @@ const localDevConfig = ({
|
||||
uniacid: 2797,
|
||||
domain: 'https://xcx.aigc-quickapp.com/',
|
||||
},
|
||||
'5g-95': { // 商品分类,没有立即咨询文字
|
||||
'5g-95': { // 加热器 ,商品分类,没有立即咨询文字
|
||||
uniacid: 95,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
@@ -185,7 +185,7 @@ const localDevConfig = ({
|
||||
domain: 'http://192.168.8.118:8050/',
|
||||
// domain: 'http://localhost:8050/',
|
||||
},
|
||||
'5g-59': { // 合成孔径雷达,一级分类背景字体颜色
|
||||
'5g-59': { // 脱模剂粘合剂乐瑞固Lorechem
|
||||
uniacid: 59,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
@@ -210,7 +210,7 @@ const localDevConfig = ({
|
||||
uniacid: 2505,
|
||||
domain: 'https://xcx.aigc-quickapp.com/',
|
||||
},
|
||||
'3007': { // 煤矿钻机
|
||||
'3007': { // 钎焊材料
|
||||
uniacid: 3007,
|
||||
domain: 'https://xcx.aigc-quickapp.com/',
|
||||
},
|
||||
@@ -218,6 +218,10 @@ const localDevConfig = ({
|
||||
uniacid: 317,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'5g-312': { // 天然植物功效活性物
|
||||
uniacid: 312,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
// })['5g-237']; // 商品分类,没有立即咨询文字
|
||||
// })['5g-97']; // 大宗商品贸易,支持自定义字体大小、颜色
|
||||
// })['5g-50']; // 燃气安全产品机器人,购物车无反应
|
||||
@@ -230,10 +234,16 @@ const localDevConfig = ({
|
||||
// })['2881']; // 鸡肉调理品, 优惠卷显示不全,及逻辑错误
|
||||
// })['2724']; // 生物菌肥
|
||||
// })['2811']; // 选择要使用的环境配置
|
||||
// })['5g-351']; //洗车机
|
||||
// })['5g-204']; //热固性塑料
|
||||
// })['5g-59']; //合成孔径雷达
|
||||
// })['2709']; //中央厨房
|
||||
// })['3007']; //钎焊材料
|
||||
// })['2505']; //煤矿钻机
|
||||
// })['local-11']; // 选择要使用的环境配置
|
||||
// })['2811']; // 本地
|
||||
//})['5g-317']; // 电池兼容
|
||||
})['192.168.8.118-1']; //
|
||||
//})['5g-317']; // 电磁兼容
|
||||
})['5g-351']; //
|
||||
// })['local-2']; // 本地
|
||||
// })['local-11']; // 本地
|
||||
// })['1-test']; // test
|
||||
|
||||
@@ -38,7 +38,12 @@ const EXT_MIME_MAP = {
|
||||
bmp: 'image/bmp', tif: 'image/tiff', tiff: 'image/tiff',
|
||||
mp3: 'audio/mpeg', wav: 'audio/wav', aac: 'audio/aac',
|
||||
m4a: 'audio/mp4', flac: 'audio/flac', ogg: 'audio/ogg',
|
||||
oga: 'audio/ogg', opus: 'audio/opus', amr: 'audio/amr', wma: 'audio/x-ms-wma',
|
||||
aiff: 'audio/aiff', aif: 'audio/aiff', ape: 'audio/x-ape', mid: 'audio/midi',
|
||||
mp4: 'video/mp4', mov: 'video/quicktime', avi: 'video/x-msvideo',
|
||||
mkv: 'video/x-matroska', webm: 'video/webm', flv: 'video/x-flv',
|
||||
m4v: 'video/x-m4v', wmv: 'video/x-ms-wmv', '3gp': 'video/3gpp',
|
||||
mpeg: 'video/mpeg', mpg: 'video/mpeg', ts: 'video/mp2t',
|
||||
doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
ppt: 'application/vnd.ms-powerpoint',
|
||||
@@ -117,17 +122,55 @@ export async function uploadImage(filePath, opts = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传 audio / video / file (一律 STS, 因为体积大概率 >2MB)
|
||||
* 上传 audio / video / file
|
||||
* 策略: 商家配了云存储 (oss_sts/auto) → STS 直传, 失败回退 PHP 中转;
|
||||
* 未配云存储 (backend) → 直接 PHP 中转
|
||||
* @param {string} filePath
|
||||
* @param {'audio'|'video'|'file'} kind
|
||||
* @param {{name?:string, mime?:string, size?:number}} [opts]
|
||||
* PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): H5 传进来的是 blob URL (无扩展名),
|
||||
* 必须由调用方把原始 File 的 name/mime/size 带进来, 否则 STS key 会以 .bin 结尾,
|
||||
* Content-Type 退化成 application/octet-stream, 音频/视频播放器取不到正确类型。
|
||||
*/
|
||||
export async function uploadMedia(filePath, kind) {
|
||||
export async function uploadMedia(filePath, kind, opts = {}) {
|
||||
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)
|
||||
const agentId = aiService.getAgentId()
|
||||
const hints = { name: opts.name || '', mime: opts.mime || '' }
|
||||
const size = opts.size || 0
|
||||
const strategy = (config && config.upload_strategy) || 'backend'
|
||||
|
||||
// PHP 中转 (kind 以调用方传入的音视频分类为准, 后端只区分 image|file)
|
||||
const doPhpUpload = async (fallbackErr) => {
|
||||
let phpRes = null
|
||||
try {
|
||||
phpRes = await uploadViaPhp(filePath, kind, addon, agentId, size)
|
||||
} catch (err) {
|
||||
throw new Error((err && err.message) || (fallbackErr && fallbackErr.message) || '上传失败')
|
||||
}
|
||||
if (!phpRes || !phpRes.url) {
|
||||
throw new Error((fallbackErr && fallbackErr.message) || '上传失败: 当前平台不支持该上传方式')
|
||||
}
|
||||
return Object.assign({}, phpRes, { kind })
|
||||
}
|
||||
|
||||
// 商家未开启云存储 (strategy=backend): 与图片保持一致走 PHP 中转,
|
||||
// 避免没配 STS 时"视频/音频"按钮一选就报 "STS 凭据获取失败" 完全不可用
|
||||
if (strategy === 'backend') {
|
||||
return await doPhpUpload()
|
||||
}
|
||||
|
||||
try {
|
||||
return await uploadViaSts(filePath, kind, addon, agentId, config, size, hints)
|
||||
} catch (e) {
|
||||
// PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): STS 不可用 (未配置云存储 / 凭证接口失败)
|
||||
// 时回退 PHP 中转, 保证音频/视频文件仍能上传
|
||||
console.warn('[media-upload] STS 上传失败, 回退 PHP 中转:', e && e.message)
|
||||
return await doPhpUpload(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -241,7 +284,7 @@ async function uploadViaPhp(filePath, kind, addon, agentId, knownSize = 0) {
|
||||
* PR-FRONTEND-MEDIA-UPLOAD-FIX-HOTFIX (2026-09-16): 提到模块顶层,
|
||||
* 之前只在 uploadViaSts 里, pathToFile 引用不到 → "extractExt is not defined"
|
||||
*/
|
||||
function extractExt(filePath, mimeHint = '') {
|
||||
function extractExt(filePath, mimeHint = '', nameHint = '') {
|
||||
let ext = 'bin'
|
||||
if (typeof filePath === 'string') {
|
||||
const lastSeg = filePath.split('?')[0].split('/').pop() || ''
|
||||
@@ -252,6 +295,11 @@ function extractExt(filePath, mimeHint = '') {
|
||||
const m2 = String(filePath.name).match(/\.([a-z0-9]+)$/i)
|
||||
if (m2) ext = m2[1].toLowerCase()
|
||||
}
|
||||
// PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): H5 blob URL + 调用方带下来的原始文件名
|
||||
if (ext === 'bin' && nameHint) {
|
||||
const m3 = String(nameHint).match(/\.([a-z0-9]+)$/i)
|
||||
if (m3) ext = m3[1].toLowerCase()
|
||||
}
|
||||
// 3) mime→ext 反查 (PR-FRONTEND-MEDIA-UPLOAD-FIX-HOTFIX): blob URL 无扩展名但
|
||||
// blob.type 是 image/png 这种, 反查 EXT_MIME_MAP → png
|
||||
if (ext === 'bin' && mimeHint) {
|
||||
@@ -263,11 +311,13 @@ function extractExt(filePath, mimeHint = '') {
|
||||
return ext
|
||||
}
|
||||
|
||||
async function uploadViaSts(filePath, kind, addon, agentId, config, knownSize = 0) {
|
||||
async function uploadViaSts(filePath, kind, addon, agentId, config, knownSize = 0, hints = {}) {
|
||||
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 用更稳的扩展名提取
|
||||
// H5 blob URL (blob:https://xxx/uuid) 无扩展名 → split('.').pop() 会拿整条 URL
|
||||
// 优先 file.name (H5 file / APP tempFile.name), 都没有就 blob URL → 'bin'
|
||||
const ext = extractExt(filePath)
|
||||
// PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): H5 走 blob URL 时 file.name 拿不到,
|
||||
// 由 uploadMedia 的 opts.name/mime 兜底 (音频 .mp3 / 视频 .mp4 才不会被写成 .bin)
|
||||
const ext = extractExt(filePath, hints.mime || '', hints.name || '')
|
||||
|
||||
// 1. 拿 STS 凭证 (addon 模式 URL)
|
||||
const stsUrl = (addon === 'aikefu' ? '/aikefu/api/upload/' : '/aiagent/api/upload/') + 'ossSts'
|
||||
|
||||
@@ -98,7 +98,8 @@
|
||||
</block>
|
||||
</block>
|
||||
|
||||
<!-- 模板1: 后台配置二级分类 + 有子分类时, 在一级分类标题下以双列网格显示所有商品(图 c2df0f3d29 样式) -->
|
||||
<!-- 模板1: 后台配置二级分类 + 有子分类时, 在一级分类标题下以双列网格显示所有商品(图 c2df0f3d29 样式)
|
||||
模板5 不走这里: 它有专属分支 value.template == 5(后台"展示分类"决定渲染商品网格或二级分类网格) -->
|
||||
<view v-else-if="value.template == 1">
|
||||
<view :class="['category-title', 'category-title-with-lines']">{{ category.category_name }}</view>
|
||||
<view class="template1-goods-wrap">
|
||||
@@ -139,8 +140,51 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 非模板1, 2级分类: 显示二级分类网格,点击跳转到列表页 -->
|
||||
<block v-else-if="categoryLevel == 2">
|
||||
<!-- 模板5 专属: 后台"展示分类"决定右侧渲染内容(对应图2/图3)
|
||||
二级分类(value.level == 2) → 一级分类标题 + 商品网格(图2)
|
||||
三级分类(value.level == 3) → 一级分类标题 + 二级分类网格(图3) -->
|
||||
<block v-else-if="value.template == 5">
|
||||
<block v-if="value.level == 2">
|
||||
<view :class="['category-title', '']">{{ category.category_name }}</view>
|
||||
<view class="template1-goods-wrap">
|
||||
<view class="goods-list show" :class="{ 'double-column': !isList, 'single-column': isList }" v-if="template1GoodsList.length">
|
||||
<view class="goods-item" v-for="(item, index) in template1GoodsList" :key="index">
|
||||
<view class="goods-img" @tap.stop="toDetail(item)">
|
||||
<image :src="goodsImg(item.goods_image)" mode="widthFix" @error="template1ImgError(index)" show-menu-by-longpress />
|
||||
<view class="color-base-bg goods-tag" v-if="item.label_name">{{ item.label_name }}</view>
|
||||
</view>
|
||||
<view class="info-wrap">
|
||||
<view class="name-wrap" @tap.stop="toDetail(item)">
|
||||
<view class="goods-name">{{ isEnEnv ? item.en_goods_name : item.goods_name }}</view>
|
||||
</view>
|
||||
<view class="price-wrap">
|
||||
<view class="discount-price">
|
||||
<block v-if="item.isinformation == 0">
|
||||
<text class="unit price-style small">{{ $lang('common.currencySymbol') }}</text>
|
||||
<text class="price price-style large">{{ parseFloat(showPrice(item)).toFixed(2).split('.')[0] }}</text>
|
||||
<text class="unit price-style small">.{{ parseFloat(showPrice(item)).toFixed(2).split('.')[1] }}</text>
|
||||
</block>
|
||||
</view>
|
||||
<view class="delete-price font-size-activity-tag color-tip price-font"
|
||||
v-if="item.isinformation == 0 && showMarketPrice(item)">
|
||||
<text class="unit">{{ $lang('common.currencySymbol') }}</text>
|
||||
<text>{{ showMarketPrice(item) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="footer-wrap footer-wrap-template1">
|
||||
<view class="detail-btn" @tap.stop="toDetail(item)">{{ $lang('components-diy.diy-category-item.consultNow') }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="category-goods-empty" v-else-if="!template1Loading">
|
||||
<image :src="$util.img('public/uniapp/category/empty.png')" mode="widthFix" show-menu-by-longpress></image>
|
||||
<view class="tips">暂无商品</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
<block v-else>
|
||||
<!-- 三级分类(及更深层级): 一级分类标题 + 二级分类网格(图3) -->
|
||||
<view :class="['category-title', '']">{{ category.category_name }}</view>
|
||||
<view class="category-list">
|
||||
<view class="category-item" v-for="(one, oneIndex) in category.child_list" :key="oneIndex"
|
||||
@@ -152,25 +196,11 @@
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block v-else-if="categoryLevel == 3">
|
||||
<block v-for="(one, oneIndex) in category.child_list" :key="oneIndex">
|
||||
<view :class="['category-title', '']">{{ one.category_name }}</view>
|
||||
<view class="category-list">
|
||||
<view class="category-item" v-for="(two, twoIndex) in one.child_list" :key="twoIndex"
|
||||
@tap.stop="$util.redirectTo('/pages_goods/list', { category_id: two.category_id })">
|
||||
<view class="img-box">
|
||||
<image :src="categoryImg(two.image)" mode="aspectFit" @error="categoryImgError(3, oneIndex, twoIndex)" show-menu-by-longpress />
|
||||
</view>
|
||||
<view class="name">{{ two.category_name }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
</block>
|
||||
</block>
|
||||
</block>
|
||||
|
||||
<!-- 模板1: 无子分类时(外层块不进入), 在一级分类标题下以双列网格显示所有商品(图 c2df0f3d29 样式) -->
|
||||
<view v-else-if="value.template == 1 && type == 'category'">
|
||||
<view v-else-if="(value.template == 1 || value.template == 5) && type == 'category'">
|
||||
<view :class="['category-title', 'category-title-with-lines']">{{ category.category_name }}</view>
|
||||
<view class="template1-goods-wrap">
|
||||
<view class="goods-list show" :class="{ 'double-column': !isList, 'single-column': isList }" v-if="template1GoodsList.length">
|
||||
@@ -617,13 +647,13 @@ export default {
|
||||
* 三级分类 (value.level == 3) → true → 按二级分类分别显示各自商品
|
||||
*/
|
||||
isGroupedDisplay() {
|
||||
// 仅模板1 走"按二级分类分组显示商品"; 模板5 的层级由 value.level 直接决定(见下方 value.template == 5 分支)
|
||||
return this.value.template == 1 && this.value.level == 3;
|
||||
},
|
||||
/**
|
||||
* 模板1三级分类: 渲染所有二级分类(包括无商品的空分类)
|
||||
* 不再按"是否有商品"过滤, 保证一级分类下的二级分类全部显示,
|
||||
* 避免 10 个二级分类只显示 8 个(2 个被当成空分类过滤掉)的问题
|
||||
* 加载中(template1ChildLoading[id] === true)的二级分类暂不渲染, 避免闪烁
|
||||
* 模板1三级分类: 只渲染"有商品"的二级分类
|
||||
* 无商品的二级分类整块(标题 + 网格)都不渲染; 加载中的二级分类也暂不渲染(避免闪烁)
|
||||
* 判定时机: 该二级分类加载完成(template1ChildLoading[id] === false)且商品列表非空
|
||||
*/
|
||||
filteredGroupedChildren() {
|
||||
if (!this.isGroupedDisplay) return [];
|
||||
@@ -631,8 +661,9 @@ export default {
|
||||
return this.category.child_list.filter(one => {
|
||||
// 还在加载中: 暂不渲染(避免闪烁)
|
||||
if (this.template1ChildLoading[one.category_id]) return false;
|
||||
// 加载完成(无论是否有商品)都渲染, 无商品时显示空状态
|
||||
return true;
|
||||
// 加载完成: 只有该二级分类下有商品时才显示, 无商品则整块隐藏
|
||||
const list = this.template1ChildGoodsList[one.category_id];
|
||||
return !!(list && list.length);
|
||||
});
|
||||
},
|
||||
/**
|
||||
@@ -642,7 +673,7 @@ export default {
|
||||
* 若 type 不跟随更新, 模板1 会误走 goods 分支, 导致"资讯类商品=否"的商品不显示立即咨询按钮
|
||||
*/
|
||||
type() {
|
||||
return this.value.template == 1 || this.value.template == 99 ? 'category' : 'goods';
|
||||
return this.value.template == 1 || this.value.template == 5 || this.value.template == 99 ? 'category' : 'goods';
|
||||
},
|
||||
},
|
||||
created() {
|
||||
@@ -664,7 +695,14 @@ export default {
|
||||
// 模板1:根据后台"展示分类"配置 + 数据层级决定加载方式
|
||||
// 三级分类模式 (isGroupedDisplay) + 有二级分类时,按二级分类分别加载各自商品
|
||||
// 其他情况(二级分类模式 / 无二级分类),直接加载当前一级分类下的所有商品
|
||||
if (this.value.template == 1 && this.category && this.category.category_id) {
|
||||
// 模板5 渲染规则:
|
||||
// 二级分类(value.level == 2) → 商品网格(图2), 需要加载该一级分类下所有商品(合并其二级分类)
|
||||
// 三级分类(value.level == 3) → 二级分类网格(图3), 静态网格无需加载商品
|
||||
// 若该一级分类下没有子分类(兜底), 仍需要加载商品
|
||||
const template5NeedGoods = this.value.template == 5 &&
|
||||
(this.value.level == 2 ||
|
||||
!(this.category && this.category.child_list && this.category.child_list.length));
|
||||
if ((this.value.template == 1 || template5NeedGoods) && this.category && this.category.category_id) {
|
||||
if (this.isGroupedDisplay && this.category.child_list && this.category.child_list.length) {
|
||||
this.category.child_list.forEach(child => {
|
||||
this.getTemplate1ChildGoods(child.category_id);
|
||||
@@ -739,6 +777,8 @@ export default {
|
||||
}
|
||||
// 模板1:切换一级分类时重新加载该分类下的商品
|
||||
// 三级分类模式 + 有二级分类时,按二级分类分别重新加载,否则直接加载当前分类
|
||||
// 注意: 模板5 是"全部分类纵向平铺", 商品由 created 首次加载 + 父级链式懒加载提供,
|
||||
// 滚动时 select 会随 listenScroll 变化, 若此处也重载会导致滚动中反复清空/重复请求, 故排除 5
|
||||
if (this.value.template == 1 && this.index == this.select && this.category && this.category.category_id) {
|
||||
this.template1GoodsList = [];
|
||||
this.template1ChildGoodsList = {};
|
||||
@@ -1489,42 +1529,42 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
// 分类网格(模板5 / legacy 商品分类): 白底卡片 + 3 列 图片 + 名称
|
||||
// 与 Branch_5cfee417 的商品分类保持一致
|
||||
.category-list {
|
||||
display: grid !important;
|
||||
grid-template-columns: repeat(3, 1fr) !important;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
background: #fff;
|
||||
margin: 0 20rpx;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
gap: 20rpx 0;
|
||||
border-radius: 10rpx;
|
||||
padding: 10rpx 0;
|
||||
|
||||
.category-item {
|
||||
width: 100% !important;
|
||||
width: 33.33%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
padding: 16rpx;
|
||||
box-sizing: border-box;
|
||||
|
||||
.img-box {
|
||||
width: 70% !important;
|
||||
width: 80%;
|
||||
height: 0;
|
||||
padding-bottom: 70%;
|
||||
padding-top: 80%;
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
margin-bottom: 16rpx;
|
||||
margin-bottom: 20rpx;
|
||||
position: relative;
|
||||
background: transparent !important;
|
||||
border-radius: 0 !important;
|
||||
background: transparent;
|
||||
|
||||
image {
|
||||
position: absolute;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
left: 0 !important;
|
||||
top: 0 !important;
|
||||
transform: none !important;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1539,7 +1579,6 @@ export default {
|
||||
font-size: var(--diy-category-item-name-size, 24rpx);
|
||||
}
|
||||
|
||||
// 模板1选中状态
|
||||
&.selected {
|
||||
.img-box {
|
||||
border: 2rpx solid var(--base-color, #FF6B35);
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
{ select: select == index },
|
||||
{ 'border-bottom': value.template == 4 && select + 1 === index },
|
||||
{ 'border-top': value.template == 4 && select - 1 === index },
|
||||
{ 'first-category-item': index == 0 && (value.template == 99 || value.template == 1) }
|
||||
{ 'first-category-item': index == 0 && (value.template == 99 || value.template == 1 || value.template == 5) }
|
||||
]" @tap.stop="switchOneCategory(index)">
|
||||
<view class="">{{ item.category_name }}</view>
|
||||
</view>
|
||||
@@ -152,11 +152,44 @@
|
||||
<diy-category-item :category="item" :value="value" ref="categoryItem" :index="index"
|
||||
:select="select" :oneCategorySelect="oneCategorySelect" :isList="isList"
|
||||
@tologin="toLogin" @selectsku="selectSku($event, index)" @addCart="addCartPoint"
|
||||
@saveSecondCategory="saveSecondCategoryName"></diy-category-item>
|
||||
@saveSecondCategory="saveSecondCategoryName"> </diy-category-item>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</scroll-view>
|
||||
|
||||
<scroll-view scroll-y="true" class="content-wrap" v-else-if="value.template == 99 || loadType == 'all'"
|
||||
<!-- 模板5 + 二级分类(value.level==2): 单面板切换 - 右侧只显示"当前选中"的一级分类,
|
||||
点左侧分类切换, 其它分类隐藏(display:none); 每个一级分类: 标题 + 商品网格 -->
|
||||
<scroll-view scroll-y="true" class="content-wrap"
|
||||
v-else-if="value.template == 5 && value.level == 2"
|
||||
:refresher-enabled="true" refresher-default-style="none"
|
||||
:refresher-triggered="triggered" @refresherrefresh="onRefresh" @refresherrestore="onRestore">
|
||||
<view class="child-category-wrap" v-for="(item, index) in categoryTree" :key="index"
|
||||
:id="'category-' + index" :style="{ display: select == index ? 'block' : 'none' }">
|
||||
<diy-category-item :category="item" :value="value" ref="categoryItem" :index="index"
|
||||
:select="select" :oneCategorySelect="oneCategorySelect" :isList="isList"
|
||||
@tologin="toLogin" @selectsku="selectSku($event, index)" @addCart="addCartPoint"
|
||||
@saveSecondCategory="saveSecondCategoryName"> </diy-category-item>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 模板5 + 三级分类(value.level!=2): 左侧分类树 + 右侧纵向平铺"全部一级分类",
|
||||
每个一级分类: 标题 + 二级分类网格; 左侧点击分类用 scroll-into-view 定位,
|
||||
滚动时由 listenScroll 反推并高亮左侧分类 -->
|
||||
<scroll-view scroll-y="true" class="content-wrap" v-else-if="value.template == 5"
|
||||
ref="contentWrap" :scroll-into-view="categoryId" :scroll-with-animation="true"
|
||||
:refresher-enabled="true" refresher-default-style="none"
|
||||
:refresher-triggered="triggered" @refresherrefresh="onRefresh" @refresherrestore="onRestore"
|
||||
@scroll="listenScroll" @touchstart="touchStart">
|
||||
<view class="child-category" v-for="(item, index) in categoryTree" :key="index"
|
||||
:id="'category-' + index">
|
||||
<diy-category-item :category="item" :value="value" ref="categoryItem" :index="index"
|
||||
:select="select" :oneCategorySelect="oneCategorySelect" :isList="isList"
|
||||
@tologin="toLogin" @selectsku="selectSku($event, index)" @addCart="addCartPoint"
|
||||
@loadfinish="getHeightArea" @saveSecondCategory="saveSecondCategoryName"></diy-category-item>
|
||||
</view>
|
||||
<view class="end-tips" ref="endTips" :style="{ opacity: endTips }">{{ $lang('components-diy.diy-category.reachedBottom') }}</view>
|
||||
</scroll-view>
|
||||
|
||||
<scroll-view scroll-y="true" class="content-wrap" v-else-if="value.template == 99 || loadType == 'all'"
|
||||
ref="contentWrap" :scroll-into-view="categoryId" :scroll-with-animation="true"
|
||||
@scroll="listenScroll" @touchstart="touchStart" :refresher-enabled="true"
|
||||
refresher-default-style="none" :refresher-triggered="triggered" @refresherrefresh="onRefresh"
|
||||
@@ -308,6 +341,7 @@ export default {
|
||||
})
|
||||
.exec();
|
||||
if (this.value.template == 1) this.getHeightArea(-1);
|
||||
if (this.value.template == 5) this.getHeightArea(-1);
|
||||
}, 500);
|
||||
},
|
||||
watch: {
|
||||
@@ -317,6 +351,9 @@ export default {
|
||||
if (nval === false && oval === true) {
|
||||
this.$nextTick(() => {
|
||||
if (!this.heightArea.length) this.getHeightArea(-1);
|
||||
// 分类渲染完成后重新对齐到当前选中的一级分类顶部,
|
||||
// 避免首屏第一个分类的标题被顶出可视区
|
||||
if (this.categoryTree && this.categoryTree.length) this.alignToCategory(this.select);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -536,8 +573,30 @@ export default {
|
||||
switchOneCategory(index) {
|
||||
if (index >= this.categoryTree.length) return;
|
||||
this.select = index;
|
||||
this.categoryId = 'category-' + index;
|
||||
this.scrollLock = true;
|
||||
this.alignToCategory(index);
|
||||
},
|
||||
/**
|
||||
* 把右侧滚动区定位到指定一级分类的顶部
|
||||
*
|
||||
* uni-app 只在 scroll-into-view 的值"发生变化"时才重新滚动,
|
||||
* 而 categoryId 的初值就是 'category-0', 且只有点击左侧分类才会被改写;
|
||||
* 用户在右侧手动滚动后 categoryId 仍是 'category-0', 此时再点第一个分类(index 0)
|
||||
* 值时没有变化, 右侧不会回滚到该分类, 该分类的标题就被顶出可视区
|
||||
* (看起来像"标题丢了 / 卡片被切了一半"), 而点其它分类因为值变了所以都正常。
|
||||
* 这里先置空、下一帧再设置目标 id, 保证每次点击都能重新定位到分类顶部。
|
||||
* @param {Number} index 一级分类下标
|
||||
*/
|
||||
alignToCategory(index) {
|
||||
const target = 'category-' + index;
|
||||
if (this.categoryId === target) {
|
||||
this.categoryId = '';
|
||||
this.$nextTick(() => {
|
||||
this.categoryId = target;
|
||||
});
|
||||
} else {
|
||||
this.categoryId = target;
|
||||
}
|
||||
},
|
||||
saveSecondCategoryName(name) {
|
||||
this.lastSecondCategoryName = name;
|
||||
@@ -559,7 +618,10 @@ export default {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (this.value.template != 1 && this.value.loadType == 'all' && this.heightArea[this.select][1] -
|
||||
// 模板5 三级分类为静态分类网格平铺, 不需要懒加载商品; 这里只保留 loadType=all 的懒加载,
|
||||
// 并补上 heightArea[this.select] 越界保护
|
||||
if (this.value.template != 1 && this.value.loadType == 'all' &&
|
||||
this.heightArea[this.select] && this.heightArea[this.select][1] -
|
||||
scrollTop - contentWrapHeight < 300) {
|
||||
if (Array.isArray(this.$refs.categoryItem) && this.$refs.categoryItem[this.select]) this.$refs.categoryItem[this.select].getGoodsList();
|
||||
}
|
||||
@@ -1674,6 +1736,134 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 模板5 商品分类样式(新增: 基于模板1双栏布局) =====================
|
||||
.category-template-5 {
|
||||
.search-box {
|
||||
padding: 20rpx 24rpx;
|
||||
background: #fff;
|
||||
|
||||
.search-content {
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 40rpx;
|
||||
|
||||
input {
|
||||
background: #f5f5f5;
|
||||
border-radius: 40rpx;
|
||||
padding: 0 30rpx;
|
||||
font-size: 26rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.iconfont {
|
||||
right: 16rpx;
|
||||
color: #89899a;
|
||||
font-size: 32rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content-box {
|
||||
// 模板5 + 三级分类: 标题行所在整片右半边去掉白色, 与模板1一致
|
||||
background: transparent;
|
||||
|
||||
.tree-wrap {
|
||||
width: 170rpx;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.right-flex-wrap {
|
||||
background: #f5f5f5;
|
||||
|
||||
.content-wrap {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
// 模板5 纵向平铺: 每个一级分类独占一行(与模板99"全部分类"结构一致)
|
||||
.child-category {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 穿透到子组件 diy-category-item: 调整 banner 横向内边距 + 分类网格容器为透明
|
||||
::v-deep .category-adv {
|
||||
padding: 0 20rpx;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
// 模板5: .category-list 直接沿用子组件的 legacy 白底卡片样式(不再覆盖为透明)
|
||||
|
||||
// 穿透到子组件 diy-category-item: 模板5标题行定高, 与左侧第一个一级分类等高对齐
|
||||
::v-deep .category-title-with-lines {
|
||||
height: 100rpx;
|
||||
box-sizing: border-box;
|
||||
padding: 30rpx 0 20rpx;
|
||||
}
|
||||
|
||||
// 模板5: 左侧第一个一级分类定高, 与右侧标题行垂直对齐
|
||||
.tree-wrap .category-item.first-category-item {
|
||||
height: 100rpx;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
||||
view {
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
// 模板5: 分类网格的 .img-box / .name 直接沿用子组件 legacy 尺寸(80% + 名称),
|
||||
// 不再做 70%/透明 覆盖, 以与 Branch_5cfee417 的商品分类保持一致
|
||||
|
||||
// 左侧分类栏样式
|
||||
.tree-wrap .category-item {
|
||||
padding: 28rpx 16rpx;
|
||||
background-color: var(--diy-category-tree-normal-bg-color, #f5f5f5);
|
||||
border-radius: 0;
|
||||
|
||||
view {
|
||||
color: var(--diy-category-tree-normal-text-color, #222222);
|
||||
font-size: 26rpx;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
&.border-top,
|
||||
&.border-bottom {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
&.select {
|
||||
background: var(--diy-category-tree-select-bg-color, #fff);
|
||||
|
||||
view {
|
||||
color: var(--diy-category-tree-select-text-color, #333);
|
||||
font-weight: bold;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: ' ';
|
||||
width: 6rpx;
|
||||
height: 32rpx;
|
||||
background: var(--base-color, #FF6B35);
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border-radius: 0 6rpx 6rpx 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 模板99 商品分类样式 =====================
|
||||
.category-template-99 {
|
||||
.search-box {
|
||||
|
||||
@@ -377,7 +377,13 @@ export default {
|
||||
: index + 1;
|
||||
|
||||
if (newItem.componentName !== 'Search' || newItem.positionWay !== 'fixed') {
|
||||
newItem.pageStyle = `background-color:${newItem.pageBgColor};`;
|
||||
// PR-DIY-SEARCH-MARGIN-COLOR (2026-09-17): 搜索组件的上下左右边距区域颜色
|
||||
// 与搜索框背景色(componentBgColor, 后台自定义)保持一致, 使边距和搜索框融为一体;
|
||||
// 兼容旧数据: 未配置 componentBgColor 时回退 pageBgColor。其余组件仍用 pageBgColor。
|
||||
const wrapBgColor = (newItem.componentName === 'Search' && newItem.componentBgColor)
|
||||
? newItem.componentBgColor
|
||||
: newItem.pageBgColor;
|
||||
newItem.pageStyle = `background-color:${wrapBgColor};`;
|
||||
|
||||
if (newItem.margin) {
|
||||
newItem.pageStyle += `padding-top:${newItem.margin.top * 2}rpx;`;
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
<template>
|
||||
<view :style="componentStyle" data-component-name="diy-image-nav">
|
||||
<scroll-view :class="['image-nav', value.showStyle == 'fixed' ? 'fixed-layout' : value.showStyle]"
|
||||
:scroll-x="value.showStyle == 'singleSlide'">
|
||||
<!-- 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="margin-right: 28rpx;">
|
||||
:style="imageItemStyle">
|
||||
<!-- 图片部分 -->
|
||||
<view v-if="value.mode != 'text'" class="image-img" :style="{
|
||||
'font-size': (value.imageSize * 2 + 'rpx') + ';',
|
||||
@@ -60,7 +66,10 @@ export default {
|
||||
slideDidDrag: false, // 5px 阈值后置 true,用于判断 mouseup 后是否拦截 click
|
||||
slideStartX: 0,
|
||||
slideStartScrollLeft: 0,
|
||||
_suppressNextClick: false // 拖拽结束后拦截一次 click,防止误触发跳转
|
||||
_suppressNextClick: false, // 拖拽结束后拦截一次 click,防止误触发跳转
|
||||
// PR-FRONTEND-DIY-SCROLLBAR (2026-09-18): "滑动时显示"模式专用
|
||||
// 滚动中为 true → 给 scroll-view 加 is-scrolling 类展示滚动条, 停止 800ms 后隐藏
|
||||
slideScrolling: false
|
||||
}
|
||||
},
|
||||
created() {
|
||||
@@ -75,12 +84,14 @@ export default {
|
||||
var self = this;
|
||||
this.$nextTick(function() {
|
||||
self.bindSlideMouseEvents();
|
||||
self.bindSlideScrollbar();
|
||||
});
|
||||
// #endif
|
||||
},
|
||||
beforeDestroy() {
|
||||
// #ifdef H5
|
||||
this.unbindSlideMouseEvents();
|
||||
this.unbindSlideScrollbar();
|
||||
// #endif
|
||||
},
|
||||
watch: {
|
||||
@@ -89,9 +100,47 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
// PR-FRONTEND-DIY-IMAGE-NAV-SPACING (2026-09-18): 组件背景色
|
||||
// 后台"组件背景"面板下发的字段是 componentBgColor(与图片轮播/搜索等组件一致);
|
||||
// 兼容别名/数组形态/嵌套容器(避免后台换名或存成数组时前端读不到而完全不上色),
|
||||
// 最后兜底组件级 pageBgColor(部分组件后台把组件背景存在这里)。
|
||||
// 空值/非法值(图片URL等)返回空串, 此时不输出 background-color, 保持历史透明视觉。
|
||||
componentBgColor() {
|
||||
const v = this.value || {};
|
||||
const norm = k => String(k).toLowerCase().replace(/[\s_-]/g, '');
|
||||
const asColor = val => {
|
||||
if (Array.isArray(val)) val = val[0];
|
||||
if (typeof val !== 'string') return '';
|
||||
const s = val.trim();
|
||||
if (!s) return '';
|
||||
// 排除图片/URL 等非颜色值
|
||||
if (/^(url\(|https?:|data:|upload\/|addon\/|\/)/i.test(s)) return '';
|
||||
return s;
|
||||
};
|
||||
const sources = [v];
|
||||
['style', 'config', 'setting', 'data'].forEach(k => {
|
||||
if (v[k] && typeof v[k] === 'object' && !Array.isArray(v[k])) sources.push(v[k]);
|
||||
});
|
||||
const candidates = [
|
||||
'componentbgcolor', 'componentbackgroundcolor', 'componentcolor', 'componentbg',
|
||||
'bgcolor', 'backgroundcolor', 'background',
|
||||
'pagebgcolor'
|
||||
];
|
||||
for (let c = 0; c < candidates.length; c++) {
|
||||
for (let s = 0; s < sources.length; s++) {
|
||||
for (const key in sources[s]) {
|
||||
if (norm(key) === candidates[c]) {
|
||||
const color = asColor(sources[s][key]);
|
||||
if (color) return color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
},
|
||||
componentStyle() {
|
||||
let style = '';
|
||||
style += 'background-color:' + this.value.componentBgColor + ';';
|
||||
if (this.componentBgColor) style += 'background-color:' + this.componentBgColor + ';';
|
||||
|
||||
if (this.value.componentAngle == 'round') {
|
||||
style += 'border-top-left-radius:' + (2 * this.value.topAroundRadius) + 'rpx;';
|
||||
@@ -123,9 +172,219 @@ export default {
|
||||
isIndicatorDots() {
|
||||
return this.value.carousel.type != 'hide' &&
|
||||
1 != Math.ceil(this.value.list.length / (this.value.pageCount * this.value.rowCount));
|
||||
},
|
||||
|
||||
// ========== PR-FRONTEND-DIY-SCROLLBAR (2026-09-18): 水平滚动条显隐三态 ==========
|
||||
// 后台"水平滚动条"配置: 滑动时显示 / 始终显示 / 永不显示 → 归一化为 slide / always / never
|
||||
// 字段名兼容: scrollBar / scrollbar / scrollBarType / horizontalScrollBar / horizontalScrollbar
|
||||
// (忽略大小写/下划线/中划线差异, 并向下探测 value.scrollBar / value.style 等嵌套容器)
|
||||
// 值兼容: 英文枚举(slide/auto/always/never/none...) / 中文枚举(滑动时显示/始终显示/永不显示)
|
||||
// / 数字(0=滑动时显示 1=始终显示 2=永不显示) / 布尔(true=始终显示 false=永不显示)
|
||||
// 兜底: 未下发或无法识别 → 'slide' (滑动时显示, 与历史行为一致)
|
||||
scrollBarMode() {
|
||||
const v = this.value || {};
|
||||
const norm = k => String(k).toLowerCase().replace(/[\s_-]/g, '');
|
||||
// 从配置对象中取"滚动条"字段的原始值
|
||||
const pickRaw = obj => {
|
||||
if (!obj || typeof obj != 'object') return undefined;
|
||||
for (const key in obj) {
|
||||
const k = norm(key);
|
||||
if (k.indexOf('scrollbar') != -1 || k.indexOf('scrolltype') != -1) {
|
||||
const val = obj[key];
|
||||
if (val && typeof val === 'object') {
|
||||
// 子配置对象形态: { scrollBar: { type: 'always' } }
|
||||
return val.type !== undefined ? val.type : (val.value !== undefined ? val.value : val.mode);
|
||||
}
|
||||
if (val !== undefined && val !== null && val !== '') return val;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
let raw = pickRaw(v);
|
||||
// 嵌套容器兜底
|
||||
if (raw === undefined) {
|
||||
const containers = ['scrollBar', 'scrollbar', 'style', 'config', 'setting', 'data'];
|
||||
for (let i = 0; i < containers.length; i++) {
|
||||
if (v[containers[i]] && typeof v[containers[i]] === 'object') {
|
||||
raw = pickRaw(v[containers[i]]);
|
||||
if (raw !== undefined) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (raw === undefined || raw === null || raw === '') return 'slide';
|
||||
if (typeof raw === 'boolean') return raw ? 'always' : 'never';
|
||||
if (raw === 0 || raw === '0') return 'slide';
|
||||
if (raw === 1 || raw === '1') return 'always';
|
||||
if (raw === 2 || raw === '2') return 'never';
|
||||
|
||||
const map = {
|
||||
slide: 'slide', scroll: 'slide', auto: 'slide', scrolling: 'slide', whilescrolling: 'slide', moveslide: 'slide',
|
||||
always: 'always', alwaysdisplay: 'always', show: 'always',
|
||||
never: 'never', none: 'never', hidden: 'never', hide: 'never',
|
||||
// 后台直出中文枚举时兜底
|
||||
'滑动时显示': 'slide', '滑动时': 'slide', '滚动时显示': 'slide',
|
||||
'始终显示': 'always', '一直显示': 'always', '永久显示': 'always',
|
||||
'永不显示': 'never', '从不显示': 'never', '不显示': 'never'
|
||||
};
|
||||
const hit = map[String(raw).toLowerCase().trim()] || map[norm(raw)];
|
||||
return hit || 'slide';
|
||||
},
|
||||
|
||||
// scroll-view 上的滚动条显隐 class: scrollbar-slide / scrollbar-always / scrollbar-never
|
||||
// "滑动时显示"在滚动中追加 is-scrolling (由 scroll 监听维护)
|
||||
scrollBarClass() {
|
||||
const cls = ['scrollbar-' + this.scrollBarMode];
|
||||
if (this.scrollBarMode == 'slide' && this.slideScrolling) cls.push('is-scrolling');
|
||||
return cls;
|
||||
},
|
||||
|
||||
// ========== PR-FRONTEND-DIY-IMAGE-NAV-SPACING (2026-09-18): 图片自定义间距 ==========
|
||||
// 后台"滑动图片"组件(componentName = ImageNav)的 3 项间距配置。
|
||||
// 字段名已用线上接口 POST /api/diyview/info(uniacid=312, DIY_VIEW_INDEX) 核对, 实际为:
|
||||
// imageSpacing 图片横向间距: 相邻图片项之间的横向间隔
|
||||
// imageSpacingTop 图片上间距 : 图片区域内侧的上内边距
|
||||
// imageSpacingBottom 图片下间距 : 图片区域内侧的下内边距
|
||||
// 单位: 按项目惯例(与 diy-group 的 margin.top、图片轮播的 margin.both 一致)换算 rpx = 后台数值 × 2
|
||||
// 容错: 除后台现用命名外, 仍按归一化(忽略大小写/下划线/中划线)兼容其它命名,
|
||||
// 并向下探测 value.imageSpacing / value.margin / value.padding 等嵌套容器。
|
||||
// 兜底: 字段缺失/非法时返回 null, 由 imageSpacingStyle / imageItemStyle 回退历史视觉
|
||||
// (横向 28rpx, 上/下沿用 CSS 的 16rpx), 老装修数据不受影响。
|
||||
imageSpacingConfig() {
|
||||
const v = this.value || {};
|
||||
const RATIO = 2; // 后台数值 → rpx 换算比例
|
||||
|
||||
const norm = k => String(k).toLowerCase().replace(/[\s_-]/g, '');
|
||||
// 非数字(如 'diy'/枚举)或空值 → null, 交给兜底
|
||||
const toRpx = val => {
|
||||
if (val === undefined || val === null || val === '') return null;
|
||||
const n = Number(val);
|
||||
if (isNaN(n)) return null;
|
||||
return Math.max(0, n) * RATIO;
|
||||
};
|
||||
|
||||
// 查找源: value 自身 + 常见嵌套容器(顶层优先)
|
||||
// 注意: 后端偶发把 margin 存成数组(线上该组件 margin: []), 数组不能当容器取裸键, 直接跳过
|
||||
const sources = [v];
|
||||
['imageSpacing', 'imageSpace', 'spacing', 'space', 'margin', 'padding', 'style', 'config', 'setting']
|
||||
.forEach(k => {
|
||||
if (v[k] && typeof v[k] === 'object' && !Array.isArray(v[k])) sources.push(v[k]);
|
||||
});
|
||||
|
||||
// 三级查找: ① 全源精确名 → ② 全源模糊包含名 → ③ 嵌套容器内的裸键
|
||||
// (如 imageSpacing: { horizontal: 3, top: 0, bottom: 0 })
|
||||
const pick = (exact, fuzzy, nested) => {
|
||||
for (let s = 0; s < sources.length; s++) {
|
||||
const src = sources[s];
|
||||
for (const key in src) {
|
||||
if (exact.indexOf(norm(key)) != -1) {
|
||||
const rpx = toRpx(src[key]);
|
||||
if (rpx !== null) return rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let s = 0; s < sources.length; s++) {
|
||||
const src = sources[s];
|
||||
for (const key in src) {
|
||||
const k = norm(key);
|
||||
if (fuzzy.some(f => k.indexOf(f) != -1)) {
|
||||
const rpx = toRpx(src[key]);
|
||||
if (rpx !== null) return rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 裸键(top/bottom/both...)只在嵌套容器内解释, 避免误取顶层同名字段
|
||||
for (let s = 1; s < sources.length; s++) {
|
||||
const src = sources[s];
|
||||
for (const key in src) {
|
||||
if (nested.indexOf(norm(key)) != -1) {
|
||||
const rpx = toRpx(src[key]);
|
||||
if (rpx !== null) return rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return {
|
||||
// 图片横向间距 —— 后台现用命名 imageSpacing(首项命中即可, 后面都是兼容别名)
|
||||
horizontal: pick(
|
||||
['imagespacing', 'imagespacinghorizontal', 'imagehorizontalspacing', 'imagespacingboth', 'imagespacenum',
|
||||
'imagemargin', 'imagemarginnum', 'imagespace', 'horizontalspacing', 'horizontalspacenum', 'imagegap',
|
||||
'spacenum', 'marginnum', 'imageverticalspace', 'space', 'gap'],
|
||||
['horizontalspacing', 'imagespacing', 'imagespace', 'imagemargin', 'spacenum', 'marginnum', 'imagegap', 'gap'],
|
||||
['horizontal', 'horizontalnum', 'lr', 'leftright', 'both', 'bothnum', 'imagespacing', 'num', 'margin', 'space', 'gap']
|
||||
),
|
||||
// 图片上间距 —— 后台现用命名 imageSpacingTop
|
||||
paddingTop: pick(
|
||||
['imagespacingtop', 'imagetopspacing', 'imagespacetop', 'imagetopspace', 'imagepaddingtop',
|
||||
'paddingtop', 'spacingtop', 'topspace', 'margintop'],
|
||||
['imagespacingtop', 'paddingtop', 'spacingtop', 'topspacing', 'topspace', 'margintop'],
|
||||
['top', 'topnum', 'paddingtop', 'margintop', 'spacetop', 'spacingtop']
|
||||
),
|
||||
// 图片下间距 —— 后台现用命名 imageSpacingBottom
|
||||
paddingBottom: pick(
|
||||
['imagespacingbottom', 'imagebottomspacing', 'imagespacebottom', 'imagebottomspace', 'imagepaddingbottom',
|
||||
'paddingbottom', 'spacingbottom', 'bottomspace', 'marginbottom'],
|
||||
['imagespacingbottom', 'paddingbottom', 'spacingbottom', 'bottomspacing', 'bottomspace', 'marginbottom'],
|
||||
['bottom', 'bottomnum', 'paddingbottom', 'marginbottom', 'spacebottom', 'spacingbottom']
|
||||
)
|
||||
};
|
||||
},
|
||||
|
||||
// 图片区域上/下内边距(作用在滚动容器上); 未配置时不输出内联样式, 沿用 CSS padding
|
||||
imageSpacingStyle() {
|
||||
const cfg = this.imageSpacingConfig;
|
||||
let style = '';
|
||||
if (cfg.paddingTop !== null) style += 'padding-top:' + cfg.paddingTop + 'rpx;';
|
||||
if (cfg.paddingBottom !== null) style += 'padding-bottom:' + cfg.paddingBottom + 'rpx;';
|
||||
return style;
|
||||
},
|
||||
|
||||
// 图片项之间的横向间距; 未配置时回退原写死的 28rpx
|
||||
imageItemStyle() {
|
||||
const horizontal = this.imageSpacingConfig.horizontal;
|
||||
return 'margin-right:' + (horizontal === null ? 28 : horizontal) + 'rpx;';
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// ========== PR-FRONTEND-DIY-SCROLLBAR (2026-09-18): H5 "滑动时显示"滚动条控制 ==========
|
||||
// 背景: H5 端 scroll-view 的横向滚动条默认被 CSS 隐藏; "滑动时显示"需要监听 scroll,
|
||||
// 滚动中给容器加 is-scrolling 类展示滚动条, 停止滚动 800ms 后移除类再隐藏。
|
||||
// "始终显示/永不显示"为纯 CSS 类控制(scrollbar-always/scrollbar-never), 无需 JS。
|
||||
bindSlideScrollbar() {
|
||||
// #ifdef H5
|
||||
if (this._slideScrollBound) return;
|
||||
var el = this.getSlideContainer();
|
||||
if (!el) return;
|
||||
this._slideScrollEl = el;
|
||||
this._slideScrollBound = true;
|
||||
var self = this;
|
||||
this._onSlideScroll = function() {
|
||||
if (self.scrollBarMode != 'slide') return;
|
||||
if (!self.slideScrolling) self.slideScrolling = true;
|
||||
clearTimeout(self._slideScrollTimer);
|
||||
self._slideScrollTimer = setTimeout(function() {
|
||||
self.slideScrolling = false;
|
||||
}, 800);
|
||||
};
|
||||
el.addEventListener('scroll', this._onSlideScroll, { passive: true });
|
||||
// #endif
|
||||
},
|
||||
unbindSlideScrollbar() {
|
||||
// #ifdef H5
|
||||
if (this._slideScrollEl && this._onSlideScroll) {
|
||||
this._slideScrollEl.removeEventListener('scroll', this._onSlideScroll);
|
||||
}
|
||||
clearTimeout(this._slideScrollTimer);
|
||||
this._slideScrollEl = null;
|
||||
this._onSlideScroll = null;
|
||||
this._slideScrollBound = false;
|
||||
this.slideScrolling = false;
|
||||
// #endif
|
||||
},
|
||||
|
||||
// 页面跳转
|
||||
redirectTo(link) {
|
||||
if (!link.wap_url || this.$util.getCurrRoute() != this.$util.MEMBER_PAGE_URL || this.storeToken) {
|
||||
@@ -327,17 +586,61 @@ export default {
|
||||
// #ifdef H5
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
// 隐藏滚动条,视觉上仍保持单页布局;用户用鼠标拖拽/滚轮即可滚动
|
||||
scrollbar-width: none; // Firefox
|
||||
-ms-overflow-style: none; // IE/Edge legacy
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
// 禁止鼠标选中文字(拖拽时选中会显得很别扭)
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
// 拖拽时关闭过渡,避免 Chrome 的惯性滚动与 JS 抢控制权
|
||||
-webkit-overflow-scrolling: touch;
|
||||
|
||||
// ========== PR-FRONTEND-DIY-SCROLLBAR (2026-09-18): 水平滚动条显隐三态 ==========
|
||||
// 默认态(滑动时显示的静止态 / 永不显示): 隐藏滚动条,视觉上仍保持单页布局;
|
||||
// 用户用鼠标拖拽/滚轮即可滚动(滚动能力不受滚动条显隐影响)
|
||||
scrollbar-width: none; // Firefox
|
||||
-ms-overflow-style: none; // IE/Edge legacy
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
// uni scroll-view 在 H5 编译后真滚动容器是内层 .uni-scroll-view, 同步隐藏
|
||||
::v-deep .uni-scroll-view {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
::v-deep .uni-scroll-view::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// "始终显示" 与 "滑动时显示"(滚动中, 由 is-scrolling 类驱动): 展示细滚动条
|
||||
&.scrollbar-always,
|
||||
&.scrollbar-slide.is-scrolling {
|
||||
scrollbar-width: thin; // Firefox
|
||||
scrollbar-color: rgba(0, 0, 0, 0.25) transparent;
|
||||
&::-webkit-scrollbar {
|
||||
display: block;
|
||||
height: 4px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(0, 0, 0, 0.25);
|
||||
border-radius: 2px;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background-color: transparent;
|
||||
}
|
||||
::v-deep .uni-scroll-view {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(0, 0, 0, 0.25) transparent;
|
||||
}
|
||||
::v-deep .uni-scroll-view::-webkit-scrollbar {
|
||||
display: block;
|
||||
height: 4px;
|
||||
}
|
||||
::v-deep .uni-scroll-view::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(0, 0, 0, 0.25);
|
||||
border-radius: 2px;
|
||||
}
|
||||
::v-deep .uni-scroll-view::-webkit-scrollbar-track {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
|
||||
|
||||
@@ -6,13 +6,18 @@
|
||||
<image class="round" :src="$util.img(item.imageUrl)" style="border-radius:6rpx" show-menu-by-longpress></image>
|
||||
</view>
|
||||
<view class="fui-list-inner">
|
||||
<view class="title" style="height: 75rpx;">
|
||||
<text style="font-weight:600" :style="{ color: item.textColor }">{{ item.title }}</text>
|
||||
<view class="subtitle" style="font-size:24rpx" :style="{ color: item.textColor }">{{ item.desc }}
|
||||
</view>
|
||||
<view class="title">
|
||||
<text class="title-text" :style="titleStyle(item)">{{ item.title }}</text>
|
||||
<view class="subtitle" :style="descStyle(item)">{{ item.desc }}</view>
|
||||
<!-- #ifndef H5 -->
|
||||
<!-- PR-DIY-KEFU-ONE-LINE (2026-09-17): 小程序端隐藏测量节点, 以默认字号实测文本宽度, 反推一行能放下的最大字号 -->
|
||||
<text class="measure-text measure-title" style="font-size:28rpx;">{{ item.title }}</text>
|
||||
<text class="measure-text measure-desc" style="font-size:24rpx;">{{ item.desc }}</text>
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
</view>
|
||||
<view class="fui-remark jump" style="padding-right: 20rpx; text-align: center; line-height: 140rpx;">
|
||||
<!-- PR-DIY-KEFU-ONE-LINE (2026-09-17): 按钮颜色未配置时不再渲染/占位, 避免透明按钮挤窄文字区 -->
|
||||
<view class="fui-remark jump" v-if="item.BtBgColor || item.BtColor" style="padding-right: 20rpx; text-align: center; line-height: 140rpx;">
|
||||
<span style="font-size:24rpx;padding: 14rpx 18rpx;border-radius:8rpx"
|
||||
:style="{ background: item.BtBgColor, color: item.BtColor }" @tap.stop="previewSqs()">{{ $lang('components-diy.diy-kefu.addNow') }}</span>
|
||||
</view>
|
||||
@@ -35,15 +40,31 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
// PR-DIY-KEFU-ONE-LINE (2026-09-17): 小程序端实测得出的一行自适应字号(rpx), 0 表示用默认字号
|
||||
mpTitleSize: 0,
|
||||
mpDescSize: 0
|
||||
};
|
||||
},
|
||||
created() {
|
||||
// this.getDataList();
|
||||
},
|
||||
// PR-DIY-KEFU-ONE-LINE (2026-09-17): 英文公司名等长标题一行完整展示(不换行、不省略)
|
||||
// H5: 挂载/数据变化后实测每个文本的溢出宽度, 逐级缩小 font-size 直到恰好一行放下 —— 保证无省略号;
|
||||
// 小程序端无法测 DOM, 用字符宽度估算兜底(CJK 1em / 其他 0.6em), 极端超长时仍有省略号兜底
|
||||
mounted() {
|
||||
this.$nextTick(this.fitOneLineText)
|
||||
},
|
||||
watch: {
|
||||
// 组件刷新监听
|
||||
componentRefresh: function (nval) {
|
||||
// this.getDataList();
|
||||
this.$nextTick(this.fitOneLineText)
|
||||
},
|
||||
value: {
|
||||
deep: true,
|
||||
handler() {
|
||||
this.$nextTick(this.fitOneLineText)
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -66,9 +87,130 @@ export default {
|
||||
current: img,
|
||||
urls: [img]
|
||||
})
|
||||
},
|
||||
// PR-DIY-KEFU-ONE-LINE (2026-09-17): 单行自适应 —— 标题/描述一行完整展示(不换行), 字号取一行能放下的最大值
|
||||
// #ifdef H5
|
||||
fitOneLineText() {
|
||||
this.$nextTick(() => {
|
||||
const root = this.$el && this.$el.querySelectorAll ? this.$el : null
|
||||
if (!root) return
|
||||
const nodes = root.querySelectorAll('.title-text, .subtitle')
|
||||
nodes.forEach((el) => {
|
||||
// 先还原 CSS 默认字号, 再实测是否溢出; 目标宽度留 3px 右侧空隙, 不贴边
|
||||
el.style.fontSize = ''
|
||||
let size = parseFloat(window.getComputedStyle(el).fontSize)
|
||||
const MIN_PX = 10 // 下限 10px, 再小可读性太差
|
||||
const target = el.clientWidth - 3
|
||||
let guard = 0
|
||||
while (el.scrollWidth > target && size > MIN_PX && guard < 60) {
|
||||
size -= 0.5
|
||||
el.style.fontSize = size + 'px'
|
||||
guard++
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
// 小程序端: 隐藏测量节点以默认字号实测文本宽度, 宽度与字号成线性关系, 反推一行能放下的最大字号
|
||||
fitOneLineText() {
|
||||
uni.createSelectorQuery().in(this)
|
||||
.select('.fui-list-inner').boundingClientRect()
|
||||
.select('.measure-title').boundingClientRect()
|
||||
.select('.measure-desc').boundingClientRect()
|
||||
.exec((rects) => {
|
||||
if (!rects || !rects[0]) return
|
||||
const sys = uni.getSystemInfoSync()
|
||||
const pxPerRpx = (sys.windowWidth || 375) / 750
|
||||
// 可用宽度 = 文字区宽度 - 容器右内边距(8rpx) - 与右侧元素(按钮)之间留 8rpx 空隙
|
||||
const avail = rects[0].width - 16 * pxPerRpx
|
||||
if (avail <= 0) return
|
||||
// 标题: 默认 28rpx, 最小 16rpx
|
||||
if (rects[1] && rects[1].width > avail) {
|
||||
this.mpTitleSize = Math.max(16, Math.floor(28 * avail / rects[1].width))
|
||||
} else {
|
||||
this.mpTitleSize = 0
|
||||
}
|
||||
// 描述: 默认 24rpx, 最小 16rpx
|
||||
if (rects[2] && rects[2].width > avail) {
|
||||
this.mpDescSize = Math.max(16, Math.floor(24 * avail / rects[2].width))
|
||||
} else {
|
||||
this.mpDescSize = 0
|
||||
}
|
||||
})
|
||||
},
|
||||
// #endif
|
||||
titleStyle() {
|
||||
// #ifndef H5
|
||||
return this.mpTitleSize ? 'font-size:' + this.mpTitleSize + 'rpx;' : ''
|
||||
// #endif
|
||||
// #ifdef H5
|
||||
return ''
|
||||
// #endif
|
||||
},
|
||||
descStyle() {
|
||||
// #ifndef H5
|
||||
return this.mpDescSize ? 'font-size:' + this.mpDescSize + 'rpx;' : ''
|
||||
// #endif
|
||||
// #ifdef H5
|
||||
return ''
|
||||
// #endif
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss"></style>
|
||||
<style lang="scss">
|
||||
// 客服展示:标题/描述需要完整展示(中英文均不截断)
|
||||
// 全局 .fui-list-inner .title 有 nowrap + ellipsis + overflow:hidden,这里用更高优先级的选择器覆盖
|
||||
// PR-DIY-KEFU-ONE-LINE (2026-09-17): 标题/描述强制单行(配合 JS 自适应字号保证一行放得下), 超长省略号兜底
|
||||
.diy-kefu {
|
||||
.fui-list-group {
|
||||
.fui-list {
|
||||
align-items: center;
|
||||
|
||||
.fui-list-inner {
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
// 右侧留少量空隙, 避免单行文本贴着容器右边缘, 又能尽量铺满到按钮旁
|
||||
padding-right: 8rpx;
|
||||
box-sizing: border-box;
|
||||
|
||||
.title {
|
||||
height: auto;
|
||||
line-height: 1.4;
|
||||
|
||||
.title-text {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
// PR-DIY-KEFU-ONE-LINE (2026-09-17): 小程序端隐藏测量节点(不占布局、不可见)
|
||||
.measure-text {
|
||||
position: absolute;
|
||||
left: -9999rpx;
|
||||
top: 0;
|
||||
white-space: nowrap;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -12,10 +12,10 @@
|
||||
<!-- #ifdef MP -->
|
||||
<block
|
||||
v-if="(memberInfo.nickname.indexOf('u_') != -1 && memberInfo.nickname == memberInfo.username) || memberInfo.nickname == memberInfo.mobile">
|
||||
<view class="nickname"><text class="name" @tap.stop="getWxAuth">{{ $lang('components-diy.diy-member-info.clickToAuthorizeAvatarNickname') }}</text></view>
|
||||
<view class="nickname"><text class="name" :style="nameStyle" @tap.stop="getWxAuth">{{ $lang('components-diy.diy-member-info.clickToAuthorizeAvatarNickname') }}</text></view>
|
||||
</block>
|
||||
<view class="nickname" v-else>
|
||||
<text class="name" @tap.stop="getWxAuth">{{ memberInfo.nickname }}</text>
|
||||
<text class="name" :style="nameStyle" @tap.stop="getWxAuth">{{ memberInfo.nickname }}</text>
|
||||
<view class="member-level"
|
||||
v-if="(value.style == 1 || value.style == 2) && memberInfo.member_level">
|
||||
<!-- <text class="icondiy icon-system-huangguan"></text> -->
|
||||
@@ -39,7 +39,7 @@
|
||||
<view class="nickname"><text class="name" @tap.stop="getWxAuth">{{ $lang('components-diy.diy-member-info.clickToGetWechatAvatar') }}</text></view>
|
||||
</block>
|
||||
<view class="nickname" v-else>
|
||||
<text class="name" @tap.stop="redirect('/pages_tool/member/info')">{{ memberInfo.nickname
|
||||
<text class="name" :style="nameStyle" @tap.stop="redirect('/pages_tool/member/info')">{{ memberInfo.nickname
|
||||
}}</text>
|
||||
<view class="member-level"
|
||||
v-if="(value.style == 1 || value.style == 2) && memberInfo.member_level">
|
||||
@@ -69,7 +69,7 @@
|
||||
<image :src="$util.getDefaultImage().head" mode="widthFix" show-menu-by-longpress></image>
|
||||
</view>
|
||||
<view class="info">
|
||||
<view class="nickname"><text class="name">{{ $lang('member.index.login') }}</text></view>
|
||||
<view class="nickname"><text class="name" :style="nameStyle">{{ $lang('member.index.login') }}</text></view>
|
||||
<view class="desc">{{ $lang('member.index.loginTips') }}</view>
|
||||
</view>
|
||||
|
||||
@@ -367,6 +367,26 @@ export default {
|
||||
isDisabled() {
|
||||
if (this.nickName.length > 0) return false;
|
||||
return true;
|
||||
},
|
||||
// PR-DIY-MEMBER-INFO-NAME-FIT (2026-09-17): 英文公司名等长标题自动缩小字号保证一行放下(像后台预览一样一排)
|
||||
// 标题来源: 已登录取昵称, 未登录取 $lang('member.index.login') 文案(英文包里配置的是公司全称)
|
||||
// 估算宽度: CJK/全角字符按 1em, 其余(英文/数字/空格)按 0.55em; 标题可用宽度约 470rpx
|
||||
// 最小 20rpx, 超长时仍有 .name 的省略号兜底
|
||||
nameStyle() {
|
||||
let name = '';
|
||||
if (this.memberInfo && this.memberInfo.nickname) {
|
||||
name = this.memberInfo.nickname;
|
||||
} else {
|
||||
try { name = this.$lang('member.index.login') || ''; } catch (e) { name = ''; }
|
||||
}
|
||||
if (!name) return '';
|
||||
let units = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
units += /[\u2e80-\u9fff\uac00-\ud7af\u3000-\u303f\uff01-\uff60]/.test(name[i]) ? 1 : 0.55;
|
||||
}
|
||||
if (units <= 10) return ''; // 短标题走默认 38rpx
|
||||
const size = Math.max(20, Math.min(34, Math.floor(470 / units)));
|
||||
return 'font-size:' + size + 'rpx;';
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
@error="imgError(index)" show-menu-by-longpress />
|
||||
</view>
|
||||
<view class="info-wrap">
|
||||
<text class="title" @tap.stop="handlerClick(item)">{{ item.merch_name }}</text>
|
||||
<text class="desc">{{ item.desc }}</text>
|
||||
<text class="title" :style="titleStyle(item)" @tap.stop="handlerClick(item)">{{ item.merch_name }}</text>
|
||||
<text class="desc" :style="descStyle(item)">{{ item.desc }}</text>
|
||||
<view class="read-wrap"></view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -179,6 +179,25 @@ export default {
|
||||
this.toDetail(item);
|
||||
}
|
||||
})
|
||||
},
|
||||
// PR-DIY-MERCH-LIST-ONE-LINE (2026-09-17): 英文公司名等长标题/描述自动缩小字号, 保证单行显示(像后台预览一样一排)
|
||||
// 估算宽度: CJK/全角字符按 1em, 其余(英文/数字/空格)按 0.55em; 文字区可用宽度约 500rpx
|
||||
// 短文本走 CSS 默认字号, 极端超长时仍有省略号兜底
|
||||
oneLineFontStyle(text, defaultSize, minSize) {
|
||||
if (!text) return '';
|
||||
let units = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
units += /[\u2e80-\u9fff\uac00-\ud7af\u3000-\u303f\uff01-\uff60]/.test(text[i]) ? 1 : 0.55;
|
||||
}
|
||||
if (units <= 12) return '';
|
||||
const size = Math.max(minSize, Math.min(defaultSize, Math.floor(500 / units)));
|
||||
return 'font-size:' + size + 'rpx;';
|
||||
},
|
||||
titleStyle(item) {
|
||||
return this.oneLineFontStyle(item.merch_name, 30, 20);
|
||||
},
|
||||
descStyle(item) {
|
||||
return this.oneLineFontStyle(item.desc, 24, 20);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -296,13 +315,19 @@ export default {
|
||||
}
|
||||
|
||||
.info-wrap {
|
||||
flex: 1;
|
||||
min-width: 0; /* PR-DIY-MERCH-LIST-ONE-LINE: 约束宽度让单行省略号生效 */
|
||||
|
||||
// flex: 1;
|
||||
// display: flex;
|
||||
// flex-direction: column;
|
||||
// justify-content: space-between;
|
||||
.desc {
|
||||
color: #888
|
||||
color: #888;
|
||||
/* PR-DIY-MERCH-LIST-ONE-LINE: 描述单行, 超出省略 */
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.title {
|
||||
@@ -311,8 +336,9 @@ export default {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-line-clamp: 1; /* PR-DIY-MERCH-LIST-ONE-LINE: 标题一行(原为2行), 配合自适应字号保证放得下 */
|
||||
-webkit-box-orient: vertical;
|
||||
white-space: nowrap;
|
||||
font-size: 30rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -113,8 +113,16 @@ export const lang = {
|
||||
// ====== ai-chat-tools-panel ======
|
||||
moreFunctions: 'More Features',
|
||||
image: 'Image',
|
||||
video: 'Video/Audio',
|
||||
file: 'File',
|
||||
voice: 'Voice',
|
||||
recordVoice: 'Record Voice',
|
||||
uploadAudio: 'Upload Audio',
|
||||
pickVideo: 'Choose Video',
|
||||
pickAudio: 'Choose Audio',
|
||||
chooseAudioFail: 'Failed to select audio',
|
||||
chooseVideoFail: 'Failed to select video',
|
||||
audioNotReady: 'Audio not ready',
|
||||
location: 'Location',
|
||||
|
||||
// ====== ai-chat-voice-panel ======
|
||||
|
||||
@@ -113,8 +113,16 @@ export const lang = {
|
||||
// ====== ai-chat-tools-panel ======
|
||||
moreFunctions: '更多功能',
|
||||
image: '图片',
|
||||
video: '视频/音频',
|
||||
file: '文件',
|
||||
voice: '语音',
|
||||
recordVoice: '录制语音',
|
||||
uploadAudio: '上传音频',
|
||||
pickVideo: '选择视频',
|
||||
pickAudio: '选择音频',
|
||||
chooseAudioFail: '选择音频失败',
|
||||
chooseVideoFail: '选择视频失败',
|
||||
audioNotReady: '音频尚未就绪',
|
||||
location: '位置',
|
||||
|
||||
// ====== ai-chat-voice-panel ======
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,25 +11,32 @@
|
||||
<view class="tools-grid">
|
||||
<view class="tool-item" @click="selectImage">
|
||||
<view class="tool-icon">
|
||||
<text class="iconfont icon-image"></text>
|
||||
<text class="icondiy icon-system-image-line"></text>
|
||||
</view>
|
||||
<text class="tool-text">{{ $lang('ai-chat.index.image') }}</text>
|
||||
</view>
|
||||
<view class="tool-item" @click="selectVideo">
|
||||
<view class="tool-icon">
|
||||
<text class="icondiy icon-system-video"></text>
|
||||
</view>
|
||||
<!-- 第二个参数是 $lang 的默认值: 后端 bundle 尚未下发该 key 时也不会显示 raw key -->
|
||||
<text class="tool-text">{{ $lang('ai-chat.index.video', '视频/音频') }}</text>
|
||||
</view>
|
||||
<view class="tool-item" @click="selectFile">
|
||||
<view class="tool-icon">
|
||||
<text class="iconfont icon-file"></text>
|
||||
<text class="icondiy icon-system-file-2-line"></text>
|
||||
</view>
|
||||
<text class="tool-text">{{ $lang('ai-chat.index.file') }}</text>
|
||||
</view>
|
||||
<view class="tool-item" @click="startRecord">
|
||||
<view class="tool-item" @click="selectVoiceOrAudio">
|
||||
<view class="tool-icon">
|
||||
<text class="iconfont icon-voice"></text>
|
||||
<text class="icondiy icon-system-chat-voice-line"></text>
|
||||
</view>
|
||||
<text class="tool-text">{{ $lang('ai-chat.index.voice') }}</text>
|
||||
</view>
|
||||
<view class="tool-item" @click="selectLocation">
|
||||
<view class="tool-icon">
|
||||
<text class="iconfont icon-location"></text>
|
||||
<text class="icondiy icon-system-map-pin-line"></text>
|
||||
</view>
|
||||
<text class="tool-text">{{ $lang('ai-chat.index.location') }}</text>
|
||||
</view>
|
||||
@@ -49,7 +56,7 @@ import { uploadImage, uploadMedia } from '@/common/js/media-upload.js'
|
||||
* Events:
|
||||
* update:visible - 同步关闭状态
|
||||
* image-selected - 选择图片,参数:图片消息对象
|
||||
* file-selected - 选择文件,参数:文件消息对象
|
||||
* file-selected - 选择文件/视频/音频,参数:消息对象 (type: 'file' | 'video' | 'audio')
|
||||
* voice-start - 开始语音
|
||||
* location-selected - 选择位置,参数:位置消息对象
|
||||
*/
|
||||
@@ -153,6 +160,153 @@ export default {
|
||||
// #endif
|
||||
},
|
||||
|
||||
// 选择「视频/音频」并上传 (H5 / 微信小程序 / App)
|
||||
// PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): 原来 H5 只写 accept='video/*',
|
||||
// 系统文件选择器会把本地 .mp3/.wav 等音频文件全部过滤掉, 用户看不到也选不了音频。
|
||||
// 现在 accept 同时包含 audio/*, 选中后按 mime / 扩展名自动判定 kind (video | audio)。
|
||||
selectVideo() {
|
||||
const that = this
|
||||
// #ifdef H5
|
||||
const mediaInput = document.createElement('input')
|
||||
mediaInput.type = 'file'
|
||||
mediaInput.accept = 'video/*,audio/*'
|
||||
mediaInput.onchange = async (e) => {
|
||||
const file = e.target.files[0]
|
||||
if (!file) return
|
||||
that.hideToolsPanel()
|
||||
await that._uploadOneFile(file, that._guessMediaKind(file))
|
||||
}
|
||||
mediaInput.click()
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN || APP-PLUS
|
||||
// 小程序/App 没有"音视频二合一"的选择器, 用二次弹窗让用户明确选视频还是音频
|
||||
uni.showActionSheet({
|
||||
itemList: [
|
||||
that.$lang('ai-chat.index.pickVideo', '选择视频'),
|
||||
that.$lang('ai-chat.index.pickAudio', '选择音频')
|
||||
],
|
||||
success: (res) => {
|
||||
if (res.tapIndex === 0) that._chooseVideo()
|
||||
else if (res.tapIndex === 1) that.selectAudio()
|
||||
},
|
||||
fail: () => {}
|
||||
})
|
||||
// #endif
|
||||
},
|
||||
|
||||
// 按 mime / 扩展名判断选中的文件是视频还是音频
|
||||
_guessMediaKind(file) {
|
||||
const type = (file && file.type ? file.type : '').toLowerCase()
|
||||
if (type.indexOf('audio/') === 0) return 'audio'
|
||||
if (type.indexOf('video/') === 0) return 'video'
|
||||
const ext = String((file && file.name) || '').split('.').pop().toLowerCase()
|
||||
const audioExt = ['mp3', 'wav', 'aac', 'm4a', 'flac', 'ogg', 'oga', 'amr', 'wma', 'aiff', 'aif', 'opus', 'ape', 'mid']
|
||||
if (audioExt.indexOf(ext) !== -1) return 'audio'
|
||||
// 兜底: mime 为空且扩展名不认识的 (如 Windows 未关联的音频) 交给后端按 URL 判断, 先按音频走
|
||||
return type ? 'video' : 'audio'
|
||||
},
|
||||
|
||||
// 选视频 (微信小程序 / App 共用)
|
||||
_chooseVideo() {
|
||||
const that = this
|
||||
uni.chooseVideo({
|
||||
sourceType: ['album', 'camera'],
|
||||
compressed: true,
|
||||
maxDuration: 60,
|
||||
success: async (res) => {
|
||||
that.hideToolsPanel()
|
||||
await that._uploadOneFile({
|
||||
path: res.tempFilePath,
|
||||
size: res.size || 0,
|
||||
duration: res.duration || 0,
|
||||
name: 'video_' + Date.now() + '.mp4',
|
||||
type: 'video/mp4'
|
||||
}, 'video')
|
||||
},
|
||||
fail: (err) => {
|
||||
if (err && err.errMsg && !err.errMsg.includes('cancel')) {
|
||||
uni.showToast({ title: that.$lang('ai-chat.index.chooseVideoFail'), icon: 'none' })
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// “语音/音频”: 提供 录制语音 / 上传音频 两种方式
|
||||
selectVoiceOrAudio() {
|
||||
uni.showActionSheet({
|
||||
itemList: [
|
||||
this.$lang('ai-chat.index.recordVoice'),
|
||||
this.$lang('ai-chat.index.uploadAudio')
|
||||
],
|
||||
success: (res) => {
|
||||
if (res.tapIndex === 0) {
|
||||
this.startRecord()
|
||||
} else if (res.tapIndex === 1) {
|
||||
this.selectAudio()
|
||||
}
|
||||
},
|
||||
fail: () => {}
|
||||
})
|
||||
},
|
||||
|
||||
// 选择音频文件并上传 (H5 / 微信小程序 / App)
|
||||
selectAudio() {
|
||||
const that = this
|
||||
// #ifdef H5
|
||||
const audioInput = document.createElement('input')
|
||||
audioInput.type = 'file'
|
||||
audioInput.accept = 'audio/*'
|
||||
audioInput.onchange = async (e) => {
|
||||
const file = e.target.files[0]
|
||||
if (!file) return
|
||||
that.hideToolsPanel()
|
||||
await that._uploadOneFile(file, 'audio')
|
||||
}
|
||||
audioInput.click()
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
// 微信小程序没有专门的音频选择 API, 用 chooseMessageFile 选文件后按扩展名过滤
|
||||
const chooseFileApi = (typeof uni.chooseMessageFile === 'function')
|
||||
? uni.chooseMessageFile
|
||||
: (typeof wx !== 'undefined' ? wx.chooseMessageFile : null)
|
||||
if (!chooseFileApi) {
|
||||
uni.showToast({ title: that.$lang('ai-chat.index.chooseAudioFail'), icon: 'none' })
|
||||
return
|
||||
}
|
||||
chooseFileApi({
|
||||
count: 1,
|
||||
type: 'file',
|
||||
extension: ['mp3', 'wav', 'aac', 'm4a', 'flac', 'ogg', 'amr', 'wma', 'aiff'],
|
||||
success: async (res) => {
|
||||
const file = (res.tempFiles && res.tempFiles[0]) || null
|
||||
if (!file) return
|
||||
that.hideToolsPanel()
|
||||
await that._uploadOneFile(file, 'audio')
|
||||
},
|
||||
fail: (err) => {
|
||||
if (err && err.errMsg && !err.errMsg.includes('cancel')) {
|
||||
uni.showToast({ title: that.$lang('ai-chat.index.chooseAudioFail'), icon: 'none' })
|
||||
}
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
|
||||
// #ifdef APP-PLUS
|
||||
uni.chooseFile({
|
||||
count: 1,
|
||||
extension: ['mp3', 'wav', 'aac', 'm4a', 'flac', 'ogg', 'amr'],
|
||||
success: async (res) => {
|
||||
const file = res.tempFiles[0]
|
||||
if (!file) return
|
||||
that.hideToolsPanel()
|
||||
await that._uploadOneFile(file, 'audio')
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
},
|
||||
|
||||
async _uploadOneFile(file, kind) {
|
||||
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): Blob URL 生命周期管理
|
||||
// 原代码 URL.createObjectURL 出的 blob URL 永不释放, 长期跑会泄漏
|
||||
@@ -177,6 +331,12 @@ export default {
|
||||
fileName: file.name || (file.path ? file.path.split('/').pop() : ''),
|
||||
fileSize: file.size || 0,
|
||||
fileType: file.type || '',
|
||||
// url 供音频/视频播放器使用; imageUrl 供图片/视频封面预览
|
||||
url: displayPath,
|
||||
duration: file.duration || 0,
|
||||
// 音频播放状态 (audio 气泡点击播放/暂停需要, 预先声明保证响应式)
|
||||
playing: false,
|
||||
currentTime: 0,
|
||||
imageUrl: displayPath,
|
||||
uploading: true,
|
||||
uploadError: null,
|
||||
@@ -188,12 +348,18 @@ export default {
|
||||
// 传给 uploadImage/uploadMedia: H5 传 file 对象 (media-upload 内部 pathToFile 转),
|
||||
// 非 H5 传 file.path (uni.uploadFile 需要)
|
||||
const inputArg = blobUrl || file.path || file
|
||||
// PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): H5 传的是 blob URL (无扩展名),
|
||||
// 把原始 name/mime/size 传给 media-upload, 保证 STS 上传 key 保留 .mp3/.mp4 后缀,
|
||||
// 否则云端存成 .bin + application/octet-stream, 音频/视频播放器拿不到正确 Content-Type
|
||||
const uploaded = kind === 'image'
|
||||
? await uploadImage(inputArg)
|
||||
: await uploadMedia(inputArg, kind)
|
||||
: await uploadMedia(inputArg, kind, { name: file.name, mime: file.type, size: file.size })
|
||||
this.$set(pending, 'uploading', false)
|
||||
this.$set(pending, 'uploaded', uploaded)
|
||||
if (uploaded.url) this.$set(pending, 'imageUrl', uploaded.url)
|
||||
if (uploaded.url) {
|
||||
this.$set(pending, 'imageUrl', uploaded.url)
|
||||
this.$set(pending, 'url', uploaded.url)
|
||||
}
|
||||
} catch (e) {
|
||||
this.$set(pending, 'uploading', false)
|
||||
this.$set(pending, 'uploadError', e.message || '上传失败')
|
||||
@@ -325,7 +491,8 @@ export default {
|
||||
margin-bottom: 16rpx;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
.iconfont {
|
||||
.iconfont,
|
||||
.icondiy {
|
||||
font-size: 48rpx;
|
||||
color: $color-primary;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user