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>
|
||||
|
||||
<!-- 模板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,10 +152,43 @@
|
||||
<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>
|
||||
|
||||
<!-- 模板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"
|
||||
@@ -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 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>
|
||||
<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 ======
|
||||
|
||||
@@ -53,21 +53,31 @@
|
||||
<!-- 用户消息 (WorkBuddy 风格: 右对齐 + 浅灰气泡 + 头像在右) -->
|
||||
<view v-if="message.role === 'user'" class="user-message">
|
||||
<view class="message-content">
|
||||
<view v-if="message.type === 'image'">
|
||||
<view class="message-bubble user-image-bubble">
|
||||
<image :src="message.imageUrl" mode="aspectFill" class="user-image" />
|
||||
<view v-if="message.uploading" class="upload-mask"><text class="upload-tip">上传中...</text></view>
|
||||
<view v-if="message.uploadError" class="upload-mask upload-error"><text class="upload-tip">{{ message.uploadError }}</text></view>
|
||||
<view v-if="message.type === 'image'" class="user-media-group">
|
||||
<!-- 图片气泡 (在上, 与下方文字气泡同列右对齐)
|
||||
PR-FRONTEND-MULTI-IMAGE-ROW (2026-09-17): 上传几张就显示几张, 横向排列 -->
|
||||
<view class="user-image-row" :class="'n' + Math.min(messageImages(message).length || 1, 4)">
|
||||
<view
|
||||
v-for="(img, imgIndex) in messageImages(message)"
|
||||
:key="imgIndex"
|
||||
class="message-bubble user-image-bubble">
|
||||
<image
|
||||
:src="img.url"
|
||||
mode="aspectFill"
|
||||
class="user-image"
|
||||
@click="previewUserImage(message, imgIndex)" />
|
||||
<view v-if="imgIndex === 0 && message.uploading" class="upload-mask"><text class="upload-tip">上传中...</text></view>
|
||||
<view v-if="imgIndex === 0 && message.uploadError" class="upload-mask upload-error"><text class="upload-tip">{{ message.uploadError }}</text></view>
|
||||
</view>
|
||||
<!-- 图片配文: 图文一起发出时, 文字显示在图片下方 -->
|
||||
</view>
|
||||
<!-- 图片配文: 独立气泡, 显示在图片下方 -->
|
||||
<view v-if="message.content" class="message-bubble image-caption-bubble">
|
||||
<text class="message-text" selectable>{{ message.content }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 用户文件气泡
|
||||
tools-panel _uploadOneFile 选 kind='file' (audio/video 当前未走
|
||||
用户侧, 走 AI 侧 attachments) 时, m.type='file' + m.fileName/size
|
||||
+ m.imageUrl=本地预览图(若有) / m.uploaded.url 上传后 URL -->
|
||||
tools-panel _uploadOneFile 选 kind='file'/'video'/'audio' 时,
|
||||
m.type + m.fileName/size + m.imageUrl=本地预览(若有) / m.uploaded.url 上传后 URL -->
|
||||
<view v-else-if="message.type === 'file'" class="message-bubble user-file-bubble" @click="previewUserFile(message)">
|
||||
<view class="file-item">
|
||||
<view class="file-icon"><text class="iconfont icon-file"></text></view>
|
||||
@@ -79,10 +89,45 @@
|
||||
<view v-if="message.uploadError" class="upload-mask upload-error"><text class="upload-tip">{{ message.uploadError }}</text></view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 用户视频消息气泡 (视频上传): 内嵌播放器 -->
|
||||
<view v-else-if="message.type === 'video'" class="message-bubble user-video-bubble">
|
||||
<view class="video-item">
|
||||
<video
|
||||
class="user-video"
|
||||
:src="message.url || message.imageUrl"
|
||||
:controls="true"
|
||||
:autoplay="false"
|
||||
object-fit="contain"></video>
|
||||
<view v-if="message.uploading" class="upload-mask"><text class="upload-tip">上传中...</text></view>
|
||||
<view v-if="message.uploadError" class="upload-mask upload-error"><text class="upload-tip">{{ message.uploadError }}</text></view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 用户音频消息气泡 (音频上传): 点击播放/暂停 -->
|
||||
<view v-else-if="message.type === 'audio'" class="message-bubble user-audio-bubble" @click="playUserAudio(message)">
|
||||
<view class="file-item">
|
||||
<view class="file-icon"><text class="icondiy" :class="message.playing ? 'icon-system-pause-circle-fill' : 'icon-system-play-circle-fill'"></text></view>
|
||||
<view class="file-info">
|
||||
<text class="file-name">{{ message.fileName || $lang('ai-chat.index.audioMessage') }}</text>
|
||||
<text class="file-size">{{ formatFileSize(message.fileSize) }}</text>
|
||||
</view>
|
||||
<view v-if="message.uploading" class="upload-mask"><text class="upload-tip">上传中...</text></view>
|
||||
<view v-if="message.uploadError" class="upload-mask upload-error"><text class="upload-tip">{{ message.uploadError }}</text></view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="message-bubble">
|
||||
<rich-text v-if="searchKeyword" :nodes="highlightContent(message.content)" class="message-rich-text" />
|
||||
<text v-else class="message-text" selectable>{{ message.content }}</text>
|
||||
</view>
|
||||
<!-- 用户发送的附件(文件/视频/音频)预览: 与文字/图片同气泡下方展示。
|
||||
图片附件不在此重复渲染(messageImages 已在上方图片气泡展示), 这里只渲染非图片附件 -->
|
||||
<view v-if="message.attachments && message.attachments.length && message.role === 'user'" class="attachments user-attachments">
|
||||
<template v-for="att in message.attachments">
|
||||
<view v-if="!(att.type === 'image' || att.kind === 'image')" :key="att.url" class="attachment" @click="previewAttachment(att)">
|
||||
<text class="file-icon">{{ att.type === 'video' ? '🎬' : (att.type === 'audio' ? '🎵' : '📎') }}</text>
|
||||
<text class="file-name">{{ att.name || '文件' }}</text>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
<view class="avatar" @tap="showAvatarSelector = true">
|
||||
<image :src="localUserAvatar" mode="aspectFill" show-menu-by-longpress/>
|
||||
@@ -301,10 +346,11 @@
|
||||
<text class="iconfont icon-copy">⎘</text>
|
||||
</view>
|
||||
<view class="ai-action-btn" :class="{ active: message._liked }" @click="onAiActionLike(message)">
|
||||
<text class="iconfont icon-thumbsup">👍</text>
|
||||
<!-- PR-FRONTEND-ACTION-SVG-ICON (2026-09-18): 点赞/点踩改用线性 SVG 矢量图, 不再用彩色 emoji -->
|
||||
<view class="svg-icon icon-like"></view>
|
||||
</view>
|
||||
<view class="ai-action-btn" :class="{ active: message._disliked }" @click="onAiActionDislike(message)">
|
||||
<text class="iconfont icon-thumbsdown">👎</text>
|
||||
<view class="svg-icon icon-dislike"></view>
|
||||
</view>
|
||||
<view class="ai-action-btn more-btn" @click="onAiActionMore(message)">
|
||||
<text class="iconfont icon-more">···</text>
|
||||
@@ -320,10 +366,12 @@
|
||||
|
||||
<!-- 输入区域 (新版: 顶部胶囊形输入条 + 底部 5 个图标) -->
|
||||
<view class="input-area" :style="inputAreaStyle">
|
||||
<view class="input-card">
|
||||
<!-- 待发送图片附件预览: 挂在输入条内部顶端(文字行上方), 出现时输入框自动向上撑高, 删除后恢复原高度 -->
|
||||
<view v-if="pendingImages.length" class="pending-attachment">
|
||||
<view v-for="p in pendingImages" :key="p.localId" class="pending-item">
|
||||
<view class="input-card" :class="{ 'is-voice-mode': voiceMode, 'is-text-mode': showTextMode }">
|
||||
<!-- 待发送附件预览(图片 / 文件 / 视频 / 音频): 挂在输入条内部顶端(文字行上方),
|
||||
出现时输入框自动向上撑高, 删除后恢复原高度。
|
||||
图片与文件走同一套 pending 逻辑: 选完先在这里预览, 等用户点发送再随文字一起发出 -->
|
||||
<view v-if="pendingImages.length || pendingFiles.length" class="pending-attachment">
|
||||
<view v-for="p in pendingImages" :key="'img-' + p.localId" class="pending-item">
|
||||
<image :src="p.uploaded ? p.uploaded.url : p.tempPath" mode="aspectFill" class="pending-image" />
|
||||
<view v-if="p.uploading" class="pending-mask"><text>上传中</text></view>
|
||||
<view v-else-if="p.uploadError" class="pending-mask error"><text>失败</text>
|
||||
@@ -332,18 +380,48 @@
|
||||
</view>
|
||||
<view class="pending-remove" @tap="removePendingImage(p.localId)">×</view>
|
||||
</view>
|
||||
<view v-for="f in pendingFiles" :key="'file-' + f.localId" class="pending-item">
|
||||
<image v-if="f.type === 'image' && (f.uploaded ? f.uploaded.url : f.imageUrl)" :src="f.uploaded ? f.uploaded.url : f.imageUrl" mode="aspectFill" class="pending-image" />
|
||||
<view v-else class="pending-file-thumb">
|
||||
<text class="pending-file-icon">{{ f.type === 'video' ? '🎬' : (f.type === 'audio' ? '🎵' : '📄') }}</text>
|
||||
<text class="pending-file-name">{{ f.fileName }}</text>
|
||||
</view>
|
||||
<!-- 顶部输入行: 语音图标 + 输入区 + 加号 + 发送 -->
|
||||
<view class="input-row-top">
|
||||
<!-- 无文字时左侧显示语音图标,点击才打开语音面板 -->
|
||||
<view v-if="!hasInputText" class="input-row-icon" @click="onVoiceClick">
|
||||
<view v-if="f.uploading" class="pending-mask"><text>上传中</text></view>
|
||||
<view v-else-if="f.uploadError" class="pending-mask error"><text>失败</text>
|
||||
<view class="pending-retry-delete" @tap.stop="removePendingFile(f.localId)">删除</view>
|
||||
</view>
|
||||
<view class="pending-remove" @tap="removePendingFile(f.localId)">×</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- PR-FRONTEND-INPUT-3STATES (2026-09-17): 输入区三态
|
||||
① 默认(未输入/未聚焦): 单行胶囊 —— 语音图标 + 输入框 + 加号
|
||||
② 语音模式(点语音图标进入): 「按住说话」按钮 + 底部工具栏(表情/模型/加号)
|
||||
③ 文字输入(聚焦或有文字): 输入框独占一行 + 底部工具栏(语音/模型/加号/发送) -->
|
||||
<!-- 语音模式: 按住说话 -->
|
||||
<view
|
||||
v-if="voiceMode"
|
||||
class="voice-hold-btn"
|
||||
:class="{ 'is-holding': voiceHolding }"
|
||||
@touchstart.stop.prevent="onVoiceHoldStart"
|
||||
@touchend.stop.prevent="onVoiceHoldEnd"
|
||||
@touchcancel.stop.prevent="onVoiceHoldCancel"
|
||||
@click.stop="onVoiceHoldEnd">
|
||||
<text class="voice-hold-text">{{ voiceHolding ? '松开发送' : '按住说话' }}</text>
|
||||
</view>
|
||||
|
||||
<view v-else class="input-row-top">
|
||||
<!-- 默认态左侧: 点击进入语音(按住说话)模式 -->
|
||||
<!-- PR-FRONTEND-H5-INPUT-FOCUS (2026-09-17): 兄弟节点加 key, 防止 H5 下 Vue 无 key 复用
|
||||
同标签 view 导致 textarea 被销毁重建、聚焦即失焦(塌回胶囊无法输入) -->
|
||||
<view v-if="!showTextMode" key="voice-icon" class="input-row-icon" @click="enterVoiceMode">
|
||||
<!-- 微信小程序不支持内联 SVG,改用 base64 SVG 背景图 -->
|
||||
<view class="voice-icon-bg"></view>
|
||||
</view>
|
||||
|
||||
<!-- 输入区: textarea 始终可点击输入,有文字时下方显示 Auto 选择器 -->
|
||||
<view class="input-left-content" :class="{ 'with-voice-icon': !hasInputText }">
|
||||
<!-- 输入区: textarea 三态共用同一个节点, 避免切换时丢焦点 -->
|
||||
<view key="input-content" class="input-left-content" :class="{ 'with-voice-icon': !showTextMode }">
|
||||
<textarea
|
||||
ref="messageInput"
|
||||
v-model="inputText"
|
||||
class="message-input"
|
||||
placeholder="发消息或按住说话"
|
||||
@@ -351,12 +429,37 @@
|
||||
:maxlength="500"
|
||||
:auto-height="true"
|
||||
:show-confirm-bar="false"
|
||||
:adjust-position="false"
|
||||
:adjust-position="adjustPosition"
|
||||
:cursor-spacing="150"
|
||||
@confirm="sendMessage"
|
||||
@input="onInput"
|
||||
@touchstart="onInputPointerDown"
|
||||
@mousedown="onInputPointerDown"
|
||||
@focus="onInputFocus"
|
||||
@blur="onInputBlur"
|
||||
@linechange="onLineChange" />
|
||||
<view v-if="hasInputText" class="model-selector" @click="onToggleModelSelector">
|
||||
</view>
|
||||
|
||||
<!-- 默认态右侧: 只有加号 -->
|
||||
<view v-if="!showTextMode" key="right-actions" class="input-right-actions">
|
||||
<button class="top-plus-btn" @click="showMoreTools">
|
||||
<text class="plus-icon">+</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部工具栏行: 语音模式 / 文字输入时显示 -->
|
||||
<view v-if="voiceMode || showTextMode" class="input-toolbar-row">
|
||||
<!-- 语音模式: 键盘图标 (点击回到文字输入) -->
|
||||
<view v-if="voiceMode" class="toolbar-round-btn" @click="exitVoiceMode">
|
||||
<view class="emoji-icon-bg"></view>
|
||||
</view>
|
||||
<!-- 文字输入: 语音图标 (切到按住说话) -->
|
||||
<view v-else class="toolbar-round-btn" @click="enterVoiceMode">
|
||||
<view class="voice-icon-bg"></view>
|
||||
</view>
|
||||
|
||||
<view class="model-selector" @click="onToggleModelSelector">
|
||||
<!-- 圆圈 @ 图标,H5/小程序通用 -->
|
||||
<view class="model-icon-circle">
|
||||
<text class="model-icon-text">@</text>
|
||||
@@ -364,20 +467,19 @@
|
||||
<text class="model-name">{{ selectedModel }}</text>
|
||||
<text class="model-arrow">▾</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="input-right-actions">
|
||||
<view class="toolbar-flex-spacer"></view>
|
||||
|
||||
<button class="top-plus-btn" @click="showMoreTools">
|
||||
<text class="plus-icon">+</text>
|
||||
</button>
|
||||
|
||||
<!-- 有文字或图片附件上传完成时显示发送按钮 -->
|
||||
<button v-if="hasInputText || canSendPendingImage" class="top-send-btn" @click="sendMessage">
|
||||
<button class="top-send-btn" :class="{ 'is-disabled': !canSend }" @click="sendMessage">
|
||||
<!-- H5/小程序都兼容:旋转箭头模拟向上的纸飞机 -->
|
||||
<text class="send-plane-text">➤</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部工具栏: 5 个图标按钮均匀分布(当前隐藏,保留代码) -->
|
||||
<view class="input-toolbar" style="display: none;">
|
||||
@@ -546,11 +648,23 @@ export default {
|
||||
// 必须用非下划线前缀. PR-FRONTEND-MEDIA-UPLOAD-FIX-HOTFIX (2026-09-16)
|
||||
pendingIdSeq: 0,
|
||||
pendingImages: [],
|
||||
// PR-FRONTEND-MEDIA-PENDING-FILE (2026-09-17): 文件/视频/音频与图片同一套 pending 逻辑
|
||||
// 选完先挂在这里预览, 等用户点发送再随文字一起发出 (不再选完直接进聊天区)
|
||||
pendingFiles: [],
|
||||
scrollTop: 0,
|
||||
scrollIntoViewId: null,
|
||||
hasMoreHistory: false,
|
||||
loadingText: '',
|
||||
voiceInputing: false,
|
||||
// PR-FRONTEND-INPUT-3STATES (2026-09-17): 输入区三态
|
||||
// voiceMode 语音模式(按住说话) / voiceHolding 按住中 / inputFocused 输入框是否聚焦
|
||||
voiceMode: false,
|
||||
voiceHolding: false,
|
||||
inputFocused: false,
|
||||
// 失焦定时器: 用于延迟收起文字输入态, 避免 H5 布局变化触发的瞬时 blur 误收起
|
||||
_blurTimer: null,
|
||||
// 平台相关: H5 让浏览器自动把输入框顶起(adjust-position=true), 小程序用键盘垫高自行处理
|
||||
adjustPosition: false,
|
||||
audioContext: null,
|
||||
currentAudio: null,
|
||||
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): AI 侧音频播放引擎
|
||||
@@ -641,12 +755,32 @@ export default {
|
||||
if (!this.pendingImages.length) return false
|
||||
return this.pendingImages.every(p => !p.uploading && !p.uploadError && p.uploaded)
|
||||
},
|
||||
// PR-FRONTEND-MEDIA-PENDING-FILE (2026-09-17): 待发送文件附件是否均可发送
|
||||
canSendPendingFile() {
|
||||
if (!this.pendingFiles || !this.pendingFiles.length) return false
|
||||
return this.pendingFiles.every(p => !p.uploading && !p.uploadError && p.uploaded)
|
||||
},
|
||||
hasInputText() {
|
||||
return this.inputText && this.inputText.trim() !== ''
|
||||
},
|
||||
// PR-FRONTEND-INPUT-3STATES (2026-09-17): 文字输入态
|
||||
// 聚焦 或 已有文字时展开为"输入框一行 + 工具栏一行", 否则回到单行胶囊。
|
||||
// 用聚焦(而不是只判断文字)是为了: 删除最后一个字时布局不会突然塌回胶囊。
|
||||
// 另外: 有"上传完成的待发送图片/文件"时也必须展开, 否则发送按钮(在工具栏行)不可见。
|
||||
showTextMode() {
|
||||
return !this.voiceMode && (this.inputFocused || this.hasInputText || this.canSendPendingImage || this.canSendPendingFile)
|
||||
},
|
||||
// 发送按钮可用态 (无内容时按钮保留但置灰)
|
||||
canSend() {
|
||||
return this.hasInputText || this.canSendPendingImage || this.canSendPendingFile
|
||||
}
|
||||
},
|
||||
// 新增:组件销毁时清除所有定时器
|
||||
beforeDestroy() {
|
||||
if (this._blurTimer) clearTimeout(this._blurTimer);
|
||||
// #ifdef H5
|
||||
this._teardownH5KeyboardObserver && this._teardownH5KeyboardObserver();
|
||||
// #endif
|
||||
if (this.scrollTimer) clearTimeout(this.scrollTimer);
|
||||
if (this.searchFlashTimer) {
|
||||
clearTimeout(this.searchFlashTimer);
|
||||
@@ -693,6 +827,11 @@ export default {
|
||||
// 确保 H5 在组件挂载后也尝试加载历史(兜底)
|
||||
this.loadHistoryIfNeeded()
|
||||
})
|
||||
// #ifdef H5
|
||||
// H5 下 uni.onKeyboardHeightChange 不会触发, 改用 visualViewport/window 监听键盘高度,
|
||||
// 通过 keyboard-spacer 把输入框顶起, 行为与小程序一致(否则键盘会遮住输入框/输入区)。
|
||||
this._setupH5KeyboardObserver()
|
||||
// #endif
|
||||
},
|
||||
watch: {
|
||||
messages: {
|
||||
@@ -808,10 +947,23 @@ export default {
|
||||
},
|
||||
|
||||
onFileSelected(fileMessage) {
|
||||
this.messages.push(fileMessage)
|
||||
// PR-FRONTEND-MEDIA-PENDING-FILE (2026-09-17): 文件与图片同一逻辑 ——
|
||||
// 选完不直接进聊天区, 挂到 pendingFiles 在输入框上方预览, 等点发送再随问题一起发出
|
||||
const localId = ++this.pendingIdSeq
|
||||
this.$set(fileMessage, 'localId', localId)
|
||||
this.pendingFiles.push(fileMessage)
|
||||
this.$emit('file-selected', fileMessage)
|
||||
},
|
||||
|
||||
// 移除待发送文件附件 (按 localId)
|
||||
removePendingFile(localId) {
|
||||
if (localId === undefined || localId === null) {
|
||||
this.pendingFiles = []
|
||||
return
|
||||
}
|
||||
this.pendingFiles = this.pendingFiles.filter(p => p.localId !== localId)
|
||||
},
|
||||
|
||||
onLocationSelected(locationMessage) {
|
||||
this.messages.push(locationMessage)
|
||||
this.$emit('location-selected', locationMessage)
|
||||
@@ -927,6 +1079,47 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
// #ifdef H5
|
||||
// H5 软键盘高度检测: 监听 visualViewport(优先) / window.resize(降级)。
|
||||
// 键盘弹起时可视区域高度变小, 差值即为键盘高度, 写入 keyboardHeight 由视图垫高层顶起输入框。
|
||||
_setupH5KeyboardObserver() {
|
||||
try {
|
||||
const vv = window.visualViewport
|
||||
if (vv && typeof vv.addEventListener === 'function') {
|
||||
this._h5KbOnResize = () => {
|
||||
const kb = Math.max(0, window.innerHeight - vv.height - (vv.offsetTop || 0))
|
||||
this.updateKeyboardHeight(kb)
|
||||
}
|
||||
vv.addEventListener('resize', this._h5KbOnResize)
|
||||
vv.addEventListener('scroll', this._h5KbOnResize)
|
||||
// 初始记录一次基准高度
|
||||
this._h5KbOnResize()
|
||||
} else {
|
||||
this._h5WinH = window.innerHeight
|
||||
this._h5KbOnResize = () => {
|
||||
const kb = Math.max(0, this._h5WinH - window.innerHeight)
|
||||
this.updateKeyboardHeight(kb)
|
||||
}
|
||||
window.addEventListener('resize', this._h5KbOnResize)
|
||||
}
|
||||
} catch (e) { /* H5 键盘监听失败不阻断输入 */ }
|
||||
},
|
||||
|
||||
_teardownH5KeyboardObserver() {
|
||||
try {
|
||||
const vv = window.visualViewport
|
||||
if (vv && this._h5KbOnResize) {
|
||||
vv.removeEventListener('resize', this._h5KbOnResize)
|
||||
vv.removeEventListener('scroll', this._h5KbOnResize)
|
||||
}
|
||||
if (this._h5KbOnResize) {
|
||||
window.removeEventListener('resize', this._h5KbOnResize)
|
||||
}
|
||||
} catch (e) { /* noop */ }
|
||||
this._h5KbOnResize = null
|
||||
},
|
||||
// #endif
|
||||
|
||||
// 【新增:会话 ID 本地缓存方法】
|
||||
saveConversationIdToLocal(convId) {
|
||||
if (convId) {
|
||||
@@ -1052,48 +1245,46 @@ async loadChatHistoryIfExist() {
|
||||
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): pendingImages 数组版
|
||||
// 原 pendingImage 单数, 改支持 1-9 张图一起发
|
||||
async sendMessage() {
|
||||
// 确保有内容、表情或待发送图片
|
||||
// 确保有内容、表情或待发送附件(图片/文件)
|
||||
const hasContent = this.inputText && this.inputText.trim() !== ''
|
||||
const hasEmoji = this.containsEmoji(this.inputText)
|
||||
const pendingList = this.pendingImages
|
||||
const pendingImagesList = this.pendingImages
|
||||
const pendingFilesList = this.pendingFiles
|
||||
const allPending = [...pendingImagesList, ...pendingFilesList]
|
||||
|
||||
// 附件图片必须上传完成才能发送 (数组版: 任何一张还在 uploading/出错都拦)
|
||||
if (pendingList.length) {
|
||||
if (pendingList.some(p => p.uploading)) {
|
||||
uni.showToast({ title: '图片上传中,请稍候', icon: 'none' })
|
||||
// 附件必须上传完成才能发送 (任何一张/个还在 uploading/出错都拦)
|
||||
if (allPending.length) {
|
||||
if (allPending.some(p => p.uploading)) {
|
||||
uni.showToast({ title: '附件上传中,请稍候', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (pendingList.some(p => p.uploadError || !p.uploaded)) {
|
||||
uni.showToast({ title: '图片上传失败,请删除后重试', icon: 'none' })
|
||||
if (allPending.some(p => p.uploadError || !p.uploaded)) {
|
||||
uni.showToast({ title: '附件上传失败,请删除后重试', icon: 'none' })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasContent && !hasEmoji && !pendingList.length) return
|
||||
if (!hasContent && !hasEmoji && !allPending.length) return
|
||||
|
||||
// 收集已上传、尚未随消息发送的附件 (用户图片/文件消息)
|
||||
const readyAttachments = this.messages
|
||||
.filter(m => m.role === 'user' && m.uploaded && !m.sendWithBatch)
|
||||
.map(m => {
|
||||
m.sendWithBatch = true
|
||||
return {
|
||||
// 待发送文件/视频/音频附件 (与图片一并带出给后端)
|
||||
const fileAttachments = pendingFilesList
|
||||
.filter(p => p.uploaded)
|
||||
.map(p => ({
|
||||
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 不再把 audio/video 强转 file
|
||||
// m.uploaded.kind 是后端响应 (image/file); m.type 是前端分类
|
||||
// (audio/video 当前未走用户侧, 但保留路径供未来 audio recorder 接入)
|
||||
type: m.uploaded.kind || m.type || 'file',
|
||||
url: m.uploaded.url,
|
||||
mime: m.uploaded.mime,
|
||||
name: m.uploaded.name,
|
||||
size: m.uploaded.size
|
||||
}
|
||||
})
|
||||
// p.uploaded.kind 是后端响应 (image/file/audio/video); p.type 是前端分类
|
||||
type: p.uploaded.kind || p.type || 'file',
|
||||
url: p.uploaded.url,
|
||||
mime: p.uploaded.mime,
|
||||
name: p.uploaded.name,
|
||||
size: p.uploaded.size
|
||||
}))
|
||||
|
||||
// 输入框待发送图片: 全部 push, 第一张作为 userMessage.imageUrl 显示
|
||||
// PR-FRONTEND-MEDIA-INLINE-BASE64 (2026-09-16): 同时给附件补 data: <base64> 字段,
|
||||
// 避开 backend MediaGuard SSRF 闸门 (LAN URL 被识别为内网 IP 直接丢).
|
||||
// 注意: 必须 await 让 base64 生成后再 build userMessage / sendStreamMessage, 否则附件没 data 字段
|
||||
const enrichedPendingAttachments = await Promise.all(
|
||||
pendingList
|
||||
pendingImagesList
|
||||
.filter(p => p.uploaded)
|
||||
.map(async p => {
|
||||
const base = {
|
||||
@@ -1115,7 +1306,9 @@ async loadChatHistoryIfExist() {
|
||||
return base
|
||||
})
|
||||
)
|
||||
readyAttachments.push(...enrichedPendingAttachments)
|
||||
|
||||
// 实际发送给后端的内容 = 图片(含 base64) + 文件/视频/音频
|
||||
const readyAttachments = [...fileAttachments, ...enrichedPendingAttachments]
|
||||
|
||||
// 记录用户发出消息的时间戳, AI 回复完成时算 elapsed
|
||||
this._aiRequestStart = Date.now()
|
||||
@@ -1123,18 +1316,12 @@ async loadChatHistoryIfExist() {
|
||||
const queryText = hasContent
|
||||
? this.inputText.trim()
|
||||
: (readyAttachments.length ? '[附件]' : '')
|
||||
// 带图片时消息类型为 image, 图片 + 配文一起显示; 纯文字时仍为 text
|
||||
const firstPending = pendingList.length ? pendingList[0] : null
|
||||
const userMessage = firstPending
|
||||
? {
|
||||
id: ++this.messageId,
|
||||
role: 'user',
|
||||
type: 'image',
|
||||
imageUrl: firstPending.uploaded.url || firstPending.tempPath,
|
||||
// PR-FRONTEND-MEDIA-UPLOAD-FIX: 多图时把其他图片 URL 也带在消息上, 模板侧可循环展示
|
||||
// PR-FRONTEND-MEDIA-INLINE-BASE64 (2026-09-16): 复用上面 enrichedPendingAttachments 的 data 字段
|
||||
// (同一份 base64 计算, 不要再读一次 file → 节省内存 + CPU)
|
||||
attachments: enrichedPendingAttachments.map(a => ({
|
||||
// 带图片时消息类型为 image, 图片 + 配文一起显示; 纯文件/文字时仍为 text
|
||||
const firstPendingImage = pendingImagesList.length ? pendingImagesList[0] : null
|
||||
// 用户气泡内展示用的附件: 图片(供 messageImages 渲染多图) + 文件/视频/音频
|
||||
// 注: 图片附件由上方图片气泡渲染, 文件附件由用户消息 attachments 块渲染, 互不重复
|
||||
const displayAttachments = [
|
||||
...enrichedPendingAttachments.map(a => ({
|
||||
url: a.url,
|
||||
mime: a.mime,
|
||||
name: a.name,
|
||||
@@ -1142,6 +1329,24 @@ async loadChatHistoryIfExist() {
|
||||
kind: a.type || 'image',
|
||||
...(a.data ? { data: a.data, encoding: a.encoding } : {}),
|
||||
})),
|
||||
...fileAttachments.map(a => ({
|
||||
url: a.url,
|
||||
mime: a.mime,
|
||||
name: a.name,
|
||||
size: a.size,
|
||||
kind: a.type || 'file',
|
||||
})),
|
||||
]
|
||||
const userMessage = firstPendingImage
|
||||
? {
|
||||
id: ++this.messageId,
|
||||
role: 'user',
|
||||
type: 'image',
|
||||
imageUrl: firstPendingImage.uploaded.url || firstPendingImage.tempPath,
|
||||
// PR-FRONTEND-MEDIA-UPLOAD-FIX: 多图时把其他图片 URL 也带在消息上, 模板侧可循环展示
|
||||
// PR-FRONTEND-MEDIA-INLINE-BASE64 (2026-09-16): 复用上面 enrichedPendingAttachments 的 data 字段
|
||||
// (同一份 base64 计算, 不要再读一次 file → 节省内存 + CPU)
|
||||
attachments: displayAttachments,
|
||||
content: hasContent ? this.inputText.trim() : '',
|
||||
timestamp: Date.now()
|
||||
}
|
||||
@@ -1150,12 +1355,15 @@ async loadChatHistoryIfExist() {
|
||||
role: 'user',
|
||||
type: 'text',
|
||||
content: queryText,
|
||||
// 纯文件(无图)时把文件挂到 attachments, 用户消息气泡下方展示文件卡片
|
||||
attachments: displayAttachments,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
this.shouldScrollToBottom = true
|
||||
this.messages.push(userMessage)
|
||||
this.inputText = ''
|
||||
this.pendingImages = []
|
||||
this.pendingFiles = []
|
||||
|
||||
// 显示AI正在处理 - 带状态轮换提示
|
||||
const loadingMessage = {
|
||||
@@ -1510,6 +1718,21 @@ startQuestionTimer(msg) {
|
||||
}, 1000)
|
||||
},
|
||||
|
||||
// PR-FRONTEND-MULTI-IMAGE-ROW (2026-09-17): 取出用户消息里所有图片 (按发送顺序)
|
||||
// 优先用 attachments (多图批量发送), 只有单张时退回 imageUrl
|
||||
messageImages(message) {
|
||||
const list = (message.attachments || []).filter(a => !a.kind || a.kind === 'image' || a.type === 'image')
|
||||
if (list.length) return list
|
||||
return message.imageUrl ? [{ url: message.imageUrl }] : []
|
||||
},
|
||||
|
||||
// 点击用户图片: 在全部图片中预览 (左右可滑动切换)
|
||||
previewUserImage(message, index) {
|
||||
const urls = this.messageImages(message).map(img => img.url).filter(Boolean)
|
||||
if (!urls.length) return
|
||||
uni.previewImage({ urls, current: urls[index] || urls[0] })
|
||||
},
|
||||
|
||||
previewAttachment(att) {
|
||||
if (att.type === 'image') {
|
||||
uni.previewImage({ urls: [att.url], current: att.url })
|
||||
@@ -2100,6 +2323,18 @@ _handleStreamError(msg) {
|
||||
// 复用 AI 侧文件预览: H5 new tab; APP plus.runtime.openFile
|
||||
this.previewFile({ url })
|
||||
},
|
||||
// 用户上传的音频消息: 点击播放/暂停 (上传中/未就绪时给出提示)
|
||||
playUserAudio(message) {
|
||||
if (message.uploading) {
|
||||
uni.showToast({ title: '上传中,请稍候', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!message.url) {
|
||||
uni.showToast({ title: this.$lang('ai-chat.index.audioNotReady'), icon: 'none' })
|
||||
return
|
||||
}
|
||||
this.toggleAudio(message)
|
||||
},
|
||||
// 切换音频播放
|
||||
toggleAudio(message) {
|
||||
if (message.playing) {
|
||||
@@ -2666,7 +2901,106 @@ _handleStreamError(msg) {
|
||||
} catch (e) { /* noop */ }
|
||||
},
|
||||
|
||||
// 语音按钮点击: 复用 showVoicePanel 流程 (若需要触发工具面板里的语音入口)
|
||||
// ==================== PR-FRONTEND-INPUT-3STATES (2026-09-17) ====================
|
||||
// 进入语音模式(按住说话): 收起键盘, 输入区从"单行胶囊"切成"按住说话 + 工具栏"
|
||||
enterVoiceMode() {
|
||||
if (this.voiceMode) return
|
||||
try { uni.hideKeyboard() } catch (e) { /* noop */ }
|
||||
this.voiceMode = true
|
||||
this.voiceHolding = false
|
||||
this.inputFocused = false
|
||||
},
|
||||
|
||||
// 退出语音模式, 回到文字输入态
|
||||
exitVoiceMode() {
|
||||
this.voiceMode = false
|
||||
this.voiceHolding = false
|
||||
},
|
||||
|
||||
// 按住说话: 按下 (文案切"松开发送" + 高亮)
|
||||
onVoiceHoldStart() {
|
||||
this.voiceHolding = true
|
||||
},
|
||||
|
||||
// 按住说话: 松开
|
||||
// 项目当前没有录音 + 语音识别实现, 松开后复用原有「语音输入」面板入口, 保证功能不丢。
|
||||
// H5 鼠标(桌面预览)不会触发 touch 事件, 所以 click 也绑到同一个方法; showVoicePanel 幂等, 重复调用无副作用。
|
||||
onVoiceHoldEnd() {
|
||||
this.voiceHolding = false
|
||||
this.showVoicePanel()
|
||||
},
|
||||
|
||||
// 按住说话: 手势被系统打断 (来电/滑动取消)
|
||||
onVoiceHoldCancel() {
|
||||
this.voiceHolding = false
|
||||
},
|
||||
|
||||
// 输入框聚焦 / 失焦: 驱动"文字输入态"布局
|
||||
onInputFocus() {
|
||||
// 聚焦时取消尚未触发的失焦收起定时器(处理 H5 布局变化导致的瞬时 blur->focus)
|
||||
if (this._blurTimer) {
|
||||
clearTimeout(this._blurTimer)
|
||||
this._blurTimer = null
|
||||
}
|
||||
this._focusTs = Date.now()
|
||||
// 幻影失焦恢复触发的 focus 不重置计数, 避免失去防循环保护
|
||||
if (!this._refocusing) this._refocusCount = 0
|
||||
this.inputFocused = true
|
||||
if (this.voiceMode) this.voiceMode = false
|
||||
},
|
||||
|
||||
// PR-FRONTEND-H5-INPUT-FOCUS (2026-09-17): 按下阶段预展开
|
||||
// H5 上聚焦会触发布局从中途展开(语音图标移除/工具栏行插入), 该变动会导致 textarea 被动失焦,
|
||||
// 表现为"短暂展开后立刻塌回胶囊, 无法输入"。在 touchstart/mousedown 阶段先置 inputFocused=true,
|
||||
// 让布局在原生 focus 到来之前就完成展开, 聚焦过程中不再有布局变动。
|
||||
onInputPointerDown() {
|
||||
if (this._blurTimer) {
|
||||
clearTimeout(this._blurTimer)
|
||||
this._blurTimer = null
|
||||
}
|
||||
this._lastInputPointerTs = Date.now()
|
||||
if (!this.voiceMode && !this.inputFocused) this.inputFocused = true
|
||||
},
|
||||
|
||||
onInputBlur() {
|
||||
// 延迟收起: 先让工具栏按钮的 click 派发完, 避免点"发送/加号"时布局先塌回胶囊。
|
||||
// 同时防止 H5 因布局变化(出现/隐藏工具栏行)触发的瞬时 blur 误将输入态收起:
|
||||
// 到时若输入框仍是真实焦点(document.activeElement 仍是本 textarea), 则不收起。
|
||||
// #ifdef H5
|
||||
if (this._blurTimer) clearTimeout(this._blurTimer)
|
||||
this._blurTimer = setTimeout(() => {
|
||||
this._blurTimer = null
|
||||
const el = this.$refs.messageInput && this.$refs.messageInput.$el
|
||||
const ta = el && el.querySelector ? el.querySelector('textarea') : null
|
||||
const stillFocused = ta
|
||||
? (document.activeElement === ta || el.contains(document.activeElement))
|
||||
: false
|
||||
if (stillFocused) return
|
||||
// 幻影失焦恢复: 聚焦后极短时间内(键盘弹起/布局重排导致)失焦, 且用户刚在输入框上按下,
|
||||
// 说明用户意图是输入而非点别处 —— 主动把焦点拉回, 不塌回胶囊。最多恢复 2 次防死循环。
|
||||
const withinBounceWindow = this._focusTs && (Date.now() - this._focusTs < 800)
|
||||
const tappedInside = this._lastInputPointerTs && (Date.now() - this._lastInputPointerTs < 800)
|
||||
if (withinBounceWindow && tappedInside && (this._refocusCount || 0) < 2) {
|
||||
this._refocusCount = (this._refocusCount || 0) + 1
|
||||
this._refocusing = true
|
||||
this._focusTs = Date.now()
|
||||
try { ta && ta.focus({ preventScroll: true }) } catch (e) { try { ta && ta.focus() } catch (e2) { /* noop */ } }
|
||||
setTimeout(() => { this._refocusing = false }, 0)
|
||||
return
|
||||
}
|
||||
this.inputFocused = false
|
||||
}, 200)
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
if (this._blurTimer) clearTimeout(this._blurTimer)
|
||||
this._blurTimer = setTimeout(() => {
|
||||
this._blurTimer = null
|
||||
this.inputFocused = false
|
||||
}, 200)
|
||||
// #endif
|
||||
},
|
||||
|
||||
// 语音按钮点击: 兼容旧入口 (打开语音输入面板)
|
||||
onVoiceClick() {
|
||||
try {
|
||||
this.showVoicePanel && this.showVoicePanel()
|
||||
@@ -3759,12 +4093,39 @@ _handleStreamError(msg) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* PR-FRONTEND-ACTION-SVG-ICON (2026-09-18): 点赞/点踩线性 SVG 矢量图标
|
||||
微信小程序不支持内联 SVG, 沿用 voice-icon-bg 的 base64 背景图方案;
|
||||
默认灰色描边, active 态切换为蓝色描边版本 */
|
||||
.svg-icon {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
|
||||
&.icon-like {
|
||||
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjNjY2NjY2IiBzdHJva2Utd2lkdGg9IjEuOCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNNyAxMXY4YTEgMSAwIDAgMSAtMSAxaC0yYTEgMSAwIDAgMSAtMSAtMXYtN2ExIDEgMCAwIDEgMSAtMWgzYTQgNCAwIDAgMCA0IC00di0xYTIgMiAwIDAgMSA0IDB2NWgzYTIgMiAwIDAgMSAyIDJsLTEgNWEyIDIgMCAwIDEgLTIgMmgtN2E0IDQgMCAwIDEgLTQgLTQiLz48L3N2Zz4=");
|
||||
}
|
||||
|
||||
&.icon-dislike {
|
||||
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjNjY2NjY2IiBzdHJva2Utd2lkdGg9IjEuOCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNNyAxMXY4YTEgMSAwIDAgMSAtMSAxaC0yYTEgMSAwIDAgMSAtMSAtMXYtN2ExIDEgMCAwIDEgMSAtMWgzYTQgNCAwIDAgMCA0IC00di0xYTIgMiAwIDAgMSA0IDB2NWgzYTIgMiAwIDAgMSAyIDJsLTEgNWEyIDIgMCAwIDEgLTIgMmgtN2E0IDQgMCAwIDEgLTQgLTQiIHRyYW5zZm9ybT0icm90YXRlKDE4MCAxMiAxMikiLz48L3N2Zz4=");
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #e6f0ff;
|
||||
|
||||
.iconfont {
|
||||
color: #1f6cff;
|
||||
}
|
||||
|
||||
.svg-icon.icon-like {
|
||||
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMWY2Y2ZmIiBzdHJva2Utd2lkdGg9IjEuOCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNNyAxMXY4YTEgMSAwIDAgMSAtMSAxaC0yYTEgMSAwIDAgMSAtMSAtMXYtN2ExIDEgMCAwIDEgMSAtMWgzYTQgNCAwIDAgMCA0IC00di0xYTIgMiAwIDAgMSA0IDB2NWgzYTIgMiAwIDAgMSAyIDJsLTEgNWEyIDIgMCAwIDEgLTIgMmgtN2E0IDQgMCAwIDEgLTQgLTQiLz48L3N2Zz4=");
|
||||
}
|
||||
|
||||
.svg-icon.icon-dislike {
|
||||
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMWY2Y2ZmIiBzdHJva2Utd2lkdGg9IjEuOCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNNyAxMXY4YTEgMSAwIDAgMSAtMSAxaC0yYTEgMSAwIDAgMSAtMSAtMXYtN2ExIDEgMCAwIDEgMSAtMWgzYTQgNCAwIDAgMCA0IC00di0xYTIgMiAwIDAgMSA0IDB2NWgzYTIgMiAwIDAgMSAyIDJsLTEgNWEyIDIgMCAwIDEgLTIgMmgtN2E0IDQgMCAwIDEgLTQgLTQiIHRyYW5zZm9ybT0icm90YXRlKDE4MCAxMiAxMikiLz48L3N2Zz4=");
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
@@ -3823,10 +4184,6 @@ _handleStreamError(msg) {
|
||||
.voice-icon-bg {
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMiAzMiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMWYxZjFmIiBzdHJva2Utd2lkdGg9IjIuMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMTAgMTIgaDYgbDYgLTQgdjE2IGwtNiAtNCBoLTYgYTIgMiAwIDAgMSAtMiAtMiB2LTQgYTIgMiAwIDAgMSAyIC0yIHoiLz48cGF0aCBkPSJNMjQgMTAgYzIgMyAyIDkgMCAxMiIvPjwvc3ZnPg==");
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3913,6 +4270,138 @@ _handleStreamError(msg) {
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* ===== PR-FRONTEND-INPUT-3STATES (2026-09-17): 输入区三态 =====
|
||||
① 默认(未聚焦无文字): 单行胶囊 .input-row-top
|
||||
② 语音模式 .is-voice-mode: 「按住说话」+ 工具栏行(表情/模型/加号)
|
||||
③ 文字输入 .is-text-mode: 输入框一行 + 工具栏行(语音/模型/加号/发送) */
|
||||
|
||||
/* 语音图标 / 键盘图标 (base64 SVG: 小程序不支持内联 SVG) */
|
||||
.voice-icon-bg {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMiAzMiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMWYxZjFmIiBzdHJva2Utd2lkdGg9IjIuMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMTAgMTIgaDYgbDYgLTQgdjE2IGwtNiAtNCBoLTYgYTIgMiAwIDAgMSAtMiAtMiB2LTQgYTIgMiAwIDAgMSAyIC0yIHoiLz48cGF0aCBkPSJNMjQgMTAgYzIgMyAyIDkgMCAxMiIvPjwvc3ZnPg==");
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
/* 键盘图标: 语音模式下点击切回文字输入 */
|
||||
.emoji-icon-bg {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMiAzMiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMWYxZjFmIiBzdHJva2Utd2lkdGg9IjIuMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cmVjdCB4PSIzLjUiIHk9IjkiIHdpZHRoPSIyNSIgaGVpZ2h0PSIxNSIgcng9IjIuNSIvPjxwYXRoIGQ9Ik04IDE0aC4wMU0xMi41IDE0aC4wMU0xNyAxNGguMDFNMjEuNSAxNGguMDFNOCAxOC41aC4wMU0xMiAxOC41aC4wMU0xNiAxOC41aC4wMU0yMCAxOC41aC4wMU0yNCAxOC41aC4wMU0xMCAyMi41aDEyIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwLC0xKSIvPjwvc3ZnPg==");
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
/* 展开态: 卡片变白底圆角容器, 内部输入行不再单独成胶囊 */
|
||||
.input-card {
|
||||
&.is-voice-mode,
|
||||
&.is-text-mode {
|
||||
background: #ffffff;
|
||||
border-radius: 28rpx;
|
||||
padding: 14rpx 16rpx 6rpx;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
&.is-voice-mode .input-row-top,
|
||||
&.is-text-mode .input-row-top {
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
min-height: 76rpx;
|
||||
padding: 6rpx 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 语音模式: 按住说话 */
|
||||
.voice-hold-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 96rpx;
|
||||
margin: 4rpx 0 8rpx;
|
||||
border-radius: 16rpx;
|
||||
background: #f2f2f3;
|
||||
|
||||
&.is-holding {
|
||||
background: #e2e2e4;
|
||||
}
|
||||
|
||||
.voice-hold-text {
|
||||
font-size: 30rpx;
|
||||
color: #1f1f1f;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
/* 底部工具栏行: 语音模式 / 文字输入态 */
|
||||
.input-toolbar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 76rpx;
|
||||
padding: 0 2rpx 4rpx;
|
||||
|
||||
.toolbar-round-btn {
|
||||
flex-shrink: 0;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 4rpx;
|
||||
}
|
||||
|
||||
.toolbar-flex-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 模型选择器 (从输入框内部移到工具栏行) */
|
||||
.model-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
height: 44rpx;
|
||||
padding: 0 6rpx;
|
||||
|
||||
.model-icon-circle {
|
||||
width: 30rpx;
|
||||
height: 30rpx;
|
||||
border-radius: 50%;
|
||||
border: 2rpx solid #666666;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
|
||||
.model-icon-text {
|
||||
font-size: 18rpx;
|
||||
color: #666666;
|
||||
line-height: 1;
|
||||
margin-top: 2rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.model-name {
|
||||
font-size: 26rpx;
|
||||
color: #1f1f1f;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.model-arrow {
|
||||
font-size: 22rpx;
|
||||
color: #999999;
|
||||
line-height: 1;
|
||||
margin-top: 2rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 默认态右侧操作按钮: + / 发送 (展开态工具栏行共用同一套样式) */
|
||||
.top-plus-btn {
|
||||
flex-shrink: 0;
|
||||
width: 76rpx;
|
||||
@@ -3959,11 +4448,15 @@ _handleStreamError(msg) {
|
||||
margin-top: -2rpx;
|
||||
}
|
||||
|
||||
/* 无可发送内容时置灰 (仍有 sendMessage 内部兜底拦截) */
|
||||
&.is-disabled {
|
||||
background: #e6e6e8;
|
||||
}
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 底部工具栏: 5 个图标按钮均匀分布 */
|
||||
.input-toolbar {
|
||||
@@ -4646,11 +5139,39 @@ _handleStreamError(msg) {
|
||||
}
|
||||
}
|
||||
|
||||
/* 用户图片气泡 */
|
||||
.user-image-bubble {
|
||||
/* 用户图文消息容器: 让图片气泡与文字气泡在同一列内右对齐,
|
||||
图片自然落在文字气泡的正上方 (而不是贴在左上角) */
|
||||
.user-media-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* PR-FRONTEND-MULTI-IMAGE-ROW (2026-09-17): 用户多图横向排列容器 */
|
||||
.user-image-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
align-items: flex-end;
|
||||
max-width: 600rpx;
|
||||
|
||||
/* 图片数量越多单张越小, 保证一行放得下; 超过 4 张自动换行 */
|
||||
&.n1 .user-image { width: 280rpx; height: 280rpx; }
|
||||
&.n2 .user-image { width: 200rpx; height: 200rpx; }
|
||||
&.n3 .user-image { width: 145rpx; height: 145rpx; }
|
||||
&.n4 .user-image { width: 108rpx; height: 108rpx; }
|
||||
}
|
||||
|
||||
/* 用户图片气泡 (提高选择器权重, 覆盖 H5/移动端下 .user-message .message-content .message-bubble 的大内边距) */
|
||||
.user-message .message-content .message-bubble.user-image-bubble {
|
||||
padding: 6rpx !important;
|
||||
overflow: hidden;
|
||||
.user-image { width: 280rpx; height: 280rpx; border-radius: 12rpx; display: block; }
|
||||
position: relative;
|
||||
margin-left: 8rpx;
|
||||
.user-image { border-radius: 12rpx; display: block; }
|
||||
.upload-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -4665,7 +5186,8 @@ _handleStreamError(msg) {
|
||||
|
||||
/* PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 用户文件气泡
|
||||
复用 AI 侧 .file-item 样式 (line 2943), 仅包一层 user-file-bubble 容器 */
|
||||
.user-file-bubble {
|
||||
.user-file-bubble,
|
||||
.user-audio-bubble {
|
||||
padding: 12rpx 16rpx !important;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
@@ -4680,7 +5202,8 @@ _handleStreamError(msg) {
|
||||
height: 56rpx;
|
||||
margin-right: 12rpx;
|
||||
flex-shrink: 0;
|
||||
.iconfont { font-size: 48rpx; color: #6b7c93; }
|
||||
.iconfont,
|
||||
.icondiy { font-size: 48rpx; color: #6b7c93; }
|
||||
}
|
||||
.file-info {
|
||||
flex: 1;
|
||||
@@ -4714,7 +5237,32 @@ _handleStreamError(msg) {
|
||||
}
|
||||
}
|
||||
|
||||
/* 图片消息配文气泡 (图文一起发出时, 显示在图片下方) */
|
||||
/* 用户视频气泡 (视频上传): 内嵌播放器, 与图片气泡一样紧凑 */
|
||||
.user-video-bubble {
|
||||
padding: 6rpx !important;
|
||||
overflow: hidden;
|
||||
.video-item {
|
||||
position: relative;
|
||||
}
|
||||
.user-video {
|
||||
width: 420rpx;
|
||||
height: 300rpx;
|
||||
border-radius: 12rpx;
|
||||
display: block;
|
||||
}
|
||||
.upload-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
.upload-tip { color: #fff; font-size: 24rpx; }
|
||||
&.upload-error .upload-tip { color: #ffb3b3; }
|
||||
}
|
||||
}
|
||||
|
||||
/* 图片消息配文气泡 (图文一起发出时, 独立气泡显示在图片下方) */
|
||||
.image-caption-bubble {
|
||||
margin-top: 10rpx;
|
||||
background: #f5f5f5;
|
||||
@@ -4751,6 +5299,34 @@ _handleStreamError(msg) {
|
||||
margin-right: 10rpx;
|
||||
}
|
||||
.pending-image { width: 100%; height: 100%; display: block; }
|
||||
/* PR-FRONTEND-MEDIA-PENDING-FILE: 非图片附件(文件/视频/音频)缩略占位 */
|
||||
.pending-file-thumb {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #eef2f7;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6rpx 8rpx;
|
||||
box-sizing: border-box;
|
||||
.pending-file-icon { font-size: 44rpx; line-height: 1; }
|
||||
.pending-file-name {
|
||||
margin-top: 6rpx;
|
||||
max-width: 100%;
|
||||
font-size: 18rpx;
|
||||
color: #55657a;
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
}
|
||||
/* 用户消息里的文件附件卡片 */
|
||||
.user-attachments { margin-top: 8rpx; }
|
||||
.pending-mask {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
|
||||
@@ -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