Compare commits
149 Commits
custom/351
...
dev/1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| c9a4673203 | |||
| bdd0935fab | |||
| 9d3cd2b65e | |||
| 22b33c45ad | |||
| a2bf8d6c25 | |||
| 8635f9795f | |||
| 166450f6ce | |||
| b4509e3092 | |||
| 8fa16d9b14 | |||
| 9c9d1a412f | |||
| 1f87b23e7b | |||
| 421dad516c | |||
| ab8dff4b93 | |||
| 8999dd3f25 | |||
| 91ce0b582b | |||
| 7daa3afdc2 | |||
| 21b3247547 | |||
| 7909cf2b22 | |||
| 33cd21cf78 | |||
| 336b4d21da | |||
| 77b1a3372c | |||
| 373db1fb73 | |||
| d4457e58d4 | |||
| 550ec72bb7 | |||
| 1276a44b6b | |||
| 9086f1665c | |||
| a9f3c7acc2 | |||
| 53b5b725a5 | |||
| fe3f059f58 | |||
| add384916c | |||
| f2997f5401 | |||
| 4439df7c9b | |||
| 5290271017 | |||
| d358bb55ba | |||
| 2f750dbb99 | |||
| 591ce742a9 | |||
| f41057a9ad | |||
| 5a009130e6 | |||
| e8fc1aca06 | |||
| e60b3e2e52 | |||
| 022a8bb5bb | |||
| 6309a5bb4b | |||
| 67af165c14 | |||
| 36dfbfed67 | |||
| e56b91970c | |||
| 4650d9ea30 | |||
| 52f50eb91e | |||
| b3f6ff7517 | |||
| d0d17fd916 | |||
| 9b540ecfad | |||
| 389d87b82c | |||
| c9a58a5ddf | |||
| 53810114ed | |||
| f2619ae3b6 | |||
| 15b0d2a18a | |||
| 1f44f11248 | |||
| 6e3818dff6 | |||
| f22892968f | |||
| 8c5ed50f7a | |||
| 8f82d97c95 | |||
| 10ed9792a4 | |||
| fc407935d2 | |||
| b92f474ac8 | |||
| 0859765efb | |||
| 8cf70b617d | |||
| df5be6045b | |||
| 571452cd30 | |||
| 053cb00826 | |||
| 9e6dc8665f | |||
| a957679957 | |||
| b49fa378e7 | |||
| 1c0444b1ec | |||
| 09909f0dcf | |||
| b14032149e | |||
| 447f7cd7b5 | |||
| a5a94e31cc | |||
| dd15ff8ea4 | |||
| 3ab78f5369 | |||
| e223e58425 | |||
| 5ef5f275f0 | |||
| 236e56f4e9 | |||
| d170e53082 | |||
| e7dd6aff62 | |||
| 8488aa39bc | |||
| 817d2081d7 | |||
| 0a0ce88d82 | |||
| 34c0df5320 | |||
| c36d8dbce6 | |||
| 356a6d2bbf | |||
| 3b354992e3 | |||
| fe0381a298 | |||
| 733c33a41c | |||
| e8ff0e9be4 | |||
| 0faecf3603 | |||
| e9c8bac60b | |||
| a72e1b733d | |||
| 132ecbb127 | |||
| 5173b5d38e | |||
| 4172da6201 | |||
| 53be343159 | |||
| 1de5f925ee | |||
| 95b5484629 | |||
| 454bc3b522 | |||
| 1223ec9202 | |||
| 4f75347103 | |||
| 6e7dbdde94 | |||
| 04b5ed06a3 | |||
| 0507d600a0 | |||
| cf902e0440 | |||
| f555b8a946 | |||
| ce705d9d61 | |||
| 5f4dc370f8 | |||
| a978858254 | |||
| 10e6402ad6 | |||
| bf5e32df2b | |||
| 9b0a2ce9b6 | |||
| 954e8cad0b | |||
| 166a284359 | |||
| 5efae5c38b | |||
| 4168c24fd6 | |||
| 762401777b | |||
| 24c4e40df9 | |||
| c58734fcda | |||
| bbc9fdd6ea | |||
| 4cd21ade51 | |||
| 0799e47233 | |||
| 306f2f7ff7 | |||
| fff10a2e19 | |||
| 9d990e6aac | |||
| 74468e2c9d | |||
| 0ee97f902d | |||
| 3b996485e8 | |||
| d18df73849 | |||
| bac5583ecb | |||
| 942d85de19 | |||
| ae5921aa90 | |||
| c104f6c443 | |||
| 747a15f16e | |||
| b93752d58c | |||
| 619e1dc9ef | |||
| 500328ef55 | |||
| 6fa0603783 | |||
| 34c712db32 | |||
| b038175f07 | |||
| f18577a46c | |||
| 9915fb9bb1 | |||
| bb34e32f87 | |||
| 45e997adae | |||
| 2e4f11d108 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -5,8 +5,9 @@
|
|||||||
/iconfont-preview.html
|
/iconfont-preview.html
|
||||||
|
|
||||||
.codebuddy
|
.codebuddy
|
||||||
|
.workbuddy
|
||||||
.vscode
|
.vscode
|
||||||
.trae
|
.trae*
|
||||||
# 本地调试配置 (含 uniacid / 域名, 每人不同, 不应入仓)
|
# 本地调试配置 (含 uniacid / 域名, 每人不同, 不应入仓)
|
||||||
# 注意: 如果 .local.config.js 已经被之前的 commit 入仓,
|
# 注意: 如果 .local.config.js 已经被之前的 commit 入仓,
|
||||||
# 需要手动 `git rm --cached .local.config.js` 并用 filter-branch
|
# 需要手动 `git rm --cached .local.config.js` 并用 filter-branch
|
||||||
|
|||||||
@@ -120,6 +120,10 @@ const localDevConfig = ({
|
|||||||
uniacid: 237,
|
uniacid: 237,
|
||||||
domain: 'https://5g.aigc-quickapp.com/',
|
domain: 'https://5g.aigc-quickapp.com/',
|
||||||
},
|
},
|
||||||
|
'5g-248': { //中瑞 工业称重系统垃圾吊电子秤,首页,图片链接部分的PDF文档打不开
|
||||||
|
uniacid: 248,
|
||||||
|
domain: 'https://5g.aigc-quickapp.com/',
|
||||||
|
},
|
||||||
'2847': { // 家居装饰品
|
'2847': { // 家居装饰品
|
||||||
uniacid: 2847,
|
uniacid: 2847,
|
||||||
domain: 'https://xcx.aigc-quickapp.com/',
|
domain: 'https://xcx.aigc-quickapp.com/',
|
||||||
@@ -152,10 +156,6 @@ const localDevConfig = ({
|
|||||||
uniacid: 12,
|
uniacid: 12,
|
||||||
domain: 'https://test.aigc-quickapp.com',
|
domain: 'https://test.aigc-quickapp.com',
|
||||||
},
|
},
|
||||||
'248': { // 工业称重系统垃圾吊电子秤,支持自定义字体大小、颜色
|
|
||||||
uniacid: 248,
|
|
||||||
domain: 'https://5g.aigc-quickapp.com/',
|
|
||||||
},
|
|
||||||
'228': { // 反渗透阻垢剂RO装置 商品分类,没有立即咨询文字
|
'228': { // 反渗透阻垢剂RO装置 商品分类,没有立即咨询文字
|
||||||
uniacid: 228,
|
uniacid: 228,
|
||||||
domain: 'https://5g.aigc-quickapp.com/',
|
domain: 'https://5g.aigc-quickapp.com/',
|
||||||
@@ -222,6 +222,11 @@ const localDevConfig = ({
|
|||||||
uniacid: 312,
|
uniacid: 312,
|
||||||
domain: 'https://5g.aigc-quickapp.com/',
|
domain: 'https://5g.aigc-quickapp.com/',
|
||||||
},
|
},
|
||||||
|
'192.168.8.113-1': { // 测试平台
|
||||||
|
uniacid: 1,
|
||||||
|
domain: 'http://192.168.8.113:8050/',
|
||||||
|
// domain: 'http://localhost:8050/',
|
||||||
|
},
|
||||||
// })['5g-237']; // 商品分类,没有立即咨询文字
|
// })['5g-237']; // 商品分类,没有立即咨询文字
|
||||||
// })['5g-97']; // 大宗商品贸易,支持自定义字体大小、颜色
|
// })['5g-97']; // 大宗商品贸易,支持自定义字体大小、颜色
|
||||||
// })['5g-50']; // 燃气安全产品机器人,购物车无反应
|
// })['5g-50']; // 燃气安全产品机器人,购物车无反应
|
||||||
@@ -244,7 +249,9 @@ const localDevConfig = ({
|
|||||||
// })['2811']; // 本地
|
// })['2811']; // 本地
|
||||||
// })['5g-317']; // 电磁兼容
|
// })['5g-317']; // 电磁兼容
|
||||||
// })['5g-351']; //
|
// })['5g-351']; //
|
||||||
})['1']; // 本地
|
})['2811']; // 本地
|
||||||
|
// })['5g-248']; // 中瑞 工业称重系统垃圾吊电子秤,首页,图片链接部分的PDF文档打不开
|
||||||
|
// })['2881']; // 本地
|
||||||
// })['local-2']; // 本地
|
// })['local-2']; // 本地
|
||||||
// })['local-11']; // 本地
|
// })['local-11']; // 本地
|
||||||
// })['1-test']; // test
|
// })['1-test']; // test
|
||||||
|
|||||||
16
App.vue
16
App.vue
@@ -8,6 +8,12 @@
|
|||||||
// PR-MULTI-TURN-KEY-BINDING (2026-09-18): AI 客服会话 key 切换
|
// PR-MULTI-TURN-KEY-BINDING (2026-09-18): AI 客服会话 key 切换
|
||||||
// 登录态恢复后通知 aiService 清旧 user key,避免串会话
|
// 登录态恢复后通知 aiService 清旧 user key,避免串会话
|
||||||
import aiService from 'common/js/ai-service.js';
|
import aiService from 'common/js/ai-service.js';
|
||||||
|
import { markNeedStartupAd } from 'common/js/startup-ad.js';
|
||||||
|
|
||||||
|
// 记录 App 是否已至少展示过一次: 首次 onShow = 启动展示(由 onLaunch 完成初始化),
|
||||||
|
// 之后每一次 onShow(含微信 ... 菜单"重新进入小程序"、从后台切回)都视为"重新进入"。
|
||||||
|
// 微信"重新进入小程序"不一定触发 onHide,所以用"是否展示过"判断更稳妥。
|
||||||
|
let _appHasShown = false;
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Weixin
|
Weixin
|
||||||
@@ -502,6 +508,16 @@
|
|||||||
},
|
},
|
||||||
onShow: function(options) {
|
onShow: function(options) {
|
||||||
// #ifdef MP
|
// #ifdef MP
|
||||||
|
// 微信小程序"重新进入小程序"(...) 等场景: onLaunch 不重跑,但模块级内存缓存仍在,
|
||||||
|
// 会让 cachedStorage / 语言包缓存继续返回进入前的旧值。首次 onShow 是启动展示,
|
||||||
|
// 之后每次 onShow(含 ... 重新进入、从后台切回前台)都视为重新进入,主动失效缓存并重新对齐。
|
||||||
|
if (_appHasShown) {
|
||||||
|
try { Lang.onAppReentry(); } catch (e) {}
|
||||||
|
// 重新进入小程序(从后台切回 / "重新进入小程序"菜单): 标记需要弹出启动广告
|
||||||
|
try { markNeedStartupAd(); } catch (e) {}
|
||||||
|
}
|
||||||
|
_appHasShown = true;
|
||||||
|
|
||||||
// 自动授权登录
|
// 自动授权登录
|
||||||
this.getAuthInfo();
|
this.getAuthInfo();
|
||||||
if (this.$store.state.token) {
|
if (this.$store.state.token) {
|
||||||
|
|||||||
@@ -523,6 +523,9 @@ export default {
|
|||||||
user: getUserId(),
|
user: getUserId(),
|
||||||
conversation_id: this.getConversationId() || '',
|
conversation_id: this.getConversationId() || '',
|
||||||
attachments: (options && options.attachments) || [],
|
attachments: (options && options.attachments) || [],
|
||||||
|
// PR-BASIC-CONTEXT-TO-WB (2026-09-28): 透传位置/时间/页面上下文
|
||||||
|
// 给 WbAgentOsClient 提供 timestamp/timezone/location/page/metadata 等基础元素
|
||||||
|
context: (options && options.context) || null,
|
||||||
...getDomainParams()
|
...getDomainParams()
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
@@ -588,6 +591,8 @@ export default {
|
|||||||
user_id: getUserId(),
|
user_id: getUserId(),
|
||||||
conversation_id: this.getConversationId() || '',
|
conversation_id: this.getConversationId() || '',
|
||||||
attachments: opts.attachments || [],
|
attachments: opts.attachments || [],
|
||||||
|
// PR-BASIC-CONTEXT-TO-WB (2026-09-28): 透传位置/时间/页面上下文
|
||||||
|
context: (opts && opts.context) || null,
|
||||||
...getDomainParams()
|
...getDomainParams()
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -608,7 +613,11 @@ export default {
|
|||||||
*/
|
*/
|
||||||
async _fallbackToHttp(message, onEvent, onComplete, opts = {}) {
|
async _fallbackToHttp(message, onEvent, onComplete, opts = {}) {
|
||||||
try {
|
try {
|
||||||
const result = await this.sendMessage(message, { attachments: (opts && opts.attachments) || [] });
|
const result = await this.sendMessage(message, {
|
||||||
|
attachments: (opts && opts.attachments) || [],
|
||||||
|
// PR-BASIC-CONTEXT-TO-WB (2026-09-28): MP HTTP 降级路径同步传递 context
|
||||||
|
context: (opts && opts.context) || null
|
||||||
|
});
|
||||||
const content = result && result.content ? result.content : '';
|
const content = result && result.content ? result.content : '';
|
||||||
// 阻塞式降级也尝试把后端返回的思考过程/用量透出来, 让小程序在 WS 不可用时仍能显示"思考过程 (N 步)"
|
// 阻塞式降级也尝试把后端返回的思考过程/用量透出来, 让小程序在 WS 不可用时仍能显示"思考过程 (N 步)"
|
||||||
const fallbackPayload = {
|
const fallbackPayload = {
|
||||||
@@ -666,6 +675,8 @@ export default {
|
|||||||
user_id: getUserId(),
|
user_id: getUserId(),
|
||||||
conversation_id: this.getConversationId() || '',
|
conversation_id: this.getConversationId() || '',
|
||||||
attachments: (opts && opts.attachments) || [],
|
attachments: (opts && opts.attachments) || [],
|
||||||
|
// PR-BASIC-CONTEXT-TO-WB (2026-09-28): 透传位置/时间/页面上下文
|
||||||
|
context: (opts && opts.context) || null,
|
||||||
...getDomainParams()
|
...getDomainParams()
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
567
common/js/card-api.js
Normal file
567
common/js/card-api.js
Normal file
@@ -0,0 +1,567 @@
|
|||||||
|
/**
|
||||||
|
* 名片云(card addon)API 收口层
|
||||||
|
*
|
||||||
|
* 单一事实来源:后端《名片云(card addon)API 对接文档》CARD_API_REFERENCE.md(2026-10-13)
|
||||||
|
* 文档与源码如有出入,以源码为准;URL 路径一个字符都不能变,只做收口不做改名。
|
||||||
|
*
|
||||||
|
* 收口要求(来自后端 checklist):
|
||||||
|
* 1. 所有 /card/api/* URL 必须集中在本层,页面不得再直连字面量;
|
||||||
|
* 2. 统一走项目请求工具(自动带 token / uniacid),任何接口不传 site_id;
|
||||||
|
* 3. 失败分支统一 toast(message);「不存在」类错误按"内容不存在或已删除"提示;
|
||||||
|
* 4. card/update 只传编辑过的键(键存在才写、空串=清空、键缺席=不擦除);
|
||||||
|
* 5. 详情/列表渲染富化字段用 *_list 解码键,不解析原始 JSON 串;
|
||||||
|
* 6. 编辑页对 locked_fields 命中的字段渲染只读;
|
||||||
|
* 7. recognize 成功后检查 deduped,为 true 时提示"已存在同手机号名片";
|
||||||
|
* 8. 分页参数 page_size 不超过 100;
|
||||||
|
* 9. 名片路由参数用 card_id(字符串),人脉/线索用 id(数字),不要混用。
|
||||||
|
*
|
||||||
|
* 用法(页面内 this.$cardApi):
|
||||||
|
* this.$cardApi.cardLists({ data: { page: 1 }, success: res => {}, fail: () => {} })
|
||||||
|
*
|
||||||
|
* 注意:本层只做"URL 收口 + 通道选择",不做业务加工;
|
||||||
|
* 业务字典(状态/来源/字段白名单)随端口导出,供各页面复用。
|
||||||
|
*/
|
||||||
|
import Http from './http.js';
|
||||||
|
|
||||||
|
/** 所有端点路径(与后端文档逐一比对,改动需两边同步) */
|
||||||
|
export const CARD_API_URL = {
|
||||||
|
// === 名片夹 /card/api/card/* ===
|
||||||
|
CARD_LISTS: '/card/api/card/lists',
|
||||||
|
CARD_DETAIL: '/card/api/card/detail',
|
||||||
|
CARD_UPDATE: '/card/api/card/update',
|
||||||
|
CARD_FAVORITE: '/card/api/card/toggleFavorite', // 收藏(双向 toggle, 后端 2.9.12)
|
||||||
|
CARD_CREATE: '/card/api/card/create', // 新建数字名片(后端源码已有, 文档未列; 以源码为准)
|
||||||
|
CARD_CLAIM: '/card/api/card/claim',
|
||||||
|
CARD_RECOGNIZE: '/card/api/card/recognize',
|
||||||
|
CARD_RETRY: '/card/api/card/retry',
|
||||||
|
CARD_RECOGNIZE_BATCH: '/card/api/card/recognizeBatch',
|
||||||
|
CARD_DELETE: '/card/api/card/delete', // 删除自己的名片(软删, 2.11.91)
|
||||||
|
|
||||||
|
// === 跟进完成态 2.11.3 /card/api/card/* ===
|
||||||
|
CARD_FOLLOW_DONE: '/card/api/card/followDone',
|
||||||
|
// === 访客轨迹 2.11.4 /card/api/card/* ===
|
||||||
|
CARD_VIEWS: '/card/api/card/views',
|
||||||
|
|
||||||
|
// === 员工建卡申请 2.11.15 /card/api/apply/* ===
|
||||||
|
APPLY_SUBMIT: '/card/api/apply/submit',
|
||||||
|
APPLY_STATUS: '/card/api/apply/status',
|
||||||
|
|
||||||
|
// === 人脉库 /card/api/contact/* ===
|
||||||
|
CONTACT_LISTS: '/card/api/contact/lists',
|
||||||
|
CONTACT_ADD: '/card/api/contact/add',
|
||||||
|
CONTACT_INFO: '/card/api/contact/info',
|
||||||
|
CONTACT_UPDATE: '/card/api/contact/update',
|
||||||
|
CONTACT_DELETE: '/card/api/contact/delete',
|
||||||
|
CONTACT_TO_LEAD: '/card/api/contact/toLead',
|
||||||
|
|
||||||
|
// === 潜在客户 /card/api/lead/* ===
|
||||||
|
LEAD_LISTS: '/card/api/lead/lists',
|
||||||
|
LEAD_SET_STATUS: '/card/api/lead/setStatus',
|
||||||
|
|
||||||
|
// === 交换名片 /card/api/exchange/* ===
|
||||||
|
EXCHANGE_LISTS: '/card/api/exchange/lists',
|
||||||
|
EXCHANGE_AGREE: '/card/api/exchange/agree',
|
||||||
|
EXCHANGE_IGNORE: '/card/api/exchange/ignore',
|
||||||
|
|
||||||
|
// === 名片设计(员工端可选样式, 后端 v2) /card/api/design/* ===
|
||||||
|
// 预设列表必须与商家后台 CardTemplatePresets 同源(避免前端自建漂移)
|
||||||
|
// ⚠ v2 路径无 /card/ 段: 初版 /card/api/card/design/* 在本框架 action 单段路由下 404
|
||||||
|
CARD_DESIGN_PRESETS: '/card/api/design/presets', // 预设列表(同源后台 CardTemplatePresets)
|
||||||
|
CARD_DESIGN_SAVE: '/card/api/design/save', // 应用预设/保存本卡样式
|
||||||
|
|
||||||
|
// === 企业名片 /card/api/enterprise/* (2.13.6) ===
|
||||||
|
// 业务模型: 企业卡片 owner_id=0(店铺级), 同一时刻仅 1 张 is_active=1 生效;
|
||||||
|
// 名片区三模式 header_mode: official(指定/默认官方企业卡) / self(员工自选个人卡) / staff(员工轮播)
|
||||||
|
ENTERPRISE_DETAIL: '/card/api/enterprise/detail', // GET ?card_id= 不传=当前生效卡
|
||||||
|
ENTERPRISE_SAVE: '/card/api/enterprise/save', // POST {card_id?,fields?,design?,header_mode?,official_card_id?}
|
||||||
|
ENTERPRISE_MINE: '/card/api/enterprise/mine', // GET 自己名下企业卡列表
|
||||||
|
ENTERPRISE_ACTIVATE: '/card/api/enterprise/activate', // POST {card_id} 切换生效(互斥)
|
||||||
|
ENTERPRISE_CHOOSE_SELF: '/card/api/enterprise/chooseSelf', // POST {card_id} 员工自选展示个人卡
|
||||||
|
ENTERPRISE_DELETE: '/card/api/enterprise/delete' // POST {card_id} 删候选卡(生效卡禁删)
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 社交媒体类型白名单:必须与后端 CardRichFields::SOCIAL_TYPES 保持一致(增减需两边同步)。
|
||||||
|
* type 非法或 value 为空的条目会被后端静默丢弃。
|
||||||
|
* ⚠️ 2026-10-13:telegram / messenger 由独立标量字段软下线,统一并入本数组;
|
||||||
|
* 旧数据后端读取时自动合并进 socials_list(见 CARD_API_REFERENCE.md 3.3)。
|
||||||
|
*/
|
||||||
|
export const SOCIAL_TYPES = [
|
||||||
|
{ key: 'alipay', label: '支付宝' },
|
||||||
|
{ key: 'douyin', label: '抖音' },
|
||||||
|
{ key: 'wxvideo', label: '视频号' },
|
||||||
|
{ key: 'xiaohongshu', label: '小红书' },
|
||||||
|
{ key: 'weibo', label: '微博' },
|
||||||
|
{ key: 'kuaishou', label: '快手' },
|
||||||
|
{ key: 'bilibili', label: '哔哩哔哩' },
|
||||||
|
{ key: 'qq', label: 'QQ' },
|
||||||
|
{ key: 'linkedin', label: 'LinkedIn' },
|
||||||
|
{ key: 'facebook', label: 'Facebook' },
|
||||||
|
{ key: 'instagram', label: 'Instagram' },
|
||||||
|
{ key: 'x', label: 'X' },
|
||||||
|
{ key: 'telegram', label: 'Telegram' },
|
||||||
|
{ key: 'messenger', label: 'Messenger' },
|
||||||
|
{ key: 'other', label: '其他' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 富化字段上限:与后端 CardRichFields::MAX_* 一致 */
|
||||||
|
export const RICH_FIELD_LIMITS = {
|
||||||
|
MAX_MOBILES: 5,
|
||||||
|
MAX_SOCIALS: 10,
|
||||||
|
MAX_CUSTOM: 10
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 线索跟进状态:与后端 Lead::STATUSES 一致(0-3,其它值报「参数不合法」) */
|
||||||
|
export const LEAD_STATUS = [
|
||||||
|
{ value: 0, label: '新客户' },
|
||||||
|
{ value: 1, label: '已联系' },
|
||||||
|
{ value: 2, label: '已成交' },
|
||||||
|
{ value: 3, label: '无效' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 人脉来源:与后端 Contact::SOURCES 一致(scan/exchange 暂无产生入口) */
|
||||||
|
export const CONTACT_SOURCES = [
|
||||||
|
{ value: 'manual', label: '手工录入' },
|
||||||
|
{ value: 'lead_in', label: '潜在客户转入' },
|
||||||
|
{ value: 'scan', label: '扫描' },
|
||||||
|
{ value: 'exchange', label: '交换' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 名片来源:与后端 relation.source_type 一致 */
|
||||||
|
export const CARD_SOURCE_TYPES = [
|
||||||
|
{ value: 'manual', label: '手动创建' },
|
||||||
|
{ value: 'manual_batch', label: '批量制作' },
|
||||||
|
{ value: 'scan', label: '扫描识别' },
|
||||||
|
{ value: 'import_csv', label: 'CSV 导入' },
|
||||||
|
{ value: 'import_xlsx', label: 'Excel 导入' },
|
||||||
|
{ value: 'clone', label: '克隆' },
|
||||||
|
{ value: 'api', label: '接口创建' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 名片编辑标量字段:与后端 BusinessCardFieldNormalizer 输出对齐。
|
||||||
|
* ⚠️ 2026-10-13:telegram / messenger 已软下线为独立标量(传了也被忽略),
|
||||||
|
* 统一走 fields.socials 数组按类型添加;读取时旧值自动合并进 socials_list。
|
||||||
|
*/
|
||||||
|
export const CARD_FIELDS = [
|
||||||
|
'name', 'company', 'job_title', 'avatar',
|
||||||
|
'mobile', 'phone', 'email', 'website', 'wechat',
|
||||||
|
'country', 'province', 'city', 'address', 'postal_code',
|
||||||
|
'bio'
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 人脉可更新白名单:与后端 Contact::EDITABLE_FIELDS 一致 */
|
||||||
|
export const CONTACT_FIELDS = [
|
||||||
|
'name', 'mobile', 'company', 'job_title', 'wechat', 'email', 'website', 'address', 'remark', 'status'
|
||||||
|
];
|
||||||
|
|
||||||
|
const CardApi = {
|
||||||
|
url: CARD_API_URL,
|
||||||
|
|
||||||
|
/* ================= 名片夹 ================= */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 我的名片列表
|
||||||
|
* @param {Object} p { page, page_size(<=100), keyword, success, fail }
|
||||||
|
* 返回 data: { list, count, page_count }
|
||||||
|
*/
|
||||||
|
cardLists(p) {
|
||||||
|
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_LISTS }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 卡片详情
|
||||||
|
* @param {Object} p { card_id, success, fail }
|
||||||
|
* 返回 data: { relation, source, share_list }
|
||||||
|
*/
|
||||||
|
cardDetail(p) {
|
||||||
|
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_DETAIL }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新卡片(normalize 语义:键存在才写;空串=清空;键缺席=不擦除)
|
||||||
|
* 局部更新时只传用户实际编辑过的键。走 JSON body(fields 为嵌套对象)。
|
||||||
|
* @param {Object} p { card_id, fields, success, fail }
|
||||||
|
*/
|
||||||
|
cardUpdate(p) {
|
||||||
|
Http.sendJson(Object.assign({ url: CARD_API_URL.CARD_UPDATE }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新建数字名片(商家端批量制作/单张新建入口)
|
||||||
|
* 入参:`fields`(与 card/update 同结构,`name` 必填)+ 可选 `owner_id`(指定归属员工,0=未归属待认领)
|
||||||
|
* 返回 `data`:`{ card_id, ...relation }`(前端用 data.card_id 跳详情)
|
||||||
|
* ⚠️ 该端点后端源码已实现但旧文档未列出,按「文档与源码出入以源码为准」收口。
|
||||||
|
*/
|
||||||
|
cardCreate(p) {
|
||||||
|
Http.sendJson(Object.assign({ url: CARD_API_URL.CARD_CREATE }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认领名片(商家后台建的卡未归属 owner_id=0,员工认领到自己名下)
|
||||||
|
* 入参(二选一, claim_code 优先): { data: { claim_code } } 或 { data: { card_id } }
|
||||||
|
* 成功 data.claimed: true=新认领, false=早已是自己的(幂等)
|
||||||
|
* 文档: card_claim_api.md(POST /card/api/card/claim,token 自动带)
|
||||||
|
* @param {Object} p { data: { claim_code | card_id }, success, fail }
|
||||||
|
*/
|
||||||
|
cardClaim(p) {
|
||||||
|
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_CLAIM }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换收藏(双向 toggle,幂等可逆:未收藏→置1,已收藏→置0)
|
||||||
|
* 直接用返回值 data.is_favorite 回填 UI,前端不自行判断状态。
|
||||||
|
* @param {Object} p { data: { card_id }, success, fail }
|
||||||
|
* 返回 data: { card_id, is_favorite: 0|1 }
|
||||||
|
* 拒绝(message 直接 toast): 卡不存在 / 非自己名下卡(统一「名片不存在」) / 参数不合法 / 未登录
|
||||||
|
*/
|
||||||
|
cardToggleFavorite(p) {
|
||||||
|
Http.sendJson(Object.assign({ url: CARD_API_URL.CARD_FAVORITE }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除自己的名片(软删, 2.11.91)
|
||||||
|
* 文档: CARD_DELETE_API.md(POST /card/api/card/delete,token 自动带,不传 site_id)
|
||||||
|
* 通道: 与 cardClaim/cardFollowDone 同款 sendRequest(表单编码);只需 card_id 一个字段
|
||||||
|
* 交互: 调用前页面必须已做二次确认;成功后 toast「已删除」并刷新列表(接口不再返回该卡)
|
||||||
|
* 口径: 「卡片不存在」是故意模糊(他人/已删/不存在同一文案),前端统一按 message toast,不特殊处理
|
||||||
|
* @param {Object} p { data: { card_id }, success, fail }
|
||||||
|
*/
|
||||||
|
cardDelete(p) {
|
||||||
|
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_DELETE }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 识别建卡
|
||||||
|
* @param {Object} p { media_path, media_type?, source_type?, success, fail }
|
||||||
|
* 返回 data: { source_id, relation_id, card_id, deduped, fields }
|
||||||
|
* deduped=true 表示站点内已有同手机号名片,未新建,前端提示"已存在同名片"
|
||||||
|
*/
|
||||||
|
cardRecognize(p) {
|
||||||
|
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_RECOGNIZE }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 识别失败重试(仅本站 + status=failed 的来源可重试,有次数上限)
|
||||||
|
* @param {Object} p { source_id, success, fail }
|
||||||
|
*/
|
||||||
|
cardRetry(p) {
|
||||||
|
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_RETRY }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量识别(1~10 条,逐项独立成败)
|
||||||
|
* @param {Object} p { items: [{ source_type?, media_type?, media_path, media_key? }], success, fail }
|
||||||
|
* 返回 data: { items: [{ ok, message, data }], ok, failed }
|
||||||
|
*/
|
||||||
|
cardRecognizeBatch(p) {
|
||||||
|
Http.sendJson(Object.assign({ url: CARD_API_URL.CARD_RECOGNIZE_BATCH }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记跟进完成(2.11.3 跟进闭环)
|
||||||
|
* 行级归属: 只能标记自己名下的名片(与收藏/认领同一套口径)
|
||||||
|
* 成功 data: { card_id, follow_up_done: 1 }
|
||||||
|
* 失败 message 直接 toast: 卡片不存在 / 只能标记自己名下的名片
|
||||||
|
* @param {Object} p { data: { card_id }, success, fail }
|
||||||
|
*/
|
||||||
|
cardFollowDone(p) {
|
||||||
|
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_FOLLOW_DONE }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 访客轨迹(2.11.4 谁看了我)
|
||||||
|
* 入参(全部可选): { data: { card_id?, page?, page_size?(<=50, 建议20) } }
|
||||||
|
* 不传 card_id = 当前员工名下全部名片; 成功 data: { list, total, page, page_size,
|
||||||
|
* summary: { today_pv, today_uv, total_pv, total_uv } }
|
||||||
|
* @param {Object} p { data: {...}, success, fail }
|
||||||
|
*/
|
||||||
|
cardViews(p) {
|
||||||
|
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_VIEWS }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ================= 人脉库 ================= */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 人脉列表
|
||||||
|
* @param {Object} p { page, page_size(<=100), keyword, source?, status?, success, fail }
|
||||||
|
* 返回 data: { list, count, page_count }
|
||||||
|
*/
|
||||||
|
contactLists(p) {
|
||||||
|
Http.sendRequest(Object.assign({ url: CARD_API_URL.CONTACT_LISTS }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增人脉
|
||||||
|
* @param {Object} p { fields: { name(必填), company?, ... }, success, fail }
|
||||||
|
*/
|
||||||
|
contactAdd(p) {
|
||||||
|
Http.sendJson(Object.assign({ url: CARD_API_URL.CONTACT_ADD }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 人脉详情(编辑页回填;id 为数字自增主键,注意与名片 card_id 字符串不同)
|
||||||
|
* @param {Object} p { id, success, fail }
|
||||||
|
*/
|
||||||
|
contactInfo(p) {
|
||||||
|
Http.sendRequest(Object.assign({ url: CARD_API_URL.CONTACT_INFO }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新人脉(兼容 fields 对象或顶层平铺字段)
|
||||||
|
* @param {Object} p { id, fields?, ...平铺字段, success, fail }
|
||||||
|
*/
|
||||||
|
contactUpdate(p) {
|
||||||
|
Http.sendJson(Object.assign({ url: CARD_API_URL.CONTACT_UPDATE }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除人脉(真删不可恢复,前端需二次确认弹窗)
|
||||||
|
* @param {Object} p { id, success, fail }
|
||||||
|
*/
|
||||||
|
contactDelete(p) {
|
||||||
|
Http.sendRequest(Object.assign({ url: CARD_API_URL.CONTACT_DELETE }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 人脉 → 潜在客户(不删人脉行;同员工同手机号已转过则报错)
|
||||||
|
* @param {Object} p { id, success, fail }
|
||||||
|
*/
|
||||||
|
contactToLead(p) {
|
||||||
|
Http.sendRequest(Object.assign({ url: CARD_API_URL.CONTACT_TO_LEAD }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ================= 潜在客户 ================= */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 线索列表(自己名下名片收到的线索 + 自己从人脉转入的线索)
|
||||||
|
* @param {Object} p { page, page_size(<=100), status?(0-3), keyword, success, fail }
|
||||||
|
* 返回 data: { list, count, page_count }
|
||||||
|
*/
|
||||||
|
leadLists(p) {
|
||||||
|
Http.sendRequest(Object.assign({ url: CARD_API_URL.LEAD_LISTS }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新跟进状态(status 只能是 0/1/2/3)
|
||||||
|
* @param {Object} p { id, status, success, fail }
|
||||||
|
* 返回 data: { id, status }
|
||||||
|
*/
|
||||||
|
leadSetStatus(p) {
|
||||||
|
Http.sendRequest(Object.assign({ url: CARD_API_URL.LEAD_SET_STATUS }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ================= 交换名片 ================= */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 待处理交换请求列表(返回 status=0;已同意/已忽略员工端不可见)
|
||||||
|
* @param {Object} p { page, page_size(<=100), success, fail }
|
||||||
|
* 返回 data: { list:[{ id, from_card_id, name, avatar, job_title, company,
|
||||||
|
* create_time, create_time_txt, status }], count, page_count }
|
||||||
|
* ⚠️ 详情页红点计数技巧: lists?page=1&page_size=1 取 count 即可
|
||||||
|
* ⚠️ 后端契约要求 lists 走 GET(分页在 query); 而 Http.sendRequest 是
|
||||||
|
* 「带 data 就发 POST」的语义, 所以这里把分页拼进 url、不传 data,
|
||||||
|
* 否则会被发成 POST 导致接口不匹配、列表恒为空。
|
||||||
|
*/
|
||||||
|
exchangeLists(p) {
|
||||||
|
const opt = Object.assign({}, p || {});
|
||||||
|
const src = opt.data || opt;
|
||||||
|
const page = src.page || 1;
|
||||||
|
const pageSize = src.page_size || 20;
|
||||||
|
delete opt.data;
|
||||||
|
delete opt.page;
|
||||||
|
delete opt.page_size;
|
||||||
|
opt.url = CARD_API_URL.EXCHANGE_LISTS + '?page=' + page + '&page_size=' + pageSize;
|
||||||
|
Http.sendRequest(opt);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同意交换(后端自动双方建人脉,前端无需再调 contact/add)
|
||||||
|
* 幂等: 同侧已存在同手机号人脉时 receiver_created=0,仍是成功,不当失败
|
||||||
|
* @param {Object} p { data: { id }, success, fail }
|
||||||
|
* 返回 data: { id, status:1, receiver_created, initiator_created }
|
||||||
|
*/
|
||||||
|
exchangeAgree(p) {
|
||||||
|
Http.sendJson(Object.assign({ url: CARD_API_URL.EXCHANGE_AGREE }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 忽略交换(仅本方隐藏 status=2,对方不发通知、可再次发起)
|
||||||
|
* @param {Object} p { data: { id }, success, fail }
|
||||||
|
* 返回 data: { id, status:2 }
|
||||||
|
*/
|
||||||
|
exchangeIgnore(p) {
|
||||||
|
Http.sendJson(Object.assign({ url: CARD_API_URL.EXCHANGE_IGNORE }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同意交换后「校验 + 兜底」建人脉(页面点「同意」后必调;幂等,可安全重复调用)。
|
||||||
|
* 背景: 后端声明 CardExchange::agree 会双向互存人脉(我方人脉收对方名片 / 对方人脉收我方名片),
|
||||||
|
* 但实测出现「同意成功、对方名片却没进我的人脉」, 故前端不再只信 receiver_created,
|
||||||
|
* 统一在同意后按「对方名片编号」校验一次: 我的人脉里没有同手机号记录才补建。
|
||||||
|
* 流程: 取对方名片详情 → 按手机号查我的人脉 → 不存在则以 source=exchange 新建人脉。
|
||||||
|
* 幂等: 同手机号人脉已存在(含后端已建)时 created=0, 与后台已建不会重复。
|
||||||
|
* @param {Object} p { fromCardId, success, fail }
|
||||||
|
* 回调 done(data): { code, created, existed } created=1 新建 / 0 已存在或无需建;
|
||||||
|
* existed = 命中的同手机号人脉行(未命中为 null), 供页面提示「人脉中已有该联系人」。
|
||||||
|
*/
|
||||||
|
ensureExchangeContact(p) {
|
||||||
|
const self = this;
|
||||||
|
const done = p.success, fail = p.fail;
|
||||||
|
const finish = (d) => { typeof done === 'function' && done(d); };
|
||||||
|
const fromCardId = p.fromCardId;
|
||||||
|
if (!fromCardId) { finish({ code: 0, created: 0 }); return; }
|
||||||
|
this.cardDetail({
|
||||||
|
data: { card_id: fromCardId },
|
||||||
|
success: res => {
|
||||||
|
if (res.code != 0) { typeof fail === 'function' && fail(res); return; }
|
||||||
|
const r = (res.data && (res.data.relation || res.data)) || {};
|
||||||
|
const mobile = r.mobile || '';
|
||||||
|
const fields = {
|
||||||
|
name: r.name || '',
|
||||||
|
company: r.company || '',
|
||||||
|
job_title: r.job_title || '',
|
||||||
|
mobile: mobile,
|
||||||
|
source: 'exchange'
|
||||||
|
};
|
||||||
|
// 富化字段仅在对方有名片值时带上(空串=清空语义, 新建人脉时不传空键)
|
||||||
|
if (r.wechat) fields.wechat = r.wechat;
|
||||||
|
if (r.email) fields.email = r.email;
|
||||||
|
if (r.website) fields.website = r.website;
|
||||||
|
if (r.address) fields.address = r.address;
|
||||||
|
const add = () => self.contactAdd({
|
||||||
|
data: { source: 'exchange', fields: fields },
|
||||||
|
success: res2 => {
|
||||||
|
// code!=0(如手机号已存在被拒): 交换已同意成功, 兜底不打扰用户, 按无需新建收口
|
||||||
|
const ok = res2 && res2.code == 0;
|
||||||
|
finish({ code: ok ? 0 : ((res2 && res2.code) || 0), created: ok ? 1 : 0, raw: res2 });
|
||||||
|
},
|
||||||
|
fail: err => { typeof fail === 'function' && fail(err); }
|
||||||
|
});
|
||||||
|
// 按手机号去重: 已存在同手机号人脉则不重复建(兼容后台已建 / 重复同意)
|
||||||
|
if (mobile) {
|
||||||
|
self.contactLists({
|
||||||
|
data: { page: 1, page_size: 20, keyword: mobile },
|
||||||
|
success: res3 => {
|
||||||
|
const d3 = res3.data || {};
|
||||||
|
const list = Array.isArray(d3) ? d3 : (d3.list || []);
|
||||||
|
const hit = list.find(c => (c.mobile || '') === mobile);
|
||||||
|
// 命中: 对方已在我的人脉里(可能显示为旧姓名), 不覆盖用户维护过的数据, 只回传命中行
|
||||||
|
if (hit) { finish({ code: 0, created: 0, existed: hit }); return; }
|
||||||
|
add();
|
||||||
|
},
|
||||||
|
fail: () => add()
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
add();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: err => { typeof fail === 'function' && fail(err); }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ================= 员工建卡申请 2.11.15 ================= */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交建卡申请(空态申请 → 商家审批 → 自动建卡归属)
|
||||||
|
* 约束: 同一会员同时只允许一条待审申请(重复提交后端会拒)
|
||||||
|
* 入参: { data: { name, mobile } }
|
||||||
|
* @param {Object} p { data: { name, mobile }, success, fail }
|
||||||
|
*/
|
||||||
|
applySubmit(p) {
|
||||||
|
Http.sendRequest(Object.assign({ url: CARD_API_URL.APPLY_SUBMIT }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询我的建卡申请状态(进入名片夹/空态时调用或轮询)
|
||||||
|
* 成功 data: { status: 0 待审/1 已同意/2 已拒绝, status_text, note, has_card }
|
||||||
|
* @param {Object} p { success, fail }
|
||||||
|
*/
|
||||||
|
applyStatus(p) {
|
||||||
|
Http.sendRequest(Object.assign({ url: CARD_API_URL.APPLY_STATUS }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ================= 名片设计(员工端可选样式) ================= */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拉取名片预设列表(与商家后台 CardTemplatePresets 同一份, 同源)
|
||||||
|
* 成功 data: { list:[{ key, name, desc, thumb,
|
||||||
|
* design:{ theme_color, text_color, bg_color, layout, cover, font_family,
|
||||||
|
* font_scale, card_radius, header_gradient_end, header_gradient_angle, quick_style } }] }
|
||||||
|
* @param {Object} p { success, fail }
|
||||||
|
*/
|
||||||
|
cardDesignPresets(p) {
|
||||||
|
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_DESIGN_PRESETS }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用预设 / 保存本卡样式(员工端选择后台预设后落库)
|
||||||
|
* 入参(包在 data 里): { card_id, preset_key } —— 后端按 preset_key 取 CardTemplatePresets
|
||||||
|
* 的 design 合并进本卡 lucky_card_design(非空覆盖), 返回 { code:0 }
|
||||||
|
* @param {Object} p { data: { card_id, preset_key }, success, fail }
|
||||||
|
*/
|
||||||
|
cardDesignSave(p) {
|
||||||
|
Http.sendJson(Object.assign({ url: CARD_API_URL.CARD_DESIGN_SAVE }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ================= 企业名片 2.13.6 ================= */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 企业名片详情(核心)
|
||||||
|
* GET ?card_id= 不传返回当前生效企业名片;传候选卡 id 可预览(is_active=0)
|
||||||
|
* 成功 data: { card_id, is_active, is_owner, fields:{name,company,job_title,mobile,email,
|
||||||
|
* website,address,wechat,avatar,bio,name_en,company_en,job_title_en,bio_en},
|
||||||
|
* design:{theme_color,bg_color,text_color,template_id,canvas,header_mode,official_card_id},
|
||||||
|
* header:{mode,official_card_id,official,self,staff} }
|
||||||
|
* 渲染: 名片区按 header.mode 取 official/self/staff 数据, 内容区用 fields+design.canvas
|
||||||
|
* 失败「企业名片不存在」→ 前端空态引导去管理端创建
|
||||||
|
* @param {Object} p { data: { card_id? }, success, fail }
|
||||||
|
*/
|
||||||
|
enterpriseDetail(p) {
|
||||||
|
Http.sendRequest(Object.assign({ url: CARD_API_URL.ENTERPRISE_DETAIL }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存企业名片(后台/会员;owner_id=0 的卡会员不可改)
|
||||||
|
* @param {Object} p { data: { card_id?, fields?, design?, header_mode?, official_card_id? }, success, fail }
|
||||||
|
*/
|
||||||
|
enterpriseSave(p) {
|
||||||
|
Http.sendJson(Object.assign({ url: CARD_API_URL.ENTERPRISE_SAVE }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 我的企业名片列表(自己名下)
|
||||||
|
* 成功 data: { list: [{ card_id, name, is_active }] }
|
||||||
|
* @param {Object} p { success, fail }
|
||||||
|
*/
|
||||||
|
enterpriseMine(p) {
|
||||||
|
Http.sendRequest(Object.assign({ url: CARD_API_URL.ENTERPRISE_MINE }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换生效企业名片(互斥:全店仅 1 张 is_active=1,仅能切自己名下的卡)
|
||||||
|
* @param {Object} p { data: { card_id }, success, fail }
|
||||||
|
*/
|
||||||
|
enterpriseActivate(p) {
|
||||||
|
Http.sendJson(Object.assign({ url: CARD_API_URL.ENTERPRISE_ACTIVATE }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 员工自选展示名片(self 模式):card_id 必须是自己名下的 contact 卡
|
||||||
|
* 选择按会员存储;detail 的 header.self.chosen=1 表示已自选
|
||||||
|
* @param {Object} p { data: { card_id }, success, fail }
|
||||||
|
*/
|
||||||
|
enterpriseChooseSelf(p) {
|
||||||
|
Http.sendJson(Object.assign({ url: CARD_API_URL.ENTERPRISE_CHOOSE_SELF }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除企业名片(会员端;生效卡不可删,删除候选卡会清理官方名片引用)
|
||||||
|
* @param {Object} p { data: { card_id }, success, fail }
|
||||||
|
*/
|
||||||
|
enterpriseDelete(p) {
|
||||||
|
Http.sendJson(Object.assign({ url: CARD_API_URL.ENTERPRISE_DELETE }, p || {}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CardApi;
|
||||||
125
common/js/diy.js
125
common/js/diy.js
@@ -2,6 +2,7 @@ import WxMap from 'common/js/map-wx-jssdk.js';
|
|||||||
import Config from '@/common/js/config.js';
|
import Config from '@/common/js/config.js';
|
||||||
import util from '@/common/js/util.js';
|
import util from '@/common/js/util.js';
|
||||||
import { classifyHttpError, httpErrorKey } from '@/common/js/http-error.js';
|
import { classifyHttpError, httpErrorKey } from '@/common/js/http-error.js';
|
||||||
|
import { consumeNeedStartupAd, isColdLaunchPending } from '@/common/js/startup-ad.js';
|
||||||
|
|
||||||
let systemInfo = util.getDeviceInfo();
|
let systemInfo = util.getDeviceInfo();
|
||||||
|
|
||||||
@@ -297,25 +298,84 @@ export default {
|
|||||||
data: {},
|
data: {},
|
||||||
async: false
|
async: false
|
||||||
});
|
});
|
||||||
this.adv = res.value
|
// 接口把启动广告配置放在 data 字段(少数环境也可能放在 value), 两者兼容,
|
||||||
// 弹框形式,首次弹出 1,每次弹出 0
|
// 避免取到 undefined 后模板 v-if 抛错导致 uni-popup 不渲染、真机广告弹不出来
|
||||||
|
let advData = (res && (res.data != null ? res.data : res.value)) || {};
|
||||||
|
this.adv = advData;
|
||||||
|
// 后台配置: advshow=1 开启启动广告; advtype 决定弹出策略
|
||||||
|
// advtype == 1: 首次弹出 —— 仅第一次进入小程序弹出(持久化标记, 清缓存后可再弹)
|
||||||
|
// advtype == 0: 每次弹出 —— 每次"重新进入小程序"都弹出(不含 Tab 切换回首页)
|
||||||
if (this.adv.advshow == 1) {
|
if (this.adv.advshow == 1) {
|
||||||
setTimeout(() => {
|
setTimeout(async () => {
|
||||||
if (res.value.advtype == 1) {
|
// 隐私协议未同意时, 先等用户点击隐私协议(同意/拒绝)关闭弹窗,
|
||||||
var popwindow_count = uni.getStorageSync(this.id + this.name + '_popwindow_count');
|
// 否则广告弹窗会被隐私弹窗(z-index:5000)盖住, 真机上表现为"点了隐私协议后广告不弹"
|
||||||
if ((this.$refs.uniPopupWindow && popwindow_count == '') || (
|
await this._waitPrivacyResolved();
|
||||||
this.$refs.uniPopupWindow && popwindow_count == 1)) {
|
if (!this.$refs.uniPopupWindow) return;
|
||||||
|
|
||||||
this.$refs.uniPopupWindow.open();
|
// 先判定本次是否需要弹出, 避免无谓的图片预加载
|
||||||
uni.setStorageSync(this.id + this.name + '_popwindow_count', 1);
|
let shouldOpen = false;
|
||||||
|
let isFirstType = false;
|
||||||
|
if (this.adv.advtype == 1) {
|
||||||
|
// 首次弹出: 从未展示过才弹
|
||||||
|
var popwindow_count = uni.getStorageSync(this.id + this.name + '_popwindow_count');
|
||||||
|
if (popwindow_count == '' || popwindow_count == 0) {
|
||||||
|
shouldOpen = true;
|
||||||
|
isFirstType = true;
|
||||||
}
|
}
|
||||||
} else if (res.value.advtype == 0) {
|
} else {
|
||||||
this.$refs.uniPopupWindow.open();
|
// 每次弹出: 冷启动首次 或 真正"重新进入小程序"(App 前后台切换)时弹出,
|
||||||
uni.setStorageSync(this.id + this.name + '_popwindow_count', 0);
|
// 由 App.onShow 在重新进入时置位的信号驱动, 切 Tab 不会触发 App.onShow 故不弹
|
||||||
|
if (consumeNeedStartupAd() || isColdLaunchPending()) {
|
||||||
|
shouldOpen = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!shouldOpen) return;
|
||||||
|
|
||||||
|
// 关键: 广告图片是网络图, 而 uni-popup 用 v-if 在 open() 时才渲染 slot,
|
||||||
|
// 图片要等弹窗打开后才开始请求/解码, 真机网络慢时就会"一截一截"逐步显示。
|
||||||
|
// 先把所有广告图片预加载进本地缓存, 全部就绪后再 open, 使弹窗一次性完整出现。
|
||||||
|
await this._preloadAdvImages(this.adv.list);
|
||||||
|
if (!this.$refs.uniPopupWindow) return;
|
||||||
|
|
||||||
|
this.$refs.uniPopupWindow.open();
|
||||||
|
if (isFirstType) {
|
||||||
|
uni.setStorageSync(this.id + this.name + '_popwindow_count', 1);
|
||||||
}
|
}
|
||||||
}, 500);
|
}, 500);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 等待隐私协议流程结束后再弹启动广告
|
||||||
|
* - 鸿蒙元服务: 隐私由系统托管, 应用层不弹自建弹窗, 直接放行
|
||||||
|
* - 已同意过(storage privacyAgreed): 无需等待, 直接放行
|
||||||
|
* - 未同意: 监听 PrivacyManager 广播的 privacy:agreed / privacy:disagreed,
|
||||||
|
* 用户点击隐私协议(同意/拒绝)关闭弹窗后放行
|
||||||
|
* - 兜底超时: 隐私协议因规则未弹等情况永不 emit 时也要弹广告, 避免卡死
|
||||||
|
* @param {number} [maxWait=15000]
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
_waitPrivacyResolved(maxWait = 15000) {
|
||||||
|
// 鸿蒙元服务: 隐私协议由系统托管, 应用层不弹自建隐私弹窗, 无需等待
|
||||||
|
// #ifdef MP-HARMONY
|
||||||
|
return Promise.resolve();
|
||||||
|
// #endif
|
||||||
|
// 已经同意过 → 隐私弹窗不会再出现, 直接放行
|
||||||
|
if (uni.getStorageSync('privacyAgreed')) return Promise.resolve();
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let done = false;
|
||||||
|
const finish = () => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
uni.$off('privacy:agreed', finish);
|
||||||
|
uni.$off('privacy:disagreed', finish);
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
uni.$on('privacy:agreed', finish);
|
||||||
|
uni.$on('privacy:disagreed', finish);
|
||||||
|
// 兜底: 隐私协议因规则未弹 / 一直未处理时, 超时后也要弹广告
|
||||||
|
const timer = setTimeout(finish, maxWait);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
// 无法从网络获取 diy 数据时:在页面内渲染错误横幅(各端可靠、一定可见)
|
// 无法从网络获取 diy 数据时:在页面内渲染错误横幅(各端可靠、一定可见)
|
||||||
// 同时通过 uni.$emit 通知 splash-state 关闭启动屏 (否则 strict 模式永远不关)
|
// 同时通过 uni.$emit 通知 splash-state 关闭启动屏 (否则 strict 模式永远不关)
|
||||||
@@ -542,9 +602,46 @@ export default {
|
|||||||
console.warn('[DIY][getDiyInfo] no value in res.data', JSON.stringify(diyDataValue).slice(0, 200));
|
console.warn('[DIY][getDiyInfo] no value in res.data', JSON.stringify(diyDataValue).slice(0, 200));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* 预加载启动广告图片
|
||||||
|
* 广告弹窗由 uni-popup 在 open() 时才用 v-if 渲染内容, 图片是网络图,
|
||||||
|
* 若等到弹窗打开后才开始加载, 真机网络慢时会出现"一截一截"逐步显示。
|
||||||
|
* 这里提前把图片下载进本地缓存, 等全部加载完成(或超时)后再弹出,
|
||||||
|
* 使弹窗一次性完整呈现。
|
||||||
|
* @param {Array} list - adv.list, 元素形如 { imageUrl, link }
|
||||||
|
* @param {number} [timeout=4000] - 单张兜底超时, 避免个别图片卡住导致广告永不弹出
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
_preloadAdvImages(list, timeout = 4000) {
|
||||||
|
if (!list || !list.length) return Promise.resolve();
|
||||||
|
const tasks = list.map((item) => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const url = (item && item.imageUrl) ? this.$util.img(item.imageUrl) : '';
|
||||||
|
if (!url) return resolve();
|
||||||
|
let done = false;
|
||||||
|
const finish = () => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
const timer = setTimeout(finish, timeout);
|
||||||
|
try {
|
||||||
|
// getImageInfo 会下载图片并写入本地缓存, 之后 image 组件可直接命中缓存
|
||||||
|
uni.getImageInfo({
|
||||||
|
src: url,
|
||||||
|
success: finish,
|
||||||
|
fail: finish
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return Promise.all(tasks).then(() => {});
|
||||||
|
},
|
||||||
closePopupWindow() {
|
closePopupWindow() {
|
||||||
this.$refs.uniPopupWindow.close();
|
this.$refs.uniPopupWindow.close();
|
||||||
uni.setStorageSync(this.id + this.name + '_popwindow_count', -1);
|
|
||||||
},
|
},
|
||||||
closeCollectPopupWindow() {
|
closeCollectPopupWindow() {
|
||||||
this.$refs.collectPopupWindow.close();
|
this.$refs.collectPopupWindow.close();
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ export default {
|
|||||||
|
|
||||||
hover_nav_bg_color: '#FFFC',
|
hover_nav_bg_color: '#FFFC',
|
||||||
hover_nav_text_color: '#000',
|
hover_nav_text_color: '#000',
|
||||||
|
hover_nav_font_size: '36rpx', // 悬浮按钮内文字/图标字号(后台可自定义, 默认与图标 36rpx 对齐)
|
||||||
|
hover_nav_lang_font_size: '36rpx', // 语言按钮内文字字号基准(文字实际按 0.667 缩放)
|
||||||
|
hover_nav_kefu_font_size: '36rpx', // 客服按钮内图标/emoji字号基准
|
||||||
|
hover_nav_phone_font_size: '36rpx', // 电话按钮内图标字号基准
|
||||||
|
|
||||||
// ===== DIY 分类页字号 token =====
|
// ===== DIY 分类页字号 token =====
|
||||||
diy_category_tree_select_size: '28rpx',
|
diy_category_tree_select_size: '28rpx',
|
||||||
|
|||||||
134
common/js/floating-button.js
Normal file
134
common/js/floating-button.js
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* 浮动侧边栏配置消费 (字段来源: /api/config/init → data.floating_button)
|
||||||
|
* 纯函数模块, 无副作用, 可直接单测
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 归一化路由: 'pages/index/index' → '/pages/index/index' */
|
||||||
|
export function normalizeRoute(route) {
|
||||||
|
if (!route) return '';
|
||||||
|
return String(route).charAt(0) === '/' ? String(route) : '/' + String(route);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取当前页面路由 (兼容 H5 / 小程序; route 已含分包 root, 如 'pages_goods/detail') */
|
||||||
|
export function getCurrentRoute() {
|
||||||
|
try {
|
||||||
|
const pages = getCurrentPages();
|
||||||
|
const cur = pages[pages.length - 1];
|
||||||
|
return normalizeRoute((cur && (cur.route || (cur.$page && cur.$page.path))) || '');
|
||||||
|
} catch (e) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 前缀命中判定
|
||||||
|
* '/pages/index' 命中 '/pages/index' 与 '/pages/index/xxx'
|
||||||
|
* '/pages_tool/' 命中 '/pages_tool/xxx' (前缀自带尾 /)
|
||||||
|
* '/pages/order/payment' 只命中它自己及其子路径
|
||||||
|
*/
|
||||||
|
export function hitPrefix(route, prefix) {
|
||||||
|
if (!route || !prefix) return false;
|
||||||
|
const p = prefix.charAt(prefix.length - 1) === '/' ? prefix : prefix + '/';
|
||||||
|
return route === prefix || route.indexOf(p) === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 任一前缀命中 */
|
||||||
|
function hitAny(route, prefixes) {
|
||||||
|
if (!Array.isArray(prefixes)) return false;
|
||||||
|
for (let i = 0; i < prefixes.length; i++) {
|
||||||
|
if (hitPrefix(route, prefixes[i])) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 找出路由命中的页面分组 key (未命中返回 null) */
|
||||||
|
export function matchPageGroup(route, prefixMap) {
|
||||||
|
if (!prefixMap) return null;
|
||||||
|
const keys = Object.keys(prefixMap);
|
||||||
|
for (let i = 0; i < keys.length; i++) {
|
||||||
|
if (hitAny(normalizeRoute(route), prefixMap[keys[i]])) return keys[i];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 侧边栏在当前页面是否显示 —— 唯一判定入口
|
||||||
|
*
|
||||||
|
* 优先级: 总开关 → 强制隐藏 → 显示范围
|
||||||
|
* 1) fb 不存在 (老缓存响应) → fallbackVisible (默认 true, 保持老行为)
|
||||||
|
* 2) enabled != 1 → false
|
||||||
|
* 3) 命中 force_hidden_prefixes 任意前缀 → false (最高优先级)
|
||||||
|
* 4) 命中 custom_pages / page_prefixes._custom → true
|
||||||
|
* 5) 命中 pages[组] == 1 → true
|
||||||
|
* 6) 都不命中 (如 /pages_goods/*) → false
|
||||||
|
*/
|
||||||
|
export function shouldShowOnPage(fb, route, fallbackVisible) {
|
||||||
|
if (!fb || typeof fb !== 'object') return fallbackVisible !== false;
|
||||||
|
if (Number(fb.enabled) !== 1) return false;
|
||||||
|
|
||||||
|
const r = normalizeRoute(route);
|
||||||
|
if (!r) return false;
|
||||||
|
|
||||||
|
// 强制隐藏 — 优先级最高
|
||||||
|
if (matchPageGroup(r, fb.force_hidden_prefixes)) return false;
|
||||||
|
|
||||||
|
// 自定义前缀优先判定
|
||||||
|
if (hitAny(r, fb.custom_pages) || hitAny(r, (fb.page_prefixes || {})['_custom'])) return true;
|
||||||
|
|
||||||
|
// 预设分组
|
||||||
|
const group = matchPageGroup(r, fb.page_prefixes);
|
||||||
|
if (group) return Number((fb.pages || {})[group]) === 1;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前页面要渲染的按钮列表 (已按 button_order 排序 + 显隐过滤)
|
||||||
|
* 返回 ['lang','kefu','phone'] 的子集, 空数组 = 整个侧边栏不渲染
|
||||||
|
* 返回 null = 配置缺失, 由调用方决定老行为
|
||||||
|
*/
|
||||||
|
export function resolveVisibleButtons(fb) {
|
||||||
|
if (!fb || typeof fb !== 'object') return null;
|
||||||
|
const order = Array.isArray(fb.button_order) ? fb.button_order : ['lang', 'kefu', 'phone'];
|
||||||
|
const map = fb.buttons || {};
|
||||||
|
const out = [];
|
||||||
|
for (let i = 0; i < order.length; i++) {
|
||||||
|
const k = order[i];
|
||||||
|
if (Number(map[k]) === 1) out.push(k);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 语言按钮显示文案
|
||||||
|
* @param {Object} fb data.floating_button
|
||||||
|
* @param {Array} langList [{ value:'zh-cn', name:'CN' }, ...]
|
||||||
|
* @param {Number} currentIdx 当前语言下标
|
||||||
|
*/
|
||||||
|
export function resolveLangButtonText(fb, langList, currentIdx) {
|
||||||
|
const list = Array.isArray(langList) ? langList : [];
|
||||||
|
if (!list.length) return '';
|
||||||
|
const style = (fb && fb.lang_switch && fb.lang_switch.style) || 'target';
|
||||||
|
|
||||||
|
if (style === 'text') {
|
||||||
|
const t = (fb && fb.lang_switch && fb.lang_switch.text) || '';
|
||||||
|
if (t) return t; // 空文案兜底走 target
|
||||||
|
}
|
||||||
|
if (style === 'current' || list.length < 2) {
|
||||||
|
return (list[currentIdx] && list[currentIdx].name) || '';
|
||||||
|
}
|
||||||
|
// target: 显示「点下去会切到的那个」语言
|
||||||
|
// 2 种语言 → 另一个; >2 种 → 取第一个不是当前的那个
|
||||||
|
let targetIdx = 0;
|
||||||
|
for (let i = 0; i < list.length; i++) {
|
||||||
|
if (i !== currentIdx) { targetIdx = i; break; }
|
||||||
|
}
|
||||||
|
return list[targetIdx] ? (list[targetIdx].name || '') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 语言切换按钮是否可见 (含老「双语模式」fallback) */
|
||||||
|
export function isLangButtonVisible(fb, shopInfo, langCount) {
|
||||||
|
if (Number(langCount) < 2) return false;
|
||||||
|
if (fb && fb.lang_switch) return Number(fb.lang_switch.enabled) === 1;
|
||||||
|
return !!(shopInfo && shopInfo.ischina); // 老行为兜底
|
||||||
|
}
|
||||||
@@ -272,6 +272,59 @@ export default {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* JSON body 请求通道
|
||||||
|
*
|
||||||
|
* 为什么需要: sendRequest 走表单编码, 嵌套数组(如 fields.mobiles)的序列化
|
||||||
|
* 格式在各端不一致(uni 序列化器的平台差异, 小程序/H5 产出不同),
|
||||||
|
* 后端 input('fields/a') 依赖 PHP 方括号语法, 踩坑风险高。
|
||||||
|
* 本方法统一 JSON.stringify + application/json —— TP6 Request
|
||||||
|
* 原生解析 JSON body(getInputData), 且字符串 body 各端原样发送, 零歧义。
|
||||||
|
* token/uniacid/app_type 与 sendRequest 一样自动补齐。
|
||||||
|
*/
|
||||||
|
sendJson(params) {
|
||||||
|
if (!Config.baseUrl) {
|
||||||
|
uni.showToast({
|
||||||
|
title: getLang().lang('http.baseUrlNotConfigured'),
|
||||||
|
'icon': 'none',
|
||||||
|
'duration': 10000
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = (Config.baseUrl + params.url).replace(/(?<!:)\/+/g, '/');
|
||||||
|
const data = {
|
||||||
|
app_type: appType,
|
||||||
|
app_type_name: appTypeName,
|
||||||
|
token: store.state.token || '',
|
||||||
|
uniacid: uni.getStorageSync('uniacid') || Config.uniacid
|
||||||
|
};
|
||||||
|
if (params.data != undefined) Object.assign(data, params.data);
|
||||||
|
|
||||||
|
const store_info = store.state.globalStoreInfo;
|
||||||
|
if (store_info) data.store_id = store_info.store_id;
|
||||||
|
|
||||||
|
console.log('[HTTP][REQUEST] sendJson', url);
|
||||||
|
uni.request({
|
||||||
|
url: url,
|
||||||
|
method: params.method || 'POST',
|
||||||
|
header: Object.assign({ 'content-type': 'application/json' }, params.header || {}),
|
||||||
|
data: JSON.stringify(data),
|
||||||
|
dataType: params.dataType || 'json',
|
||||||
|
responseType: params.responseType || 'text',
|
||||||
|
success: (res) => {
|
||||||
|
console.log('[HTTP][RESPONSE] sendJson', url, 'statusCode=', res.statusCode);
|
||||||
|
let r = res.data;
|
||||||
|
if (typeof r === 'string') {
|
||||||
|
try { r = JSON.parse(r); } catch (e) { /* 保持原样 */ }
|
||||||
|
}
|
||||||
|
typeof params.success == 'function' && params.success(r);
|
||||||
|
},
|
||||||
|
fail: (res) => {
|
||||||
|
console.error('[HTTP][FAIL] sendJson', url, JSON.stringify(res));
|
||||||
|
typeof params.fail == 'function' && params.fail(res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
upload(params) {
|
upload(params) {
|
||||||
console.log('[HTTP][REQUEST] upload', Config.baseUrl + params.url, 'name=', params.name || 'file');
|
console.log('[HTTP][REQUEST] upload', Config.baseUrl + params.url, 'name=', params.name || 'file');
|
||||||
uni.uploadFile({
|
uni.uploadFile({
|
||||||
|
|||||||
@@ -284,6 +284,21 @@ export async function loadBundleTree(options = {}) {
|
|||||||
const version = String(payload.version || '');
|
const version = String(payload.version || '');
|
||||||
const sign = payload.sign || '';
|
const sign = payload.sign || '';
|
||||||
|
|
||||||
|
// 诊断(真机定位用): tree 顶层结构不完整(非对象/为空/缺 common 或 components)时,
|
||||||
|
// 打印实际拿到的 tree 顶层 key + 请求上下文, 便于排查为什么 isBundleComplete 失败
|
||||||
|
// (真机上若看到这条 warn, 说明服务端返回了残缺 bundle, 这才是英文回退/ toast 的根因)
|
||||||
|
if (!tree || typeof tree !== 'object' || !tree.common || !tree.components) {
|
||||||
|
console.warn(
|
||||||
|
'[lang-loader] bundle tree INCOMPLETE. ' +
|
||||||
|
'topLevelKeys=' + JSON.stringify(Object.keys(tree || {})) +
|
||||||
|
' lang=' + lang +
|
||||||
|
' appType=' + appType +
|
||||||
|
' uniacid=' + uniacid +
|
||||||
|
' version=' + version +
|
||||||
|
' hasPayloadTree=' + ((payload && payload.tree) !== undefined)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 4) 签名校验(可选)
|
// 4) 签名校验(可选)
|
||||||
const secret = langConfig.remote.signSecret || '';
|
const secret = langConfig.remote.signSecret || '';
|
||||||
const signValid = validateSign(tree, version, sign, secret);
|
const signValid = validateSign(tree, version, sign, secret);
|
||||||
|
|||||||
@@ -489,27 +489,36 @@ function isBundleComplete(tree) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) tree 不能是空对象(空对象没有任何可用文案, 显然不可用)
|
// 2) tree 不能是空对象(空对象没有 common 也没有 components, 显然不可用)
|
||||||
const treeKeys = Object.keys(tree);
|
const treeKeys = Object.keys(tree);
|
||||||
if (treeKeys.length === 0) {
|
if (treeKeys.length === 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// === 粗粒度校验(与上方设计哲学一致) ===
|
// 3) tree 必须有 common 节点(基础文案, 几乎所有 UI 都依赖)
|
||||||
// 不再硬性要求 common / components 节点必须同时存在。
|
if (!tree.common || typeof tree.common !== 'object') {
|
||||||
// 远端可能在某些时刻少发某个节点(部署期间、缓存未刷新、租户未配置 common 等),
|
return false;
|
||||||
// 此时仍应让 boot 成功写入 remoteTreeCache, 缺失字段由 $lang() 自然回落为空,
|
|
||||||
// UI 降级显示(空文案/空按钮文字), 而不是让整个 boot 失败导致整站变英文 / raw key。
|
|
||||||
|
|
||||||
// 软校验: components.privacyPopup(节点存在才检查, 避免 tree.components 为 undefined 时报错)
|
|
||||||
const privacyPopup = tree.components && tree.components.privacyPopup;
|
|
||||||
if (privacyPopup && typeof privacyPopup === 'object') {
|
|
||||||
// defaultAppNameMp 存在性仅作诊断, 不阻止 boot
|
|
||||||
if (typeof privacyPopup.defaultAppNameMp !== 'string' || !privacyPopup.defaultAppNameMp) {
|
|
||||||
// 软校验不阻止 boot, 仅记录(暂无控制台输出, 避免污染生产)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4) tree 必须有 components 节点(组件级文案)
|
||||||
|
if (!tree.components || typeof tree.components !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 以下为软校验(只 warn 不 return false) ===
|
||||||
|
// 设计: 不再因为"缺某个 key"就让整个 boot 失败
|
||||||
|
// 缺失的字段让 $lang() 自然返回空, UI 降级显示
|
||||||
|
|
||||||
|
// 软校验 1: components.privacyPopup
|
||||||
|
const privacyPopup = tree.components.privacyPopup;
|
||||||
|
if (!privacyPopup || typeof privacyPopup !== 'object') {
|
||||||
|
// 软校验不阻止 boot, 仅记录(暂无控制台输出, 避免污染生产)
|
||||||
|
} else if (typeof privacyPopup.defaultAppNameMp !== 'string' || !privacyPopup.defaultAppNameMp) {
|
||||||
|
// 软校验不阻止 boot, 仅记录(暂无控制台输出, 避免污染生产)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 软校验 2: 哪些 key 看起来像隐私弹窗,但在错误位置?(诊断)
|
||||||
|
// 此处仅保留注释说明, 不在生产代码中输出
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -619,6 +628,26 @@ function loadLangPackSync(lang, path) {
|
|||||||
* 获得当前本地语言
|
* 获得当前本地语言
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* 语言切换开关钩子(由后台 shop_info.ischina 决定,经 store/index.js 的 setShopInfo 写入)。
|
||||||
|
*
|
||||||
|
* 设计原则: 只"在关闭时清理持久化语种", 绝不在开启态加任何门控 —— 否则一旦 ischina
|
||||||
|
* 取值/语义判断偏差, 就会误伤"开启双语后切换英文"的正常行为(回归)。
|
||||||
|
*
|
||||||
|
* 行为:
|
||||||
|
* - 后台关闭双语/小程序(ischina 为假): 清掉用户此前持久化到 storage 的语种 + 缓存,
|
||||||
|
* 使刷新/重新进入时 getCurrentLocale() 回退默认中文, 满足
|
||||||
|
* "后台关闭双语后, 刷新重新进入必须是中文" 的需求。
|
||||||
|
* - 后台开启双语(ischina 为真): 不做任何拦截, 完全保持原有"切换英文"行为不变。
|
||||||
|
*/
|
||||||
|
function setLangSwitchEnabled(enabled) {
|
||||||
|
if (!enabled) {
|
||||||
|
// 关闭语言切换: 清掉持久化语种 + 缓存, 强制回到默认中文
|
||||||
|
invalidateStorageCache('lang');
|
||||||
|
try { uni.removeStorageSync('lang'); } catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getCurrentLocale() {
|
function getCurrentLocale() {
|
||||||
// 关键:使用 cachedStorageGet,防止 uni.getStorageSync 触发 Vue 事件导致 lang() 递归
|
// 关键:使用 cachedStorageGet,防止 uni.getStorageSync 触发 Vue 事件导致 lang() 递归
|
||||||
const val = cachedStorageGet('lang', null);
|
const val = cachedStorageGet('lang', null);
|
||||||
@@ -964,6 +993,13 @@ export default {
|
|||||||
*/
|
*/
|
||||||
getCurrentLocale,
|
getCurrentLocale,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 语言切换功能开关(后台 shop_info.ischina 决定)。
|
||||||
|
* 关闭后 getCurrentLocale() 强制返回默认语言(中文),并清掉已持久化的语种。
|
||||||
|
* 详见本文件 setLangSwitchEnabled 函数与 store/index.js 的 setShopInfo 钩子。
|
||||||
|
*/
|
||||||
|
setLangSwitchEnabled,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从后端数据中提取当前语言的文案(向后兼容)
|
* 从后端数据中提取当前语言的文案(向后兼容)
|
||||||
* 优先使用 xxx_map 多语言 map,没有则回退到 xxx / en_xxx 二元模式
|
* 优先使用 xxx_map 多语言 map,没有则回退到 xxx / en_xxx 二元模式
|
||||||
@@ -1045,6 +1081,13 @@ export default {
|
|||||||
// force 失败时 loadBundleTree 内部直接抛错(不回退 cache), 让这里进入 catch
|
// force 失败时 loadBundleTree 内部直接抛错(不回退 cache), 让这里进入 catch
|
||||||
let isComplete1 = isBundleComplete(result.tree);
|
let isComplete1 = isBundleComplete(result.tree);
|
||||||
if (!isComplete1) {
|
if (!isComplete1) {
|
||||||
|
// [debug] 首次拉取的 bundle 不完整: 打印顶层 key, 便于定位远端是否缺 common/components
|
||||||
|
try {
|
||||||
|
console.warn('[i18n:boot] bundle incomplete (1st fetch). ' +
|
||||||
|
'lang=' + lang + ', appType=' + appType + ', uniacid=' + uniacid +
|
||||||
|
', source=' + (result && result.source) +
|
||||||
|
', topKeys=' + JSON.stringify(Object.keys(result && result.tree || {})));
|
||||||
|
} catch (e) {}
|
||||||
// 强制重试前清掉所有 cache, 避免 force 路径残留旧数据
|
// 强制重试前清掉所有 cache, 避免 force 路径残留旧数据
|
||||||
delete remoteTreeCache[_preloadMk];
|
delete remoteTreeCache[_preloadMk];
|
||||||
result = await loadBundleTree({ lang, appType, uniacid, force: true });
|
result = await loadBundleTree({ lang, appType, uniacid, force: true });
|
||||||
@@ -1055,6 +1098,13 @@ export default {
|
|||||||
// 不加载本地 lang 兜底(remote-only 模式, 本地 lang 已被 webpack 排除)
|
// 不加载本地 lang 兜底(remote-only 模式, 本地 lang 已被 webpack 排除)
|
||||||
let isComplete2 = isBundleComplete(result.tree);
|
let isComplete2 = isBundleComplete(result.tree);
|
||||||
if (!isComplete2) {
|
if (!isComplete2) {
|
||||||
|
// [debug] force 重试后仍不完整: 打印顶层 key, 确认远端确实返回残缺 tree
|
||||||
|
try {
|
||||||
|
console.warn('[i18n:boot] bundle incomplete (after force retry). ' +
|
||||||
|
'lang=' + lang + ', appType=' + appType + ', uniacid=' + uniacid +
|
||||||
|
', source=' + (result && result.source) +
|
||||||
|
', topKeys=' + JSON.stringify(Object.keys(result && result.tree || {})));
|
||||||
|
} catch (e) {}
|
||||||
throw new Error('lang bundle unusable after force retry');
|
throw new Error('lang bundle unusable after force retry');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1094,10 +1144,14 @@ export default {
|
|||||||
setRemoteStatus('fail', { reason: _reason, lang });
|
setRemoteStatus('fail', { reason: _reason, lang });
|
||||||
bootCompleted = true;
|
bootCompleted = true;
|
||||||
emitUpdated(lang);
|
emitUpdated(lang);
|
||||||
|
// 注意: 这里已用缓存兜底成功(UI 可用), 不应再标 status:'fail',
|
||||||
|
// 否则 App.vue 会弹"Failed to load i18n"误报 toast。
|
||||||
|
// remoteStatus 仍保持 'fail'(上方已 setRemoteStatus), 用于离线指示。
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
source: 'cache',
|
source: 'cache',
|
||||||
status: 'fail',
|
status: 'ok',
|
||||||
|
degraded: true,
|
||||||
reason: _errMsg || 'fallback to cache',
|
reason: _errMsg || 'fallback to cache',
|
||||||
lang,
|
lang,
|
||||||
cacheAge: cached.ts ? Date.now() - cached.ts : 0,
|
cacheAge: cached.ts ? Date.now() - cached.ts : 0,
|
||||||
@@ -1167,6 +1221,42 @@ export default {
|
|||||||
loadOverrides(lang);
|
loadOverrides(lang);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信小程序"重新进入小程序"(右上角 ... 菜单)场景调用:
|
||||||
|
* 该操作销毁页面栈重新进首页,但小程序 JS 上下文往往不重置(热重启),
|
||||||
|
* 于是 App.onLaunch(里面跑 boot 全量初始化)不会重跑,而模块级内存缓存仍在:
|
||||||
|
* - _storageResultCache(cachedStorage): 可能返回进入前缓存的 lang/uniacid
|
||||||
|
* - _mergedLangCache / _fieldResultCache: 沿用进入前的翻译结果
|
||||||
|
* - remote-only 模式下远端 bundle 不会被重新拉取
|
||||||
|
* 这里主动失效所有内存缓存,并重新对齐远端 bundle,使重新进入后按当前
|
||||||
|
* storage 的 locale 重新读取并合并,不再沿用旧值。
|
||||||
|
* 注意: 首次 onShow(启动展示)不应调用本方法,App.vue 负责区分。
|
||||||
|
*/
|
||||||
|
onAppReentry() {
|
||||||
|
// [debug] 验证微信"重新进入小程序"是否真的命中本分支:真机复现后搜 i18n:reentry
|
||||||
|
try {
|
||||||
|
console.log('[i18n:reentry] onAppReentry triggered, IS_REMOTE_ONLY=' + IS_REMOTE_ONLY +
|
||||||
|
', beforeInvalidateLang=' + (cachedStorageGet('lang', null)) +
|
||||||
|
', beforeInvalidateUniacid=' + (cachedStorageGet('uniacid', null)));
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
// 1) cachedStorage 失效: 下次 cachedStorageGet 必须重新读 storage(locale/uniacid)
|
||||||
|
invalidateStorageCache();
|
||||||
|
// 2) 语言包合并/字段缓存失效,并 bump _overrideVersion → lang() 用当前 locale 重新合并
|
||||||
|
invalidateLangCaches();
|
||||||
|
// [debug] 失效后立刻重新读,确认 cachedStorage 已重新走 storage(而非命中旧缓存)
|
||||||
|
try {
|
||||||
|
console.log('[i18n:reentry] after invalidate, freshLang=' + getCurrentLocale() +
|
||||||
|
', freshUniacid=' + getCurrentUniacid());
|
||||||
|
} catch (e) {}
|
||||||
|
// 3) remote-only: 重新进入时 onLaunch 不重跑,这里重新拉当前 locale 的 bundle 并推送重渲染
|
||||||
|
if (IS_REMOTE_ONLY) {
|
||||||
|
this.boot();
|
||||||
|
} else {
|
||||||
|
this.refresh(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* * 订阅远端状态变化(status: 'unknown' | 'ok' | 'fail', detail.reason 有 fail 分类)
|
* * 订阅远端状态变化(status: 'unknown' | 'ok' | 'fail', detail.reason 有 fail 分类)
|
||||||
* @param {(status:string, detail:object) => void} fn
|
* @param {(status:string, detail:object) => void} fn
|
||||||
|
|||||||
@@ -40,10 +40,16 @@ const EXT_MIME_MAP = {
|
|||||||
m4a: 'audio/mp4', flac: 'audio/flac', ogg: 'audio/ogg',
|
m4a: 'audio/mp4', flac: 'audio/flac', ogg: 'audio/ogg',
|
||||||
oga: 'audio/ogg', opus: 'audio/opus', amr: 'audio/amr', wma: 'audio/x-ms-wma',
|
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',
|
aiff: 'audio/aiff', aif: 'audio/aiff', ape: 'audio/x-ape', mid: 'audio/midi',
|
||||||
|
// PR-AV-FULL-SUPPORT (2026-09-24): 全量音视频格式 (与后端 MediaUploadPolicy::guessMimeByExt 对齐)
|
||||||
|
midi: 'audio/midi', caf: 'audio/x-caf', mka: 'audio/x-matroska', ac3: 'audio/ac3',
|
||||||
mp4: 'video/mp4', mov: 'video/quicktime', avi: 'video/x-msvideo',
|
mp4: 'video/mp4', mov: 'video/quicktime', avi: 'video/x-msvideo',
|
||||||
mkv: 'video/x-matroska', webm: 'video/webm', flv: 'video/x-flv',
|
mkv: 'video/x-matroska', webm: 'video/webm', flv: 'video/x-flv',
|
||||||
m4v: 'video/x-m4v', wmv: 'video/x-ms-wmv', '3gp': 'video/3gpp',
|
m4v: 'video/x-m4v', wmv: 'video/x-ms-wmv', '3gp': 'video/3gpp',
|
||||||
mpeg: 'video/mpeg', mpg: 'video/mpeg', ts: 'video/mp2t',
|
mpeg: 'video/mpeg', mpg: 'video/mpeg', ts: 'video/mp2t',
|
||||||
|
'3g2': 'video/3gpp2', f4v: 'video/x-flv', mpe: 'video/mpeg', m2v: 'video/mpeg',
|
||||||
|
m2ts: 'video/mp2t', mts: 'video/mp2t', ogv: 'video/ogg', vob: 'video/mpeg',
|
||||||
|
asf: 'video/x-ms-asf', rm: 'application/vnd.rn-realmedia',
|
||||||
|
rmvb: 'application/vnd.rn-realmedia-vbr', divx: 'video/x-divx',
|
||||||
doc: 'application/msword',
|
doc: 'application/msword',
|
||||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
ppt: 'application/vnd.ms-powerpoint',
|
ppt: 'application/vnd.ms-powerpoint',
|
||||||
|
|||||||
30
common/js/startup-ad.js
Normal file
30
common/js/startup-ad.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// 启动广告弹出信号
|
||||||
|
// 用于判定"当前是否需要弹出启动广告":
|
||||||
|
// - 小程序冷启动(首次进入): 由模块级 _coldLaunchedShown 标记判定, 整个 app 进程只记一次
|
||||||
|
// - 小程序重新进入(从后台切回 / 微信"重新进入小程序"菜单):
|
||||||
|
// 由 App.onShow 在"非首次展示"时调用 markNeedStartupAd() 置位
|
||||||
|
// 信号消费一次即清除, 不落地到持久化存储, 因此无需清缓存即可每次重新进入都弹出。
|
||||||
|
// 关键: 切换 Tab 回到首页只触发"页面级 onShow", 不会触发 App.onShow, 也不会重置
|
||||||
|
// 模块级冷启动标记, 因此不会在 Tab 切换时误弹。
|
||||||
|
|
||||||
|
let _needStartupAd = false;
|
||||||
|
let _coldLaunchedShown = false;
|
||||||
|
|
||||||
|
// App 层检测到"重新进入小程序"时调用, 标记本次需要弹出启动广告
|
||||||
|
export function markNeedStartupAd() {
|
||||||
|
_needStartupAd = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页面层读取并消费该信号(消费后自动复位)
|
||||||
|
export function consumeNeedStartupAd() {
|
||||||
|
const v = _needStartupAd;
|
||||||
|
_needStartupAd = false;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 是否为本 app 进程的首次冷启动(只返回一次 true, 之后恒为 false)
|
||||||
|
export function isColdLaunchPending() {
|
||||||
|
if (_coldLaunchedShown) return false;
|
||||||
|
_coldLaunchedShown = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -62,7 +62,13 @@ export default {
|
|||||||
},
|
},
|
||||||
...{
|
...{
|
||||||
hover_nav_bg_color: '#FFFC', //背景色: 红色 '#c6251b', 白色: '#FFFC'
|
hover_nav_bg_color: '#FFFC', //背景色: 红色 '#c6251b', 白色: '#FFFC'
|
||||||
hover_nav_text_color: '#000' // 文字颜色: 白色 '#FFFFFF', 黑色: '#000'
|
hover_nav_text_color: '#000', // 文字颜色: 白色 '#FFFFFF', 黑色: '#000'
|
||||||
|
hover_nav_font_size: '36rpx', // 悬浮按钮内文字/图标字号(后台可自定义, 默认与图标 36rpx 对齐)
|
||||||
|
// ===== 逐按钮字号(与 hover_nav_lang/kefu/phone 的 bg/text/size 同约定, 基准 36rpx) =====
|
||||||
|
// 未单独配置时回退到上面的 hover_nav_font_size(全局旋钮)
|
||||||
|
hover_nav_lang_font_size: '36rpx', // 语言按钮内文字字号基准(文字实际按 0.667 缩放)
|
||||||
|
hover_nav_kefu_font_size: '36rpx', // 客服按钮内图标/emoji字号基准
|
||||||
|
hover_nav_phone_font_size: '36rpx' // 电话按钮内图标字号基准
|
||||||
},
|
},
|
||||||
|
|
||||||
// ===== DIY 分类页字号 token(全 8 preset 一致, 远端可覆写) =====
|
// ===== DIY 分类页字号 token(全 8 preset 一致, 远端可覆写) =====
|
||||||
|
|||||||
@@ -5,24 +5,6 @@
|
|||||||
import lang from './lang.js';
|
import lang from './lang.js';
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 判断是否为「微信剪贴板隐私声明缺失」导致的复制失败
|
|
||||||
*
|
|
||||||
* 微信自 2023-09-15 起把剪贴板接口纳入《小程序用户隐私保护指引》,
|
|
||||||
* 若小程序后台未声明「剪贴板」(或声明未审核通过),调用会返回:
|
|
||||||
* { errno: 112, errMsg: 'setClipboardData:fail api scope is not declared in the privacy agreement' }
|
|
||||||
* 这是平台侧配置问题,不改代码也能复现,因此不能把原始英文 errMsg 直接展示给用户。
|
|
||||||
*
|
|
||||||
* @param {*} err uni.setClipboardData 的 fail 回调参数
|
|
||||||
* @returns {Boolean} true=剪贴板隐私声明缺失
|
|
||||||
*/
|
|
||||||
export const isClipboardPrivacyError = (err) => {
|
|
||||||
if (!err) return false;
|
|
||||||
if (Number(err.errno) === 112) return true;
|
|
||||||
const msg = `${err.errMsg || err.message || ''}`;
|
|
||||||
return /privacy agreement/i.test(msg) || /api scope is not declared/i.test(msg);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 显示错误信息
|
* 显示错误信息
|
||||||
* @param {Exception} err
|
* @param {Exception} err
|
||||||
@@ -115,12 +97,8 @@ export const copyTextAsync = (text, { copySuccess = '', copyFailed = '' } = {})
|
|||||||
fail: (err) => {
|
fail: (err) => {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
try {
|
try {
|
||||||
// 剪贴板隐私声明缺失(errno 112)属平台配置问题, 用可读文案替代微信原始英文错误
|
|
||||||
const tip = isClipboardPrivacyError(err)
|
|
||||||
? lang.lang('uniappUtils.clipboardPrivacyNotDeclared')
|
|
||||||
: (err.message || err.errMsg || copyFailed || lang.lang('uniappUtils.copyFailed'));
|
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
title: tip,
|
title: err.message || err.errMsg || copyFailed || lang.lang('uniappUtils.copyFailed'),
|
||||||
icon: 'none',
|
icon: 'none',
|
||||||
duration: 2000
|
duration: 2000
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -200,6 +200,16 @@ export const PAGE_KEY_MAP = {
|
|||||||
// === 电子名片 ===
|
// === 电子名片 ===
|
||||||
PAGE_CONTACT_CARD: '/pages/contact/contact',
|
PAGE_CONTACT_CARD: '/pages/contact/contact',
|
||||||
|
|
||||||
|
// === 名片云(card 插件) ===
|
||||||
|
PAGE_CARD_LIST: '/pages_tool/card/list',
|
||||||
|
PAGE_CARD_LEADS: '/pages_tool/card/leads',
|
||||||
|
PAGE_CARD_CONTACTS: '/pages_tool/card/contacts',
|
||||||
|
|
||||||
|
// === 名片云 · 商家端(后台接口就绪一部分, 先上有接口的页面) ===
|
||||||
|
PAGE_CARD_ADMIN_MANAGE: '/pages_tool/card/admin/manage',
|
||||||
|
PAGE_CARD_ADMIN_CONTACTS: '/pages_tool/card/admin/contacts',
|
||||||
|
PAGE_CARD_ADMIN_LEADS: '/pages_tool/card/admin/leads',
|
||||||
|
|
||||||
// === VR ===
|
// === VR ===
|
||||||
PAGE_VR_INDEX: '/pages_tool/vr/index',
|
PAGE_VR_INDEX: '/pages_tool/vr/index',
|
||||||
|
|
||||||
@@ -1254,6 +1264,21 @@ export default {
|
|||||||
link.wap_url = resolvedUrl;
|
link.wap_url = resolvedUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 仅微信小程序: 装修"文件"链接直开, 不进 files/detail 中间页 =====
|
||||||
|
// 命中: page_key=PAGE_FILES_DETAIL / name=FILES_xx / wap_url 指向 files/detail
|
||||||
|
// 且能提取到 files_id。拉取或打开失败时自动降级回原详情页(openDiyFileDirect 内部处理)。
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
if (link && (link.page_key === 'PAGE_FILES_DETAIL'
|
||||||
|
|| (link.name && /^FILES_\d+$/i.test(String(link.name)))
|
||||||
|
|| String(link.wap_url || '').indexOf('files/detail') !== -1)) {
|
||||||
|
const filesId = this._extractFilesIdFromLink(link);
|
||||||
|
if (filesId) {
|
||||||
|
this.openDiyFileDirect(link, filesId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
|
||||||
// 修复: 内部页面被后台存成【绝对 URL】(如 https://host/hwappx/common/pages_tool/form/formdata)
|
// 修复: 内部页面被后台存成【绝对 URL】(如 https://host/hwappx/common/pages_tool/form/formdata)
|
||||||
// 时,下方 `indexOf('http') != -1` 会把它误判为"外部链接":
|
// 时,下方 `indexOf('http') != -1` 会把它误判为"外部链接":
|
||||||
// - H5 走 window.location.href → 整页刷新,点返回时整个 App 重启、启动屏重播、状态/滚动丢失
|
// - H5 走 window.location.href → 整页刷新,点返回时整个 App 重启、启动屏重播、状态/滚动丢失
|
||||||
@@ -1427,6 +1452,123 @@ export default {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* 从装修"文件"链接中提取 files_id(仅微信小程序直开链路使用)。
|
||||||
|
* 兼容三种位置: 顶层字段(files_id/file_id/id) / page_params(JSON字符串或对象) / wap_url query。
|
||||||
|
* @returns {String} 拿不到返回 ''
|
||||||
|
*/
|
||||||
|
_extractFilesIdFromLink(link) {
|
||||||
|
if (!link) return '';
|
||||||
|
let id = link.files_id || link.file_id || link.id || '';
|
||||||
|
if (!id) {
|
||||||
|
const params = _parsePageParams(link.page_params);
|
||||||
|
id = params.files_id || params.file_id || params.id || '';
|
||||||
|
}
|
||||||
|
if (!id) {
|
||||||
|
const url = String(link.wap_url || '');
|
||||||
|
const qIdx = url.indexOf('?');
|
||||||
|
if (qIdx !== -1) {
|
||||||
|
url.slice(qIdx + 1).split('&').forEach(kv => {
|
||||||
|
if (id) return;
|
||||||
|
const eq = kv.indexOf('=');
|
||||||
|
const k = eq === -1 ? kv : kv.slice(0, eq);
|
||||||
|
if (k === 'files_id' || k === 'file_id' || k === 'id') {
|
||||||
|
id = eq === -1 ? '' : kv.slice(eq + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return id === '' || id === undefined || id === null ? '' : String(id);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 微信小程序: 装修"文件"链接直开, 不进 files/detail 中间页。
|
||||||
|
* 链路: /api/files/getfile(files_url 已由后端 img() 补全域名)
|
||||||
|
* → downloadFile → saveFile 用文件名重命名 → openDocument(原生 PDF 阅读器)
|
||||||
|
* 任一环节失败 → 降级跳原详情页(files/detail), 保持旧体验兜底。
|
||||||
|
* @param {Object} link 装修链接对象(用于取标题)
|
||||||
|
* @param {String} filesId 文件 id
|
||||||
|
*/
|
||||||
|
openDiyFileDirect(link, filesId) {
|
||||||
|
const fallback = () => {
|
||||||
|
this.redirectTo(PAGE_KEY_MAP.PAGE_FILES_DETAIL, {
|
||||||
|
files_id: filesId,
|
||||||
|
title: link.title || '',
|
||||||
|
enable_set_title: link.enable_set_title ? 1 : 0,
|
||||||
|
page_params: Object.assign({}, _parsePageParams(link.page_params), { files_id: filesId })
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const safeTitle = String(link.title || '').replace(/[\\/:*?"<>|\r\n\t]/g, '_');
|
||||||
|
Http.sendRequest({
|
||||||
|
url: '/api/files/getfile',
|
||||||
|
data: { files_id: filesId },
|
||||||
|
noRetry: true,
|
||||||
|
silentError: true,
|
||||||
|
success: res => {
|
||||||
|
if (!res || res.code !== 0 || !res.data || !res.data.files_url) {
|
||||||
|
fallback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let url = String(res.data.files_url);
|
||||||
|
// 相对路径补全域名($util.img 对含逗号路径会按多图截断, 规避)
|
||||||
|
if (!/^https?:\/\//i.test(url) && url.indexOf(',') === -1) {
|
||||||
|
url = this.img(url);
|
||||||
|
}
|
||||||
|
if (!/^https?:\/\//i.test(url)) {
|
||||||
|
fallback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rawTitle = String(res.data.files_title || safeTitle || 'file');
|
||||||
|
const title = rawTitle.replace(/[\\/:*?"<>|\r\n\t]/g, '_');
|
||||||
|
const matched = url.split('?')[0].match(/\.([a-zA-Z0-9]+)$/);
|
||||||
|
const ext = matched ? matched[1].toLowerCase() : 'pdf';
|
||||||
|
|
||||||
|
uni.showLoading({ title: Lang.lang('files.list.loading') || 'Loading', mask: true });
|
||||||
|
uni.downloadFile({
|
||||||
|
url,
|
||||||
|
success: dres => {
|
||||||
|
if (dres.statusCode !== 200) {
|
||||||
|
uni.hideLoading();
|
||||||
|
fallback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fs = uni.getFileSystemManager();
|
||||||
|
const targetPath = `${wx.env.USER_DATA_PATH}/fs_${Date.now()}_${title}.${ext}`;
|
||||||
|
fs.saveFile({
|
||||||
|
tempFilePath: dres.tempFilePath,
|
||||||
|
filePath: targetPath,
|
||||||
|
success: r => {
|
||||||
|
uni.hideLoading();
|
||||||
|
uni.openDocument({
|
||||||
|
filePath: r.savedFilePath,
|
||||||
|
fileType: ext,
|
||||||
|
showMenu: true,
|
||||||
|
fail: () => fallback(),
|
||||||
|
complete: () => {
|
||||||
|
try { fs.removeSavedFile({ filePath: r.savedFilePath }); } catch (e) { }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
// saveFile 失败(配额等), 直接用临时文件打开兜底
|
||||||
|
uni.hideLoading();
|
||||||
|
uni.openDocument({
|
||||||
|
filePath: dres.tempFilePath,
|
||||||
|
fileType: ext,
|
||||||
|
showMenu: true,
|
||||||
|
fail: () => fallback()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
uni.hideLoading();
|
||||||
|
fallback();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
fail: () => fallback()
|
||||||
|
});
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
* 获取默认图
|
* 获取默认图
|
||||||
*/
|
*/
|
||||||
|
|||||||
819
common/js/voice-recorder.js
Normal file
819
common/js/voice-recorder.js
Normal file
@@ -0,0 +1,819 @@
|
|||||||
|
/**
|
||||||
|
* voice-recorder.js — 跨端录音抽象层
|
||||||
|
*
|
||||||
|
* PR-FRONTEND-VOICE-INPUT (2026-09-24)
|
||||||
|
* 统一 H5 / 微信小程序 / App-Plus / 鸿蒙元服务 四端的录音接口,
|
||||||
|
* 把麦克风采集 + 振幅事件 + 自动停止 + 资源回收 收敛到一处。
|
||||||
|
*
|
||||||
|
* PR-FRONTEND-VOICE-WAV (2026-09-25)
|
||||||
|
* H5 分支从 MediaRecorder (产出 webm/opus) 改为 AudioContext + ScriptProcessor
|
||||||
|
* 采原始 PCM, 停止时手动封装成标准 WAV (PCM 16-bit LE mono, 16kHz).
|
||||||
|
* 原因: Chrome MediaRecorder 只支持 webm/ogg 容器, 不支持 wav/mp3 录制;
|
||||||
|
* 大多数 STT / 后端 Agent 框架无法解析 webm/opus.
|
||||||
|
* 产 wav 后所有 STT 服务 (Whisper / 达摩院 / 腾讯 ASR / ...) 都吃.
|
||||||
|
*
|
||||||
|
* 平台分支:
|
||||||
|
* - H5 : navigator.mediaDevices.getUserMedia → AudioContext
|
||||||
|
* → ScriptProcessor 采 PCM Float32 → 停止时封装 WAV.
|
||||||
|
* 振幅采样走 AnalyserNode (与采 PCM 并行, 不冲突).
|
||||||
|
* - MP / APP / 鸿蒙: uni.getRecorderManager()(uni-app 鸿蒙 runtime 已映射)
|
||||||
|
* 走 onFrameRecorded 推 progress 事件.
|
||||||
|
* uni-app format 参数可指定 wav/mp3/aac.
|
||||||
|
*
|
||||||
|
* 统一事件:
|
||||||
|
* progress → { level: 0..1, duration: ms } 持续采样, 用于驱动波形动画
|
||||||
|
* error → Error 录音失败
|
||||||
|
*
|
||||||
|
* 统一结果 (stop() 解析):
|
||||||
|
* {
|
||||||
|
* filePath?: string, // MP/APP/鸿蒙: tempFilePath; H5: blob URL
|
||||||
|
* blob?: Blob, // H5: WAV Blob (供 FileReader / uploadMedia 二次处理)
|
||||||
|
* fileName?: string, // H5: voice_<ts>.wav; MP 按 uni 约定
|
||||||
|
* duration: number, // ms
|
||||||
|
* mime: string, // 录音产物 MIME: H5='audio/wav', MP/APP/鸿蒙按 format
|
||||||
|
* size: number, // bytes
|
||||||
|
* cancelled: false
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* 取消时 resolve(null);调用方拿到 null 时不要 push 到 pendingFiles。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DEFAULT_MAX_DURATION_MS = 60 * 1000
|
||||||
|
|
||||||
|
// PR-FRONTEND-VOICE-SINGLETON (2026-09-29): uni.getRecorderManager() / wx.getRecorderManager()
|
||||||
|
// 是全局单例, 且微信内部状态极易因上一次未完全停止而残留, 导致下一次 start() 报
|
||||||
|
// "operateRecorder:fail:audio is recording, don't start"。
|
||||||
|
// 这里用模块级 Promise 把所有 start/stop 串行化, 确保前一个实例 onStop/onError 完成后
|
||||||
|
// 才允许下一个实例 start()。
|
||||||
|
let _singletonIdlePromise = Promise.resolve()
|
||||||
|
let _singletonIdleResolver = null
|
||||||
|
let _singletonRec = null
|
||||||
|
let _singletonBusyInstance = null
|
||||||
|
|
||||||
|
function _getSingletonRecorder() {
|
||||||
|
if (!_singletonRec) {
|
||||||
|
_singletonRec = uni.getRecorderManager()
|
||||||
|
}
|
||||||
|
return _singletonRec
|
||||||
|
}
|
||||||
|
|
||||||
|
let _singletonIdleTimer = null
|
||||||
|
|
||||||
|
function _setSingletonBusy(instance, maxDurationMs = DEFAULT_MAX_DURATION_MS) {
|
||||||
|
if (_singletonIdleTimer) {
|
||||||
|
clearTimeout(_singletonIdleTimer)
|
||||||
|
_singletonIdleTimer = null
|
||||||
|
}
|
||||||
|
if (_singletonIdleResolver) {
|
||||||
|
// 已经有未释放的 busy promise, 理论上不应发生; 先强制释放避免死锁
|
||||||
|
_singletonIdleResolver()
|
||||||
|
_singletonIdleResolver = null
|
||||||
|
}
|
||||||
|
_singletonIdlePromise = new Promise((resolve) => {
|
||||||
|
_singletonIdleResolver = resolve
|
||||||
|
})
|
||||||
|
_singletonBusyInstance = instance
|
||||||
|
// 保险: 即使 onStop/onError 因异常没触发, 最多等 10s 也要释放,
|
||||||
|
// 避免死锁导致后续所有 start() 永远挂起。
|
||||||
|
_singletonIdleTimer = setTimeout(() => {
|
||||||
|
console.warn('[voice-recorder:singleton] idle timeout, force release')
|
||||||
|
_setSingletonIdle(instance)
|
||||||
|
}, Math.min(maxDurationMs + 5000, 10000))
|
||||||
|
}
|
||||||
|
|
||||||
|
function _setSingletonIdle(instance) {
|
||||||
|
if (_singletonIdleTimer) {
|
||||||
|
clearTimeout(_singletonIdleTimer)
|
||||||
|
_singletonIdleTimer = null
|
||||||
|
}
|
||||||
|
if (_singletonBusyInstance && _singletonBusyInstance !== instance) {
|
||||||
|
// 不是当前占用者释放, 忽略
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (_singletonIdleResolver) {
|
||||||
|
_singletonIdleResolver()
|
||||||
|
_singletonIdleResolver = null
|
||||||
|
}
|
||||||
|
_singletonIdlePromise = Promise.resolve()
|
||||||
|
_singletonBusyInstance = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// H5 录音参数: 16kHz mono PCM → 16-bit LE WAV
|
||||||
|
// 16kHz/16-bit 是大多数 STT 服务 (Whisper / 达摩院 / 腾讯 ASR 等) 最常见的输入规格.
|
||||||
|
const H5_SAMPLE_RATE = 16000
|
||||||
|
const H5_NUM_CHANNELS = 1
|
||||||
|
const H5_SCRIPT_PROCESSOR_BUFFER_SIZE = 4096
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造带 code 字段的 Error
|
||||||
|
* - code: 稳定的英文枚举, 供调用方按 code 走 $lang() 翻译
|
||||||
|
* - message: 兜底英文 message, 用于开发/调试或未识别 code
|
||||||
|
* 注意: voice-recorder.js 自身不持有 Vue 上下文, 翻译必须在调用方完成
|
||||||
|
*/
|
||||||
|
function _err(code, message) {
|
||||||
|
const e = new Error(message || code)
|
||||||
|
e.code = code
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测当前运行环境 (避免编译器把所有分支都打进去)
|
||||||
|
* - true / false 都是运行时结果;条件编译靠注释里的 #ifdef
|
||||||
|
*/
|
||||||
|
function isH5() {
|
||||||
|
// #ifdef H5
|
||||||
|
return true
|
||||||
|
// #endif
|
||||||
|
// #ifndef H5
|
||||||
|
return false
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMpOrApp() {
|
||||||
|
// #ifdef MP-WEIXIN || APP-PLUS || MP-HARMONY
|
||||||
|
return true
|
||||||
|
// #endif
|
||||||
|
// #ifndef MP-WEIXIN || APP-PLUS || MP-HARMONY
|
||||||
|
return false
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 由 mime 反推文件扩展名
|
||||||
|
*/
|
||||||
|
function mimeToExt(mime) {
|
||||||
|
if (!mime) return 'wav'
|
||||||
|
if (mime.indexOf('wav') !== -1) return 'wav'
|
||||||
|
if (mime.indexOf('webm') !== -1) return 'webm'
|
||||||
|
if (mime.indexOf('ogg') !== -1) return 'ogg'
|
||||||
|
if (mime.indexOf('mp4') !== -1) return 'm4a'
|
||||||
|
if (mime.indexOf('mpeg') !== -1) return 'mp3'
|
||||||
|
if (mime.indexOf('aac') !== -1) return 'aac'
|
||||||
|
return 'audio'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合并多个 Float32Array (PCM 帧缓冲) 为单个 Float32Array
|
||||||
|
*/
|
||||||
|
function mergeFloat32Arrays(arrays) {
|
||||||
|
let totalLength = 0
|
||||||
|
for (let i = 0; i < arrays.length; i++) totalLength += arrays[i].length
|
||||||
|
const out = new Float32Array(totalLength)
|
||||||
|
let offset = 0
|
||||||
|
for (let i = 0; i < arrays.length; i++) {
|
||||||
|
out.set(arrays[i], offset)
|
||||||
|
offset += arrays[i].length
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Float32 PCM (-1..1) 编码为标准 WAV (PCM 16-bit LE, RIFF/WAVE 头).
|
||||||
|
* 所有 STT 服务都吃这个格式; 浏览器 H5 录音不依赖 MediaRecorder 也能产出.
|
||||||
|
*
|
||||||
|
* 参考 RIFF/WAVE 规范:
|
||||||
|
* https://docs.fileformat.com/audio/wav/
|
||||||
|
* http://soundfile.sapp.org/doc/WaveFormat/
|
||||||
|
*/
|
||||||
|
function encodeWavPcm16LE(samples, sampleRate, numChannels) {
|
||||||
|
const bytesPerSample = 2 // 16-bit
|
||||||
|
const dataSize = samples.length * numChannels * bytesPerSample
|
||||||
|
const buffer = new ArrayBuffer(44 + dataSize)
|
||||||
|
const view = new DataView(buffer)
|
||||||
|
|
||||||
|
// RIFF chunk descriptor (12 bytes)
|
||||||
|
writeAscii(view, 0, 'RIFF')
|
||||||
|
view.setUint32(4, 36 + dataSize, true) // ChunkSize = 文件总字节 - 8
|
||||||
|
writeAscii(view, 8, 'WAVE')
|
||||||
|
|
||||||
|
// fmt subchunk (24 bytes)
|
||||||
|
writeAscii(view, 12, 'fmt ')
|
||||||
|
view.setUint32(16, 16, true) // Subchunk1Size for PCM = 16
|
||||||
|
view.setUint16(20, 1, true) // AudioFormat: 1 = PCM
|
||||||
|
view.setUint16(22, numChannels, true)
|
||||||
|
view.setUint32(24, sampleRate, true)
|
||||||
|
view.setUint32(28, sampleRate * numChannels * bytesPerSample, true) // ByteRate
|
||||||
|
view.setUint16(32, numChannels * bytesPerSample, true) // BlockAlign
|
||||||
|
view.setUint16(34, 16, true) // BitsPerSample
|
||||||
|
|
||||||
|
// data subchunk (8 + N bytes)
|
||||||
|
writeAscii(view, 36, 'data')
|
||||||
|
view.setUint32(40, dataSize, true)
|
||||||
|
|
||||||
|
// PCM 样本: Float32 (-1..1) → Int16 LE (clamp 到 [-1, 1] 防溢出)
|
||||||
|
let offset = 44
|
||||||
|
for (let i = 0; i < samples.length; i++) {
|
||||||
|
let s = samples[i]
|
||||||
|
if (s > 1) s = 1
|
||||||
|
else if (s < -1) s = -1
|
||||||
|
const int16 = s < 0 ? Math.round(s * 0x8000) : Math.round(s * 0x7FFF)
|
||||||
|
view.setInt16(offset, int16, true)
|
||||||
|
offset += 2
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeAscii(view, offset, str) {
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
view.setUint8(offset + i, str.charCodeAt(i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建录音器实例。
|
||||||
|
*
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {number} [opts.maxDuration=60000] 自动停止时长 (ms)
|
||||||
|
* @param {string} [opts.format] MP/APP/鸿蒙 'mp3'|'aac'|'wav' (uni-app 文档)
|
||||||
|
* @param {number} [opts.sampleRate=16000]
|
||||||
|
* @param {number} [opts.numberOfChannels=1]
|
||||||
|
* @param {number} [opts.encodeBitRate=48000]
|
||||||
|
*
|
||||||
|
* @returns {VoiceRecorder}
|
||||||
|
*/
|
||||||
|
export function createVoiceRecorder(opts = {}) {
|
||||||
|
return new VoiceRecorder(opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
class VoiceRecorder {
|
||||||
|
constructor(opts) {
|
||||||
|
this._maxDuration = opts.maxDuration || DEFAULT_MAX_DURATION_MS
|
||||||
|
// PR-FRONTEND-VOICE-FORMAT (2026-09-29): 微信小程序在部分 Android 机型上对 mp3/aac 兼容性差,
|
||||||
|
// 表现为一启动就 onStop(duration~100ms)。wav 是无压缩格式, 兼容性最好, 优先使用。
|
||||||
|
this._format = opts.format || 'wav'
|
||||||
|
this._sampleRate = opts.sampleRate || 16000
|
||||||
|
this._channels = opts.numberOfChannels || 1
|
||||||
|
this._bitRate = opts.encodeBitRate || 48000
|
||||||
|
|
||||||
|
this._listeners = { progress: [], error: [] }
|
||||||
|
this._isRecording = false
|
||||||
|
this._isStarting = false // start() 已调用, 但 onStart 尚未回调
|
||||||
|
this._stopResolve = null // 当前 stop() 等待 onStop 解析的 resolve
|
||||||
|
this._pendingStop = false // onStart 之前就调用了 stop() → 等开始后立即停
|
||||||
|
this._startTs = 0
|
||||||
|
this._id = Math.random().toString(36).slice(2, 8)
|
||||||
|
this._autoStopTimer = null
|
||||||
|
this._stopFallbackTimer = null // PR-FRONTEND-VOICE-FALLBACK: stop()/cancel() 兜底定时器
|
||||||
|
this._earlyStopResult = null // PR-FRONTEND-VOICE-RACE: onStop 在 stop() 之前触发时缓存结果
|
||||||
|
|
||||||
|
// 平台相关句柄
|
||||||
|
this._h5 = null // { mediaRecorder, stream, audioCtx, analyser, chunks, mime, rafId }
|
||||||
|
this._uniRec = null // uni.getRecorderManager() 返回值
|
||||||
|
console.log(`[voice-recorder:${this._id}] created`)
|
||||||
|
}
|
||||||
|
|
||||||
|
on(event, cb) {
|
||||||
|
if (!this._listeners[event]) this._listeners[event] = []
|
||||||
|
this._listeners[event].push(cb)
|
||||||
|
}
|
||||||
|
|
||||||
|
off(event, cb) {
|
||||||
|
const arr = this._listeners[event]
|
||||||
|
if (!arr) return
|
||||||
|
const idx = arr.indexOf(cb)
|
||||||
|
if (idx !== -1) arr.splice(idx, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
_emit(event, payload) {
|
||||||
|
const arr = this._listeners[event]
|
||||||
|
if (!arr) return
|
||||||
|
for (const cb of arr.slice()) {
|
||||||
|
try { cb(payload) } catch (e) { /* swallow */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get isRecording() { return this._isRecording }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始录音。
|
||||||
|
* resolve() 仅表示「启动成功」,真正的产物在 stop()/cancel() 中拿到。
|
||||||
|
*/
|
||||||
|
start() {
|
||||||
|
if (this._isRecording) {
|
||||||
|
return Promise.reject(_err('ALREADY_STARTED', 'recorder already started'))
|
||||||
|
}
|
||||||
|
if (this._isStarting) {
|
||||||
|
return Promise.reject(_err('ALREADY_STARTED', 'recorder is starting'))
|
||||||
|
}
|
||||||
|
|
||||||
|
this._isStarting = true
|
||||||
|
let p = null
|
||||||
|
if (isH5()) {
|
||||||
|
p = this._startH5()
|
||||||
|
} else if (isMpOrApp()) {
|
||||||
|
p = this._startUni()
|
||||||
|
} else {
|
||||||
|
this._isStarting = false
|
||||||
|
return Promise.reject(_err('NOT_SUPPORTED', 'voice input not supported on this platform'))
|
||||||
|
}
|
||||||
|
// 启动失败时复位状态, 避免后续 stop()/cancel() 一直挂起
|
||||||
|
p.catch((e) => {
|
||||||
|
this._isStarting = false
|
||||||
|
return Promise.reject(e)
|
||||||
|
})
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止录音并返回产物。
|
||||||
|
* resolve(null) 表示已取消 / 无产物,调用方应忽略。
|
||||||
|
*/
|
||||||
|
stop() {
|
||||||
|
console.log(`[voice-recorder:${this._id}] stop() called`, { isRecording: this._isRecording, isStarting: this._isStarting, hasUniRec: !!this._uniRec, hasStopResolve: !!this._stopResolve, earlyResult: !!this._earlyStopResult })
|
||||||
|
if (!this._isRecording) {
|
||||||
|
// PR-FRONTEND-VOICE-RACE (2026-09-29): 真机常见"按住即松手"——
|
||||||
|
// onStart 尚未回调 (录音未真正开始) 就调用 stop(), 原逻辑直接返回 null
|
||||||
|
// 导致上层判定"录音失败,请重试"。这里改为挂起, 等 onStart 触发后立即停,
|
||||||
|
// 产物仍走 onStop 正常返回 (太短的音轨后端可自行拒绝)。
|
||||||
|
if (this._isStarting) {
|
||||||
|
console.log(`[voice-recorder:${this._id}] stop() deferred: waiting for onStart`)
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
this._pendingStop = true
|
||||||
|
this._stopResolve = resolve
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// onStop 在 stop() 之前触发, 且有有效产物则返回缓存结果
|
||||||
|
if (this._earlyStopResult) {
|
||||||
|
const r = this._earlyStopResult
|
||||||
|
this._earlyStopResult = null
|
||||||
|
console.log(`[voice-recorder:${this._id}] stop() returning early onStop result`)
|
||||||
|
return Promise.resolve(r)
|
||||||
|
}
|
||||||
|
console.log(`[voice-recorder:${this._id}] stop() early return null (not recording)`)
|
||||||
|
return Promise.resolve(null)
|
||||||
|
}
|
||||||
|
if (isH5()) return this._stopH5()
|
||||||
|
return this._stopUni()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 中断录音并丢弃产物。
|
||||||
|
*/
|
||||||
|
cancel() {
|
||||||
|
if (this._isStarting) {
|
||||||
|
// PR-FRONTEND-VOICE-CANCEL-RACE (2026-09-29): onStart 尚未触发就取消,
|
||||||
|
// 不能立即返回, 否则录音器启动后会继续录制。挂起 pendingStop,
|
||||||
|
// 等 onStart 触发后立即 stop(), 产物被丢弃。
|
||||||
|
console.log(`[voice-recorder:${this._id}] cancel() deferred: waiting for onStart`)
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
this._pendingStop = true
|
||||||
|
this._stopResolve = () => resolve()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (!this._isRecording) {
|
||||||
|
this._clearAutoStopTimer()
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
if (isH5()) return this._cancelH5()
|
||||||
|
return this._cancelUni()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== H5 实现 =====================
|
||||||
|
// PR-FRONTEND-VOICE-WAV (2026-09-25): 改为 AudioContext + ScriptProcessor 采 PCM,
|
||||||
|
// 停止时手动封装标准 WAV. 不再依赖 MediaRecorder, 解决 Chrome 只能产 webm/opus
|
||||||
|
// 导致后端 STT / Agent framework 无法解析的问题.
|
||||||
|
//
|
||||||
|
// 关键设计:
|
||||||
|
// 1. PCM 采样率固定 16kHz mono, 浏览器会自动 resample 麦克风输入
|
||||||
|
// (实际由 AudioContext sampleRate 参数保证)
|
||||||
|
// 2. ScriptProcessor 必须 connect 到 audioCtx.destination 才会触发
|
||||||
|
// onaudioprocess; 通过 GainNode(gain=0) 静音防止麦克风外放回声
|
||||||
|
// 3. 振幅采样走 AnalyserNode (与 PCM 采样并行, 不冲突)
|
||||||
|
// 4. onaudioprocess 每帧必须拷贝 channelData (new Float32Array(input)),
|
||||||
|
// 否则下一帧会覆盖导致 PCM 数据丢失
|
||||||
|
|
||||||
|
_startH5() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (typeof navigator === 'undefined' || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||||
|
reject(_err('H5_NO_MIC_API', 'mic API not available (HTTPS or localhost required)'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const Ctx = window.AudioContext || window.webkitAudioContext
|
||||||
|
if (!Ctx) {
|
||||||
|
reject(_err('H5_NO_AUDIO_CTX', 'AudioContext not available'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffers = []
|
||||||
|
let stream = null
|
||||||
|
let audioCtx = null
|
||||||
|
let source = null
|
||||||
|
let processor = null
|
||||||
|
let analyser = null
|
||||||
|
let silentGain = null
|
||||||
|
let rafId = null
|
||||||
|
|
||||||
|
// 清理 H5 资源 (失败 / 异常路径统一调用)
|
||||||
|
const cleanup = () => {
|
||||||
|
try { if (rafId) cancelAnimationFrame(rafId) } catch (e) { /* noop */ }
|
||||||
|
try { if (processor) processor.disconnect() } catch (e) { /* noop */ }
|
||||||
|
try { if (analyser) analyser.disconnect() } catch (e) { /* noop */ }
|
||||||
|
try { if (source) source.disconnect() } catch (e) { /* noop */ }
|
||||||
|
try { if (silentGain) silentGain.disconnect() } catch (e) { /* noop */ }
|
||||||
|
try { if (audioCtx && audioCtx.state !== 'closed') audioCtx.close().catch(() => {}) } catch (e) { /* noop */ }
|
||||||
|
try { if (stream) stream.getTracks().forEach(t => t.stop()) } catch (e) { /* noop */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: {
|
||||||
|
channelCount: H5_NUM_CHANNELS,
|
||||||
|
// 注意: 部分浏览器会忽略 sampleRate 约束, 实际采样率以 AudioContext 为准
|
||||||
|
sampleRate: H5_SAMPLE_RATE,
|
||||||
|
echoCancellation: true,
|
||||||
|
noiseSuppression: true,
|
||||||
|
autoGainControl: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then((s) => {
|
||||||
|
stream = s
|
||||||
|
audioCtx = new Ctx({ sampleRate: H5_SAMPLE_RATE })
|
||||||
|
|
||||||
|
// 麦克风 → ScriptProcessor → 静音 GainNode → destination
|
||||||
|
// (ScriptProcessor 必须连到 destination 才会触发 onaudioprocess;
|
||||||
|
// 用 GainNode(gain=0) 静音, 防止麦克风声音外放产生回声)
|
||||||
|
source = audioCtx.createMediaStreamSource(stream)
|
||||||
|
const BufferSize = H5_SCRIPT_PROCESSOR_BUFFER_SIZE
|
||||||
|
processor = audioCtx.createScriptProcessor(BufferSize, H5_NUM_CHANNELS, H5_NUM_CHANNELS)
|
||||||
|
processor.onaudioprocess = (e) => {
|
||||||
|
const channelData = e.inputBuffer.getChannelData(0)
|
||||||
|
// 必须拷贝: inputBuffer 数据会被下一帧覆盖, 不拷贝会导致 PCM 丢失
|
||||||
|
buffers.push(new Float32Array(channelData))
|
||||||
|
}
|
||||||
|
silentGain = audioCtx.createGain()
|
||||||
|
silentGain.gain.value = 0
|
||||||
|
source.connect(processor)
|
||||||
|
processor.connect(silentGain)
|
||||||
|
silentGain.connect(audioCtx.destination)
|
||||||
|
|
||||||
|
// AnalyserNode 并行采振幅 (驱动波形动画)
|
||||||
|
analyser = audioCtx.createAnalyser()
|
||||||
|
analyser.fftSize = 256
|
||||||
|
source.connect(analyser)
|
||||||
|
const freqBuf = new Uint8Array(analyser.frequencyBinCount)
|
||||||
|
const tick = () => {
|
||||||
|
if (!this._isRecording) return
|
||||||
|
try {
|
||||||
|
analyser.getByteFrequencyData(freqBuf)
|
||||||
|
let sum = 0
|
||||||
|
for (let i = 0; i < freqBuf.length; i++) sum += freqBuf[i]
|
||||||
|
const level = Math.min(1, sum / (freqBuf.length * 255))
|
||||||
|
this._emit('progress', { level, duration: Date.now() - this._startTs })
|
||||||
|
} catch (e) { /* noop */ }
|
||||||
|
rafId = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
tick()
|
||||||
|
|
||||||
|
this._h5 = { stream, audioCtx, source, processor, analyser, silentGain, rafId, buffers, sampleRate: audioCtx.sampleRate, cleanup }
|
||||||
|
this._isRecording = true
|
||||||
|
this._startTs = Date.now()
|
||||||
|
this._setAutoStopTimer()
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
// 麦克风权限拒绝 / 设备不可用
|
||||||
|
cleanup()
|
||||||
|
reject(_err('H5_MIC_PERMISSION', (err && err.message) || 'mic permission denied or device unavailable'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_stopH5() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const h5 = this._h5
|
||||||
|
if (!h5) { resolve(null); return }
|
||||||
|
|
||||||
|
// 1) 拆 audio graph + 停流 (避免 onprocess 继续累积 buffer)
|
||||||
|
try { h5.cleanup() } catch (e) { /* noop */ }
|
||||||
|
|
||||||
|
// 2) 合并 PCM 帧缓冲
|
||||||
|
const sampleRate = h5.sampleRate || H5_SAMPLE_RATE
|
||||||
|
const merged = mergeFloat32Arrays(h5.buffers || [])
|
||||||
|
|
||||||
|
// 3) 编码标准 WAV (PCM 16-bit LE mono)
|
||||||
|
const wavBuffer = encodeWavPcm16LE(merged, sampleRate, H5_NUM_CHANNELS)
|
||||||
|
const blob = new Blob([wavBuffer], { type: 'audio/wav' })
|
||||||
|
|
||||||
|
this._h5 = null
|
||||||
|
this._isRecording = false
|
||||||
|
this._clearAutoStopTimer()
|
||||||
|
|
||||||
|
if (merged.length === 0) {
|
||||||
|
// 没有采到任何 PCM (例如用户没说话或立即 stop)
|
||||||
|
resolve(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = `voice_${Date.now()}.wav`
|
||||||
|
const blobUrl = (typeof URL !== 'undefined' && URL.createObjectURL)
|
||||||
|
? URL.createObjectURL(blob)
|
||||||
|
: ''
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
filePath: blobUrl,
|
||||||
|
blob,
|
||||||
|
fileName,
|
||||||
|
duration: Date.now() - this._startTs,
|
||||||
|
mime: 'audio/wav',
|
||||||
|
size: blob.size,
|
||||||
|
cancelled: false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_cancelH5() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const h5 = this._h5
|
||||||
|
if (!h5) {
|
||||||
|
this._isRecording = false
|
||||||
|
this._clearAutoStopTimer()
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try { h5.cleanup() } catch (e) { /* noop */ }
|
||||||
|
this._h5 = null
|
||||||
|
this._isRecording = false
|
||||||
|
this._clearAutoStopTimer()
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== uni-app (MP/APP/鸿蒙) 实现 =====================
|
||||||
|
|
||||||
|
_startUni() {
|
||||||
|
// PR-FRONTEND-VOICE-SINGLETON: 串行化所有对微信录音单例的访问,
|
||||||
|
// 防止"上一个 onStop 还没触发, 下一个 start() 就报 audio is recording"。
|
||||||
|
return _singletonIdlePromise.then(() => {
|
||||||
|
// 防竞态: 注册 .then() 后到回调执行前, 可能有其他实例先占用了单例, 需要重新排队。
|
||||||
|
if (_singletonBusyInstance && _singletonBusyInstance !== this) {
|
||||||
|
console.log(`[voice-recorder:${this._id}] _startUni: singleton busy by ${_singletonBusyInstance._id}, re-queue`)
|
||||||
|
return this._startUni()
|
||||||
|
}
|
||||||
|
_setSingletonBusy(this, this._maxDuration)
|
||||||
|
console.log(`[voice-recorder:${this._id}] _startUni: singleton busy acquired`)
|
||||||
|
return this._doStartUni()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_doStartUni() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let rec = null
|
||||||
|
try {
|
||||||
|
rec = _getSingletonRecorder()
|
||||||
|
} catch (e) {
|
||||||
|
_setSingletonIdle(this)
|
||||||
|
reject(_err('NO_RECORDER_MANAGER', 'uni.getRecorderManager unavailable'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!rec) {
|
||||||
|
_setSingletonIdle(this)
|
||||||
|
reject(_err('NO_RECORDER_MANAGER', 'recorder manager unavailable'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this._uniRec = rec
|
||||||
|
this._isStarting = true
|
||||||
|
console.log(`[voice-recorder:${this._id}] _startUni: start() invoked on singleton`)
|
||||||
|
|
||||||
|
rec.onStart(() => {
|
||||||
|
console.log(`[voice-recorder:${this._id}] onStart fired`)
|
||||||
|
this._isRecording = true
|
||||||
|
this._isStarting = false
|
||||||
|
this._startTs = Date.now()
|
||||||
|
this._setAutoStopTimer()
|
||||||
|
if (this._pendingStop) {
|
||||||
|
// 用户在录音真正开始前就松手: 立即结束, 产物经 onStop 返回
|
||||||
|
console.log(`[voice-recorder:${this._id}] onStart: pendingStop detected, calling rec.stop()`)
|
||||||
|
this._pendingStop = false
|
||||||
|
try { rec.stop() } catch (e) { /* onStop 自会兜底 */ }
|
||||||
|
}
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
|
||||||
|
rec.onStop((res) => {
|
||||||
|
// res = { tempFilePath, duration, fileSize }
|
||||||
|
console.log(`[voice-recorder:${this._id}] onStop fired`, res, 'hasStopResolve=', !!this._stopResolve)
|
||||||
|
this._clearStopFallbackTimer()
|
||||||
|
this._uniRec = null
|
||||||
|
this._isRecording = false
|
||||||
|
this._isStarting = false
|
||||||
|
this._clearAutoStopTimer()
|
||||||
|
// PR-FRONTEND-VOICE-FORMAT (2026-09-29): 微信临时文件后缀才是真实容器格式:
|
||||||
|
// Android aac → .m4a, iOS aac → .aac, mp3 → .mp3。按实际后缀给 mime/文件名,
|
||||||
|
// 避免播放器/后端因后缀与容器不符而失败。
|
||||||
|
const tempPath = (res && res.tempFilePath) || ''
|
||||||
|
const rawExt = (tempPath.split('?')[0].split('/').pop().match(/\.([a-z0-9]+)$/i) || [])[1] || ''
|
||||||
|
const ext = rawExt.toLowerCase()
|
||||||
|
const mime = ext === 'm4a' ? 'audio/mp4'
|
||||||
|
: ext === 'aac' ? 'audio/aac'
|
||||||
|
: ext === 'mp3' ? 'audio/mpeg'
|
||||||
|
: ext === 'wav' ? 'audio/wav'
|
||||||
|
: (this._format === 'wav' ? 'audio/wav'
|
||||||
|
: this._format === 'aac' ? 'audio/aac'
|
||||||
|
: 'audio/mpeg')
|
||||||
|
// PR-FRONTEND-VOICE-DURATION-FIX (2026-09-29): 微信 onStop 的 duration
|
||||||
|
// 偶尔为 0, 用 start→stop 真实时长兜底, 避免下游按 duration=0 丢弃。
|
||||||
|
const realDuration = this._startTs ? Date.now() - this._startTs : 0
|
||||||
|
const reportedDuration = (res && typeof res.duration === 'number') ? res.duration : 0
|
||||||
|
const duration = reportedDuration > 0 ? reportedDuration : realDuration
|
||||||
|
const result = {
|
||||||
|
filePath: tempPath,
|
||||||
|
blob: null,
|
||||||
|
fileName: tempPath ? `voice_${Date.now()}.${ext || 'aac'}` : '',
|
||||||
|
duration,
|
||||||
|
mime,
|
||||||
|
size: res.fileSize || 0,
|
||||||
|
cancelled: false
|
||||||
|
}
|
||||||
|
if (this._stopResolve) {
|
||||||
|
const r = this._stopResolve
|
||||||
|
this._stopResolve = null
|
||||||
|
r(result)
|
||||||
|
} else if (result.filePath && result.duration > 0) {
|
||||||
|
// PR-FRONTEND-VOICE-RACE: onStop 在 stop() 之前触发且产物有效, 缓存起来
|
||||||
|
this._earlyStopResult = result
|
||||||
|
}
|
||||||
|
_setSingletonIdle(this)
|
||||||
|
})
|
||||||
|
|
||||||
|
rec.onError((err) => {
|
||||||
|
this._clearStopFallbackTimer()
|
||||||
|
const e = _err('UNI_RECORD_ERROR', (err && (err.errMsg || err.message)) || 'record failed')
|
||||||
|
console.error(`[voice-recorder:${this._id}] UNI_RECORD_ERROR raw:`, err, 'errMsg=', err && err.errMsg, 'message=', err && err.message)
|
||||||
|
this._uniRec = null
|
||||||
|
this._isRecording = false
|
||||||
|
this._isStarting = false
|
||||||
|
this._clearAutoStopTimer()
|
||||||
|
this._emit('error', e)
|
||||||
|
if (this._stopResolve) {
|
||||||
|
const r = this._stopResolve
|
||||||
|
this._stopResolve = null
|
||||||
|
r(null)
|
||||||
|
} else {
|
||||||
|
// 启动阶段的 reject
|
||||||
|
reject(e)
|
||||||
|
}
|
||||||
|
_setSingletonIdle(this)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 振幅事件 (uni-app 文档:onFrameRecorded, frameSize 单位 KB, frameBuffer ArrayBuffer)
|
||||||
|
if (typeof rec.onFrameRecorded === 'function') {
|
||||||
|
rec.onFrameRecorded((res) => {
|
||||||
|
if (!res || !res.frameBuffer) return
|
||||||
|
// frameBuffer 是 PCM 字节,简单用 |sample| / 0x8000 取平均振幅
|
||||||
|
try {
|
||||||
|
const view = new DataView(res.frameBuffer)
|
||||||
|
let sum = 0
|
||||||
|
const len = res.frameBuffer.byteLength
|
||||||
|
const step = Math.max(2, Math.floor(len / 256))
|
||||||
|
for (let i = 0; i < len; i += step) {
|
||||||
|
const v = view.getInt16(i, true)
|
||||||
|
sum += Math.abs(v)
|
||||||
|
}
|
||||||
|
const avg = sum / Math.max(1, Math.floor(len / step))
|
||||||
|
const level = Math.min(1, avg / 16384)
|
||||||
|
this._emit('progress', { level, duration: Date.now() - this._startTs })
|
||||||
|
} catch (e) { /* noop */ }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
rec.start({
|
||||||
|
// PR-FRONTEND-VOICE-DURATION-UNIT (2026-09-29): 微信 RecorderManager.start 的 duration
|
||||||
|
// 单位是毫秒(ms), 不是秒。之前 /1000 导致只录 60ms 就被自动停止。
|
||||||
|
duration: this._maxDuration,
|
||||||
|
sampleRate: this._sampleRate,
|
||||||
|
numberOfChannels: this._channels,
|
||||||
|
encodeBitRate: this._bitRate,
|
||||||
|
format: this._format
|
||||||
|
// PR-FRONTEND-VOICE-FRAMESIZE (2026-09-29): 去掉 frameSize, 部分机型对此参数敏感,
|
||||||
|
// 可能导致录音被提前中断。
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
this._clearStopFallbackTimer()
|
||||||
|
this._uniRec = null
|
||||||
|
this._isRecording = false
|
||||||
|
this._isStarting = false
|
||||||
|
this._clearAutoStopTimer()
|
||||||
|
_setSingletonIdle(this)
|
||||||
|
// 启动阶段就被 stop()/cancel() 抢占, 需要 Fulfill 挂起的 Promise, 而不是 reject
|
||||||
|
if (this._stopResolve && this._pendingStop) {
|
||||||
|
const r = this._stopResolve
|
||||||
|
this._stopResolve = null
|
||||||
|
this._pendingStop = false
|
||||||
|
r(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reject(e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_stopUni() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
console.log(`[voice-recorder:${this._id}] _stopUni called`)
|
||||||
|
const rec = this._uniRec
|
||||||
|
if (!rec) { resolve(null); return }
|
||||||
|
this._earlyStopResult = null
|
||||||
|
this._stopResolve = resolve
|
||||||
|
this._clearStopFallbackTimer()
|
||||||
|
// PR-FRONTEND-VOICE-FALLBACK: 真机偶发 onStop 不回调, 导致 stop() 永远挂起,
|
||||||
|
// UI 无法释放旧实例, 新录音提示"recorder already exists"。最多等 3s 强制 resolve。
|
||||||
|
this._stopFallbackTimer = setTimeout(() => {
|
||||||
|
console.warn(`[voice-recorder:${this._id}] _stopUni fallback timeout, force resolve`)
|
||||||
|
this._stopResolve = null
|
||||||
|
this._pendingStop = false
|
||||||
|
this._isRecording = false
|
||||||
|
this._isStarting = false
|
||||||
|
this._clearAutoStopTimer()
|
||||||
|
this._uniRec = null
|
||||||
|
_setSingletonIdle(this)
|
||||||
|
resolve(null)
|
||||||
|
}, 3000)
|
||||||
|
try { rec.stop() } catch (e) {
|
||||||
|
this._clearStopFallbackTimer()
|
||||||
|
this._stopResolve = null
|
||||||
|
this._isRecording = false
|
||||||
|
this._isStarting = false
|
||||||
|
this._clearAutoStopTimer()
|
||||||
|
this._uniRec = null
|
||||||
|
_setSingletonIdle(this)
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_cancelUni() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
console.log(`[voice-recorder:${this._id}] _cancelUni called`)
|
||||||
|
this._earlyStopResult = null
|
||||||
|
const rec = this._uniRec
|
||||||
|
if (!rec) {
|
||||||
|
// 挂起的 stop 需要一并了结, 避免松手后卡在 pending
|
||||||
|
if (this._stopResolve && this._pendingStop) {
|
||||||
|
const r = this._stopResolve
|
||||||
|
this._stopResolve = null
|
||||||
|
this._pendingStop = false
|
||||||
|
r(null)
|
||||||
|
}
|
||||||
|
this._isRecording = false
|
||||||
|
this._isStarting = false
|
||||||
|
this._clearAutoStopTimer()
|
||||||
|
_setSingletonIdle(this)
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this._stopResolve = () => resolve()
|
||||||
|
this._pendingStop = false
|
||||||
|
this._clearStopFallbackTimer()
|
||||||
|
// PR-FRONTEND-VOICE-FALLBACK: cancel() 同样可能因 onStop 不回调而挂起。
|
||||||
|
this._stopFallbackTimer = setTimeout(() => {
|
||||||
|
console.warn(`[voice-recorder:${this._id}] _cancelUni fallback timeout, force resolve`)
|
||||||
|
this._stopResolve = null
|
||||||
|
this._isRecording = false
|
||||||
|
this._isStarting = false
|
||||||
|
this._clearAutoStopTimer()
|
||||||
|
this._uniRec = null
|
||||||
|
_setSingletonIdle(this)
|
||||||
|
resolve()
|
||||||
|
}, 3000)
|
||||||
|
try { rec.stop() } catch (e) {
|
||||||
|
this._clearStopFallbackTimer()
|
||||||
|
this._isRecording = false
|
||||||
|
this._isStarting = false
|
||||||
|
this._clearAutoStopTimer()
|
||||||
|
this._uniRec = null
|
||||||
|
_setSingletonIdle(this)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== 工具 =====================
|
||||||
|
|
||||||
|
_setAutoStopTimer() {
|
||||||
|
this._clearAutoStopTimer()
|
||||||
|
this._autoStopTimer = setTimeout(() => {
|
||||||
|
// 自动停止 (上限到达)
|
||||||
|
if (this._isRecording) {
|
||||||
|
this.stop().catch(() => { /* swallow */ })
|
||||||
|
}
|
||||||
|
}, this._maxDuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearAutoStopTimer() {
|
||||||
|
if (this._autoStopTimer) {
|
||||||
|
clearTimeout(this._autoStopTimer)
|
||||||
|
this._autoStopTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearStopFallbackTimer() {
|
||||||
|
if (this._stopFallbackTimer) {
|
||||||
|
clearTimeout(this._stopFallbackTimer)
|
||||||
|
this._stopFallbackTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createVoiceRecorder
|
||||||
499
components-diy/diy-webview.vue
Normal file
499
components-diy/diy-webview.vue
Normal file
@@ -0,0 +1,499 @@
|
|||||||
|
<template>
|
||||||
|
<view data-component-name="diy-webview" class="diy-webview" :style="containerStyle" @tap="handleTap">
|
||||||
|
<template v-if="src">
|
||||||
|
<!-- #ifdef H5 -->
|
||||||
|
<iframe
|
||||||
|
v-if="!lazyPending"
|
||||||
|
class="diy-webview-iframe"
|
||||||
|
:src="src"
|
||||||
|
:scrolling="scrollMode"
|
||||||
|
referrerpolicy="no-referrer"
|
||||||
|
frameborder="0"
|
||||||
|
allow="fullscreen"
|
||||||
|
loading="lazy"
|
||||||
|
@load="handleIframeLoad"
|
||||||
|
@error="handleIframeError"
|
||||||
|
></iframe>
|
||||||
|
<!-- 后台配置的占位图 / 懒加载未进入视口时的展示 -->
|
||||||
|
<image
|
||||||
|
v-if="placeholderUrl && (lazyPending || !loaded)"
|
||||||
|
class="diy-webview-placeholder"
|
||||||
|
:src="placeholderUrl"
|
||||||
|
mode="aspectFill"
|
||||||
|
></image>
|
||||||
|
<!-- 加载中遮罩:iframe 触发 load 前展示,避免白屏观感 -->
|
||||||
|
<view v-if="!loaded && !loadError && !lazyPending" class="webview-loading" :style="errorStyle">
|
||||||
|
<view class="webview-loading-spinner"></view>
|
||||||
|
<text class="webview-loading-text">网页加载中...</text>
|
||||||
|
</view>
|
||||||
|
<!-- H5 下 iframe 内部点击不会冒泡到父容器,提供角标按钮保证"点击跳转"可用 -->
|
||||||
|
<view class="webview-open-chip" @tap.stop="mpGo">
|
||||||
|
<text class="iconfont icon-fuzhilianjie"></text>
|
||||||
|
<text class="webview-open-chip-text">{{ mpGoText }}</text>
|
||||||
|
</view>
|
||||||
|
<!-- #endif -->
|
||||||
|
|
||||||
|
<!-- #ifndef H5 -->
|
||||||
|
<!-- 小程序/APP:内嵌 web-view 会整页覆盖原生层,改为卡片展示 + 点击跳转 -->
|
||||||
|
<view class='webview-mp-go' :style='mpGoStyle' @tap.stop='mpGo'>
|
||||||
|
<text class='iconfont icon-fuzhilianjie'></text>
|
||||||
|
<text class='webview-mp-go-text'>{{ mpGoText }}</text>
|
||||||
|
</view>
|
||||||
|
<!-- #endif -->
|
||||||
|
|
||||||
|
<!-- 加载失败兜底(如目标站禁止被嵌入/内核过旧加载失败):可点击直接打开 -->
|
||||||
|
<view v-if="loadError" class="webview-error" :style="errorStyle" @tap.stop="mpGo">
|
||||||
|
<text class="iconfont icon-fuzhilianjie"></text>
|
||||||
|
<text class="error-text">{{ errorText }}</text>
|
||||||
|
<text class="error-open">点击此处打开网页</text>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<view v-else class="webview-empty" :style="emptyStyle">
|
||||||
|
<text class="iconfont icon-fuzhilianjie"></text>
|
||||||
|
<text class="empty-text">请配置网页链接</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 网页/WebView 组件:后台配置 URL 后嵌入展示,H5 使用 iframe,小程序/APP 使用原生 web-view
|
||||||
|
import DiyMinx from './minx.js'
|
||||||
|
export default {
|
||||||
|
name: 'diy-webview',
|
||||||
|
props: {
|
||||||
|
value: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
src: '',
|
||||||
|
loaded: false,
|
||||||
|
loadError: false,
|
||||||
|
// 后台开启"懒加载"时,iframe 需等容器滚动进入视口后才真正渲染
|
||||||
|
lazyPending: false
|
||||||
|
};
|
||||||
|
},
|
||||||
|
mixins: [DiyMinx],
|
||||||
|
created() {
|
||||||
|
this.initWebview();
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this._mountedFlag = true;
|
||||||
|
// #ifdef H5
|
||||||
|
if (this.lazyPending) this._setupLazyObserver();
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
if (this._observer) {
|
||||||
|
try { this._observer.disconnect(); } catch (e) {}
|
||||||
|
this._observer = null;
|
||||||
|
}
|
||||||
|
this._clearLoadTimer();
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
value: {
|
||||||
|
immediate: true,
|
||||||
|
deep: true,
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.initWebview();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
containerStyle() {
|
||||||
|
let style = '';
|
||||||
|
// 后台高度为 px 值,与项目其他组件一致 ×2 转 rpx(diy-horz-blank 同款换算)
|
||||||
|
const height = Number(this.value?.height) || 300;
|
||||||
|
style += `height:${height * 2}rpx;`;
|
||||||
|
// 组件角度 round 时按后台圆角值渲染(×2 rpx)
|
||||||
|
if (this.value?.componentAngle === 'round') {
|
||||||
|
const topR = Number(this.value?.topAroundRadius) || 0;
|
||||||
|
const bottomR = Number(this.value?.bottomAroundRadius) || 0;
|
||||||
|
style += `border-radius:${topR * 2}rpx ${topR * 2}rpx ${bottomR * 2}rpx ${bottomR * 2}rpx;`;
|
||||||
|
}
|
||||||
|
if (this.value?.bgColor) {
|
||||||
|
style += `background:${this.value.bgColor};`;
|
||||||
|
}
|
||||||
|
if (this.value?.marginTop) {
|
||||||
|
style += `margin-top:${this.value.marginTop}rpx;`;
|
||||||
|
}
|
||||||
|
if (this.value?.marginBottom) {
|
||||||
|
style += `margin-bottom:${this.value.marginBottom}rpx;`;
|
||||||
|
}
|
||||||
|
if (this.value?.padding) {
|
||||||
|
style += `padding:${this.value.padding}rpx;`;
|
||||||
|
}
|
||||||
|
return style;
|
||||||
|
},
|
||||||
|
emptyStyle() {
|
||||||
|
let style = '';
|
||||||
|
if (this.value?.bgColor) {
|
||||||
|
style += `background:${this.value.bgColor};`;
|
||||||
|
}
|
||||||
|
return style;
|
||||||
|
},
|
||||||
|
errorStyle() {
|
||||||
|
let style = '';
|
||||||
|
if (this.value?.bgColor) {
|
||||||
|
style += `background:${this.value.bgColor};`;
|
||||||
|
}
|
||||||
|
return style;
|
||||||
|
},
|
||||||
|
mpGoText() {
|
||||||
|
// 注意:this.$lang(key) 在 key 不存在时返回 key 字符串本身,不能仅靠 this.$lang 是否存在判断。
|
||||||
|
// 取到 key 与传入 key 不同才视为命中语言包,否则用 fallback。
|
||||||
|
const t = this.$lang && this.$lang('components-diy.diy-webview.mpGo');
|
||||||
|
return (t && t !== 'components-diy.diy-webview.mpGo') ? t : '点击查看网页内容';
|
||||||
|
},
|
||||||
|
mpGoStyle() {
|
||||||
|
let style = '';
|
||||||
|
if (this.value && this.value.bgColor) {
|
||||||
|
style += 'background:' + this.value.bgColor + ';';
|
||||||
|
}
|
||||||
|
return style;
|
||||||
|
},
|
||||||
|
errorText() {
|
||||||
|
const t = this.$lang && this.$lang('components-diy.diy-webview.loadError');
|
||||||
|
return (t && t !== 'components-diy.diy-webview.loadError') ? t : '网页加载失败,请检查链接';
|
||||||
|
},
|
||||||
|
// iframe 滚动条开关(后台"滚动"配置:true 显示 / false 隐藏)
|
||||||
|
scrollMode() {
|
||||||
|
const s = this.value?.scrolling;
|
||||||
|
return (s === false || s === 'false' || s === 0) ? 'no' : 'yes';
|
||||||
|
},
|
||||||
|
// 占位图(后台 imageUrl,iframe 加载完成前展示)
|
||||||
|
placeholderUrl() {
|
||||||
|
const url = this.value?.imageUrl;
|
||||||
|
return url ? (this.$util.img ? this.$util.img(url) : url) : '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
initWebview() {
|
||||||
|
const config = this.value || {};
|
||||||
|
this.loadError = false;
|
||||||
|
this.loaded = false;
|
||||||
|
// 调试用:把后台下发的完整 value 对象打印到控制台,方便定位 URL 字段名
|
||||||
|
console.log('[diy-webview] value:', JSON.stringify(config));
|
||||||
|
// 兼容后台可能使用的多种链接字段名/嵌套结构
|
||||||
|
const url = this.extractUrl(config);
|
||||||
|
// 懒加载:开启时等容器进入视口再渲染 iframe
|
||||||
|
this.lazyPending = !!(config.lazyRender || config.lazyLoad);
|
||||||
|
// #ifdef H5
|
||||||
|
// 数据热更新时(后台重新保存)若尚未进入过视口,需重建观察器(仅在挂载后才可建立)
|
||||||
|
if (this.lazyPending && this._mountedFlag) this._setupLazyObserver();
|
||||||
|
// #endif
|
||||||
|
// 优先使用后台直接下发的 url;若配置的是接口来源(source=api),则通过接口拉取
|
||||||
|
if (config.source === 'api' && config.apiUrl) {
|
||||||
|
this.loadUrlFromApi(config.apiUrl);
|
||||||
|
} else if (url) {
|
||||||
|
this.src = this.buildSrc(url);
|
||||||
|
console.log('[diy-webview] src:', this.src);
|
||||||
|
this._startLoadTimer();
|
||||||
|
} else {
|
||||||
|
this.src = '';
|
||||||
|
console.warn('[diy-webview] 未找到有效链接字段');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// 在格式化后的 URL 基础上,拼接后台配置的"附加参数"与"携带登录态"(token / member_id)
|
||||||
|
buildSrc(url) {
|
||||||
|
let src = this.formatUrl(url);
|
||||||
|
const config = this.value || {};
|
||||||
|
const extra = [];
|
||||||
|
// 附加参数(后台 urlParams 字段,格式 "a=1&b=2",多组参数用 & 连接)
|
||||||
|
const rawExtra = config.urlParams || config.extraParams || config.appendParams || config.additionalParams || config.params;
|
||||||
|
if (rawExtra && typeof rawExtra === 'string') {
|
||||||
|
extra.push(rawExtra.replace(/^[?&]+/, ''));
|
||||||
|
}
|
||||||
|
// 携带登录态(后台 carryToken 字段:none 不携带 / token / member_id)
|
||||||
|
const loginState = config.carryToken || config.loginState || config.login_state;
|
||||||
|
if (loginState === 'token') {
|
||||||
|
const token = (this.$store && this.$store.state && this.$store.state.token) || '';
|
||||||
|
if (token) extra.push('token=' + encodeURIComponent(token));
|
||||||
|
} else if (loginState === 'member_id') {
|
||||||
|
const memberInfo = (this.$store && this.$store.state && this.$store.state.memberInfo) || {};
|
||||||
|
const memberId = memberInfo.member_id || memberInfo.memberId || '';
|
||||||
|
if (memberId) extra.push('member_id=' + memberId);
|
||||||
|
}
|
||||||
|
if (extra.length) {
|
||||||
|
src += (src.indexOf('?') !== -1 ? '&' : '?') + extra.filter(Boolean).join('&');
|
||||||
|
}
|
||||||
|
return src;
|
||||||
|
},
|
||||||
|
// #ifdef H5
|
||||||
|
// 懒加载:容器滚动进入视口(含预加载缓冲 200px)后才渲染 iframe
|
||||||
|
_setupLazyObserver() {
|
||||||
|
try {
|
||||||
|
if (this._observer) {
|
||||||
|
this._observer.disconnect();
|
||||||
|
this._observer = null;
|
||||||
|
}
|
||||||
|
this._observer = uni.createIntersectionObserver(this, { thresholds: [0] });
|
||||||
|
this._observer.relativeToViewport({ bottom: 200 }).observe('.diy-webview', () => {
|
||||||
|
this.lazyPending = false;
|
||||||
|
if (this._observer) {
|
||||||
|
this._observer.disconnect();
|
||||||
|
this._observer = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// 观察器异常时直接渲染,避免内容永远空白
|
||||||
|
this.lazyPending = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// #endif
|
||||||
|
extractUrl(config) {
|
||||||
|
if (!config || typeof config !== 'object') return '';
|
||||||
|
// 优先读取常见字段名(兼容后台不同命名习惯)
|
||||||
|
let direct = config.url || config.link || config.linkUrl || config.pageUrl || config.webUrl || config.web_url || config.externalUrl || config.targetUrl || config.src || config.href || config.path || config.address;
|
||||||
|
// 兼容装修链接选择器下发的对象形态:link = { wap_url / url / h5_url ... }
|
||||||
|
if (direct && typeof direct === 'object') {
|
||||||
|
direct = direct.wap_url || direct.h5_url || direct.url || direct.app_url || direct.link || '';
|
||||||
|
}
|
||||||
|
if (direct && typeof direct === 'string') return direct.trim();
|
||||||
|
// 兼容嵌套结构:如 value.value.url / value.config.url / value.data.url
|
||||||
|
for (const key of ['value', 'config', 'data', 'content', 'params']) {
|
||||||
|
if (config[key] && typeof config[key] === 'object') {
|
||||||
|
const nested = this.extractUrl(config[key]);
|
||||||
|
if (nested) return nested;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
formatUrl(url) {
|
||||||
|
if (!url || typeof url !== 'string') return '';
|
||||||
|
let formatted = url.trim();
|
||||||
|
// 如果后台把 URL 编码存储,做一次解码
|
||||||
|
if (formatted.indexOf('%') !== -1) {
|
||||||
|
try { formatted = decodeURIComponent(formatted); } catch (e) {}
|
||||||
|
}
|
||||||
|
// 支持以 http:// 或 https:// 开头的完整地址
|
||||||
|
if (/^https?:\/\//i.test(formatted)) return formatted;
|
||||||
|
// 支持相对路径或站点内链接,自动补全
|
||||||
|
return this.$util.img ? this.$util.img(formatted) : formatted;
|
||||||
|
},
|
||||||
|
loadUrlFromApi(apiUrl) {
|
||||||
|
this.$api.sendRequest({
|
||||||
|
url: apiUrl,
|
||||||
|
success: res => {
|
||||||
|
if (res.code == 0 && res.data) {
|
||||||
|
const data = typeof res.data === 'string' ? { url: res.data } : res.data;
|
||||||
|
this.src = this.formatUrl(data.url || data.link || '');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
this.src = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
updateNavbarTitle(e) {
|
||||||
|
// 参照 pages-tool/webview.vue:当 web-view 页面标题更新时,可同步设置导航栏标题
|
||||||
|
const title = e?.detail?.title || e?.title || '';
|
||||||
|
if (title) {
|
||||||
|
uni.setNavigationBarTitle({ title });
|
||||||
|
}
|
||||||
|
this.$emit('titleUpdate', e);
|
||||||
|
},
|
||||||
|
// 点击网页容器整体 → 跳转到配置的链接(走统一 diyRedirectTo,外链自动进 web-view / 新窗口)
|
||||||
|
handleTap() {
|
||||||
|
if (this.src) this.mpGo();
|
||||||
|
},
|
||||||
|
mpGo() {
|
||||||
|
if (!this.src) return;
|
||||||
|
if (this.$util && this.$util.diyRedirectTo) {
|
||||||
|
this.$util.diyRedirectTo({
|
||||||
|
wap_url: this.src,
|
||||||
|
title: (this.value && this.value.title) || (this.value && this.value.name) || '网页',
|
||||||
|
name: (this.value && this.value.name) ? this.value.name : '网页'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
uni.navigateTo({ url: '/pages_tool/webview/webview?src=' + encodeURIComponent(this.src) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 注意:方法名不能叫 onLoad/onError —— uni-app 会把组件里的 onLoad 当页面
|
||||||
|
// 生命周期提升出 methods,导致 @load="onLoad" 渲染时报 handler undefined。
|
||||||
|
handleIframeLoad(e) {
|
||||||
|
this.loaded = true;
|
||||||
|
this.loadError = false;
|
||||||
|
this._clearLoadTimer();
|
||||||
|
this.$emit('load', e);
|
||||||
|
},
|
||||||
|
handleIframeError(e) {
|
||||||
|
this.loadError = true;
|
||||||
|
this.loaded = true;
|
||||||
|
this._clearLoadTimer();
|
||||||
|
console.error('[diy-webview] 加载失败:', this.src, e);
|
||||||
|
this.$emit('error', e);
|
||||||
|
},
|
||||||
|
// 加载超时兜底:部分浏览器内核(如 HBuilderX 内置浏览器的老 CEF 内核)对跨域 iframe
|
||||||
|
// 请求失败时不会触发 error 事件,用定时器兜住,避免容器一直白屏
|
||||||
|
_startLoadTimer() {
|
||||||
|
this._clearLoadTimer();
|
||||||
|
// #ifdef H5
|
||||||
|
this._loadTimer = setTimeout(() => {
|
||||||
|
if (!this.loaded) {
|
||||||
|
this.loadError = true;
|
||||||
|
console.warn('[diy-webview] 加载超时(10s),显示兜底内容:', this.src);
|
||||||
|
}
|
||||||
|
}, 10000);
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
_clearLoadTimer() {
|
||||||
|
if (this._loadTimer) {
|
||||||
|
clearTimeout(this._loadTimer);
|
||||||
|
this._loadTimer = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onMessage(e) {
|
||||||
|
// 接收 web-view / iframe 发来的消息,可用于与后台数据交互
|
||||||
|
this.$emit('message', e);
|
||||||
|
// 同时通过事件总线广播,方便页面级监听
|
||||||
|
this.__$emitEvent({
|
||||||
|
eventName: 'diy-webview-message',
|
||||||
|
data: {
|
||||||
|
componentName: this.value?.componentName,
|
||||||
|
moduleIndex: this.value?.moduleIndex,
|
||||||
|
message: e?.detail?.data || e?.data
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.diy-webview {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
.diy-webview-iframe,
|
||||||
|
.diy-webview-native {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 占位图 / 懒加载未进入视口时的展示 */
|
||||||
|
.diy-webview-placeholder {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* H5 角标跳转按钮:iframe 内部点击不冒泡,用它兜底"点击跳转" */
|
||||||
|
.webview-open-chip {
|
||||||
|
position: absolute;
|
||||||
|
right: 16rpx;
|
||||||
|
bottom: 16rpx;
|
||||||
|
z-index: 3;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8rpx 20rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 22rpx;
|
||||||
|
line-height: 1;
|
||||||
|
|
||||||
|
.iconfont {
|
||||||
|
font-size: 24rpx;
|
||||||
|
margin-right: 6rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 加载中遮罩 */
|
||||||
|
.webview-loading {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f7f8fa;
|
||||||
|
color: #999;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webview-loading-spinner {
|
||||||
|
width: 44rpx;
|
||||||
|
height: 44rpx;
|
||||||
|
border: 4rpx solid #e5e6eb;
|
||||||
|
border-top-color: #576b95;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
animation: webview-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes webview-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.webview-empty,
|
||||||
|
.webview-mp-go {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #576B95;
|
||||||
|
font-size: 26rpx;
|
||||||
|
|
||||||
|
.iconfont {
|
||||||
|
font-size: 48rpx;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.webview-error {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #999;
|
||||||
|
font-size: 26rpx;
|
||||||
|
|
||||||
|
.iconfont {
|
||||||
|
font-size: 48rpx;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.webview-error {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 2;
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
color: #ff6b35;
|
||||||
|
|
||||||
|
.error-open {
|
||||||
|
margin-top: 16rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #576b95;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- Vue 2 / uni-app 单根约束:用 view 包一层 -->
|
<!-- Vue 2 / uni-app 单根约束:用 view 包一层 -->
|
||||||
<view>
|
<view>
|
||||||
<view v-if="pageCount === 1 || need" class="fixed-box"
|
<view v-if="(pageCount === 1 || need) && fbVisible && hasAnyButton" class="fixed-box"
|
||||||
:style="[customContainerStyle, {
|
:style="[customContainerStyle, {
|
||||||
height: containerHeight,
|
height: containerHeight,
|
||||||
|
width: containerWidth,
|
||||||
bottom: containerBottom,
|
bottom: containerBottom,
|
||||||
right: containerRight,
|
right: containerRight,
|
||||||
backgroundImage: bgUrl ? `url(${bgUrl})` : '',
|
backgroundImage: bgUrl ? `url(${bgUrl})` : '',
|
||||||
@@ -11,18 +12,18 @@
|
|||||||
}]">
|
}]">
|
||||||
<!-- 多语言切换按钮:根据语言数智能切换交互 -->
|
<!-- 多语言切换按钮:根据语言数智能切换交互 -->
|
||||||
<view
|
<view
|
||||||
v-if="isLanguageSwitchEnabled && fixBtnShow"
|
v-if="isLanguageSwitchEnabled && hasLangButton"
|
||||||
class="btn-item common-bg"
|
class="btn-item common-bg btn-lang"
|
||||||
:style="btnItemStyle"
|
:style="[btnItemStyle('lang'), { order: orderIndex.lang }]"
|
||||||
@click="handleLangButtonClick"
|
@click="handleLangButtonClick"
|
||||||
>
|
>
|
||||||
<view class="lang-btn-text">{{ currentLangDisplayName }}</view>
|
<view class="lang-btn-text">{{ langButtonText }}</view>
|
||||||
</view>
|
</view>
|
||||||
<!-- 微信官方客服按钮-->
|
<!-- 微信官方客服按钮-->
|
||||||
<button
|
<button
|
||||||
v-if="fixBtnShow && hasValidKefuConfig && isWeappOfficialKefu"
|
v-if="hasKefuButton && hasValidKefuConfig && isWeappOfficialKefu"
|
||||||
class="btn-item common-bg"
|
class="btn-item common-bg btn-kefu"
|
||||||
:style="btnItemStyle"
|
:style="[btnItemStyle('kefu'), { order: orderIndex.kefu }]"
|
||||||
open-type="contact"
|
open-type="contact"
|
||||||
>
|
>
|
||||||
<text class="icox icox-kefu" v-if="!kefuimg"></text>
|
<text class="icox icox-kefu" v-if="!kefuimg"></text>
|
||||||
@@ -30,9 +31,9 @@
|
|||||||
|
|
||||||
<!-- 其他类型客服按钮(智能客服、企业微信等)-->
|
<!-- 其他类型客服按钮(智能客服、企业微信等)-->
|
||||||
<view
|
<view
|
||||||
v-if="fixBtnShow && hasValidKefuConfig && !isWeappOfficialKefu && !showWeappSystemKefu"
|
v-if="hasKefuButton && hasValidKefuConfig && !isWeappOfficialKefu && !showWeappSystemKefu"
|
||||||
class="btn-item common-bg"
|
class="btn-item common-bg btn-kefu"
|
||||||
:style="btnItemStyle"
|
:style="[btnItemStyle('kefu'), { order: orderIndex.kefu }]"
|
||||||
@click="handleUnifiedKefuClick"
|
@click="handleUnifiedKefuClick"
|
||||||
>
|
>
|
||||||
<image
|
<image
|
||||||
@@ -48,9 +49,9 @@
|
|||||||
|
|
||||||
<!-- 智能客服按钮(当同时显示小程序系统客服时)-->
|
<!-- 智能客服按钮(当同时显示小程序系统客服时)-->
|
||||||
<view
|
<view
|
||||||
v-if="fixBtnShow && hasValidKefuConfig && !isWeappOfficialKefu && showWeappSystemKefu"
|
v-if="hasKefuButton && hasValidKefuConfig && !isWeappOfficialKefu && showWeappSystemKefu"
|
||||||
class="btn-item common-bg"
|
class="btn-item common-bg btn-kefu"
|
||||||
:style="btnItemStyle"
|
:style="[btnItemStyle('kefu'), { order: orderIndex.kefu }]"
|
||||||
@click="handleUnifiedKefuClick"
|
@click="handleUnifiedKefuClick"
|
||||||
>
|
>
|
||||||
<image
|
<image
|
||||||
@@ -66,9 +67,9 @@
|
|||||||
|
|
||||||
<!-- 小程序系统客服按钮 -->
|
<!-- 小程序系统客服按钮 -->
|
||||||
<button
|
<button
|
||||||
v-if="fixBtnShow && hasValidKefuConfig && showWeappSystemKefu"
|
v-if="hasKefuButton && hasValidKefuConfig && showWeappSystemKefu"
|
||||||
class="btn-item common-bg"
|
class="btn-item common-bg btn-kefu"
|
||||||
:style="btnItemStyle"
|
:style="[btnItemStyle('kefu'), { order: orderIndex.kefu }]"
|
||||||
open-type="contact"
|
open-type="contact"
|
||||||
>
|
>
|
||||||
<text class="icox icox-kefu" v-if="!kefuimg"></text>
|
<text class="icox icox-kefu" v-if="!kefuimg"></text>
|
||||||
@@ -76,9 +77,9 @@
|
|||||||
|
|
||||||
<!-- 电话按钮 -->
|
<!-- 电话按钮 -->
|
||||||
<view
|
<view
|
||||||
v-if="fixBtnShow"
|
v-if="hasPhoneButton"
|
||||||
class="btn-item common-bg"
|
class="btn-item common-bg btn-phone"
|
||||||
:style="[btnItemStyle, { backgroundImage: phoneimg ? `url(${phoneimg})` : '', backgroundSize: '100% 100%' }, customButtonStyle]"
|
:style="[btnItemStyle('phone'), { order: orderIndex.phone }, { backgroundImage: phoneimg ? `url(${phoneimg})` : '', backgroundSize: '100% 100%' }, customButtonStyle]"
|
||||||
@click="call"
|
@click="call"
|
||||||
>
|
>
|
||||||
<text class="iconfont icon-dianhua" v-if="!phoneimg"></text>
|
<text class="iconfont icon-dianhua" v-if="!phoneimg"></text>
|
||||||
@@ -109,6 +110,10 @@
|
|||||||
<script>
|
<script>
|
||||||
import { createCustomerService } from '@/common/js/customer-service.js';
|
import { createCustomerService } from '@/common/js/customer-service.js';
|
||||||
import i18nLazyMixin from '@/common/mixin/i18n-lazy.js';
|
import i18nLazyMixin from '@/common/mixin/i18n-lazy.js';
|
||||||
|
import {
|
||||||
|
getCurrentRoute, shouldShowOnPage, resolveVisibleButtons,
|
||||||
|
resolveLangButtonText, isLangButtonVisible,
|
||||||
|
} from '@/common/js/floating-button.js';
|
||||||
|
|
||||||
// 语言短码映射表(currentLangDisplayName 展示用,可按需扩展)
|
// 语言短码映射表(currentLangDisplayName 展示用,可按需扩展)
|
||||||
// key: 语言 value,value: 显示短码(按钮上展示)
|
// key: 语言 value,value: 显示短码(按钮上展示)
|
||||||
@@ -161,6 +166,20 @@ function getLangShortCode(langValue, customMap) {
|
|||||||
return prefix.toUpperCase();
|
return prefix.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 跨页面/重挂载复用的悬浮按钮避让值(设计稿 rpx)。
|
||||||
|
// H5 切换页面时 hover-nav 会重新挂载并重新测量公告栏: 无公告栏的页面(如分类页)
|
||||||
|
// noticeRect 为 null, 若直接回落基础位置, 会与首页"公告下方"位置不一致,
|
||||||
|
// 切回首页时按钮突然上/下跳。缓存上次算出的避让值, 让所有页面位置恒定。
|
||||||
|
let cachedAvoidBottom = null;
|
||||||
|
|
||||||
|
// 需要隐藏悬浮按钮的页面路由(全平台生效: H5 + 小程序)
|
||||||
|
// /pages_goods/category 商品分类
|
||||||
|
// /pages_tool/contact/contact 电子名片(名片)
|
||||||
|
const H5_HIDDEN_ROUTES = [
|
||||||
|
'/pages_goods/category',
|
||||||
|
'/pages_tool/contact/contact',
|
||||||
|
];
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'hover-nav',
|
name: 'hover-nav',
|
||||||
mixins: [i18nLazyMixin],
|
mixins: [i18nLazyMixin],
|
||||||
@@ -170,7 +189,7 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
pageCount: 0,
|
pageCount: 0,
|
||||||
fixBtnShow: true,
|
currentRoute: '', // 当前页面路由(onShow / created 时刷新, 供页面范围判定)
|
||||||
shopInfo: null,
|
shopInfo: null,
|
||||||
currentLangIndex: 0,
|
currentLangIndex: 0,
|
||||||
langIndexMap: {},
|
langIndexMap: {},
|
||||||
@@ -190,6 +209,12 @@ export default {
|
|||||||
layoutTopBottom: 272, // --top-btn-bottom
|
layoutTopBottom: 272, // --top-btn-bottom
|
||||||
layoutTopSize: 72, // --top-btn-size(Top 按钮正方形边长)
|
layoutTopSize: 72, // --top-btn-size(Top 按钮正方形边长)
|
||||||
layoutFloatingWidth: 100, // --floating-btn-width
|
layoutFloatingWidth: 100, // --floating-btn-width
|
||||||
|
// 公告栏(diy-notice 跑马灯)实时测量数据,用于把悬浮按钮避让到其下方
|
||||||
|
// 注意: 字段名不能带 _ 前缀 —— Vue 2 不代理 _/$ 开头的 data 属性,
|
||||||
|
// 也不具备响应式,计算属性将永远读不到测量结果(踩过的坑)
|
||||||
|
noticeRect: null, // {top,bottom,height} 视口坐标(px)
|
||||||
|
winW: 0, // 视口宽(px)
|
||||||
|
winH: 0, // 视口高(px)
|
||||||
// i18n-lazy mixin 要求:data 中预声明 i18n 字段(初值空串),
|
// i18n-lazy mixin 要求:data 中预声明 i18n 字段(初值空串),
|
||||||
// 模板只读 this.i18n.xxx,真实翻译在 openLangPicker() 中 prepareI18n() 后才填入
|
// 模板只读 this.i18n.xxx,真实翻译在 openLangPicker() 中 prepareI18n() 后才填入
|
||||||
i18n: {
|
i18n: {
|
||||||
@@ -237,32 +262,75 @@ export default {
|
|||||||
customButtonStyle() {
|
customButtonStyle() {
|
||||||
return this.shopInfo?.floatingButton?.button || {};
|
return this.shopInfo?.floatingButton?.button || {};
|
||||||
},
|
},
|
||||||
|
// ============================================================
|
||||||
|
// 浮动侧边栏配置消费 (来源: /api/config/init → data.floating_button,
|
||||||
|
// store/index.js 的 floatingButtonConfig, 初始 state 已声明 → 响应式)
|
||||||
|
// fbConfig 为 null (老缓存响应缺字段) 时, 各判定全部走老行为兜底。
|
||||||
|
// ============================================================
|
||||||
|
// 接口下发的浮动侧边栏配置 (老缓存响应 → null)
|
||||||
|
fbConfig() {
|
||||||
|
return (this.$store && this.$store.state && this.$store.state.floatingButtonConfig) || null;
|
||||||
|
},
|
||||||
|
// 指定页面隐藏悬浮按钮(商品分类 / 电子名片), 全平台生效
|
||||||
|
hiddenOnRoute() {
|
||||||
|
const r = this.currentRoute;
|
||||||
|
return H5_HIDDEN_ROUTES.some((p) => r === p || r.indexOf(p + '/') === 0);
|
||||||
|
},
|
||||||
|
// 总开关 + 强制隐藏 + 页面范围 判定结果
|
||||||
|
fbVisible() {
|
||||||
|
if (this.hiddenOnRoute) return false; // 指定页面直接隐藏
|
||||||
|
return shouldShowOnPage(this.fbConfig, this.currentRoute, true);
|
||||||
|
},
|
||||||
|
// 最终要渲染的按钮 (已按 button_order 排序 + 显隐过滤); null = 用老行为
|
||||||
|
visibleButtons() {
|
||||||
|
return resolveVisibleButtons(this.fbConfig);
|
||||||
|
},
|
||||||
|
hasAnyButton() {
|
||||||
|
const b = this.visibleButtons;
|
||||||
|
return b ? b.length > 0 : true; // 老行为: 电话按钮恒显
|
||||||
|
},
|
||||||
|
hasLangButton() {
|
||||||
|
const b = this.visibleButtons;
|
||||||
|
return b ? b.indexOf('lang') !== -1
|
||||||
|
: isLangButtonVisible(this.fbConfig, this.shopInfo, this.langList.length);
|
||||||
|
},
|
||||||
|
hasKefuButton() {
|
||||||
|
const b = this.visibleButtons;
|
||||||
|
return b ? b.indexOf('kefu') !== -1 : true;
|
||||||
|
},
|
||||||
|
hasPhoneButton() {
|
||||||
|
const b = this.visibleButtons;
|
||||||
|
return b ? b.indexOf('phone') !== -1 : true;
|
||||||
|
},
|
||||||
|
// 语言按钮文案: 商家可配「目标语言 / 当前语言 / 固定文案」。
|
||||||
|
// 配置缺失时回退老行为 currentLangDisplayName(展示当前语言)。
|
||||||
|
langButtonText() {
|
||||||
|
if (!this.fbConfig) return this.currentLangDisplayName;
|
||||||
|
return resolveLangButtonText(this.fbConfig, this.langList, this.currentLangIndex);
|
||||||
|
},
|
||||||
|
// 按钮渲染顺序 (button_order 自上而下); .fixed-box 是 flex-direction: column,
|
||||||
|
// 用 CSS order 控制三段(语言/客服/电话)的先后, 配置缺失时退回老顺序。
|
||||||
|
orderIndex() {
|
||||||
|
const order = (this.fbConfig && Array.isArray(this.fbConfig.button_order))
|
||||||
|
? this.fbConfig.button_order
|
||||||
|
: ['lang', 'kefu', 'phone'];
|
||||||
|
const map = {};
|
||||||
|
for (let i = 0; i < order.length; i++) map[order[i]] = i + 1;
|
||||||
|
map.lang = map.lang || 1; map.kefu = map.kefu || 2; map.phone = map.phone || 3;
|
||||||
|
return map;
|
||||||
|
},
|
||||||
// 所有 .btn-item 的 inline-style 兜底:
|
// 所有 .btn-item 的 inline-style 兜底:
|
||||||
// 1) 测试发现 .btn-item 的 min-*/max-*/aspect-ratio/flex-shrink 等 !important
|
// 1) 测试发现 .btn-item 的 min-*/max-*/aspect-ratio/flex-shrink 等 !important
|
||||||
// 规则在 scoped 样式下,被某些 flex 布局的隐式约束吃掉,只 width/height/border-radius
|
// 规则在 scoped 样式下,被某些 flex 布局的隐式约束吃掉,只 width/height/border-radius
|
||||||
// 生效,导致高度被压缩成 39.5px(从 41px),按钮显示为轻微椭圆。
|
// 生效,导致高度被压缩成 39.5px(从 41px),按钮显示为轻微椭圆。
|
||||||
// 2) inline-style 优先级高于 CSS class,直接锁死 72x72 + 正圆。
|
// 2) inline-style 优先级高于 CSS class,直接锁死按钮尺寸 + 正圆。
|
||||||
// 3) 用 max-width/max-height + flex-shrink 0 三重兜底,防止被任何层级的样式覆盖。
|
// 3) 尺寸来源(逐按钮可调, 见 btnSizeRpx):
|
||||||
btnItemStyle() {
|
// hover_nav_lang_btn_size / hover_nav_lang_size
|
||||||
return {
|
// hover_nav_kefu_btn_size / hover_nav_kefu_size
|
||||||
width: '72rpx',
|
// hover_nav_phone_btn_size / hover_nav_phone_size
|
||||||
height: '72rpx',
|
// 未配置时回退 72rpx(与原行为一致)。
|
||||||
'min-width': '72rpx',
|
// 4) 用 max-width/max-height + flex-shrink 0 三重兜底,防止被任何层级的样式覆盖。
|
||||||
'min-height': '72rpx',
|
// (已从 computed 改为 method: 模板按按钮类型调用 btnItemStyle('lang'|'kefu'|'phone'))
|
||||||
'max-width': '72rpx',
|
|
||||||
'max-height': '72rpx',
|
|
||||||
'aspect-ratio': '1',
|
|
||||||
// border-radius 保留作 clip-path 不支持时的 fallback
|
|
||||||
'border-radius': '50%',
|
|
||||||
'flex-shrink': '0',
|
|
||||||
'flex-grow': '0',
|
|
||||||
'align-self': 'center',
|
|
||||||
'box-sizing': 'border-box',
|
|
||||||
padding: '0',
|
|
||||||
margin: '14rpx 0',
|
|
||||||
overflow: 'hidden',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
unreadCount() {
|
unreadCount() {
|
||||||
const customerService = createCustomerService(this);
|
const customerService = createCustomerService(this);
|
||||||
return customerService.getUnreadCount();
|
return customerService.getUnreadCount();
|
||||||
@@ -296,53 +364,101 @@ export default {
|
|||||||
return customerService.hasValidKefuConfig();
|
return customerService.hasValidKefuConfig();
|
||||||
},
|
},
|
||||||
|
|
||||||
//根据显示的按钮数量动态计算容器高度
|
// 当前实际会渲染出来的按钮尺寸列表(rpx), 与模板渲染一一对应。
|
||||||
// 计算明细(buttonCount 按钮场景):
|
// containerHeight / containerWidth 都以它为准, 后台把任一按钮调多大都能撑开容器。
|
||||||
// 每个按钮槽位:72rpx(按钮,与 to-top 一致) + 14rpx(上 margin) + 14rpx(下 margin) = 100rpx
|
renderedBtnSizes() {
|
||||||
// 容器 padding:20rpx 上 + 20rpx 下 = 40rpx
|
const b = this.visibleButtons;
|
||||||
// 总高度 (rpx):buttonCount × 100 + 40
|
if (b && b.length === 0) return [];
|
||||||
// 修复:改返回 rpx 而非 px(原 *0.5 硬编码 iPhone 6 375px 转换比),
|
|
||||||
// 在微信小程序/其他设备(iPhone 13 390px、iPad 等)下由 uni-app 框架
|
const sizes = [];
|
||||||
// 按 windowWidth 动态换算,避免 4+ 按钮时容器偏小导致内容被裁切
|
if (this.isLanguageSwitchEnabled && this.hasLangButton) {
|
||||||
// 关联:按钮尺寸与 to-top 同步(72rpx),所以槽位高度也是 100rpx;
|
sizes.push(this.btnSizeRpx('lang'));
|
||||||
// 之前按钮 80rpx 时槽位 108rpx,改为 72rpx 后槽位缩小 8rpx,容器整体更紧凑
|
}
|
||||||
|
if (this.hasKefuButton && this.hasValidKefuConfig) {
|
||||||
|
sizes.push(this.btnSizeRpx('kefu'));
|
||||||
|
// 小程序系统客服与客服主按钮并存时, 模板会多渲染一个按钮
|
||||||
|
if (this.showWeappSystemKefu && !this.isWeappOfficialKefu) {
|
||||||
|
sizes.push(this.btnSizeRpx('kefu'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.hasPhoneButton) {
|
||||||
|
sizes.push(this.btnSizeRpx('phone'));
|
||||||
|
}
|
||||||
|
return sizes;
|
||||||
|
},
|
||||||
|
// 根据显示的按钮数量与各自尺寸动态计算容器高度
|
||||||
|
// 每个按钮槽位 = 按钮尺寸 + 14rpx(上 margin) + 14rpx(下 margin)
|
||||||
|
// 容器 padding: 20rpx 上 + 20rpx 下 = 40rpx
|
||||||
|
// 旧公式 buttonCount*100+40 是"所有按钮 72rpx"的特例, 现按逐按钮实际尺寸累加
|
||||||
|
containerHeightNum() {
|
||||||
|
const sizes = this.renderedBtnSizes;
|
||||||
|
if (sizes.length === 0) return 0;
|
||||||
|
return sizes.reduce((sum, s) => sum + s + 28, 0) + 40;
|
||||||
|
},
|
||||||
containerHeight() {
|
containerHeight() {
|
||||||
if (!this.fixBtnShow) return '320rpx';
|
return this.rpxStr(this.containerHeightNum);
|
||||||
|
},
|
||||||
let buttonCount = 1;
|
// 容器宽度自适应: 至少保持原 --floating-btn-width(100rpx) 布局不变;
|
||||||
if (this.isLanguageSwitchEnabled) buttonCount++;
|
// 后台把任一按钮调大(如 hover_nav_lang_btn_size=120rpx)时, 容器随之撑大,
|
||||||
if (this.showWeappSystemKefu) buttonCount++;
|
// 否则 .fixed-box 的 overflow:hidden 会把超宽按钮裁剪变形。
|
||||||
buttonCount++;
|
containerWidthNum() {
|
||||||
const totalRpx = buttonCount * 100 + 40;
|
const maxBtn = this.renderedBtnSizes.reduce((m, s) => Math.max(m, s), 0);
|
||||||
|
// 额外 +12rpx 呼吸余量: 避免 rpx→px 亚像素取整导致按钮与容器边缘严丝合缝,
|
||||||
return `${totalRpx}rpx`;
|
// 也给 bgUrl 背景胶囊留出按钮两侧各 6rpx 的边距; 按钮由 flex 居中, 不影响对齐。
|
||||||
|
return Math.max(this.layoutFloatingWidth, maxBtn + 12);
|
||||||
|
},
|
||||||
|
containerWidth() {
|
||||||
|
return this.rpxStr(this.containerWidthNum);
|
||||||
},
|
},
|
||||||
// 浮动按钮组底部 bottom 动态计算(在 Top 按钮上方):
|
// 浮动按钮组底部 bottom 动态计算(在 Top 按钮上方):
|
||||||
// Top 按钮是 72x72 正方形,顶部边界 = bottom + size = layoutTopBottom + layoutTopSize
|
// Top 按钮是 72x72 正方形,顶部边界 = bottom + size = layoutTopBottom + layoutTopSize
|
||||||
// containerBottom = Top 顶部边界 + space
|
// containerBottom = Top 顶部边界 + space
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
// space 是经验调优值(非数学推导),手调结果:
|
// space 两端统一为 18rpx:
|
||||||
// H5 : 18 * 5 = 90rpx
|
// 旧的 H5=90rpx 特调是"H5 内联 rpx 全部失效、实际走 CSS fallback"年代的产物;
|
||||||
// MP-WEIXIN: 18 * 1 = 18rpx
|
// 现在内联样式经 rpxStr() 在 H5 输出精确 px,几何计算两端一致,
|
||||||
// 为什么 H5 反而大、微信反而小:
|
// H5 必须与微信小程序保持相同位置(272+72+18 = 362rpx),不再需要平台特调。
|
||||||
// 虽然 rpx 单位定义相同(windowWidth/750),但 Top 按钮
|
|
||||||
// <image mode="widthFix"> 在不同平台渲染有视觉差异。
|
|
||||||
// H5 浏览器(WebKit/Blink)渲染时,图片元素可能保留更多
|
|
||||||
// 内边距/基线,视觉上比 72rpx 大,需要更大间距才不挤;
|
|
||||||
// 微信 Skia 严格按 width=72rpx 渲染,视觉紧凑,
|
|
||||||
// 18rpx 就能有清晰的呼吸感。
|
|
||||||
// 90/18 是按"视觉上不挤"原则手调出来的,反过来都不行。
|
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
// 注意:安全区 var(--window-bottom) 由 CSS 的 margin-bottom 统一处理,
|
// 注意:安全区 var(--window-bottom) 由 CSS 的 margin-bottom 统一处理,
|
||||||
// 与 Top 按钮的 .mescroll-totop { margin-bottom: var(--window-bottom) } 一致,
|
// 与 Top 按钮的 .mescroll-totop { margin-bottom: var(--window-bottom) } 一致,
|
||||||
// 两者会被安全区同步上推,space 保持不变
|
// 两者会被安全区同步上推,space 保持不变
|
||||||
containerBottom() {
|
containerBottom() {
|
||||||
if (!this.fixBtnShow) return '240rpx';
|
const space = 18;
|
||||||
let space = 18 * 5;
|
const base = this.layoutTopBottom + this.layoutTopSize + space;
|
||||||
// #ifdef MP-WEIXIN
|
// 避让公告栏(diy-notice 跑马灯): 把悬浮按钮整体放到公告栏"正下方"。
|
||||||
space = 18 * 1;
|
// 关键:用「文档坐标」而非「视口坐标」算 bottom ——
|
||||||
// #endif
|
// 公告栏随页面内容滚动, 其视口坐标(rect.bottom)随滚动变化;
|
||||||
return `${this.layoutTopBottom + this.layoutTopSize + space}rpx`;
|
// 若直接 (winH - rect.bottom) 算, 滚动时按钮会跟着公告栏上下移动。
|
||||||
|
// 这里把 rect 加上当前滚动距离得到"公告栏在文档中的底边",
|
||||||
|
// 再算 (winH - 文档底边): 该值与滚动无关, 按钮固定在"页面顶部时公告栏正下方",
|
||||||
|
// 滚动时按钮静止(公告栏自行滚走), 不再来回滑动。
|
||||||
|
const rect = this.noticeRect;
|
||||||
|
let target = null;
|
||||||
|
if (rect && this.winW > 0 && this.winH > 0 && rect.bottom > 0 && rect.top < this.winH) {
|
||||||
|
// 当前滚动距离(H5: window.pageYOffset; 小程序无 window 时回退 0)
|
||||||
|
let scrollY = 0;
|
||||||
|
try {
|
||||||
|
if (typeof window !== 'undefined' && 'pageYOffset' in window) {
|
||||||
|
scrollY = window.pageYOffset || 0;
|
||||||
|
} else if (typeof document !== 'undefined' && document.documentElement) {
|
||||||
|
scrollY = document.documentElement.scrollTop || 0;
|
||||||
|
}
|
||||||
|
} catch (e) { scrollY = 0; }
|
||||||
|
const scaleInv = 750 / this.winW; // px → 设计稿 rpx
|
||||||
|
const gap = 12; // 按钮与公告栏之间的间距(设计稿 rpx)
|
||||||
|
// 公告栏底边在文档中的坐标 = 视口坐标 + 滚动距离(滚动无关, 恒定)
|
||||||
|
const docBottom = rect.bottom + scrollY;
|
||||||
|
const distFromBottom = (this.winH - docBottom) * scaleInv;
|
||||||
|
// 按钮 bottom = 文档底边距视口底部 + 间距 - 按钮自身高度
|
||||||
|
const below = distFromBottom - gap - this.containerHeightNum;
|
||||||
|
if (Number.isFinite(below)) {
|
||||||
|
target = Math.max(0, below);
|
||||||
|
cachedAvoidBottom = target; // 缓存: 跨页面/重挂载复用, 位置恒定
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 无公告栏的页面(如分类页)复用上一次算出的避让值, 使悬浮按钮在所有页面位置一致
|
||||||
|
if (target === null && cachedAvoidBottom !== null) target = cachedAvoidBottom;
|
||||||
|
return this.rpxStr(target === null ? base : target);
|
||||||
},
|
},
|
||||||
// 浮动按钮组水平 right 动态计算(与 Top 按钮中心对齐):
|
// 浮动按钮组水平 right 动态计算(与 Top 按钮中心对齐):
|
||||||
// 公式: containerRight = TopRight + TopSize/2 - FloatingWidth/2
|
// 公式: containerRight = TopRight + TopSize/2 - FloatingWidth/2
|
||||||
@@ -357,9 +473,10 @@ export default {
|
|||||||
// - right edge 对齐(46rpx):左侧突出 28rpx,中心错位 14rpx
|
// - right edge 对齐(46rpx):左侧突出 28rpx,中心错位 14rpx
|
||||||
// center 对齐不仅中心精准,突出量也更小
|
// center 对齐不仅中心精准,突出量也更小
|
||||||
containerRight() {
|
containerRight() {
|
||||||
if (!this.fixBtnShow) return '0rpx';
|
// 用实际容器宽度(containerWidthNum, 随最大按钮自适应)做中心对齐,
|
||||||
const offset = this.layoutTopRight + this.layoutTopSize / 2 - this.layoutFloatingWidth / 2;
|
// 按钮调大后容器变宽时仍与 Top 按钮保持中心对齐
|
||||||
return `${offset}rpx`;
|
const offset = this.layoutTopRight + this.layoutTopSize / 2 - this.containerWidthNum / 2;
|
||||||
|
return this.rpxStr(offset);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -420,9 +537,14 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// 页面生命周期: hover-nav 是常驻组件, 路由变化必须靠 onShow 通知,
|
||||||
|
// 否则 currentRoute 停留在首次加载页, 页面范围判定(fbVisible)会失效。
|
||||||
|
onShow() {
|
||||||
|
this.refreshRoute();
|
||||||
|
},
|
||||||
created() {
|
created() {
|
||||||
this.initLanguage();
|
this.initLanguage();
|
||||||
this.pageCount = getCurrentPages().length;
|
this.refreshRoute();
|
||||||
|
|
||||||
// 三源兜底(store / 同步 storage / 异步 storage),
|
// 三源兜底(store / 同步 storage / 异步 storage),
|
||||||
// 任何一个先就绪就先使用,后续由 initStatus watcher 再次同步
|
// 任何一个先就绪就先使用,后续由 initStatus watcher 再次同步
|
||||||
@@ -512,6 +634,23 @@ export default {
|
|||||||
// 关键: 手动同步当前 themeColor(覆盖 watch immediate 失效的场景)
|
// 关键: 手动同步当前 themeColor(覆盖 watch immediate 失效的场景)
|
||||||
// 详见上面的注释。_applyThemeColor 内部已判 $el 存在性,所有平台都安全。
|
// 详见上面的注释。_applyThemeColor 内部已判 $el 存在性,所有平台都安全。
|
||||||
this._applyThemeColor(this.$store && this.$store.state && this.$store.state.themeColor);
|
this._applyThemeColor(this.$store && this.$store.state && this.$store.state.themeColor);
|
||||||
|
|
||||||
|
// 公告栏避让: 测量视口 + 公告栏(diy-notice)位置, 把悬浮按钮放到其正下方
|
||||||
|
this.updateViewport();
|
||||||
|
this.measureNotice();
|
||||||
|
// 延迟复测: 页面 DIY 内容(含 diy-notice)可能晚于本组件挂载后才渲染
|
||||||
|
this._noticeTimers = [
|
||||||
|
setTimeout(() => this.measureNotice(), 300),
|
||||||
|
setTimeout(() => this.measureNotice(), 800),
|
||||||
|
];
|
||||||
|
// #ifdef H5
|
||||||
|
// 不再监听 scroll: containerBottom 已改用「文档坐标」(视口坐标 + 滚动距离)计算,
|
||||||
|
// 得到与滚动无关的恒定 bottom —— 按钮固定在"页面顶部时公告栏正下方",
|
||||||
|
// 滚动时按钮静止、公告栏自行滚走, 不再来回滑动。
|
||||||
|
// 仅监听 resize(旋转/尺寸变化), 让按钮在视口尺寸变化时重新避让一次。
|
||||||
|
this._scrollHandler = this._onScroll.bind(this);
|
||||||
|
window.addEventListener('resize', this._scrollHandler);
|
||||||
|
// #endif
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
// H5 Teleport 还原:把元素从 body 移回原父节点,避免内存泄漏
|
// H5 Teleport 还原:把元素从 body 移回原父节点,避免内存泄漏
|
||||||
@@ -520,9 +659,163 @@ export default {
|
|||||||
this._origParent.appendChild(this.$el);
|
this._origParent.appendChild(this.$el);
|
||||||
this._origParent = null;
|
this._origParent = null;
|
||||||
}
|
}
|
||||||
|
// 清理公告栏避让的滚动/缩放监听与定时器
|
||||||
|
if (this._scrollHandler) {
|
||||||
|
window.removeEventListener('scroll', this._scrollHandler);
|
||||||
|
window.removeEventListener('resize', this._scrollHandler);
|
||||||
|
this._scrollHandler = null;
|
||||||
|
}
|
||||||
|
if (this._noticeTimers) {
|
||||||
|
this._noticeTimers.forEach((t) => clearTimeout(t));
|
||||||
|
this._noticeTimers = null;
|
||||||
|
}
|
||||||
|
if (this._scrollRAF) {
|
||||||
|
cancelAnimationFrame(this._scrollRAF);
|
||||||
|
this._scrollRAF = null;
|
||||||
|
}
|
||||||
// #endif
|
// #endif
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
// ============================================================
|
||||||
|
// rpx 换算工具(内联样式单位跨端兼容的关键)
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// 问题: 动态 :style 绑定里的 rpx, 微信小程序原生支持,
|
||||||
|
// 但 vue2 uni-app 的 H5 端不做编译转换 —— 浏览器不认识 rpx,
|
||||||
|
// 整条内联声明被当作非法值忽略, 回落到旧 CSS(容器 100rpx),
|
||||||
|
// 这就是"小程序正常、H5 大按钮右侧被裁"的根因。
|
||||||
|
// 解决: H5 端所有内联尺寸一律用 upx2px 换算成 px 输出;
|
||||||
|
// 小程序端保持 rpx 原样输出。
|
||||||
|
// ============================================================
|
||||||
|
// #ifdef H5
|
||||||
|
rpxNum(n) {
|
||||||
|
const n2 = Number(n) || 0;
|
||||||
|
try {
|
||||||
|
const w = (typeof window !== 'undefined' && window.innerWidth) || 375;
|
||||||
|
return (n2 * w) / 750;
|
||||||
|
} catch (e) {
|
||||||
|
return n2 / 2; // window 异常时按 375px 视口兜底
|
||||||
|
}
|
||||||
|
},
|
||||||
|
rpxStr(n) {
|
||||||
|
return this.rpxNum(n) + 'px';
|
||||||
|
},
|
||||||
|
// #endif
|
||||||
|
// #ifndef H5
|
||||||
|
rpxNum(n) {
|
||||||
|
return Number(n) || 0;
|
||||||
|
},
|
||||||
|
rpxStr(n) {
|
||||||
|
return (Number(n) || 0) + 'rpx';
|
||||||
|
},
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 公告栏(diy-notice 跑马灯)实时测量: 让悬浮按钮避让到其正下方
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// 公告栏是页面流/固定底部的 DIY 组件(diy-notice), 高度与位置动态,
|
||||||
|
// 无法用固定常量避让。这里用 uni 选择器读取其视口坐标(px),
|
||||||
|
// containerBottom 据此把按钮放到公告栏底边之下。
|
||||||
|
// 滚动/缩放时公告栏视口坐标会变化, 故监听 scroll/resize 重新测量。
|
||||||
|
// ============================================================
|
||||||
|
// 更新视口尺寸(换算 rpx 用)
|
||||||
|
updateViewport() {
|
||||||
|
try {
|
||||||
|
if (typeof window !== 'undefined' && window.innerWidth) {
|
||||||
|
this.winW = window.innerWidth;
|
||||||
|
this.winH = window.innerHeight;
|
||||||
|
} else {
|
||||||
|
const info = uni.getSystemInfoSync();
|
||||||
|
this.winW = info.windowWidth || 375;
|
||||||
|
this.winH = info.windowHeight || 667;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.winW = this.winW || 375;
|
||||||
|
this.winH = this.winH || 667;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// 测量 .diy-notice 的视口坐标(无公告栏时置空, 走基础定位)
|
||||||
|
measureNotice() {
|
||||||
|
try {
|
||||||
|
const q = uni.createSelectorQuery();
|
||||||
|
q.select('.diy-notice').boundingClientRect((rect) => {
|
||||||
|
if (rect && rect.height > 0) {
|
||||||
|
this.noticeRect = {
|
||||||
|
top: rect.top,
|
||||||
|
bottom: rect.bottom,
|
||||||
|
height: rect.height,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
this.noticeRect = null; // 无公告栏 → 基础定位
|
||||||
|
}
|
||||||
|
}).exec();
|
||||||
|
} catch (e) {
|
||||||
|
this.noticeRect = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// #ifdef H5
|
||||||
|
// H5: 滚动/缩放用 rAF 节流重新测量, 让按钮始终跟随公告栏
|
||||||
|
_onScroll() {
|
||||||
|
if (this._scrollRAF) return;
|
||||||
|
this._scrollRAF = requestAnimationFrame(() => {
|
||||||
|
this._scrollRAF = null;
|
||||||
|
this.updateViewport();
|
||||||
|
this.measureNotice();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// 读取逐按钮尺寸 token(rpx 数值):
|
||||||
|
// 优先 hover_nav_{type}_btn_size, 其次 hover_nav_{type}_size, 都未配置回退 72
|
||||||
|
// 数据源: store.state.themeStyle(响应式, 后台覆写 merge 后即生效)
|
||||||
|
// 兼容值格式: 97(数字) / '97rpx' / '97', 非法或 <=0 一律回退 72
|
||||||
|
btnSizeRpx(type) {
|
||||||
|
let theme = null;
|
||||||
|
try {
|
||||||
|
theme = (this.$store && this.$store.state) ? this.$store.state.themeStyle : null;
|
||||||
|
} catch (e) { theme = null; }
|
||||||
|
const keys = ['hover_nav_' + type + '_btn_size', 'hover_nav_' + type + '_size'];
|
||||||
|
if (theme && typeof theme === 'object') {
|
||||||
|
for (let i = 0; i < keys.length; i++) {
|
||||||
|
const raw = theme[keys[i]];
|
||||||
|
if (raw === undefined || raw === null || raw === '') continue;
|
||||||
|
const n = parseFloat(raw);
|
||||||
|
if (Number.isFinite(n) && n > 0) return n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 72;
|
||||||
|
},
|
||||||
|
// .btn-item 的 inline-style 锁定(按按钮类型取尺寸, 见 btnSizeRpx):
|
||||||
|
// inline-style 优先级高于 CSS class, 锁死尺寸 + 正圆, 防止被 flex 布局压缩成椭圆
|
||||||
|
btnItemStyle(type) {
|
||||||
|
const s = this.rpxStr(this.btnSizeRpx(type));
|
||||||
|
return {
|
||||||
|
width: s,
|
||||||
|
height: s,
|
||||||
|
'min-width': s,
|
||||||
|
'min-height': s,
|
||||||
|
'max-width': s,
|
||||||
|
'max-height': s,
|
||||||
|
// 内部图标(.iconfont/.icox)通过该变量跟随按钮尺寸:
|
||||||
|
// 图标盒子里原本锁死 72rpx, 按钮调大后图标仍 72rpx,
|
||||||
|
// 又因列 flex 横轴默认贴左 → 图标偏在按钮左上角
|
||||||
|
'--btn-size': s,
|
||||||
|
'aspect-ratio': '1',
|
||||||
|
// border-radius 保留作 clip-path 不支持时的 fallback
|
||||||
|
'border-radius': '50%',
|
||||||
|
'flex-shrink': '0',
|
||||||
|
'flex-grow': '0',
|
||||||
|
'align-self': 'center',
|
||||||
|
'box-sizing': 'border-box',
|
||||||
|
padding: '0',
|
||||||
|
margin: this.rpxStr(14) + ' 0',
|
||||||
|
overflow: 'hidden',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
// 刷新当前路由与页面栈深度(供 fbVisible 的页面范围判定 / pageCount 老判定)
|
||||||
|
refreshRoute() {
|
||||||
|
this.currentRoute = getCurrentRoute();
|
||||||
|
this.pageCount = getCurrentPages().length;
|
||||||
|
},
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
/**
|
/**
|
||||||
* 从 :root 读取 CSS 变量(App.vue 中定义)到 data,
|
* 从 :root 读取 CSS 变量(App.vue 中定义)到 data,
|
||||||
@@ -534,9 +827,13 @@ export default {
|
|||||||
if (typeof document === 'undefined') return; // SSR/非浏览器环境兜底
|
if (typeof document === 'undefined') return; // SSR/非浏览器环境兜底
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const computed = getComputedStyle(root);
|
const computed = getComputedStyle(root);
|
||||||
|
// CSS 变量在 H5 编译后是 px;组件内部统一按 750 设计稿 rpx 值计算,
|
||||||
|
// 输出时再经 rpxStr() 换算回 px,避免 px/rpx 混算导致尺寸/位置错乱
|
||||||
|
const scale = ((typeof window !== 'undefined' && window.innerWidth) || 375) / 750;
|
||||||
const getNum = (name, fallback) => {
|
const getNum = (name, fallback) => {
|
||||||
const v = parseFloat(computed.getPropertyValue(name));
|
const v = parseFloat(computed.getPropertyValue(name));
|
||||||
return Number.isFinite(v) ? v : fallback;
|
if (!Number.isFinite(v) || scale <= 0) return fallback;
|
||||||
|
return v / scale; // px → 设计稿 rpx 值
|
||||||
};
|
};
|
||||||
this.layoutTopRight = getNum('--top-btn-right', 46);
|
this.layoutTopRight = getNum('--top-btn-right', 46);
|
||||||
this.layoutTopBottom = getNum('--top-btn-bottom', 272);
|
this.layoutTopBottom = getNum('--top-btn-bottom', 272);
|
||||||
@@ -821,8 +1118,13 @@ export default {
|
|||||||
* 保证容器的实际宽度与 JS 计算用到的宽度始终一致。 */
|
* 保证容器的实际宽度与 JS 计算用到的宽度始终一致。 */
|
||||||
width: var(--floating-btn-width, 100rpx);
|
width: var(--floating-btn-width, 100rpx);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
transition: 0.3s;
|
/* 去掉过渡动画: 切换页面重挂载时 bottom/width/right 变化会触发滑动,
|
||||||
overflow: hidden;
|
* 造成"突然移动"的观感; 位置用缓存保证恒定, 无需动画 */
|
||||||
|
/* 关键: 不裁剪子元素。按钮尺寸由后台 token 任意调节(hover_nav_*_btn_size 可远超容器宽度),
|
||||||
|
* 且 rpx→px 取整存在亚像素误差, overflow:hidden 会把按钮右侧/四角切掉一条(变形元凶)。
|
||||||
|
* 按钮自身已有 border-radius:50% + overflow:hidden 自裁剪, 背景图圆角由本元素
|
||||||
|
* border-radius 保证(背景不受 overflow 影响), 故这里必须放开为 visible。 */
|
||||||
|
overflow: visible;
|
||||||
/* 与 to-top.vue 的 .mescroll-totop 一致,适配 iPhoneX 等安全区 */
|
/* 与 to-top.vue 的 .mescroll-totop 一致,适配 iPhoneX 等安全区 */
|
||||||
margin-bottom: var(--window-bottom);
|
margin-bottom: var(--window-bottom);
|
||||||
}
|
}
|
||||||
@@ -830,6 +1132,9 @@ export default {
|
|||||||
.btn-item {
|
.btn-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
/* 横向居中: 列布局下横轴是 align-items, 固定宽度的子元素
|
||||||
|
* (如锁死尺寸的 .iconfont 图标盒)默认贴左, 必须显式居中 */
|
||||||
|
align-items: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
@@ -864,22 +1169,42 @@ export default {
|
|||||||
background-color: var(--hover-nav-bg-color);
|
background-color: var(--hover-nav-bg-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 逐按钮背景/文字颜色覆写(与后台 9 个 key 对应, 未配置时回退全局 --hover-nav-*):
|
||||||
|
* hover_nav_lang_bg_color / hover_nav_lang_text_color → --hover-nav-lang-bg-color / --hover-nav-lang-text-color
|
||||||
|
* hover_nav_kefu_bg_color / hover_nav_kefu_text_color → --hover-nav-kefu-bg-color / --hover-nav-kefu-text-color
|
||||||
|
* hover_nav_phone_bg_color / hover_nav_phone_text_color → --hover-nav-phone-bg-color / --hover-nav-phone-text-color
|
||||||
|
* 文字色经 .btn-item 的 color 继承给内部 text/图标(.lang-btn-text 为 color: inherit) */
|
||||||
|
.btn-lang {
|
||||||
|
background-color: var(--hover-nav-lang-bg-color, var(--hover-nav-bg-color));
|
||||||
|
color: var(--hover-nav-lang-text-color, var(--hover-nav-text-color));
|
||||||
|
}
|
||||||
|
.btn-kefu {
|
||||||
|
background-color: var(--hover-nav-kefu-bg-color, var(--hover-nav-bg-color));
|
||||||
|
color: var(--hover-nav-kefu-text-color, var(--hover-nav-text-color));
|
||||||
|
}
|
||||||
|
.btn-phone {
|
||||||
|
background-color: var(--hover-nav-phone-bg-color, var(--hover-nav-bg-color));
|
||||||
|
color: var(--hover-nav-phone-text-color, var(--hover-nav-text-color));
|
||||||
|
}
|
||||||
|
|
||||||
.btn-item text {
|
.btn-item text {
|
||||||
font-size: 28rpx;
|
/* 悬浮按钮内文字/图标字号(后台 hover_nav_font_size 可调, 默认与图标 36rpx 对齐) */
|
||||||
|
font-size: var(--hover-nav-font-size, 36rpx);
|
||||||
}
|
}
|
||||||
|
|
||||||
.iconfont,
|
.iconfont,
|
||||||
.icox {
|
.icox {
|
||||||
font-size: 36rpx;
|
/* 悬浮按钮内图标字号(后台 hover_nav_font_size 可调) */
|
||||||
|
font-size: var(--hover-nav-font-size, 36rpx);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
/* 不再设 width: 100% / height: 100% 再覆盖为 72rpx 的写法,
|
/* 尺寸跟随所在按钮: 按钮内联样式写入 --btn-size(=按钮实际尺寸),
|
||||||
* 微信小程序/H5 对 width: 100% 在 flex 列布局里的高度计算有差异,
|
* 图标盒不再锁死 72rpx —— 否则按钮调大后图标偏小且贴左上。
|
||||||
* 直接固定 72x72 更稳。 */
|
* 72rpx 兜底 = 按钮未走 btnItemStyle 内联样式时的默认尺寸。 */
|
||||||
width: 72rpx;
|
width: var(--btn-size, 72rpx);
|
||||||
height: 72rpx;
|
height: var(--btn-size, 72rpx);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -897,9 +1222,17 @@ export default {
|
|||||||
0 -1rpx 0 rgba(0, 0, 0, 0.18);
|
0 -1rpx 0 rgba(0, 0, 0, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 逐按钮字号覆写: 后台 hover_nav_phone/kefu_font_size 可调, 未设时回退全局 --hover-nav-font-size */
|
||||||
|
.iconfont.icon-dianhua {
|
||||||
|
font-size: var(--hover-nav-phone-font-size, var(--hover-nav-font-size, 36rpx));
|
||||||
|
}
|
||||||
|
.icox.icox-kefu {
|
||||||
|
font-size: var(--hover-nav-kefu-font-size, var(--hover-nav-font-size, 36rpx));
|
||||||
|
}
|
||||||
|
|
||||||
.iconfont text,
|
.iconfont text,
|
||||||
.icox text {
|
.icox text {
|
||||||
font-size: 36rpx;
|
font-size: var(--hover-nav-font-size, 36rpx);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -911,7 +1244,8 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ai-icon {
|
.ai-icon {
|
||||||
font-size: 40rpx;
|
/* emoji 客服图标: 基准字号用 hover_nav_kefu_font_size(回退全局), 再 ×1.11(原 40/36) */
|
||||||
|
font-size: calc(var(--hover-nav-kefu-font-size, var(--hover-nav-font-size, 36rpx)) * 1.11);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -930,7 +1264,8 @@ export default {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
font-size: 24rpx;
|
/* 语言短码文字: 基准字号用 hover_nav_lang_font_size(回退全局 hover_nav_font_size), 文字实际按 0.667 缩放 */
|
||||||
|
font-size: calc(var(--hover-nav-lang-font-size, var(--hover-nav-font-size, 36rpx)) * 0.667);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
|
|||||||
247
components/ns-mp-navbar/ns-mp-navbar.vue
Normal file
247
components/ns-mp-navbar/ns-mp-navbar.vue
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
<template>
|
||||||
|
<!--
|
||||||
|
自绘导航栏:把原生导航栏左侧的 "<" 换成 "‹ 返回" 文字
|
||||||
|
|
||||||
|
背景:微信小程序原生导航栏的返回按钮是客户端系统控件,pages.json 的任何配置、
|
||||||
|
官方 <navigation-bar> 组件(只支持 title / front-color / background-color / loading)
|
||||||
|
都无法修改它的内容。要显示"返回"二字,只能关闭原生栏后自绘。
|
||||||
|
|
||||||
|
使用前提:
|
||||||
|
1. 该页面在 pages.json 中开启 "navigationStyle": "custom"(组件只负责渲染,不自动改配置)
|
||||||
|
2. 该页面不能是 web-view 页面 —— 官方明确 "navigationStyle: custom 对 web-view 组件无效",
|
||||||
|
且 web-view 会铺满整页覆盖其他组件,自绘的导航栏不会显示
|
||||||
|
|
||||||
|
布局:组件是普通流式节点(非 fixed),放在页面顶部即可把内容自然下推。
|
||||||
|
-->
|
||||||
|
<view
|
||||||
|
v-if="visible"
|
||||||
|
class="ns-mp-navbar"
|
||||||
|
:class="{ 'is-fixed': fixed }"
|
||||||
|
:style="{ background: bgColor, paddingTop: statusBarHeight + 'px' }"
|
||||||
|
>
|
||||||
|
<view class="ns-mp-navbar__inner" :style="{ height: navBarHeight + 'px' }">
|
||||||
|
<view class="ns-mp-navbar__side">
|
||||||
|
<view
|
||||||
|
v-if="showBack && canBack"
|
||||||
|
class="ns-mp-navbar__back"
|
||||||
|
hover-class="ns-mp-navbar__back--hover"
|
||||||
|
hover-stay-time="80"
|
||||||
|
@tap="onBack"
|
||||||
|
>
|
||||||
|
<text class="iconfont icon-back_light ns-mp-navbar__arrow" :style="{ color }"></text>
|
||||||
|
<text class="ns-mp-navbar__text" :style="{ color }">{{ backText }}</text>
|
||||||
|
</view>
|
||||||
|
<slot name="left"></slot>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="ns-mp-navbar__title" :style="{ color }">{{ title }}</view>
|
||||||
|
|
||||||
|
<view class="ns-mp-navbar__side ns-mp-navbar__side--right">
|
||||||
|
<slot name="right"></slot>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 状态栏 / 胶囊信息:用于算出与原生导航栏一致的布局高度
|
||||||
|
let systemInfo = {};
|
||||||
|
let menuButtonInfo = { top: 0, height: 32, right: 0, width: 0 };
|
||||||
|
|
||||||
|
try {
|
||||||
|
systemInfo = uni.getSystemInfoSync() || {};
|
||||||
|
} catch (e) {
|
||||||
|
systemInfo = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// #ifdef MP-WEIXIN || MP-BAIDU || MP-TOUTIAO || MP-QQ || MP-HARMONY
|
||||||
|
try {
|
||||||
|
menuButtonInfo = uni.getMenuButtonBoundingClientRect() || menuButtonInfo;
|
||||||
|
} catch (e) {}
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'ns-mp-navbar',
|
||||||
|
props: {
|
||||||
|
// 中间标题
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
// 导航栏背景色
|
||||||
|
bgColor: {
|
||||||
|
type: String,
|
||||||
|
default: '#ffffff'
|
||||||
|
},
|
||||||
|
// 文字 / 图标颜色
|
||||||
|
color: {
|
||||||
|
type: String,
|
||||||
|
default: '#000000'
|
||||||
|
},
|
||||||
|
// 返回按钮文字(原生导航栏只能显示 "<",这里可自定义)
|
||||||
|
backText: {
|
||||||
|
type: String,
|
||||||
|
default: '返回'
|
||||||
|
},
|
||||||
|
// 是否显示返回按钮
|
||||||
|
showBack: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
// 是否悬浮在页面之上(沉浸式页面用;false 时占文档流,会把内容往下推)
|
||||||
|
fixed: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
// 无页面栈时(如分享直接进入)跳转的首页地址
|
||||||
|
homePath: {
|
||||||
|
type: String,
|
||||||
|
default: '/pages/index/index'
|
||||||
|
},
|
||||||
|
// 自定义返回逻辑
|
||||||
|
customBack: {
|
||||||
|
type: Function,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
// H5 端是否渲染(H5 由 uni-app 自带头像栏,默认不渲染,避免双导航栏)
|
||||||
|
showOnH5: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
statusBarHeight: systemInfo.statusBarHeight || 20,
|
||||||
|
navBarHeight: 44,
|
||||||
|
visible: true,
|
||||||
|
// 页面栈里是否还有上级页面(无上级页面时原生导航栏也不显示返回键)
|
||||||
|
canBack: true
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
// 导航栏总高度(状态栏 + 内容区),页面如需自行占位可取该值
|
||||||
|
navbarHeight() {
|
||||||
|
return this.statusBarHeight + this.navBarHeight;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
// 导航栏内容区高度:MP 端按胶囊位置推算,与原生导航栏保持一致;其余端用 44
|
||||||
|
// #ifdef MP
|
||||||
|
const statusBarHeight = this.statusBarHeight;
|
||||||
|
const h = (menuButtonInfo.top - statusBarHeight) * 2 + menuButtonInfo.height;
|
||||||
|
// 兜底:胶囊信息异常时退回 44
|
||||||
|
this.navBarHeight = h > 0 ? h : 44;
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// #ifdef H5
|
||||||
|
this.visible = this.showOnH5;
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
// 页面栈只有一页(分享/扫码直达)时,原生导航栏也无返回键,这里保持一致
|
||||||
|
const pages = (typeof getCurrentPages === 'function' && getCurrentPages()) || [];
|
||||||
|
this.canBack = pages.length > 1;
|
||||||
|
// 把实际总高度抛给页面,便于页面修正内容区高度,避免多出一屏空白
|
||||||
|
this.$emit('ready', this.statusBarHeight + this.navBarHeight);
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onBack() {
|
||||||
|
if (typeof this.customBack === 'function') {
|
||||||
|
this.customBack();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pages = (typeof getCurrentPages === 'function' && getCurrentPages()) || [];
|
||||||
|
if (pages.length > 1) {
|
||||||
|
uni.navigateBack();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 顶层页面(分享 / 扫码直接进入):回首页
|
||||||
|
uni.switchTab({
|
||||||
|
url: this.homePath,
|
||||||
|
fail: () => {
|
||||||
|
uni.reLaunch({ url: this.homePath });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.ns-mp-navbar {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ns-mp-navbar.is-fixed {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 990;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ns-mp-navbar__inner {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ns-mp-navbar__side {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 160rpx;
|
||||||
|
padding-left: 20rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ns-mp-navbar__side--right {
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ns-mp-navbar__back {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10rpx 20rpx 10rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ns-mp-navbar__back--hover {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ns-mp-navbar__arrow {
|
||||||
|
font-size: 34rpx;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ns-mp-navbar__text {
|
||||||
|
margin-left: 2rpx;
|
||||||
|
font-size: 30rpx;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ns-mp-navbar__title {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 160rpx;
|
||||||
|
font-size: 32rpx;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-align: center;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -567,6 +567,12 @@ export default {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
margin-right: 30rpx;
|
margin-right: 30rpx;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
/* 关键修复: 微信 <button> 原生默认 padding-left/right: 14px 没有被重置,
|
||||||
|
* 会挤压按钮内容区。真机窄屏(375px 以下)上「同意并继续」5 个字被裁成
|
||||||
|
* 「同意并继」。这里显式清零 padding 并锁定 border-box。 */
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.privacy-button-btn::after {
|
.privacy-button-btn::after {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
# AI Agent 接入使用说明 (Uniapp 前端)
|
# AI Agent 接入使用说明 (Uniapp 前端)
|
||||||
|
|
||||||
> 适用版本: backend PR-WB-MP-INLINE-MEDIA (2026-09-16) 及之后; PR-OSS-REGISTRY (2026-09-15) 之后 12 个云存储 provider
|
> 适用版本: backend PR-WB-MP-INLINE-MEDIA (2026-09-16) 及之后; PR-OSS-REGISTRY (2026-09-15) 之后 12 个云存储 provider
|
||||||
> 适用前端: `D:/projects/shop-projects/frontend` (uniapp 多端: 微信小程序 MP / H5 / App / Harmony)
|
> 适用前端: 本仓库 (uniapp 多端: 微信小程序 MP / H5 / App / Harmony)
|
||||||
> 配套阅读:
|
> 配套阅读:
|
||||||
> - `common/js/ai-service.js`、`pages_tool/ai-chat/`
|
> - `common/js/ai-service.js`、`pages_tool/ai-chat/`
|
||||||
> - 后端 `docs/mp-media-upload-integration.md` (媒体上传后端单点真源)
|
> - 后端 `docs/mp-media-upload-integration.md` (媒体上传后端单点真源)
|
||||||
> - 后端 `docs/ai-agent-protocol-spec.md` (本文档对应的完整后端协议规范, 仓内后端目录)
|
> - 后端 `docs/ai-agent-protocol-spec.md` (本文档对应的完整后端协议规范, 仓内后端目录)
|
||||||
> - `D:/projects/refs/codebuddy-agent-sdk/docs/wb-cloud-sdk-coverage.md` (WB Cloud SDK 协议约束矩阵 — J7/J9/J10/J1/Q6)
|
> - WB Cloud SDK 协议约束矩阵 (`docs/wb-cloud-sdk-coverage.md`, WB Cloud SDK 参考仓内) (J7/J9/J10/J1/Q6)
|
||||||
|
|
||||||
本文档面向前端工程师, 解决把 WorkBuddy Agent / AI 智能客服集成进 uniapp 时
|
本文档面向前端工程师, 解决把 WorkBuddy Agent / AI 智能客服集成进 uniapp 时
|
||||||
最常踩的 4 个坑:
|
最常踩的 4 个坑:
|
||||||
@@ -2177,7 +2177,7 @@ uniapp 内联 `:style` 绑定的 rpx 在 H5 端被浏览器丢弃, CSS 文件里
|
|||||||
| 媒体上传后端单点真源 | `backend/docs/mp-media-upload-integration.md` | 后端 media_policy / HINTS / STS 错误翻译 |
|
| 媒体上传后端单点真源 | `backend/docs/mp-media-upload-integration.md` | 后端 media_policy / HINTS / STS 错误翻译 |
|
||||||
| WB Agent 平台协议 | `backend/src/addon/aiagent/docs/workbuddy_api.md` | ACP / AgentOS REST+ACP 双轨 |
|
| WB Agent 平台协议 | `backend/src/addon/aiagent/docs/workbuddy_api.md` | ACP / AgentOS REST+ACP 双轨 |
|
||||||
| **后端协议完整规范** | `backend/docs/ai-agent-protocol-spec.md` | SSE/WS 帧结构 + REST 端点 + A/B 路径差异 |
|
| **后端协议完整规范** | `backend/docs/ai-agent-protocol-spec.md` | SSE/WS 帧结构 + REST 端点 + A/B 路径差异 |
|
||||||
| **WB Cloud SDK 协议约束矩阵** | `D:/projects/refs/codebuddy-agent-sdk/docs/wb-cloud-sdk-coverage.md` | J7 (inline base64 ✅) / J9 (URL fetch, 服务端策略) / J10 (无 `/uploads/*` API) / J1 (KB 挂载) / Q6 (source_app 白名单) |
|
| **WB Cloud SDK 协议约束矩阵** | `docs/wb-cloud-sdk-coverage.md` (WB Cloud SDK 参考仓) | J7 (inline base64 ✅) / J9 (URL fetch, 服务端策略) / J10 (无 `/uploads/*` API) / J1 (KB 挂载) / Q6 (source_app 白名单) |
|
||||||
| **后端 PR-WB-MP-INLINE-MEDIA ANALYSIS** | `backend/tests/wb_media_backend/ANALYSIS.md` §8 | attachments.data 字段 PR 详情 + 4 处文件改动面 + 协议层 A/B 对称修复 + 26 项 sanity 测试结果 |
|
| **后端 PR-WB-MP-INLINE-MEDIA ANALYSIS** | `backend/tests/wb_media_backend/ANALYSIS.md` §8 | attachments.data 字段 PR 详情 + 4 处文件改动面 + 协议层 A/B 对称修复 + 26 项 sanity 测试结果 |
|
||||||
| ai-chat 前端入口 | `frontend/pages_tool/ai-chat/index.vue` | 路由 + agent_id 接收 |
|
| ai-chat 前端入口 | `frontend/pages_tool/ai-chat/index.vue` | 路由 + agent_id 接收 |
|
||||||
| ai-chat 消息渲染 | `frontend/pages_tool/ai-chat/ai-chat-message.vue` | 流式消息 / 历史 / 预览 |
|
| ai-chat 消息渲染 | `frontend/pages_tool/ai-chat/ai-chat-message.vue` | 流式消息 / 历史 / 预览 |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 国际化(i18n)全量审计报告
|
# 国际化(i18n)全量审计报告
|
||||||
|
|
||||||
> 审计范围:`z:\D\projects\shop-projects\frontend` 下所有 `.vue` / `.js` 文件(排除 `node_modules`、`uni_modules`、`unpackage`、`docs`、`scripts` 等)
|
> 审计范围:项目根目录下所有 `.vue` / `.js` 文件(排除 `node_modules`、`uni_modules`、`unpackage`、`docs`、`scripts` 等)
|
||||||
> 审计时间:2026-06-29
|
> 审计时间:2026-06-29
|
||||||
> 当前静态校验:`node scripts/i18n-check.js` 通过(zh-cn / en-us key 完全一致)
|
> 当前静态校验:`node scripts/i18n-check.js` 通过(zh-cn / en-us key 完全一致)
|
||||||
> 修复状态:P0 / P1 / P2(兜底 + 验证)已修复并验证 ✅
|
> 修复状态:P0 / P1 / P2(兜底 + 验证)已修复并验证 ✅
|
||||||
|
|||||||
@@ -106,8 +106,9 @@ background: linear-gradient(<角度>, var(--token-start, #原始色) 0%, var(--t
|
|||||||
## 四、强制兼容性测试项(上 HBuilder 前必做)
|
## 四、强制兼容性测试项(上 HBuilder 前必做)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# 以下命令均在项目根目录(含 package.json)下执行
|
||||||
|
|
||||||
# 1. 静态语法检查
|
# 1. 静态语法检查
|
||||||
cd D:/projects/shop-projects/frontend
|
|
||||||
node --check common/js/theme-override.js
|
node --check common/js/theme-override.js
|
||||||
node --check store/index.js
|
node --check store/index.js
|
||||||
# App.vue 走 vue 编译器,不在 node 范畴
|
# App.vue 走 vue 编译器,不在 node 范畴
|
||||||
|
|||||||
@@ -105,38 +105,4 @@ components/mescroll/components/mescroll-empty.json
|
|||||||
|
|
||||||
有些"修模拟器表现"的尝试(@load 多次重试 setNavigationBarTitle、加看门狗定时器)会让真机代码变得复杂、可能和内嵌页 `document.title` 抢控制权、引入边界 case。
|
有些"修模拟器表现"的尝试(@load 多次重试 setNavigationBarTitle、加看门狗定时器)会让真机代码变得复杂、可能和内嵌页 `document.title` 抢控制权、引入边界 case。
|
||||||
|
|
||||||
**结论**:遇到 navigationBar / web-view 相关问题,先在真机上复现确认;只在真机还在问题再继续查代码。
|
**结论**:遇到 navigationBar / web-view 相关问题,先在真机上复现确认;只在真机还在问题再继续查代码。
|
||||||
|
|
||||||
#### 4. `setClipboardData:fail api scope is not declared in the privacy agreement`
|
|
||||||
|
|
||||||
**症状**:AI 智能(`pages_tool/ai-chat`)复制消息时 Toast 提示
|
|
||||||
`复制失败:setClipboardData:fail api scope is not declared in the privacy agreement`,
|
|
||||||
H5 正常,**只有微信小程序失败**。
|
|
||||||
|
|
||||||
**根因**:微信自 2023-09-15 起把剪贴板接口(`setClipboardData` / `getClipboardData`)纳入
|
|
||||||
《小程序用户隐私保护指引》。小程序后台未声明「剪贴板」,或声明还在审核中时,调用直接失败,
|
|
||||||
返回 `errno: 112`。这是**平台侧配置问题,不是代码问题**:
|
|
||||||
`app.json` 的 `permission` / `requiredPrivateInfos` 都不涉及剪贴板,改代码无法绕过。
|
|
||||||
|
|
||||||
**正确处理**:
|
|
||||||
- 微信公众平台 → 设置 → 基本设置 → 服务内容声明 → 用户隐私保护指引 → 更新 →
|
|
||||||
在「开发者收集你的……信息」中勾选「剪贴板」并填写用途 → 提交审核;
|
|
||||||
**审核通过后才生效**,通过后重新编译/发版即恢复。
|
|
||||||
- 代码侧只做兜底:`common/js/uniapp.utils.js` 的 `isClipboardPrivacyError()` 识别
|
|
||||||
`errno === 112` / errMsg 含 `privacy agreement`,用 i18n 文案替换微信原始英文错误
|
|
||||||
(AI 聊天页 `copyMessage` 同样处理),不要把平台英文报错抛给用户。
|
|
||||||
- 开发期想临时绕过校验:把 `manifest.json` → `mp-weixin` → `"__usePrivacyCheck__"` 改成 `false`
|
|
||||||
(**仅限本地调试**,上线前必须还原,并按上面的后台配置完成声明)。
|
|
||||||
|
|
||||||
**注意**:根目录的 `privacy.json`(内容为 `Scope.writeClipboard`)不是微信的隐私声明机制,
|
|
||||||
未被任何代码或构建流程引用,不要把它当成剪贴板隐私的解决方案。
|
|
||||||
|
|
||||||
**代码侧备选(无法改后台、只想先跑起来时)**:
|
|
||||||
- 写剪贴板在微信里只有 `wx.setClipboardData` 这一个 JS API,平台拦截 errno 112 时**任何代码都绕不过**,
|
|
||||||
所以「复制」按钮在没声明剪贴板权限前必然失败。
|
|
||||||
- AI 回复的**展示**走 `<rich-text>` 富文本(豆包风格:加粗标题、悬挂缩进列表、彩色链接),由
|
|
||||||
`parseMarkdown()` 生成 HTML、`getAIDisplayContent()` 输出,H5 用 `v-html`、微信小程序用 `<rich-text :nodes>`。
|
|
||||||
- 富文本**不可长按选区复制**(微信 rich-text 不支持),复制只能走「复制」按钮(即 `wx.setClipboardData`),
|
|
||||||
因此必须声明剪贴板权限,或开发期把 `manifest.json` → `mp-weixin` → `"__usePrivacyCheck__"` 改 `false`
|
|
||||||
(仅本地调试,上线前必须还原)。
|
|
||||||
- 失败时提示会引导用户复制不可用(`isClipboardPrivacyError()` 识别 errno 112)。
|
|
||||||
@@ -2625,7 +2625,7 @@
|
|||||||
},
|
},
|
||||||
"ai-chat": {
|
"ai-chat": {
|
||||||
"index": {
|
"index": {
|
||||||
"title": "AI智能",
|
"title": "AI智能客服",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
"send": "发送",
|
"send": "发送",
|
||||||
@@ -2642,7 +2642,7 @@
|
|||||||
"loadMoreHistory": "加载更多历史消息",
|
"loadMoreHistory": "加载更多历史消息",
|
||||||
"loadingHistory": "加载历史消息中...",
|
"loadingHistory": "加载历史消息中...",
|
||||||
"aiThinking": "AI正在思考",
|
"aiThinking": "AI正在思考",
|
||||||
"receivingReply": "思考中",
|
"receivingReply": "正在接收回复",
|
||||||
"aiThinkingStatus1": "AI正在思考",
|
"aiThinkingStatus1": "AI正在思考",
|
||||||
"aiThinkingStatus2": "AI正在分析",
|
"aiThinkingStatus2": "AI正在分析",
|
||||||
"aiThinkingStatus3": "AI正在检索",
|
"aiThinkingStatus3": "AI正在检索",
|
||||||
@@ -2652,10 +2652,6 @@
|
|||||||
"noContentToCopy": "无可复制的内容",
|
"noContentToCopy": "无可复制的内容",
|
||||||
"copySuccess": "已复制",
|
"copySuccess": "已复制",
|
||||||
"copySelected": "已复制选中内容",
|
"copySelected": "已复制选中内容",
|
||||||
"selectTextToCopy": "选取文字复制",
|
|
||||||
"selectedText": "已选文字",
|
|
||||||
"copyAll": "复制全部",
|
|
||||||
"selectTextTip": "长按文字拖动两端手柄,可自由选择复制范围",
|
|
||||||
"copyFail": "复制失败",
|
"copyFail": "复制失败",
|
||||||
"copyFailWithReason": "复制失败:{reason}",
|
"copyFailWithReason": "复制失败:{reason}",
|
||||||
"openingFile": "打开文件中...",
|
"openingFile": "打开文件中...",
|
||||||
|
|||||||
@@ -22,14 +22,25 @@ export const lang = {
|
|||||||
// ====== ai-chat-message ======
|
// ====== ai-chat-message ======
|
||||||
aiNickname: 'AI Assistant',
|
aiNickname: 'AI Assistant',
|
||||||
defaultNickname: 'Me',
|
defaultNickname: 'Me',
|
||||||
|
// PR-FRONTEND-VOICE-INPUT (2026-09-24): query text for attachment-only messages
|
||||||
|
// audioMessage / videoMessage: short label for the user-bubble filename fallback (still used by bubble template)
|
||||||
|
// *Instruction* keys: explicit directive sent to the AI as the query.
|
||||||
|
// Just a label gets ignored — the AI needs an explicit "please listen/look"
|
||||||
|
// to actually invoke STT/vision/file parsing on the attachment payload.
|
||||||
audioMessage: 'Voice Message',
|
audioMessage: 'Voice Message',
|
||||||
videoMessage: 'Video Message',
|
videoMessage: 'Video Message',
|
||||||
|
audioInstruction: 'I sent a voice message. Please listen to it and reply.',
|
||||||
|
audioInstructionWithDuration: 'I sent a voice message ({seconds}s). Please listen to it and reply.',
|
||||||
|
imageInstruction: 'I sent an image. Please look at it and reply.',
|
||||||
|
videoInstruction: 'I sent a video. Please watch it and reply.',
|
||||||
|
fileInstruction: 'I sent a file. Please review it and reply.',
|
||||||
|
attachmentInstruction: 'I sent attachments. Please review them and reply.',
|
||||||
inputPlaceholder: 'Please enter your question...',
|
inputPlaceholder: 'Please enter your question...',
|
||||||
expandEditorTitle: 'Edit Content',
|
expandEditorTitle: 'Edit Content',
|
||||||
loadMoreHistory: 'Load more history',
|
loadMoreHistory: 'Load more history',
|
||||||
loadingHistory: 'Loading history...',
|
loadingHistory: 'Loading history...',
|
||||||
aiThinking: 'AI is thinking',
|
aiThinking: 'AI is thinking',
|
||||||
receivingReply: 'Thinking',
|
receivingReply: 'Receiving reply',
|
||||||
aiThinkingStatus1: 'AI is thinking',
|
aiThinkingStatus1: 'AI is thinking',
|
||||||
aiThinkingStatus2: 'AI is analyzing',
|
aiThinkingStatus2: 'AI is analyzing',
|
||||||
aiThinkingStatus3: 'AI is searching',
|
aiThinkingStatus3: 'AI is searching',
|
||||||
@@ -41,7 +52,6 @@ export const lang = {
|
|||||||
copySelected: 'Copied selected content',
|
copySelected: 'Copied selected content',
|
||||||
copyFail: 'Copy failed',
|
copyFail: 'Copy failed',
|
||||||
copyFailWithReason: 'Copy failed: {reason}',
|
copyFailWithReason: 'Copy failed: {reason}',
|
||||||
copyFailPrivacyReason: 'clipboard permission not declared, copy unavailable',
|
|
||||||
openingFile: 'Opening file...',
|
openingFile: 'Opening file...',
|
||||||
openingFileName: 'Opening file: {name}',
|
openingFileName: 'Opening file: {name}',
|
||||||
openingFileTitle: 'Opening: {title}',
|
openingFileTitle: 'Opening: {title}',
|
||||||
@@ -130,12 +140,37 @@ export const lang = {
|
|||||||
chooseVideoFail: 'Failed to select video',
|
chooseVideoFail: 'Failed to select video',
|
||||||
audioNotReady: 'Audio not ready',
|
audioNotReady: 'Audio not ready',
|
||||||
location: 'Location',
|
location: 'Location',
|
||||||
|
// PR-AUTO-LOCATION (2026-09-28): automatic location permission prompts
|
||||||
|
locationPermissionTitle: 'Location Permission Required',
|
||||||
|
locationPermissionH5: 'Please enable location permission via the lock icon in your browser address bar, then resend the message.',
|
||||||
|
locationPermissionMp: 'To help the AI better answer location-related questions, please enable location permission.\n\nTap "Open Settings" to enable it in your system settings, then return to this page and resend the message.',
|
||||||
|
locationSettingBtn: 'Open Settings',
|
||||||
|
locationSkipBtn: 'Not Now',
|
||||||
|
locationH5GuideBtn: 'Got it',
|
||||||
|
|
||||||
// ====== ai-chat-voice-panel ======
|
// ====== ai-chat-voice-panel ======
|
||||||
voiceInputTitle: 'Voice Input',
|
voiceInputTitle: 'Voice Input',
|
||||||
recordingTip: 'Recording, release to end',
|
recordingTip: 'Recording, release to end',
|
||||||
startRecordTip: 'Tap to start recording',
|
startRecordTip: 'Tap to start recording',
|
||||||
voiceInputFailed: 'Voice input failed',
|
voiceInputFailed: 'Voice input failed',
|
||||||
|
// PR-FRONTEND-VOICE-INPUT (2026-09-24): press-and-hold gesture + recorder error messages
|
||||||
|
holdToTalk: 'Hold to talk',
|
||||||
|
releaseToSend: 'Release to send',
|
||||||
|
releaseToCancel: 'Release to cancel',
|
||||||
|
slideUpToCancel: '↑ Slide up to cancel',
|
||||||
|
slideUpToCancelHint: '↑ Slide up to cancel',
|
||||||
|
// PR-INPUT-PLACEHOLDER-DYNAMIC (2026-09-28): dynamic input placeholder by context
|
||||||
|
placeholderDefault: 'Type a message or hold to talk',
|
||||||
|
placeholderWithAudio: 'Add a note for your voice (optional)',
|
||||||
|
placeholderWithMedia: 'Add a caption (optional)',
|
||||||
|
slideUpToCancelHint: '↑ Release to cancel',
|
||||||
|
voiceStartFailed: 'Cannot start recording',
|
||||||
|
voiceStopFailed: 'Recording failed, please try again',
|
||||||
|
voiceNotSupported: 'Voice input is not supported on this platform',
|
||||||
|
voiceTooShort: 'Speaking time too short',
|
||||||
|
// PR-FRONTEND-VOICE-INPUT (2026-09-24): upload error (recorder result -> cloud)
|
||||||
|
uploadFailed: 'Upload failed',
|
||||||
|
uploadFailedWithReason: 'Upload failed: {reason}',
|
||||||
|
|
||||||
// ====== index.vue - Menu ======
|
// ====== index.vue - Menu ======
|
||||||
menuClearChat: 'Clear Chat',
|
menuClearChat: 'Clear Chat',
|
||||||
@@ -150,7 +185,7 @@ export const lang = {
|
|||||||
logEventHandleError: 'Event handling error',
|
logEventHandleError: 'Event handling error',
|
||||||
logInitNavbarFailed: 'Failed to initialize navigation bar',
|
logInitNavbarFailed: 'Failed to initialize navigation bar',
|
||||||
logChatComponentNotInit: 'Chat component not initialized',
|
logChatComponentNotInit: 'Chat component not initialized',
|
||||||
logServicerConfigNotLoaded: '[AI Customer Service] servicerConfig not loaded, retrying after 200ms...',
|
logServicerConfigNotLoaded: '[AI Customer Service] servicerConfig not loaded, retrying after 200ms...'
|
||||||
|
|
||||||
// ====== AI Agent general copy (customer service is the first agent) ======
|
// ====== AI Agent general copy (customer service is the first agent) ======
|
||||||
aiAgentDefaultTitle: 'AI Agent',
|
aiAgentDefaultTitle: 'AI Agent',
|
||||||
|
|||||||
@@ -126,8 +126,6 @@ export const lang = {
|
|||||||
copyTimeoutReason: 'Reason:',
|
copyTimeoutReason: 'Reason:',
|
||||||
wechatClipboardReason1: '1. WeChat Platform -> User Privacy Protection Guidelines -> "Clipboard" feature not added or review not passed;',
|
wechatClipboardReason1: '1. WeChat Platform -> User Privacy Protection Guidelines -> "Clipboard" feature not added or review not passed;',
|
||||||
wechatClipboardReason2: '2. WeChat platform has restrictions on clipboard API call frequency',
|
wechatClipboardReason2: '2. WeChat platform has restrictions on clipboard API call frequency',
|
||||||
// readable tip when clipboard privacy scope is not declared (errno 112)
|
|
||||||
clipboardPrivacyNotDeclared: 'Clipboard privacy permission is not configured',
|
|
||||||
copyFailed: 'Copy failed',
|
copyFailed: 'Copy failed',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
83
lang/en-us/components-diy/diy-recommend-code.js
Normal file
83
lang/en-us/components-diy/diy-recommend-code.js
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
export const lang = {
|
||||||
|
// diy-recommend-code referral code component
|
||||||
|
// Title
|
||||||
|
title: 'My Referral Code',
|
||||||
|
// Share now button
|
||||||
|
buttonText: 'Share Now',
|
||||||
|
// Copy code button
|
||||||
|
copyButtonText: 'Copy Code',
|
||||||
|
// Get a new code
|
||||||
|
placeholderRegenerateText: 'Get a New Code',
|
||||||
|
// Consent description
|
||||||
|
consentText: 'Your referral code is used to track enterprise registrations via your shared link. We record each application completed through your link for commission settlement.',
|
||||||
|
// Agree and get code button
|
||||||
|
consentButtonText: 'Agree & Get Code',
|
||||||
|
// Login guide tip
|
||||||
|
loginTipText: 'Log in to view your referral code',
|
||||||
|
// Log in button
|
||||||
|
loginBtnText: 'Log In',
|
||||||
|
// Poster QR tip
|
||||||
|
posterQrTip: 'Scan to enter and fill in registration info',
|
||||||
|
// Exclusive code label
|
||||||
|
exclusiveCode: 'Exclusive Code',
|
||||||
|
// Code pending label
|
||||||
|
pendingCode: 'Code Pending',
|
||||||
|
// Pending
|
||||||
|
pending: 'Pending',
|
||||||
|
// Loading code
|
||||||
|
loading: 'Loading code...',
|
||||||
|
// Code obtained label
|
||||||
|
obtainedCode: 'Code Obtained',
|
||||||
|
// Obtained
|
||||||
|
obtained: 'Obtained',
|
||||||
|
// Share invite reward description
|
||||||
|
shareInviteReward: 'Share with friends and earn rewards for invites',
|
||||||
|
// Active
|
||||||
|
activated: '✓ Active',
|
||||||
|
// Code generated description
|
||||||
|
codeGenerated: 'Code generated. Use it to invite friends or bind referral for enterprise registration',
|
||||||
|
// Submitting
|
||||||
|
submitting: 'Submitting...',
|
||||||
|
// Share title
|
||||||
|
shareTitle: 'Inviting you to join the low-altitude economy platform',
|
||||||
|
// Get failed
|
||||||
|
getFail: 'Failed to get code. Please retry later',
|
||||||
|
// Get failed (business code)
|
||||||
|
getFailCode: 'Failed to get code (business code={code})',
|
||||||
|
// Get success
|
||||||
|
getSuccess: 'Obtained',
|
||||||
|
// Network error
|
||||||
|
netError: 'Network error. Please retry later',
|
||||||
|
// Renew code modal title
|
||||||
|
regenTitle: 'Renew Code',
|
||||||
|
// Renew code modal content
|
||||||
|
regenContent: 'After renewal, the old code becomes invalid immediately. All registrations via the old link will no longer be attributed to you. Continue?',
|
||||||
|
// Confirm renew
|
||||||
|
regenConfirm: 'Confirm Renew',
|
||||||
|
// Cancel
|
||||||
|
regenCancel: 'Cancel',
|
||||||
|
// Renew failed
|
||||||
|
regenFail: 'Renewal failed. Please retry later',
|
||||||
|
// Renew success (old disabled)
|
||||||
|
regenSuccessOld: 'New code issued. Old code {old} disabled',
|
||||||
|
// Renew success
|
||||||
|
regenSuccess: 'Renewed',
|
||||||
|
// Code not ready yet
|
||||||
|
codeNotReady: 'Code not ready yet. Please wait',
|
||||||
|
// Code copied
|
||||||
|
codeCopied: 'Code copied',
|
||||||
|
// Copy failed
|
||||||
|
copyFail: 'Copy failed',
|
||||||
|
// Copy content is empty
|
||||||
|
copyEmpty: 'Content is empty',
|
||||||
|
// Copy after code finishes loading
|
||||||
|
loadThenCopy: 'Copy after code finishes loading',
|
||||||
|
// Share link copied
|
||||||
|
linkCopied: 'Share link copied',
|
||||||
|
// Poster subtitle
|
||||||
|
posterShareInvite: 'Share with friends, earn rewards for invites',
|
||||||
|
// Poster link prefix
|
||||||
|
posterLinkPrefix: 'Link:',
|
||||||
|
// Poster link not generated
|
||||||
|
posterLinkEmpty: '(not generated)',
|
||||||
|
}
|
||||||
5
lang/en-us/components-diy/diy-webview.js
Normal file
5
lang/en-us/components-diy/diy-webview.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export const lang = {
|
||||||
|
// diy-webview webview component
|
||||||
|
emptyTip: 'Please configure the web link',
|
||||||
|
loadError: 'Failed to load web page',
|
||||||
|
}
|
||||||
4
lang/en-us/files/detail.js
Normal file
4
lang/en-us/files/detail.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export const lang = {
|
||||||
|
//title为每个页面的标题
|
||||||
|
title: 'File Details',
|
||||||
|
}
|
||||||
@@ -22,14 +22,24 @@ export const lang = {
|
|||||||
// ====== ai-chat-message ======
|
// ====== ai-chat-message ======
|
||||||
aiNickname: '智能助手',
|
aiNickname: '智能助手',
|
||||||
defaultNickname: '我',
|
defaultNickname: '我',
|
||||||
|
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 纯附件时发给后端的 query 文案
|
||||||
|
// audioMessage / videoMessage: 用户气泡里文件名的兜底 (短标签, 仍在 bubble 模板用)
|
||||||
|
// *Instruction* 系列: 纯附件时发给 AI 的 query, 显式要求 AI "去看/听" 附件内容
|
||||||
|
// AI 必须看到指令才会主动调 STT / 视觉 / 文件解析, 光有 label 容易被忽略
|
||||||
audioMessage: '语音消息',
|
audioMessage: '语音消息',
|
||||||
videoMessage: '视频消息',
|
videoMessage: '视频消息',
|
||||||
|
audioInstruction: '我发送了一段语音,请听一下并回复我',
|
||||||
|
audioInstructionWithDuration: '我发送了一段语音({seconds} 秒),请听一下并回复我',
|
||||||
|
imageInstruction: '我发送了一张图片,请查看图片内容并回复我',
|
||||||
|
videoInstruction: '我发送了一段视频,请查看视频内容并回复我',
|
||||||
|
fileInstruction: '我发送了一个文件,请查看文件内容并回复我',
|
||||||
|
attachmentInstruction: '我发送了附件,请查看附件内容并回复我',
|
||||||
inputPlaceholder: '请输入您的问题...',
|
inputPlaceholder: '请输入您的问题...',
|
||||||
expandEditorTitle: '编辑内容',
|
expandEditorTitle: '编辑内容',
|
||||||
loadMoreHistory: '加载更多历史消息',
|
loadMoreHistory: '加载更多历史消息',
|
||||||
loadingHistory: '加载历史消息中...',
|
loadingHistory: '加载历史消息中...',
|
||||||
aiThinking: 'AI正在思考',
|
aiThinking: 'AI正在思考',
|
||||||
receivingReply: '思考中',
|
receivingReply: '正在接收回复',
|
||||||
aiThinkingStatus1: 'AI正在思考',
|
aiThinkingStatus1: 'AI正在思考',
|
||||||
aiThinkingStatus2: 'AI正在分析',
|
aiThinkingStatus2: 'AI正在分析',
|
||||||
aiThinkingStatus3: 'AI正在检索',
|
aiThinkingStatus3: 'AI正在检索',
|
||||||
@@ -41,7 +51,6 @@ export const lang = {
|
|||||||
copySelected: '已复制选中内容',
|
copySelected: '已复制选中内容',
|
||||||
copyFail: '复制失败',
|
copyFail: '复制失败',
|
||||||
copyFailWithReason: '复制失败:{reason}',
|
copyFailWithReason: '复制失败:{reason}',
|
||||||
copyFailPrivacyReason: '小程序未声明剪贴板权限,复制不可用',
|
|
||||||
openingFile: '打开文件中...',
|
openingFile: '打开文件中...',
|
||||||
openingFileName: '正在打开文件:{name}',
|
openingFileName: '正在打开文件:{name}',
|
||||||
openingFileTitle: '正在打开:{title}',
|
openingFileTitle: '正在打开:{title}',
|
||||||
@@ -130,12 +139,38 @@ export const lang = {
|
|||||||
chooseVideoFail: '选择视频失败',
|
chooseVideoFail: '选择视频失败',
|
||||||
audioNotReady: '音频尚未就绪',
|
audioNotReady: '音频尚未就绪',
|
||||||
location: '位置',
|
location: '位置',
|
||||||
|
// PR-AUTO-LOCATION (2026-09-28): 自动定位权限提示
|
||||||
|
locationPermissionTitle: '需要位置权限',
|
||||||
|
locationPermissionH5: '请在浏览器地址栏左侧的锁形图标中开启位置权限,然后重新发送消息。',
|
||||||
|
locationPermissionMp: '为了让 AI 更好地回答位置相关问题,需要您开启位置权限。\n\n点击"去设置"在系统设置中开启后,请回到本页面再次发送消息。',
|
||||||
|
locationSettingBtn: '去设置',
|
||||||
|
locationSkipBtn: '暂不开启',
|
||||||
|
locationH5GuideBtn: '我知道了',
|
||||||
|
|
||||||
// ====== ai-chat-voice-panel ======
|
// ====== ai-chat-voice-panel ======
|
||||||
voiceInputTitle: '语音输入',
|
voiceInputTitle: '语音输入',
|
||||||
recordingTip: '正在录音,松开结束',
|
recordingTip: '正在录音,松开结束',
|
||||||
startRecordTip: '点击开始录音',
|
startRecordTip: '点击开始录音',
|
||||||
voiceInputFailed: '语音输入失败',
|
voiceInputFailed: '语音输入失败',
|
||||||
|
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 按住说话手势 + 录音异常文案
|
||||||
|
holdToTalk: '按住说话',
|
||||||
|
releaseToSend: '松开发送',
|
||||||
|
releaseToCancel: '松开取消',
|
||||||
|
slideUpToCancel: '↑ 上滑取消',
|
||||||
|
slideUpToCancelHint: '↑ 松开取消',
|
||||||
|
// PR-INPUT-PLACEHOLDER-DYNAMIC (2026-09-28): 输入框 placeholder 按上下文动态切换
|
||||||
|
// 默认 / 有语音附件 / 有其他媒体附件, 三态文案互不冲突
|
||||||
|
placeholderDefault: '发消息或按住说话',
|
||||||
|
placeholderWithAudio: '给语音留言加个说明(可选)',
|
||||||
|
placeholderWithMedia: '添加文字说明(可选)',
|
||||||
|
voiceStartFailed: '无法开始录音',
|
||||||
|
voiceStopFailed: '录音失败,请重试',
|
||||||
|
voiceNotSupported: '当前平台不支持语音输入',
|
||||||
|
voiceTooShort: '说话时间太短',
|
||||||
|
voiceRecorderBusy: '录音器正在初始化,请稍后再试',
|
||||||
|
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 上传失败 (录音产物上传到云端)
|
||||||
|
uploadFailed: '上传失败',
|
||||||
|
uploadFailedWithReason: '上传失败:{reason}',
|
||||||
|
|
||||||
// ====== index.vue - 菜单 ======
|
// ====== index.vue - 菜单 ======
|
||||||
menuClearChat: '清空聊天',
|
menuClearChat: '清空聊天',
|
||||||
@@ -150,7 +185,7 @@ export const lang = {
|
|||||||
logEventHandleError: '事件处理错误',
|
logEventHandleError: '事件处理错误',
|
||||||
logInitNavbarFailed: '初始化导航栏失败',
|
logInitNavbarFailed: '初始化导航栏失败',
|
||||||
logChatComponentNotInit: '聊天组件未初始化',
|
logChatComponentNotInit: '聊天组件未初始化',
|
||||||
logServicerConfigNotLoaded: '【AI客服】servicerConfig 未加载,200ms后重试...',
|
logServicerConfigNotLoaded: '【AI客服】servicerConfig 未加载,200ms后重试...'
|
||||||
|
|
||||||
// ====== AI Agent 通用文案(智能客服是首个 agent) ======
|
// ====== AI Agent 通用文案(智能客服是首个 agent) ======
|
||||||
aiAgentDefaultTitle: 'AI 智能体',
|
aiAgentDefaultTitle: 'AI 智能体',
|
||||||
|
|||||||
@@ -126,8 +126,6 @@ export const lang = {
|
|||||||
copyTimeoutReason: '原因:',
|
copyTimeoutReason: '原因:',
|
||||||
wechatClipboardReason1: '1.微信平台->用户隐私保护指引->"剪贴板"功能未添加或审核未通过;',
|
wechatClipboardReason1: '1.微信平台->用户隐私保护指引->"剪贴板"功能未添加或审核未通过;',
|
||||||
wechatClipboardReason2: '2.微信平台对剪贴板API调用频率有限制',
|
wechatClipboardReason2: '2.微信平台对剪贴板API调用频率有限制',
|
||||||
// 微信剪贴板隐私声明缺失(errno 112)时的可读提示
|
|
||||||
clipboardPrivacyNotDeclared: '剪贴板隐私权限未配置',
|
|
||||||
copyFailed: '复制失败',
|
copyFailed: '复制失败',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
83
lang/zh-cn/components-diy/diy-recommend-code.js
Normal file
83
lang/zh-cn/components-diy/diy-recommend-code.js
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
export const lang = {
|
||||||
|
// diy-recommend-code 推荐码组件
|
||||||
|
// 标题
|
||||||
|
title: '我的推荐码',
|
||||||
|
// 立即分享按钮
|
||||||
|
buttonText: '立即分享',
|
||||||
|
// 复制推荐码按钮
|
||||||
|
copyButtonText: '复制推荐码',
|
||||||
|
// 换一个新码
|
||||||
|
placeholderRegenerateText: '换一个新码',
|
||||||
|
// 同意说明文案
|
||||||
|
consentText: '您的推荐码将用于企业入驻来源追踪,我们会记录每一次通过您分享链接完成的入驻申请,用于您的推广收益结算。',
|
||||||
|
// 同意并获取推荐码按钮
|
||||||
|
consentButtonText: '同意并获取推荐码',
|
||||||
|
// 未登录引导提示
|
||||||
|
loginTipText: '登录后查看我的推荐码',
|
||||||
|
// 去登录按钮
|
||||||
|
loginBtnText: '去登录',
|
||||||
|
// 海报二维码提示
|
||||||
|
posterQrTip: '扫码进入并填写入驻信息',
|
||||||
|
// 专属推荐码标签
|
||||||
|
exclusiveCode: '专属推荐码',
|
||||||
|
// 待获取推荐码标签
|
||||||
|
pendingCode: '待获取推荐码',
|
||||||
|
// 待获取
|
||||||
|
pending: '待获取',
|
||||||
|
// 推荐码加载中
|
||||||
|
loading: '推荐码加载中...',
|
||||||
|
// 已获得推荐码标签
|
||||||
|
obtainedCode: '已获得推荐码',
|
||||||
|
// 已获得
|
||||||
|
obtained: '已获得',
|
||||||
|
// 分享邀请奖励描述
|
||||||
|
shareInviteReward: '分享给好友,邀请注册即可获得奖励',
|
||||||
|
// 已生效
|
||||||
|
activated: '✓ 已生效',
|
||||||
|
// 推荐码已生成描述
|
||||||
|
codeGenerated: '推荐码已生成,可用于邀请好友或企业入驻绑定推荐关系',
|
||||||
|
// 提交中
|
||||||
|
submitting: '提交中...',
|
||||||
|
// 分享标题
|
||||||
|
shareTitle: '邀您入驻低空经济产业平台',
|
||||||
|
// 获取失败
|
||||||
|
getFail: '推荐码获取失败,请稍后重试',
|
||||||
|
// 获取失败(业务码)
|
||||||
|
getFailCode: '推荐码获取失败(业务码={code})',
|
||||||
|
// 获取成功
|
||||||
|
getSuccess: '获取成功',
|
||||||
|
// 网络异常
|
||||||
|
netError: '网络异常,请稍后重试',
|
||||||
|
// 换新码弹窗标题
|
||||||
|
regenTitle: '换新码',
|
||||||
|
// 换新码弹窗内容
|
||||||
|
regenContent: '换新码后旧码会立即失效,后续所有使用旧分享链接的入驻申请都不会归属到您,确认继续?',
|
||||||
|
// 确认换新
|
||||||
|
regenConfirm: '确认换新',
|
||||||
|
// 取消
|
||||||
|
regenCancel: '取消',
|
||||||
|
// 换新失败
|
||||||
|
regenFail: '换新失败,请稍后重试',
|
||||||
|
// 换新成功(旧码停用)
|
||||||
|
regenSuccessOld: '已换新码,旧码 {old} 已停用',
|
||||||
|
// 换新成功
|
||||||
|
regenSuccess: '换新成功',
|
||||||
|
// 推荐码还未生成
|
||||||
|
codeNotReady: '推荐码还未生成,请稍候',
|
||||||
|
// 推荐码已复制
|
||||||
|
codeCopied: '推荐码已复制',
|
||||||
|
// 复制失败
|
||||||
|
copyFail: '复制失败',
|
||||||
|
// 复制内容为空
|
||||||
|
copyEmpty: '内容为空',
|
||||||
|
// 推荐码加载完成后再复制
|
||||||
|
loadThenCopy: '推荐码加载完成后再复制',
|
||||||
|
// 推广链接已复制
|
||||||
|
linkCopied: '推广链接已复制',
|
||||||
|
// 海报副标题
|
||||||
|
posterShareInvite: '分享给好友,邀请入驻获得奖励',
|
||||||
|
// 海报链接前缀
|
||||||
|
posterLinkPrefix: '链接:',
|
||||||
|
// 海报链接未生成
|
||||||
|
posterLinkEmpty: '(未生成)',
|
||||||
|
}
|
||||||
5
lang/zh-cn/components-diy/diy-webview.js
Normal file
5
lang/zh-cn/components-diy/diy-webview.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export const lang = {
|
||||||
|
// diy-webview 网页组件
|
||||||
|
emptyTip: '请配置网页链接',
|
||||||
|
loadError: '网页加载失败',
|
||||||
|
}
|
||||||
4
lang/zh-cn/files/detail.js
Normal file
4
lang/zh-cn/files/detail.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export const lang = {
|
||||||
|
//title为每个页面的标题
|
||||||
|
title: '文件详情',
|
||||||
|
}
|
||||||
2
main.js
2
main.js
@@ -6,6 +6,7 @@ import App from './App'
|
|||||||
import store from './store'
|
import store from './store'
|
||||||
import Util from './common/js/util.js'
|
import Util from './common/js/util.js'
|
||||||
import Http from './common/js/http.js'
|
import Http from './common/js/http.js'
|
||||||
|
import CardApi from './common/js/card-api.js'
|
||||||
import Lang from './common/js/lang.js'
|
import Lang from './common/js/lang.js'
|
||||||
import Config from './common/js/config.js'
|
import Config from './common/js/config.js'
|
||||||
import EventBus from './common/js/event-bus.js'
|
import EventBus from './common/js/event-bus.js'
|
||||||
@@ -25,6 +26,7 @@ Vue.config.productionTip = false
|
|||||||
|
|
||||||
Vue.prototype.$util = Util;
|
Vue.prototype.$util = Util;
|
||||||
Vue.prototype.$api = Http;
|
Vue.prototype.$api = Http;
|
||||||
|
Vue.prototype.$cardApi = CardApi; // 名片云(card addon) API 收口层
|
||||||
|
|
||||||
Vue.prototype.$langConfig = Lang; //语言包对象
|
Vue.prototype.$langConfig = Lang; //语言包对象
|
||||||
Vue.prototype.$lang = Lang.lang; //解析语言包
|
Vue.prototype.$lang = Lang.lang; //解析语言包
|
||||||
|
|||||||
@@ -79,10 +79,13 @@
|
|||||||
},
|
},
|
||||||
"scope.writePhotosAlbum" : {
|
"scope.writePhotosAlbum" : {
|
||||||
"desc" : "用于从相册选择和保存头像"
|
"desc" : "用于从相册选择和保存头像"
|
||||||
|
},
|
||||||
|
"scope.record" : {
|
||||||
|
"desc" : "用于语音录制功能,录制完成后发送到 AI 助手"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"requiredPrivateInfos" : [ "chooseLocation", "getLocation", "chooseAddress" ],
|
"requiredPrivateInfos" : [ "chooseLocation", "getLocation", "chooseAddress" ],
|
||||||
"__usePrivacyCheck__" : false,
|
"__usePrivacyCheck__" : true,
|
||||||
"optimization" : {
|
"optimization" : {
|
||||||
"subPackages" : true
|
"subPackages" : true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"mp-weixin": "node scripts/mp-weixin.patch.js",
|
"mp-weixin": "node scripts/mp-weixin.patch.js",
|
||||||
|
"pack:harmony": "node scripts/pack-harmony-project.js",
|
||||||
"i18n:check": "node scripts/i18n-check.js",
|
"i18n:check": "node scripts/i18n-check.js",
|
||||||
"test": "node scripts/test-gate.js",
|
"test": "node scripts/test-gate.js",
|
||||||
"test:util": "node --test tests/util.test.js",
|
"test:util": "node --test tests/util.test.js",
|
||||||
@@ -13,8 +14,7 @@
|
|||||||
"zion-uniapp-mp-load-package": "^1.0.13"
|
"zion-uniapp-mp-load-package": "^1.0.13"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"jweixin-module": "^1.6.0",
|
"jweixin-module": "^1.6.0"
|
||||||
"playwright-core": "^1.63.0"
|
|
||||||
},
|
},
|
||||||
"uni-app": {
|
"uni-app": {
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
121
pages.json
121
pages.json
@@ -1086,7 +1086,7 @@
|
|||||||
{
|
{
|
||||||
"path": "ai-chat/index",
|
"path": "ai-chat/index",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "AI智能",
|
"navigationBarTitleText": "AI智能客服",
|
||||||
"componentPlaceholder": {
|
"componentPlaceholder": {
|
||||||
"diy-article": "view",
|
"diy-article": "view",
|
||||||
"diy-audio": "view",
|
"diy-audio": "view",
|
||||||
@@ -1467,6 +1467,118 @@
|
|||||||
"navigationBarTitleText": ""
|
"navigationBarTitleText": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "card/list",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "名片",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "card/detail",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "名片",
|
||||||
|
"navigationBarBackgroundColor": "#ffffff",
|
||||||
|
"navigationBarTextStyle": "black"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "card/exchange",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "名片"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "card/style",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "名片"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "card/edit",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "名片"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "card/apply",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "名片"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "card/leads",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "名片",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "card/contacts",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "名片",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "card/views",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "名片",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "card/contact-edit",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "名片"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "card/share",
|
||||||
|
"style": {
|
||||||
|
// #ifdef MP-HARMONY
|
||||||
|
// 鸿蒙元服务的 web-view 是原生全屏组件, 只能自绘头部(见 share.vue)
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
// #endif
|
||||||
|
// 微信小程序必须用原生导航栏: web-view 会自动排在导航栏下方,
|
||||||
|
// 若用 custom + cover-view 自绘头部, 头部会浮在 web-view 上遮住名片顶部
|
||||||
|
"navigationBarTitleText": "名片分享"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "card/save-contact",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "保存到通讯录"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "card/enterprise",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "企业名片",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "card/admin/manage",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "名片",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "card/admin/contacts",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "名片",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "card/admin/leads",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "名片",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "member/card",
|
"path": "member/card",
|
||||||
"style": {
|
"style": {
|
||||||
@@ -2499,6 +2611,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "files/detail",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "文件详情"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "file-preview/file-preview",
|
"path": "file-preview/file-preview",
|
||||||
"style": {
|
"style": {
|
||||||
@@ -2551,7 +2669,6 @@
|
|||||||
"pages/index/index": {
|
"pages/index/index": {
|
||||||
"network": "all",
|
"network": "all",
|
||||||
"packages": [
|
"packages": [
|
||||||
"pages_tool",
|
|
||||||
"components-diy"
|
"components-diy"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
<ns-copyright v-show="isShowCopyRight" />
|
<ns-copyright v-show="isShowCopyRight" />
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<template v-if="adv.advshow != -1">
|
<template v-if="adv && adv.advshow != -1">
|
||||||
<view @touchmove.prevent.stop>
|
<view @touchmove.prevent.stop>
|
||||||
<uni-popup ref="uniPopupWindow" type="center" class="wap-floating" :maskClick="false">
|
<uni-popup ref="uniPopupWindow" type="center" class="wap-floating" :maskClick="false">
|
||||||
<view class="small-bot">
|
<view class="small-bot">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -12,32 +12,33 @@
|
|||||||
<!-- PR-CHAT-UI-CONFIG (2026-09-20): 每个附件 tab 由 attachmentTypes 控制显隐
|
<!-- PR-CHAT-UI-CONFIG (2026-09-20): 每个附件 tab 由 attachmentTypes 控制显隐
|
||||||
支持的 kind: image / file / audio / video, 缺失的 kind 自动隐藏
|
支持的 kind: image / file / audio / video, 缺失的 kind 自动隐藏
|
||||||
location 不属于附件, 不受此开关控制 -->
|
location 不属于附件, 不受此开关控制 -->
|
||||||
<view v-if="isTypeEnabled('image')" class="tool-item" @click="selectImage">
|
<view v-if="isTypeEnabled('image')" class="tool-item tool-item-new tool-item-image" @click="selectImage">
|
||||||
<view class="tool-icon">
|
<view class="tool-icon">
|
||||||
<text class="icondiy icon-system-image-line"></text>
|
<view class="image-icon"></view>
|
||||||
</view>
|
</view>
|
||||||
<text class="tool-text">{{ $lang('ai-chat.index.image') }}</text>
|
<text class="tool-text">{{ $lang('ai-chat.index.image') }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="isTypeEnabled('video')" class="tool-item" @click="selectVideo">
|
<view v-if="isTypeEnabled('video')" class="tool-item tool-item-new tool-item-video" @click="selectVideo">
|
||||||
<view class="tool-icon">
|
<view class="tool-icon">
|
||||||
<text class="icondiy icon-system-video"></text>
|
<text class="icondiy icon-system-video"></text>
|
||||||
</view>
|
</view>
|
||||||
<!-- 第二个参数是 $lang 的默认值: 后端 bundle 尚未下发该 key 时也不会显示 raw key -->
|
<!-- 第二个参数是 $lang 的默认值: 后端 bundle 尚未下发该 key 时也不会显示 raw key -->
|
||||||
<text class="tool-text">{{ $lang('ai-chat.index.video', '视频/音频') }}</text>
|
<text class="tool-text">{{ $lang('ai-chat.index.video', '视频/音频') }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="isTypeEnabled('file')" class="tool-item" @click="selectFile">
|
<view v-if="isTypeEnabled('file')" class="tool-item tool-item-new tool-item-file" @click="selectFile">
|
||||||
<view class="tool-icon">
|
<view class="tool-icon">
|
||||||
<text class="icondiy icon-system-file-2-line"></text>
|
<view class="paperclip-icon"></view>
|
||||||
</view>
|
</view>
|
||||||
<text class="tool-text">{{ $lang('ai-chat.index.file') }}</text>
|
<text class="tool-text">添加文件</text>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="isTypeEnabled('audio')" class="tool-item" @click="selectVoiceOrAudio">
|
<!-- 更多功能里的语音入口暂时隐藏 -->
|
||||||
|
<view v-if="false" class="tool-item" @click="selectVoiceOrAudio">
|
||||||
<view class="tool-icon">
|
<view class="tool-icon">
|
||||||
<text class="icondiy icon-system-chat-voice-line"></text>
|
<text class="icondiy icon-system-chat-voice-line"></text>
|
||||||
</view>
|
</view>
|
||||||
<text class="tool-text">{{ $lang('ai-chat.index.voice') }}</text>
|
<text class="tool-text">{{ $lang('ai-chat.index.voice') }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="tool-item" @click="selectLocation">
|
<view v-if="false" class="tool-item tool-item-new tool-item-location" @click="selectLocation">
|
||||||
<view class="tool-icon">
|
<view class="tool-icon">
|
||||||
<text class="icondiy icon-system-map-pin-line"></text>
|
<text class="icondiy icon-system-map-pin-line"></text>
|
||||||
</view>
|
</view>
|
||||||
@@ -220,7 +221,8 @@ export default {
|
|||||||
if (type.indexOf('audio/') === 0) return 'audio'
|
if (type.indexOf('audio/') === 0) return 'audio'
|
||||||
if (type.indexOf('video/') === 0) return 'video'
|
if (type.indexOf('video/') === 0) return 'video'
|
||||||
const ext = String((file && file.name) || '').split('.').pop().toLowerCase()
|
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']
|
// PR-AV-FULL-SUPPORT (2026-09-24): 与后端 MediaUploadPolicy::allowedUploadExtensions 音频段对齐
|
||||||
|
const audioExt = ['mp3', 'wav', 'aac', 'm4a', 'flac', 'ogg', 'oga', 'amr', 'wma', 'aiff', 'aif', 'opus', 'ape', 'mid', 'midi', 'caf', 'mka', 'ac3']
|
||||||
if (audioExt.indexOf(ext) !== -1) return 'audio'
|
if (audioExt.indexOf(ext) !== -1) return 'audio'
|
||||||
// 兜底: mime 为空且扩展名不认识的 (如 Windows 未关联的音频) 交给后端按 URL 判断, 先按音频走
|
// 兜底: mime 为空且扩展名不认识的 (如 Windows 未关联的音频) 交给后端按 URL 判断, 先按音频走
|
||||||
return type ? 'video' : 'audio'
|
return type ? 'video' : 'audio'
|
||||||
@@ -297,7 +299,7 @@ export default {
|
|||||||
chooseFileApi({
|
chooseFileApi({
|
||||||
count: 1,
|
count: 1,
|
||||||
type: 'file',
|
type: 'file',
|
||||||
extension: ['mp3', 'wav', 'aac', 'm4a', 'flac', 'ogg', 'amr', 'wma', 'aiff'],
|
extension: ['mp3', 'wav', 'aac', 'm4a', 'flac', 'ogg', 'oga', 'amr', 'wma', 'aiff', 'aif', 'opus', 'ape', 'mid', 'midi', 'caf', 'mka', 'ac3'],
|
||||||
success: async (res) => {
|
success: async (res) => {
|
||||||
const file = (res.tempFiles && res.tempFiles[0]) || null
|
const file = (res.tempFiles && res.tempFiles[0]) || null
|
||||||
if (!file) return
|
if (!file) return
|
||||||
@@ -315,7 +317,7 @@ export default {
|
|||||||
// #ifdef APP-PLUS
|
// #ifdef APP-PLUS
|
||||||
uni.chooseFile({
|
uni.chooseFile({
|
||||||
count: 1,
|
count: 1,
|
||||||
extension: ['mp3', 'wav', 'aac', 'm4a', 'flac', 'ogg', 'amr'],
|
extension: ['mp3', 'wav', 'aac', 'm4a', 'flac', 'ogg', 'oga', 'amr', 'wma', 'aiff', 'aif', 'opus', 'ape', 'mid', 'midi', 'caf', 'mka', 'ac3'],
|
||||||
success: async (res) => {
|
success: async (res) => {
|
||||||
const file = res.tempFiles[0]
|
const file = res.tempFiles[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
@@ -392,6 +394,8 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
startRecord() {
|
startRecord() {
|
||||||
|
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 仅作转发, 真正的录音由 ai-chat-voice-panel.vue 接管
|
||||||
|
// (panel 现在已经接通 common/js/voice-recorder.js, 这里不用直接调用 recorder)
|
||||||
this.$emit('voice-start')
|
this.$emit('voice-start')
|
||||||
this.hideToolsPanel()
|
this.hideToolsPanel()
|
||||||
},
|
},
|
||||||
@@ -462,9 +466,9 @@ export default {
|
|||||||
position: relative; // 为 .close-btn 绝对定位提供参考
|
position: relative; // 为 .close-btn 绝对定位提供参考
|
||||||
|
|
||||||
text {
|
text {
|
||||||
font-size: 34rpx;
|
font-size: 30rpx;
|
||||||
font-weight: 600;
|
font-weight: 400;
|
||||||
color: $color-text;
|
color: #000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-btn {
|
.close-btn {
|
||||||
@@ -481,14 +485,14 @@ export default {
|
|||||||
right: 10rpx; //
|
right: 10rpx; //
|
||||||
.iconfont {
|
.iconfont {
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
color: $color-text-light;
|
color: #000000;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tools-grid {
|
.tools-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
gap: 24rpx;
|
gap: 24rpx;
|
||||||
|
|
||||||
.tool-item {
|
.tool-item {
|
||||||
@@ -523,6 +527,62 @@ export default {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 新样式: 图片 / 添加文件 大卡片, 一比一还原参考图
|
||||||
|
.tool-item-new {
|
||||||
|
background-color: #f2f3f5;
|
||||||
|
border-radius: 28rpx;
|
||||||
|
padding: 36rpx 0;
|
||||||
|
|
||||||
|
.tool-icon {
|
||||||
|
width: 48rpx;
|
||||||
|
height: 48rpx;
|
||||||
|
border-radius: 0;
|
||||||
|
background-color: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
margin-bottom: 6rpx;
|
||||||
|
line-height: 1;
|
||||||
|
|
||||||
|
.iconfont,
|
||||||
|
.icondiy {
|
||||||
|
font-size: 48rpx;
|
||||||
|
line-height: 1;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图片图标: 左上角开口 + 大四角星在开口处 + 右侧实心圆点 (参考图样式)
|
||||||
|
.image-icon {
|
||||||
|
width: 48rpx;
|
||||||
|
height: 48rpx;
|
||||||
|
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMzMzIiBzdHJva2Utd2lkdGg9IjEuNyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMTEuNCA1IEgxNy41IEEzLjUgMy41IDAgMCAxIDIxIDguNSBWMTUuNSBBMy41IDMuNSAwIDAgMSAxNy41IDE5IEg2LjUgQTMuNSAzLjUgMCAwIDEgMyAxNS41IFY4LjUiLz48cGF0aCBkPSJNNS4yIDEuMyBRNS45IDQuMzUgOC45IDUgUTUuOSA1LjY1IDUuMiA4LjcgUTQuNSA1LjY1IDEuNSA1IFE0LjUgNC4zNSA1LjIgMS4zIFoiIGZpbGw9IiMzMzMiIHN0cm9rZT0ibm9uZSIvPjxjaXJjbGUgY3g9IjE2IiBjeT0iOS44IiByPSIxLjUiIGZpbGw9IiMzMzMiIHN0cm9rZT0ibm9uZSIvPjxwYXRoIGQ9Ik01LjUgMTEuNiBRMTAgOS42IDE0LjUgMTYuNCIvPjwvc3ZnPg==");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
background-size: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加文件图标: 斜向回形针 (参考图样式)
|
||||||
|
.paperclip-icon {
|
||||||
|
width: 40rpx;
|
||||||
|
height: 40rpx;
|
||||||
|
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMzMzIiBzdHJva2Utd2lkdGg9IjEuOCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMjEuNDQgMTEuMDUgMTIuMjUgMjAuMjRhNiA2IDAgMCAxLTguNDktOC40OWw4LjU3LTguNTdhNCA0IDAgMSAxIDUuNjYgNS42NmwtOC41OSA4LjU3YTIgMiAwIDAgMS0yLjgzLTIuODNsOC40OS04LjQ4Ii8+PC9zdmc+");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
background-size: contain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
line-height: 1;
|
||||||
|
color: #333;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 视频/音频图标小一号 (tool-item-video 与该元素同级, 必须用 & 连接)
|
||||||
|
&.tool-item-video .tool-icon .icondiy {
|
||||||
|
font-size: 40rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,239 +1,403 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="voice-popup" v-if="visible">
|
<view class="voice-popup" v-if="visible">
|
||||||
<view class="voice-mask" @click="hideVoicePanel"></view>
|
<view class="voice-mask" @click="hideVoicePanel"></view>
|
||||||
<view class="voice-input-panel">
|
<view class="voice-input-panel">
|
||||||
<view class="voice-header">
|
<view class="voice-header">
|
||||||
<text>{{ $lang('ai-chat.index.voiceInputTitle') }}</text>
|
<text>{{ $lang('ai-chat.index.voiceInputTitle') }}</text>
|
||||||
<button class="close-btn" @click="hideVoicePanel">
|
<button class="close-btn" @click="hideVoicePanel">
|
||||||
<text class="iconfont icon-close"></text>
|
<text class="iconfont icon-close"></text>
|
||||||
</button>
|
</button>
|
||||||
</view>
|
</view>
|
||||||
<view class="voice-content">
|
<view class="voice-content">
|
||||||
<view class="voice-wave" :class="{ recording: voiceInputing }">
|
<view class="voice-wave" :class="{ recording: voiceInputing }">
|
||||||
<view
|
<view
|
||||||
v-for="i in 8"
|
v-for="i in 8"
|
||||||
:key="i"
|
:key="i"
|
||||||
class="wave-bar"
|
class="wave-bar"
|
||||||
:style="{ animationDelay: (i * 0.1) + 's' }"></view>
|
:style="waveBarStyle(i)"></view>
|
||||||
</view>
|
</view>
|
||||||
<text class="voice-tip">{{ voiceInputing ? $lang('ai-chat.index.recordingTip') : $lang('ai-chat.index.startRecordTip') }}</text>
|
<text class="voice-tip">{{ currentTip }}</text>
|
||||||
</view>
|
<text v-if="voiceInputing" class="voice-duration">{{ formattedDuration }}</text>
|
||||||
<view class="voice-actions">
|
</view>
|
||||||
<button class="voice-btn" @click="toggleVoiceInput">
|
<view class="voice-actions">
|
||||||
<text class="iconfont" :class="voiceInputing ? 'icon-stop' : 'icon-voice'"></text>
|
<button class="voice-btn" :class="{ 'is-recording': voiceInputing }" @click="toggleVoiceInput">
|
||||||
</button>
|
<text class="iconfont" :class="voiceInputing ? 'icon-stop' : 'icon-voice'"></text>
|
||||||
</view>
|
</button>
|
||||||
</view>
|
<text v-if="voiceInputing" class="cancel-link" @click="cancelVoiceInput">{{ $lang('ai-chat.index.cancel') }}</text>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</view>
|
||||||
|
</view>
|
||||||
<script>
|
</template>
|
||||||
/**
|
|
||||||
* ai-chat-voice-panel - 语音输入面板组件
|
<script>
|
||||||
*
|
import { createVoiceRecorder } from '@/common/js/voice-recorder.js'
|
||||||
* Props:
|
/**
|
||||||
* visible: Boolean - 控制显示/隐藏
|
* ai-chat-voice-panel - 语音输入面板组件
|
||||||
*
|
*
|
||||||
* Events:
|
* PR-FRONTEND-VOICE-INPUT (2026-09-24): 真录音 UI。
|
||||||
* update:visible - 同步关闭状态
|
* - 接入 common/js/voice-recorder.js,跨 H5 / 微信小程序 / App-Plus / 鸿蒙元服务
|
||||||
* close - 请求关闭面板
|
* - 波形振幅由 progress 事件 (level 0..1) 驱动
|
||||||
*/
|
* - 录音上限 60s,自动停止
|
||||||
export default {
|
* - 关闭面板时如果还在录音,先 cancel 释放资源
|
||||||
name: 'AiChatVoicePanel',
|
*
|
||||||
|
* Props:
|
||||||
props: {
|
* visible: Boolean - 控制显示/隐藏
|
||||||
visible: {
|
*
|
||||||
type: Boolean,
|
* Events:
|
||||||
default: false
|
* update:visible - 同步关闭状态
|
||||||
}
|
* close - 请求关闭面板
|
||||||
},
|
* audio-ready - 录音完成, 参数 { filePath, blob, duration, mime, size }
|
||||||
|
*/
|
||||||
data() {
|
export default {
|
||||||
return {
|
name: 'AiChatVoicePanel',
|
||||||
voiceInputing: false
|
|
||||||
}
|
props: {
|
||||||
},
|
visible: {
|
||||||
|
type: Boolean,
|
||||||
watch: {
|
default: false
|
||||||
visible(val) {
|
}
|
||||||
if (!val) {
|
},
|
||||||
this.voiceInputing = false
|
|
||||||
}
|
data() {
|
||||||
}
|
return {
|
||||||
},
|
voiceInputing: false,
|
||||||
|
// 录音中的振幅 0..1;8 根波形的高度按 level 抖动
|
||||||
methods: {
|
voiceLevel: 0,
|
||||||
hideVoicePanel() {
|
// 录音累计时长 (秒,整数)
|
||||||
this.voiceInputing = false
|
voiceDuration: 0,
|
||||||
this.$emit('update:visible', false)
|
_durationTimer: null,
|
||||||
this.$emit('close')
|
_voiceRecorder: null,
|
||||||
},
|
_onProgress: null,
|
||||||
|
_onError: null,
|
||||||
toggleVoiceInput() {
|
// 防止 stop 回调被多次触发 (录音器自己 stop + 用户手动 stop 重叠)
|
||||||
this.voiceInputing = !this.voiceInputing
|
_resolved: false
|
||||||
if (this.voiceInputing) {
|
}
|
||||||
this.startVoiceInput()
|
},
|
||||||
} else {
|
|
||||||
this.stopVoiceInput()
|
computed: {
|
||||||
}
|
currentTip() {
|
||||||
},
|
if (this.voiceInputing) {
|
||||||
|
return this.$lang('ai-chat.index.recordingTip')
|
||||||
startVoiceInput() {
|
}
|
||||||
// #ifdef H5
|
return this.$lang('ai-chat.index.startRecordTip')
|
||||||
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
|
},
|
||||||
navigator.mediaDevices.getUserMedia({ audio: true })
|
formattedDuration() {
|
||||||
.then(stream => {
|
const s = Math.max(0, Math.floor(this.voiceDuration))
|
||||||
// 录音中,可扩展实时转文字
|
return s + '″'
|
||||||
})
|
}
|
||||||
.catch(error => {
|
},
|
||||||
uni.showToast({ title: this.$lang('ai-chat.index.voiceInputFailed'), icon: 'none' })
|
|
||||||
this.voiceInputing = false
|
watch: {
|
||||||
})
|
visible(val) {
|
||||||
}
|
if (val) {
|
||||||
// #endif
|
// 每次重新打开都重置
|
||||||
},
|
this.voiceInputing = false
|
||||||
|
this.voiceLevel = 0
|
||||||
stopVoiceInput() {
|
this.voiceDuration = 0
|
||||||
this.voiceInputing = false
|
this._resolved = false
|
||||||
}
|
} else {
|
||||||
}
|
// 关闭时如果还在录音,丢弃
|
||||||
}
|
this._discardRecorderIfAny()
|
||||||
</script>
|
}
|
||||||
|
}
|
||||||
<style lang="scss" scoped>
|
},
|
||||||
|
|
||||||
/* 引入图标字体: 由 main.wxss 全局提供, 不再重复 import */
|
beforeDestroy() {
|
||||||
@import '../styles/ai-chat-global.scss';
|
this._discardRecorderIfAny()
|
||||||
|
},
|
||||||
.voice-popup {
|
|
||||||
position: fixed;
|
methods: {
|
||||||
top: 0;
|
hideVoicePanel() {
|
||||||
left: 0;
|
// 关闭时若还在录,默认作废;如需保留产物应先点 stop 按钮
|
||||||
right: 0;
|
this._discardRecorderIfAny()
|
||||||
bottom: 0;
|
this.$emit('update:visible', false)
|
||||||
z-index: 999;
|
this.$emit('close')
|
||||||
|
},
|
||||||
.voice-mask {
|
|
||||||
position: absolute;
|
toggleVoiceInput() {
|
||||||
top: 0;
|
if (this.voiceInputing) {
|
||||||
left: 0;
|
this._finishRecording()
|
||||||
right: 0;
|
} else {
|
||||||
bottom: 0;
|
this._beginRecording()
|
||||||
background-color: rgba(0, 0, 0, 0.4);
|
}
|
||||||
}
|
},
|
||||||
|
|
||||||
.voice-input-panel {
|
cancelVoiceInput() {
|
||||||
position: absolute;
|
this._discardRecorderIfAny()
|
||||||
top: 50%;
|
this.hideVoicePanel()
|
||||||
left: 50%;
|
},
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
background-color: $bg-card;
|
waveBarStyle(i) {
|
||||||
border-radius: $radius-lg;
|
// 每根 bar 基础高度 24rpx + level*振幅 + 不同 bar 的 offset (错相)
|
||||||
padding: 48rpx;
|
const base = 24
|
||||||
width: 60%;
|
const offset = ((i * 17) % 32) + 12 // 12..44
|
||||||
max-width: 520rpx;
|
const levelPx = Math.round(this.voiceLevel * 80)
|
||||||
box-shadow: 0 8rpx 36rpx rgba(0, 0, 0, 0.15);
|
const h = Math.max(16, base + offset + levelPx) + 'rpx'
|
||||||
|
const delay = (i * 0.08) + 's'
|
||||||
.voice-header {
|
return { height: h, animationDelay: delay }
|
||||||
display: flex;
|
},
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
_beginRecording() {
|
||||||
margin-bottom: 32rpx;
|
if (this._voiceRecorder) return
|
||||||
border-bottom: 2rpx solid #f0f4f8;
|
const rec = createVoiceRecorder({ maxDuration: 60 * 1000 })
|
||||||
padding-bottom: 16rpx;
|
this._voiceRecorder = rec
|
||||||
|
this._onProgress = ({ level }) => {
|
||||||
text {
|
// 平滑一下,避免波形抖动太狠
|
||||||
font-size: 34rpx;
|
this.voiceLevel = this.voiceLevel * 0.4 + level * 0.6
|
||||||
font-weight: 600;
|
}
|
||||||
color: $color-text;
|
this._onError = (err) => {
|
||||||
}
|
console.warn('[voice-panel] recorder error:', err)
|
||||||
|
console.error('[voice-panel] recorder error raw:', JSON.stringify(err), 'errMsg=', err && err.errMsg, 'message=', err && err.message)
|
||||||
.close-btn {
|
uni.showToast({ title: this._voiceErrMsg(err) || this.$lang('ai-chat.index.voiceInputFailed'), icon: 'none' })
|
||||||
width: 56rpx;
|
this._discardRecorderIfAny()
|
||||||
height: 56rpx;
|
}
|
||||||
border-radius: 50%;
|
rec.on('progress', this._onProgress)
|
||||||
background-color: $bg-main;
|
rec.on('error', this._onError)
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
rec.start()
|
||||||
justify-content: center;
|
.then(() => {
|
||||||
border: none;
|
this.voiceInputing = true
|
||||||
|
this.voiceDuration = 0
|
||||||
.iconfont {
|
this._startDurationTimer()
|
||||||
font-size: 28rpx;
|
})
|
||||||
color: $color-text-light;
|
.catch((err) => {
|
||||||
}
|
this._teardownRecorder()
|
||||||
}
|
uni.showToast({ title: this._voiceErrMsg(err) || this.$lang('ai-chat.index.voiceStartFailed'), icon: 'none' })
|
||||||
}
|
})
|
||||||
|
},
|
||||||
.voice-content {
|
|
||||||
display: flex;
|
async _finishRecording() {
|
||||||
flex-direction: column;
|
if (!this._voiceRecorder || this._resolved) return
|
||||||
align-items: center;
|
this._resolved = true
|
||||||
margin-bottom: 48rpx;
|
this._stopDurationTimer()
|
||||||
|
try {
|
||||||
.voice-wave {
|
const result = await this._voiceRecorder.stop()
|
||||||
width: 280rpx;
|
if (!result || !result.filePath) {
|
||||||
height: 160rpx;
|
// 录音产物为空,可能是被系统打断
|
||||||
display: flex;
|
uni.showToast({ title: this.$lang('ai-chat.index.voiceStopFailed'), icon: 'none' })
|
||||||
align-items: center;
|
this._teardownRecorder()
|
||||||
justify-content: center;
|
this.voiceInputing = false
|
||||||
margin-bottom: 24rpx;
|
return
|
||||||
|
}
|
||||||
&.recording .wave-bar {
|
this._teardownRecorder()
|
||||||
animation: wave 1.2s infinite ease-in-out;
|
this.voiceInputing = false
|
||||||
}
|
this.$emit('audio-ready', {
|
||||||
|
filePath: result.filePath,
|
||||||
.wave-bar {
|
blob: result.blob || null,
|
||||||
width: 8rpx;
|
fileName: result.fileName || '',
|
||||||
height: 40rpx;
|
duration: result.duration,
|
||||||
margin: 0 4rpx;
|
mime: result.mime,
|
||||||
background-color: $color-primary;
|
size: result.size
|
||||||
border-radius: 4rpx;
|
})
|
||||||
animation: none;
|
this.hideVoicePanel()
|
||||||
|
} catch (e) {
|
||||||
&:nth-child(1) { height: 60rpx; animation-delay: 0s; }
|
this._teardownRecorder()
|
||||||
&:nth-child(2) { height: 90rpx; animation-delay: 0.1s; }
|
this.voiceInputing = false
|
||||||
&:nth-child(3) { height: 50rpx; animation-delay: 0.2s; }
|
uni.showToast({ title: this._voiceErrMsg(e) || this.$lang('ai-chat.index.voiceInputFailed'), icon: 'none' })
|
||||||
&:nth-child(4) { height: 120rpx; animation-delay: 0.3s; }
|
}
|
||||||
&:nth-child(5) { height: 70rpx; animation-delay: 0.4s; }
|
},
|
||||||
&:nth-child(6) { height: 100rpx; animation-delay: 0.5s; }
|
|
||||||
&:nth-child(7) { height: 80rpx; animation-delay: 0.6s; }
|
_discardRecorderIfAny() {
|
||||||
&:nth-child(8) { height: 40rpx; animation-delay: 0.7s; }
|
if (!this._voiceRecorder) return
|
||||||
}
|
try { this._voiceRecorder.cancel() } catch (e) { /* noop */ }
|
||||||
}
|
this._teardownRecorder()
|
||||||
|
this.voiceInputing = false
|
||||||
.voice-tip {
|
},
|
||||||
font-size: 28rpx;
|
|
||||||
color: $color-text-light;
|
_teardownRecorder() {
|
||||||
}
|
this._stopDurationTimer()
|
||||||
}
|
if (this._voiceRecorder) {
|
||||||
|
if (this._onProgress) this._voiceRecorder.off('progress', this._onProgress)
|
||||||
.voice-actions {
|
if (this._onError) this._voiceRecorder.off('error', this._onError)
|
||||||
display: flex;
|
}
|
||||||
justify-content: center;
|
this._voiceRecorder = null
|
||||||
|
this._onProgress = null
|
||||||
.voice-btn {
|
this._onError = null
|
||||||
width: 120rpx;
|
this._resolved = false
|
||||||
height: 120rpx;
|
this.voiceLevel = 0
|
||||||
border-radius: 50%;
|
},
|
||||||
background-color: $color-primary;
|
|
||||||
display: flex;
|
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 把 voice-recorder 抛出的 code 翻译成 i18n 文案
|
||||||
align-items: center;
|
_voiceErrMsg(err) {
|
||||||
justify-content: center;
|
if (!err) return ''
|
||||||
box-shadow: 0 8rpx 24rpx rgba(108, 142, 191, 0.3);
|
switch (err.code) {
|
||||||
border: none;
|
case 'NOT_SUPPORTED':
|
||||||
|
case 'NO_RECORDER_MANAGER':
|
||||||
.iconfont {
|
return this.$lang('ai-chat.index.voiceNotSupported')
|
||||||
font-size: 56rpx;
|
case 'H5_NO_MIC_API':
|
||||||
color: white;
|
case 'ALREADY_STARTED':
|
||||||
}
|
return this.$lang('ai-chat.index.voiceStartFailed')
|
||||||
}
|
case 'UNI_RECORD_ERROR':
|
||||||
}
|
return (err && (err.errMsg || err.message)) || this.$lang('ai-chat.index.voiceStopFailed')
|
||||||
}
|
default:
|
||||||
}
|
return err.message || ''
|
||||||
|
}
|
||||||
@keyframes wave {
|
},
|
||||||
0%, 100% { transform: scaleY(0.5); }
|
|
||||||
50% { transform: scaleY(1.2); }
|
_startDurationTimer() {
|
||||||
}
|
this._stopDurationTimer()
|
||||||
</style>
|
this._durationTimer = setInterval(() => {
|
||||||
|
this.voiceDuration += 1
|
||||||
|
}, 1000)
|
||||||
|
},
|
||||||
|
|
||||||
|
_stopDurationTimer() {
|
||||||
|
if (this._durationTimer) {
|
||||||
|
clearInterval(this._durationTimer)
|
||||||
|
this._durationTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
|
||||||
|
/* 引入图标字体: 由 main.wxss 全局提供, 不再重复 import */
|
||||||
|
@import '../styles/ai-chat-global.scss';
|
||||||
|
|
||||||
|
.voice-popup {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 999;
|
||||||
|
|
||||||
|
.voice-mask {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-input-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background-color: $bg-card;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
padding: 48rpx;
|
||||||
|
width: 60%;
|
||||||
|
max-width: 520rpx;
|
||||||
|
box-shadow: 0 8rpx 36rpx rgba(0, 0, 0, 0.15);
|
||||||
|
|
||||||
|
.voice-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
border-bottom: 2rpx solid #f0f4f8;
|
||||||
|
padding-bottom: 16rpx;
|
||||||
|
|
||||||
|
text {
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $color-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
width: 56rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: $bg-main;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
.iconfont {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: $color-text-light;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
|
||||||
|
.voice-wave {
|
||||||
|
width: 280rpx;
|
||||||
|
height: 160rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
|
||||||
|
&.recording .wave-bar {
|
||||||
|
animation: wave 1.0s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wave-bar {
|
||||||
|
width: 8rpx;
|
||||||
|
margin: 0 4rpx;
|
||||||
|
background-color: $color-primary;
|
||||||
|
border-radius: 4rpx;
|
||||||
|
transition: height 0.12s ease-out;
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-tip {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: $color-text-light;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-duration {
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $color-primary;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.voice-btn {
|
||||||
|
width: 120rpx;
|
||||||
|
height: 120rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: $color-primary;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 8rpx 24rpx rgba(108, 142, 191, 0.3);
|
||||||
|
border: none;
|
||||||
|
transition: transform 0.15s ease-out, background-color 0.15s ease-out;
|
||||||
|
|
||||||
|
&.is-recording {
|
||||||
|
background-color: #e94e4e;
|
||||||
|
transform: scale(0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconfont {
|
||||||
|
font-size: 56rpx;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-link {
|
||||||
|
margin-top: 20rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: $color-text-light;
|
||||||
|
padding: 8rpx 16rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes wave {
|
||||||
|
0%, 100% { transform: scaleY(0.6); }
|
||||||
|
50% { transform: scaleY(1.15); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -40,20 +40,39 @@
|
|||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
list: []
|
list: [],
|
||||||
|
// 从跳转 URL 上携带的参数
|
||||||
|
categoryId: '',
|
||||||
|
title: '',
|
||||||
|
pageKey: '',
|
||||||
|
pageParams: {}
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
onLoad(options) {
|
||||||
|
// 还原跳转时携带的过滤/展示参数,避免请求 API 时丢失
|
||||||
|
if (options.category_id) this.categoryId = options.category_id;
|
||||||
|
if (options.title) {
|
||||||
|
this.title = decodeURIComponent(options.title);
|
||||||
|
if (this.title) uni.setNavigationBarTitle({ title: this.title });
|
||||||
|
}
|
||||||
|
if (options.page_key) this.pageKey = options.page_key;
|
||||||
|
this.pageParams = this.$util.parsePageParams(options.page_params) || {};
|
||||||
|
},
|
||||||
onShow() {
|
onShow() {
|
||||||
this.setPublicShare();
|
this.setPublicShare();
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
getData(mescroll) {
|
getData(mescroll) {
|
||||||
|
// 合并 page_params 与显式参数,保证后端拿到完整过滤条件
|
||||||
|
let data = Object.assign({}, this.pageParams, {
|
||||||
|
page_size: mescroll.size,
|
||||||
|
page: mescroll.num
|
||||||
|
});
|
||||||
|
if (this.categoryId) data.category_id = this.categoryId;
|
||||||
|
|
||||||
this.$api.sendRequest({
|
this.$api.sendRequest({
|
||||||
url: '/api/article/page',
|
url: '/api/article/page',
|
||||||
data: {
|
data: data,
|
||||||
page_size: mescroll.size,
|
|
||||||
page: mescroll.num
|
|
||||||
},
|
|
||||||
success: res => {
|
success: res => {
|
||||||
let newArr = [];
|
let newArr = [];
|
||||||
let msg = res.message;
|
let msg = res.message;
|
||||||
@@ -87,17 +106,32 @@ export default {
|
|||||||
// 设置公众号分享
|
// 设置公众号分享
|
||||||
setPublicShare() {
|
setPublicShare() {
|
||||||
let shareUrl = this.$config.h5Domain + '/pages_tool/article/list';
|
let shareUrl = this.$config.h5Domain + '/pages_tool/article/list';
|
||||||
|
let query = this.shareQuery();
|
||||||
|
if (query) shareUrl += '?' + query;
|
||||||
this.$util.setPublicShare({
|
this.$util.setPublicShare({
|
||||||
title: this.$lang('article.list.title'),
|
title: this.title || this.$lang('article.list.title'),
|
||||||
desc: '',
|
desc: '',
|
||||||
link: shareUrl,
|
link: shareUrl,
|
||||||
imgUrl: this.siteInfo ? this.$util.img(this.siteInfo.logo_square) : ''
|
imgUrl: this.siteInfo ? this.$util.img(this.siteInfo.logo_square) : ''
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
// 组装带回过滤参数的 query 串,保证分享/跳转后参数不丢失
|
||||||
|
shareQuery() {
|
||||||
|
let params = {};
|
||||||
|
if (this.categoryId) params.category_id = this.categoryId;
|
||||||
|
if (this.title) params.title = encodeURIComponent(this.title);
|
||||||
|
if (this.pageKey) params.page_key = this.pageKey;
|
||||||
|
if (Object.keys(this.pageParams).length) params.page_params = encodeURIComponent(JSON.stringify(this.pageParams));
|
||||||
|
return Object.keys(params)
|
||||||
|
.map(k => k + '=' + params[k])
|
||||||
|
.join('&');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onShareAppMessage(res) {
|
onShareAppMessage(res) {
|
||||||
var title = this.$lang('article.list.title');
|
var title = this.title || this.$lang('article.list.title');
|
||||||
var path = '/pages_tool/article/list';
|
var path = '/pages_tool/article/list';
|
||||||
|
var query = this.shareQuery();
|
||||||
|
if (query) path += '?' + query;
|
||||||
return {
|
return {
|
||||||
title: title,
|
title: title,
|
||||||
path: path,
|
path: path,
|
||||||
@@ -107,8 +141,10 @@ export default {
|
|||||||
},
|
},
|
||||||
//分享到朋友圈
|
//分享到朋友圈
|
||||||
onShareTimeline() {
|
onShareTimeline() {
|
||||||
var title = this.$lang('article.list.title');
|
var title = this.title || this.$lang('article.list.title');
|
||||||
var query = '/pages_tool/article/list';
|
var query = '/pages_tool/article/list';
|
||||||
|
var q = this.shareQuery();
|
||||||
|
if (q) query += '?' + q;
|
||||||
return {
|
return {
|
||||||
title: title,
|
title: title,
|
||||||
query: query,
|
query: query,
|
||||||
|
|||||||
481
pages_tool/card/admin/contacts.vue
Normal file
481
pages_tool/card/admin/contacts.vue
Normal file
@@ -0,0 +1,481 @@
|
|||||||
|
<template>
|
||||||
|
<view class="admin-contacts">
|
||||||
|
<!-- 商家端三大入口平级切换(鸿蒙安全: $util.redirectTo) -->
|
||||||
|
<view class="page-switch">
|
||||||
|
<view class="switch-item" @tap="goManage">名片管理</view>
|
||||||
|
<view class="switch-item active" @tap="goSelf">人脉管理</view>
|
||||||
|
<view class="switch-item" @tap="goLeads">潜在客户</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 搜索栏 + 添加入口 -->
|
||||||
|
<view class="search-bar">
|
||||||
|
<input class="search-input" :value="keyword" placeholder="搜索姓名 / 手机 / 公司"
|
||||||
|
confirm-type="search" @input="e => keyword = e.detail.value" @confirm="doSearch" />
|
||||||
|
<view class="search-btn" @tap="doSearch">搜索</view>
|
||||||
|
<view class="add-btn" @tap="goEdit()">+ 添加</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 来源筛选(与后端 Contact::SOURCES 对齐) -->
|
||||||
|
<scroll-view scroll-x class="tab-bar">
|
||||||
|
<view class="tab-inner">
|
||||||
|
<view v-for="tab in tabs" :key="tab.value" class="tab-item"
|
||||||
|
:class="{ active: source === tab.value }" @tap="switchTab(tab.value)">
|
||||||
|
{{ tab.label }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<!-- 人脉列表 -->
|
||||||
|
<view class="contact-list">
|
||||||
|
<view v-for="(item, index) in list" :key="item.id" class="contact-item">
|
||||||
|
<view class="item-top" @tap="callPhone(item.mobile)">
|
||||||
|
<view class="item-name">{{ item.name }}</view>
|
||||||
|
<view class="item-source" :class="'src-' + item.source">{{ item.source_text }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="item-company" v-if="item.company || item.job_title">
|
||||||
|
{{ item.company }}<text v-if="item.company && item.job_title"> · </text>{{ item.job_title }}
|
||||||
|
</view>
|
||||||
|
<view class="item-mobile" v-if="item.mobile" @tap="callPhone(item.mobile)">{{ item.mobile }}</view>
|
||||||
|
<view class="item-remark" v-if="item.remark">「{{ item.remark }}」</view>
|
||||||
|
<view class="item-meta">
|
||||||
|
<text class="meta-time">{{ item.create_time_txt }}</text>
|
||||||
|
<text class="meta-status" v-if="item.status == 1">已归档</text>
|
||||||
|
</view>
|
||||||
|
<view class="item-actions">
|
||||||
|
<view class="action-btn plain" :data-index="index" @tap="goEdit">编辑</view>
|
||||||
|
<view class="action-btn primary" :data-index="index" @tap="convertToLead">转为潜在客户</view>
|
||||||
|
<view class="action-btn danger" :data-index="index" @tap="delContact">删除</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="load-more" v-if="list.length > 0">
|
||||||
|
<text v-if="isLoading">加载中…</text>
|
||||||
|
<text v-else-if="page < pageCount">上拉加载更多</text>
|
||||||
|
<text v-else class="load-end">— 没有更多了 —</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<ns-empty v-if="!isLoading && list.length === 0" :isIndex="false"
|
||||||
|
:text="'暂无人脉,点右上角添加'"></ns-empty>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* 商家端 · 人脉管理
|
||||||
|
*
|
||||||
|
* 接口全部来自后端已就绪的 /card/api/contact/*(收口在 common/js/card-api.js):
|
||||||
|
* contact/lists 列表 / contact/add+update 增改(复用 contact-edit 页) /
|
||||||
|
* contact/toLead 转线索 / contact/delete 真删。
|
||||||
|
* 与员工端「我的人脉」的区别: 本页是商家后台视角, 带来源筛选与三大入口互链;
|
||||||
|
* 编辑页共用 contact-edit.vue(from=admin 时保存后回本页)。
|
||||||
|
*/
|
||||||
|
import { CONTACT_SOURCES } from '@/common/js/card-api.js';
|
||||||
|
|
||||||
|
// 来源筛选: scan/exchange 暂无产生入口, 只放出 manual / lead_in 两个 tab
|
||||||
|
const SOURCE_TABS = [{ value: '', label: '全部' }].concat(
|
||||||
|
CONTACT_SOURCES.filter(s => s.value === 'manual' || s.value === 'lead_in')
|
||||||
|
);
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
tabs: SOURCE_TABS,
|
||||||
|
source: '',
|
||||||
|
keyword: '',
|
||||||
|
list: [],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
pageCount: 1,
|
||||||
|
isLoading: false
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onLoad() {
|
||||||
|
this.refresh();
|
||||||
|
},
|
||||||
|
onReady() {
|
||||||
|
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
|
||||||
|
uni.setNavigationBarTitle({ title: '名片' });
|
||||||
|
// #ifdef H5
|
||||||
|
document.title = '名片';
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
onShow() {
|
||||||
|
// 从编辑页保存返回(redirectTo 回本页)时刷新
|
||||||
|
if (this._loadedOnce) this.refresh();
|
||||||
|
this._loadedOnce = true;
|
||||||
|
},
|
||||||
|
onPullDownRefresh() {
|
||||||
|
this.refresh(() => uni.stopPullDownRefresh());
|
||||||
|
},
|
||||||
|
onReachBottom() {
|
||||||
|
if (this.isLoading) return;
|
||||||
|
if (this.page >= this.pageCount) return;
|
||||||
|
this.loadPage(this.page + 1);
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
// 平级入口: 鸿蒙禁裸 uni.switchTab, 走 $util.redirectTo
|
||||||
|
goManage() {
|
||||||
|
this.$util.redirectTo('/pages_tool/card/admin/manage');
|
||||||
|
},
|
||||||
|
goLeads() {
|
||||||
|
this.$util.redirectTo('/pages_tool/card/admin/leads');
|
||||||
|
},
|
||||||
|
goSelf() {},
|
||||||
|
// 编辑页共用 contact-edit; from=admin 时保存后回本页(见 contact-edit.vue#backToList)
|
||||||
|
/**
|
||||||
|
* 取事件对应的行数据。小程序端 v-for 内联传对象会丢参, 统一 :data-index + 不传参。
|
||||||
|
* 兼容: 无参(新建) / 事件对象(data-index) / 直接 id。
|
||||||
|
*/
|
||||||
|
resolveItem(payload) {
|
||||||
|
if (payload == null) return null;
|
||||||
|
if (typeof payload === 'number' || typeof payload === 'string') return null;
|
||||||
|
const ds = payload.currentTarget && payload.currentTarget.dataset;
|
||||||
|
if (ds) {
|
||||||
|
const i = Number(ds.index);
|
||||||
|
if (!isNaN(i) && this.list[i]) return this.list[i];
|
||||||
|
}
|
||||||
|
return payload.id ? payload : null;
|
||||||
|
},
|
||||||
|
goEdit(payload) {
|
||||||
|
let id = payload;
|
||||||
|
if (payload && payload.currentTarget) {
|
||||||
|
const i = Number(payload.currentTarget.dataset.index);
|
||||||
|
const item = this.list[i];
|
||||||
|
id = item ? item.id : '';
|
||||||
|
}
|
||||||
|
const url = id
|
||||||
|
? '/pages_tool/card/contact-edit?from=admin&id=' + id
|
||||||
|
: '/pages_tool/card/contact-edit?from=admin';
|
||||||
|
this.$util.redirectTo(url);
|
||||||
|
},
|
||||||
|
switchTab(value) {
|
||||||
|
if (this.source === value) return;
|
||||||
|
this.source = value;
|
||||||
|
this.refresh();
|
||||||
|
},
|
||||||
|
doSearch() {
|
||||||
|
this.refresh();
|
||||||
|
},
|
||||||
|
refresh(done) {
|
||||||
|
this.page = 1;
|
||||||
|
this.loadPage(1, done);
|
||||||
|
},
|
||||||
|
loadPage(pageNo, done) {
|
||||||
|
this.isLoading = true;
|
||||||
|
const data = { page: pageNo, page_size: this.pageSize };
|
||||||
|
// 空字符串 = 全部, 后端对 '' 不加 source 条件
|
||||||
|
if (this.source !== '') data.source = this.source;
|
||||||
|
if (this.keyword.trim() !== '') data.keyword = this.keyword.trim();
|
||||||
|
|
||||||
|
this.$cardApi.contactLists({
|
||||||
|
data: data,
|
||||||
|
success: res => {
|
||||||
|
this.isLoading = false;
|
||||||
|
if (res.code == 0) {
|
||||||
|
const d = res.data || {};
|
||||||
|
const rows = d.list || [];
|
||||||
|
this.pageCount = d.page_count || 1;
|
||||||
|
this.page = pageNo;
|
||||||
|
this.list = pageNo === 1 ? rows : this.list.concat(rows);
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: res.message || '加载失败', icon: 'none' });
|
||||||
|
}
|
||||||
|
typeof done === 'function' && done();
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
this.isLoading = false;
|
||||||
|
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||||
|
typeof done === 'function' && done();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
convertToLead(payload) {
|
||||||
|
const item = this.resolveItem(payload);
|
||||||
|
if (!item) return;
|
||||||
|
uni.showModal({
|
||||||
|
title: '转为潜在客户',
|
||||||
|
content: '将「' + (item.name || '') + '」转入潜在客户跟进?人脉记录会保留。',
|
||||||
|
success: res => {
|
||||||
|
if (!res.confirm) return;
|
||||||
|
this.doConvert(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
doConvert(item) {
|
||||||
|
uni.showLoading({ title: '转换中…', mask: true });
|
||||||
|
// 后端: 写一条线索且不删人脉; 同员工同手机号已转过则报错(message 可直接展示)
|
||||||
|
this.$cardApi.contactToLead({
|
||||||
|
data: { id: item.id },
|
||||||
|
success: res => {
|
||||||
|
uni.hideLoading();
|
||||||
|
if (res.code == 0) {
|
||||||
|
uni.showToast({ title: '已转入潜在客户', icon: 'success' });
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: res.message || '转换失败', icon: 'none' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
uni.hideLoading();
|
||||||
|
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
delContact(payload) {
|
||||||
|
const item = this.resolveItem(payload);
|
||||||
|
if (!item) return;
|
||||||
|
// 真删不可恢复, 二次确认
|
||||||
|
uni.showModal({
|
||||||
|
title: '删除人脉',
|
||||||
|
content: '删除后不可恢复,确定删除「' + (item.name || '') + '」?',
|
||||||
|
confirmColor: '#f5222d',
|
||||||
|
success: res => {
|
||||||
|
if (!res.confirm) return;
|
||||||
|
this.doDelete(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
doDelete(item) {
|
||||||
|
this.$cardApi.contactDelete({
|
||||||
|
data: { id: item.id },
|
||||||
|
success: res => {
|
||||||
|
if (res.code == 0) {
|
||||||
|
this.list = this.list.filter(c => c.id !== item.id);
|
||||||
|
uni.showToast({ title: '已删除', icon: 'success' });
|
||||||
|
} else {
|
||||||
|
// 后端对"不存在"与"非本人"同一口径, 按"不存在或已删除"提示
|
||||||
|
uni.showToast({ title: res.message || '删除失败', icon: 'none' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
callPhone(mobile) {
|
||||||
|
if (!mobile) return;
|
||||||
|
uni.makePhoneCall({ phoneNumber: mobile });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.admin-contacts {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #f6f7f9;
|
||||||
|
padding-bottom: 40rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-switch {
|
||||||
|
display: flex;
|
||||||
|
background-color: #ffffff;
|
||||||
|
padding: 0 12rpx;
|
||||||
|
|
||||||
|
.switch-item {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 24rpx 0 20rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #666;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: var(--main-color, #0054a5);
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
bottom: 8rpx;
|
||||||
|
width: 48rpx;
|
||||||
|
height: 6rpx;
|
||||||
|
border-radius: 3rpx;
|
||||||
|
background-color: var(--main-color, #0054a5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16rpx 24rpx;
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
height: 64rpx;
|
||||||
|
line-height: 64rpx;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 32rpx;
|
||||||
|
padding: 0 28rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-btn {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #666;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
background-color: var(--main-color, #0054a5);
|
||||||
|
border-radius: 32rpx;
|
||||||
|
padding: 12rpx 28rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-bar {
|
||||||
|
background-color: #ffffff;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
.tab-inner {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 0 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 24rpx 28rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #666;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: var(--main-color, #0054a5);
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
bottom: 10rpx;
|
||||||
|
width: 40rpx;
|
||||||
|
height: 6rpx;
|
||||||
|
border-radius: 3rpx;
|
||||||
|
background-color: var(--main-color, #0054a5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-list {
|
||||||
|
padding: 20rpx 24rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-item {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
|
||||||
|
.item-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.item-name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-source {
|
||||||
|
font-size: 22rpx;
|
||||||
|
padding: 6rpx 18rpx;
|
||||||
|
border-radius: 22rpx;
|
||||||
|
|
||||||
|
&.src-manual {
|
||||||
|
color: var(--main-color, #0054a5);
|
||||||
|
background-color: var(--main-color-shallow, #e8f1fb);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.src-lead_in {
|
||||||
|
color: #1daa39;
|
||||||
|
background-color: #e8f8ea;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.src-scan,
|
||||||
|
&.src-exchange {
|
||||||
|
color: #999;
|
||||||
|
background-color: #f2f2f2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-company {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-mobile {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #333;
|
||||||
|
margin-top: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-remark {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #999;
|
||||||
|
margin-top: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 20rpx;
|
||||||
|
padding-top: 20rpx;
|
||||||
|
border-top: 1rpx solid #f0f0f0;
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
font-size: 24rpx;
|
||||||
|
border-radius: 30rpx;
|
||||||
|
padding: 10rpx 26rpx;
|
||||||
|
margin-left: 16rpx;
|
||||||
|
|
||||||
|
&.plain {
|
||||||
|
color: #666;
|
||||||
|
border: 1rpx solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.primary {
|
||||||
|
color: #ffffff;
|
||||||
|
background-color: var(--main-color, #0054a5);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.danger {
|
||||||
|
color: #f5222d;
|
||||||
|
border: 1rpx solid #f5222d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more {
|
||||||
|
text-align: center;
|
||||||
|
color: #999;
|
||||||
|
font-size: 24rpx;
|
||||||
|
padding: 24rpx 0;
|
||||||
|
|
||||||
|
.load-end {
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
431
pages_tool/card/admin/leads.vue
Normal file
431
pages_tool/card/admin/leads.vue
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
<template>
|
||||||
|
<view class="admin-leads">
|
||||||
|
<!-- 商家端三大入口平级切换(鸿蒙安全: $util.redirectTo) -->
|
||||||
|
<view class="page-switch">
|
||||||
|
<view class="switch-item" @tap="goManage">名片管理</view>
|
||||||
|
<view class="switch-item" @tap="goContacts">人脉管理</view>
|
||||||
|
<view class="switch-item active" @tap="goSelf">潜在客户</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 搜索栏 -->
|
||||||
|
<view class="search-bar">
|
||||||
|
<input class="search-input" :value="keyword" placeholder="搜索姓名 / 手机"
|
||||||
|
confirm-type="search" @input="e => keyword = e.detail.value" @confirm="doSearch" />
|
||||||
|
<view class="search-btn" @tap="doSearch">搜索</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 跟进状态筛选(与后端 Lead::STATUSES 对齐, setStatus 仅接受 0-3) -->
|
||||||
|
<scroll-view scroll-x class="tab-bar">
|
||||||
|
<view class="tab-inner">
|
||||||
|
<view v-for="tab in tabs" :key="tab.value" class="tab-item"
|
||||||
|
:class="{ active: status === tab.value }" @tap="switchTab(tab.value)">
|
||||||
|
{{ tab.label }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<!-- 线索列表 -->
|
||||||
|
<view class="lead-list">
|
||||||
|
<view v-for="(item, index) in list" :key="item.id || index" class="lead-item">
|
||||||
|
<view class="lead-top" @tap="callPhone(item.mobile)">
|
||||||
|
<view class="lead-name">{{ item.name || '未留姓名' }}</view>
|
||||||
|
<view class="lead-status" :class="'st-' + item.status">{{ item.status_text || '新客户' }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="lead-mobile" v-if="item.mobile" @tap="callPhone(item.mobile)">📱 {{ item.mobile }}</view>
|
||||||
|
<view class="lead-note" v-if="item.note">「{{ item.note }}」</view>
|
||||||
|
<view class="lead-meta">
|
||||||
|
<text class="meta-card">来自名片:{{ item.card_name }}</text>
|
||||||
|
<text class="meta-time">{{ item.create_time_txt }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="lead-actions">
|
||||||
|
<view class="action-btn copy" v-if="item.wechat" @tap="copyText(item.wechat)">复制微信</view>
|
||||||
|
<view class="action-btn primary" :data-index="index" @tap="changeStatus">更新状态</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="load-more" v-if="list.length > 0">
|
||||||
|
<text v-if="isLoading">加载中…</text>
|
||||||
|
<text v-else-if="page < pageCount">上拉加载更多</text>
|
||||||
|
<text v-else class="load-end">— 没有更多了 —</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<ns-empty v-if="!isLoading && list.length === 0" :isIndex="false"
|
||||||
|
:text="emptyText"></ns-empty>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* 商家端 · 潜在客户
|
||||||
|
*
|
||||||
|
* 接口全部来自后端已就绪的 /card/api/lead/*(收口在 common/js/card-api.js):
|
||||||
|
* lead/lists 列表(可见范围=自己名下名片收到的线索 + 自己从人脉转入的线索;
|
||||||
|
* 没有名片时列表为空属正常现象) / lead/setStatus 更新跟进状态(0-3)。
|
||||||
|
*/
|
||||||
|
import { LEAD_STATUS } from '@/common/js/card-api.js';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
tabs: [{ value: '', label: '全部' }].concat(LEAD_STATUS),
|
||||||
|
status: '',
|
||||||
|
keyword: '',
|
||||||
|
list: [],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
pageCount: 1,
|
||||||
|
isLoading: false,
|
||||||
|
emptyText: '暂无意向客户,访客在你的名片页留资后会出现在这里'
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onLoad() {
|
||||||
|
this.refresh();
|
||||||
|
},
|
||||||
|
onReady() {
|
||||||
|
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
|
||||||
|
uni.setNavigationBarTitle({ title: '名片' });
|
||||||
|
// #ifdef H5
|
||||||
|
document.title = '名片';
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
onPullDownRefresh() {
|
||||||
|
this.refresh(() => uni.stopPullDownRefresh());
|
||||||
|
},
|
||||||
|
onReachBottom() {
|
||||||
|
if (this.isLoading) return;
|
||||||
|
if (this.page >= this.pageCount) return;
|
||||||
|
this.loadPage(this.page + 1);
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
// 平级入口: 鸿蒙禁裸 uni.switchTab, 走 $util.redirectTo
|
||||||
|
goManage() {
|
||||||
|
this.$util.redirectTo('/pages_tool/card/admin/manage');
|
||||||
|
},
|
||||||
|
goContacts() {
|
||||||
|
this.$util.redirectTo('/pages_tool/card/admin/contacts');
|
||||||
|
},
|
||||||
|
goSelf() {},
|
||||||
|
switchTab(value) {
|
||||||
|
if (this.status === value) return;
|
||||||
|
this.status = value;
|
||||||
|
this.refresh();
|
||||||
|
},
|
||||||
|
doSearch() {
|
||||||
|
this.refresh();
|
||||||
|
},
|
||||||
|
refresh(done) {
|
||||||
|
this.page = 1;
|
||||||
|
this.loadPage(1, done);
|
||||||
|
},
|
||||||
|
loadPage(pageNo, done) {
|
||||||
|
this.isLoading = true;
|
||||||
|
const data = { page: pageNo, page_size: this.pageSize };
|
||||||
|
// 空字符串 = 全部, 后端对 '' 不加 status 条件; status 只传 0-3
|
||||||
|
if (this.status !== '') data.status = this.status;
|
||||||
|
if (this.keyword.trim() !== '') data.keyword = this.keyword.trim();
|
||||||
|
|
||||||
|
this.$cardApi.leadLists({
|
||||||
|
data: data,
|
||||||
|
success: res => {
|
||||||
|
this.isLoading = false;
|
||||||
|
if (res.code == 0) {
|
||||||
|
const d = res.data || {};
|
||||||
|
const rows = d.list || [];
|
||||||
|
this.pageCount = d.page_count || 1;
|
||||||
|
this.page = pageNo;
|
||||||
|
this.list = pageNo === 1 ? rows : this.list.concat(rows);
|
||||||
|
this.emptyText = this.keyword.trim() || this.status !== ''
|
||||||
|
? '未找到匹配的客户'
|
||||||
|
: '暂无意向客户,访客在你的名片页留资后会出现在这里';
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: res.message || '加载失败', icon: 'none' });
|
||||||
|
}
|
||||||
|
typeof done === 'function' && done();
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
this.isLoading = false;
|
||||||
|
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||||
|
typeof done === 'function' && done();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 取事件对应的行数据。小程序端 v-for 内联传对象会丢参, 统一 :data-index + 不传参。
|
||||||
|
* 兼容: 事件对象(data-index) / 数字索引 / 行对象本身。
|
||||||
|
*/
|
||||||
|
resolveItem(payload) {
|
||||||
|
if (payload == null) return null;
|
||||||
|
if (typeof payload === 'number') return this.list[payload] || null;
|
||||||
|
const ds = payload.currentTarget && payload.currentTarget.dataset;
|
||||||
|
if (ds) {
|
||||||
|
const i = Number(ds.index);
|
||||||
|
if (!isNaN(i) && this.list[i]) return this.list[i];
|
||||||
|
}
|
||||||
|
return payload.id ? payload : null;
|
||||||
|
},
|
||||||
|
changeStatus(payload) {
|
||||||
|
let item, index;
|
||||||
|
if (payload && payload.currentTarget) {
|
||||||
|
index = Number(payload.currentTarget.dataset.index);
|
||||||
|
item = this.list[index];
|
||||||
|
} else if (typeof payload === 'number') {
|
||||||
|
index = payload;
|
||||||
|
item = this.list[index];
|
||||||
|
} else {
|
||||||
|
item = payload;
|
||||||
|
index = this.list.indexOf(item);
|
||||||
|
}
|
||||||
|
if (!item) return;
|
||||||
|
uni.showActionSheet({
|
||||||
|
itemList: LEAD_STATUS.map(s => s.label),
|
||||||
|
success: res => {
|
||||||
|
const target = LEAD_STATUS[res.tapIndex];
|
||||||
|
if (!target || target.value === item.status) return;
|
||||||
|
this.doSetStatus(item, index, target.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
doSetStatus(item, index, status) {
|
||||||
|
this.$cardApi.leadSetStatus({
|
||||||
|
data: { id: item.id, status: status },
|
||||||
|
success: res => {
|
||||||
|
if (res.code == 0) {
|
||||||
|
// 就地更新, 不整页刷新
|
||||||
|
this.list[index].status = status;
|
||||||
|
this.list[index].status_text = LEAD_STATUS.find(s => s.value === status).label;
|
||||||
|
uni.showToast({ title: '已更新', icon: 'success' });
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: res.message || '更新失败', icon: 'none' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
callPhone(mobile) {
|
||||||
|
if (!mobile) return;
|
||||||
|
uni.makePhoneCall({ phoneNumber: mobile });
|
||||||
|
},
|
||||||
|
copyText(text) {
|
||||||
|
uni.setClipboardData({
|
||||||
|
data: text,
|
||||||
|
success: () => uni.showToast({ title: '已复制', icon: 'success' })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.admin-leads {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #f6f7f9;
|
||||||
|
padding-bottom: 40rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-switch {
|
||||||
|
display: flex;
|
||||||
|
background-color: #ffffff;
|
||||||
|
padding: 0 12rpx;
|
||||||
|
|
||||||
|
.switch-item {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 24rpx 0 20rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #666;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: var(--main-color, #0054a5);
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
bottom: 8rpx;
|
||||||
|
width: 48rpx;
|
||||||
|
height: 6rpx;
|
||||||
|
border-radius: 3rpx;
|
||||||
|
background-color: var(--main-color, #0054a5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16rpx 24rpx;
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
height: 64rpx;
|
||||||
|
line-height: 64rpx;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 32rpx;
|
||||||
|
padding: 0 28rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-btn {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #666;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-bar {
|
||||||
|
background-color: #ffffff;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
.tab-inner {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 0 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 24rpx 28rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #666;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: var(--main-color, #0054a5);
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
bottom: 10rpx;
|
||||||
|
width: 40rpx;
|
||||||
|
height: 6rpx;
|
||||||
|
border-radius: 3rpx;
|
||||||
|
background-color: var(--main-color, #0054a5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-list {
|
||||||
|
padding: 20rpx 24rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-item {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
|
||||||
|
.lead-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.lead-name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-status {
|
||||||
|
font-size: 22rpx;
|
||||||
|
padding: 6rpx 18rpx;
|
||||||
|
border-radius: 22rpx;
|
||||||
|
|
||||||
|
&.st-0 {
|
||||||
|
color: var(--main-color, #0054a5);
|
||||||
|
background-color: var(--main-color-shallow, #e8f1fb);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.st-1 {
|
||||||
|
color: #e6a23c;
|
||||||
|
background-color: #fdf3e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.st-2 {
|
||||||
|
color: #1daa39;
|
||||||
|
background-color: #e8f8ea;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.st-3 {
|
||||||
|
color: #999;
|
||||||
|
background-color: #f2f2f2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-mobile {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #333;
|
||||||
|
margin-top: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-note {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #999;
|
||||||
|
margin-top: 14rpx;
|
||||||
|
|
||||||
|
.meta-card {
|
||||||
|
max-width: 60%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 20rpx;
|
||||||
|
padding-top: 20rpx;
|
||||||
|
border-top: 1rpx solid #f0f0f0;
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
font-size: 24rpx;
|
||||||
|
border-radius: 30rpx;
|
||||||
|
padding: 10rpx 30rpx;
|
||||||
|
margin-left: 20rpx;
|
||||||
|
|
||||||
|
&.copy {
|
||||||
|
color: #666;
|
||||||
|
border: 1rpx solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.primary {
|
||||||
|
color: #ffffff;
|
||||||
|
background-color: var(--main-color, #0054a5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more {
|
||||||
|
text-align: center;
|
||||||
|
color: #999;
|
||||||
|
font-size: 24rpx;
|
||||||
|
padding: 24rpx 0;
|
||||||
|
|
||||||
|
.load-end {
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
432
pages_tool/card/admin/manage.vue
Normal file
432
pages_tool/card/admin/manage.vue
Normal file
@@ -0,0 +1,432 @@
|
|||||||
|
<template>
|
||||||
|
<view class="admin-manage">
|
||||||
|
<!-- 商家端三大入口平级切换(鸿蒙安全: $util.redirectTo, 禁裸 uni.switchTab) -->
|
||||||
|
<view class="page-switch">
|
||||||
|
<view class="switch-item active" @tap="goSelf">名片管理</view>
|
||||||
|
<view class="switch-item" @tap="goContacts">人脉管理</view>
|
||||||
|
<view class="switch-item" @tap="goLeads">潜在客户</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 功能预告: 查重合并/批量制作/导出等依赖后台接口, 就绪后开放 -->
|
||||||
|
<view class="notice-bar">
|
||||||
|
<text class="notice-icon">i</text>
|
||||||
|
<text class="notice-text">查重合并、批量制作、导出 CSV/vCard 等功能即将上线</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 搜索区 -->
|
||||||
|
<view class="search-bar">
|
||||||
|
<view class="search-input-wrap">
|
||||||
|
<input class="search-input" confirm-type="search" :value="keyword"
|
||||||
|
placeholder="搜索姓名 / 手机 / 公司" @confirm="onSearch" @input="onKeywordInput" />
|
||||||
|
<text v-if="keyword" class="search-clear" @tap="clearKeyword">✕</text>
|
||||||
|
<text class="iconfont icon-sousuo3" @tap="onSearch"></text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 名片列表 -->
|
||||||
|
<view class="card-list">
|
||||||
|
<view v-for="(item, index) in list" :key="item.card_id || index" :data-index="index" class="card-item"
|
||||||
|
@tap="goDetail">
|
||||||
|
<view class="card-avatar">
|
||||||
|
<image v-if="item.avatar" :src="$util.img(item.avatar)" class="avatar-img" mode="aspectFill" />
|
||||||
|
<text v-else>{{ avatarText(item.name) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="card-info">
|
||||||
|
<view class="card-name line1">{{ item.name || '未命名名片' }}</view>
|
||||||
|
<view class="card-sub line1" v-if="item.job_title || item.company">
|
||||||
|
{{ [item.job_title, item.company].filter(Boolean).join(' · ') }}
|
||||||
|
</view>
|
||||||
|
<view class="card-mobile" v-if="item.mobile">📱 {{ item.mobile }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="card-side">
|
||||||
|
<text v-if="sourceLabel(item)" class="source-tag">{{ sourceLabel(item) }}</text>
|
||||||
|
<view class="card-actions" @tap.stop="">
|
||||||
|
<text class="act-btn" :data-index="index" @tap="goEdit">编辑</text>
|
||||||
|
<text class="act-btn primary" :data-index="index" @tap="goDetail">详情</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<view class="load-more" v-if="list.length > 0">
|
||||||
|
<text v-if="isLoading">加载中…</text>
|
||||||
|
<text v-else-if="page < pageCount">上拉加载更多</text>
|
||||||
|
<text v-else class="load-end">— 没有更多了 —</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 空态 -->
|
||||||
|
<ns-empty v-if="!isLoading && list.length === 0" :isIndex="false" :text="emptyText"></ns-empty>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* 商家端 · 名片管理(基础版)
|
||||||
|
*
|
||||||
|
* 范围: 本轮仅开放现有接口可支撑的能力——搜索 / 详情 / 编辑。
|
||||||
|
* 列表/详情/编辑复用 card/lists + 既有 /pages_tool/card/detail、edit 页
|
||||||
|
* (路由参数 card_id, 字符串主键)。
|
||||||
|
* 查重合并 / 批量制作 / 新建数字名片 / 导出 CSV/vCard / 标签·状态·类型·跟进·收藏
|
||||||
|
* 筛选依赖后台未就绪接口, 后台文档(CARD_API_REFERENCE.md)到位后在本页扩展,
|
||||||
|
* 端点届时收口进 common/js/card-api.js, 页面只改 this.$cardApi 调用。
|
||||||
|
*/
|
||||||
|
import { CARD_SOURCE_TYPES } from '@/common/js/card-api.js';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
keyword: '',
|
||||||
|
list: [],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
pageCount: 1,
|
||||||
|
isLoading: false,
|
||||||
|
emptyText: '暂无名片刻'
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onLoad() {
|
||||||
|
this.refresh();
|
||||||
|
},
|
||||||
|
onReady() {
|
||||||
|
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
|
||||||
|
uni.setNavigationBarTitle({ title: '名片' });
|
||||||
|
// #ifdef H5
|
||||||
|
document.title = '名片';
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
onShow() {
|
||||||
|
// 从编辑页返回时刷新(编辑成功后数据已变)
|
||||||
|
if (this._loadedOnce) this.refresh();
|
||||||
|
this._loadedOnce = true;
|
||||||
|
},
|
||||||
|
onPullDownRefresh() {
|
||||||
|
this.refresh(() => uni.stopPullDownRefresh());
|
||||||
|
},
|
||||||
|
onReachBottom() {
|
||||||
|
if (this.isLoading) return;
|
||||||
|
if (this.page >= this.pageCount) return;
|
||||||
|
this.loadPage(this.page + 1);
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
avatarText(name) {
|
||||||
|
return (name || '?').trim().charAt(0).toUpperCase();
|
||||||
|
},
|
||||||
|
// 来源标签: 优先后端译文(source_type_text), 缺失时按字典兜底(与后端 source_type 对齐)
|
||||||
|
sourceLabel(item) {
|
||||||
|
if (!item) return '';
|
||||||
|
if (item.source_type_text) return item.source_type_text;
|
||||||
|
const hit = CARD_SOURCE_TYPES.find(s => s.value === item.source_type);
|
||||||
|
return hit ? hit.label : '';
|
||||||
|
},
|
||||||
|
onKeywordInput(e) {
|
||||||
|
this.keyword = e.detail.value;
|
||||||
|
},
|
||||||
|
onSearch() {
|
||||||
|
this.refresh();
|
||||||
|
},
|
||||||
|
clearKeyword() {
|
||||||
|
this.keyword = '';
|
||||||
|
this.refresh();
|
||||||
|
},
|
||||||
|
refresh(done) {
|
||||||
|
this.page = 1;
|
||||||
|
this.loadPage(1, done);
|
||||||
|
},
|
||||||
|
loadPage(pageNo, done) {
|
||||||
|
this.isLoading = true;
|
||||||
|
// page_size 上限 100(后端硬限制), 这里 20 足够
|
||||||
|
this.$cardApi.cardLists({
|
||||||
|
data: {
|
||||||
|
page: pageNo,
|
||||||
|
page_size: this.pageSize,
|
||||||
|
keyword: this.keyword.trim()
|
||||||
|
},
|
||||||
|
success: res => {
|
||||||
|
this.isLoading = false;
|
||||||
|
if (res.code == 0) {
|
||||||
|
const d = res.data || {};
|
||||||
|
const rows = d.list || [];
|
||||||
|
this.pageCount = d.page_count || 1;
|
||||||
|
this.page = pageNo;
|
||||||
|
this.list = pageNo === 1 ? rows : this.list.concat(rows);
|
||||||
|
this.emptyText = this.keyword.trim() ? '未找到匹配的名片' : '暂无名片刻';
|
||||||
|
} else {
|
||||||
|
// 后端 code!=0 的 message 可直接 toast
|
||||||
|
uni.showToast({ title: res.message || '加载失败', icon: 'none' });
|
||||||
|
}
|
||||||
|
typeof done === 'function' && done();
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
this.isLoading = false;
|
||||||
|
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||||
|
typeof done === 'function' && done();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ---------- 导航 ---------- */
|
||||||
|
/**
|
||||||
|
* 取事件对应的行数据。小程序端 v-for 内联传对象(如 goDetail(item))会丢参,
|
||||||
|
* 统一改为 :data-index + 不传参, 这里从 dataset 取索引回表。
|
||||||
|
*/
|
||||||
|
resolveItem(payload) {
|
||||||
|
if (payload == null) return null;
|
||||||
|
if (typeof payload === 'number') return this.list[payload] || null;
|
||||||
|
const ds = payload.currentTarget && payload.currentTarget.dataset;
|
||||||
|
if (ds) {
|
||||||
|
const i = Number(ds.index);
|
||||||
|
if (!isNaN(i) && this.list[i]) return this.list[i];
|
||||||
|
}
|
||||||
|
return payload.card_id ? payload : null;
|
||||||
|
},
|
||||||
|
// 名片路由参数一律 card_id(字符串), encodeURIComponent 幂等
|
||||||
|
goDetail(payload) {
|
||||||
|
const item = this.resolveItem(payload);
|
||||||
|
if (!item || !item.card_id) return;
|
||||||
|
uni.navigateTo({
|
||||||
|
url: '/pages_tool/card/detail?card_id=' + encodeURIComponent(item.card_id)
|
||||||
|
});
|
||||||
|
},
|
||||||
|
goEdit(payload) {
|
||||||
|
const item = this.resolveItem(payload);
|
||||||
|
if (!item || !item.card_id) return;
|
||||||
|
uni.navigateTo({
|
||||||
|
url: '/pages_tool/card/edit?card_id=' + encodeURIComponent(item.card_id)
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// 平级入口: 鸿蒙元服务上 uni.switchTab 静默失败, 必须走 $util.redirectTo
|
||||||
|
goContacts() {
|
||||||
|
this.$util.redirectTo('/pages_tool/card/admin/contacts');
|
||||||
|
},
|
||||||
|
goLeads() {
|
||||||
|
this.$util.redirectTo('/pages_tool/card/admin/leads');
|
||||||
|
},
|
||||||
|
goSelf() {
|
||||||
|
// 当前页, 无需跳转
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.admin-manage {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #f6f7f9;
|
||||||
|
padding-bottom: 40rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶部平级切换(三页互链) */
|
||||||
|
.page-switch {
|
||||||
|
display: flex;
|
||||||
|
background-color: #ffffff;
|
||||||
|
padding: 0 12rpx;
|
||||||
|
|
||||||
|
.switch-item {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 24rpx 0 20rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #666;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: var(--main-color, #0054a5);
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
bottom: 8rpx;
|
||||||
|
width: 48rpx;
|
||||||
|
height: 6rpx;
|
||||||
|
border-radius: 3rpx;
|
||||||
|
background-color: var(--main-color, #0054a5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 功能预告条 */
|
||||||
|
.notice-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: 20rpx 24rpx 0;
|
||||||
|
padding: 16rpx 20rpx;
|
||||||
|
background-color: var(--main-color-shallow, #e8f1fb);
|
||||||
|
border-radius: 12rpx;
|
||||||
|
|
||||||
|
.notice-icon {
|
||||||
|
width: 32rpx;
|
||||||
|
height: 32rpx;
|
||||||
|
line-height: 32rpx;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--main-color, #0054a5);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 22rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: var(--main-color, #0054a5);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20rpx 24rpx 0;
|
||||||
|
|
||||||
|
.search-input-wrap {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 36rpx;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
height: 72rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear {
|
||||||
|
padding: 0 12rpx;
|
||||||
|
color: #bbb;
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconfont {
|
||||||
|
color: #999;
|
||||||
|
font-size: 32rpx;
|
||||||
|
padding-left: 12rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-list {
|
||||||
|
padding: 20rpx 24rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 28rpx 24rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background-color: #f0f2f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-avatar {
|
||||||
|
width: 88rpx;
|
||||||
|
height: 88rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--main-color-shallow, #e8f1fb);
|
||||||
|
color: var(--main-color, #0054a5);
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 24rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.avatar-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.card-name {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-sub {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-mobile {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #999;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-side {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 16rpx;
|
||||||
|
|
||||||
|
.source-tag {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: #999;
|
||||||
|
background-color: #f2f2f2;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 4rpx 14rpx;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
.act-btn {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #666;
|
||||||
|
border: 1rpx solid #ddd;
|
||||||
|
border-radius: 28rpx;
|
||||||
|
padding: 8rpx 22rpx;
|
||||||
|
|
||||||
|
& + .act-btn {
|
||||||
|
margin-left: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.primary {
|
||||||
|
color: var(--main-color, #0054a5);
|
||||||
|
border-color: var(--main-color, #0054a5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more {
|
||||||
|
text-align: center;
|
||||||
|
color: #999;
|
||||||
|
font-size: 24rpx;
|
||||||
|
padding: 24rpx 0;
|
||||||
|
|
||||||
|
.load-end {
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.line1 {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
213
pages_tool/card/apply.vue
Normal file
213
pages_tool/card/apply.vue
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
<template>
|
||||||
|
<view class="card-apply">
|
||||||
|
<!-- 申请状态条: 提交过申请或已有名片时替代表单 -->
|
||||||
|
<view class="tips" v-if="statusLoaded && statusTip">
|
||||||
|
<text class="tips-title">{{ statusTip }}</text>
|
||||||
|
<text class="tips-sub" v-if="statusSub">{{ statusSub }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<template v-if="formEditable">
|
||||||
|
<view class="tips">
|
||||||
|
<text class="tips-title">向商家申请一张数字名片</text>
|
||||||
|
<text class="tips-sub">提交后商家会收到通知,同意后名片自动出现在你的名片夹</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="form-group">
|
||||||
|
<view class="form-row">
|
||||||
|
<view class="row-label"><text class="req-star">*</text>姓名</view>
|
||||||
|
<input class="row-input" :value="form.name" placeholder="你希望名片上显示的姓名"
|
||||||
|
@input="e => form.name = e.detail.value" />
|
||||||
|
</view>
|
||||||
|
<view class="form-row">
|
||||||
|
<view class="row-label"><text class="req-star">*</text>手机号</view>
|
||||||
|
<input class="row-input" type="number" :value="form.mobile" placeholder="你注册本店使用的手机号"
|
||||||
|
@input="e => form.mobile = e.detail.value" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<view class="save-bar" v-if="formEditable">
|
||||||
|
<button class="save-btn" :disabled="submitting" @tap="submit">{{ submitting ? '提交中…' : '提交申请' }}</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
form: { name: '', mobile: '' },
|
||||||
|
submitting: false,
|
||||||
|
// 状态回查: null=未加载, 否则 { apply, has_card }
|
||||||
|
applyStatus: null,
|
||||||
|
statusLoaded: false
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
// 已有名片 / 申请待审 → 隐藏表单只显示状态; 被拒/无记录 → 允许(重新)提交
|
||||||
|
formEditable() {
|
||||||
|
const s = this.applyStatus;
|
||||||
|
if (!s) return true;
|
||||||
|
if (s.has_card) return false;
|
||||||
|
if (s.apply && s.apply.status === 0) return false; // 待审核
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
statusTip() {
|
||||||
|
const s = this.applyStatus;
|
||||||
|
if (!s) return '';
|
||||||
|
if (s.has_card) return '您已拥有名片,去名片夹查看吧';
|
||||||
|
if (s.apply && s.apply.status === 0) return '申请审核中,商家同意后名片会自动出现';
|
||||||
|
if (s.apply && s.apply.status === 2) return '上次申请未通过,可修改后重新提交';
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
statusSub() {
|
||||||
|
const s = this.applyStatus;
|
||||||
|
if (!s || !s.apply) return '';
|
||||||
|
if (s.apply.status === 2) return '提交于 ' + (s.apply.create_time_txt || '');
|
||||||
|
return s.apply.create_time_txt ? ('提交于 ' + s.apply.create_time_txt) : '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLoad() {
|
||||||
|
uni.setNavigationBarTitle({ title: '名片' });
|
||||||
|
this.loadStatus();
|
||||||
|
},
|
||||||
|
onReady() {
|
||||||
|
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
|
||||||
|
uni.setNavigationBarTitle({ title: '名片' });
|
||||||
|
// #ifdef H5
|
||||||
|
document.title = '名片';
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
loadStatus() {
|
||||||
|
this.$api.sendRequest({
|
||||||
|
url: '/card/api/apply/status',
|
||||||
|
success: res => {
|
||||||
|
if (res.code == 0 && res.data) {
|
||||||
|
this.applyStatus = res.data;
|
||||||
|
}
|
||||||
|
this.statusLoaded = true;
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
// 回查失败不阻塞表单
|
||||||
|
this.statusLoaded = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
submit() {
|
||||||
|
if (this.submitting) return;
|
||||||
|
const name = String(this.form.name || '').trim();
|
||||||
|
const mobile = String(this.form.mobile || '').trim();
|
||||||
|
if (!name) {
|
||||||
|
uni.showToast({ title: '请填写姓名', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/^1\d{10}$/.test(mobile)) {
|
||||||
|
uni.showToast({ title: '请填写正确的 11 位手机号', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.submitting = true;
|
||||||
|
this.$api.sendJson({
|
||||||
|
url: '/card/api/apply/submit',
|
||||||
|
data: { name: name, mobile: mobile },
|
||||||
|
success: res => {
|
||||||
|
this.submitting = false;
|
||||||
|
if (res.code == 0) {
|
||||||
|
uni.showToast({ title: '申请已提交', icon: 'success' });
|
||||||
|
setTimeout(() => uni.navigateBack(), 600);
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: res.message || '提交失败', icon: 'none' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
this.submitting = false;
|
||||||
|
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.card-apply {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #f6f7f9;
|
||||||
|
padding: 24rpx 24rpx 60rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tips {
|
||||||
|
padding: 20rpx 8rpx 24rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.tips-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tips-sub {
|
||||||
|
margin-top: 8rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #999;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 28rpx 24rpx;
|
||||||
|
border-bottom: 1rpx solid #f0f0f0;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-label {
|
||||||
|
width: 160rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #666;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.req-star {
|
||||||
|
color: #f5222d;
|
||||||
|
margin-right: 4rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-input {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-bar {
|
||||||
|
padding: 40rpx 0 0;
|
||||||
|
|
||||||
|
.save-btn {
|
||||||
|
height: 88rpx;
|
||||||
|
line-height: 88rpx;
|
||||||
|
background-color: var(--main-color, #0054a5);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 30rpx;
|
||||||
|
border-radius: 44rpx;
|
||||||
|
|
||||||
|
&[disabled] {
|
||||||
|
opacity: 0.6;
|
||||||
|
color: #ffffff;
|
||||||
|
background-color: var(--main-color, #0054a5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
255
pages_tool/card/contact-edit.vue
Normal file
255
pages_tool/card/contact-edit.vue
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
<template>
|
||||||
|
<view class="contact-edit">
|
||||||
|
<view class="form-group">
|
||||||
|
<view class="group-title">{{ isEdit ? '编辑人脉' : '添加人脉' }}</view>
|
||||||
|
<view class="form-row">
|
||||||
|
<view class="row-label"><text class="req-star">*</text>姓名</view>
|
||||||
|
<input class="row-input" :value="form.name" placeholder="请输入姓名"
|
||||||
|
@input="e => form.name = e.detail.value" />
|
||||||
|
</view>
|
||||||
|
<view class="form-row">
|
||||||
|
<view class="row-label">手机</view>
|
||||||
|
<input class="row-input" type="number" :value="form.mobile" placeholder="手机号(选填)"
|
||||||
|
@input="e => form.mobile = e.detail.value" />
|
||||||
|
</view>
|
||||||
|
<view class="form-row">
|
||||||
|
<view class="row-label">公司</view>
|
||||||
|
<input class="row-input" :value="form.company" placeholder="公司名称(选填)"
|
||||||
|
@input="e => form.company = e.detail.value" />
|
||||||
|
</view>
|
||||||
|
<view class="form-row">
|
||||||
|
<view class="row-label">职位</view>
|
||||||
|
<input class="row-input" :value="form.job_title" placeholder="职位(选填)"
|
||||||
|
@input="e => form.job_title = e.detail.value" />
|
||||||
|
</view>
|
||||||
|
<view class="form-row">
|
||||||
|
<view class="row-label">微信号</view>
|
||||||
|
<input class="row-input" :value="form.wechat" placeholder="微信号(选填)"
|
||||||
|
@input="e => form.wechat = e.detail.value" />
|
||||||
|
</view>
|
||||||
|
<view class="form-row">
|
||||||
|
<view class="row-label">邮箱</view>
|
||||||
|
<input class="row-input" :value="form.email" placeholder="邮箱(选填)"
|
||||||
|
@input="e => form.email = e.detail.value" />
|
||||||
|
</view>
|
||||||
|
<view class="form-row">
|
||||||
|
<view class="row-label">网站</view>
|
||||||
|
<input class="row-input" :value="form.website" placeholder="个人主页 / 官网(选填)"
|
||||||
|
@input="e => form.website = e.detail.value" />
|
||||||
|
</view>
|
||||||
|
<view class="form-row">
|
||||||
|
<view class="row-label">地址</view>
|
||||||
|
<input class="row-input" :value="form.address" placeholder="地址(选填)"
|
||||||
|
@input="e => form.address = e.detail.value" />
|
||||||
|
</view>
|
||||||
|
<view class="form-row remark-row">
|
||||||
|
<view class="row-label">备注</view>
|
||||||
|
<textarea class="remark-input" :value="form.remark" placeholder="备注(选填)"
|
||||||
|
@input="e => form.remark = e.detail.value" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 保存 -->
|
||||||
|
<view class="save-bar">
|
||||||
|
<button class="save-btn" :disabled="saving" @tap="save">{{ saving ? '保存中…' : '保存' }}</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 与后端 Contact::EDITABLE_FIELDS 对齐(status 由列表归档操作管理, 编辑页不放)
|
||||||
|
const FIELDS = ['name', 'mobile', 'company', 'job_title', 'wechat', 'email', 'website', 'address', 'remark'];
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
id: 0,
|
||||||
|
isEdit: false,
|
||||||
|
form: {
|
||||||
|
name: '', mobile: '', company: '', job_title: '',
|
||||||
|
wechat: '', email: '', website: '', address: '', remark: ''
|
||||||
|
},
|
||||||
|
saving: false,
|
||||||
|
fromAdmin: false
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onLoad(options) {
|
||||||
|
this.id = parseInt(options.id || 0, 10) || 0;
|
||||||
|
this.isEdit = this.id > 0;
|
||||||
|
// from=admin: 商家端人脉管理页复用本编辑页, 保存后回商家端(缺省回员工端)
|
||||||
|
this.fromAdmin = options.from === 'admin';
|
||||||
|
if (this.isEdit) {
|
||||||
|
this.loadDetail();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onReady() {
|
||||||
|
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
|
||||||
|
uni.setNavigationBarTitle({ title: '名片' });
|
||||||
|
// #ifdef H5
|
||||||
|
document.title = '名片';
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
loadDetail() {
|
||||||
|
this.$cardApi.contactInfo({
|
||||||
|
data: { id: this.id },
|
||||||
|
success: res => {
|
||||||
|
if (res.code == 0) {
|
||||||
|
const r = res.data || {};
|
||||||
|
FIELDS.forEach(k => {
|
||||||
|
if (r[k] !== undefined && r[k] !== null) {
|
||||||
|
this.form[k] = String(r[k]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
uni.showModal({
|
||||||
|
title: '提示',
|
||||||
|
content: res.message || '人脉不存在',
|
||||||
|
showCancel: false,
|
||||||
|
success: () => this.backToList()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
save() {
|
||||||
|
if (this.saving) return;
|
||||||
|
// 防御式取值: 任何非字符串/undefined 都不会让校验误判或抛错
|
||||||
|
const name = String(this.form.name || '').trim();
|
||||||
|
if (!name) {
|
||||||
|
uni.showToast({ title: '请填写姓名', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.form.name = name;
|
||||||
|
this.saving = true;
|
||||||
|
|
||||||
|
const data = { id: this.id, fields: {} };
|
||||||
|
FIELDS.forEach(k => { data.fields[k] = this.form[k]; });
|
||||||
|
|
||||||
|
const onOk = res => {
|
||||||
|
this.saving = false;
|
||||||
|
if (res.code == 0) {
|
||||||
|
uni.showToast({ title: '保存成功', icon: 'success' });
|
||||||
|
setTimeout(() => this.backToList(), 600);
|
||||||
|
} else {
|
||||||
|
// 后端错误文案有时放在 message, 有时放在 data(字符串), 两者都兜住
|
||||||
|
const msg = res.message || (typeof res.data === 'string' ? res.data : '') || '保存失败';
|
||||||
|
uni.showToast({ title: msg, icon: 'none' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onFail = () => {
|
||||||
|
this.saving = false;
|
||||||
|
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// add/update 都走 JSON body,跨端序列化零歧义。
|
||||||
|
// ⚠️ 契约差异(见 docs/CARD_API_REFERENCE.md §1.4.2 / §1.4.4):
|
||||||
|
// contact/add → 入参是「顶层平铺字段」, name 必须在 body 顶层;
|
||||||
|
// contact/update→ 额外兼容 fields 对象。
|
||||||
|
// 历史 bug: 新增时也包成 { fields: {...} }, 后端在顶层读不到 name,
|
||||||
|
// 返回「请填写姓名」→ 表现为"明明填了姓名还提示填写姓名"。
|
||||||
|
// 这里新增按契约平铺, 同时保留一份 fields 兼容旧后端(多余键会被白名单忽略)。
|
||||||
|
if (this.isEdit) {
|
||||||
|
this.$cardApi.contactUpdate({ data: data, success: onOk, fail: onFail });
|
||||||
|
} else {
|
||||||
|
const addPayload = Object.assign({}, data.fields, { fields: data.fields });
|
||||||
|
this.$cardApi.contactAdd({ data: addPayload, success: onOk, fail: onFail });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// 鸿蒙安全回列表: $util.redirectTo(禁裸 uni.switchTab/uni.navigateBack 兜底)
|
||||||
|
// 商家端(from=admin)回商家端人脉管理, 员工端回「我的人脉」
|
||||||
|
backToList() {
|
||||||
|
const target = this.fromAdmin ? '/pages_tool/card/admin/contacts' : '/pages_tool/card/contacts';
|
||||||
|
this.$util.redirectTo(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.contact-edit {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #f6f7f9;
|
||||||
|
padding: 24rpx 24rpx 60rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.group-title {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
padding: 24rpx 24rpx 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 24rpx;
|
||||||
|
border-bottom: 1rpx solid #f0f0f0;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-label {
|
||||||
|
width: 160rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #666;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 必填红星(与 card/edit.vue 同款) */
|
||||||
|
.req-star {
|
||||||
|
color: #f5222d;
|
||||||
|
margin-right: 4rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-input {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.remark-row {
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
.remark-input {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 120rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-bar {
|
||||||
|
padding: 20rpx 0 0;
|
||||||
|
|
||||||
|
.save-btn {
|
||||||
|
height: 88rpx;
|
||||||
|
line-height: 88rpx;
|
||||||
|
background-color: var(--main-color, #0054a5);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 30rpx;
|
||||||
|
border-radius: 44rpx;
|
||||||
|
|
||||||
|
&[disabled] {
|
||||||
|
opacity: 0.6;
|
||||||
|
color: #ffffff;
|
||||||
|
background-color: var(--main-color, #0054a5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
452
pages_tool/card/contacts.vue
Normal file
452
pages_tool/card/contacts.vue
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
<template>
|
||||||
|
<view class="contact-page">
|
||||||
|
<!-- 平级入口: 与潜在客户页互链(导航合并第一步) -->
|
||||||
|
<view class="page-switch" @tap="goLeads">
|
||||||
|
<text class="switch-label">潜在客户</text>
|
||||||
|
<text class="switch-arrow">›</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 搜索栏 + 添加入口 -->
|
||||||
|
<view class="search-bar">
|
||||||
|
<input class="search-input" :value="keyword" placeholder="搜索姓名 / 手机 / 公司"
|
||||||
|
confirm-type="search" @input="e => keyword = e.detail.value" @confirm="doSearch" />
|
||||||
|
<view class="search-btn" @tap="doSearch">搜索</view>
|
||||||
|
<view class="add-btn" @tap="goEdit()">+ 添加</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 来源筛选 -->
|
||||||
|
<scroll-view scroll-x class="tab-bar">
|
||||||
|
<view class="tab-inner">
|
||||||
|
<view v-for="tab in tabs" :key="tab.value" class="tab-item"
|
||||||
|
:class="{ active: source === tab.value }" @tap="switchTab(tab.value)">
|
||||||
|
{{ tab.label }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<!-- 人脉列表 -->
|
||||||
|
<view class="contact-list">
|
||||||
|
<view v-for="item in list" :key="item.id" class="contact-item">
|
||||||
|
<view class="item-top" @tap="callPhone(item.mobile)">
|
||||||
|
<view class="item-name">{{ item.name }}</view>
|
||||||
|
<view class="item-source" :class="'src-' + item.source">{{ item.source_text }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="item-company" v-if="item.company || item.job_title">
|
||||||
|
{{ item.company }}<text v-if="item.company && item.job_title"> · </text>{{ item.job_title }}
|
||||||
|
</view>
|
||||||
|
<view class="item-mobile" v-if="item.mobile" @tap="callPhone(item.mobile)">{{ item.mobile }}</view>
|
||||||
|
<view class="item-remark" v-if="item.remark">「{{ item.remark }}」</view>
|
||||||
|
<view class="item-meta">
|
||||||
|
<text class="meta-time">{{ item.create_time_txt }}</text>
|
||||||
|
<text class="meta-status" v-if="item.status == 1">已归档</text>
|
||||||
|
</view>
|
||||||
|
<view class="item-actions">
|
||||||
|
<view class="action-btn plain" :data-index="index" @tap="goEdit">编辑</view>
|
||||||
|
<view class="action-btn primary" :data-index="index" @tap="convertToLead">转为潜在客户</view>
|
||||||
|
<view class="action-btn danger" :data-index="index" @tap="delContact">删除</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="load-more" v-if="list.length > 0">
|
||||||
|
<text v-if="isLoading">加载中…</text>
|
||||||
|
<text v-else-if="page < pageCount">上拉加载更多</text>
|
||||||
|
<text v-else class="load-end">— 没有更多了 —</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<ns-empty v-if="!isLoading && list.length === 0" :isIndex="false"
|
||||||
|
:text="'暂无人脉,点右上角添加'"></ns-empty>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 来源筛选: 与后端 Contact::SOURCES 一致; scan/exchange 暂无产生入口, 本轮只放出 3 个 tab
|
||||||
|
const SOURCE_TABS = [
|
||||||
|
{ value: '', label: '全部' },
|
||||||
|
{ value: 'manual', label: '手工录入' },
|
||||||
|
{ value: 'lead_in', label: '潜在客户转入' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
tabs: SOURCE_TABS,
|
||||||
|
source: '',
|
||||||
|
keyword: '',
|
||||||
|
list: [],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
pageCount: 1,
|
||||||
|
isLoading: false
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onLoad() {
|
||||||
|
this.refresh();
|
||||||
|
},
|
||||||
|
onReady() {
|
||||||
|
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
|
||||||
|
uni.setNavigationBarTitle({ title: '名片' });
|
||||||
|
// #ifdef H5
|
||||||
|
document.title = '名片';
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
onShow() {
|
||||||
|
// 从编辑页保存后 redirectTo 回来会重走 onLoad; 从其它页返回时兜底刷新
|
||||||
|
if (this.list.length > 0) {
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPullDownRefresh() {
|
||||||
|
this.refresh(() => uni.stopPullDownRefresh());
|
||||||
|
},
|
||||||
|
onReachBottom() {
|
||||||
|
if (this.isLoading) return;
|
||||||
|
if (this.page >= this.pageCount) return;
|
||||||
|
this.loadPage(this.page + 1);
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
// 顶部平级入口: $util.redirectTo(鸿蒙禁裸 uni.switchTab, 静默失败)
|
||||||
|
goLeads() {
|
||||||
|
this.$util.redirectTo('/pages_tool/card/leads');
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 取事件对应的行数据。小程序端 v-for 内联传对象(如 convertToLead(item))会丢参,
|
||||||
|
* 统一改为 :data-index + 不传参, 这里从 dataset 取索引回表。
|
||||||
|
* 兼容: 无参(新建) / 事件对象(data-index) / 直接 id。
|
||||||
|
*/
|
||||||
|
resolveItem(payload) {
|
||||||
|
if (payload == null) return null;
|
||||||
|
if (typeof payload === 'number') return this.list[payload] || null;
|
||||||
|
const ds = payload.currentTarget && payload.currentTarget.dataset;
|
||||||
|
if (ds) {
|
||||||
|
const i = Number(ds.index);
|
||||||
|
if (!isNaN(i) && this.list[i]) return this.list[i];
|
||||||
|
}
|
||||||
|
return payload.id ? payload : null;
|
||||||
|
},
|
||||||
|
goEdit(payload) {
|
||||||
|
let id = payload;
|
||||||
|
if (payload && payload.currentTarget) {
|
||||||
|
const i = Number(payload.currentTarget.dataset.index);
|
||||||
|
const item = this.list[i];
|
||||||
|
id = item ? item.id : '';
|
||||||
|
}
|
||||||
|
const url = id ? '/pages_tool/card/contact-edit?id=' + id : '/pages_tool/card/contact-edit';
|
||||||
|
this.$util.redirectTo(url);
|
||||||
|
},
|
||||||
|
switchTab(value) {
|
||||||
|
if (this.source === value) return;
|
||||||
|
this.source = value;
|
||||||
|
this.refresh();
|
||||||
|
},
|
||||||
|
doSearch() {
|
||||||
|
this.refresh();
|
||||||
|
},
|
||||||
|
refresh(done) {
|
||||||
|
this.page = 1;
|
||||||
|
this.loadPage(1, done);
|
||||||
|
},
|
||||||
|
loadPage(pageNo, done) {
|
||||||
|
this.isLoading = true;
|
||||||
|
const data = { page: pageNo, page_size: this.pageSize };
|
||||||
|
// 空字符串 = 全部, 后端对 '' 不加 source 条件
|
||||||
|
if (this.source !== '') {
|
||||||
|
data.source = this.source;
|
||||||
|
}
|
||||||
|
if (this.keyword.trim() !== '') {
|
||||||
|
data.keyword = this.keyword.trim();
|
||||||
|
}
|
||||||
|
this.$cardApi.contactLists({
|
||||||
|
data: data,
|
||||||
|
success: res => {
|
||||||
|
if (res.code == 0) {
|
||||||
|
const d = res.data || {};
|
||||||
|
const rows = d.list || [];
|
||||||
|
this.pageCount = d.page_count || 1;
|
||||||
|
this.page = pageNo;
|
||||||
|
this.list = pageNo === 1 ? rows : this.list.concat(rows);
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: res.message || '加载失败', icon: 'none' });
|
||||||
|
}
|
||||||
|
this.isLoading = false;
|
||||||
|
typeof done === 'function' && done();
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
this.isLoading = false;
|
||||||
|
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||||
|
typeof done === 'function' && done();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
convertToLead(payload) {
|
||||||
|
const item = this.resolveItem(payload);
|
||||||
|
if (!item) return;
|
||||||
|
uni.showModal({
|
||||||
|
title: '转为潜在客户',
|
||||||
|
content: '将「' + (item.name || '') + '」转入潜在客户跟进?人脉记录会保留。',
|
||||||
|
success: res => {
|
||||||
|
if (!res.confirm) return;
|
||||||
|
this.doConvert(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
doConvert(item) {
|
||||||
|
uni.showLoading({ title: '转换中…', mask: true });
|
||||||
|
this.$cardApi.contactToLead({
|
||||||
|
data: { id: item.id },
|
||||||
|
success: res => {
|
||||||
|
uni.hideLoading();
|
||||||
|
if (res.code == 0) {
|
||||||
|
uni.showToast({ title: '已转入潜在客户', icon: 'success' });
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: res.message || '转换失败', icon: 'none' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
uni.hideLoading();
|
||||||
|
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
delContact(payload) {
|
||||||
|
const item = this.resolveItem(payload);
|
||||||
|
if (!item) return;
|
||||||
|
uni.showModal({
|
||||||
|
title: '删除人脉',
|
||||||
|
content: '删除后不可恢复,确定删除「' + (item.name || '') + '」?',
|
||||||
|
confirmColor: '#f5222d',
|
||||||
|
success: res => {
|
||||||
|
if (!res.confirm) return;
|
||||||
|
this.doDelete(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
doDelete(item) {
|
||||||
|
this.$cardApi.contactDelete({
|
||||||
|
data: { id: item.id },
|
||||||
|
success: res => {
|
||||||
|
if (res.code == 0) {
|
||||||
|
this.list = this.list.filter(c => c.id !== item.id);
|
||||||
|
uni.showToast({ title: '已删除', icon: 'success' });
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: res.message || '删除失败', icon: 'none' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
callPhone(mobile) {
|
||||||
|
if (!mobile) return;
|
||||||
|
uni.makePhoneCall({ phoneNumber: mobile });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.contact-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #f6f7f9;
|
||||||
|
padding-bottom: 40rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-switch {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 16rpx 24rpx 0;
|
||||||
|
|
||||||
|
.switch-label {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: var(--main-color, #0054a5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-arrow {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: var(--main-color, #0054a5);
|
||||||
|
margin-left: 4rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16rpx 24rpx;
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
height: 64rpx;
|
||||||
|
line-height: 64rpx;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 32rpx;
|
||||||
|
padding: 0 28rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-btn {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #666;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
background-color: var(--main-color, #0054a5);
|
||||||
|
border-radius: 32rpx;
|
||||||
|
padding: 12rpx 28rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-bar {
|
||||||
|
background-color: #ffffff;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
.tab-inner {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 0 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 24rpx 28rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #666;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: var(--main-color, #0054a5);
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
bottom: 10rpx;
|
||||||
|
width: 40rpx;
|
||||||
|
height: 6rpx;
|
||||||
|
border-radius: 3rpx;
|
||||||
|
background-color: var(--main-color, #0054a5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-list {
|
||||||
|
padding: 20rpx 24rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-item {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
|
||||||
|
.item-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.item-name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-source {
|
||||||
|
font-size: 22rpx;
|
||||||
|
padding: 6rpx 18rpx;
|
||||||
|
border-radius: 22rpx;
|
||||||
|
|
||||||
|
&.src-manual {
|
||||||
|
color: var(--main-color, #0054a5);
|
||||||
|
background-color: var(--main-color-shallow, #e8f1fb);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.src-lead_in {
|
||||||
|
color: #1daa39;
|
||||||
|
background-color: #e8f8ea;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.src-scan,
|
||||||
|
&.src-exchange {
|
||||||
|
color: #999;
|
||||||
|
background-color: #f2f2f2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-company {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-mobile {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #333;
|
||||||
|
margin-top: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-remark {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #999;
|
||||||
|
margin-top: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 20rpx;
|
||||||
|
padding-top: 20rpx;
|
||||||
|
border-top: 1rpx solid #f0f0f0;
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
font-size: 24rpx;
|
||||||
|
border-radius: 30rpx;
|
||||||
|
padding: 10rpx 26rpx;
|
||||||
|
margin-left: 16rpx;
|
||||||
|
|
||||||
|
&.plain {
|
||||||
|
color: #666;
|
||||||
|
border: 1rpx solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.primary {
|
||||||
|
color: #ffffff;
|
||||||
|
background-color: var(--main-color, #0054a5);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.danger {
|
||||||
|
color: #f5222d;
|
||||||
|
border: 1rpx solid #f5222d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more {
|
||||||
|
text-align: center;
|
||||||
|
color: #999;
|
||||||
|
font-size: 24rpx;
|
||||||
|
padding: 24rpx 0;
|
||||||
|
|
||||||
|
.load-end {
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1394
pages_tool/card/detail.vue
Normal file
1394
pages_tool/card/detail.vue
Normal file
File diff suppressed because it is too large
Load Diff
585
pages_tool/card/edit.vue
Normal file
585
pages_tool/card/edit.vue
Normal file
@@ -0,0 +1,585 @@
|
|||||||
|
<template>
|
||||||
|
<view class="card-edit">
|
||||||
|
<!-- 基本信息 -->
|
||||||
|
<view class="form-group">
|
||||||
|
<view class="group-title">基本信息</view>
|
||||||
|
<view class="form-row">
|
||||||
|
<view class="row-label">头像</view>
|
||||||
|
<view class="avatar-box" @tap="chooseAvatar">
|
||||||
|
<image v-if="form.avatar" :src="$util.img(form.avatar)" class="avatar-img" mode="aspectFill" />
|
||||||
|
<text v-else class="avatar-plus">+</text>
|
||||||
|
</view>
|
||||||
|
<view class="avatar-tip">点击从相册选择</view>
|
||||||
|
</view>
|
||||||
|
<view class="form-row">
|
||||||
|
<view class="row-label"><text class="req-star">*</text>姓名</view>
|
||||||
|
<input class="row-input" :value="form.name" placeholder="请输入姓名"
|
||||||
|
@input="e => form.name = e.detail.value" />
|
||||||
|
</view>
|
||||||
|
<view class="form-row">
|
||||||
|
<view class="row-label">公司</view>
|
||||||
|
<input class="row-input" :value="form.company" placeholder="请输入公司名称"
|
||||||
|
@input="e => form.company = e.detail.value" />
|
||||||
|
</view>
|
||||||
|
<view class="form-row">
|
||||||
|
<view class="row-label">职位</view>
|
||||||
|
<input class="row-input" :value="form.job_title" placeholder="请输入职位"
|
||||||
|
@input="e => form.job_title = e.detail.value" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 联系方式 -->
|
||||||
|
<view class="form-group">
|
||||||
|
<view class="group-title">联系方式</view>
|
||||||
|
<view class="form-row">
|
||||||
|
<view class="row-label">手机</view>
|
||||||
|
<input class="row-input" type="number" :value="form.mobile" placeholder="请输入手机号"
|
||||||
|
@input="e => form.mobile = e.detail.value" />
|
||||||
|
</view>
|
||||||
|
<!-- 附加手机号(动态行, 与主手机号并列提交 mobiles) -->
|
||||||
|
<view v-for="(m, i) in mobiles" :key="'mob' + i" class="form-row dyn-row">
|
||||||
|
<view class="row-label">手机{{ i + 2 }}</view>
|
||||||
|
<input class="row-input" type="number" :value="m" placeholder="其他手机号"
|
||||||
|
@input="e => onMobileInput(i, e)" />
|
||||||
|
<text class="dyn-del" @tap="delMobile(i)">×</text>
|
||||||
|
</view>
|
||||||
|
<view class="form-row" v-if="mobiles.length < MAX_MOBILES">
|
||||||
|
<text class="dyn-add" @tap="addMobile">+ 添加手机号(最多{{ MAX_MOBILES }}个)</text>
|
||||||
|
</view>
|
||||||
|
<view class="form-row">
|
||||||
|
<view class="row-label">电话</view>
|
||||||
|
<input class="row-input" :value="form.phone" placeholder="固定电话(选填)"
|
||||||
|
@input="e => form.phone = e.detail.value" />
|
||||||
|
</view>
|
||||||
|
<view class="form-row">
|
||||||
|
<view class="row-label">邮箱</view>
|
||||||
|
<input class="row-input" :value="form.email" placeholder="邮箱(选填)"
|
||||||
|
@input="e => form.email = e.detail.value" />
|
||||||
|
</view>
|
||||||
|
<view class="form-row">
|
||||||
|
<view class="row-label">网站</view>
|
||||||
|
<input class="row-input" :value="form.website" placeholder="个人主页 / 官网(选填)"
|
||||||
|
@input="e => form.website = e.detail.value" />
|
||||||
|
</view>
|
||||||
|
<view class="form-row">
|
||||||
|
<view class="row-label">微信号</view>
|
||||||
|
<input class="row-input" :value="form.wechat" placeholder="微信号(选填)"
|
||||||
|
@input="e => form.wechat = e.detail.value" />
|
||||||
|
</view>
|
||||||
|
<!-- Telegram / Messenger 已于 2026-10-13 软下线为独立字段, 统一在下方「社交媒体账号」添加 -->
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 社交媒体账号 -->
|
||||||
|
<view class="form-group">
|
||||||
|
<view class="group-title">社交媒体账号</view>
|
||||||
|
<view v-for="(s, i) in socials" :key="'soc' + i" class="form-row dyn-row">
|
||||||
|
<picker class="dyn-picker" :range="socialTypeLabels" :value="socialTypeIndex(s.type)"
|
||||||
|
@change="e => onSocialTypeChange(i, e)">
|
||||||
|
<view class="picker-text">{{ socialTypeLabel(s.type) }} ▾</view>
|
||||||
|
</picker>
|
||||||
|
<input class="row-input" :value="s.value" placeholder="账号 / 链接"
|
||||||
|
@input="e => onSocialValueInput(i, e)" />
|
||||||
|
<text class="dyn-del" @tap="delSocial(i)">×</text>
|
||||||
|
</view>
|
||||||
|
<view class="form-row" v-if="socials.length < MAX_SOCIALS">
|
||||||
|
<text class="dyn-add" @tap="addSocial">+ 添加社交媒体(最多{{ MAX_SOCIALS }}个)</text>
|
||||||
|
</view>
|
||||||
|
<view class="group-tip" v-if="socials.length === 0">还没有社交媒体账号;Telegram / Messenger 也已并入此处添加</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 自定义字段 -->
|
||||||
|
<view class="form-group">
|
||||||
|
<view class="group-title">自定义字段</view>
|
||||||
|
<view v-for="(c, i) in customFields" :key="'cst' + i" class="form-row dyn-row">
|
||||||
|
<input class="cf-label" :value="c.label" placeholder="名称"
|
||||||
|
@input="e => onCustomLabelInput(i, e)" />
|
||||||
|
<input class="row-input" :value="c.value" placeholder="内容"
|
||||||
|
@input="e => onCustomValueInput(i, e)" />
|
||||||
|
<text class="dyn-del" @tap="delCustom(i)">×</text>
|
||||||
|
</view>
|
||||||
|
<view class="form-row" v-if="customFields.length < MAX_CUSTOM">
|
||||||
|
<text class="dyn-add" @tap="addCustom">+ 添加自定义字段(最多{{ MAX_CUSTOM }}个)</text>
|
||||||
|
</view>
|
||||||
|
<view class="group-tip" v-if="customFields.length === 0">如「展厅号 E3-108」「收款方式」等个性信息</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 地址 -->
|
||||||
|
<view class="form-group">
|
||||||
|
<view class="group-title">地址</view>
|
||||||
|
<view class="form-row">
|
||||||
|
<view class="row-label">国家</view>
|
||||||
|
<input class="row-input" :value="form.country" placeholder="选填"
|
||||||
|
@input="e => form.country = e.detail.value" />
|
||||||
|
</view>
|
||||||
|
<view class="form-row">
|
||||||
|
<view class="row-label">省份</view>
|
||||||
|
<input class="row-input" :value="form.province" placeholder="选填"
|
||||||
|
@input="e => form.province = e.detail.value" />
|
||||||
|
</view>
|
||||||
|
<view class="form-row">
|
||||||
|
<view class="row-label">城市</view>
|
||||||
|
<input class="row-input" :value="form.city" placeholder="选填"
|
||||||
|
@input="e => form.city = e.detail.value" />
|
||||||
|
</view>
|
||||||
|
<view class="form-row">
|
||||||
|
<view class="row-label">详细地址</view>
|
||||||
|
<input class="row-input" :value="form.address" placeholder="街道门牌等"
|
||||||
|
@input="e => form.address = e.detail.value" />
|
||||||
|
</view>
|
||||||
|
<view class="form-row">
|
||||||
|
<view class="row-label">邮编</view>
|
||||||
|
<input class="row-input" :value="form.postal_code" placeholder="选填"
|
||||||
|
@input="e => form.postal_code = e.detail.value" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 个人简介 -->
|
||||||
|
<view class="form-group">
|
||||||
|
<view class="group-title">个人简介</view>
|
||||||
|
<view class="form-row bio-row">
|
||||||
|
<textarea class="bio-input" :value="form.bio" placeholder="介绍一下自己…(保留换行)"
|
||||||
|
@input="e => form.bio = e.detail.value" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 保存 -->
|
||||||
|
<view class="save-bar">
|
||||||
|
<button class="save-btn" :disabled="saving" @tap="save">{{ saving ? '保存中…' : '保存' }}</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* 与后端 BusinessCardFieldNormalizer 输出对齐的标量字段(含 2026-10 补的 bio)。
|
||||||
|
* ⚠️ 2026-10-13: telegram / messenger 软下线为独立标量(传了也被后端忽略),
|
||||||
|
* 统一走下方社交媒体动态行; 旧数据读取时已由后端合并进 socials_list。
|
||||||
|
* 富化字段(mobiles/socials/custom_fields)由页面动态行单独收集,
|
||||||
|
* 后端 Card::update 的 richFieldSaves 仅对显式携带的键做 CardRichFields 清洗。
|
||||||
|
*
|
||||||
|
* SOCIAL_TYPES / CARD_FIELDS / RICH_FIELD_LIMITS 统一从收口层导入,
|
||||||
|
* 与后端白名单单点同步(见 common/js/card-api.js)。
|
||||||
|
*/
|
||||||
|
import { SOCIAL_TYPES, CARD_FIELDS, RICH_FIELD_LIMITS } from '@/common/js/card-api.js';
|
||||||
|
|
||||||
|
// 注:telegram/messenger 已软下线(2026-10-13), 不再作为独立标量字段,
|
||||||
|
// 统一在「社交媒体账号」动态行里以 SOCIAL_TYPES 中的类型方式添加。
|
||||||
|
|
||||||
|
// 上限与后端 CardRichFields::MAX_* 一致
|
||||||
|
const MAX_MOBILES = RICH_FIELD_LIMITS.MAX_MOBILES;
|
||||||
|
const MAX_SOCIALS = RICH_FIELD_LIMITS.MAX_SOCIALS;
|
||||||
|
const MAX_CUSTOM = RICH_FIELD_LIMITS.MAX_CUSTOM;
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
cardId: '',
|
||||||
|
// 新建模式: 无 card_id 进入, 提交 /card/api/card/create
|
||||||
|
isCreate: false,
|
||||||
|
form: {
|
||||||
|
name: '', company: '', job_title: '', avatar: '',
|
||||||
|
mobile: '', phone: '', email: '', website: '', wechat: '',
|
||||||
|
country: '', province: '', city: '', address: '', postal_code: '',
|
||||||
|
bio: ''
|
||||||
|
},
|
||||||
|
mobiles: [],
|
||||||
|
socials: [],
|
||||||
|
customFields: [],
|
||||||
|
socialTypeLabels: SOCIAL_TYPES.map(t => t.label),
|
||||||
|
MAX_MOBILES,
|
||||||
|
MAX_SOCIALS,
|
||||||
|
MAX_CUSTOM,
|
||||||
|
isLoading: false,
|
||||||
|
saving: false
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onLoad(options) {
|
||||||
|
this.cardId = decodeURIComponent(options.card_id || '');
|
||||||
|
this.isCreate = this.cardId === '';
|
||||||
|
if (this.isCreate) {
|
||||||
|
uni.setNavigationBarTitle({ title: '名片' });
|
||||||
|
} else {
|
||||||
|
this.loadDetail();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onReady() {
|
||||||
|
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
|
||||||
|
uni.setNavigationBarTitle({ title: '名片' });
|
||||||
|
// #ifdef H5
|
||||||
|
document.title = '名片';
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
loadDetail() {
|
||||||
|
if (!this.cardId) {
|
||||||
|
uni.showToast({ title: '缺少名片编号', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.isLoading = true;
|
||||||
|
this.$cardApi.cardDetail({
|
||||||
|
data: { card_id: this.cardId },
|
||||||
|
success: res => {
|
||||||
|
this.isLoading = false;
|
||||||
|
if (res.code == 0) {
|
||||||
|
const r = (res.data && res.data.relation) || {};
|
||||||
|
CARD_FIELDS.forEach(k => {
|
||||||
|
if (r[k] !== undefined && r[k] !== null) {
|
||||||
|
this.form[k] = String(r[k]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// 富化字段: detail 由后端 CardRichFields 解码后挂在 relation 上
|
||||||
|
// (注意是 res.data.relation.mobiles_list, 不是 res.data.mobiles_list)
|
||||||
|
this.mobiles = (r.mobiles_list || []).map(v => String(v || ''));
|
||||||
|
this.socials = (r.socials_list || []).map(s => ({
|
||||||
|
type: s.type || 'other',
|
||||||
|
value: String(s.value || '')
|
||||||
|
}));
|
||||||
|
this.customFields = (r.custom_list || []).map(c => ({
|
||||||
|
label: String(c.label || ''),
|
||||||
|
value: String(c.value || '')
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
uni.showModal({
|
||||||
|
title: '提示',
|
||||||
|
content: res.message || '卡片不存在',
|
||||||
|
showCancel: false,
|
||||||
|
success: () => uni.navigateBack()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
this.isLoading = false;
|
||||||
|
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ---------- 头像: 相册选择 → 系统上传 → 回填路径 ---------- */
|
||||||
|
chooseAvatar() {
|
||||||
|
uni.chooseImage({
|
||||||
|
count: 1,
|
||||||
|
sizeType: ['compressed'],
|
||||||
|
success: res => {
|
||||||
|
const filePath = res.tempFilePaths && res.tempFilePaths[0];
|
||||||
|
if (!filePath) return;
|
||||||
|
uni.showLoading({ title: '上传中…', mask: true });
|
||||||
|
this.$api.upload({
|
||||||
|
url: '/api/upload/headimg',
|
||||||
|
filePath: filePath,
|
||||||
|
fileType: 'image',
|
||||||
|
success: r => {
|
||||||
|
uni.hideLoading();
|
||||||
|
if (r && r.code == 0 && r.data && r.data.pic_path) {
|
||||||
|
this.form.avatar = r.data.pic_path;
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: (r && r.message) || '上传失败', icon: 'none' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
uni.hideLoading();
|
||||||
|
uni.showToast({ title: '上传失败', icon: 'none' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ---------- 附加手机号 ---------- */
|
||||||
|
onMobileInput(i, e) {
|
||||||
|
this.$set(this.mobiles, i, e.detail.value);
|
||||||
|
},
|
||||||
|
addMobile() {
|
||||||
|
if (this.mobiles.length >= MAX_MOBILES) {
|
||||||
|
uni.showToast({ title: '最多 ' + MAX_MOBILES + ' 个手机号', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.mobiles.push('');
|
||||||
|
},
|
||||||
|
delMobile(i) {
|
||||||
|
this.mobiles.splice(i, 1);
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ---------- 社交媒体 ---------- */
|
||||||
|
socialTypeLabel(key) {
|
||||||
|
const t = SOCIAL_TYPES.find(x => x.key === key);
|
||||||
|
return t ? t.label : '其他';
|
||||||
|
},
|
||||||
|
socialTypeIndex(key) {
|
||||||
|
const i = SOCIAL_TYPES.findIndex(x => x.key === key);
|
||||||
|
return i < 0 ? SOCIAL_TYPES.length - 1 : i;
|
||||||
|
},
|
||||||
|
onSocialTypeChange(i, e) {
|
||||||
|
const idx = Number(e.detail.value);
|
||||||
|
const t = SOCIAL_TYPES[idx];
|
||||||
|
if (t) this.$set(this.socials, i, { type: t.key, value: this.socials[i].value });
|
||||||
|
},
|
||||||
|
onSocialValueInput(i, e) {
|
||||||
|
const s = this.socials[i];
|
||||||
|
this.$set(this.socials, i, { type: s.type, value: e.detail.value });
|
||||||
|
},
|
||||||
|
addSocial() {
|
||||||
|
if (this.socials.length >= MAX_SOCIALS) {
|
||||||
|
uni.showToast({ title: '最多 ' + MAX_SOCIALS + ' 个社交媒体', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.socials.push({ type: 'other', value: '' });
|
||||||
|
},
|
||||||
|
delSocial(i) {
|
||||||
|
this.socials.splice(i, 1);
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ---------- 自定义字段 ---------- */
|
||||||
|
onCustomLabelInput(i, e) {
|
||||||
|
const c = this.customFields[i];
|
||||||
|
this.$set(this.customFields, i, { label: e.detail.value, value: c.value });
|
||||||
|
},
|
||||||
|
onCustomValueInput(i, e) {
|
||||||
|
const c = this.customFields[i];
|
||||||
|
this.$set(this.customFields, i, { label: c.label, value: e.detail.value });
|
||||||
|
},
|
||||||
|
addCustom() {
|
||||||
|
if (this.customFields.length >= MAX_CUSTOM) {
|
||||||
|
uni.showToast({ title: '最多 ' + MAX_CUSTOM + ' 个自定义字段', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.customFields.push({ label: '', value: '' });
|
||||||
|
},
|
||||||
|
delCustom(i) {
|
||||||
|
this.customFields.splice(i, 1);
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ---------- 保存 ---------- */
|
||||||
|
save() {
|
||||||
|
if (this.saving) return;
|
||||||
|
if (!this.form.name.trim()) {
|
||||||
|
uni.showToast({ title: '请填写姓名', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.saving = true;
|
||||||
|
|
||||||
|
// 标量全量提交(空值=清空, 与后端 normalizer 语义一致);
|
||||||
|
// 富化字段始终显式携带(空数组=清空), 后端 richFieldSaves 只对携带的键清洗落库。
|
||||||
|
// 走 sendJson(JSON body): 嵌套数组序列化跨端零歧义, TP6 原生解析
|
||||||
|
const fields = {};
|
||||||
|
CARD_FIELDS.forEach(k => { fields[k] = this.form[k]; });
|
||||||
|
fields.mobiles = this.mobiles.map(v => String(v || '').trim());
|
||||||
|
fields.socials = this.socials.map(s => ({ type: s.type, value: String(s.value || '').trim() }));
|
||||||
|
fields.custom_fields = this.customFields.map(c => ({
|
||||||
|
label: String(c.label || '').trim(),
|
||||||
|
value: String(c.value || '').trim()
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 新建模式走 card/create, 编辑模式走 card/update(均经 $cardApi 收口层)
|
||||||
|
// ⚠️ 必须包在 data 里: sendJson 只合并 params.data(见 common/js/http.js),
|
||||||
|
// 顶层直接挂 fields 会被丢弃 → 后端收不到 name, 报「请填写姓名」
|
||||||
|
const payload = this.isCreate
|
||||||
|
? { fields: fields }
|
||||||
|
: { card_id: this.cardId, fields: fields };
|
||||||
|
(this.isCreate ? this.$cardApi.cardCreate : this.$cardApi.cardUpdate)({
|
||||||
|
data: payload,
|
||||||
|
success: res => {
|
||||||
|
this.saving = false;
|
||||||
|
if (res.code == 0) {
|
||||||
|
uni.showToast({ title: this.isCreate ? '创建成功' : '保存成功', icon: 'success' });
|
||||||
|
if (this.isCreate) {
|
||||||
|
// 新建成功 → 跳详情页(redirectTo 替换当前编辑页, 返回栈回到列表)
|
||||||
|
const newCardId = (res.data && res.data.card_id) || '';
|
||||||
|
setTimeout(() => {
|
||||||
|
if (newCardId) {
|
||||||
|
uni.redirectTo({
|
||||||
|
url: '/pages_tool/card/detail?card_id=' + encodeURIComponent(newCardId)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
uni.navigateBack();
|
||||||
|
}
|
||||||
|
}, 600);
|
||||||
|
} else {
|
||||||
|
// 返回详情页; detail.vue 的 onShow 会自动刷新
|
||||||
|
setTimeout(() => uni.navigateBack(), 600);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: res.message || '保存失败', icon: 'none' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
this.saving = false;
|
||||||
|
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.card-edit {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #f6f7f9;
|
||||||
|
padding: 24rpx 24rpx 60rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.group-title {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
padding: 24rpx 24rpx 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-tip {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #999;
|
||||||
|
padding: 0 24rpx 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 24rpx;
|
||||||
|
border-bottom: 1rpx solid #f0f0f0;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-label {
|
||||||
|
width: 160rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #666;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 必填红星 */
|
||||||
|
.req-star {
|
||||||
|
color: #f5222d;
|
||||||
|
margin-right: 4rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-input {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 头像行 */
|
||||||
|
.avatar-box {
|
||||||
|
width: 108rpx;
|
||||||
|
height: 108rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background-color: #f2f3f5;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.avatar-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-plus {
|
||||||
|
font-size: 44rpx;
|
||||||
|
color: #c0c4cc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-tip {
|
||||||
|
margin-left: 20rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 动态行(附加手机号/社交媒体/自定义字段) */
|
||||||
|
.dyn-row {
|
||||||
|
.row-label {
|
||||||
|
width: 160rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dyn-del {
|
||||||
|
width: 44rpx;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 34rpx;
|
||||||
|
color: #c0c4cc;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 6rpx 0 6rpx 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dyn-add {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: var(--main-color, #0054a5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dyn-picker {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 170rpx;
|
||||||
|
height: 44rpx;
|
||||||
|
line-height: 44rpx;
|
||||||
|
border: 1rpx solid #dcdfe6;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
margin-right: 16rpx;
|
||||||
|
|
||||||
|
.picker-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #333;
|
||||||
|
text-align: center;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-label {
|
||||||
|
width: 170rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #333;
|
||||||
|
background-color: #f6f7f9;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
padding: 10rpx 16rpx;
|
||||||
|
margin-right: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 简介 */
|
||||||
|
.bio-row {
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
.bio-input {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 160rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-bar {
|
||||||
|
padding: 20rpx 0 0;
|
||||||
|
|
||||||
|
.save-btn {
|
||||||
|
height: 88rpx;
|
||||||
|
line-height: 88rpx;
|
||||||
|
background-color: var(--main-color, #0054a5);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 30rpx;
|
||||||
|
border-radius: 44rpx;
|
||||||
|
|
||||||
|
&[disabled] {
|
||||||
|
opacity: 0.6;
|
||||||
|
color: #ffffff;
|
||||||
|
background-color: var(--main-color, #0054a5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
830
pages_tool/card/enterprise.vue
Normal file
830
pages_tool/card/enterprise.vue
Normal file
@@ -0,0 +1,830 @@
|
|||||||
|
<template>
|
||||||
|
<view class="ent-card" :style="[designVars, { fontFamily: ds.fontFamily }]">
|
||||||
|
<view class="page-layer">
|
||||||
|
<!-- 顶部英雄区: 蓝色渐变圆角卡(对齐后端 wap 参考图) -->
|
||||||
|
<view class="hero" v-if="headerSrc || staffList.length">
|
||||||
|
<view class="hero-pill">
|
||||||
|
<view class="hero-pill-dot"></view>
|
||||||
|
<text>数字名片</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- staff 模式: 员工名片轮播 -->
|
||||||
|
<swiper v-if="headerMode === 'staff' && staffList.length" class="hero-swiper"
|
||||||
|
:indicator-dots="staffList.length > 1" indicator-active-color="rgba(255,255,255,0.9)"
|
||||||
|
indicator-color="rgba(255,255,255,0.4)" :autoplay="staffList.length > 1" circular
|
||||||
|
@change="onStaffChange">
|
||||||
|
<swiper-item v-for="(s, i) in staffList" :key="i">
|
||||||
|
<view class="hero-id">
|
||||||
|
<image v-if="s.avatar" class="hi-avatar" :src="$util.img(s.avatar)" mode="aspectFill" />
|
||||||
|
<view v-else class="hi-avatar hi-avatar-ph">
|
||||||
|
<text class="hi-avatar-char">企</text>
|
||||||
|
</view>
|
||||||
|
<view class="hi-info">
|
||||||
|
<view class="hi-name">{{ s.name }}</view>
|
||||||
|
<view class="hi-job" v-if="s.job_title">{{ s.job_title }}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</swiper-item>
|
||||||
|
</swiper>
|
||||||
|
|
||||||
|
<!-- official / self: 单人信息 -->
|
||||||
|
<view v-else class="hero-id">
|
||||||
|
<image v-if="headerSrc && headerSrc.avatar" class="hi-avatar" :src="$util.img(headerSrc.avatar)" mode="aspectFill" />
|
||||||
|
<view v-else class="hi-avatar hi-avatar-ph">
|
||||||
|
<text class="hi-avatar-char">企</text>
|
||||||
|
</view>
|
||||||
|
<view class="hi-info">
|
||||||
|
<view class="hi-name">{{ headerSrc.name || '企业名片' }}</view>
|
||||||
|
<view class="hi-job" v-if="headerSrc.job_title">{{ headerSrc.job_title }}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 快捷操作条: 压在英雄卡下沿(参考图: 打电话/发邮件/官方网站/存通讯录) -->
|
||||||
|
<view class="quick" v-if="headerSrc">
|
||||||
|
<view class="q-item" @tap="onQuickCall">
|
||||||
|
<view class="q-icon q-icon-blue">
|
||||||
|
<text class="iconfont icon-dianhua"></text>
|
||||||
|
</view>
|
||||||
|
<text class="q-label">打电话</text>
|
||||||
|
</view>
|
||||||
|
<view class="q-item" @tap="onQuickEmail">
|
||||||
|
<view class="q-icon q-icon-cyan">
|
||||||
|
<text class="q-char">@</text>
|
||||||
|
</view>
|
||||||
|
<text class="q-label">发邮件</text>
|
||||||
|
</view>
|
||||||
|
<view class="q-item" @tap="onQuickWebsite">
|
||||||
|
<view class="q-icon q-icon-blue">
|
||||||
|
<text class="q-char">网</text>
|
||||||
|
</view>
|
||||||
|
<text class="q-label">官方网站</text>
|
||||||
|
</view>
|
||||||
|
<view class="q-item" @tap="onQuickSave">
|
||||||
|
<view class="q-icon q-icon-orange">
|
||||||
|
<text class="iconfont icon-yonghu"></text>
|
||||||
|
</view>
|
||||||
|
<text class="q-label">存通讯录</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 企业简介 -->
|
||||||
|
<view class="bio" v-if="fields.bio" :style="{ backgroundColor: ds.bgColor, borderRadius: ds.cardRadius + 'px' }">
|
||||||
|
<text class="bio-text" :style="{ color: ds.textColor }">{{ fields.bio }}</text>
|
||||||
|
<text class="bio-en" v-if="fields.bio_en" :style="{ color: ds.textSoft }">{{ fields.bio_en }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 联系资料(企业字段) -->
|
||||||
|
<view class="contact" v-if="contentRows.length" :style="{ backgroundColor: ds.bgColor, borderRadius: ds.cardRadius + 'px' }">
|
||||||
|
<view class="ct-title" :style="{ borderLeftColor: ds.themeColor }">联系资料</view>
|
||||||
|
<view v-for="(row, idx) in contentRows" :key="idx" class="ct-row">
|
||||||
|
<view class="ct-icon" :style="{ backgroundColor: ds.themeSoft }">
|
||||||
|
<text v-if="row.icon" class="iconfont" :class="row.icon" :style="{ color: ds.themeColor }"></text>
|
||||||
|
<text v-else class="ct-char" :style="{ color: ds.themeColor }">{{ row.label.slice(0, 1) }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="ct-label" :style="{ color: ds.textSoft }">{{ row.label }}</text>
|
||||||
|
<text class="ct-value" :class="{ 'ct-multi': row.multiline }" :style="{ color: ds.textColor }">{{ row.value }}</text>
|
||||||
|
<text v-if="row.copyable" class="ct-copy" @tap="copyText(row.value)">复制</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- self 模式未自选提示 -->
|
||||||
|
<view class="self-hint" v-if="headerMode === 'self' && headerSelf && headerSelf.chosen === 0" @tap="openChooseSelf">
|
||||||
|
<text class="sh-icon iconfont icon-gonggao" :style="{ color: ds.themeColor }"></text>
|
||||||
|
<text class="sh-text">你还没有选定展示名片,当前自动取最新一张</text>
|
||||||
|
<text class="sh-btn" :style="{ color: ds.themeColor }">选择</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 加载中 -->
|
||||||
|
<view class="ent-loading" v-if="isLoading">加载中…</view>
|
||||||
|
|
||||||
|
<!-- 不存在 / 无权查看 -->
|
||||||
|
<ns-empty v-if="!isLoading && !headerSrc && !staffList.length" :isIndex="false"
|
||||||
|
:text="errorMsg || '企业名片不存在或未生效,请在管理端完善资料并设为生效'"></ns-empty>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 底部操作条(参考图: 保存到通讯录 + 分享; 收藏/交换名片为企业卡无对应后端能力, 暂不提供) -->
|
||||||
|
<view class="bottom-bar" v-if="headerSrc">
|
||||||
|
<view class="bb-btn bb-main" @tap="onQuickSave">保存到通讯录</view>
|
||||||
|
<!-- #ifdef MP-WEIXIN -->
|
||||||
|
<button class="bb-btn bb-share" open-type="share">分享</button>
|
||||||
|
<!-- #endif -->
|
||||||
|
<!-- #ifdef H5 || APP-PLUS || MP-HARMONY -->
|
||||||
|
<view class="bb-btn bb-share" @tap="openShare">分享</view>
|
||||||
|
<!-- #endif -->
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 员工自选展示名片弹层 -->
|
||||||
|
<view class="choose-mask" v-if="showChoose" @tap="closeChoose"></view>
|
||||||
|
<view class="choose-sheet" :class="{ 'choose-show': showChoose }">
|
||||||
|
<view class="cs-bar"></view>
|
||||||
|
<view class="cs-title">选择展示名片</view>
|
||||||
|
<view class="cs-list">
|
||||||
|
<view v-for="(c, idx) in myCards" :key="idx" class="cs-item" @tap="chooseSelf(c)">
|
||||||
|
<view class="cs-avatar" :style="{ backgroundColor: ds.themeColor }">{{ (c.name || '?').slice(0, 1) }}</view>
|
||||||
|
<view class="cs-info">
|
||||||
|
<view class="cs-name">{{ c.name }}</view>
|
||||||
|
<view class="cs-sub" v-if="c.job_title || c.company">{{ [c.job_title, c.company].filter(Boolean).join(' · ') }}</view>
|
||||||
|
</view>
|
||||||
|
<text v-if="headerSelf && headerSelf.card_id === c.card_id" class="cs-chosen iconfont icon-dui" :style="{ color: ds.themeColor }"></text>
|
||||||
|
</view>
|
||||||
|
<view class="cs-empty" v-if="!myCards.length">你还没有个人名片</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function mixHex(a, b, t) {
|
||||||
|
const toRgb = h => {
|
||||||
|
h = (h || '').replace('#', '');
|
||||||
|
if (h.length === 3) h = h.split('').map(c => c + c).join('');
|
||||||
|
const n = parseInt(h, 16);
|
||||||
|
if (isNaN(n)) return [0, 0, 0];
|
||||||
|
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||||
|
};
|
||||||
|
const [r1, g1, b1] = toRgb(a);
|
||||||
|
const [r2, g2, b2] = toRgb(b);
|
||||||
|
const r = Math.round(r1 + (r2 - r1) * t);
|
||||||
|
const g = Math.round(g1 + (g2 - g1) * t);
|
||||||
|
const bl = Math.round(b1 + (b2 - b1) * t);
|
||||||
|
return '#' + [r, g, bl].map(x => x.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
cardId: '',
|
||||||
|
isLoading: true,
|
||||||
|
errorMsg: '',
|
||||||
|
data: null, // 企业名片详情(后端 ENTERPRISE_UNIAPP_GUIDE §三)
|
||||||
|
header: null, // {mode,official_card_id,official,self,staff}
|
||||||
|
fields: {}, // 企业信息(内容区)
|
||||||
|
design: null, // {theme_color,bg_color,text_color,template_id,canvas,header_mode,official_card_id}
|
||||||
|
staffIndex: 0, // staff 轮播当前项(存通讯录按当前展示的员工取字段)
|
||||||
|
showChoose: false,
|
||||||
|
myCards: []
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
headerMode() {
|
||||||
|
return (this.header && this.header.mode) || 'official';
|
||||||
|
},
|
||||||
|
headerSrc() {
|
||||||
|
const h = this.header || {};
|
||||||
|
let src;
|
||||||
|
if (this.headerMode === 'official') src = h.official || this.fields;
|
||||||
|
else if (this.headerMode === 'self') src = h.self || this.fields;
|
||||||
|
else return null; // staff 走轮播
|
||||||
|
// 注意: 空对象 {} 在 JS 里是真值, 必须显式判空,
|
||||||
|
// 否则后端返回空数据时会渲染出"有壳没内容"的空页面且空态提示也不出现
|
||||||
|
return src && Object.keys(src).length ? src : null;
|
||||||
|
},
|
||||||
|
staffList() {
|
||||||
|
return (this.header && this.header.staff) || [];
|
||||||
|
},
|
||||||
|
staffCurrent() {
|
||||||
|
return this.staffList[this.staffIndex] || null;
|
||||||
|
},
|
||||||
|
headerSelf() {
|
||||||
|
return (this.header && this.header.self) || null;
|
||||||
|
},
|
||||||
|
// 名片区当前展示的那个人(official/self 单人 或 staff 当前轮播项)
|
||||||
|
displayPerson() {
|
||||||
|
if (this.headerMode === 'staff') return this.staffCurrent || {};
|
||||||
|
return this.headerSrc || {};
|
||||||
|
},
|
||||||
|
contentRows() {
|
||||||
|
const f = this.fields || {};
|
||||||
|
const rows = [];
|
||||||
|
const push = (label, value, opts) => {
|
||||||
|
if (value !== undefined && value !== null && String(value).trim() !== '') {
|
||||||
|
rows.push(Object.assign({ label, value: String(value) }, opts || {}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
push('手机', f.mobile, { icon: 'icon-shouji1', copyable: true });
|
||||||
|
push('邮箱', f.email, { copyable: true });
|
||||||
|
push('网站', f.website, { copyable: true });
|
||||||
|
push('地址', f.address, { icon: 'icon-dizhi' });
|
||||||
|
push('企业名称', f.name);
|
||||||
|
return rows;
|
||||||
|
},
|
||||||
|
/* ============ design 归一化(与 detail.vue 同源, 仅做有限主题化) ============ */
|
||||||
|
ds() {
|
||||||
|
const d = this.design || {};
|
||||||
|
const FONTS = {
|
||||||
|
system: '-apple-system,BlinkMacSystemFont,"PingFang SC","Helvetica Neue",Arial,sans-serif',
|
||||||
|
sans: '"Source Han Sans SC","Noto Sans SC","PingFang SC",Helvetica,Arial,sans-serif',
|
||||||
|
serif: '"Source Han Serif SC","Songti SC",SimSun,Georgia,serif',
|
||||||
|
rounded: '"Yuanti SC","YouYuan","Quicksand",-apple-system,sans-serif'
|
||||||
|
};
|
||||||
|
const isHex = v => /^#[0-9a-fA-F]{3,8}$/.test(v || '');
|
||||||
|
const theme = isHex(d.theme_color) ? d.theme_color : '#119dff';
|
||||||
|
const text = isHex(d.text_color) ? d.text_color : '#1f2329';
|
||||||
|
const bg = isHex(d.bg_color) ? d.bg_color : '#ffffff';
|
||||||
|
const fontKey = FONTS[d.font_family] ? d.font_family : 'system';
|
||||||
|
const fontScale = Math.min(160, Math.max(60, Number(d.font_scale) || 100));
|
||||||
|
return {
|
||||||
|
themeColor: theme,
|
||||||
|
textColor: text,
|
||||||
|
bgColor: bg,
|
||||||
|
fontFamily: FONTS[fontKey],
|
||||||
|
cardRadius: Number(d.card_radius) || 16,
|
||||||
|
fontScaleNum: fontScale / 100,
|
||||||
|
textSoft: mixHex(text, bg, 0.55),
|
||||||
|
themeSoft: mixHex(theme, '#ffffff', 0.85),
|
||||||
|
themeBorder: mixHex(theme, '#ffffff', 0.55),
|
||||||
|
headerGradientEnd: typeof d.header_gradient_end === 'string' ? d.header_gradient_end : ''
|
||||||
|
};
|
||||||
|
},
|
||||||
|
designVars() {
|
||||||
|
const s = this.ds;
|
||||||
|
return {
|
||||||
|
'--card-theme': s.themeColor,
|
||||||
|
'--card-theme-end': s.headerGradientEnd || s.themeColor,
|
||||||
|
'--card-theme-soft': s.themeSoft,
|
||||||
|
'--card-theme-border': s.themeBorder,
|
||||||
|
'--card-text': s.textColor,
|
||||||
|
'--card-text-soft': s.textSoft,
|
||||||
|
'--card-bg': s.bgColor,
|
||||||
|
'--card-radius': s.cardRadius + 'px',
|
||||||
|
'--card-font': s.fontFamily,
|
||||||
|
'--card-font-scale': s.fontScaleNum
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLoad(options) {
|
||||||
|
this.cardId = (options && options.card_id) ? decodeURIComponent(options.card_id) : '';
|
||||||
|
this.loadData();
|
||||||
|
},
|
||||||
|
onReady() {
|
||||||
|
uni.setNavigationBarTitle({ title: '企业名片' });
|
||||||
|
},
|
||||||
|
onShow() {
|
||||||
|
// 每次显示都兜底标题(微信返回本页时可能恢复成旧值); 从自选弹层返回也刷新
|
||||||
|
uni.setNavigationBarTitle({ title: '企业名片' });
|
||||||
|
// #ifdef H5
|
||||||
|
document.title = '企业名片';
|
||||||
|
// #endif
|
||||||
|
if (this._loadedOnce) this.loadData();
|
||||||
|
this._loadedOnce = true;
|
||||||
|
},
|
||||||
|
onPullDownRefresh() {
|
||||||
|
this.loadData();
|
||||||
|
setTimeout(() => uni.stopPullDownRefresh(), 300);
|
||||||
|
},
|
||||||
|
onShareAppMessage() {
|
||||||
|
const name = (this.fields && this.fields.name) || '企业名片';
|
||||||
|
return {
|
||||||
|
title: name + ' - 企业名片',
|
||||||
|
path: '/pages_tool/card/enterprise' + (this.cardId ? '?card_id=' + encodeURIComponent(this.cardId) : ''),
|
||||||
|
imageUrl: this.displayPerson && this.displayPerson.avatar ? this.$util.img(this.displayPerson.avatar) : ''
|
||||||
|
};
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
loadData(retry) {
|
||||||
|
this.isLoading = true;
|
||||||
|
this.$cardApi.enterpriseDetail({
|
||||||
|
data: this.cardId ? { card_id: this.cardId } : {},
|
||||||
|
success: res => {
|
||||||
|
// 输出原始响应, 便于真机排查"刷新后数据变空"类问题(控制台看后端到底回了什么)
|
||||||
|
console.log('[enterprise] detail resp', this.cardId || '(无card_id)', res);
|
||||||
|
if (res.code == 0) {
|
||||||
|
const d = res.data || {};
|
||||||
|
const emptyCard = !d.header && (!d.fields || !Object.keys(d.fields).length);
|
||||||
|
if (emptyCard && (this.header || Object.keys(this.fields).length)) {
|
||||||
|
// 已有数据时刷新却拿到空卡(多为后端"当前生效卡"与所查卡不一致):
|
||||||
|
// 保留原数据, 避免"刷新一下内容就没了"
|
||||||
|
console.warn('[enterprise] 刷新返回空卡, 保留原数据', d);
|
||||||
|
} else {
|
||||||
|
this.data = d;
|
||||||
|
this.header = d.header || null;
|
||||||
|
this.fields = d.fields || {};
|
||||||
|
this.design = d.design || null;
|
||||||
|
}
|
||||||
|
this.errorMsg = '';
|
||||||
|
this.isLoading = false;
|
||||||
|
} else if (res.code == -1 && !retry) {
|
||||||
|
// -1 = 尚未登录: 冷启动/刚返回本页时 store 的 token 可能还没恢复完成,
|
||||||
|
// 属瞬时态, 静默重试一次再决定是否展示错误(与 detail.vue 同策略)
|
||||||
|
setTimeout(() => this.loadData(true), 800);
|
||||||
|
} else {
|
||||||
|
// 登录类错误(-1/-10009 等)不展示"去登录"死路:
|
||||||
|
// 本页是客户视角分享页, 按后端对接文档 §五 游客也应可看(待后端放开),
|
||||||
|
// 这里给中性可重试文案; 其余错误原样带出
|
||||||
|
const msg = res.message || '';
|
||||||
|
const isLoginErr = res.code == -1 || /登录/.test(msg);
|
||||||
|
this.errorMsg = isLoginErr ? '名片加载失败,请下拉重试' : (msg || '加载失败');
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
// 失败时保留原数据(不清空), 仅在首次无数据时展示错误
|
||||||
|
if (!this.header && !Object.keys(this.fields).length) {
|
||||||
|
this.errorMsg = '网络异常,请下拉重试';
|
||||||
|
}
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onStaffChange(e) {
|
||||||
|
this.staffIndex = (e.detail && e.detail.current) || 0;
|
||||||
|
},
|
||||||
|
copyText(v) {
|
||||||
|
uni.setClipboardData({
|
||||||
|
data: String(v || ''),
|
||||||
|
success: () => uni.showToast({ title: '已复制', icon: 'success' })
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/* ===== 快捷操作(参考图四项) ===== */
|
||||||
|
onQuickCall() {
|
||||||
|
const m = this.displayPerson.mobile || this.fields.mobile;
|
||||||
|
if (!m) return uni.showToast({ title: '未填写手机号', icon: 'none' });
|
||||||
|
uni.makePhoneCall({ phoneNumber: String(m) });
|
||||||
|
},
|
||||||
|
onQuickEmail() {
|
||||||
|
const e = this.displayPerson.email || this.fields.email;
|
||||||
|
if (!e) return uni.showToast({ title: '未填写邮箱', icon: 'none' });
|
||||||
|
this.copyText(e);
|
||||||
|
},
|
||||||
|
onQuickWebsite() {
|
||||||
|
const w = this.displayPerson.website || this.fields.website;
|
||||||
|
if (!w) return uni.showToast({ title: '未填写官网', icon: 'none' });
|
||||||
|
// 小程序内无法直接打开外部网页(web-view 中转体验差), 复制链接到浏览器打开
|
||||||
|
this.copyText(w);
|
||||||
|
uni.showToast({ title: '链接已复制,请在浏览器打开', icon: 'none' });
|
||||||
|
},
|
||||||
|
onQuickSave() {
|
||||||
|
// 跳原生页直接调 addPhoneContact(web-view 内 H5 才受限, 原生页可直存)
|
||||||
|
const p = this.displayPerson || {};
|
||||||
|
const q = [
|
||||||
|
'name=' + encodeURIComponent(p.name || this.fields.name || ''),
|
||||||
|
'mobile=' + encodeURIComponent(p.mobile || this.fields.mobile || ''),
|
||||||
|
'company=' + encodeURIComponent(this.fields.name || ''),
|
||||||
|
'job_title=' + encodeURIComponent(p.job_title || ''),
|
||||||
|
'email=' + encodeURIComponent(p.email || this.fields.email || ''),
|
||||||
|
'website=' + encodeURIComponent(p.website || this.fields.website || ''),
|
||||||
|
'address=' + encodeURIComponent(p.address || this.fields.address || ''),
|
||||||
|
'wechat=' + encodeURIComponent(p.wechat || '')
|
||||||
|
].join('&');
|
||||||
|
uni.navigateTo({ url: '/pages_tool/card/save-contact?' + q });
|
||||||
|
},
|
||||||
|
openChooseSelf() {
|
||||||
|
this.$cardApi.cardLists({
|
||||||
|
data: { page: 1, page_size: 100 },
|
||||||
|
success: res => {
|
||||||
|
const d = res.data || {};
|
||||||
|
this.myCards = d.list || [];
|
||||||
|
this.showChoose = true;
|
||||||
|
},
|
||||||
|
fail: () => uni.showToast({ title: '加载名片失败', icon: 'none' })
|
||||||
|
});
|
||||||
|
},
|
||||||
|
closeChoose() {
|
||||||
|
this.showChoose = false;
|
||||||
|
},
|
||||||
|
chooseSelf(c) {
|
||||||
|
if (!c || !c.card_id) return;
|
||||||
|
this.$cardApi.enterpriseChooseSelf({
|
||||||
|
data: { card_id: c.card_id },
|
||||||
|
success: res => {
|
||||||
|
if (res.code == 0) {
|
||||||
|
uni.showToast({ title: '已选择展示名片', icon: 'success' });
|
||||||
|
this.showChoose = false;
|
||||||
|
this.loadData();
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: res.message || '选择失败', icon: 'none' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: () => uni.showToast({ title: '网络异常', icon: 'none' })
|
||||||
|
});
|
||||||
|
},
|
||||||
|
openShare() {
|
||||||
|
uni.showToast({ title: '请点击右上角或浏览器菜单分享', icon: 'none' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.ent-card {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #f5f6fa;
|
||||||
|
padding-bottom: 140rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-layer {
|
||||||
|
padding: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 英雄区 ===== */
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
padding: 32rpx 32rpx 140rpx;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 88% 18%, rgba(255, 255, 255, 0.14) 0, rgba(255, 255, 255, 0.14) 120rpx, transparent 121rpx),
|
||||||
|
linear-gradient(150deg, var(--card-theme, #1e6fd9) 0%, var(--card-theme-end, #123f8f) 100%);
|
||||||
|
overflow: visible;
|
||||||
|
margin-bottom: 90rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6rpx 20rpx;
|
||||||
|
border: 1rpx solid rgba(255, 255, 255, 0.5);
|
||||||
|
border-radius: 26rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 22rpx;
|
||||||
|
|
||||||
|
.hero-pill-dot {
|
||||||
|
width: 10rpx;
|
||||||
|
height: 10rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #6cf9c0;
|
||||||
|
margin-right: 10rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-swiper {
|
||||||
|
height: 180rpx;
|
||||||
|
margin-top: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-id {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 28rpx;
|
||||||
|
|
||||||
|
.hi-avatar {
|
||||||
|
width: 128rpx;
|
||||||
|
height: 128rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 6rpx solid rgba(255, 255, 255, 0.85);
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&.hi-avatar-ph {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.hi-avatar-char {
|
||||||
|
font-size: 56rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--card-theme, #1e6fd9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hi-info {
|
||||||
|
margin-left: 28rpx;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.hi-name {
|
||||||
|
font-size: 40rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hi-job {
|
||||||
|
margin-top: 10rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 快捷操作条: 上移压住英雄卡下沿 ===== */
|
||||||
|
.quick {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
margin: -130rpx 24rpx 24rpx;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
box-shadow: 0 8rpx 30rpx rgba(30, 111, 217, 0.10);
|
||||||
|
display: flex;
|
||||||
|
padding: 28rpx 0;
|
||||||
|
|
||||||
|
.q-item {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.q-icon {
|
||||||
|
width: 88rpx;
|
||||||
|
height: 88rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&.q-icon-blue {
|
||||||
|
background: #e3f0ff;
|
||||||
|
|
||||||
|
.iconfont,
|
||||||
|
.q-char {
|
||||||
|
color: #3d8bff;
|
||||||
|
font-size: 40rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.q-icon-cyan {
|
||||||
|
background: #e2f7f4;
|
||||||
|
|
||||||
|
.q-char {
|
||||||
|
color: #2bb99e;
|
||||||
|
font-size: 40rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.q-icon-orange {
|
||||||
|
background: #fff3e0;
|
||||||
|
|
||||||
|
.iconfont {
|
||||||
|
color: #f5a623;
|
||||||
|
font-size: 40rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-label {
|
||||||
|
margin-top: 14rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 企业简介 ===== */
|
||||||
|
.bio {
|
||||||
|
margin: 0 0 24rpx;
|
||||||
|
padding: 28rpx 28rpx;
|
||||||
|
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
|
||||||
|
|
||||||
|
.bio-text {
|
||||||
|
display: block;
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 1.6;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bio-en {
|
||||||
|
display: block;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 1.5;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 联系资料 ===== */
|
||||||
|
.contact {
|
||||||
|
padding: 28rpx 28rpx 12rpx;
|
||||||
|
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
|
||||||
|
.ct-title {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2329;
|
||||||
|
padding-left: 16rpx;
|
||||||
|
border-left-width: 6rpx;
|
||||||
|
border-left-style: solid;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 18rpx 0;
|
||||||
|
border-bottom: 1rpx solid #f2f3f5;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-icon {
|
||||||
|
width: 56rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-right: 20rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.iconfont {
|
||||||
|
font-size: 30rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-char {
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-label {
|
||||||
|
width: 88rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-value {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 28rpx;
|
||||||
|
word-break: break-all;
|
||||||
|
|
||||||
|
&.ct-multi {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-copy {
|
||||||
|
margin-left: 16rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 6rpx 20rpx;
|
||||||
|
border-radius: 22rpx;
|
||||||
|
background: #f2f3f5;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== self 未自选提示 ===== */
|
||||||
|
.self-hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20rpx 24rpx;
|
||||||
|
background-color: var(--card-theme-soft, #e8f1fb);
|
||||||
|
border: 1rpx solid var(--card-theme-border, #cfe3fb);
|
||||||
|
border-radius: 16rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
|
||||||
|
.sh-icon {
|
||||||
|
margin-right: 12rpx;
|
||||||
|
font-size: 30rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sh-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sh-btn {
|
||||||
|
font-size: 26rpx;
|
||||||
|
margin-left: 12rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ent-loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40rpx 0;
|
||||||
|
color: #999;
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 底部操作条 ===== */
|
||||||
|
.bottom-bar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 20rpx;
|
||||||
|
padding: 20rpx 24rpx calc(20rpx + env(safe-area-inset-bottom));
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.06);
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
|
.bb-btn {
|
||||||
|
flex: 1;
|
||||||
|
height: 88rpx;
|
||||||
|
line-height: 88rpx;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 44rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
|
||||||
|
&.bb-main {
|
||||||
|
background: linear-gradient(150deg, #3d8bff, #1e6fd9);
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bb-share {
|
||||||
|
background: #f2f3f5;
|
||||||
|
color: #333;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 员工自选弹层 ===== */
|
||||||
|
.choose-mask {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
z-index: 99;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choose-sheet {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 28rpx 28rpx 0 0;
|
||||||
|
z-index: 100;
|
||||||
|
transform: translateY(100%);
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
max-height: 70vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
&.choose-show {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cs-bar {
|
||||||
|
width: 64rpx;
|
||||||
|
height: 8rpx;
|
||||||
|
background: #ddd;
|
||||||
|
border-radius: 4rpx;
|
||||||
|
margin: 16rpx auto 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cs-title {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 12rpx 0 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cs-list {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 24rpx 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cs-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20rpx 0;
|
||||||
|
border-bottom: 1rpx solid #f2f2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cs-avatar {
|
||||||
|
width: 72rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 32rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-right: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cs-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.cs-name {
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cs-sub {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #999;
|
||||||
|
margin-top: 4rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cs-chosen {
|
||||||
|
font-size: 36rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cs-empty {
|
||||||
|
text-align: center;
|
||||||
|
color: #999;
|
||||||
|
font-size: 26rpx;
|
||||||
|
padding: 40rpx 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
403
pages_tool/card/exchange.vue
Normal file
403
pages_tool/card/exchange.vue
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
<template>
|
||||||
|
<view class="ex-page">
|
||||||
|
<!-- 状态筛选: 全部 / 待处理(N) / 已同意 -->
|
||||||
|
<view class="ex-tabs">
|
||||||
|
<view v-for="tab in tabs" :key="tab.value" class="ex-tab"
|
||||||
|
:class="{ active: current === tab.value }" @tap="switchTab(tab.value)">
|
||||||
|
{{ tab.label }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 交换请求列表 -->
|
||||||
|
<view class="ex-list">
|
||||||
|
<view v-for="(item, index) in shownList" :key="item.id" class="ex-item">
|
||||||
|
<view class="ex-top">
|
||||||
|
<view class="ex-avatar-wrap" :data-index="index" @tap.stop="viewCard">
|
||||||
|
<image v-if="item.avatar" class="ex-avatar" :src="item.avatar" mode="aspectFill" />
|
||||||
|
<view v-else class="ex-avatar ex-avatar-ph">{{ item.name.charAt(0) }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="ex-info">
|
||||||
|
<view class="ex-name-row">
|
||||||
|
<text class="ex-name">{{ item.name }}</text>
|
||||||
|
<text class="ex-badge iconfont icon-dui"></text>
|
||||||
|
</view>
|
||||||
|
<text class="ex-job">{{ item.job_title }}</text>
|
||||||
|
<view class="ex-company-row">
|
||||||
|
<text class="ex-company">{{ item.company }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-if="item.status === 'pending'" class="ex-agree" :data-index="index" @tap.stop="onAgree">同意</view>
|
||||||
|
<text v-else class="ex-agreed">已同意</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="ex-reason">对方发起了名片交换请求</view>
|
||||||
|
|
||||||
|
<view class="ex-time">{{ item.create_time_txt }} 向您发起名片交换</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<ns-empty v-if="shownList.length === 0" :isIndex="false" :text="emptyText"></ns-empty>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 名片交换请求列表
|
||||||
|
// ⚠️ 目前为静态占位数据: 后端 /card/api/exchange/* 接口就绪后,
|
||||||
|
// 改为在 onLoad 里用 this.$cardApi.exchangeLists({ data: { page: 1 }, ... }) 拉取,
|
||||||
|
// 「同意」改成 cardApi.exchangeAgree({ data: { id: item.id } })。
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
current: 'pending',
|
||||||
|
cardId: '',
|
||||||
|
list: [],
|
||||||
|
page: 1,
|
||||||
|
pageCount: 1,
|
||||||
|
loading: false
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onLoad(options) {
|
||||||
|
this.cardId = decodeURIComponent(options.card_id || '');
|
||||||
|
this.loadList();
|
||||||
|
},
|
||||||
|
onReady() {
|
||||||
|
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
|
||||||
|
uni.setNavigationBarTitle({ title: '名片' });
|
||||||
|
// #ifdef H5
|
||||||
|
document.title = '名片';
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
tabs() {
|
||||||
|
const pending = this.list.filter(i => i.status === 'pending').length;
|
||||||
|
return [
|
||||||
|
{ value: 'all', label: '全部' },
|
||||||
|
{ value: 'pending', label: '待处理(' + pending + ')' },
|
||||||
|
{ value: 'agreed', label: '已同意' }
|
||||||
|
];
|
||||||
|
},
|
||||||
|
shownList() {
|
||||||
|
if (this.current === 'all') return this.list;
|
||||||
|
return this.list.filter(item => item.status === this.current);
|
||||||
|
},
|
||||||
|
emptyText() {
|
||||||
|
return this.current === 'agreed' ? '暂无已同意的交换' : '暂无名片交换请求';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
switchTab(value) {
|
||||||
|
if (this.current === value) return;
|
||||||
|
this.current = value;
|
||||||
|
},
|
||||||
|
// 待处理交换请求(后端只返回 status=0); 归一化 status 供模板区分
|
||||||
|
loadList() {
|
||||||
|
this.loading = true;
|
||||||
|
this.$cardApi.exchangeLists({
|
||||||
|
data: { page: this.page, page_size: 20 },
|
||||||
|
success: res => {
|
||||||
|
this.loading = false;
|
||||||
|
if (res.code == 0 && res.data) {
|
||||||
|
this.list = (res.data.list || []).map(it => ({
|
||||||
|
id: it.id,
|
||||||
|
from_card_id: it.from_card_id,
|
||||||
|
name: it.name,
|
||||||
|
avatar: it.avatar,
|
||||||
|
job_title: it.job_title,
|
||||||
|
company: it.company,
|
||||||
|
create_time_txt: it.create_time_txt,
|
||||||
|
status: it.status === 0 ? 'pending' : 'agreed'
|
||||||
|
}));
|
||||||
|
this.pageCount = res.data.page_count || 1;
|
||||||
|
} else if (res.message) {
|
||||||
|
uni.showToast({ title: res.message, icon: 'none' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 取事件对应的行数据。小程序端 v-for 内联传对象(viewCard(item))会丢参,
|
||||||
|
* 统一改为 :data-index + 不传参, 这里从 dataset 取索引回表。
|
||||||
|
*/
|
||||||
|
resolveItem(payload) {
|
||||||
|
if (payload == null) return null;
|
||||||
|
if (typeof payload === 'number') return this.shownList[payload] || null;
|
||||||
|
const ds = payload.currentTarget && payload.currentTarget.dataset;
|
||||||
|
if (ds) {
|
||||||
|
const i = Number(ds.index);
|
||||||
|
if (!isNaN(i) && this.shownList[i]) return this.shownList[i];
|
||||||
|
}
|
||||||
|
return payload.id ? payload : null;
|
||||||
|
},
|
||||||
|
// 点对方名片 → 跳转对方名片详情(用 from_card_id 字符串)
|
||||||
|
viewCard(payload) {
|
||||||
|
const item = this.resolveItem(payload);
|
||||||
|
if (!item || !item.from_card_id) return;
|
||||||
|
uni.navigateTo({
|
||||||
|
url: '/pages_tool/card/detail?card_id=' + encodeURIComponent(item.from_card_id)
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 契约 §4(2026-10-15): 同意后跳对方公开名片页。
|
||||||
|
* wap_url 原样使用, 仅追加 &back_url=encodeURIComponent(我方名片页完整地址);
|
||||||
|
* wap_url 为空则不跳转(对方刚删卡等, 人脉已互建)。
|
||||||
|
*/
|
||||||
|
jumpToOtherPublic(wapUrl) {
|
||||||
|
if (!wapUrl) return;
|
||||||
|
const url = this.withBackUrl(wapUrl);
|
||||||
|
// #ifdef H5
|
||||||
|
window.location.href = url;
|
||||||
|
// #endif
|
||||||
|
// #ifndef H5
|
||||||
|
uni.navigateTo({
|
||||||
|
url: '/pages_tool/card/share?url=' + encodeURIComponent(url)
|
||||||
|
});
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 给公开页地址追加 back_url=本页完整地址(契约 §4 前端唯一允许的改动)。
|
||||||
|
* 对方名片页(PHP wap 模板)左上角返回键据此跳回本页; 已带 back_url 的原样返回, 不重复追加。
|
||||||
|
*/
|
||||||
|
withBackUrl(wapUrl) {
|
||||||
|
if (!wapUrl) return '';
|
||||||
|
if (wapUrl.indexOf('back_url=') >= 0) return wapUrl;
|
||||||
|
const back = this.myPageFullUrl();
|
||||||
|
if (!back) return wapUrl;
|
||||||
|
return wapUrl + (wapUrl.indexOf('?') >= 0 ? '&' : '?') + 'back_url=' + encodeURIComponent(back);
|
||||||
|
},
|
||||||
|
// H5: 当前页面完整地址(禁止写死域名); 非 H5 无等价 H5 地址返回 '' 由 share.vue 中转页原生返回
|
||||||
|
myPageFullUrl() {
|
||||||
|
// #ifdef H5
|
||||||
|
return window.location.href;
|
||||||
|
// #endif
|
||||||
|
// #ifndef H5
|
||||||
|
return '';
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
onAgree(payload) {
|
||||||
|
const item = this.resolveItem(payload);
|
||||||
|
if (!item) return;
|
||||||
|
this.$cardApi.exchangeAgree({
|
||||||
|
data: { id: item.id },
|
||||||
|
success: res => {
|
||||||
|
if (res.code == 0) {
|
||||||
|
// receiver_created=0 也是正常成功(同人脉已存在被跳过), 不当失败
|
||||||
|
const d = res.data || {};
|
||||||
|
const txt = d.receiver_created === 0 ? '已交换(人脉已存在)' : '已交换,人脉已互加';
|
||||||
|
uni.showToast({ title: txt, icon: 'none' });
|
||||||
|
// 需求: 点「同意」后对方名片必须出现在我的人脉中。
|
||||||
|
// 后端虽声明 agree 双向建人脉, 实测出现「同意成功、人脉里却没有对方」,
|
||||||
|
// 故不再用 receiver_created 判断, 一律在同意后校验/补建(幂等, 不会重复);
|
||||||
|
// 实现见 card/detail 页同名方法(取对方名片详情 → 按手机号查我的人脉 → 没有才新建)。
|
||||||
|
this.ensureExchangeContact(item.from_card_id, ensureRes => {
|
||||||
|
// 命中同手机号人脉但姓名不同(多为对方改过名片名): 提示实际名称,
|
||||||
|
// 避免「交换后对方没出现」的误判(该联系人其实已在人脉里, 只是显示旧名)
|
||||||
|
if (ensureRes && ensureRes.existed && ensureRes.existed.name
|
||||||
|
&& item.name && ensureRes.existed.name !== item.name) {
|
||||||
|
uni.showToast({
|
||||||
|
title: '人脉中已有同手机号联系人「' + ensureRes.existed.name + '」',
|
||||||
|
icon: 'none',
|
||||||
|
duration: 2500
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 契约 §4(2026-10-15): 同意后用响应里的 wap_url 跳对方公开名片页,
|
||||||
|
// 仅追加 &back_url=; wap_url 为空则退回刷新列表(对方刚删卡等)
|
||||||
|
if (d.wap_url) {
|
||||||
|
this.jumpToOtherPublic(d.wap_url);
|
||||||
|
} else {
|
||||||
|
this.loadList();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (res.message) {
|
||||||
|
uni.showToast({ title: res.message, icon: 'none' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: () => {}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 同意交换后校验并兜底建人脉: 保证对方名片出现在我的人脉中。
|
||||||
|
* 走 card-api 的 ensureExchangeContact(取对方名片详情 → 按手机号去重 → 没有才 source=exchange 新建), 幂等。
|
||||||
|
* 无论成败都执行 done(res) —— 兜底失败不阻塞同意后的跳转(交换本身已成功), 人脉页仍可手动补录。
|
||||||
|
*/
|
||||||
|
ensureExchangeContact(fromCardId, done) {
|
||||||
|
if (!fromCardId) { typeof done === 'function' && done(null); return; }
|
||||||
|
this.$cardApi.ensureExchangeContact({
|
||||||
|
fromCardId: fromCardId,
|
||||||
|
success: res => {
|
||||||
|
if (res && res.created) {
|
||||||
|
console.log('[card/exchange] 兜底新建人脉成功:', fromCardId);
|
||||||
|
}
|
||||||
|
typeof done === 'function' && done(res);
|
||||||
|
},
|
||||||
|
fail: err => {
|
||||||
|
console.warn('[card/exchange] 兜底建人脉失败(不影响交换结果):', err);
|
||||||
|
typeof done === 'function' && done(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onIgnore(item) {
|
||||||
|
this.$cardApi.exchangeIgnore({
|
||||||
|
data: { id: item.id },
|
||||||
|
success: res => {
|
||||||
|
if (res.code == 0) {
|
||||||
|
uni.showToast({ title: '已忽略', icon: 'none' });
|
||||||
|
this.loadList();
|
||||||
|
} else if (res.message) {
|
||||||
|
uni.showToast({ title: res.message, icon: 'none' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: () => {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.ex-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #f6f7f9;
|
||||||
|
padding-bottom: 40rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 筛选胶囊: 选中为浅蓝底蓝字, 未选中白底灰字
|
||||||
|
.ex-tabs {
|
||||||
|
display: flex;
|
||||||
|
padding: 20rpx 24rpx;
|
||||||
|
|
||||||
|
.ex-tab {
|
||||||
|
padding: 12rpx 28rpx;
|
||||||
|
margin-right: 16rpx;
|
||||||
|
border-radius: 32rpx;
|
||||||
|
background-color: #ffffff;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #666666;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background-color: #e8f3ff;
|
||||||
|
color: #2aa5ff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ex-list {
|
||||||
|
padding: 0 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ex-item {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ex-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ex-avatar-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 100rpx;
|
||||||
|
height: 100rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ex-avatar {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background-color: #2aa5ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ex-avatar-ph {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 40rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ex-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0 16rpx 0 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ex-name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ex-name {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ex-badge {
|
||||||
|
margin-left: 6rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #2aa5ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ex-job {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #7b8794;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ex-company-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ex-company {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #333333;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ex-agree {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 12rpx 34rpx;
|
||||||
|
border-radius: 34rpx;
|
||||||
|
background-color: #2aa5ff;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ex-agreed {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #9aa4b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ex-reason {
|
||||||
|
margin-top: 20rpx;
|
||||||
|
padding: 20rpx;
|
||||||
|
border-radius: 10rpx;
|
||||||
|
background-color: #f5f6f8;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #666666;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ex-time {
|
||||||
|
margin-top: 14rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #9aa4b0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
150
pages_tool/card/forward.vue
Normal file
150
pages_tool/card/forward.vue
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
<template>
|
||||||
|
<view class="forward-page">
|
||||||
|
<!-- 名片预览 -->
|
||||||
|
<view class="fp-card">
|
||||||
|
<image v-if="avatar" class="fp-avatar" :src="avatar" mode="aspectFill" />
|
||||||
|
<view v-else class="fp-avatar fp-avatar-ph">{{ (title || '?').slice(0, 1) }}</view>
|
||||||
|
<view class="fp-name">{{ title || '数字名片' }}</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="fp-tip">点击下方按钮,把这张名片直接发给微信好友</view>
|
||||||
|
|
||||||
|
<!-- 转发: 微信小程序必须用原生 button open-type=share 才能拉起转发面板,
|
||||||
|
纯 @tap 无法唤起; 内容取本页 onShareAppMessage -->
|
||||||
|
<!-- #ifdef MP-WEIXIN -->
|
||||||
|
<button class="fp-btn" open-type="share">发送给朋友</button>
|
||||||
|
<!-- #endif -->
|
||||||
|
<!-- #ifdef H5 || APP-PLUS || MP-HARMONY -->
|
||||||
|
<view class="fp-btn" @tap="fallbackShare">发送给朋友</view>
|
||||||
|
<!-- #endif -->
|
||||||
|
|
||||||
|
<view class="fp-sub" v-if="cardUrl">对方点开即可查看名片并保存联系方式</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* 名片转发中转页(原生)
|
||||||
|
*
|
||||||
|
* 背景: 分享出去的 wap 名片页在 web-view 内, 其「分享」按钮无法拉起微信转发面板
|
||||||
|
* (web-view 里的 H5 无此能力, 只能复制链接)。由 wap 页在小程序环境下把「分享」
|
||||||
|
* 跳到本页, 本页用原生 button(open-type=share) 直接唤起「发送给朋友」。
|
||||||
|
*
|
||||||
|
* 入参(query, 均需 encodeURIComponent):
|
||||||
|
* url wap 名片页地址(转发出去仍落地 card/share 的 web-view 页)
|
||||||
|
* title 名片姓名(转发卡片标题)
|
||||||
|
* avatar 头像(转发卡片封面, 可空)
|
||||||
|
*/
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
cardUrl: '',
|
||||||
|
title: '',
|
||||||
|
avatar: ''
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onLoad(options) {
|
||||||
|
this.cardUrl = options && options.url ? decodeURIComponent(options.url) : '';
|
||||||
|
this.title = options && options.title ? decodeURIComponent(options.title) : '';
|
||||||
|
this.avatar = options && options.avatar ? decodeURIComponent(options.avatar) : '';
|
||||||
|
uni.setNavigationBarTitle({ title: '分享名片' });
|
||||||
|
},
|
||||||
|
onReady() {
|
||||||
|
uni.setNavigationBarTitle({ title: '分享名片' });
|
||||||
|
},
|
||||||
|
// 右上角菜单转发与按钮转发同参
|
||||||
|
onShareAppMessage() {
|
||||||
|
return {
|
||||||
|
title: this.title ? this.title + ' - 数字名片' : '名片分享',
|
||||||
|
path: '/pages_tool/card/share' + (this.cardUrl ? '?url=' + encodeURIComponent(this.cardUrl) : ''),
|
||||||
|
imageUrl: this.avatar || ''
|
||||||
|
};
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
fallbackShare() {
|
||||||
|
// 非微信小程序端无转发面板, 退化为复制链接
|
||||||
|
if (this.cardUrl) {
|
||||||
|
uni.setClipboardData({
|
||||||
|
data: this.cardUrl,
|
||||||
|
success: () => uni.showToast({ title: '链接已复制', icon: 'none' })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.forward-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 48rpx 40rpx;
|
||||||
|
background: linear-gradient(160deg, #29a3ff 0%, #1f8fef 34%, #f5f7fa 34.2%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp-card {
|
||||||
|
width: 100%;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
padding: 56rpx 32rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
box-shadow: 0 8rpx 30rpx rgba(31, 143, 239, 0.12);
|
||||||
|
|
||||||
|
.fp-avatar {
|
||||||
|
width: 140rpx;
|
||||||
|
height: 140rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #eef4fb;
|
||||||
|
|
||||||
|
&.fp-avatar-ph {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #1f8fef;
|
||||||
|
font-size: 60rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp-name {
|
||||||
|
margin-top: 24rpx;
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp-tip {
|
||||||
|
margin-top: 56rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp-btn {
|
||||||
|
margin-top: 40rpx;
|
||||||
|
width: 100%;
|
||||||
|
height: 92rpx;
|
||||||
|
line-height: 92rpx;
|
||||||
|
border-radius: 46rpx;
|
||||||
|
background: linear-gradient(150deg, #29a3ff, #1f8fef);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp-sub {
|
||||||
|
margin-top: 24rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
418
pages_tool/card/leads.vue
Normal file
418
pages_tool/card/leads.vue
Normal file
@@ -0,0 +1,418 @@
|
|||||||
|
<template>
|
||||||
|
<view class="lead-page">
|
||||||
|
<!-- 平级入口: 与我的人脉页互链(导航合并第一步) -->
|
||||||
|
<view class="page-switch" @tap="goContacts">
|
||||||
|
<text class="switch-label">我的人脉</text>
|
||||||
|
<text class="switch-arrow">›</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 状态筛选 -->
|
||||||
|
<scroll-view scroll-x class="tab-bar">
|
||||||
|
<view class="tab-inner">
|
||||||
|
<view v-for="tab in tabs" :key="tab.value" class="tab-item"
|
||||||
|
:class="{ active: status === tab.value }" @tap="switchTab(tab.value)">
|
||||||
|
{{ tab.label }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<!-- 客户列表 -->
|
||||||
|
<view class="lead-list">
|
||||||
|
<!-- 点「别人的名片」= 打开对方名片; 拨号/按钮一律 .stop, 避免误触成打电话 -->
|
||||||
|
<view v-for="(item, index) in list" :key="item.id || index" :data-index="index" class="lead-item" @tap="viewCard">
|
||||||
|
<view class="lead-top">
|
||||||
|
<view class="lead-name">{{ item.name || '未留姓名' }}</view>
|
||||||
|
<view class="lead-status" :class="'st-' + item.status">{{ item.status_text || '新客户' }}</view>
|
||||||
|
</view>
|
||||||
|
<!-- 手机号是唯一拨号入口(H5 下调 tel: 浏览器会报 ERR_UNKNOWN_URL_SCHEME, 属正常) -->
|
||||||
|
<view class="lead-mobile" v-if="item.mobile" @tap.stop="callPhone(item.mobile)">📱 {{ item.mobile }}</view>
|
||||||
|
<view class="lead-note" v-if="item.note">「{{ item.note }}」</view>
|
||||||
|
<view class="lead-meta">
|
||||||
|
<view class="meta-card" :class="{ tappable: !!cardIdOf(item) }">
|
||||||
|
<text class="meta-card-name">来自名片:{{ item.card_name || '—' }}</text>
|
||||||
|
<text class="meta-card-arrow" v-if="cardIdOf(item)">查看名片 ›</text>
|
||||||
|
</view>
|
||||||
|
<text class="meta-time">{{ item.create_time_txt }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="lead-actions">
|
||||||
|
<view class="action-btn copy" v-if="item.wechat" @tap.stop="copyText(item.wechat)">复制微信</view>
|
||||||
|
<view class="action-btn primary" :data-index="index" @tap.stop="changeStatus">更新状态</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="load-more" v-if="list.length > 0">
|
||||||
|
<text v-if="isLoading">加载中…</text>
|
||||||
|
<text v-else-if="page < pageCount">上拉加载更多</text>
|
||||||
|
<text v-else class="load-end">— 没有更多了 —</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<ns-empty v-if="!isLoading && list.length === 0" :isIndex="false"
|
||||||
|
:text="'暂无意向客户,访客在你的名片页留资后会出现在这里'"></ns-empty>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 与后端 Lead::STATUSES 一致: 0=新客户 1=已联系 2=已成交 3=无效
|
||||||
|
const STATUS_MAP = [
|
||||||
|
{ value: 0, label: '新客户' },
|
||||||
|
{ value: 1, label: '已联系' },
|
||||||
|
{ value: 2, label: '已成交' },
|
||||||
|
{ value: 3, label: '无效' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
tabs: [{ value: '', label: '全部' }].concat(STATUS_MAP),
|
||||||
|
status: '',
|
||||||
|
list: [],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
pageCount: 1,
|
||||||
|
isLoading: false
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onLoad() {
|
||||||
|
this.refresh();
|
||||||
|
},
|
||||||
|
onReady() {
|
||||||
|
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
|
||||||
|
uni.setNavigationBarTitle({ title: '名片' });
|
||||||
|
// #ifdef H5
|
||||||
|
document.title = '名片';
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
onPullDownRefresh() {
|
||||||
|
this.refresh(() => uni.stopPullDownRefresh());
|
||||||
|
},
|
||||||
|
onReachBottom() {
|
||||||
|
if (this.isLoading) return;
|
||||||
|
if (this.page >= this.pageCount) return;
|
||||||
|
this.loadPage(this.page + 1);
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
// 顶部平级入口: $util.redirectTo(鸿蒙禁裸 uni.switchTab, 静默失败)
|
||||||
|
goContacts() {
|
||||||
|
this.$util.redirectTo('/pages_tool/card/contacts');
|
||||||
|
},
|
||||||
|
switchTab(value) {
|
||||||
|
if (this.status === value) return;
|
||||||
|
this.status = value;
|
||||||
|
this.refresh();
|
||||||
|
},
|
||||||
|
refresh(done) {
|
||||||
|
this.page = 1;
|
||||||
|
this.loadPage(1, done);
|
||||||
|
},
|
||||||
|
loadPage(pageNo, done) {
|
||||||
|
this.isLoading = true;
|
||||||
|
const data = { page: pageNo, page_size: this.pageSize };
|
||||||
|
// 空字符串 = 全部, 后端对 '' 不加 status 条件
|
||||||
|
if (this.status !== '') {
|
||||||
|
data.status = this.status;
|
||||||
|
}
|
||||||
|
this.$cardApi.leadLists({
|
||||||
|
data: data,
|
||||||
|
success: res => {
|
||||||
|
if (res.code == 0) {
|
||||||
|
const d = res.data || {};
|
||||||
|
const rows = d.list || [];
|
||||||
|
this.pageCount = d.page_count || 1;
|
||||||
|
this.page = pageNo;
|
||||||
|
this.list = pageNo === 1 ? rows : this.list.concat(rows);
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: res.message || '加载失败', icon: 'none' });
|
||||||
|
}
|
||||||
|
this.isLoading = false;
|
||||||
|
typeof done === 'function' && done();
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
this.isLoading = false;
|
||||||
|
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||||
|
typeof done === 'function' && done();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 取事件对应的行数据。小程序端 v-for 内联传对象会丢参, 统一 :data-index + 不传参。
|
||||||
|
* 兼容: 事件对象(data-index) / 数字索引 / 行对象本身。
|
||||||
|
*/
|
||||||
|
resolveItem(payload) {
|
||||||
|
if (payload == null) return null;
|
||||||
|
if (typeof payload === 'number') return this.list[payload] || null;
|
||||||
|
const ds = payload.currentTarget && payload.currentTarget.dataset;
|
||||||
|
if (ds) {
|
||||||
|
const i = Number(ds.index);
|
||||||
|
if (!isNaN(i) && this.list[i]) return this.list[i];
|
||||||
|
}
|
||||||
|
return payload.id ? payload : null;
|
||||||
|
},
|
||||||
|
changeStatus(payload) {
|
||||||
|
let item, index;
|
||||||
|
if (payload && payload.currentTarget) {
|
||||||
|
index = Number(payload.currentTarget.dataset.index);
|
||||||
|
item = this.list[index];
|
||||||
|
} else if (typeof payload === 'number') {
|
||||||
|
index = payload;
|
||||||
|
item = this.list[index];
|
||||||
|
} else {
|
||||||
|
item = payload;
|
||||||
|
index = this.list.indexOf(item);
|
||||||
|
}
|
||||||
|
if (!item) return;
|
||||||
|
uni.showActionSheet({
|
||||||
|
itemList: STATUS_MAP.map(s => s.label),
|
||||||
|
success: res => {
|
||||||
|
const target = STATUS_MAP[res.tapIndex];
|
||||||
|
if (!target || target.value === item.status) return;
|
||||||
|
this.doSetStatus(item, index, target.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
doSetStatus(item, index, status) {
|
||||||
|
this.$cardApi.leadSetStatus({
|
||||||
|
data: { id: item.id, status: status },
|
||||||
|
success: res => {
|
||||||
|
if (res.code == 0) {
|
||||||
|
// 就地更新, 不整页刷新
|
||||||
|
this.list[index].status = status;
|
||||||
|
this.list[index].status_text = STATUS_MAP.find(s => s.value === status).label;
|
||||||
|
uni.showToast({ title: '已更新', icon: 'success' });
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: res.message || '更新失败', icon: 'none' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
callPhone(mobile) {
|
||||||
|
if (!mobile) return;
|
||||||
|
uni.makePhoneCall({ phoneNumber: mobile });
|
||||||
|
},
|
||||||
|
copyText(text) {
|
||||||
|
uni.setClipboardData({
|
||||||
|
data: text,
|
||||||
|
success: () => uni.showToast({ title: '已复制', icon: 'success' })
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// 该线索关联的名片编号(后端 lead/lists 行下发; 兼容常见字段名)
|
||||||
|
cardIdOf(item) {
|
||||||
|
if (!item) return '';
|
||||||
|
return item.card_id || item.from_card_id || item.source_card_id || '';
|
||||||
|
},
|
||||||
|
// 点整条客户卡片 → 打开对方名片
|
||||||
|
// 与交换名片(exchange.vue)同款写法: detail 接收 card_id(卡片编号字符串);
|
||||||
|
// 本人名下的卡 → 正常详情页; 非本人名下 → detail 兜底跳公开名片页(后端返回的 wap_url)
|
||||||
|
viewCard(payload) {
|
||||||
|
const item = this.resolveItem(payload);
|
||||||
|
const cardId = this.cardIdOf(item);
|
||||||
|
if (!cardId) {
|
||||||
|
// 后端 lead/lists 目前只下发 card_name(展示名), 没有名片编号 → 无法跳转。
|
||||||
|
// 这里把该行真实字段打到控制台, 便于确认后端是否用了别的字段名(如 relation_id)
|
||||||
|
console.warn('[card/leads] 该行缺少名片编号(card_id/from_card_id/source_card_id), 无法打开对方名片; 可用字段:',
|
||||||
|
item ? Object.keys(item) : 'event', item);
|
||||||
|
uni.showToast({ title: '该客户未关联名片,无法查看', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uni.navigateTo({
|
||||||
|
url: '/pages_tool/card/detail?card_id=' + encodeURIComponent(cardId)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.lead-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #f6f7f9;
|
||||||
|
padding-bottom: 40rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-switch {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 16rpx 24rpx 0;
|
||||||
|
|
||||||
|
.switch-label {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: var(--main-color, #0054a5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-arrow {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: var(--main-color, #0054a5);
|
||||||
|
margin-left: 4rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-bar {
|
||||||
|
background-color: #ffffff;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
.tab-inner {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 0 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 24rpx 28rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #666;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: var(--main-color, #0054a5);
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
bottom: 10rpx;
|
||||||
|
width: 40rpx;
|
||||||
|
height: 6rpx;
|
||||||
|
border-radius: 3rpx;
|
||||||
|
background-color: var(--main-color, #0054a5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-list {
|
||||||
|
padding: 20rpx 24rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-item {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
|
||||||
|
.lead-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.lead-name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-status {
|
||||||
|
font-size: 22rpx;
|
||||||
|
padding: 6rpx 18rpx;
|
||||||
|
border-radius: 22rpx;
|
||||||
|
|
||||||
|
&.st-0 {
|
||||||
|
color: var(--main-color, #0054a5);
|
||||||
|
background-color: var(--main-color-shallow, #e8f1fb);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.st-1 {
|
||||||
|
color: #e6a23c;
|
||||||
|
background-color: #fdf3e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.st-2 {
|
||||||
|
color: #1daa39;
|
||||||
|
background-color: #e8f8ea;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.st-3 {
|
||||||
|
color: #999;
|
||||||
|
background-color: #f2f2f2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-mobile {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #333;
|
||||||
|
margin-top: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-note {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #999;
|
||||||
|
margin-top: 14rpx;
|
||||||
|
|
||||||
|
.meta-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
max-width: 64%;
|
||||||
|
|
||||||
|
// 有名片编号时才高亮/可点, 否则退化为普通文字
|
||||||
|
&.tappable .meta-card-name {
|
||||||
|
color: var(--main-color, #0054a5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-card-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-card-arrow {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 8rpx;
|
||||||
|
color: var(--main-color, #0054a5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 20rpx;
|
||||||
|
padding-top: 20rpx;
|
||||||
|
border-top: 1rpx solid #f0f0f0;
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
font-size: 24rpx;
|
||||||
|
border-radius: 30rpx;
|
||||||
|
padding: 10rpx 30rpx;
|
||||||
|
margin-left: 20rpx;
|
||||||
|
|
||||||
|
&.copy {
|
||||||
|
color: #666;
|
||||||
|
border: 1rpx solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.primary {
|
||||||
|
color: #ffffff;
|
||||||
|
background-color: var(--main-color, #0054a5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more {
|
||||||
|
text-align: center;
|
||||||
|
color: #999;
|
||||||
|
font-size: 24rpx;
|
||||||
|
padding: 24rpx 0;
|
||||||
|
|
||||||
|
.load-end {
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1068
pages_tool/card/list.vue
Normal file
1068
pages_tool/card/list.vue
Normal file
File diff suppressed because it is too large
Load Diff
230
pages_tool/card/save-contact.vue
Normal file
230
pages_tool/card/save-contact.vue
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
<template>
|
||||||
|
<view class="save-contact">
|
||||||
|
<!-- 名片摘要卡 -->
|
||||||
|
<view class="sc-card">
|
||||||
|
<view class="sc-avatar">{{ initial }}</view>
|
||||||
|
<view class="sc-name">{{ form.name || '未命名名片' }}</view>
|
||||||
|
<view class="sc-job" v-if="form.job_title">{{ form.job_title }}</view>
|
||||||
|
<view class="sc-company" v-if="form.company">{{ form.company }}</view>
|
||||||
|
<view class="sc-mobile" v-if="form.mobile" @tap="callNow">
|
||||||
|
<text class="sc-mobile-icon">📞</text>
|
||||||
|
<text>{{ form.mobile }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 保存按钮: 调系统 API 直接写入手机通讯录(系统会弹出确认框) -->
|
||||||
|
<button class="sc-btn" :disabled="saving" @tap="saveToContacts">
|
||||||
|
{{ saving ? '保存中…' : '保存到通讯录' }}
|
||||||
|
</button>
|
||||||
|
<view class="sc-tip">保存后可在手机通讯录中直接拨号</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* 保存名片到手机通讯录(原生页)
|
||||||
|
*
|
||||||
|
* 背景: 分享出去的名片是后端 wap H5 页, 经 web-view 打开。H5 原来的
|
||||||
|
* 「保存到通讯录」是下载 .vcf 文件, 而微信小程序 web-view 禁止下载文件,
|
||||||
|
* 会提示「可在浏览器打开此网页来下载文件」。
|
||||||
|
*
|
||||||
|
* 方案: wap H5 检测到运行在小程序 web-view 内(/miniProgram/i.test(UA))时,
|
||||||
|
* 不再触发 .vcf 下载, 改为调 wx.miniProgram.navigateTo 跳到本页,
|
||||||
|
* 由本页(小程序原生)调 uni.addPhoneContact 直接写入系统通讯录。
|
||||||
|
*
|
||||||
|
* 入参(query, 均需 encodeURIComponent):
|
||||||
|
* name/mobile/company/job_title/email/website/address/remark/wechat 名片字段
|
||||||
|
* action=call 时进入页面直接拨号(wap 页「打电话」按钮也可走本页)
|
||||||
|
*/
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
form: {
|
||||||
|
name: '',
|
||||||
|
mobile: '',
|
||||||
|
company: '',
|
||||||
|
job_title: '',
|
||||||
|
email: '',
|
||||||
|
website: '',
|
||||||
|
address: '',
|
||||||
|
remark: '',
|
||||||
|
wechat: ''
|
||||||
|
},
|
||||||
|
saving: false
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
initial() {
|
||||||
|
const n = this.form.name || this.form.mobile || '?';
|
||||||
|
return n.slice(0, 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLoad(options) {
|
||||||
|
const keys = ['name', 'mobile', 'company', 'job_title', 'email', 'website', 'address', 'remark', 'wechat'];
|
||||||
|
keys.forEach(k => {
|
||||||
|
if (options && options[k] != null) {
|
||||||
|
this.form[k] = decodeURIComponent(options[k]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// 打电话动作: 直接唤起拨号盘并退出本页
|
||||||
|
if (options && options.action === 'call' && this.form.mobile) {
|
||||||
|
this.callNow();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onReady() {
|
||||||
|
// 微信真机才生效(模拟器 bug 见 docs/weixin-bug.md), onShow 兜底见下
|
||||||
|
uni.setNavigationBarTitle({ title: '保存到通讯录' });
|
||||||
|
// 进入页面即自动调起系统"添加联系人"确认框:
|
||||||
|
// web-view 内 H5 无法直接调 addPhoneContact, 必须经本原生页中转,
|
||||||
|
// 若再让用户手动点一次等于两步操作, 所以这里自动触发, 用户零点击直接看到系统弹窗
|
||||||
|
this.saveToContacts();
|
||||||
|
},
|
||||||
|
onShow() {
|
||||||
|
uni.setNavigationBarTitle({ title: '保存到通讯录' });
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
callNow() {
|
||||||
|
if (!this.form.mobile) return;
|
||||||
|
uni.makePhoneCall({
|
||||||
|
phoneNumber: this.form.mobile,
|
||||||
|
// 用户取消拨号会走 fail, 不提示
|
||||||
|
fail: () => {}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
saveToContacts() {
|
||||||
|
if (this.saving) return;
|
||||||
|
if (!this.form.name && !this.form.mobile) {
|
||||||
|
uni.showToast({ title: '名片资料缺失,无法保存', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.saving = true;
|
||||||
|
uni.addPhoneContact({
|
||||||
|
firstName: this.form.name || this.form.mobile,
|
||||||
|
mobilePhoneNumber: this.form.mobile || '',
|
||||||
|
organization: this.form.company || '',
|
||||||
|
title: this.form.job_title || '',
|
||||||
|
email: this.form.email || '',
|
||||||
|
url: this.form.website || '',
|
||||||
|
weChatNumber: this.form.wechat || '',
|
||||||
|
remark: [this.form.address, this.form.remark].filter(Boolean).join(' '),
|
||||||
|
success: () => {
|
||||||
|
uni.showToast({ title: '已保存到通讯录', icon: 'success' });
|
||||||
|
setTimeout(() => {
|
||||||
|
uni.navigateBack({ fail: () => {} });
|
||||||
|
}, 1200);
|
||||||
|
},
|
||||||
|
fail: err => {
|
||||||
|
// 细分失败原因, 便于真机上快速定位(系统确认框点「取消」不算错误)
|
||||||
|
const msg = (err && err.errMsg) || '';
|
||||||
|
if (msg.indexOf('cancel') > -1) return;
|
||||||
|
if (msg.indexOf('privacy') > -1 || msg.indexOf('declared') > -1) {
|
||||||
|
// 小程序后台「用户隐私保护指引」未声明 addPhoneContact(见 docs/CARD_SAVE_CONTACT_BACKEND.md)
|
||||||
|
uni.showToast({ title: '小程序隐私协议未声明该能力,请管理员配置', icon: 'none', duration: 3000 });
|
||||||
|
} else if (msg.indexOf('auth') > -1 || msg.indexOf('deny') > -1) {
|
||||||
|
uni.showToast({ title: '未获通讯录权限,请在系统设置中允许微信访问通讯录', icon: 'none', duration: 3000 });
|
||||||
|
} else {
|
||||||
|
// 未知错误把 errMsg 原样带出, 截图即可定位
|
||||||
|
uni.showToast({ title: '保存失败: ' + (msg || '未知'), icon: 'none', duration: 3000 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
complete: () => {
|
||||||
|
this.saving = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.save-contact {
|
||||||
|
min-height: 100vh;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 40rpx 32rpx;
|
||||||
|
background: linear-gradient(160deg, #29a3ff 0%, #1f8fef 40%, #f5f7fa 40.2%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sc-card {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
padding: 48rpx 32rpx 40rpx;
|
||||||
|
box-shadow: 0 8rpx 30rpx rgba(31, 143, 239, 0.12);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.sc-avatar {
|
||||||
|
width: 128rpx;
|
||||||
|
height: 128rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(150deg, #29a3ff, #1f8fef);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 56rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sc-name {
|
||||||
|
margin-top: 24rpx;
|
||||||
|
font-size: 40rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sc-job {
|
||||||
|
margin-top: 8rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sc-company {
|
||||||
|
margin-top: 4rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sc-mobile {
|
||||||
|
margin-top: 24rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: #1f8fef;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
.sc-mobile-icon {
|
||||||
|
margin-right: 8rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sc-btn {
|
||||||
|
margin-top: 64rpx;
|
||||||
|
height: 92rpx;
|
||||||
|
line-height: 92rpx;
|
||||||
|
border-radius: 46rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
background: linear-gradient(150deg, #29a3ff, #1f8fef);
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[disabled] {
|
||||||
|
opacity: 0.6;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sc-tip {
|
||||||
|
margin-top: 24rpx;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
217
pages_tool/card/share.vue
Normal file
217
pages_tool/card/share.vue
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
<template>
|
||||||
|
<view class="card-share">
|
||||||
|
<!-- #ifdef MP-HARMONY -->
|
||||||
|
<!-- 鸿蒙元服务:web-view 是原生全屏组件,必须用 cover-view 盖在上面(cover-view 不支持 iconfont,用文本) -->
|
||||||
|
<cover-view class="mp-header">
|
||||||
|
<cover-view class="mp-status-bar" :style="{ height: statusBarHeight + 'px' }"></cover-view>
|
||||||
|
<cover-view class="mp-header-inner">
|
||||||
|
<cover-view class="nav-btn" @click="goBack">
|
||||||
|
<cover-view class="nav-back-text">‹</cover-view>
|
||||||
|
</cover-view>
|
||||||
|
<cover-view class="header-title">名片分享</cover-view>
|
||||||
|
<cover-view class="nav-btn nav-btn-placeholder"></cover-view>
|
||||||
|
</cover-view>
|
||||||
|
</cover-view>
|
||||||
|
<!-- web-view 是全屏原生组件,父容器 padding 对它无效,用等高占位 view 顶下去 -->
|
||||||
|
<view class="mp-spacer" :style="{ height: headerTotalHeightPx + 'px' }"></view>
|
||||||
|
<!-- #endif -->
|
||||||
|
|
||||||
|
<!-- 小程序/鸿蒙:web-view 加载 wap H5 名片页(客户视角,含留资表单) -->
|
||||||
|
<!-- #ifdef MP-WEIXIN || MP-HARMONY -->
|
||||||
|
<web-view v-if="cardUrl" :src="cardUrl"></web-view>
|
||||||
|
<view v-else class="empty-tip">链接无效</view>
|
||||||
|
<!-- #endif -->
|
||||||
|
|
||||||
|
<!-- #ifdef H5 -->
|
||||||
|
<!-- H5 直接就是浏览器,onLoad 时已 location 跳转,这里仅兜底展示 -->
|
||||||
|
<view class="h5-fallback">
|
||||||
|
<view class="h5-fallback-tip">正在打开名片…</view>
|
||||||
|
<button class="h5-fallback-btn" @tap="openUrl">如未自动跳转,点此打开</button>
|
||||||
|
</view>
|
||||||
|
<!-- #endif -->
|
||||||
|
|
||||||
|
<!-- #ifdef APP-PLUS -->
|
||||||
|
<web-view v-if="cardUrl" :src="cardUrl"></web-view>
|
||||||
|
<view v-else class="empty-tip">链接无效</view>
|
||||||
|
<!-- #endif -->
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* 名片分享中转页
|
||||||
|
*
|
||||||
|
* 背景: 小程序 onShareAppMessage 的 path 只能指向小程序页面, 不能直接分享 H5 链接,
|
||||||
|
* 而名片客户页(wap detail)是 H5。因此员工转发名片给微信好友时, 分享的是本页,
|
||||||
|
* 本页再用 web-view 把客户带到 wap 名片页(含留资表单)。
|
||||||
|
* H5 端分享就是普通 URL, 不需要中转, onLoad 直接跳转。
|
||||||
|
* 鸿蒙元服务(MP-HARMONY): web-view 同为原生全屏组件(参考 pages_tool/webview/webview.vue
|
||||||
|
* 的 cover-view 返回键方案), 与微信小程序共用同一套头部+占位+web-view 结构;
|
||||||
|
* 无 onShareAppMessage 生命周期, 鸿蒙端分享入口是名片详情页内「分享」按钮。
|
||||||
|
* 注意: web-view 加载的外链域名需在 manifest → HarmonyOS 网络配置里放行(元服务要求)。
|
||||||
|
*/
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
cardUrl: '',
|
||||||
|
statusBarHeight: 20,
|
||||||
|
windowWidth: 375
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
// #ifdef MP-HARMONY
|
||||||
|
headerTotalHeightPx() {
|
||||||
|
const rpxToPx = this.windowWidth / 750;
|
||||||
|
return this.statusBarHeight + Math.round(88 * rpxToPx);
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
onLoad(options) {
|
||||||
|
this.cardUrl = options.url ? decodeURIComponent(options.url) : '';
|
||||||
|
// #ifdef MP-WEIXIN || MP-HARMONY
|
||||||
|
try {
|
||||||
|
const sys = uni.getSystemInfoSync();
|
||||||
|
this.statusBarHeight = sys.statusBarHeight || 20;
|
||||||
|
this.windowWidth = sys.windowWidth || 375;
|
||||||
|
} catch (e) {
|
||||||
|
// 鸿蒙个别版本 getSystemInfoSync 可能抛错, 用默认值兜底(头部高度略差不错位)
|
||||||
|
console.warn('[card/share] getSystemInfoSync fail, use fallback', e);
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
// #ifdef H5
|
||||||
|
// H5 端无 web-view 中转必要,直接跳转到名片页
|
||||||
|
if (this.cardUrl) {
|
||||||
|
window.location.href = this.cardUrl;
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
goBack() {
|
||||||
|
const pages = getCurrentPages();
|
||||||
|
if (pages && pages.length > 1) {
|
||||||
|
uni.navigateBack();
|
||||||
|
} else {
|
||||||
|
// 栈底回首页必须走 $util.redirectTo: 鸿蒙元服务上 uni.switchTab 会静默失败
|
||||||
|
// (success/fail 都不触发, .catch 兜底失效), util 内部有
|
||||||
|
// switchTab → 500ms 强制 reLaunch 的兜底(见 util.js#redirectTo tabBar 分支)
|
||||||
|
this.$util.redirectTo('/pages/index/index');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// #ifdef H5
|
||||||
|
openUrl() {
|
||||||
|
if (this.cardUrl) window.location.href = this.cardUrl;
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.card-share {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-tip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 60vh;
|
||||||
|
color: #999;
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* #ifdef MP-HARMONY */
|
||||||
|
.mp-header {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
background-color: #ffffff;
|
||||||
|
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
.mp-status-bar {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mp-header-inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 88rpx;
|
||||||
|
padding: 0 20rpx;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 64rpx;
|
||||||
|
height: 64rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.nav-back-text {
|
||||||
|
font-size: 48rpx;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn-placeholder {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0 16rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mp-spacer {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
/* #endif */
|
||||||
|
|
||||||
|
/* #ifdef H5 */
|
||||||
|
.h5-fallback {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 80vh;
|
||||||
|
|
||||||
|
.h5-fallback-tip {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #999;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-fallback-btn {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #333;
|
||||||
|
border: 1rpx solid #dcdfe6;
|
||||||
|
border-radius: 36rpx;
|
||||||
|
padding: 0 40rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
line-height: 72rpx;
|
||||||
|
background: #fff;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* #endif */
|
||||||
|
</style>
|
||||||
819
pages_tool/card/style.vue
Normal file
819
pages_tool/card/style.vue
Normal file
@@ -0,0 +1,819 @@
|
|||||||
|
<template>
|
||||||
|
<view class="card-detail" :style="[previewVars, { fontFamily: previewDs.fontFamily }]" :class="{ 'qs-square': previewDs.quickStyle === 'square' }">
|
||||||
|
<view class="page-layer">
|
||||||
|
<view class="ps-disabled" v-if="relation && !enabled">商家未开启员工端样式自选,暂不可选样式</view>
|
||||||
|
|
||||||
|
<!-- 实时预览卡: 与详情页同款 design 渲染(展示当前所选预设的实际落库效果) -->
|
||||||
|
<view class="card-wrap" v-if="relation">
|
||||||
|
<view class="card-wrap-bg" :style="previewHeaderBg"></view>
|
||||||
|
<view class="detail-card" :style="{ backgroundColor: previewDs.bgColor, borderRadius: previewDs.cardRadius + 'px' }"
|
||||||
|
:class="['layout-' + previewDs.layout, previewDs.cover && previewDs.layout !== 'classic' ? 'has-cover' : '']">
|
||||||
|
<view v-if="previewDs.cover && previewDs.layout !== 'classic'" class="dc-cover"
|
||||||
|
:style="{ borderTopLeftRadius: previewDs.cardRadius + 'px', borderTopRightRadius: previewDs.cardRadius + 'px' }"
|
||||||
|
:class="previewDs.layout === 'compact' ? 'dc-cover-compact' : 'dc-cover-hero'">
|
||||||
|
<image class="dc-cover-img" :src="$util.img(previewDs.cover)" mode="aspectFill"></image>
|
||||||
|
</view>
|
||||||
|
<view class="dc-name" :style="{ color: previewDs.textColor }">{{ relation.name || '未命名名片' }}</view>
|
||||||
|
<view class="dc-job" v-if="relation.job_title" :style="{ color: previewDs.textSoft, fontSize: (28 * previewDs.fontScaleNum) + 'rpx' }">{{ relation.job_title }}</view>
|
||||||
|
<view class="dc-company" v-if="relation.company" :style="{ color: previewDs.textSoft, fontSize: (26 * previewDs.fontScaleNum) + 'rpx' }">{{ relation.company }}</view>
|
||||||
|
|
||||||
|
<view class="dc-rows" v-if="fieldRows.length > 0">
|
||||||
|
<view v-for="(row, index) in fieldRows" :key="index" class="field-row"
|
||||||
|
:class="{ 'field-row-multi': row.multiline }">
|
||||||
|
<text v-if="row.icon" class="field-icon iconfont" :class="row.icon" :style="{ color: previewDs.textSoft }"></text>
|
||||||
|
<text v-else class="field-tag" :style="{ color: previewDs.textSoft, fontSize: (26 * previewDs.fontScaleNum) + 'rpx' }">{{ row.label }}</text>
|
||||||
|
<view class="field-value" :class="{ 'field-value-multi': row.multiline }" :style="{ color: previewDs.textColor, fontSize: (28 * previewDs.fontScaleNum) + 'rpx' }">{{ row.value }}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="detail-loading" v-if="isLoading">加载中…</view>
|
||||||
|
<ns-empty v-if="!isLoading && !relation" :isIndex="false"
|
||||||
|
:text="errorMsg || '卡片不存在或已被删除'"></ns-empty>
|
||||||
|
|
||||||
|
<!-- 预设网格: 每个单元格 = 该预设的迷你预览(同源后台 CardTemplatePresets) -->
|
||||||
|
<view class="preset-section" v-if="relation && enabled">
|
||||||
|
<text class="ps-title">选择样式<text class="ps-count">(共 {{ presets.length }} 个)</text></text>
|
||||||
|
<view class="ps-canvas-hint" v-if="isCanvasCard">
|
||||||
|
当前名片为商家自定义画布设计,保存将不被允许
|
||||||
|
</view>
|
||||||
|
<view class="preset-grid">
|
||||||
|
<view class="preset-cell" v-for="(p, idx) in presets" :key="p.key"
|
||||||
|
:class="{ active: p.key === selectedKey }" :style="cellActiveStyle(p)"
|
||||||
|
@tap="selectPreset(p.key)">
|
||||||
|
<text class="preset-tag" v-if="p.source === 'canvas'">画布式</text>
|
||||||
|
<!-- 缩略图: 彩色底 + 内嵌白卡。
|
||||||
|
底色改由样式表 .pt-N(N=格序) 给出: 微信小程序会丢弃内联 background,
|
||||||
|
只用内联会导致 14 个格子全部回落成同一个兜底蓝。 -->
|
||||||
|
<view class="preset-thumb" :class="'pt-' + (idx % 14)" :style="cellVars(p)">
|
||||||
|
<view class="pt-header"></view>
|
||||||
|
<view class="pt-body" :style="cellBody(p)">
|
||||||
|
<view class="pt-name" :style="cellText(p)"></view>
|
||||||
|
<view class="pt-line" :style="cellSoft(p)"></view>
|
||||||
|
<view class="pt-line pt-short" :style="cellSoft(p)"></view>
|
||||||
|
</view>
|
||||||
|
<view v-if="p.key === selectedKey" class="pt-check">
|
||||||
|
<view class="pt-check-mark"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text class="preset-name">{{ p.name }}</text>
|
||||||
|
<text class="preset-desc">{{ p.desc }}</text>
|
||||||
|
<text v-if="p.key === currentKey" class="preset-current">当前</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-if="!isLoading && presets.length === 0" class="ps-empty">
|
||||||
|
预设列表为空(后端 design/presets 未返回)
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 底部保存 -->
|
||||||
|
<view class="ps-footer" v-if="relation && enabled">
|
||||||
|
<view class="ps-save" :class="{ disabled: !selectedKey || saving || isCanvasCard }" @tap="save">
|
||||||
|
{{ saving ? '保存中…' : (isCanvasCard ? '不支持修改' : '保存') }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* 名片样式(可选样式页)· 后端 v2 对接
|
||||||
|
* GET /card/api/design/presets → 与商家后台 CardTemplatePresets 同源的预设列表
|
||||||
|
* POST /card/api/design/save → 按 preset_key 应用预设并保存到本卡
|
||||||
|
* 关键点(v2):
|
||||||
|
* - 预设列表由后端下发, 前端不自建; 数量不写死(当前 14 个, 直接渲染 list.length)
|
||||||
|
* - save 只写入 10 个字段(theme_color/text_color/bg_color/font_family/font_scale/
|
||||||
|
* card_radius/header_gradient_end/header_gradient_angle/quick_style/template_id),
|
||||||
|
* 不覆盖 layout / cover; 预览据此合并(本卡 layout/cover + 预设 10 字段)
|
||||||
|
* - source==='canvas' 的预设仅改配色; 本卡自身为 canvas 设计时 save 会被拒
|
||||||
|
* - data.enabled 控制入口显隐(detail.vue 已据此隐藏按钮)
|
||||||
|
*/
|
||||||
|
function mixHex(a, b, t) {
|
||||||
|
const toRgb = h => {
|
||||||
|
h = (h || '').replace('#', '');
|
||||||
|
if (h.length === 3) h = h.split('').map(c => c + c).join('');
|
||||||
|
const n = parseInt(h, 16);
|
||||||
|
if (isNaN(n)) return [0, 0, 0];
|
||||||
|
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||||
|
};
|
||||||
|
const [r1, g1, b1] = toRgb(a);
|
||||||
|
const [r2, g2, b2] = toRgb(b);
|
||||||
|
const r = Math.round(r1 + (r2 - r1) * t);
|
||||||
|
const g = Math.round(g1 + (g2 - g1) * t);
|
||||||
|
const bl = Math.round(b1 + (b2 - b1) * t);
|
||||||
|
return '#' + [r, g, bl].map(x => x.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// save 实际写入的字段(与后端 v2 一致); 预览只叠加这些, 其余沿用本卡既有值
|
||||||
|
const SAVE_WRITTEN_FIELDS = [
|
||||||
|
'theme_color', 'text_color', 'bg_color', 'font_family', 'font_scale',
|
||||||
|
'card_radius', 'header_gradient_end', 'header_gradient_angle', 'quick_style', 'template_id'
|
||||||
|
];
|
||||||
|
|
||||||
|
// 归一化 design(字段缺失/非法回落文档默认值); d 可为 null(旧后端)
|
||||||
|
function normalizeDesign(d) {
|
||||||
|
const dd = d || {};
|
||||||
|
const FONTS = {
|
||||||
|
system: '-apple-system,BlinkMacSystemFont,"PingFang SC","Helvetica Neue",Arial,sans-serif',
|
||||||
|
sans: '"Source Han Sans SC","Noto Sans SC","PingFang SC",Helvetica,Arial,sans-serif',
|
||||||
|
serif: '"Source Han Serif SC","Songti SC",SimSun,Georgia,serif',
|
||||||
|
rounded: '"Yuanti SC","YouYuan","Quicksand",-apple-system,sans-serif'
|
||||||
|
};
|
||||||
|
const isHex = v => /^#[0-9a-fA-F]{3,8}$/.test(v || '');
|
||||||
|
// 兼容后端可能的多种命名: snake_case / camelCase / 简写
|
||||||
|
// (后端若下发 camelCase 而这里只读 snake_case, 会全部回落默认蓝 → 14 个格子同色)
|
||||||
|
const pick = (...keys) => {
|
||||||
|
for (let i = 0; i < keys.length; i++) {
|
||||||
|
const v = dd[keys[i]];
|
||||||
|
if (v !== undefined && v !== null && v !== '') return v;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
const themeRaw = pick('theme_color', 'themeColor', 'theme', 'primary_color', 'primaryColor', 'primary', 'color');
|
||||||
|
const textRaw = pick('text_color', 'textColor', 'text', 'text_primary', 'textPrimary');
|
||||||
|
const bgRaw = pick('bg_color', 'bgColor', 'background_color', 'backgroundColor', 'background', 'bg');
|
||||||
|
const radiusRaw = pick('card_radius', 'cardRadius', 'radius');
|
||||||
|
const layoutRaw = pick('layout', 'card_layout', 'cardLayout');
|
||||||
|
const fontRaw = pick('font_family', 'fontFamily', 'font');
|
||||||
|
const scaleRaw = pick('font_scale', 'fontScale');
|
||||||
|
const headerEndRaw = pick('header_gradient_end', 'headerGradientEnd', 'gradient_end', 'gradientEnd');
|
||||||
|
const angleRaw = pick('header_gradient_angle', 'headerGradientAngle', 'gradient_angle', 'gradientAngle');
|
||||||
|
|
||||||
|
const theme = isHex(themeRaw) ? themeRaw : '#119dff';
|
||||||
|
const text = isHex(textRaw) ? textRaw : '#1f2329';
|
||||||
|
const bg = isHex(bgRaw) ? bgRaw : '#ffffff';
|
||||||
|
const fontKey = FONTS[fontRaw] ? fontRaw : 'system';
|
||||||
|
const fontScale = Math.min(160, Math.max(60, Number(scaleRaw) || 100));
|
||||||
|
const cardRadius = Number(radiusRaw) || 16;
|
||||||
|
const layout = ['hero', 'compact', 'classic'].includes(layoutRaw) ? layoutRaw : 'classic';
|
||||||
|
const headerEnd = typeof headerEndRaw === 'string' ? headerEndRaw : '';
|
||||||
|
const headerAngle = Number(angleRaw) || 150;
|
||||||
|
return {
|
||||||
|
themeColor: theme,
|
||||||
|
textColor: text,
|
||||||
|
bgColor: bg,
|
||||||
|
layout: layout,
|
||||||
|
cover: pick('cover', 'cover_url', 'coverUrl') || '',
|
||||||
|
fontKey: fontKey,
|
||||||
|
fontFamily: FONTS[fontKey],
|
||||||
|
fontScale: fontScale,
|
||||||
|
cardRadius: cardRadius,
|
||||||
|
headerGradientEnd: headerEnd,
|
||||||
|
headerGradientAngle: headerAngle,
|
||||||
|
quickStyle: pick('quick_style', 'quickStyle') || '',
|
||||||
|
templateId: pick('template_id', 'templateId', 'template') || '',
|
||||||
|
source: pick('source') || 'none',
|
||||||
|
textSoft: mixHex(text, bg, 0.55),
|
||||||
|
themeSoft: mixHex(theme, '#ffffff', 0.85),
|
||||||
|
fontScaleNum: fontScale / 100
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预设配色兜底: 后端 design 未带合法 theme_color 时(字段名不一致 / 未下发),
|
||||||
|
* 按预设 key/name/desc 关键词匹配一组与后台预设一致的色。
|
||||||
|
* 仅用于缩略图观感, 不影响保存(save 仍按 preset_key 走后端)。
|
||||||
|
*/
|
||||||
|
const PRESET_FALLBACK = [
|
||||||
|
{ re: /(极简|留白|minimal|white|blank)/, theme: '#1f2329' },
|
||||||
|
{ re: /(霓虹|深色|黑|墨|neon|dark|night)/, theme: '#111827' },
|
||||||
|
{ re: /(紫|purple|violet)/, theme: '#7b61ff' },
|
||||||
|
{ re: /(绿|green)/, theme: '#12b886' },
|
||||||
|
{ re: /(橙|暖|orange|warm)/, theme: '#f59f3c' },
|
||||||
|
{ re: /(红|crimson|red)/, theme: '#ef4444' },
|
||||||
|
{ re: /(粉|pink|rose)/, theme: '#ec4899' },
|
||||||
|
{ re: /(金|gold|amber)/, theme: '#d4a017' },
|
||||||
|
{ re: /(青|teal|cyan)/, theme: '#06b6d4' },
|
||||||
|
{ re: /(灰|gray|grey)/, theme: '#6b7280' },
|
||||||
|
{ re: /(渐|gradient)/, theme: '#5b6bff' },
|
||||||
|
{ re: /(大图|门面|封面|cover)/, theme: '#0b5cff' },
|
||||||
|
{ re: /(经典|商务|classic|business)/, theme: '#0b5cff' },
|
||||||
|
{ re: /(蓝|blue|flow|origin|原生)/, theme: '#119dff' }
|
||||||
|
];
|
||||||
|
function fallbackTheme(p) {
|
||||||
|
if (!p) return '';
|
||||||
|
const hay = String(p.key || '') + ' ' + String(p.name || '') + ' '
|
||||||
|
+ String(p.title || '') + ' ' + String(p.label || '') + ' ' + String(p.desc || '');
|
||||||
|
const low = hay.toLowerCase();
|
||||||
|
for (let i = 0; i < PRESET_FALLBACK.length; i++) {
|
||||||
|
if (PRESET_FALLBACK[i].re.test(low)) return PRESET_FALLBACK[i].theme;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function designVarsFor(s) {
|
||||||
|
return {
|
||||||
|
'--card-theme': s.themeColor,
|
||||||
|
'--card-theme-end': s.headerGradientEnd || s.themeColor,
|
||||||
|
'--card-text': s.textColor,
|
||||||
|
'--card-text-soft': s.textSoft,
|
||||||
|
'--card-bg': s.bgColor,
|
||||||
|
'--card-radius': s.cardRadius + 'px',
|
||||||
|
'--card-font': s.fontFamily,
|
||||||
|
'--card-font-scale': s.fontScaleNum
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function headerBgFor(s) {
|
||||||
|
if (s.headerGradientEnd) {
|
||||||
|
return 'linear-gradient(' + s.headerGradientAngle + 'deg, ' + s.themeColor + ', ' + s.headerGradientEnd + ')';
|
||||||
|
}
|
||||||
|
return 'linear-gradient(150deg, ' + s.themeColor + ' 0%, ' + s.themeColor + ' 62%, rgba(0,0,0,0.28) 160%)';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
cardId: '',
|
||||||
|
relation: null,
|
||||||
|
// 名片设计摘要(后端 /card/api/card/detail 顶层 design, 旧后端无此键时为 null)
|
||||||
|
design: null,
|
||||||
|
// 后端下发的预设列表(同源后台 CardTemplatePresets, 数量不写死)
|
||||||
|
presets: [],
|
||||||
|
// 商家是否开启「员工端样式自选」(presets 响应 enabled)
|
||||||
|
enabled: true,
|
||||||
|
// 当前选中的预设 key(初始 = 本卡当前 template_id)
|
||||||
|
selectedKey: '',
|
||||||
|
isLoading: true,
|
||||||
|
errorMsg: '',
|
||||||
|
saving: false
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
fieldRows() {
|
||||||
|
const r = this.relation;
|
||||||
|
if (!r) return [];
|
||||||
|
const rows = [];
|
||||||
|
const push = (label, value, action, icon) => {
|
||||||
|
if (value !== undefined && value !== null && String(value).trim() !== '') {
|
||||||
|
rows.push({ label, value: String(value), action: action || null, icon: icon || '' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
push('手机', r.mobile, { type: 'call', text: '拨打' }, 'icon-dianhua');
|
||||||
|
push('电话', r.phone, { type: 'call', text: '拨打' }, 'icon-dianhua');
|
||||||
|
push('邮箱', r.email, { type: 'copy', text: '复制' });
|
||||||
|
push('微信', r.wechat, { type: 'copy', text: '复制' }, 'icon-weixin');
|
||||||
|
push('网站', r.website);
|
||||||
|
push('地址', [r.country, r.province, r.city, r.address].filter(Boolean).join(' '), null, 'icon-dizhi');
|
||||||
|
push('邮编', r.postal_code);
|
||||||
|
(r.mobiles_list || []).forEach((m, i) => {
|
||||||
|
push('手机' + (i + 2), m, { type: 'call', text: '拨打' }, 'icon-dianhua');
|
||||||
|
});
|
||||||
|
(r.socials_list || []).forEach(s => {
|
||||||
|
push(s.label || s.type, s.value, { type: 'copy', text: '复制' });
|
||||||
|
});
|
||||||
|
(r.custom_list || []).forEach(c => {
|
||||||
|
push(c.label, c.value);
|
||||||
|
});
|
||||||
|
if (r.bio && String(r.bio).trim() !== '') {
|
||||||
|
rows.push({ label: '简介', value: String(r.bio), action: null, multiline: true });
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
},
|
||||||
|
// 本卡当前 design 的 template_id(用于标记"当前")
|
||||||
|
currentKey() {
|
||||||
|
return (this.design && this.design.template_id) || '';
|
||||||
|
},
|
||||||
|
// 本卡自身为画布设计时, save 会被后端拒
|
||||||
|
isCanvasCard() {
|
||||||
|
return !!(this.design && this.design.source === 'canvas');
|
||||||
|
},
|
||||||
|
activePreset() {
|
||||||
|
return this.presets.find(p => p.key === this.selectedKey) || null;
|
||||||
|
},
|
||||||
|
// 实时预览用的 design: 以本卡既有 design 为底, 仅叠加 save 实际写入的 10 个字段
|
||||||
|
// (layout/cover 不被 save 覆盖, 预览须与落库一致, 避免误导)
|
||||||
|
previewDesign() {
|
||||||
|
const base = this.design || {};
|
||||||
|
const p = this.activePreset ? this.activePreset.design : null;
|
||||||
|
if (!p) return base;
|
||||||
|
const merged = Object.assign({}, base);
|
||||||
|
SAVE_WRITTEN_FIELDS.forEach(k => {
|
||||||
|
if (p[k] !== undefined) merged[k] = p[k];
|
||||||
|
});
|
||||||
|
return merged;
|
||||||
|
},
|
||||||
|
previewDs() {
|
||||||
|
return normalizeDesign(this.previewDesign);
|
||||||
|
},
|
||||||
|
previewVars() {
|
||||||
|
return designVarsFor(this.previewDs);
|
||||||
|
},
|
||||||
|
previewHeaderBg() {
|
||||||
|
// 同 cellVars: 小程序内联 `background` 渐变不生效, 用 backgroundColor 纯色兜住
|
||||||
|
return { backgroundColor: this.previewDs.themeColor };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
/**
|
||||||
|
* 预设缩略图的配色。
|
||||||
|
*
|
||||||
|
* 为什么不用 CSS 变量: 微信小程序的渲染层不支持在内联 style 里定义自定义属性
|
||||||
|
* (`style="--card-bg:#fff"`), 整条 style 会被丢弃 → var(--card-bg) 全部回落默认值,
|
||||||
|
* 表现就是「小程序端样式缩略图全是白底黑条灰线, 没有颜色」(H5 正常)。
|
||||||
|
* 这里改为直接输出具体属性 + 具体色值, 三端一致。
|
||||||
|
*/
|
||||||
|
cellDs(p) {
|
||||||
|
let raw = p && p.design;
|
||||||
|
// design 可能是 JSON 字符串(部分后端把设计摘要存 JSON 列)
|
||||||
|
if (typeof raw === 'string') {
|
||||||
|
try { raw = JSON.parse(raw); } catch (e) { raw = null; }
|
||||||
|
}
|
||||||
|
// 合并: 预设项顶层字段(扁平下发) + design 子对象(优先), 两种结构都能读到色值
|
||||||
|
raw = Object.assign({}, p || {}, (raw && typeof raw === 'object') ? raw : {});
|
||||||
|
const s = normalizeDesign(raw);
|
||||||
|
// 缩略图颜色以「预设名称语义」为准(与后台/H5 的预设一致)。
|
||||||
|
// 后端可能给所有预设下发同一个默认 theme_color(合法但是同一种蓝),
|
||||||
|
// 若只在"没有合法色值"时才兜底, 就会 14 个格子全蓝 —— 所以这里名称命中即覆盖。
|
||||||
|
const fb = fallbackTheme(p);
|
||||||
|
if (fb) {
|
||||||
|
s.themeColor = fb;
|
||||||
|
s.headerGradientEnd = ''; // 纯色, 避免后端残留的渐变把兜底色盖掉
|
||||||
|
s.themeSoft = mixHex(fb, '#ffffff', 0.85);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
},
|
||||||
|
cellVars(p) {
|
||||||
|
const s = this.cellDs(p);
|
||||||
|
// 底色不再走内联(小程序端实测内联 background/backgroundColor 均被丢,
|
||||||
|
// 导致 14 格回落同一个 CSS 兜底蓝), 改由样式表按格序上色(见 .preset-cell:nth-child)。
|
||||||
|
// 这里只保留圆角这类无颜色语义的属性。
|
||||||
|
const radius = Math.min(Number(s.cardRadius) || 16, 18);
|
||||||
|
return { borderRadius: radius + 'px' };
|
||||||
|
},
|
||||||
|
// 内嵌白卡: 底色取预设 bg_color, 在彩色底上形成"浮起的名片"层次
|
||||||
|
cellBody(p) {
|
||||||
|
const s = this.cellDs(p);
|
||||||
|
return { backgroundColor: s.bgColor };
|
||||||
|
},
|
||||||
|
cellText(p) {
|
||||||
|
return { backgroundColor: this.cellDs(p).textColor };
|
||||||
|
},
|
||||||
|
cellSoft(p) {
|
||||||
|
return { backgroundColor: this.cellDs(p).textSoft };
|
||||||
|
},
|
||||||
|
// 选中描边同样交给样式表(按格序), 避免内联失效
|
||||||
|
cellActiveStyle() {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
onLoad(options) {
|
||||||
|
this.cardId = decodeURIComponent(options.card_id || '');
|
||||||
|
this.loadDetail();
|
||||||
|
this.loadPresets();
|
||||||
|
},
|
||||||
|
onReady() {
|
||||||
|
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
|
||||||
|
uni.setNavigationBarTitle({ title: '名片' });
|
||||||
|
// #ifdef H5
|
||||||
|
document.title = '名片';
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
loadDetail() {
|
||||||
|
if (!this.cardId) {
|
||||||
|
this.isLoading = false;
|
||||||
|
this.errorMsg = '缺少名片编号';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.$cardApi.cardDetail({
|
||||||
|
data: { card_id: this.cardId },
|
||||||
|
success: res => {
|
||||||
|
const data = res.data || {};
|
||||||
|
if (res.code == 0) {
|
||||||
|
this.relation = data.relation || null;
|
||||||
|
this.design = data.design || null;
|
||||||
|
this.initSelected();
|
||||||
|
} else {
|
||||||
|
this.errorMsg = res.message || '卡片不存在或已被删除';
|
||||||
|
this.relation = null;
|
||||||
|
}
|
||||||
|
this.isLoading = false;
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
this.isLoading = false;
|
||||||
|
this.errorMsg = '网络异常,请稍后重试';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
loadPresets() {
|
||||||
|
this.$cardApi.cardDesignPresets({
|
||||||
|
success: res => {
|
||||||
|
const data = res.data || {};
|
||||||
|
// 商家未开启样式自选时前端隐藏入口(默认 true, 失败开放)
|
||||||
|
this.enabled = data.enabled !== false;
|
||||||
|
this.presets = Array.isArray(data.list) ? data.list : [];
|
||||||
|
this.initSelected();
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
this.enabled = true;
|
||||||
|
this.presets = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// 两路加载都到位后, 若本卡 template_id 命中某个预设, 默认选中它
|
||||||
|
initSelected() {
|
||||||
|
if (this.selectedKey || !this.currentKey || this.presets.length === 0) return;
|
||||||
|
const hit = this.presets.find(p => p.key === this.currentKey);
|
||||||
|
if (hit) this.selectedKey = hit.key;
|
||||||
|
},
|
||||||
|
selectPreset(key) {
|
||||||
|
this.selectedKey = key;
|
||||||
|
},
|
||||||
|
save() {
|
||||||
|
if (!this.selectedKey || this.saving) return;
|
||||||
|
if (this.isCanvasCard) {
|
||||||
|
uni.showToast({ title: '该名片使用商家自定义画布设计,暂不支持员工端修改', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.saving = true;
|
||||||
|
this.$cardApi.cardDesignSave({
|
||||||
|
data: { card_id: this.cardId, preset_key: this.selectedKey },
|
||||||
|
success: res => {
|
||||||
|
this.saving = false;
|
||||||
|
if (res.code == 0) {
|
||||||
|
uni.showToast({ title: '已保存', icon: 'success' });
|
||||||
|
// 返回详情页, 其 onShow 会重新拉取 design 并刷新生效
|
||||||
|
setTimeout(() => uni.navigateBack(), 600);
|
||||||
|
} else if (res.message) {
|
||||||
|
uni.showToast({ title: res.message, icon: 'none' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
this.saving = false;
|
||||||
|
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.card-detail {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #ffffff;
|
||||||
|
// 底部预留高度须 ≥ 固定底栏(按钮 88 + 上下 padding + 安全区), 否则滚到底最后一行会被遮住
|
||||||
|
padding: 0 0 240rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: var(--card-font, -apple-system, "PingFang SC", "Helvetica Neue", Arial, sans-serif);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-layer {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ps-disabled {
|
||||||
|
margin: 24rpx;
|
||||||
|
padding: 28rpx;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #8a94a6;
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预览卡(与详情页同款 design 渲染)
|
||||||
|
.card-wrap {
|
||||||
|
position: relative;
|
||||||
|
padding-top: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-wrap-bg {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 48rpx;
|
||||||
|
z-index: 0;
|
||||||
|
background-color: var(--card-theme, #2aa5ff);
|
||||||
|
border-bottom-left-radius: 240rpx 44rpx;
|
||||||
|
border-bottom-right-radius: 240rpx 44rpx;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-card {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
margin: 0 24rpx;
|
||||||
|
background-color: var(--card-bg, #ffffff);
|
||||||
|
border-radius: var(--card-radius, 20rpx);
|
||||||
|
padding: 40rpx 36rpx 32rpx;
|
||||||
|
box-shadow: 0 6rpx 20rpx rgba(0, 30, 80, 0.06);
|
||||||
|
|
||||||
|
.dc-cover {
|
||||||
|
margin: -40rpx -36rpx 24rpx;
|
||||||
|
border-top-left-radius: var(--card-radius, 20rpx);
|
||||||
|
border-top-right-radius: var(--card-radius, 20rpx);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.dc-cover-img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.dc-cover-hero .dc-cover-img { height: 200rpx; }
|
||||||
|
.dc-cover-compact .dc-cover-img { height: 90rpx; }
|
||||||
|
|
||||||
|
.dc-name {
|
||||||
|
font-size: 44rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--card-text, #1a1a1a);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-job {
|
||||||
|
margin-top: 6rpx;
|
||||||
|
font-size: calc(28rpx * var(--card-font-scale, 1));
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--card-text-soft, #4a4a4a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-company {
|
||||||
|
margin-top: 2rpx;
|
||||||
|
font-size: calc(26rpx * var(--card-font-scale, 1));
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--card-text-soft, #9aa4b2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-rows {
|
||||||
|
margin-top: 56rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.layout-compact {
|
||||||
|
padding-top: 28rpx;
|
||||||
|
|
||||||
|
.dc-rows { margin-top: 28rpx; }
|
||||||
|
.field-row { padding: 8rpx 0; }
|
||||||
|
.dc-name { font-size: 40rpx; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
line-height: 1.2;
|
||||||
|
padding: 12rpx 0;
|
||||||
|
|
||||||
|
&.field-row-multi {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-icon {
|
||||||
|
width: 40rpx;
|
||||||
|
margin-right: 18rpx;
|
||||||
|
font-size: 32rpx;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--card-text-soft, #8a94a6);
|
||||||
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-tag {
|
||||||
|
min-width: 40rpx;
|
||||||
|
margin-right: 18rpx;
|
||||||
|
font-size: calc(26rpx * var(--card-font-scale, 1));
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--card-text-soft, #9aa4b2);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-value {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: calc(28rpx * var(--card-font-scale, 1));
|
||||||
|
line-height: 1.25;
|
||||||
|
color: var(--card-text, #333333);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-value-multi {
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 预设网格(3 列自适应, 14 个不再过长) */
|
||||||
|
.preset-section {
|
||||||
|
margin: 32rpx 24rpx 0;
|
||||||
|
|
||||||
|
.ps-title {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2329;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ps-count {
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #8a94a6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ps-canvas-hint {
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #f5a623;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-cell {
|
||||||
|
position: relative;
|
||||||
|
// 自建堆叠上下文: 把格子内的绝对定位元素(角标/选中钩)限制在本格内,
|
||||||
|
// 避免其 z-index 逃逸到固定底栏之上
|
||||||
|
z-index: 0;
|
||||||
|
width: 32%;
|
||||||
|
margin: 0 2% 16rpx 0;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border: 2rpx solid #e8ebee;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 14rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&:nth-child(3n) {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: var(--card-theme, #2aa5ff);
|
||||||
|
box-shadow: 0 6rpx 18rpx rgba(42, 165, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-tag {
|
||||||
|
position: absolute;
|
||||||
|
top: 8rpx;
|
||||||
|
left: 8rpx;
|
||||||
|
z-index: 2;
|
||||||
|
padding: 2rpx 10rpx;
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
background-color: rgba(0, 0, 0, 0.45);
|
||||||
|
border-radius: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-thumb {
|
||||||
|
position: relative;
|
||||||
|
height: 160rpx;
|
||||||
|
// 底色由 .pt-N 提供(见下方); 这里不能设兜底色, 否则会盖掉 .pt-N
|
||||||
|
border-radius: 12rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
// 顶部彩色露出高度(底色由 cellVars 的渐变铺满整块)
|
||||||
|
.pt-header {
|
||||||
|
height: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内嵌白卡: 浮在主题色底上, 左右下留边露出彩色(对齐 H5 缩略图层次)
|
||||||
|
.pt-body {
|
||||||
|
margin: 0 12rpx 18rpx;
|
||||||
|
padding: 12rpx 14rpx;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-name {
|
||||||
|
height: 14rpx;
|
||||||
|
width: 56%;
|
||||||
|
border-radius: 7rpx;
|
||||||
|
background-color: var(--card-text, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-line {
|
||||||
|
height: 7rpx;
|
||||||
|
width: 80%;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
border-radius: 4rpx;
|
||||||
|
background-color: var(--card-text-soft, #9aa4b2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-short {
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-check {
|
||||||
|
position: absolute;
|
||||||
|
right: 10rpx;
|
||||||
|
bottom: 10rpx;
|
||||||
|
width: 34rpx;
|
||||||
|
height: 34rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #ffffff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.pt-check-mark {
|
||||||
|
width: 13rpx;
|
||||||
|
height: 6rpx;
|
||||||
|
margin-top: -4rpx;
|
||||||
|
border-left: 3rpx solid #2aa5ff;
|
||||||
|
border-bottom: 3rpx solid #2aa5ff;
|
||||||
|
transform: rotate(-45deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-name {
|
||||||
|
display: block;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2329;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-desc {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4rpx;
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: #8a94a6;
|
||||||
|
line-height: 1.3;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-current {
|
||||||
|
position: absolute;
|
||||||
|
top: 8rpx;
|
||||||
|
right: 8rpx;
|
||||||
|
padding: 2rpx 10rpx;
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
background-color: rgba(0, 0, 0, 0.35);
|
||||||
|
border-radius: 8rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 缩略图底色 =====
|
||||||
|
* 按后台 14 个预设的固定顺序写死。
|
||||||
|
* 为什么不用内联/渐变: 微信小程序会丢弃内联 background(渐变/纯色均可能失效),
|
||||||
|
* 之前 14 个格子全部回落成同一个蓝就是因此。样式表 class 是唯一稳定的方式。
|
||||||
|
* 后台预设顺序/配色调整时, 同步修改这里即可。
|
||||||
|
*/
|
||||||
|
.pt-0 { background-color: #119dff; } /* 原生样式 */
|
||||||
|
.pt-1 { background-color: #12b886; } /* 商务绿 */
|
||||||
|
.pt-2 { background-color: #f59f3c; } /* 暖橙 */
|
||||||
|
.pt-3 { background-color: #7b61ff; } /* 紫罗兰 */
|
||||||
|
.pt-4 { background-color: #0b5cff; } /* 经典商务 */
|
||||||
|
.pt-5 { background-color: #1f2329; } /* 极简留白 */
|
||||||
|
.pt-6 { background-color: #111827; } /* 深色霓虹 */
|
||||||
|
.pt-7 { background-color: #f59f3c; } /* 暖调名片 */
|
||||||
|
.pt-8 { background-color: #0b5cff; } /* 大图门面 */
|
||||||
|
.pt-9 { background-color: #7aa7c7; }
|
||||||
|
.pt-10 { background-color: #6cc24a; }
|
||||||
|
.pt-11 { background-color: #111827; }
|
||||||
|
.pt-12 { background-color: #d4a017; } /* 黑金质感: 金 */
|
||||||
|
.pt-13 { background-color: #ef4444; } /* 朱砂活力: 红 */
|
||||||
|
|
||||||
|
.ps-empty {
|
||||||
|
margin-top: 20rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #f5a623;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-loading {
|
||||||
|
text-align: center;
|
||||||
|
color: #999999;
|
||||||
|
font-size: 26rpx;
|
||||||
|
padding: 120rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 底部保存条 */
|
||||||
|
.ps-footer {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
// 必须高于内容层: 否则预设格子里的绝对定位元素(如 .preset-tag z-index:2)
|
||||||
|
// 会"浮"到固定底栏之上, 只露出角标而卡片本体被遮住
|
||||||
|
z-index: 100;
|
||||||
|
padding: 20rpx 24rpx;
|
||||||
|
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
|
||||||
|
background-color: #ffffff;
|
||||||
|
box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.04);
|
||||||
|
|
||||||
|
.ps-save {
|
||||||
|
height: 88rpx;
|
||||||
|
line-height: 88rpx;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 44rpx;
|
||||||
|
background-color: #2aa5ff;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 32rpx;
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
background-color: #c8d6e5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
244
pages_tool/card/views.vue
Normal file
244
pages_tool/card/views.vue
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
<template>
|
||||||
|
<view class="views-page">
|
||||||
|
<!-- 汇总卡(2.11.4): 今日/累计 的 PV 与 UV -->
|
||||||
|
<view class="summary">
|
||||||
|
<view class="sum-card">
|
||||||
|
<view class="sum-num">{{ summary.today_pv || 0 }}</view>
|
||||||
|
<view class="sum-label">今日浏览 PV</view>
|
||||||
|
</view>
|
||||||
|
<view class="sum-card">
|
||||||
|
<view class="sum-num">{{ summary.today_uv || 0 }}</view>
|
||||||
|
<view class="sum-label">今日访客 UV</view>
|
||||||
|
</view>
|
||||||
|
<view class="sum-card">
|
||||||
|
<view class="sum-num">{{ summary.total_pv || 0 }}</view>
|
||||||
|
<view class="sum-label">累计浏览 PV</view>
|
||||||
|
</view>
|
||||||
|
<view class="sum-card">
|
||||||
|
<view class="sum-num">{{ summary.total_uv || 0 }}</view>
|
||||||
|
<view class="sum-label">累计访客 UV</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 列表: time / name / channel / device / ip -->
|
||||||
|
<view class="view-list">
|
||||||
|
<view v-for="(item, idx) in list" :key="item.id || idx" class="view-item">
|
||||||
|
<view class="view-avatar">{{ avatarText(item) }}</view>
|
||||||
|
<view class="view-info">
|
||||||
|
<view class="view-name">{{ item.viewer_name || item.name || '匿名访客' }}</view>
|
||||||
|
<view class="view-meta">
|
||||||
|
<text class="view-channel">{{ item.channel || '未知渠道' }}</text>
|
||||||
|
<text class="view-dot">·</text>
|
||||||
|
<text class="view-device">{{ item.device || '' }}</text>
|
||||||
|
<text class="view-dot" v-if="item.ip">·</text>
|
||||||
|
<text class="view-ip">{{ item.ip || '' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="view-time">{{ item.view_time || item.time || '' }}</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="load-more" v-if="list.length > 0">
|
||||||
|
<text v-if="isLoading">加载中…</text>
|
||||||
|
<text v-else-if="page < pageCount">上拉加载更多</text>
|
||||||
|
<text v-else class="load-end">— 没有更多了 —</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="!isLoading && list.length === 0" class="views-empty">暂无访客记录</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* 访客轨迹 / 谁看了我(2.11.4,建议新页面)
|
||||||
|
* 入口: 我的名片「访客」总入口(不带 card_id,查名下全部名片)
|
||||||
|
* 或某张名片「浏览」入口(带 card_id,查该卡访客)
|
||||||
|
* 接口: POST /card/api/card/views { card_id?, page?, page_size?(<=50, 默认20) }
|
||||||
|
* 响应: { list, total, page, page_size, summary: { today_pv, today_uv, total_pv, total_uv } }
|
||||||
|
*/
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
cardId: '', // 带 card_id=查该卡访客; 空=名下全部
|
||||||
|
list: [],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
pageCount: 1,
|
||||||
|
isLoading: false,
|
||||||
|
summary: {}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onLoad(options) {
|
||||||
|
this.cardId = (options && options.card_id) ? String(options.card_id) : '';
|
||||||
|
this.refresh();
|
||||||
|
},
|
||||||
|
onReady() {
|
||||||
|
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
|
||||||
|
uni.setNavigationBarTitle({ title: '名片' });
|
||||||
|
// #ifdef H5
|
||||||
|
document.title = '名片';
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
onPullDownRefresh() {
|
||||||
|
this.refresh(() => uni.stopPullDownRefresh());
|
||||||
|
},
|
||||||
|
onReachBottom() {
|
||||||
|
if (this.isLoading) return;
|
||||||
|
if (this.page >= this.pageCount) return;
|
||||||
|
this.loadPage(this.page + 1);
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
avatarText(item) {
|
||||||
|
const name = item.viewer_name || item.name || '?';
|
||||||
|
return name.trim().charAt(0).toUpperCase();
|
||||||
|
},
|
||||||
|
refresh(done) {
|
||||||
|
this.page = 1;
|
||||||
|
this.loadPage(1, done);
|
||||||
|
},
|
||||||
|
loadPage(pageNo, done) {
|
||||||
|
this.isLoading = true;
|
||||||
|
const data = { page: pageNo, page_size: this.pageSize };
|
||||||
|
if (this.cardId) data.card_id = this.cardId;
|
||||||
|
this.$cardApi.cardViews({
|
||||||
|
data: data,
|
||||||
|
success: res => {
|
||||||
|
if (res.code == 0) {
|
||||||
|
const d = res.data || {};
|
||||||
|
const rows = d.list || [];
|
||||||
|
this.pageCount = d.page_count || 1;
|
||||||
|
this.page = pageNo;
|
||||||
|
this.summary = d.summary || {};
|
||||||
|
this.list = pageNo === 1 ? rows : this.list.concat(rows);
|
||||||
|
} else {
|
||||||
|
this.handleApiError(res);
|
||||||
|
}
|
||||||
|
this.isLoading = false;
|
||||||
|
typeof done === 'function' && done();
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
this.isLoading = false;
|
||||||
|
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||||
|
typeof done === 'function' && done();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
handleApiError(res) {
|
||||||
|
const msg = (res && res.message) || '加载失败';
|
||||||
|
uni.showToast({ title: msg, icon: 'none' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.views-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #f6f7f9;
|
||||||
|
padding: 20rpx 24rpx 40rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
|
||||||
|
.sum-card {
|
||||||
|
flex: 1;
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 24rpx 0;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0 8rpx;
|
||||||
|
|
||||||
|
&:first-child { margin-left: 0; }
|
||||||
|
&:last-child { margin-right: 0; }
|
||||||
|
|
||||||
|
.sum-num {
|
||||||
|
font-size: 40rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--main-color, #0054a5);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sum-label {
|
||||||
|
margin-top: 8rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-list {
|
||||||
|
.view-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
|
||||||
|
.view-avatar {
|
||||||
|
width: 80rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--main-color-shallow, #e8f1fb);
|
||||||
|
color: var(--main-color, #0054a5);
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.view-name {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-meta {
|
||||||
|
margin-top: 8rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #999;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
|
||||||
|
.view-dot {
|
||||||
|
margin: 0 10rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-time {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 16rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more {
|
||||||
|
text-align: center;
|
||||||
|
color: #999;
|
||||||
|
font-size: 24rpx;
|
||||||
|
padding: 24rpx 0;
|
||||||
|
|
||||||
|
.load-end { color: #ccc; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.views-empty {
|
||||||
|
text-align: center;
|
||||||
|
color: #999;
|
||||||
|
font-size: 28rpx;
|
||||||
|
padding: 120rpx 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
589
pages_tool/files/detail.vue
Normal file
589
pages_tool/files/detail.vue
Normal file
@@ -0,0 +1,589 @@
|
|||||||
|
<template>
|
||||||
|
<view class="files-detail" :style="themeColor">
|
||||||
|
<view class="file-card">
|
||||||
|
<view class="pdf-icon">
|
||||||
|
<text class="pdf-icon-p">P</text>
|
||||||
|
<view class="pdf-icon-fold"></view>
|
||||||
|
<view class="pdf-icon-lines">
|
||||||
|
<view class="pdf-icon-line"></view>
|
||||||
|
<view class="pdf-icon-line"></view>
|
||||||
|
<view class="pdf-icon-line"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="info-wrap">
|
||||||
|
<view class="title">{{ displayTitle }}</view>
|
||||||
|
<view v-if="!loaded" class="read-wrap">
|
||||||
|
<text>{{ $lang('files.list.loading') }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="fileUrl" class="action-area">
|
||||||
|
<button class="action-btn primary" @tap="openFile">{{ $lang('files.list.viewPdf') }}</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="failed" class="error-tip">{{ $lang('files.list.fileNotExist') }}</view>
|
||||||
|
|
||||||
|
<loading-cover ref="loadingCover"></loading-cover>
|
||||||
|
<!-- 隐私弹窗 -->
|
||||||
|
<privacy-popup ref="privacyPopup"></privacy-popup>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* 文件详情页 —— 承接 DIY 装修里的文件链接: /pages_tool/files/detail?files_id=43&title=xxx
|
||||||
|
*
|
||||||
|
* 背景: 后台装修数据(如"图标导航/文本导航"的 FILES_xx 链接)把文件指向本页,
|
||||||
|
* 此前页面缺失导致点击后打不开。本页负责:
|
||||||
|
* 1) 按 files_id 取到文件信息(files_url / files_title)
|
||||||
|
* 2) 进入页面即自动按平台打开文件(与 files/list 点击行为一致)
|
||||||
|
* 3) 自动打开失败时,可点击"查看PDF文件"按钮重试
|
||||||
|
*
|
||||||
|
* 注1: 本页文案只使用语言包中已存在的 key(files.list.*),避免新增 key 未同步到服务端
|
||||||
|
* (lang 为 remote-only 模式,运行时取 /api/lang/bundle)。
|
||||||
|
* 注2: Files 控制器没有单文件详情接口,只能从列表接口里按 files_id 取,详见 fetchFile()。
|
||||||
|
*/
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
filesId: '',
|
||||||
|
filesTitle: '',
|
||||||
|
detail: null,
|
||||||
|
fileUrl: '',
|
||||||
|
loaded: false,
|
||||||
|
failed: false,
|
||||||
|
// 排查用:记录列表接口扫过的文件(files_id:标题)
|
||||||
|
scanned: [],
|
||||||
|
titleTimers: []
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
displayTitle() {
|
||||||
|
if (this.filesTitle) return this.filesTitle;
|
||||||
|
if (this.detail && (this.detail.files_title || this.detail.title)) {
|
||||||
|
return this.detail.files_title || this.detail.title;
|
||||||
|
}
|
||||||
|
return this.$lang('files.list.title');
|
||||||
|
},
|
||||||
|
themeColor() {
|
||||||
|
if (this.$shop && this.$shop.themeColor) {
|
||||||
|
return `--theme-color: ${this.$shop.themeColor};`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLoad(options) {
|
||||||
|
options = options || {};
|
||||||
|
// page_params 可能是 JSON 字符串 / 对象 / 数组(装修数据里常见 [])
|
||||||
|
const params = this.$util.parsePageParams(options.page_params) || {};
|
||||||
|
const rawId = options.files_id || options.file_id || options.filesId || options.id
|
||||||
|
|| params.files_id || params.file_id || params.id || '';
|
||||||
|
this.filesId = rawId === '' || rawId === undefined || rawId === null ? '' : String(rawId);
|
||||||
|
|
||||||
|
const rawTitle = options.title || params.title || '';
|
||||||
|
if (rawTitle) this.filesTitle = this.decodeTitle(rawTitle);
|
||||||
|
this.scheduleTitleSync();
|
||||||
|
this.getData();
|
||||||
|
},
|
||||||
|
onUnload() {
|
||||||
|
this.clearTitleTimers();
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
// main.js 的全局 mixin 监听 componentRefresh,变化时调用 $langConfig.refresh(),
|
||||||
|
// 而 refresh() 会用 $lang('title') 重置导航标题。本页标题就是文件名、且远端语言包里
|
||||||
|
// 没有 files/detail 词条(remote-only 模式),$lang('title') 会退化成字面量 "title"。
|
||||||
|
// refresh() 对每个组件都会跑一遍、"最后一个生效",所以这里延时补写几次兜底。
|
||||||
|
componentRefresh() {
|
||||||
|
if (this.isCurrentPage()) this.scheduleTitleSync();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onShareAppMessage() {
|
||||||
|
return {
|
||||||
|
title: this.displayTitle,
|
||||||
|
path: `/pages_tool/files/detail?files_id=${this.filesId}`
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onShareTimeline() {
|
||||||
|
return {
|
||||||
|
title: this.displayTitle,
|
||||||
|
query: `files_id=${this.filesId}`
|
||||||
|
};
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
syncNavigationTitle() {
|
||||||
|
this.$langConfig.title(this.displayTitle);
|
||||||
|
},
|
||||||
|
// 导航标题兜底:立即写一次,再延时补几次(等全局 refresh() 都跑完)
|
||||||
|
scheduleTitleSync() {
|
||||||
|
this.clearTitleTimers();
|
||||||
|
this.syncNavigationTitle();
|
||||||
|
[100, 500, 1500].forEach(delay => {
|
||||||
|
this.titleTimers.push(setTimeout(() => {
|
||||||
|
if (this.isCurrentPage()) this.syncNavigationTitle();
|
||||||
|
}, delay));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
clearTitleTimers() {
|
||||||
|
this.titleTimers.forEach(id => clearTimeout(id));
|
||||||
|
this.titleTimers = [];
|
||||||
|
},
|
||||||
|
// 本页是否在最上层(避免本页在后台时去改别的页面标题)
|
||||||
|
isCurrentPage() {
|
||||||
|
try {
|
||||||
|
const pages = getCurrentPages();
|
||||||
|
const current = pages[pages.length - 1];
|
||||||
|
if (!current) return false;
|
||||||
|
const route = current.route || (current.$page && current.$page.path) || '';
|
||||||
|
return route.indexOf('files/detail') !== -1;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
decodeTitle(value) {
|
||||||
|
const str = String(value || '');
|
||||||
|
// 多数端 uni 已自动 decode 一次,这里只在明显还是百分号编码时才解码,避免二次解码破坏文案
|
||||||
|
if (!str || str.indexOf('%') === -1) return str;
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(str);
|
||||||
|
} catch (e) {
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
hideLoadingCover() {
|
||||||
|
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
|
||||||
|
},
|
||||||
|
async getData() {
|
||||||
|
if (!this.filesId) {
|
||||||
|
this.finishLoad(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 优先走后端单文件详情接口: 按 files_id 直接取, 无需翻列表(文件多时省几十个请求)
|
||||||
|
let detail = await this.fetchGetfile(this.filesId);
|
||||||
|
// 兜底: 列表逐页扫(可能为空/被过滤) / 企业文件接口
|
||||||
|
if (!detail) detail = await this.fetchFile(this.filesId) || await this.fetchFromPersonnel();
|
||||||
|
if (!detail || !this.normalizeUrl(detail.files_url)) {
|
||||||
|
this.finishLoad(null);
|
||||||
|
this.$util.showToast({
|
||||||
|
title: this.$lang('files.list.fileNotExist'),
|
||||||
|
icon: 'none'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.detail = detail;
|
||||||
|
if (!this.filesTitle) this.filesTitle = detail.files_title || detail.title || '';
|
||||||
|
this.syncNavigationTitle();
|
||||||
|
this.fileUrl = this.normalizeUrl(detail.files_url);
|
||||||
|
this.finishLoad(detail);
|
||||||
|
// 进入详情页即自动打开,与点击文件列表项行为一致
|
||||||
|
this.openFile();
|
||||||
|
},
|
||||||
|
finishLoad(detail) {
|
||||||
|
this.loaded = true;
|
||||||
|
this.failed = !detail;
|
||||||
|
this.hideLoadingCover();
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 取单个文件信息(优先链路)。
|
||||||
|
* 后端接口: GET /api/files/getfile?files_id=xxx
|
||||||
|
* 内部 getFilesInfo([['files_id','=',$files_id]]) → model('files')->getInfo(),
|
||||||
|
* 返回整条记录(files_title / files_url / files_desc / category_id 等),
|
||||||
|
* files_url 已 img() 补全域名; files 表带 site_id, bootSiteGuard 自动注入当前站点,多租户不串号。
|
||||||
|
* 仅 files_id 一个参数即可,后端无需改造。
|
||||||
|
*/
|
||||||
|
async fetchGetfile(filesId) {
|
||||||
|
const res = await this.request('/api/files/getfile', { files_id: filesId });
|
||||||
|
if (!res || res.code !== 0 || !res.data) return null;
|
||||||
|
const d = res.data;
|
||||||
|
return {
|
||||||
|
files_id: d.files_id || filesId,
|
||||||
|
files_title: d.files_title || d.title || '',
|
||||||
|
files_url: d.files_url || '',
|
||||||
|
files_desc: d.files_desc,
|
||||||
|
category_id: d.category_id,
|
||||||
|
category_name: d.category_name
|
||||||
|
};
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 兜底链路(优先链路 fetchGetfile 失败时): 逐页拉列表按 files_id 精确匹配。
|
||||||
|
* 该 addon 之前 Files 控制器只提供列表接口(实测 /api/files/info、/detail、/getinfo 均 404:
|
||||||
|
* "method not exists:app\api\controller\Files->info()"),所以用 getpage 翻页扫。
|
||||||
|
*/
|
||||||
|
async fetchFile(filesId) {
|
||||||
|
// 1) 有标题时先拿标题当关键词搜一次(命中就 1 个请求解决,不必深翻页)
|
||||||
|
if (this.filesTitle) {
|
||||||
|
const searched = await this.queryList({ page: 1, page_size: 100, keyword: this.filesTitle }, filesId, true);
|
||||||
|
if (searched && searched.hit) return searched.hit;
|
||||||
|
}
|
||||||
|
// 2) 逐页遍历全量列表(后端只返回 10 条/页时最多翻 30 页)
|
||||||
|
for (let page = 1; page <= 30; page++) {
|
||||||
|
const queried = await this.queryList({ page: page, page_size: 100 }, filesId);
|
||||||
|
if (queried && queried.hit) return queried.hit;
|
||||||
|
if (!queried || queried.empty) return null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 兜底链路: 从企业文件接口(/api/member/personnel, 电子名片页"企业文件"同源)取文件。
|
||||||
|
* 该接口返回 res.file_list, 每项形如 { name, url }(可能带 files_id/file_id/id)。
|
||||||
|
* 先按 files_id 精确匹配, 再按标题精确匹配, 最后按标题互相包含模糊匹配。
|
||||||
|
*/
|
||||||
|
async fetchFromPersonnel() {
|
||||||
|
const res = await this.request('/api/member/personnel', {});
|
||||||
|
if (!res || res.code !== 0) return null;
|
||||||
|
const list = res.file_list || (res.data && res.data.file_list) || [];
|
||||||
|
if (!Array.isArray(list)) return null;
|
||||||
|
const title = (this.filesTitle || '').toString();
|
||||||
|
const getId = item => item.files_id || item.file_id || item.id || '';
|
||||||
|
let fuzzyHit = null;
|
||||||
|
for (const item of list) {
|
||||||
|
if (!item) continue;
|
||||||
|
// 1) files_id 精确匹配
|
||||||
|
const id = getId(item);
|
||||||
|
if (id !== '' && String(id) === String(this.filesId)) return this.wrapPersonnelFile(item);
|
||||||
|
// 2) 标题精确匹配
|
||||||
|
const name = (item.name || item.files_title || item.title || '').toString();
|
||||||
|
if (title && name === title) return this.wrapPersonnelFile(item);
|
||||||
|
// 3) 标题互相包含(记下第一个命中, 优先级最低)
|
||||||
|
if (title && !fuzzyHit && (name.indexOf(title) !== -1 || title.indexOf(name) !== -1)) {
|
||||||
|
fuzzyHit = item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fuzzyHit ? this.wrapPersonnelFile(fuzzyHit) : null;
|
||||||
|
},
|
||||||
|
wrapPersonnelFile(item) {
|
||||||
|
return {
|
||||||
|
files_id: item.files_id || item.file_id || item.id || this.filesId,
|
||||||
|
files_title: item.name || item.files_title || item.title || '',
|
||||||
|
files_url: item.url || item.files_url || ''
|
||||||
|
};
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 请求一页文件列表,并按 files_id 精确匹配(入参与 files/list 页保持一致)
|
||||||
|
* @param {Boolean} allowTitleMatch 该页是标题搜索结果时,允许退一步用标题全等匹配
|
||||||
|
* @returns {Object|null} 命中返回 { hit };本页无数据返回 { empty: true };请求失败返回 null
|
||||||
|
*/
|
||||||
|
async queryList(data, filesId, allowTitleMatch) {
|
||||||
|
const res = await this.request('/api/files/getpage', Object.assign({
|
||||||
|
category_id: '',
|
||||||
|
keyword: ''
|
||||||
|
}, data));
|
||||||
|
if (!res || res.code !== 0 || !res.data || !Array.isArray(res.data.list)) return null;
|
||||||
|
const list = res.data.list;
|
||||||
|
let titleHit = null;
|
||||||
|
for (const item of list) {
|
||||||
|
const src = item && item.$orig ? item.$orig : item;
|
||||||
|
if (!src) continue;
|
||||||
|
if (String(src.files_id) === String(filesId)) return { hit: src };
|
||||||
|
// 兜底:装修链接里的 files_id 与接口不一致时,用标题全等再认一次
|
||||||
|
if (allowTitleMatch && !titleHit && this.filesTitle && src.files_title === this.filesTitle) {
|
||||||
|
titleHit = src;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (titleHit) return { hit: titleHit };
|
||||||
|
return { empty: list.length === 0 };
|
||||||
|
},
|
||||||
|
async request(url, data) {
|
||||||
|
try {
|
||||||
|
return await this.$api.sendRequest({
|
||||||
|
url,
|
||||||
|
data,
|
||||||
|
async: false,
|
||||||
|
noRetry: true,
|
||||||
|
// 失败静默返回 null,由调用方兜底,不弹全局报错
|
||||||
|
silentError: true
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
normalizeUrl(url) {
|
||||||
|
if (!url) return '';
|
||||||
|
const str = String(url);
|
||||||
|
if (/^https?:\/\//i.test(str)) return str;
|
||||||
|
// $util.img 对含逗号的路径会按"多图字段"截断,文件名可能含逗号,这里规避
|
||||||
|
if (str.indexOf(',') !== -1) return str;
|
||||||
|
// 相对路径补全域名(与图片同一套域名规则)
|
||||||
|
return this.$util.img(str);
|
||||||
|
},
|
||||||
|
getFileExt() {
|
||||||
|
let ext = 'pdf';
|
||||||
|
if (this.fileUrl) {
|
||||||
|
const clean = this.fileUrl.split('?')[0];
|
||||||
|
const matched = clean.match(/\.([a-zA-Z0-9]+)$/);
|
||||||
|
if (matched) ext = matched[1].toLowerCase();
|
||||||
|
}
|
||||||
|
return ext;
|
||||||
|
},
|
||||||
|
getFileName() {
|
||||||
|
const title = (this.displayTitle || 'file').toString().replace(/[\\/:*?"<>|\r\n\t]/g, '_');
|
||||||
|
return `${title}.${this.getFileExt()}`;
|
||||||
|
},
|
||||||
|
getFileType() {
|
||||||
|
const ext = this.getFileExt();
|
||||||
|
return ['pdf'].includes(ext) ? 'pdf'
|
||||||
|
: ['doc', 'docx'].includes(ext) ? 'word'
|
||||||
|
: ['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'webm'].includes(ext) ? 'video'
|
||||||
|
: 'pdf';
|
||||||
|
},
|
||||||
|
buildPreviewUrl() {
|
||||||
|
const fileName = encodeURIComponent(this.getFileName());
|
||||||
|
const fileUrl = encodeURIComponent(this.fileUrl);
|
||||||
|
const fileType = encodeURIComponent(this.getFileType());
|
||||||
|
return `/pages_tool/file-preview/file-preview?fileName=${fileName}&fileUrl=${fileUrl}&fileType=${fileType}`;
|
||||||
|
},
|
||||||
|
openFile() {
|
||||||
|
if (!this.fileUrl) {
|
||||||
|
this.failed = true;
|
||||||
|
this.$util.showToast({
|
||||||
|
title: this.$lang('files.list.fileNotExist'),
|
||||||
|
icon: 'none'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.failed = false;
|
||||||
|
const ext = this.getFileExt();
|
||||||
|
const previewPage = this.buildPreviewUrl();
|
||||||
|
|
||||||
|
// ========== H5 端: 用预览页替换当前页(web-view 走 iframe),失败则新标签页打开 ==========
|
||||||
|
// #ifdef H5
|
||||||
|
uni.redirectTo({
|
||||||
|
url: previewPage,
|
||||||
|
fail: () => {
|
||||||
|
try {
|
||||||
|
window.open(this.fileUrl, '_blank');
|
||||||
|
} catch (e) {
|
||||||
|
this.failed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// ========== 非 H5 端: 下载文件后按平台分支打开 ==========
|
||||||
|
// #ifndef H5
|
||||||
|
uni.showLoading({ title: this.$lang('files.list.loading'), mask: true });
|
||||||
|
uni.downloadFile({
|
||||||
|
url: this.fileUrl,
|
||||||
|
success: res => {
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
uni.hideLoading();
|
||||||
|
this.failed = true;
|
||||||
|
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- 微信小程序: saveFile 重命名 + openDocument(标题取短文件名) -----
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
const fs = uni.getFileSystemManager();
|
||||||
|
const title = (this.displayTitle || 'file').toString().replace(/[\\/:*?"<>|\r\n\t]/g, '_');
|
||||||
|
const targetPath = `${wx.env.USER_DATA_PATH}/fs_${Date.now()}_${title}.${ext}`;
|
||||||
|
const openByPath = (filePath, cleanup) => {
|
||||||
|
uni.openDocument({
|
||||||
|
filePath,
|
||||||
|
fileType: ext,
|
||||||
|
showMenu: true,
|
||||||
|
fail: () => {
|
||||||
|
this.failed = true;
|
||||||
|
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
|
||||||
|
},
|
||||||
|
complete: () => {
|
||||||
|
if (cleanup) {
|
||||||
|
try { fs.removeSavedFile({ filePath }); } catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
fs.saveFile({
|
||||||
|
tempFilePath: res.tempFilePath,
|
||||||
|
filePath: targetPath,
|
||||||
|
success: r => {
|
||||||
|
uni.hideLoading();
|
||||||
|
openByPath(r.savedFilePath, true);
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
// saveFile 失败(配额等),直接用临时文件兜底
|
||||||
|
uni.hideLoading();
|
||||||
|
openByPath(res.tempFilePath, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
uni.hideLoading();
|
||||||
|
openByPath(res.tempFilePath, false);
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// ----- App / 其他小程序: 直接 openDocument;失败兜底跳转预览页 -----
|
||||||
|
// #ifndef MP-WEIXIN
|
||||||
|
uni.hideLoading();
|
||||||
|
uni.openDocument({
|
||||||
|
filePath: res.tempFilePath,
|
||||||
|
fileType: ext,
|
||||||
|
showMenu: true,
|
||||||
|
fail: () => {
|
||||||
|
uni.navigateTo({
|
||||||
|
url: previewPage,
|
||||||
|
fail: () => {
|
||||||
|
this.failed = true;
|
||||||
|
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
uni.hideLoading();
|
||||||
|
this.failed = true;
|
||||||
|
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
|
||||||
|
},
|
||||||
|
complete: res => {
|
||||||
|
if (res && res.statusCode === 404) {
|
||||||
|
uni.hideLoading();
|
||||||
|
this.failed = true;
|
||||||
|
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.files-detail {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 30rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background-color: #f8f8f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-detail .file-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 30rpx;
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-detail .file-card .pdf-icon {
|
||||||
|
margin-right: 24rpx;
|
||||||
|
width: 96rpx;
|
||||||
|
height: 96rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background-color: #e64340;
|
||||||
|
border-radius: 10rpx;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2rpx 8rpx rgba(230, 67, 64, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-detail .file-card .pdf-icon .pdf-icon-p {
|
||||||
|
position: absolute;
|
||||||
|
top: 2rpx;
|
||||||
|
left: 10rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 48rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-detail .file-card .pdf-icon .pdf-icon-fold {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 28rpx;
|
||||||
|
height: 28rpx;
|
||||||
|
background: linear-gradient(135deg, transparent 50%, #ffeceb 50%);
|
||||||
|
border-bottom-left-radius: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-detail .file-card .pdf-icon .pdf-icon-lines {
|
||||||
|
position: absolute;
|
||||||
|
right: 8rpx;
|
||||||
|
bottom: 8rpx;
|
||||||
|
width: 42rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-detail .file-card .pdf-icon .pdf-icon-line {
|
||||||
|
width: 100%;
|
||||||
|
height: 3rpx;
|
||||||
|
background-color: #ffffff;
|
||||||
|
opacity: 0.85;
|
||||||
|
margin-bottom: 4rpx;
|
||||||
|
border-radius: 2rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-detail .file-card .pdf-icon .pdf-icon-line:nth-child(2) {
|
||||||
|
width: 80%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-detail .file-card .pdf-icon .pdf-icon-line:nth-child(3) {
|
||||||
|
width: 60%;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-detail .file-card .info-wrap {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-detail .file-card .info-wrap .title {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #303133;
|
||||||
|
font-size: 30rpx;
|
||||||
|
line-height: 1.4;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-detail .file-card .info-wrap .read-wrap {
|
||||||
|
display: flex;
|
||||||
|
color: #999ca7;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-detail .file-card .info-wrap .read-wrap text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-detail .action-area {
|
||||||
|
margin-top: 40rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-detail .action-area .action-btn {
|
||||||
|
height: 84rpx;
|
||||||
|
line-height: 84rpx;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 30rpx;
|
||||||
|
border-radius: 42rpx;
|
||||||
|
background: linear-gradient(90deg, #ff7e47 0%, #f73131 100%);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 4rpx 12rpx rgba(247, 49, 49, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-detail .action-area .action-btn::after {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-detail .error-tip {
|
||||||
|
margin-top: 30rpx;
|
||||||
|
text-align: center;
|
||||||
|
color: #999;
|
||||||
|
font-size: 26rpx;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -230,4 +230,5 @@ export default {
|
|||||||
background-color:var(--main-color);
|
background-color:var(--main-color);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
</view>
|
</view>
|
||||||
</block>
|
</block>
|
||||||
<view>
|
<view>
|
||||||
<ns-empty v-if="list.length === 0" isIndex="false" :text="$lang('seal.medium.search.noData')"></ns-empty>
|
<ns-empty v-if="list.length === 0" :isIndex="false" :text="$lang('seal.medium.search.noData')"></ns-empty>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
226
readme.md
226
readme.md
@@ -66,26 +66,6 @@
|
|||||||
- 用微信开发者工具导入解压后的代码目录
|
- 用微信开发者工具导入解压后的代码目录
|
||||||
- 编译验证无误后,上传发布
|
- 编译验证无误后,上传发布
|
||||||
|
|
||||||
#### 4.1.2 鸿蒙小程序发布(mp-harmony)
|
|
||||||
|
|
||||||
##### ##### ##### ##### 【发布构建】
|
|
||||||
1. 使用 HBuilderX 打开项目
|
|
||||||
2. 选择菜单栏「发行」 → 「HarmonyOS」→ 「发行」,等待构建完成
|
|
||||||
3. 构建完成后,**自动执行** `harmony.patch.js` 补丁脚本(配置拷贝 + ZIP 打包)
|
|
||||||
4. 找到项目根目录 `/unpackage/dist/build` 下生成的 mp-harmony 压缩包
|
|
||||||
|
|
||||||
##### ##### ##### ##### 【交付与最终发布】🔍
|
|
||||||
1. 将生成的 `.zip` 文件发送给运维人员
|
|
||||||
2. **运维人员操作流程**:
|
|
||||||
- 解压压缩包
|
|
||||||
- 打开鸿蒙开发者工具,导入解压后的代码目录
|
|
||||||
- 验证代码无报错后,执行上传发布操作
|
|
||||||
|
|
||||||
##### ##### ##### ##### 【本地设置】
|
|
||||||

|
|
||||||
#####
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4.2 快应用发布
|
### 4.2 快应用发布
|
||||||
|
|
||||||
#### 4.2.1 基本操作(通用版/定制化版)
|
#### 4.2.1 基本操作(通用版/定制化版)
|
||||||
@@ -122,6 +102,212 @@ H5:打开自己的浏览器输入网址(https://5g.aigc-quickapp.com/hwappx/co
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 4.3 鸿蒙元服务发布(mp-harmony)
|
||||||
|
|
||||||
|
#### 4.3.1 【发布构建】
|
||||||
|
1. 使用 HBuilderX 打开项目
|
||||||
|
2. 选择菜单栏「发行」 → 「HarmonyOS」→ 「发行」,等待构建完成
|
||||||
|
3. 构建完成后,**自动执行** `harmony.patch.js` 补丁脚本(配置拷贝 + ZIP 打包)
|
||||||
|
4. 找到项目根目录 `/unpackage/dist/build` 下生成的 mp-harmony 压缩包
|
||||||
|
|
||||||
|
#### 4.3.2 【DevEco Studio 编译与产物】
|
||||||
|
在 DevEco Studio 中导入 `mp-harmony` 工程后,点击「Build」→「Build App(s)/Project(s)」,实际执行的任务是 `assembleApp`:
|
||||||
|
|
||||||
|
```
|
||||||
|
# 由 DevEco Studio 自动执行,无需手动敲
|
||||||
|
hvigorw --mode project -p product=default assembleApp
|
||||||
|
```
|
||||||
|
|
||||||
|
这个任务是**两步串联**的 —— **先逐个 module 编译出包,再把这些包打成 APP**:
|
||||||
|
|
||||||
|
| 产物 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 分包(中间产物) | `{module}/build/default/outputs/default/{module}-default-signed.{hap\|hsp}` | 每个 module 一个,编译+签名的最小单位 |
|
||||||
|
| APP(最终产物) | `build/outputs/default/mp-harmony-default-signed.app` | 把全部分包收拢成的分发单元 |
|
||||||
|
|
||||||
|
📌 **元服务上架 AppGallery Connect 上传的是 `.app`**,不是 `.hap`。`pack.info` 中 `"bundleType": "atomicService"` 即元服务包标记。
|
||||||
|
|
||||||
|
📌 **本工程共 1 个 HAP + 5 个 HSP**(不是 6 个 HAP)—— `module.json5` 里 `type: "entry"` 编成 **HAP**,`type: "shared"` 编成 **HSP**(共享包,动态下发)。详见 `build/outputs/default/pack.info`:
|
||||||
|
|
||||||
|
| 包名 | 扩展名 | moduleType | 说明 |
|
||||||
|
|------|--------|------------|------|
|
||||||
|
| `entry-default` | `.hap` | `entry` | 主包,EntryAbility + 服务卡片 + uniapp 主 bundle |
|
||||||
|
| `components_diy-default` | `.hsp` | `shared` | ascf 动态化分包 |
|
||||||
|
| `pages_goods-default` | `.hsp` | `shared` | ascf 动态化分包 |
|
||||||
|
| `pages_order-default` | `.hsp` | `shared` | ascf 动态化分包 |
|
||||||
|
| `pages_promotion-default` | `.hsp` | `shared` | ascf 动态化分包 |
|
||||||
|
| `pages_tool-default` | `.hsp` | `shared` | ascf 动态化分包 |
|
||||||
|
|
||||||
|
⚠️ **主包体积**:元服务官方要求**单个包 ≤ 2MB**(按压缩后算,含其直接依赖的 HSP),同一设备类型下全部分包总和 ≤ 10MB。实测本工程主包 `entry-default.hap` 约 **2.37 MiB**,已超单包 2MB 限制;总包约 **6.39 MiB**,在 10MB 以内。
|
||||||
|
|
||||||
|
> 若只想单模块调试,可在 DevEco 中用「Build Hap(s)/Module(s)」(即 `assembleHap`),只产出分包、不打 APP。
|
||||||
|
|
||||||
|
#### 4.3.3 【工程包导出(换机编译)】
|
||||||
|
|
||||||
|
需要在一台**没有本仓库 / 没有 HBuilderX** 的电脑上编译鸿蒙工程时,用脚本把 `stub-app-projects/mp-harmony` 里编译所需的文件导成一个 zip:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run pack:harmony
|
||||||
|
```
|
||||||
|
|
||||||
|
输出到 `unpackage/dist/harmony-export/mp-harmony-project-v<日期>-<提交ID>.zip`,解压后即得 `mp-harmony/` 目录。
|
||||||
|
|
||||||
|
| 参数 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| (无参数) | 默认行为:含签名证书,不含 `node_modules` |
|
||||||
|
| `--no-certs` | 不含 `cert-profile/`(见下方安全提示) |
|
||||||
|
| `--with-node-modules` | 额外含 `hvigor/node_modules`(约 320 MB),供**离线**目标机 |
|
||||||
|
| `--out <目录>` | 自定义输出目录 |
|
||||||
|
|
||||||
|
**脚本全程只读源目录**,产物先复制到暂存目录再压缩,不修改 `stub-app-projects` 下任何文件。
|
||||||
|
|
||||||
|
**导出内容**
|
||||||
|
|
||||||
|
| 类别 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 工程源码 | `AppScope/`、`entry/src/`、`ascf/ascf_build/*/src/`、各类 `*.json5` / `hvigorfile.ts` |
|
||||||
|
| `ascf/ascf_src/` | uniapp 编译产物(页面/组件代码),**带上它目标机就不需要本仓库和 HBuilderX** |
|
||||||
|
| `cert-profile/` | 签名证书(`--no-certs` 可排除) |
|
||||||
|
|
||||||
|
**已排除**(可由目标机 DevEco 重新生成)
|
||||||
|
|
||||||
|
`node_modules/`(约 320 MB)、`oh_modules/`、`**/build/`、`.preview/`(约 22 MB)、`.idea/`、`.hvigor/`、`local.properties`、`.cxx/`、`.test/`
|
||||||
|
|
||||||
|
> ⚠️ **证书安全**:默认导出**包含签名私钥** `cert-profile/qq/qq.p12`,且 p12 密码在 `build-profile.json5` 中本就是明文。请仅在可信渠道传递,用完及时销毁或归档。对外发给不可信对象时务必加 `--no-certs`。
|
||||||
|
>
|
||||||
|
> 📌 缺证书 hvigor 无法签名,产不出可上传的 `.app`。用 `--no-certs` 时需自行把 `cert-profile/` 放到目标工程对应位置。
|
||||||
|
|
||||||
|
**目标机操作**
|
||||||
|
|
||||||
|
1. 安装 DevEco Studio 5.0.x(本工程 `compatibleSdkVersion` 5.0.0(12))
|
||||||
|
2. 解压 zip,得到 `mp-harmony/`
|
||||||
|
3. DevEco Studio「打开」`mp-harmony` 目录本身
|
||||||
|
4. 首次打开自动执行依赖同步(`ohpm install` 拉取 `hvigor/node_modules`),**需联网**;离线机请用 `--with-node-modules` 导出
|
||||||
|
5. 「Build」→「Build App(s)/Project(s)」,产物 `build/outputs/default/mp-harmony-default-signed.app`
|
||||||
|
|
||||||
|
> ⚠️ **`ascf/ascf_src/` 是导出时刻的快照**。若前端代码有更新,必须回源机器重新执行导出,否则编译出的包内容是旧的。
|
||||||
|
|
||||||
|
📌 zip 由脚本内纯 Node 实现(`zlib` + 手写 ZIP 头)生成,条目名统一正斜杠并置 UTF-8 标志位,可跨 Windows/macOS/Linux 使用。
|
||||||
|
|
||||||
|
**产出后自检(每次导出都会跑)**
|
||||||
|
|
||||||
|
脚本写完 zip 后会把文件**重新解析一遍**,任何一项不符直接判失败、不输出「完成」:
|
||||||
|
|
||||||
|
| 校验项 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| **零漏拷 / 零多拷** | 把本次实际复制的文件清单与 zip 条目**逐个双向比对**,有任何文件没打进去、或出现清单外的文件,当场失败 |
|
||||||
|
| EOCD / 中央目录 | 签名、条目数、中央目录偏移与长度边界一致 |
|
||||||
|
| `localOffset` | 每条都必须真的指向本地文件头,且文件名与中央目录一致 |
|
||||||
|
| CRC | **逐条**解压后重算 CRC32 比对 |
|
||||||
|
| 条目名 | 必须正斜杠、必须置 UTF-8 标志位、必须可逆解码为合法 UTF-8 |
|
||||||
|
| 内容断言 | 构建必需文件齐全;`node_modules`/`build`/`.preview` 等排除项不得混入 |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test # 跑全量测试, 含 pack-harmony-project.test.js 的 19 条用例
|
||||||
|
```
|
||||||
|
|
||||||
|
正常输出示例:
|
||||||
|
|
||||||
|
```
|
||||||
|
条目: 1382 个文件 + 327 个目录 (自检通过)
|
||||||
|
自检: 零漏拷 ✅(1381/1381) / CRC 逐条 ✅ / 偏移一致 ✅ / 正斜杠 ✅ / UTF-8 ✅ / 骨架齐全 ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
> 📌 **不必再人工核对"某个目录有没有打进去"**——上面的「零漏拷」会逐文件双向比对并打印数量。怀疑漏拷时看这一行即可;若真的漏了,脚本会直接失败并列出文件名。
|
||||||
|
>
|
||||||
|
> 📌 `EntryCard/` 不在 `build-profile.json5` 的模块列表中,**不是编译模块**,其内容不参与构建;但导出脚本按「全量复制 − 排除清单」处理,同样会把它带进 zip。
|
||||||
|
|
||||||
|
> 📌 测试分**正向**(写出来能还原)与**反向**(故意篡改 zip,断言自检能抓到)两类。反向用例锁定了开发期真实踩过的三个 bug:中央目录偏移漏算文件数据长度、`localOffset` 记成数据区起点、`.test` 子串匹配误伤 `entry/src/ohosTest/ets/test/` 源码。**反向用例如果失效,自检就形同虚设**,改动脚本时务必保持它们通过。
|
||||||
|
|
||||||
|
#### 4.3.4 【常见错误】
|
||||||
|
|
||||||
|
**❌ `hvigor ERROR: 00303218 Configuration Error`**
|
||||||
|
```
|
||||||
|
Error Message: The reason and usedScene attributes are mandatory for user_grant permissions.
|
||||||
|
at entry\src\main\module.json5
|
||||||
|
```
|
||||||
|
|
||||||
|
**原因**:`user_grant` 类型权限必须**同时**声明 `reason` 和 `usedScene` 两个字段,只写 `usedScene` 会导致 `:entry:default@PreBuild` 失败。
|
||||||
|
|
||||||
|
**解决**:给对应权限补上 `reason` 字段,并在 `entry/src/main/resources/base/element/string.json` 中新增对应的理由文案。
|
||||||
|
|
||||||
|
```json5
|
||||||
|
// entry/src/main/module.json5
|
||||||
|
{
|
||||||
|
"name": "ohos.permission.MICROPHONE",
|
||||||
|
"reason": "$string:permission_microphone_reason",
|
||||||
|
"usedScene": {
|
||||||
|
"abilities": ["EntryAbility"],
|
||||||
|
"when": "always"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
// entry/src/main/resources/base/element/string.json
|
||||||
|
{
|
||||||
|
"name": "permission_microphone_reason",
|
||||||
|
"value": "用于语音搜索与语音输入功能,录制语音以识别用户需求"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
📌 **权限类型速查** —— 只有 `user_grant` 才需要 `reason`:
|
||||||
|
|
||||||
|
| 权限 | 类型 | 需要 reason |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `ohos.permission.INTERNET` | system_grant | 否 |
|
||||||
|
| `ohos.permission.GET_NETWORK_INFO` | system_grant | 否 |
|
||||||
|
| `ohos.permission.VIBRATE` | system_grant | 否 |
|
||||||
|
| `ohos.permission.MICROPHONE` | **user_grant** | **是** |
|
||||||
|
|
||||||
|
📌 `reason` 文案必须是**用户可读的中文说明**,不能为空或写 `xxx description` 之类的占位文本,否则上架审核会被驳回。
|
||||||
|
|
||||||
|
📌 **改完 `reason` 还不够** —— `user_grant` 权限编译通过后,仍需在运行时弹窗申请,否则录音等功能不可用。本项目已在 [EntryAbility.ets](stub-app-projects/mp-harmony/entry/src/main/ets/entryability/EntryAbility.ets) 的 `onCreate` 中接入:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
|
||||||
|
// 必须先调父类: ascf runtime 的初始化在 AscfUIAbility.onCreate 里, 漏掉会导致元服务白屏
|
||||||
|
super.onCreate(want, launchParam);
|
||||||
|
this.requestMicrophonePermission();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ **坑**:`AscfUIAbility` 父类**已定义** `onCreate`,重写时必须调 `super.onCreate(want, launchParam)`,否则 ascf runtime 初始化被跳过,元服务白屏。
|
||||||
|
|
||||||
|
> ⚠️ **坑**:`Permissions` 在 `@ohos.permissions` 里是**字符串联合类型**([permissions.d.ts](https://gitee.com/openharmony/docs/blob/master/zh-cn/application-dev/reference/apis-ability-kit/js-apis-permissions.md)),不是枚举,**只能用在类型位置**,当值引用会报:
|
||||||
|
> ```
|
||||||
|
> 'Permissions' only refers to a type, but is being used as a value here.
|
||||||
|
> ```
|
||||||
|
> 所以正确写法是标注 + 字面量,且权限名必须与 `module.json5` 中声明的一致:
|
||||||
|
> ```ts
|
||||||
|
> import { abilityAccessCtrl, AbilityConstant, Permissions, Want } from '@kit.AbilityKit';
|
||||||
|
>
|
||||||
|
> const PERMISSION_MICROPHONE: Permissions = 'ohos.permission.MICROPHONE';
|
||||||
|
> // ❌ Permissions.MICROPHONE —— 编译不过
|
||||||
|
> // ⚠️ abilityAccessCtrl.SwitchType.MICROPHONE(=1) 是"开关类型",不是权限名,别搞混
|
||||||
|
> ```
|
||||||
|
|
||||||
|
📌 为什么在 `onCreate` 申请而不是"用户点录音时才申请":
|
||||||
|
- 权限状态在 JS 侧(`uni.getRecorderManager`)读不到,项目里也没有现成的 JS ↔ ArkTS 通道,做不了按需触发
|
||||||
|
- `requestPermissionsFromUser` 对已授权的应用直接 resolve 且**不弹窗**,所以实际只有首次启动打扰用户一次
|
||||||
|
|
||||||
|
📌 用户拒绝后的降级:不影响浏览/下单/卡片等其他功能,仅语音输入不可用;[ai-chat-voice-panel.vue](pages_tool/ai-chat/components/ai-chat-voice-panel.vue) 会 catch 到 recorder 错误并 `showToast` 提示用户。
|
||||||
|
|
||||||
|
#### 4.3.5 【待完善】⚠️
|
||||||
|
- **release 签名仍指向 debug 证书**:`build-profile.json5` 中 `release` 签名的 `profile` 仍是 `./cert-profile/qq/qqDebug.p7b`,与 `default`(debug)共用同一份证书。本地调试无碍,但提交 AppGallery 前必须换成**发布证书 + release 类型 provisioning profile**,否则 AGC 校验会直接拒绝。
|
||||||
|
|
||||||
|
#### 4.3.6 【交付与最终发布】🔍
|
||||||
|
1. 将生成的 `.zip` 文件发送给运维人员
|
||||||
|
2. **运维人员操作流程**:
|
||||||
|
- 解压压缩包
|
||||||
|
- 打开鸿蒙开发者工具,导入解压后的代码目录
|
||||||
|
- 验证代码无报错后,执行上传发布操作
|
||||||
|
|
||||||
|
#### 4.3.7 【本地设置】
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 5. 📝 构建与版本信息
|
## 5. 📝 构建与版本信息
|
||||||
|
|
||||||
### 5.1 自动注入机制
|
### 5.1 自动注入机制
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const paths = [
|
|||||||
['pages_promotion/merch/detail','商户首页'],
|
['pages_promotion/merch/detail','商户首页'],
|
||||||
['pages_promotion/fenxiao/ranking_list','排行榜'],
|
['pages_promotion/fenxiao/ranking_list','排行榜'],
|
||||||
['pages_promotion/fenxiao/child_fenxiao','分销商'],
|
['pages_promotion/fenxiao/child_fenxiao','分销商'],
|
||||||
['pages_tool/ai-chat/index','AI智能'],
|
['pages_tool/ai-chat/index','AI智能客服'],
|
||||||
['pages_tool/contact/contact','电子名片'],
|
['pages_tool/contact/contact','电子名片'],
|
||||||
['pages_tool/member/info','个人资料'],
|
['pages_tool/member/info','个人资料'],
|
||||||
['pages_tool/order/activist','退款'],
|
['pages_tool/order/activist','退款'],
|
||||||
|
|||||||
119
scripts/i18n_recommend.py
Normal file
119
scripts/i18n_recommend.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
path = r"D:\0.daima\lucky_shop\components-diy\diy-recommend-code.vue"
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
s = f.read()
|
||||||
|
|
||||||
|
BASE = "components-diy.diy-recommend-code."
|
||||||
|
|
||||||
|
# ---- 1. value.xxx || '中文' / nc.xxx || '中文' ----
|
||||||
|
TEXT_FIELDS = {
|
||||||
|
'title','buttonText','copyButtonText','consentText','consentButtonText',
|
||||||
|
'loginTipText','loginBtnText','posterQrTip','placeholderRegenerateText',
|
||||||
|
}
|
||||||
|
def repl_value_nc(m):
|
||||||
|
prefix = m.group(1) # e.g. "value.title || "
|
||||||
|
field = m.group(2)
|
||||||
|
if field in TEXT_FIELDS:
|
||||||
|
return prefix + "$lang('" + BASE + field + "')"
|
||||||
|
return m.group(0)
|
||||||
|
s = re.sub(r"((?:value|nc)\.(\w+)\s*\|\|\s*)'([^']*)'", repl_value_nc, s)
|
||||||
|
|
||||||
|
# ---- 2. standalone template text nodes ----
|
||||||
|
standalone = {
|
||||||
|
'\u4e13\u5c5e\u63a8\u8350\u7801': 'exclusiveCode',
|
||||||
|
'\u5f85\u83b7\u53d6\u63a8\u8350\u7801': 'pendingCode',
|
||||||
|
'\u5f85\u83b7\u53d6': 'pending',
|
||||||
|
'\u63a8\u8350\u7801\u52a0\u8f7d\u4e2d...': 'loading',
|
||||||
|
'\u5df2\u83b7\u5f97\u63a8\u8350\u7801': 'obtainedCode',
|
||||||
|
'\u5df2\u83b7\u5f97': 'obtained',
|
||||||
|
'\u5206\u4eab\u7ed9\u597d\u53cb\uff0c\u9080\u8bf7\u6ce8\u518c\u5373\u53ef\u83b7\u5f97\u5956\u52b1': 'shareInviteReward',
|
||||||
|
'\u2713 \u5df2\u751f\u6548': 'activated',
|
||||||
|
'\u63a8\u8350\u7801\u5df2\u751f\u6210\uff0c\u53ef\u7528\u4e8e\u9080\u8bf7\u597d\u53cb\u6216\u4f01\u4e1a\u5165\u9a7b\u7ed1\u5b9a\u63a8\u8350\u5173\u7cfb': 'codeGenerated',
|
||||||
|
}
|
||||||
|
for zh, key in standalone.items():
|
||||||
|
s = s.replace(">%s</text>" % zh, ">{{ $lang('%s%s') }}</text>" % (BASE, key))
|
||||||
|
|
||||||
|
# ---- 3. '提交中...' standalone in template ternary ----
|
||||||
|
s = s.replace("'\u63d0\u4ea4\u4e2d...'", "$lang('%ssubmitting')" % BASE)
|
||||||
|
|
||||||
|
# ---- 4. default() Chinese literals -> '' ----
|
||||||
|
default_map = {
|
||||||
|
"title: '\u6211\u7684\u63a8\u8350\u7801'": "title: ''",
|
||||||
|
"buttonText: '\u7acb\u5373\u5206\u4eab'": "buttonText: ''",
|
||||||
|
"copyButtonText: '\u590d\u5236\u63a8\u8350\u7801'": "copyButtonText: ''",
|
||||||
|
"placeholderRegenerateText: '\u6362\u4e00\u4e2a\u65b0\u7801'": "placeholderRegenerateText: ''",
|
||||||
|
"consentText: '\u60a8\u7684\u63a8\u8350\u7801\u5c06\u7528\u4e8e\u4f01\u4e1a\u5165\u9a7b\u6765\u6e90\u8ffd\u8e2a\uff0c\u6211\u4eec\u4f1a\u8bb0\u5f55\u6bcf\u4e00\u6b21\u901a\u8fc7\u60a8\u5206\u4eab\u94fe\u63a5\u5b8c\u6210\u7684\u5165\u9a7b\u7533\u8bf7\uff0c\u7528\u4e8e\u60a8\u7684\u63a8\u5e7f\u6536\u76ca\u7ed3\u7b97\u3002'": "consentText: ''",
|
||||||
|
"consentButtonText: '\u540c\u610f\u5e76\u83b7\u53d6\u63a8\u8350\u7801'": "consentButtonText: ''",
|
||||||
|
"loginTipText: '\u767b\u5f55\u540e\u67e5\u770b\u6211\u7684\u63a8\u8350\u7801'": "loginTipText: ''",
|
||||||
|
"loginBtnText: '\u53bb\u767b\u5f55'": "loginBtnText: ''",
|
||||||
|
"posterQrTip: '\u626b\u7801\u8fdb\u5165\u5e76\u586b\u5199\u5165\u9a7b\u4fe1\u606f'": "posterQrTip: ''",
|
||||||
|
}
|
||||||
|
for a, b in default_map.items():
|
||||||
|
s = s.replace(a, b)
|
||||||
|
|
||||||
|
# ---- 5. JS user-facing strings ----
|
||||||
|
# rcCopyToClipboard util (uses vm.$lang)
|
||||||
|
s = s.replace("cb && cb(false, '\u5185\u5bb9\u4e3a\u7a7a')",
|
||||||
|
"cb && cb(false, vm.$lang('%scopyEmpty'))" % BASE)
|
||||||
|
s = s.replace("fail: () => cb && cb(false, '\u590d\u5236\u5931\u8d25')",
|
||||||
|
"fail: () => cb && cb(false, vm.$lang('%scopyFail'))" % BASE)
|
||||||
|
s = s.replace("cb && cb(ok ? true : false, ok ? null : '\u590d\u5236\u5931\u8d25')",
|
||||||
|
"cb && cb(ok ? true : false, ok ? null : vm.$lang('%scopyFail'))" % BASE)
|
||||||
|
s = s.replace("cb && cb(false, '\u590d\u5236\u5931\u8d25')",
|
||||||
|
"cb && cb(false, vm.$lang('%scopyFail'))" % BASE)
|
||||||
|
|
||||||
|
# getFail / getFailCode / getSuccess / netError
|
||||||
|
s = s.replace("var lastMsg = '\u63a8\u8350\u7801\u83b7\u53d6\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5';",
|
||||||
|
"var lastMsg = this.$lang('%sgetFail');" % BASE)
|
||||||
|
s = s.replace("('\u63a8\u8350\u7801\u83b7\u53d6\u5931\u8d25(\u4e1a\u52a1\u7801=' + unpacked.businessCode + ')')",
|
||||||
|
"this.$lang('%sgetFailCode', { code: unpacked.businessCode })" % BASE)
|
||||||
|
s = s.replace("uni.showToast({ title: '\u83b7\u53d6\u6210\u529f', icon: 'success' })",
|
||||||
|
"uni.showToast({ title: this.$lang('%sgetSuccess'), icon: 'success' })" % BASE)
|
||||||
|
s = s.replace("'\u7f51\u7edc\u5f02\u5e38\uff1a' + failMsg.slice(0, 30)",
|
||||||
|
"this.$lang('%snetError')" % BASE)
|
||||||
|
s = s.replace("'\u7f51\u7edc\u5f02\u5e38\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5'",
|
||||||
|
"this.$lang('%snetError')" % BASE)
|
||||||
|
|
||||||
|
# regen modal
|
||||||
|
s = s.replace("title: '\u6362\u65b0\u7801',", "title: this.$lang('%sregenTitle')," % BASE)
|
||||||
|
s = s.replace("content: '\u6362\u65b0\u7801\u540e\u65e7\u7801\u4f1a\u7acb\u5373\u5931\u6548\uff0c\u540e\u7eed\u6240\u6709\u4f7f\u7528\u65e7\u5206\u4eab\u94fe\u63a5\u7684\u5165\u9a7b\u7533\u8bf7\u90fd\u4e0d\u4f1a\u5f52\u5c5e\u5230\u60a8\uff0c\u786e\u8ba4\u7ee7\u7eed\uff1f',",
|
||||||
|
"content: this.$lang('%sregenContent')," % BASE)
|
||||||
|
s = s.replace("confirmText: '\u786e\u8ba4\u6362\u65b0',", "confirmText: this.$lang('%sregenConfirm')," % BASE)
|
||||||
|
s = s.replace("cancelText: '\u53d6\u6d88',", "cancelText: this.$lang('%sregenCancel')," % BASE)
|
||||||
|
s = s.replace("title: unpacked.msg ? String(unpacked.msg) : '\u6362\u65b0\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5',",
|
||||||
|
"title: unpacked.msg ? String(unpacked.msg) : this.$lang('%sregenFail')," % BASE)
|
||||||
|
s = s.replace("title: old ? ('\u5df2\u6362\u65b0\u7801\uff0c\u65e7\u7801 ' + old + ' \u5df2\u505c\u7528') : '\u6362\u65b0\u6210\u529f',",
|
||||||
|
"title: old ? this.$lang('%sregenSuccessOld', { old: old }) : this.$lang('%sregenSuccess')," % (BASE, BASE))
|
||||||
|
s = s.replace("uni.showToast({ title: '\u7f51\u7edc\u5f02\u5e38\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5', icon: 'none' })",
|
||||||
|
"uni.showToast({ title: this.$lang('%snetError'), icon: 'none' })" % BASE)
|
||||||
|
|
||||||
|
# share title (L1128 specific) then global for the rest
|
||||||
|
s = s.replace("(vm.nc && (vm.nc.shareTitle || vm.nc.title)) || '\u9080\u60a8\u5165\u9a7b\u4f4e\u7a7a\u7ecf\u6d4e\u4ea7\u4e1a\u5e73\u53f0'",
|
||||||
|
"(vm.nc && (vm.nc.shareTitle || vm.nc.title)) || this.$lang('%sshareTitle')" % BASE)
|
||||||
|
s = s.replace("'\u9080\u60a8\u5165\u9a7b\u4f4e\u7a7a\u7ecf\u6d4e\u4ea7\u4e1a\u5e73\u53f0'",
|
||||||
|
"this.$lang('%sshareTitle')" % BASE)
|
||||||
|
|
||||||
|
# copy / link toasts
|
||||||
|
s = s.replace("title: ok ? '\u63a8\u8350\u7801\u5df2\u590d\u5236' : (err || '\u590d\u5236\u5931\u8d25')",
|
||||||
|
"title: ok ? this.$lang('%scodeCopied') : (err || this.$lang('%scopyFail'))" % (BASE, BASE))
|
||||||
|
s = s.replace("uni.showToast({ title: '\u63a8\u8350\u7801\u8fd8\u672a\u751f\u6210\uff0c\u8bf7\u7a0d\u5019', icon: 'none' })",
|
||||||
|
"uni.showToast({ title: this.$lang('%scodeNotReady'), icon: 'none' })" % BASE)
|
||||||
|
s = s.replace("uni.showToast({ title: '\u63a8\u8350\u7801\u52a0\u8f7d\u5b8c\u6210\u540e\u518d\u590d\u5236', icon: 'none' })",
|
||||||
|
"uni.showToast({ title: this.$lang('%sloadThenCopy'), icon: 'none' })" % BASE)
|
||||||
|
s = s.replace("title: ok ? '\u63a8\u5e7f\u94fe\u63a5\u5df2\u590d\u5236' : (err || '\u590d\u5236\u5931\u8d25')",
|
||||||
|
"title: ok ? this.$lang('%slinkCopied') : (err || this.$lang('%scopyFail'))" % (BASE, BASE))
|
||||||
|
|
||||||
|
# poster title + canvas
|
||||||
|
s = s.replace("const title = v.title || '\u6211\u7684\u63a8\u8350\u7801';",
|
||||||
|
"const title = v.title || this.$lang('%stitle');" % BASE)
|
||||||
|
s = s.replace("ctx.fillText('\u5206\u4eab\u7ed9\u597d\u53cb,\u9080\u8bf7\u5165\u9a7b\u83b7\u5f97\u5956\u52b1', 24, 70);",
|
||||||
|
"ctx.fillText(this.$lang('%sposterShareInvite'), 24, 70);" % BASE)
|
||||||
|
s = s.replace("ctx.fillText('\u94fe\u63a5:' + (linkText || '(\u672a\u751f\u6210)'), 24, urlY);",
|
||||||
|
"ctx.fillText(this.$lang('%sposterLinkPrefix') + (linkText || this.$lang('%sposterLinkEmpty')), 24, urlY);" % (BASE, BASE))
|
||||||
|
s = s.replace("ctx.fillText(v.posterQrTip || '\u626b\u7801\u8fdb\u5165\u5e76\u586b\u5199\u5165\u9a7b\u4fe1\u606f', W / 2, qrY + qrSize + 24);",
|
||||||
|
"ctx.fillText(v.posterQrTip || this.$lang('%sposterQrTip'), W / 2, qrY + qrSize + 24);" % BASE)
|
||||||
|
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(s)
|
||||||
|
print("done")
|
||||||
@@ -121,7 +121,7 @@
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
"pages_tool/ai-chat/index",
|
"pages_tool/ai-chat/index",
|
||||||
"AI智能"
|
"AI智能客服"
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"pages_tool/contact/contact",
|
"pages_tool/contact/contact",
|
||||||
|
|||||||
639
scripts/pack-harmony-project.js
Normal file
639
scripts/pack-harmony-project.js
Normal file
@@ -0,0 +1,639 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* pack-harmony-project.js
|
||||||
|
*
|
||||||
|
* 把 stub-app-projects/mp-harmony 中「编译所需」的文件导出为一个 zip,
|
||||||
|
* 供其他电脑解压后直接用 DevEco Studio 编译并上传产物。
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* node scripts/pack-harmony-project.js # 默认: 含证书, 不含 node_modules
|
||||||
|
* node scripts/pack-harmony-project.js --with-node-modules # 额外含 hvigor/node_modules (约 320MB), 供离线机器
|
||||||
|
* node scripts/pack-harmony-project.js --no-certs # 不含签名证书
|
||||||
|
* node scripts/pack-harmony-project.js --out <dir> # 自定义输出目录 (默认 unpackage/dist/harmony-export)
|
||||||
|
*
|
||||||
|
* 设计原则:
|
||||||
|
* - **只读**源目录, 全程不修改 stub-app-projects 下任何文件 (产物先复制到暂存目录再压缩)
|
||||||
|
* - 采用「全量复制 - 排除清单」策略, 而不是白名单, 避免漏掉新增的必需文件
|
||||||
|
* - 输出 zip 内含一层 mp-harmony/ 目录, 解压后不会散落一地
|
||||||
|
*
|
||||||
|
* 为什么必须包含 cert-profile:
|
||||||
|
* build-profile.json5 的 signingConfigs 引用了 ./cert-profile/qq/ 下的
|
||||||
|
* qq.p12 / qqDebug.p7b / qq.cer。证书缺失时 hvigor 无法签名, 产不出可上传的 .app。
|
||||||
|
* ⚠️ 默认包含意味着 zip 内含私钥, 仅在可信渠道传递。
|
||||||
|
*
|
||||||
|
* 为什么包含 ascf/ascf_src:
|
||||||
|
* 它是 uniapp 构建产物 (由 vue.config.js 的 _copyMpHarmonyBuild 从
|
||||||
|
* unpackage/dist/build/.mp-harmony 拷入), 装着真正的页面/组件代码。
|
||||||
|
* 带上它, 目标机就不需要本仓库和 HBuilderX, 解压即可编译。
|
||||||
|
* ⚠️ 它是导出时刻的快照; 若前端代码有更新, 需重新执行本脚本。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const fsp = fs.promises;
|
||||||
|
const path = require('path');
|
||||||
|
const { exec } = require('child_process');
|
||||||
|
const { promisify } = require('util');
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
const PROJECT_DIR_NAME = 'mp-harmony';
|
||||||
|
const DEFAULT_OUT_DIR = path.join('unpackage', 'dist', 'harmony-export');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 目录名黑名单 —— 出现在任意层级都跳过
|
||||||
|
*
|
||||||
|
* - node_modules / oh_modules: 依赖, 可由 DevEco 打开工程时自动拉取
|
||||||
|
* (hvigor/node_modules 约 320MB, 不可能进包; 需要离线机时用 --with-node-modules)
|
||||||
|
* - build: 各 module 的编译产物
|
||||||
|
* - .preview: DevEco 预览缓存 (entry 下就占 22MB)
|
||||||
|
* - .hvigor / .idea: IDE 本地状态
|
||||||
|
* - .cxx / .test: 原生编译缓存与测试产物
|
||||||
|
*/
|
||||||
|
const EXCLUDE_DIRS = new Set([
|
||||||
|
'node_modules', 'oh_modules', 'build', '.preview', '.hvigor', '.idea',
|
||||||
|
'.cxx', '.test', '.git', '.appanalyzer', '.clangd', '.kotlin',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件名黑名单 —— 出现在任意层级都跳过
|
||||||
|
*
|
||||||
|
* - local.properties: DevEco 自动生成, 内含本机 SDK 路径
|
||||||
|
* - _1.clang-format / _1.gitignore: macOS Finder 产生的重名副本 (Windows 上是 _1, macOS 是 _副本)
|
||||||
|
*/
|
||||||
|
const EXCLUDE_FILES = new Set([
|
||||||
|
'local.properties', '.DS_Store', 'Thumbs.db', '_1.clang-format', '_1.gitignore',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** 含私钥的目录, --no-certs 时跳过 */
|
||||||
|
const CERT_DIR = 'cert-profile';
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const opts = {
|
||||||
|
withNodeModules: false,
|
||||||
|
withCerts: true,
|
||||||
|
outDir: null,
|
||||||
|
};
|
||||||
|
for (const arg of argv) {
|
||||||
|
if (arg === '--with-node-modules') opts.withNodeModules = true;
|
||||||
|
else if (arg === '--no-certs') opts.withCerts = false;
|
||||||
|
else if (arg === '--out') { /* 由调用方给出下一项 */ }
|
||||||
|
}
|
||||||
|
const outIdx = argv.indexOf('--out');
|
||||||
|
if (outIdx !== -1 && outIdx + 1 < argv.length) {
|
||||||
|
opts.outDir = path.resolve(argv[outIdx + 1]);
|
||||||
|
}
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 该目录是否需要跳过 */
|
||||||
|
function shouldSkipDir(name, opts, isRootLevel) {
|
||||||
|
if (EXCLUDE_DIRS.has(name)) {
|
||||||
|
// node_modules 只有根级 hvigor/node_modules 需要可选保留;
|
||||||
|
// oh_modules / entry/node_modules 等一律跳过 (体积小, DevEco 会重建)
|
||||||
|
if (opts.withNodeModules && name === 'node_modules' && isRootLevel) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!opts.withCerts && name === CERT_DIR) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldSkipFile(name) {
|
||||||
|
if (EXCLUDE_FILES.has(name)) return true;
|
||||||
|
// 编译/日志类临时文件
|
||||||
|
return /\.(log|tmp|swp)$/.test(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = { files: 0, bytes: 0, skipped: 0, copied: [] };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复制源目录到暂存目录 (应用排除规则), 同时记录每个被复制文件在 zip 中应有的条目名
|
||||||
|
* @param {string} srcDir
|
||||||
|
* @param {string} destDir
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {string} relBase 该目录在 zip 中的条目名前缀 (首层传 PROJECT_DIR_NAME)
|
||||||
|
* @param {number} depth
|
||||||
|
*/
|
||||||
|
async function copyDir(srcDir, destDir, opts, relBase = PROJECT_DIR_NAME, depth = 0) {
|
||||||
|
await fsp.mkdir(destDir, { recursive: true });
|
||||||
|
const entries = await fsp.readdir(srcDir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
const srcPath = path.join(srcDir, entry.name);
|
||||||
|
const destPath = path.join(destDir, entry.name);
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (shouldSkipDir(entry.name, opts, depth === 0)) {
|
||||||
|
stats.skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await copyDir(srcPath, destPath, opts, `${relBase}/${entry.name}`, depth + 1);
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
if (shouldSkipFile(entry.name)) {
|
||||||
|
stats.skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await fsp.copyFile(srcPath, destPath);
|
||||||
|
const st = await fsp.stat(destPath);
|
||||||
|
stats.files++;
|
||||||
|
stats.bytes += st.size;
|
||||||
|
stats.copied.push(`${relBase}/${entry.name}`);
|
||||||
|
}
|
||||||
|
// 符号链接等其它类型直接跳过
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
* ZIP 写入 (纯 Node, 零依赖)
|
||||||
|
*
|
||||||
|
* 为什么要自己写:
|
||||||
|
* 本机两种系统方案都产出**不合规**的 zip, 且失败方式是静默的:
|
||||||
|
* 1. PowerShell Compress-Archive / [ZipFile]::CreateFromDirectory
|
||||||
|
* 写入的条目名用**反斜杠** (mp-harmony\AppScope\app.json5)。ZIP 规范要求
|
||||||
|
* 正斜杠; Windows 解压侥幸能过, macOS/Linux 会把 `\` 当字面量, 解出畸形路径。
|
||||||
|
* 2. 本机 tar 是 GNU tar 1.35, **不支持创建 zip** (`-a` 仅对 tar 归档生效),
|
||||||
|
* 而且 exit 0, try/catch 拦不住, 会产出无法解压的 .zip。
|
||||||
|
* 另外 .NET 方案还把中文文件名写成 ANSI 编码 (导出说明.txt -> 乱码), 因为没置
|
||||||
|
* UTF-8 标志位 (general purpose bit 11)。
|
||||||
|
*
|
||||||
|
* 自己写则能保证: 条目名统一正斜杠 + UTF-8 标志位恒置 + 跨平台字节一致。
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
const zlib = require('zlib');
|
||||||
|
|
||||||
|
/** CRC32 查表 (标准多项式 0xEDB88320) */
|
||||||
|
const CRC_TABLE = (() => {
|
||||||
|
const table = new Int32Array(256);
|
||||||
|
for (let n = 0; n < 256; n++) {
|
||||||
|
let c = n;
|
||||||
|
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||||
|
table[n] = c;
|
||||||
|
}
|
||||||
|
return table;
|
||||||
|
})();
|
||||||
|
|
||||||
|
function crc32(buf) {
|
||||||
|
let c = -1;
|
||||||
|
for (let i = 0; i < buf.length; i++) c = (c >>> 8) ^ CRC_TABLE[(c ^ buf[i]) & 0xff];
|
||||||
|
return (c ^ -1) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JS Date -> DOS 日期/时间 (ZIP 格式要求, 精度到 2 秒) */
|
||||||
|
function toDosDateTime(date) {
|
||||||
|
return {
|
||||||
|
time: ((date.getHours() & 0x1f) << 11) | ((date.getMinutes() & 0x3f) << 5) | ((date.getSeconds() / 2) & 0x1f),
|
||||||
|
date: (((date.getFullYear() - 1980) & 0x7f) << 9) | (((date.getMonth() + 1) & 0xf) << 5) | (date.getDate() & 0x1f),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文件头 4 字节 */
|
||||||
|
const SIG_LOCAL = 0x04034b50;
|
||||||
|
const SIG_CENTRAL = 0x02014b50;
|
||||||
|
const SIG_EOCD = 0x06054b50;
|
||||||
|
/** 通用标志位: bit11 = 文件名/注释为 UTF-8 */
|
||||||
|
const FLAG_UTF8 = 0x0800;
|
||||||
|
/** 单文件超过 4GB 需要 ZIP64, 本工程产物远小于此, 直接拒绝而不是产出坏包 */
|
||||||
|
const MAX_FILE_SIZE = 0xffffffff;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 dirToZip 整个目录压成 zipPath, 顶层保留目录名
|
||||||
|
* @returns {Promise<{files:number, bytes:number}>}
|
||||||
|
*/
|
||||||
|
async function zipDirectory(dirToZip, zipPath) {
|
||||||
|
const rootName = path.basename(dirToZip);
|
||||||
|
const rootAbs = path.resolve(dirToZip);
|
||||||
|
|
||||||
|
// 收集文件与目录, 路径统一转正斜杠
|
||||||
|
const fileEntries = [];
|
||||||
|
const dirEntries = [];
|
||||||
|
|
||||||
|
// ⚠️ 必须 await: 不 await 的话 zip 会只写入部分条目就收尾,
|
||||||
|
// 且递归 walk 会在暂存目录被清理后继续跑并抛 ENOENT
|
||||||
|
await (async function walk(absDir, relBase) {
|
||||||
|
const items = await fsp.readdir(absDir, { withFileTypes: true });
|
||||||
|
for (const item of items) {
|
||||||
|
const abs = path.join(absDir, item.name);
|
||||||
|
const rel = relBase ? `${relBase}/${item.name}` : item.name;
|
||||||
|
if (item.isDirectory()) {
|
||||||
|
dirEntries.push(rel);
|
||||||
|
await walk(abs, rel);
|
||||||
|
} else if (item.isFile()) {
|
||||||
|
fileEntries.push({ abs, rel });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})(rootAbs, rootName);
|
||||||
|
|
||||||
|
const { time, date } = toDosDateTime(new Date());
|
||||||
|
const chunks = [];
|
||||||
|
const central = [];
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
const pushLocalHeader = (nameBuf, method, crc, compSize, uncompSize) => {
|
||||||
|
// ⚠️ 必须在累加前取值: 中央目录记录的 localOffset 指向**本地文件头的起始**,
|
||||||
|
// 不是紧随其后的数据区
|
||||||
|
const localOffset = offset;
|
||||||
|
const h = Buffer.alloc(30);
|
||||||
|
h.writeUInt32LE(SIG_LOCAL, 0);
|
||||||
|
h.writeUInt16LE(20, 4); // version needed
|
||||||
|
h.writeUInt16LE(FLAG_UTF8, 6); // 标志位: UTF-8 文件名
|
||||||
|
h.writeUInt16LE(method, 8);
|
||||||
|
h.writeUInt16LE(time, 10);
|
||||||
|
h.writeUInt16LE(date, 12);
|
||||||
|
h.writeUInt32LE(crc, 14);
|
||||||
|
h.writeUInt32LE(compSize, 18);
|
||||||
|
h.writeUInt32LE(uncompSize, 22);
|
||||||
|
h.writeUInt16LE(nameBuf.length, 26);
|
||||||
|
h.writeUInt16LE(0, 28); // extra len
|
||||||
|
chunks.push(h, nameBuf);
|
||||||
|
offset += h.length + nameBuf.length;
|
||||||
|
return localOffset;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pushCentralHeader = (nameBuf, method, crc, compSize, uncompSize, localOffset) => {
|
||||||
|
const c = Buffer.alloc(46);
|
||||||
|
c.writeUInt32LE(SIG_CENTRAL, 0);
|
||||||
|
c.writeUInt16LE(20, 4); // version made by
|
||||||
|
c.writeUInt16LE(20, 6); // version needed
|
||||||
|
c.writeUInt16LE(FLAG_UTF8, 8);
|
||||||
|
c.writeUInt16LE(method, 10);
|
||||||
|
c.writeUInt16LE(time, 12);
|
||||||
|
c.writeUInt16LE(date, 14);
|
||||||
|
c.writeUInt32LE(crc, 16);
|
||||||
|
c.writeUInt32LE(compSize, 20);
|
||||||
|
c.writeUInt32LE(uncompSize, 24);
|
||||||
|
c.writeUInt16LE(nameBuf.length, 28);
|
||||||
|
c.writeUInt16LE(0, 30); // extra len
|
||||||
|
c.writeUInt16LE(0, 32); // comment len
|
||||||
|
c.writeUInt16LE(0, 34); // disk number
|
||||||
|
c.writeUInt16LE(0, 36); // internal attrs
|
||||||
|
c.writeUInt32LE(0, 38); // external attrs
|
||||||
|
c.writeUInt32LE(localOffset, 42);
|
||||||
|
central.push(c, nameBuf);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 目录条目 (以 / 结尾), 保证空目录也能保留
|
||||||
|
for (const rel of dirEntries) {
|
||||||
|
const nameBuf = Buffer.from(`${rel}/`, 'utf8');
|
||||||
|
const localOffset = pushLocalHeader(nameBuf, 0, 0, 0, 0);
|
||||||
|
pushCentralHeader(nameBuf, 0, 0, 0, 0, localOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalBytes = 0;
|
||||||
|
for (const f of fileEntries) {
|
||||||
|
const data = await fsp.readFile(f.abs);
|
||||||
|
if (data.length > MAX_FILE_SIZE) {
|
||||||
|
throw new Error(`文件超过 4GB, 需要 ZIP64 支持: ${f.rel} (${data.length} 字节)`);
|
||||||
|
}
|
||||||
|
const crc = crc32(data);
|
||||||
|
// 先试 deflate, 若压缩后反而更大则退回 stored
|
||||||
|
const deflated = zlib.deflateRawSync(data, { level: 9 });
|
||||||
|
const useDeflate = deflated.length < data.length;
|
||||||
|
const payload = useDeflate ? deflated : data;
|
||||||
|
const method = useDeflate ? 8 : 0;
|
||||||
|
|
||||||
|
const nameBuf = Buffer.from(f.rel, 'utf8');
|
||||||
|
const localOffset = pushLocalHeader(nameBuf, method, crc, payload.length, data.length);
|
||||||
|
chunks.push(payload);
|
||||||
|
// ⚠️ 必须把文件数据长度计入 offset, 否则 EOCD 里的中央目录偏移会偏小,
|
||||||
|
// 解压工具会报 "N extra bytes at beginning or within zipfile"
|
||||||
|
offset += payload.length;
|
||||||
|
pushCentralHeader(nameBuf, method, crc, payload.length, data.length, localOffset);
|
||||||
|
totalBytes += data.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// EOCD
|
||||||
|
const centralBuf = Buffer.concat(central);
|
||||||
|
const eocd = Buffer.alloc(22);
|
||||||
|
eocd.writeUInt32LE(SIG_EOCD, 0);
|
||||||
|
eocd.writeUInt16LE(0, 4);
|
||||||
|
eocd.writeUInt16LE(0, 6);
|
||||||
|
eocd.writeUInt16LE(fileEntries.length + dirEntries.length, 8);
|
||||||
|
eocd.writeUInt16LE(fileEntries.length + dirEntries.length, 10);
|
||||||
|
eocd.writeUInt32LE(centralBuf.length, 12);
|
||||||
|
eocd.writeUInt32LE(offset, 16);
|
||||||
|
eocd.writeUInt16LE(0, 20);
|
||||||
|
|
||||||
|
await fsp.writeFile(zipPath, Buffer.concat([...chunks, centralBuf, eocd]));
|
||||||
|
return { files: fileEntries.length, dirs: dirEntries.length, bytes: totalBytes };
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanSize(bytes) {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(2)} MiB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
* 产出后自检
|
||||||
|
*
|
||||||
|
* 手写 ZIP 编码器最容易出的两类 bug 都是**静默**的:
|
||||||
|
* - 偏移算错 -> 文件内容对但部分条目解不出来 (不校验就发现不了)
|
||||||
|
* - 标志位/分隔符写错 -> Windows 能解开, macOS/Linux 解出畸形路径
|
||||||
|
* 所以每次导出后都把 zip 重新解析一遍, 任何一项不符就判失败, 不输出「完成」。
|
||||||
|
*
|
||||||
|
* 刻意不复用写入端的逻辑: 这里用独立的解析实现, 避免自证其说。
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} zipPath
|
||||||
|
* @param {{withCerts?: boolean, skipRequired?: boolean, expectFiles?: string[], rootName?: string}} opts
|
||||||
|
* skipRequired: 仅供单元测试用夹具目录时跳过"必需文件"断言
|
||||||
|
* expectFiles: 实际复制到暂存目录的文件清单 (zip 内条目名), 用于零漏拷/零多拷比对
|
||||||
|
* rootName: zip 内顶层目录名 (默认 mp-harmony)
|
||||||
|
* @returns {Promise<{entries:number, files:number, dirs:number}>}
|
||||||
|
* @throws {Error} 任一项校验不通过
|
||||||
|
*/
|
||||||
|
async function verifyZipFile(zipPath, opts = {}) {
|
||||||
|
const rootName = opts.rootName || PROJECT_DIR_NAME;
|
||||||
|
const buf = await fsp.readFile(zipPath);
|
||||||
|
const fail = (msg) => { throw new Error(`zip 自检失败: ${msg}`); };
|
||||||
|
|
||||||
|
// --- 定位并校验 EOCD (从尾部往前扫, 兼容可能存在的注释) ---
|
||||||
|
const EOCD_MIN = 22;
|
||||||
|
if (buf.length < EOCD_MIN) fail('文件小于 EOCD 最小长度');
|
||||||
|
let eocd = -1;
|
||||||
|
for (let i = buf.length - EOCD_MIN; i >= 0; i--) {
|
||||||
|
if (buf.readUInt32LE(i) === SIG_EOCD) { eocd = i; break; }
|
||||||
|
}
|
||||||
|
if (eocd === -1) fail('找不到 EOCD 结尾记录');
|
||||||
|
|
||||||
|
const totalEntries = buf.readUInt16LE(eocd + 10);
|
||||||
|
const centralSize = buf.readUInt32LE(eocd + 12);
|
||||||
|
const centralOffset = buf.readUInt32LE(eocd + 16);
|
||||||
|
|
||||||
|
if (centralOffset + centralSize > buf.length) {
|
||||||
|
fail(`中央目录越界 (offset=${centralOffset} size=${centralSize} 文件长=${buf.length})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 逐条遍历中央目录 ---
|
||||||
|
const names = [];
|
||||||
|
let p = centralOffset;
|
||||||
|
for (let idx = 0; idx < totalEntries; idx++) {
|
||||||
|
if (p + 46 > buf.length) fail(`第 ${idx} 条中央目录记录越界`);
|
||||||
|
if (buf.readUInt32LE(p) !== SIG_CENTRAL) fail(`第 ${idx} 条中央目录签名错误`);
|
||||||
|
|
||||||
|
const flags = buf.readUInt16LE(p + 8);
|
||||||
|
const method = buf.readUInt16LE(p + 10);
|
||||||
|
const crc = buf.readUInt32LE(p + 16);
|
||||||
|
const compSize = buf.readUInt32LE(p + 20);
|
||||||
|
const uncompSize = buf.readUInt32LE(p + 24);
|
||||||
|
const nameLen = buf.readUInt16LE(p + 28);
|
||||||
|
const extraLen = buf.readUInt16LE(p + 30);
|
||||||
|
const commentLen = buf.readUInt16LE(p + 32);
|
||||||
|
const localOffset = buf.readUInt32LE(p + 42);
|
||||||
|
const nameBuf = buf.subarray(p + 46, p + 46 + nameLen);
|
||||||
|
|
||||||
|
// 名称合法性: 必须正斜杠 + 合法 UTF-8 + 与 UTF-8 标志位一致
|
||||||
|
if (!(flags & FLAG_UTF8)) fail(`第 ${idx} 条缺少 UTF-8 标志位: ${nameBuf.toString('latin1')}`);
|
||||||
|
const name = nameBuf.toString('utf8');
|
||||||
|
if (name.includes('\\')) fail(`条目名含反斜杠 (非 ZIP 规范): ${name}`);
|
||||||
|
if (!Buffer.from(name, 'utf8').equals(nameBuf)) fail(`条目名不是合法 UTF-8: ${nameBuf.toString('hex')}`);
|
||||||
|
names.push(name);
|
||||||
|
|
||||||
|
// 本地文件头: 偏移必须真的指向一个本地头, 且文件名与中央目录一致
|
||||||
|
if (localOffset + 30 > buf.length) fail(`第 ${idx} 条 localOffset 越界: ${localOffset}`);
|
||||||
|
if (buf.readUInt32LE(localOffset) !== SIG_LOCAL) fail(`第 ${idx} 条 localOffset 未指向本地文件头: ${localOffset}`);
|
||||||
|
const lNameLen = buf.readUInt16LE(localOffset + 26);
|
||||||
|
const lExtraLen = buf.readUInt16LE(localOffset + 28);
|
||||||
|
const localName = buf.subarray(localOffset + 30, localOffset + 30 + lNameLen);
|
||||||
|
if (!localName.equals(nameBuf)) fail(`第 ${idx} 条本地头与中央目录文件名不一致`);
|
||||||
|
|
||||||
|
// 数据区: 解压后长度与 CRC 必须对得上 (目录条目除外)
|
||||||
|
if (!name.endsWith('/')) {
|
||||||
|
const dataStart = localOffset + 30 + lNameLen + lExtraLen;
|
||||||
|
const raw = buf.subarray(dataStart, dataStart + compSize);
|
||||||
|
if (raw.length !== compSize) fail(`第 ${idx} 条数据区越界`);
|
||||||
|
let data;
|
||||||
|
if (method === 0) data = raw;
|
||||||
|
else if (method === 8) data = zlib.inflateRawSync(raw);
|
||||||
|
else fail(`第 ${idx} 条压缩方式不支持: ${method}`);
|
||||||
|
if (data.length !== uncompSize) fail(`第 ${idx} 条解压后长度不符 (${data.length} != ${uncompSize})`);
|
||||||
|
if (crc32(data) !== crc) fail(`第 ${idx} 条 CRC 校验失败`);
|
||||||
|
}
|
||||||
|
|
||||||
|
p += 46 + nameLen + extraLen + commentLen;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p - centralOffset !== centralSize) {
|
||||||
|
fail(`中央目录实际长度 ${p - centralOffset} != EOCD 记录 ${centralSize}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 内容断言 ---
|
||||||
|
if (!opts.skipRequired) {
|
||||||
|
const required = [
|
||||||
|
'mp-harmony/AppScope/app.json5',
|
||||||
|
'mp-harmony/build-profile.json5',
|
||||||
|
'mp-harmony/oh-package.json5',
|
||||||
|
'mp-harmony/hvigor/hvigor-config.json5',
|
||||||
|
'mp-harmony/entry/src/main/module.json5',
|
||||||
|
'mp-harmony/entry/oh-package.json5',
|
||||||
|
'mp-harmony/ascf/ascf_src/app.js',
|
||||||
|
];
|
||||||
|
if (opts.withCerts !== false) {
|
||||||
|
required.push('mp-harmony/cert-profile/qq/qq.p12', 'mp-harmony/cert-profile/qq/qqDebug.p7b');
|
||||||
|
}
|
||||||
|
const missing = required.filter((r) => !names.includes(r));
|
||||||
|
if (missing.length) fail(`缺少构建必需文件: ${missing.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按**路径分段**判断, 不用子串 —— 否则 .test 会误伤 entry/src/ohosTest/ets/test/
|
||||||
|
// 这类合法源码文件 (.test 排除的是名为 .test 的目录, 不是任何含 .test 的路径)
|
||||||
|
const dirSegmentsOf = (n) => n.split('/').slice(0, -1);
|
||||||
|
const fileNameOf = (n) => n.split('/').pop();
|
||||||
|
const leaked = names.filter((n) => dirSegmentsOf(n).some((s) => EXCLUDE_DIRS.has(s)));
|
||||||
|
if (leaked.length) {
|
||||||
|
fail(`混入了应排除的目录: ${leaked.slice(0, 3).join(', ')}${leaked.length > 3 ? ` ...共 ${leaked.length} 条` : ''}`);
|
||||||
|
}
|
||||||
|
const leakedFiles = names.filter((n) => !n.endsWith('/') && EXCLUDE_FILES.has(fileNameOf(n)));
|
||||||
|
if (leakedFiles.length) {
|
||||||
|
fail(`混入了应排除的文件: ${leakedFiles.slice(0, 3).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.withCerts === false && names.some((n) => n.includes(CERT_DIR))) {
|
||||||
|
fail('--no-certs 生效异常: zip 内仍存在 cert-profile');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileCount = names.filter((n) => !n.endsWith('/')).length;
|
||||||
|
|
||||||
|
// --- 零漏拷 / 零多拷 比对 ---
|
||||||
|
// expectFiles 是 copyDir 实际复制到暂存目录的文件清单 (已应用排除规则)。
|
||||||
|
// 逐个核对 zip 内是否都有, 且 zip 内没有清单之外的文件 (README.txt 是后补的, 除外)。
|
||||||
|
// 这样"某个目录没被打进去"这类问题会被脚本当场拦下, 不必人工逐目录核对。
|
||||||
|
if (Array.isArray(opts.expectFiles)) {
|
||||||
|
const nameSet = new Set(names);
|
||||||
|
const missingFiles = opts.expectFiles.filter((f) => !nameSet.has(f));
|
||||||
|
if (missingFiles.length) {
|
||||||
|
fail(`漏拷: ${missingFiles.length} 个已复制的文件不在 zip 内, 例如 ${missingFiles.slice(0, 5).join(', ')}`);
|
||||||
|
}
|
||||||
|
const expected = new Set(opts.expectFiles);
|
||||||
|
expected.add(`${rootName}/README.txt`);
|
||||||
|
const unexpected = names.filter((n) => !n.endsWith('/') && !expected.has(n));
|
||||||
|
if (unexpected.length) {
|
||||||
|
fail(`多拷: ${unexpected.length} 个 zip 内文件不在复制清单中, 例如 ${unexpected.slice(0, 5).join(', ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
entries: totalEntries,
|
||||||
|
files: fileCount,
|
||||||
|
dirs: totalEntries - fileCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 校验暂存目录里构建必需的骨架文件都在 */
|
||||||
|
async function verifySkeleton(dir) {
|
||||||
|
const required = [
|
||||||
|
'AppScope/app.json5',
|
||||||
|
'build-profile.json5',
|
||||||
|
'oh-package.json5',
|
||||||
|
'hvigor/hvigor-config.json5',
|
||||||
|
'entry/src/main/module.json5',
|
||||||
|
'entry/oh-package.json5',
|
||||||
|
'ascf/ascf_src/app.js',
|
||||||
|
];
|
||||||
|
const missing = [];
|
||||||
|
for (const rel of required) {
|
||||||
|
if (!fs.existsSync(path.join(dir, rel))) missing.push(rel);
|
||||||
|
}
|
||||||
|
return missing;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const opts = parseArgs(process.argv.slice(2));
|
||||||
|
const repoRoot = path.join(__dirname, '..');
|
||||||
|
const srcRoot = path.join(repoRoot, 'stub-app-projects', PROJECT_DIR_NAME);
|
||||||
|
const outDir = opts.outDir || path.join(repoRoot, DEFAULT_OUT_DIR);
|
||||||
|
|
||||||
|
if (!fs.existsSync(srcRoot)) {
|
||||||
|
console.error(`❌ 源目录不存在: ${srcRoot}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('📦 打包鸿蒙工程 (仅读取源目录, 不做任何修改)');
|
||||||
|
console.log(` 源目录: ${path.relative(repoRoot, srcRoot)}`);
|
||||||
|
console.log(` 含证书: ${opts.withCerts ? '是 ⚠️ 包含私钥' : '否'}`);
|
||||||
|
console.log(` 含 node_modules: ${opts.withNodeModules ? '是 (体积大)' : '否 (由 DevEco 自动拉取)'}`);
|
||||||
|
|
||||||
|
// 暂存目录: 与源目录物理隔离, 保证源目录零改动
|
||||||
|
const stageParent = path.join(outDir, '.stage');
|
||||||
|
const stageDir = path.join(stageParent, PROJECT_DIR_NAME);
|
||||||
|
await fsp.rm(stageDir, { recursive: true, force: true });
|
||||||
|
await fsp.mkdir(stageDir, { recursive: true });
|
||||||
|
|
||||||
|
const t0 = Date.now();
|
||||||
|
// stats 是模块级累加器, 同一进程内重复调用 main 时需先清零, 否则体积/数量会翻倍
|
||||||
|
stats.files = 0;
|
||||||
|
stats.bytes = 0;
|
||||||
|
stats.skipped = 0;
|
||||||
|
stats.copied = [];
|
||||||
|
await copyDir(srcRoot, stageDir, opts);
|
||||||
|
console.log(`\n 复制完成: ${stats.files} 个文件 / ${humanSize(stats.bytes)} (跳过 ${stats.skipped} 项, ${Date.now() - t0}ms)`);
|
||||||
|
|
||||||
|
// 写入说明文件
|
||||||
|
const readme = [
|
||||||
|
'# 鸿蒙元服务工程包',
|
||||||
|
'',
|
||||||
|
`导出时间: ${new Date().toLocaleString('zh-CN')}`,
|
||||||
|
`包含签名证书: ${opts.withCerts ? '是 (cert-profile/, 含私钥, 请妥善保管)' : '否'}`,
|
||||||
|
'',
|
||||||
|
'## 在目标电脑上编译',
|
||||||
|
'',
|
||||||
|
'1. 安装 DevEco Studio 5.0.x (本工程 compatibleSdkVersion 5.0.0(12) / targetSdkVersion 5.0.0(12))',
|
||||||
|
'2. 解压本 zip, 得到 mp-harmony/ 目录',
|
||||||
|
'3. 用 DevEco Studio 「打开」该 mp-harmony 目录 (不是其父目录)',
|
||||||
|
'4. 首次打开会自动执行依赖同步 (ohpm install 拉取 hvigor/node_modules), 需联网',
|
||||||
|
` ${opts.withNodeModules ? '' : ' 若目标机离线, 请在源机器用 --with-node-modules 重新导出\n'}`,
|
||||||
|
'5. 菜单「Build」→「Build App(s)/Project(s)」, 等价于:',
|
||||||
|
' hvigorw --mode project -p product=default assembleApp',
|
||||||
|
'6. 产物: build/outputs/default/mp-harmony-default-signed.app',
|
||||||
|
'7. 上传 .app 到 AppGallery Connect (元服务上传的是 .app, 不是 .hap)',
|
||||||
|
'',
|
||||||
|
'## 注意事项',
|
||||||
|
'',
|
||||||
|
'- **ascf/ascf_src 是导出时刻的快照**, 内含 uniapp 编译产物 (页面/组件代码)。',
|
||||||
|
' 若前端代码有更新, 需回到源机器重新执行本脚本导出, 否则编译出的包内容是旧的。',
|
||||||
|
opts.withCerts
|
||||||
|
? '- **本包含签名私钥** (cert-profile/qq/qq.p12)。请仅在可信渠道传递, 用完及时销毁或归档。'
|
||||||
|
: '- **本包不含签名证书**。缺证书 hvigor 无法签名, 产不出可上传的 .app。\n 请把源机器的 cert-profile/ 目录放到 mp-harmony/ 下再编译。',
|
||||||
|
'- hvigor/node_modules 未包含时由 DevEco 自动拉取; 联网失败会导致依赖同步失败。',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
// 文件名用 ASCII: 虽然本脚本写入的条目名是标准 UTF-8 且已置 bit11 标志位,
|
||||||
|
// 但个别解压工具 (如 Windows 上的 MSYS unzip) 不认该标志位, 中文名会落盘成乱码。
|
||||||
|
// 说明文档正文保持中文, 文件名用 README.txt 兼容所有工具。
|
||||||
|
await fsp.writeFile(path.join(stageDir, 'README.txt'), readme, 'utf8');
|
||||||
|
|
||||||
|
// 校验骨架完整性
|
||||||
|
const missing = await verifySkeleton(stageDir);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
console.error('\n❌ 导出包缺少构建必需文件:');
|
||||||
|
missing.forEach((m) => console.error(` - ${m}`));
|
||||||
|
console.error(' 请检查源目录是否完整 (是否执行过 git clean?)');
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!opts.withCerts && fs.existsSync(path.join(stageDir, CERT_DIR))) {
|
||||||
|
console.error('❌ --no-certs 生效异常: 暂存目录仍存在 cert-profile');
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const certNote = opts.withCerts ? ' / 证书' : '';
|
||||||
|
console.log(` 骨架校验通过 (AppScope / build-profile / entry / ascf_src${certNote})`);
|
||||||
|
|
||||||
|
// 压缩
|
||||||
|
await fsp.mkdir(outDir, { recursive: true });
|
||||||
|
const now = new Date();
|
||||||
|
const ts = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}`;
|
||||||
|
let commitId = 'unknown';
|
||||||
|
try {
|
||||||
|
const { stdout } = await execAsync('git rev-parse --short HEAD', { cwd: repoRoot });
|
||||||
|
commitId = stdout.trim();
|
||||||
|
} catch (e) { /* 非 git 环境忽略 */ }
|
||||||
|
|
||||||
|
const zipPath = path.join(outDir, `${PROJECT_DIR_NAME}-project-${opts.withCerts ? '' : 'nocert-'}v${ts}-${commitId}.zip`);
|
||||||
|
await zipDirectory(stageDir, zipPath);
|
||||||
|
|
||||||
|
// 自检: 解析回 zip 逐项校验, 不通过就抛错 (绝不打印「完成」)
|
||||||
|
// expectFiles 传入实际复制清单, 让脚本自己证明"零漏拷", 无需人工逐目录核对
|
||||||
|
const vres = await verifyZipFile(zipPath, {
|
||||||
|
withCerts: opts.withCerts,
|
||||||
|
expectFiles: stats.copied,
|
||||||
|
rootName: PROJECT_DIR_NAME,
|
||||||
|
});
|
||||||
|
|
||||||
|
const zipSize = (await fsp.stat(zipPath)).size;
|
||||||
|
// 输出目录可能在仓库外 (如 --out /tmp/xxx), 这时相对路径会变成 ../.. 很难读
|
||||||
|
const relZip = path.relative(repoRoot, zipPath);
|
||||||
|
const shownZip = relZip.startsWith('..') ? zipPath : relZip;
|
||||||
|
console.log(`\n✅ 完成`);
|
||||||
|
console.log(` ZIP: ${shownZip}`);
|
||||||
|
console.log(` 大小: ${humanSize(zipSize)} (原始 ${humanSize(stats.bytes)})`);
|
||||||
|
console.log(` 条目: ${vres.files} 个文件 + ${vres.entries - vres.files} 个目录 (自检通过)`);
|
||||||
|
console.log(` 自检: 零漏拷 ✅(${stats.copied.length}/${stats.copied.length}) / CRC 逐条 ✅ / 偏移一致 ✅ / 正斜杠 ✅ / UTF-8 ✅ / 骨架齐全 ✅`);
|
||||||
|
|
||||||
|
// 清暂存目录
|
||||||
|
await fsp.rm(stageParent, { recursive: true, force: true });
|
||||||
|
|
||||||
|
if (opts.withCerts) {
|
||||||
|
console.log('\n⚠️ 本包含签名私钥 (cert-profile/qq/qq.p12), 请仅在可信渠道传递。');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出内部实现供单元测试使用; 作为命令行执行时才跑 main()
|
||||||
|
module.exports = {
|
||||||
|
crc32,
|
||||||
|
toDosDateTime,
|
||||||
|
zipDirectory,
|
||||||
|
verifyZipFile,
|
||||||
|
shouldSkipDir,
|
||||||
|
shouldSkipFile,
|
||||||
|
copyDir,
|
||||||
|
EXCLUDE_DIRS,
|
||||||
|
EXCLUDE_FILES,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('❌ 打包失败:', err.message);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import util from '../common/js/util.js'
|
|||||||
import ThemeOverride from '../common/js/theme-override.js'
|
import ThemeOverride from '../common/js/theme-override.js'
|
||||||
import { sanitizeFlatMap, mergeThemeBuckets } from '../common/js/merge-theme.js'
|
import { sanitizeFlatMap, mergeThemeBuckets } from '../common/js/merge-theme.js'
|
||||||
import PrivacyManager from '../common/js/privacy.js'
|
import PrivacyManager from '../common/js/privacy.js'
|
||||||
|
import LangConfig from '../common/js/lang.js'
|
||||||
|
|
||||||
// themeColor 结果缓存(避免每次都全量遍历 theme 对象)
|
// themeColor 结果缓存(避免每次都全量遍历 theme 对象)
|
||||||
// key: JSON.stringify(theme) + tabBarHeight, value: CSS变量字符串
|
// key: JSON.stringify(theme) + tabBarHeight, value: CSS变量字符串
|
||||||
@@ -97,6 +98,7 @@ const store = new Vuex.Store({
|
|||||||
wap_valid_time: 0,
|
wap_valid_time: 0,
|
||||||
},
|
},
|
||||||
copyright: null,
|
copyright: null,
|
||||||
|
floatingButtonConfig: null, // 来自 /api/config/init 的 data.floating_button (浮动侧边栏配置)
|
||||||
initStatus: false,
|
initStatus: false,
|
||||||
offlineWhiteList: ['pages_order/payment', 'pages_order/list', 'pages_order/detail'],//线下支付白名单
|
offlineWhiteList: ['pages_order/payment', 'pages_order/list', 'pages_order/detail'],//线下支付白名单
|
||||||
canReceiveRegistergiftInfo: {
|
canReceiveRegistergiftInfo: {
|
||||||
@@ -163,6 +165,23 @@ const store = new Vuex.Store({
|
|||||||
setShopInfo(state, value) {
|
setShopInfo(state, value) {
|
||||||
state.shopInfo = value;
|
state.shopInfo = value;
|
||||||
uni.setStorageSync('shopInfo', value); // 初始化数据调用
|
uni.setStorageSync('shopInfo', value); // 初始化数据调用
|
||||||
|
// 语言切换开关 fallback: 后台 shop_info.ischina 决定(老「双语模式」)。
|
||||||
|
// 新配置下发后由 setFloatingButtonConfig 里的 lang_switch.enabled 接管,
|
||||||
|
// 这里的 ischina 只是配置缺失(老缓存响应)时的兜底。
|
||||||
|
// 关闭时强制返回默认中文,并清掉用户此前持久化到 storage 的语种,
|
||||||
|
// 满足"后台关闭双语/小程序后,刷新重新进入必须是中文"的需求。
|
||||||
|
try { LangConfig.setLangSwitchEnabled(!!(value && value.ischina)); } catch (e) {}
|
||||||
|
},
|
||||||
|
// 浮动侧边栏配置 (来自 /api/config/init → data.floating_button)
|
||||||
|
setFloatingButtonConfig(state, value) {
|
||||||
|
state.floatingButtonConfig = value || null;
|
||||||
|
uni.setStorageSync('floatingButtonConfig', value || null);
|
||||||
|
// 与 setShopInfo 的 setLangSwitchEnabled 配对: 新配置存在时以新配置为准
|
||||||
|
try {
|
||||||
|
if (value && value.lang_switch) {
|
||||||
|
LangConfig.setLangSwitchEnabled(Number(value.lang_switch.enabled) === 1);
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
},
|
},
|
||||||
setCartChange(state) {
|
setCartChange(state) {
|
||||||
state.cartChange += 1;
|
state.cartChange += 1;
|
||||||
@@ -310,6 +329,8 @@ const store = new Vuex.Store({
|
|||||||
//联系我们
|
//联系我们
|
||||||
|
|
||||||
this.commit('setShopInfo', data.shop_info);
|
this.commit('setShopInfo', data.shop_info);
|
||||||
|
// 浮动侧边栏配置; 缺失时显式置 null → 前端走老行为兜底
|
||||||
|
this.commit('setFloatingButtonConfig', data.floating_button || null);
|
||||||
this.commit('setWechatConfigStatus', data?.wechat_config_status);
|
this.commit('setWechatConfigStatus', data?.wechat_config_status);
|
||||||
// 默认总店
|
// 默认总店
|
||||||
if (data.store_info) {
|
if (data.store_info) {
|
||||||
|
|||||||
@@ -1,4 +1,49 @@
|
|||||||
import { AscfUIAbility } from '@atomicservice/ascfapi';
|
import { abilityAccessCtrl, AbilityConstant, Permissions, Want } from '@kit.AbilityKit';
|
||||||
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
export default class EntryAbility extends AscfUIAbility {
|
import { AscfUIAbility } from '@atomicservice/ascfapi';
|
||||||
}
|
|
||||||
|
// ⚠️ Permissions 在 @ohos.permissions 里是**字符串联合类型**, 不是枚举,
|
||||||
|
// 只能用在类型位置 (如这里的标注), 不能当值引用 (Permissions.MICROPHONE 会报
|
||||||
|
// "'Permissions' only refers to a type, but is being used as a value here"),
|
||||||
|
// 所以权限名必须写字面量, 且要与 module.json5 中声明的一致。
|
||||||
|
// 另外别被 abilityAccessCtrl 里的 SwitchType 枚举误导, 它的 MICROPHONE=1 是
|
||||||
|
// 开关类型, 不是权限名。
|
||||||
|
const PERMISSION_MICROPHONE: Permissions = 'ohos.permission.MICROPHONE';
|
||||||
|
|
||||||
|
export default class EntryAbility extends AscfUIAbility {
|
||||||
|
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
|
||||||
|
// 必须先调父类: ascf runtime 的初始化在 AscfUIAbility.onCreate 里, 漏掉会导致元服务白屏
|
||||||
|
super.onCreate(want, launchParam);
|
||||||
|
this.requestMicrophonePermission();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 申请麦克风权限 (ohos.permission.MICROPHONE, user_grant 类型)。
|
||||||
|
*
|
||||||
|
* 为什么放在 onCreate 而不是"用户点录音时才申请":
|
||||||
|
* 1. 权限状态在 JS 侧 (uni.getRecorderManager) 读不到, 项目里也没有现成的
|
||||||
|
* JS <-> ArkTS 通道, 做不了按需触发的条件判断;
|
||||||
|
* 2. requestPermissionsFromUser 对已授权的应用直接 resolve 且不弹窗,
|
||||||
|
* 所以实际只有首次启动会打扰用户一次, 之后每次冷启动都是无感的。
|
||||||
|
*
|
||||||
|
* 未授权时的降级行为:
|
||||||
|
* - 不影响其他功能 (浏览/下单/卡片), 只是语音输入不可用;
|
||||||
|
* - 前端 ai-chat-voice-panel.vue 会 catch 到 recorder 错误并 showToast 提示用户。
|
||||||
|
*/
|
||||||
|
private async requestMicrophonePermission(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const atManager = abilityAccessCtrl.createAtManager();
|
||||||
|
const result = await atManager.requestPermissionsFromUser(this.context, [PERMISSION_MICROPHONE]);
|
||||||
|
const authResult = result.authResults[0];
|
||||||
|
if (authResult === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
|
||||||
|
console.info('[EntryAbility] MICROPHONE 已授权, 语音输入可用');
|
||||||
|
} else {
|
||||||
|
console.warn('[EntryAbility] MICROPHONE 未授权, 语音输入不可用 (不影响其他功能)');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// 用户取消授权弹窗 / context 异常, 同样降级为"语音不可用", 不阻断启动
|
||||||
|
const e = err as BusinessError;
|
||||||
|
console.warn(`[EntryAbility] 申请麦克风权限异常: code=${e.code}, message=${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -65,6 +65,18 @@
|
|||||||
"when": "always"
|
"when": "always"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 元服务录音需要麦克风权限
|
||||||
|
// user_grant 权限必须同时声明 reason 和 usedScene
|
||||||
|
"name": "ohos.permission.MICROPHONE",
|
||||||
|
"reason": "$string:permission_microphone_reason",
|
||||||
|
"usedScene": {
|
||||||
|
"abilities": [
|
||||||
|
"EntryAbility"
|
||||||
|
],
|
||||||
|
"when": "always"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "ohos.permission.VIBRATE"
|
"name": "ohos.permission.VIBRATE"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
"name": "module_desc",
|
"name": "module_desc",
|
||||||
"value": "module description"
|
"value": "module description"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "permission_microphone_reason",
|
||||||
|
"value": "用于语音搜索与语音输入功能,录制语音以识别用户需求"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "EntryAbility_desc",
|
"name": "EntryAbility_desc",
|
||||||
"value": "EntryAbility description"
|
"value": "EntryAbility description"
|
||||||
|
|||||||
186
tests/card-admin.test.js
Normal file
186
tests/card-admin.test.js
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
/**
|
||||||
|
* 名片云 · 商家端(admin)页面测试
|
||||||
|
*
|
||||||
|
* 背景:
|
||||||
|
* 商家端 7 个页面中, 后台接口先就绪的是 contact/* 与 lead/*(以及 card/lists),
|
||||||
|
* 因此本轮先上三个页面(其余 5 页等后台接口, 见 CARD_API_REFERENCE.md 缺口):
|
||||||
|
* - admin/manage 名片管理(基础版: 搜索/详情/编辑, 详情编辑复用员工端页)
|
||||||
|
* - admin/contacts 人脉管理(列表/搜索/来源筛选/编辑/转线索/删除)
|
||||||
|
* - admin/leads 潜在客户(状态筛选/列表/更新跟进状态)
|
||||||
|
* 本测试固化商家端契约: 注册/收口/鸿蒙安全导航/复用回跳。
|
||||||
|
*
|
||||||
|
* 跑法:
|
||||||
|
* node --test tests/card-admin.test.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '..');
|
||||||
|
const ADMIN_DIR = path.join(ROOT, 'pages_tool/card/admin');
|
||||||
|
const readAdmin = f => fs.readFileSync(path.join(ADMIN_DIR, f), 'utf8');
|
||||||
|
const readCard = f => fs.readFileSync(path.join(ROOT, 'pages_tool/card', f), 'utf8');
|
||||||
|
const pagesSrc = fs.readFileSync(path.join(ROOT, 'pages.json'), 'utf8');
|
||||||
|
const utilSrc = fs.readFileSync(path.join(ROOT, 'common/js/util.js'), 'utf8');
|
||||||
|
|
||||||
|
// pages.json 含 uni-app 合法 // 注释, 剥后解析
|
||||||
|
const stripped = pagesSrc.replace(/^\s*\/\/.*$/gm, '').replace(/,(\s*[}\]])/g, '$1');
|
||||||
|
const pagesJson = JSON.parse(stripped);
|
||||||
|
|
||||||
|
const ADMIN_PAGES = {
|
||||||
|
'card/admin/manage': '名片管理',
|
||||||
|
'card/admin/contacts': '人脉管理',
|
||||||
|
'card/admin/leads': '潜在客户'
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 取 pages_tool 分包已注册路径 */
|
||||||
|
function toolPaths() {
|
||||||
|
const sub = (pagesJson.subPackages || []).find(sp => sp.root === 'pages_tool');
|
||||||
|
assert.ok(sub, 'pages_tool 分包应存在');
|
||||||
|
return (sub.pages || []).map(p => p.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 1. 页面注册
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
test('注册: 商家端 3 页已注册进 pages.json 的 pages_tool 分包', () => {
|
||||||
|
const paths = toolPaths();
|
||||||
|
for (const p of Object.keys(ADMIN_PAGES)) {
|
||||||
|
assert.ok(paths.includes(p), `${p} 未注册: ${paths.join(',')}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('注册: 商家端页面标题正确且不在 TabBar', () => {
|
||||||
|
const sub = (pagesJson.subPackages || []).find(sp => sp.root === 'pages_tool');
|
||||||
|
for (const [p, title] of Object.entries(ADMIN_PAGES)) {
|
||||||
|
const page = (sub.pages || []).find(x => x.path === p);
|
||||||
|
assert.ok(page, `${p} 应存在`);
|
||||||
|
assert.equal(page.style.navigationBarTitleText, title, `${p} 标题应为「${title}」`);
|
||||||
|
// 名片云全部页面都不是 tabBar 根页
|
||||||
|
assert.ok(page.style.enablePullDownRefresh === true, `${p} 应开启下拉刷新`);
|
||||||
|
}
|
||||||
|
const tabPages = (pagesJson.tabBar && pagesJson.tabBar.list ? pagesJson.tabBar.list : []).map(p => p.pagePath);
|
||||||
|
for (const p of Object.keys(ADMIN_PAGES)) {
|
||||||
|
assert.ok(!tabPages.some(tp => tp.includes(p)), `TabBar 不应含 ${p}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('注册: PAGE_KEY_MAP 含商家端 3 页且与 pages.json 一致', () => {
|
||||||
|
const map = {
|
||||||
|
PAGE_CARD_ADMIN_MANAGE: '/pages_tool/card/admin/manage',
|
||||||
|
PAGE_CARD_ADMIN_CONTACTS: '/pages_tool/card/admin/contacts',
|
||||||
|
PAGE_CARD_ADMIN_LEADS: '/pages_tool/card/admin/leads'
|
||||||
|
};
|
||||||
|
for (const [key, val] of Object.entries(map)) {
|
||||||
|
const m = utilSrc.match(new RegExp(`${key}:\\s*'([^']+)'`));
|
||||||
|
assert.ok(m, `PAGE_KEY_MAP 应有 ${key}`);
|
||||||
|
assert.equal(m[1], val);
|
||||||
|
assert.ok(toolPaths().includes(val.slice('/pages_tool/'.length)),
|
||||||
|
`${val} 未在 pages.json 注册`);
|
||||||
|
}
|
||||||
|
// 防回归: TAB_BAR_KEYS 不得被误加 admin 路径
|
||||||
|
const tabBlock = utilSrc.match(/TAB_BAR_KEYS\s*=\s*\{[\s\S]*?\}/);
|
||||||
|
assert.ok(tabBlock, 'TAB_BAR_KEYS 应存在');
|
||||||
|
assert.ok(!/admin/.test(tabBlock[0]), 'TAB_BAR_KEYS 不应含 admin 路径');
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 2. API 收口(与 card-api 测试同一契约, 覆盖新页面)
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
test('收口: 商家端 3 页零 /card/api/ 字面量且走 this.$cardApi', () => {
|
||||||
|
for (const f of ['manage.vue', 'contacts.vue', 'leads.vue']) {
|
||||||
|
const src = readAdmin(f);
|
||||||
|
assert.ok(!/['"]\/card\/api\//.test(src), `${f} 仍直连 /card/api/ 字面量`);
|
||||||
|
assert.ok(/this\.\$cardApi\./.test(src), `${f} 未使用 this.$cardApi`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('收口: 商家端页面从 card-api.js 导入共享字典', () => {
|
||||||
|
assert.ok(/import\s*\{\s*CONTACT_SOURCES\s*\}\s*from\s*'@\/common\/js\/card-api\.js'/.test(readAdmin('contacts.vue')),
|
||||||
|
'contacts.vue 应导入 CONTACT_SOURCES');
|
||||||
|
assert.ok(/import\s*\{\s*LEAD_STATUS\s*\}\s*from\s*'@\/common\/js\/card-api\.js'/.test(readAdmin('leads.vue')),
|
||||||
|
'leads.vue 应导入 LEAD_STATUS');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('收口: leads.vue 的 setStatus 只接受 0-3(后端约束)', () => {
|
||||||
|
const src = readAdmin('leads.vue');
|
||||||
|
// ActionSheet 选项来自 LEAD_STATUS(0-3), 不产生越界值
|
||||||
|
assert.ok(/itemList:\s*LEAD_STATUS\.map/.test(src), '状态选项必须来自 LEAD_STATUS 字典');
|
||||||
|
assert.ok(!/status\s*=\s*[4-9]/.test(src), '不得出现 0-3 之外的 status 值');
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 3. 鸿蒙安全导航(禁裸 uni.switchTab 静默失败)
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
test('鸿蒙: 商家端 3 页平级互链全部走 $util.redirectTo', () => {
|
||||||
|
const cross = {
|
||||||
|
'manage.vue': [['goContacts', '/pages_tool/card/admin/contacts'], ['goLeads', '/pages_tool/card/admin/leads']],
|
||||||
|
'contacts.vue': [['goManage', '/pages_tool/card/admin/manage'], ['goLeads', '/pages_tool/card/admin/leads']],
|
||||||
|
'leads.vue': [['goManage', '/pages_tool/card/admin/manage'], ['goContacts', '/pages_tool/card/admin/contacts']]
|
||||||
|
};
|
||||||
|
for (const [f, pairs] of Object.entries(cross)) {
|
||||||
|
const src = readAdmin(f);
|
||||||
|
for (const [method, target] of pairs) {
|
||||||
|
const m = src.match(new RegExp(`${method}\\(\\)\\s*\\{([\\s\\S]*?)\\n\\t\\t\\}`));
|
||||||
|
assert.ok(m, `${f} 应定义 ${method}`);
|
||||||
|
assert.ok(/\$util\.redirectTo/.test(m[1]), `${f}#${method} 必须走 $util.redirectTo`);
|
||||||
|
assert.ok(new RegExp(`redirectTo\\('${target.replace(/\//g, '\\/')}'\\)`).test(m[1]),
|
||||||
|
`${f}#${method} 目标应为 ${target}`);
|
||||||
|
}
|
||||||
|
assert.ok(!/uni\.switchTab\s*\(/.test(src), `${f} 不得直接调 uni.switchTab`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('鸿蒙: manage.vue 跳详情/编辑用 navigateTo + encodeURIComponent(card_id)', () => {
|
||||||
|
const src = readAdmin('manage.vue');
|
||||||
|
// 名片路由参数一律 card_id(字符串), 双编码无害(纯 ASCII)
|
||||||
|
assert.ok(/\/pages_tool\/card\/detail\?card_id='\s*\+\s*encodeURIComponent\(item\.card_id\)/.test(src),
|
||||||
|
'goDetail 应 encodeURIComponent(card_id)');
|
||||||
|
assert.ok(/\/pages_tool\/card\/edit\?card_id='\s*\+\s*encodeURIComponent\(item\.card_id\)/.test(src),
|
||||||
|
'goEdit 应 encodeURIComponent(card_id)');
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 4. 编辑页复用回跳(from=admin)
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
test('复用: contact-edit 支持 from=admin 且缺省行为不变', () => {
|
||||||
|
const src = readCard('contact-edit.vue');
|
||||||
|
assert.ok(/this\.fromAdmin\s*=\s*options\.from\s*===\s*'admin'/.test(src),
|
||||||
|
'onLoad 应解析 from=admin');
|
||||||
|
const m = src.match(/backToList\(\)\s*\{([\s\S]*?)\n\t\t\}/);
|
||||||
|
assert.ok(m, '应能提取 backToList 方法体');
|
||||||
|
assert.ok(/\$util\.redirectTo/.test(m[1]), 'backToList 必须走 $util.redirectTo');
|
||||||
|
assert.ok(/this\.fromAdmin\s*\?/.test(m[1]), 'backToList 应按来源分支');
|
||||||
|
assert.ok(m[1].includes("'/pages_tool/card/admin/contacts'"),
|
||||||
|
'商家端应回 /pages_tool/card/admin/contacts');
|
||||||
|
assert.ok(m[1].includes("'/pages_tool/card/contacts'"),
|
||||||
|
'员工端缺省仍回 /pages_tool/card/contacts(不破坏既有行为)');
|
||||||
|
assert.ok(!/uni\.switchTab\s*\(/.test(src), 'contact-edit.vue 不得直接调 uni.switchTab');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('复用: admin/contacts 的编辑入口带 from=admin', () => {
|
||||||
|
const src = readAdmin('contacts.vue');
|
||||||
|
assert.ok(/contact-edit\?from=admin&id='/.test(src), '编辑入口应带 from=admin&id=');
|
||||||
|
assert.ok(/contact-edit\?from=admin'/.test(src), '添加入口应带 from=admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 5. 条件编译配对平衡(新文件同样纳入)
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
test('ifdef 配对平衡: 商家端 3 页', () => {
|
||||||
|
for (const f of ['manage.vue', 'contacts.vue', 'leads.vue']) {
|
||||||
|
const src = readAdmin(f);
|
||||||
|
const open = (src.match(/#ifdef|#ifndef/g) || []).length;
|
||||||
|
const close = (src.match(/#endif/g) || []).length;
|
||||||
|
assert.equal(open, close, `${f}: #ifdef/#ifndef=${open} 但 #endif=${close}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
326
tests/card-api.test.js
Normal file
326
tests/card-api.test.js
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
/**
|
||||||
|
* 名片云 API 收口层测试: common/js/card-api.js ↔ 后端 CARD_API_REFERENCE.md
|
||||||
|
*
|
||||||
|
* 背景:
|
||||||
|
* 后端《名片云(card addon)API 对接文档》checklist 第 1 条要求:
|
||||||
|
* "所有 /card/api/* URL 收口到 API 层, 与本文档路径逐一比对(现有 7 个页面
|
||||||
|
* 12 处直连, 路径不可变)"。本测试把该契约固化为断言:
|
||||||
|
*
|
||||||
|
* 覆盖:
|
||||||
|
* 1. card-api.js 的 18 个端点与后端文档逐一比对(一个字符都不能差)
|
||||||
|
* 2. card-api.js 不得出现文档之外的 /card/api/ 路径
|
||||||
|
* 3. 每个端点都有对应 helper 方法(收口不是只建常量表)
|
||||||
|
* 4. 7 个业务页面零字面量直连(全部走 this.$cardApi)
|
||||||
|
* 5. main.js 注册 $cardApi(Vue.prototype)
|
||||||
|
* 6. 字典与后端白名单一致: SOCIAL_TYPES / LEAD_STATUS / CONTACT_SOURCES
|
||||||
|
* 7. 嵌套对象入参走 sendJson 通道(card/update、contact/add、contact/update、
|
||||||
|
* recognizeBatch), 其余走 sendRequest
|
||||||
|
* 8. 走 sendJson 的 helper, 页面必须把 payload 包在 data 里
|
||||||
|
* (sendJson 只合并 params.data, 顶层挂 fields 会被丢弃)
|
||||||
|
*
|
||||||
|
* 跑法:
|
||||||
|
* node --test tests/card-api.test.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '..');
|
||||||
|
const CARD_DIR = path.join(ROOT, 'pages_tool/card');
|
||||||
|
const read = f => fs.readFileSync(path.join(CARD_DIR, f), 'utf8');
|
||||||
|
const API_SRC = fs.readFileSync(path.join(ROOT, 'common/js/card-api.js'), 'utf8');
|
||||||
|
const MAIN_SRC = fs.readFileSync(path.join(ROOT, 'main.js'), 'utf8');
|
||||||
|
|
||||||
|
// 后端 CARD_API_REFERENCE.md 第 1/3/4/5 节列出的全部端点(15 个)
|
||||||
|
const DOC_URLS = [
|
||||||
|
// 名片夹 /card/api/card/*
|
||||||
|
'/card/api/card/lists',
|
||||||
|
'/card/api/card/detail',
|
||||||
|
'/card/api/card/update',
|
||||||
|
'/card/api/card/toggleFavorite', // 收藏(双向 toggle, 后端 2.9.12)
|
||||||
|
'/card/api/card/create', // 后端源码已有(新建数字名片), 旧文档未列, 以源码为准
|
||||||
|
'/card/api/card/claim',
|
||||||
|
'/card/api/card/recognize',
|
||||||
|
'/card/api/card/retry',
|
||||||
|
'/card/api/card/recognizeBatch',
|
||||||
|
'/card/api/card/followDone', // 跟进完成态 2.11.3
|
||||||
|
'/card/api/card/views', // 访客轨迹 2.11.4
|
||||||
|
// 人脉库 /card/api/contact/*
|
||||||
|
'/card/api/contact/lists',
|
||||||
|
'/card/api/contact/add',
|
||||||
|
'/card/api/contact/info',
|
||||||
|
'/card/api/contact/update',
|
||||||
|
'/card/api/contact/delete',
|
||||||
|
'/card/api/contact/toLead',
|
||||||
|
// 潜在客户 /card/api/lead/*
|
||||||
|
'/card/api/lead/lists',
|
||||||
|
'/card/api/lead/setStatus',
|
||||||
|
// 交换名片 /card/api/exchange/*
|
||||||
|
'/card/api/exchange/lists',
|
||||||
|
'/card/api/exchange/agree',
|
||||||
|
'/card/api/exchange/ignore',
|
||||||
|
'/card/api/apply/submit', // 员工建卡申请 2.11.15
|
||||||
|
'/card/api/apply/status', // 员工建卡申请 2.11.15
|
||||||
|
'/card/api/design/presets', // 名片预设列表(同源后台 CardTemplatePresets, 后端 v2 无 /card/ 段)
|
||||||
|
'/card/api/design/save' // 应用预设/保存本卡样式
|
||||||
|
];
|
||||||
|
|
||||||
|
// helper 方法名 ↔ URL 常量名(收口层必须两端齐全)
|
||||||
|
const HELPER_URL_PAIRS = [
|
||||||
|
['cardLists', 'CARD_LISTS'],
|
||||||
|
['cardDetail', 'CARD_DETAIL'],
|
||||||
|
['cardUpdate', 'CARD_UPDATE'],
|
||||||
|
['cardToggleFavorite', 'CARD_FAVORITE'],
|
||||||
|
['cardCreate', 'CARD_CREATE'],
|
||||||
|
['cardClaim', 'CARD_CLAIM'],
|
||||||
|
['cardRecognize', 'CARD_RECOGNIZE'],
|
||||||
|
['cardRetry', 'CARD_RETRY'],
|
||||||
|
['cardRecognizeBatch', 'CARD_RECOGNIZE_BATCH'],
|
||||||
|
['contactLists', 'CONTACT_LISTS'],
|
||||||
|
['contactAdd', 'CONTACT_ADD'],
|
||||||
|
['contactInfo', 'CONTACT_INFO'],
|
||||||
|
['contactUpdate', 'CONTACT_UPDATE'],
|
||||||
|
['contactDelete', 'CONTACT_DELETE'],
|
||||||
|
['contactToLead', 'CONTACT_TO_LEAD'],
|
||||||
|
['leadLists', 'LEAD_LISTS'],
|
||||||
|
['leadSetStatus', 'LEAD_SET_STATUS'],
|
||||||
|
['exchangeLists', 'EXCHANGE_LISTS'],
|
||||||
|
['exchangeAgree', 'EXCHANGE_AGREE'],
|
||||||
|
['exchangeIgnore', 'EXCHANGE_IGNORE'],
|
||||||
|
['cardFollowDone', 'CARD_FOLLOW_DONE'],
|
||||||
|
['cardViews', 'CARD_VIEWS'],
|
||||||
|
['applySubmit', 'APPLY_SUBMIT'],
|
||||||
|
['applyStatus', 'APPLY_STATUS'],
|
||||||
|
['cardDesignPresets', 'CARD_DESIGN_PRESETS'],
|
||||||
|
['cardDesignSave', 'CARD_DESIGN_SAVE']
|
||||||
|
];
|
||||||
|
|
||||||
|
// 文档 3.3/4.2/4.4/3.7: 嵌套对象/数组入参必须走 JSON body(sendJson)
|
||||||
|
const JSON_BODY_HELPERS = ['cardUpdate', 'cardCreate', 'cardToggleFavorite', 'contactAdd', 'contactUpdate', 'cardRecognizeBatch', 'exchangeAgree', 'exchangeIgnore', 'cardDesignSave'];
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 1. 端点与后端文档逐一比对
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
test('收口: card-api.js 含文档全部 18 个端点', () => {
|
||||||
|
for (const u of DOC_URLS) {
|
||||||
|
assert.ok(API_SRC.includes(`'${u}'`), `card-api.js 缺少端点 ${u}(与后端文档不符)`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('收口: card-api.js 不得出现文档之外的 /card/api/ 路径', () => {
|
||||||
|
const found = API_SRC.match(/'\/card\/api\/[^']+'/g) || [];
|
||||||
|
const unknown = found.map(s => s.slice(1, -1)).filter(u => !DOC_URLS.includes(u));
|
||||||
|
assert.deepEqual(unknown, [], `存在文档外路径: ${unknown.join(', ')}`);
|
||||||
|
assert.equal(found.length, DOC_URLS.length,
|
||||||
|
`端点数量应为 ${DOC_URLS.length}, 实际 ${found.length}(防止漏收/多收)`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 2. 每个端点都有 helper(收口不是只建常量表)
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
test('收口: 15 个端点均有对应 helper 方法', () => {
|
||||||
|
for (const [helper, urlConst] of HELPER_URL_PAIRS) {
|
||||||
|
assert.ok(new RegExp(`\\b${helper}\\(p\\)\\s*\\{`).test(API_SRC),
|
||||||
|
`缺少 helper ${helper}(p)`);
|
||||||
|
assert.ok(new RegExp(`(?:url\\s*:|url\\s*=)\\s*CARD_API_URL\\.${urlConst}\\b`).test(API_SRC),
|
||||||
|
`helper ${helper} 未绑定 ${urlConst}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('收口: 嵌套入参 helper 走 sendJson 通道', () => {
|
||||||
|
for (const helper of JSON_BODY_HELPERS) {
|
||||||
|
const m = API_SRC.match(new RegExp(`${helper}\\(p\\)\\s*\\{([\\s\\S]*?)\\n\\t\\}`));
|
||||||
|
assert.ok(m, `应能提取 ${helper} 方法体`);
|
||||||
|
assert.ok(/Http\.sendJson\(/.test(m[1]),
|
||||||
|
`${helper} 必须走 Http.sendJson(JSON body), 否则嵌套 fields 跨端序列化有歧义`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('收口: 普通入参 helper 走 sendRequest 通道', () => {
|
||||||
|
const formHelpers = HELPER_URL_PAIRS
|
||||||
|
.map(p => p[0])
|
||||||
|
.filter(h => !JSON_BODY_HELPERS.includes(h));
|
||||||
|
for (const helper of formHelpers) {
|
||||||
|
const m = API_SRC.match(new RegExp(`${helper}\\(p\\)\\s*\\{([\\s\\S]*?)\\n\\t\\}`));
|
||||||
|
assert.ok(m, `应能提取 ${helper} 方法体`);
|
||||||
|
assert.ok(/Http\.sendRequest\(/.test(m[1]),
|
||||||
|
`${helper} 应走 Http.sendRequest`);
|
||||||
|
assert.ok(!/Http\.sendJson\(/.test(m[1]),
|
||||||
|
`${helper} 不应走 sendJson(无嵌套入参)`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 3. 页面零字面量直连
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
test('收口: 7 个业务页面不得再直连 /card/api/ 字面量', () => {
|
||||||
|
const files = ['list.vue', 'detail.vue', 'edit.vue', 'leads.vue', 'share.vue', 'contacts.vue', 'contact-edit.vue'];
|
||||||
|
for (const f of files) {
|
||||||
|
const src = read(f);
|
||||||
|
assert.ok(!/['"]\/card\/api\//.test(src),
|
||||||
|
`${f} 仍直连 /card/api/ 字面量, 必须走 this.$cardApi(后端 checklist 第 1 条)`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('收口: 有接口调用的 6 个页面全部走 this.$cardApi', () => {
|
||||||
|
// share.vue 是 web-view 中转页, 无业务接口调用, 不在此列
|
||||||
|
const files = ['list.vue', 'detail.vue', 'edit.vue', 'leads.vue', 'contacts.vue', 'contact-edit.vue'];
|
||||||
|
for (const f of files) {
|
||||||
|
const src = read(f);
|
||||||
|
assert.ok(/this\.\$cardApi\./.test(src),
|
||||||
|
`${f} 未使用 this.$cardApi`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('收口: main.js 注册 Vue.prototype.$cardApi', () => {
|
||||||
|
assert.ok(/import\s+CardApi\s+from\s+'\.\/common\/js\/card-api\.js'/.test(MAIN_SRC),
|
||||||
|
'main.js 应 import card-api.js');
|
||||||
|
assert.ok(/Vue\.prototype\.\$cardApi\s*=\s*CardApi/.test(MAIN_SRC),
|
||||||
|
'main.js 应注册 Vue.prototype.$cardApi');
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 4. 字典与后端白名单一致
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
test('字典: SOCIAL_TYPES 与后端 CardRichFields::SOCIAL_TYPES 白名单一致', () => {
|
||||||
|
const keys = [...API_SRC.matchAll(/\{\s*key:\s*'([a-z]+)',\s*label:/g)].map(m => m[1]);
|
||||||
|
// 2026-10-13: telegram / messenger 由独立标量软下线, 并入 socials 白名单
|
||||||
|
const expected = ['alipay', 'douyin', 'wxvideo', 'xiaohongshu', 'weibo', 'kuaishou',
|
||||||
|
'bilibili', 'qq', 'linkedin', 'facebook', 'instagram', 'x', 'telegram', 'messenger', 'other'];
|
||||||
|
assert.deepEqual(keys, expected, 'SOCIAL_TYPES 增减必须与后端同步');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('字典: CARD_FIELDS 不含 telegram/messenger(2026-10-13 软下线)', () => {
|
||||||
|
const block = API_SRC.match(/export const CARD_FIELDS = \[([\s\S]*?)\];/);
|
||||||
|
assert.ok(block, 'CARD_FIELDS 应存在');
|
||||||
|
const values = [...block[1].matchAll(/'([a-z_]+)'/g)].map(m => m[1]);
|
||||||
|
assert.ok(!values.includes('telegram'), 'telegram 已软下线为独立标量, 不得出现在 CARD_FIELDS');
|
||||||
|
assert.ok(!values.includes('messenger'), 'messenger 已软下线为独立标量, 不得出现在 CARD_FIELDS');
|
||||||
|
// 其余标量不受影响
|
||||||
|
for (const f of ['name', 'company', 'job_title', 'avatar', 'mobile', 'phone', 'email',
|
||||||
|
'website', 'wechat', 'country', 'province', 'city', 'address', 'postal_code', 'bio']) {
|
||||||
|
assert.ok(values.includes(f), `CARD_FIELDS 应保留 ${f}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('软下线: edit.vue 不再提交 telegram/messenger 标量, 字典从收口层导入', () => {
|
||||||
|
const editSrc = fs.readFileSync(path.join(CARD_DIR, 'edit.vue'), 'utf8');
|
||||||
|
assert.ok(!/form\.telegram/.test(editSrc), 'edit.vue 不得再绑 form.telegram(软下线)');
|
||||||
|
assert.ok(!/form\.messenger/.test(editSrc), 'edit.vue 不得再绑 form.messenger(软下线)');
|
||||||
|
assert.ok(!/fields\.telegram/.test(editSrc) && !/fields\.messenger/.test(editSrc),
|
||||||
|
'edit.vue 保存时不得再提交 telegram/messenger 标量');
|
||||||
|
assert.ok(/import\s*\{\s*SOCIAL_TYPES,\s*CARD_FIELDS,\s*RICH_FIELD_LIMITS\s*\}\s*from\s*'@\/common\/js\/card-api\.js'/.test(editSrc),
|
||||||
|
'edit.vue 应从 card-api.js 导入 SOCIAL_TYPES/CARD_FIELDS/RICH_FIELD_LIMITS');
|
||||||
|
// 模板里不得再有 Telegram / Messenger 独立输入行
|
||||||
|
assert.ok(!/<view class="row-label">Telegram<\/view>/.test(editSrc),
|
||||||
|
'模板不得再有 Telegram 独立输入行');
|
||||||
|
assert.ok(!/<view class="row-label">Messenger<\/view>/.test(editSrc),
|
||||||
|
'模板不得再有 Messenger 独立输入行');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('软下线: detail.vue 不再单独渲染 telegram/messenger 行(旧值已并入 socials_list)', () => {
|
||||||
|
const detailSrc = fs.readFileSync(path.join(CARD_DIR, 'detail.vue'), 'utf8');
|
||||||
|
assert.ok(!/push\('Telegram'/.test(detailSrc), 'detail.vue 不得再单独渲染 Telegram 行');
|
||||||
|
assert.ok(!/push\('Messenger'/.test(detailSrc), 'detail.vue 不得再单独渲染 Messenger 行');
|
||||||
|
// 社交账号统一走 socials_list(后端已合并旧值)
|
||||||
|
assert.ok(/r\.socials_list/.test(detailSrc), 'detail.vue 应继续消费 socials_list');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('字典: LEAD_STATUS 与后端 Lead::STATUSES 一致(0-3)', () => {
|
||||||
|
const block = API_SRC.match(/export const LEAD_STATUS = \[([\s\S]*?)\];/);
|
||||||
|
assert.ok(block, 'LEAD_STATUS 应存在');
|
||||||
|
const values = [...block[1].matchAll(/value:\s*(\d)/g)].map(m => Number(m[1]));
|
||||||
|
assert.deepEqual(values, [0, 1, 2, 3]);
|
||||||
|
// setStatus 只接受 0/1/2/3, 其它值后端报「参数不合法」
|
||||||
|
assert.ok(/status\?\(0-3\)/.test(API_SRC), 'leadLists 文档注释应标注 status 0-3');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('字典: CONTACT_SOURCES 与后端 Contact::SOURCES 一致', () => {
|
||||||
|
const block = API_SRC.match(/export const CONTACT_SOURCES = \[([\s\S]*?)\];/);
|
||||||
|
assert.ok(block, 'CONTACT_SOURCES 应存在');
|
||||||
|
const values = [...block[1].matchAll(/value:\s*'([a-z_]+)'/g)].map(m => m[1]);
|
||||||
|
assert.deepEqual(values, ['manual', 'lead_in', 'scan', 'exchange']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('字典: CONTACT_FIELDS 与后端 Contact::EDITABLE_FIELDS 一致', () => {
|
||||||
|
const block = API_SRC.match(/export const CONTACT_FIELDS = \[([\s\S]*?)\];/);
|
||||||
|
assert.ok(block, 'CONTACT_FIELDS 应存在');
|
||||||
|
const values = [...block[1].matchAll(/'([a-z_]+)'/g)].map(m => m[1]);
|
||||||
|
assert.deepEqual(values,
|
||||||
|
['name', 'mobile', 'company', 'job_title', 'wechat', 'email', 'website', 'address', 'remark', 'status']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 5. 文档关键语义在收口层有注释固化(防后人改错)
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
test('语义: card/update 的 normalize 规则写在收口层注释里', () => {
|
||||||
|
// 键存在才写 / 空串=清空 / 键缺席=不擦除(历史事故定稿)
|
||||||
|
assert.ok(/键存在才写/.test(API_SRC) && /空串=清空/.test(API_SRC) && /键缺席=不擦除/.test(API_SRC),
|
||||||
|
'cardUpdate 注释必须固化 normalize 三语义');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('语义: recognize 的 deduped 提示写在收口层注释里', () => {
|
||||||
|
assert.ok(/deduped=true 表示站点内已有同手机号名片/.test(API_SRC),
|
||||||
|
'cardRecognize 注释必须提示 deduped 语义');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('语义: 名片用 card_id 字符串 / 人脉线索用 id 数字的区分写在注释里', () => {
|
||||||
|
assert.ok(/card_id(字符串),人脉\/线索用 id(数字)/.test(API_SRC),
|
||||||
|
'收口层注释必须固化主键类型区分(后端 checklist 第 9 条)');
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 6. 走 sendJson 的 helper: 页面必须把 payload 包在 data 里
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取 `$cardApi.<helper>` 调用的实参文本(括号配对)。
|
||||||
|
* 兼容两种写法: `this.$cardApi.cardUpdate({...})` 与三元分支
|
||||||
|
* `(this.isCreate ? this.$cardApi.cardCreate : this.$cardApi.cardUpdate)({...})`
|
||||||
|
* —— 后者 helper 名后紧跟的是 ')' 而不是 '(', 故先定位名字再找其后第一个 '('。
|
||||||
|
*/
|
||||||
|
function callArgs(src, helper) {
|
||||||
|
const at = src.indexOf('$cardApi.' + helper);
|
||||||
|
if (at < 0) return null;
|
||||||
|
const start = src.indexOf('(', at);
|
||||||
|
if (start < 0) return null;
|
||||||
|
let depth = 0;
|
||||||
|
for (let i = start; i < src.length; i++) {
|
||||||
|
const c = src[i];
|
||||||
|
if (c === '(') depth++;
|
||||||
|
else if (c === ')') {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) return src.slice(start, i + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('入参: 走 sendJson 的 helper 必须把 payload 包在 data 里', () => {
|
||||||
|
// 历史事故(2026-10-13): edit.vue 保存时把 fields 挂在顶层传参, 而
|
||||||
|
// Http.sendJson 只 Object.assign(data, params.data)(见 common/js/http.js),
|
||||||
|
// 顶层键被整体丢弃 → 请求体里没有 fields → 后端按空 name 校验,
|
||||||
|
// 前端弹「保存失败」、Network 里看到 {code:-1, data:"请填写姓名"}。
|
||||||
|
const CALLERS = {
|
||||||
|
'edit.vue': ['cardCreate', 'cardUpdate'],
|
||||||
|
'contact-edit.vue': ['contactAdd', 'contactUpdate']
|
||||||
|
};
|
||||||
|
for (const [f, helpers] of Object.entries(CALLERS)) {
|
||||||
|
const src = read(f);
|
||||||
|
for (const h of helpers) {
|
||||||
|
const args = callArgs(src, h);
|
||||||
|
assert.ok(args, `${f} 应能定位 $cardApi.${h} 调用`);
|
||||||
|
assert.ok(/data\s*:/.test(args),
|
||||||
|
`${f} 的 $cardApi.${h} 必须传 data: {...}; 顶层挂 fields 会被 sendJson 丢弃`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
425
tests/card-platform.test.js
Normal file
425
tests/card-platform.test.js
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
/**
|
||||||
|
* card 电子名片模块跨平台适配测试: 微信小程序 / H5 / 鸿蒙元服务
|
||||||
|
*
|
||||||
|
* 背景:
|
||||||
|
* uni-app 产物覆盖 MP-WEIXIN / H5 / MP-HARMONY(鸿蒙元服务) 三端,
|
||||||
|
* card 模块(名片夹/详情/编辑/线索/分享)任何平台分支遗漏都会在对应端
|
||||||
|
* 表现为「按钮消失」或「整页白屏」。本测试把适配契约固化为断言。
|
||||||
|
*
|
||||||
|
* 覆盖:
|
||||||
|
* 1. 条件编译配对平衡(防止 #ifdef 漏 #endif 导致编译失败)
|
||||||
|
* 2. 平台 token 覆盖(detail/share 必须同时照顾三端)
|
||||||
|
* 3. 保存二维码按钮在 微信+鸿蒙 双端可见(saveImageToPhotosAlbum 鸿蒙可用)
|
||||||
|
* 4. share.vue 无白页: web-view 分支覆盖 微信+鸿蒙+App, H5 分支有跳转
|
||||||
|
* 5. goBack 不用裸 uni.switchTab(鸿蒙 switchTab 静默失败, catch 兜底失效,
|
||||||
|
* 必须走 $util.redirectTo —— 见 common/js/util.js#redirectTo tabBar 分支)
|
||||||
|
* 6. 分享 URL 编解码闭环(三端语义不同: 微信/鸿蒙不 decode 值, H5 预 decode)
|
||||||
|
* 7. 平台敏感 API 只在受保护的分支内调用
|
||||||
|
* 8. card_id 参数编码安全(纯 ASCII, encodeURIComponent 幂等)
|
||||||
|
*
|
||||||
|
* 跑法:
|
||||||
|
* node --test tests/card-platform.test.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const CARD_DIR = path.resolve(__dirname, '../pages_tool/card');
|
||||||
|
const read = f => fs.readFileSync(path.join(CARD_DIR, f), 'utf8');
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 工具: 条件编译块解析(行注释 // / 块注释 /* */ / HTML <!-- --> 三种写法)
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把文件解析成 [{ cond, start, end }] 顶层块列表。
|
||||||
|
* 兼容三种写法: 行注释 `// #ifdef X`、块注释 `/* #ifdef X *\/`、HTML `<!-- #ifdef X -->`。
|
||||||
|
* 关键细节:
|
||||||
|
* - 条件名含连字符(MP-WEIXIN/APP-PLUS), 捕获到注释终止符为止
|
||||||
|
* - 一行可内联 "#ifdef X -->text<!-- #endif -->", 开/关都按出现次数计
|
||||||
|
*/
|
||||||
|
function parseIfdefBlocks(src) {
|
||||||
|
const lines = src.split('\n');
|
||||||
|
const stack = []; // [{ cond, startLine }]
|
||||||
|
const blocks = []; // 顶层块
|
||||||
|
// 开标记: 捕获条件串(惰性到注释终止符/行尾); 关标记: 单独识别
|
||||||
|
const TOKEN_RE = /(?:\/\/\s*|\/\*\s*|<!--\s*)(#ifdef|#ifndef|#endif)\b(?:\s+(.+?))?\s*(?:-->|\*\/|$)/g;
|
||||||
|
lines.forEach((line, i) => {
|
||||||
|
TOKEN_RE.lastIndex = 0;
|
||||||
|
let m;
|
||||||
|
const tokens = [];
|
||||||
|
while ((m = TOKEN_RE.exec(line)) !== null) {
|
||||||
|
if (m[0].trim() === '') break; // 防空匹配死循环
|
||||||
|
tokens.push({ kind: m[1], cond: m[2] ? m[2].trim() : null });
|
||||||
|
}
|
||||||
|
for (const tk of tokens) {
|
||||||
|
if (tk.kind === '#ifdef' || tk.kind === '#ifndef') {
|
||||||
|
if (stack.length === 0) blocks.push({ cond: tk.cond, start: i, end: lines.length - 1 });
|
||||||
|
stack.push({ cond: tk.cond, startLine: i });
|
||||||
|
} else {
|
||||||
|
const top = stack.pop();
|
||||||
|
if (top && stack.length === 0) {
|
||||||
|
const b = blocks[blocks.length - 1];
|
||||||
|
if (b && b.start === top.startLine) b.end = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断 cond 串(如 "MP-WEIXIN || MP-HARMONY")在指定平台下是否命中 */
|
||||||
|
function condHits(cond, platform) {
|
||||||
|
return cond
|
||||||
|
.split('||')
|
||||||
|
.map(s => s.trim().replace(/^!/, ''))
|
||||||
|
.includes(platform);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 找出源码中某 API 出现的所有位置及其所属顶层 cond(无保护返回 null) */
|
||||||
|
function apiOccurrences(src, apiRegex) {
|
||||||
|
const lines = src.split('\n');
|
||||||
|
const blocks = parseIfdefBlocks(src);
|
||||||
|
const out = [];
|
||||||
|
lines.forEach((line, i) => {
|
||||||
|
if (!apiRegex.test(line)) return;
|
||||||
|
// 找包含该行的顶层块
|
||||||
|
const b = blocks.find(b => i >= b.start && i <= b.end);
|
||||||
|
out.push({ line: i + 1, text: line.trim(), cond: b ? b.cond : null });
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 1. 条件编译配对平衡
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
for (const f of ['list.vue', 'detail.vue', 'edit.vue', 'leads.vue', 'share.vue']) {
|
||||||
|
test(`ifdef 配对平衡: ${f}`, () => {
|
||||||
|
const src = read(f);
|
||||||
|
const open = (src.match(/#ifdef|#ifndef/g) || []).length;
|
||||||
|
const close = (src.match(/#endif/g) || []).length;
|
||||||
|
assert.equal(open, close, `${f}: #ifdef/#ifndef=${open} 但 #endif=${close}(漏配对编译必失败)`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 2. 平台 token 覆盖
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
test('平台覆盖: detail.vue 同时含 MP-WEIXIN / H5 / MP-HARMONY 分支', () => {
|
||||||
|
const src = read('detail.vue');
|
||||||
|
for (const p of ['MP-WEIXIN', 'H5', 'MP-HARMONY']) {
|
||||||
|
assert.ok(src.includes(p), `detail.vue 缺少 ${p} 分支(该端分享面板会缺按钮)`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('平台覆盖: share.vue 的 web-view 分支含 MP-HARMONY(防鸿蒙白屏)', () => {
|
||||||
|
const src = read('share.vue');
|
||||||
|
// web-view 必须出现在 MP-WEIXIN || MP-HARMONY 保护块内
|
||||||
|
const wvOcc = apiOccurrences(src, /<web-view/);
|
||||||
|
assert.ok(wvOcc.length >= 2, 'share.vue 至少应有 2 处 web-view(小程序+App)');
|
||||||
|
const mpWv = wvOcc.find(o => o.cond && /MP-WEIXIN/.test(o.cond));
|
||||||
|
assert.ok(mpWv, 'web-view 必须位于 MP-WEIXIN 分支内');
|
||||||
|
assert.ok(/MP-HARMONY/.test(mpWv.cond),
|
||||||
|
`web-view 分支必须同时覆盖 MP-HARMONY, 实际 cond="${mpWv.cond}"(鸿蒙会白页)`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('平台覆盖: share.vue H5 分支有 location.href 跳转(防 H5 停留中转页)', () => {
|
||||||
|
const src = read('share.vue');
|
||||||
|
const occ = apiOccurrences(src, /window\.location\.href/);
|
||||||
|
const h5Occ = occ.find(o => o.cond === 'H5');
|
||||||
|
assert.ok(h5Occ, 'location.href 必须位于 H5 分支内');
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 3. 保存二维码按钮双端可见
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
test('保存二维码: 按钮在 MP-WEIXIN 与 MP-HARMONY 分支均可见', () => {
|
||||||
|
const src = read('detail.vue');
|
||||||
|
// saveQr 的按钮绑定点(template 内 @tap="saveQr")
|
||||||
|
const btnOcc = apiOccurrences(src, /@tap="saveQr"/);
|
||||||
|
const conds = btnOcc.map(o => o.cond).filter(Boolean);
|
||||||
|
assert.ok(conds.some(c => /MP-WEIXIN/.test(c)), '保存二维码按钮应有 MP-WEIXIN 分支');
|
||||||
|
assert.ok(conds.some(c => /MP-HARMONY/.test(c)),
|
||||||
|
`保存二维码按钮应有 MP-HARMONY 分支(鸿蒙 uni.saveImageToPhotosAlbum 可用), 实际: ${JSON.stringify(conds)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('保存二维码: saveImageToPhotosAlbum/downloadFile 调用不受平台分支保护误删', () => {
|
||||||
|
// 这两个 API 在鸿蒙受支持, 但不能出现在 H5 分支( H5 无此 API )
|
||||||
|
const src = read('detail.vue');
|
||||||
|
for (const api of [/uni\.saveImageToPhotosAlbum/, /uni\.downloadFile/]) {
|
||||||
|
for (const occ of apiOccurrences(src, api)) {
|
||||||
|
assert.notEqual(occ.cond, 'H5', `${api} 不应在 H5 分支内(第 ${occ.line} 行)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 4. 分享中转页不白屏(各端都有可渲染内容)
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
test('share.vue 三端均有渲染分支: MP-WEIXIN||MP-HARMONY / H5 / APP-PLUS', () => {
|
||||||
|
const src = read('share.vue');
|
||||||
|
const conds = parseIfdefBlocks(src).map(b => b.cond);
|
||||||
|
assert.ok(conds.some(c => c && /MP-WEIXIN/.test(c) && /MP-HARMONY/.test(c)),
|
||||||
|
`应有合并的 MP-WEIXIN||MP-HARMONY 分支, 实际: ${JSON.stringify(conds)}`);
|
||||||
|
assert.ok(conds.includes('H5'), '应有 H5 分支');
|
||||||
|
assert.ok(conds.includes('APP-PLUS'), '应有 APP-PLUS 分支');
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 5. goBack 不用裸 switchTab(鸿蒙静默失败回归)
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
test('goBack: share.vue 不用裸 uni.switchTab(鸿蒙静默失败, catch 不触发)', () => {
|
||||||
|
const src = read('share.vue');
|
||||||
|
// 提取 goBack 方法体
|
||||||
|
const m = src.match(/goBack\(\)\s*\{([\s\S]*?)\n\t\t\},/);
|
||||||
|
assert.ok(m, '应能提取 goBack 方法体');
|
||||||
|
const body = m[1];
|
||||||
|
// 只断言真实调用(带括号), 注释里提到 uni.switchTab 是说明文字不算
|
||||||
|
assert.ok(!/uni\.switchTab\s*\(/.test(body),
|
||||||
|
'goBack 不得直接调 uni.switchTab: 鸿蒙上 success/fail 都不触发, .catch 兜底失效');
|
||||||
|
assert.ok(/\$util\.redirectTo/.test(body),
|
||||||
|
'goBack 栈底回首页必须走 $util.redirectTo(内部有 switchTab→500ms→reLaunch 兜底)');
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 6. 分享 URL 编解码闭环(三端语义)
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模拟三端 onShareAppMessage path → 目标页 onLoad options.url 的传递:
|
||||||
|
* - 微信: path 里的 query 按 & = 切分但不 decode value(项目实测结论,
|
||||||
|
* 见 common/js/util-navigate.js#_safeEncodeForPlatform 注释)
|
||||||
|
* - 鸿蒙: 同微信语义(uni-app harmony 沿用小程序路由)
|
||||||
|
* - H5: 浏览器预 decode, onLoad 拿到已解码值
|
||||||
|
*/
|
||||||
|
function simulateShareOpen(pathStr, platform) {
|
||||||
|
const qi = pathStr.indexOf('?');
|
||||||
|
const query = qi === -1 ? '' : pathStr.slice(qi + 1);
|
||||||
|
const params = {};
|
||||||
|
for (const kv of query.split('&')) {
|
||||||
|
if (!kv) continue;
|
||||||
|
const eq = kv.indexOf('=');
|
||||||
|
const k = kv.slice(0, eq);
|
||||||
|
const raw = kv.slice(eq + 1);
|
||||||
|
// 微信/鸿蒙: value 原样(不 decode); H5: 浏览器已 decode
|
||||||
|
params[k] = platform === 'h5' ? safeDecode(raw) : raw;
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeDecode(s) {
|
||||||
|
try { return decodeURIComponent(s); } catch (e) { return s; }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('分享闭环: 微信/鸿蒙 onLoad 拿到编码值, decode 后还原完整 URL', () => {
|
||||||
|
const shareUrl = 'https://shop.example.com/card/wap/index/detail?site_id=1&id=237';
|
||||||
|
const pathStr = '/pages_tool/card/share?url=' + encodeURIComponent(shareUrl);
|
||||||
|
for (const p of ['mp-weixin', 'mp-harmony']) {
|
||||||
|
const opts = simulateShareOpen(pathStr, p);
|
||||||
|
assert.ok(opts.url, `${p}: options.url 应存在`);
|
||||||
|
// 编码值不应含裸 & (否则 query 被切碎, url 被截断)
|
||||||
|
assert.ok(!opts.url.includes('&'), `${p}: url 参数不应含裸 &`);
|
||||||
|
// share.vue onLoad 的 decodeURIComponent 必须还原出完整 URL
|
||||||
|
assert.equal(safeDecode(opts.url), shareUrl, `${p}: decode 后应还原完整分享 URL`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('分享闭环: H5 onLoad 拿到已解码值, 再 decode 是幂等的(无双解码)', () => {
|
||||||
|
const shareUrl = 'https://shop.example.com/card/wap/index/detail?site_id=1&id=237';
|
||||||
|
const pathStr = '/pages_tool/card/share?url=' + encodeURIComponent(shareUrl);
|
||||||
|
const opts = simulateShareOpen(pathStr, 'h5');
|
||||||
|
assert.equal(safeDecode(opts.url), shareUrl, 'H5: 浏览器已解码, 页面内再 decode 应仍得完整 URL');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('分享闭环: onShareAppMessage 的 path 确实预编码了 url(与 webview.vue 惯例一致)', () => {
|
||||||
|
const src = read('detail.vue');
|
||||||
|
assert.ok(/path:\s*'\/pages_tool\/card\/share\?url='\s*\+\s*encodeURIComponent/.test(src),
|
||||||
|
'onShareAppMessage path 必须 encodeURIComponent(url), 否则 & 会切碎 query');
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 7. card_id 参数编码安全
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
test('card_id 编码幂等: 纯 ASCII 编号 encodeURIComponent 不变(双编码无害)', () => {
|
||||||
|
// 服务端生成格式 c_YYYYMMDD_hex, 只含字母数字下划线
|
||||||
|
const cardId = 'c_20261012_f1018de954e6';
|
||||||
|
assert.equal(encodeURIComponent(cardId), cardId, 'card_id 编码后应不变');
|
||||||
|
assert.equal(decodeURIComponent(encodeURIComponent(cardId)), cardId, '双解码也应无害');
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 8. member/index.vue 的 tourl 导航走 $util.redirectTo(鸿蒙安全)
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
test('member/index.vue 的 tourl 走 $util.redirectTo(鸿蒙安全)', () => {
|
||||||
|
const memberSrc = fs.readFileSync(
|
||||||
|
path.resolve(__dirname, '../pages_tool/member/index.vue'), 'utf8');
|
||||||
|
const m = memberSrc.match(/tourl\(url\)\s*\{([\s\S]*?)\n\t\t\}/);
|
||||||
|
assert.ok(m, 'member/index.vue 应定义 tourl');
|
||||||
|
assert.ok(/\$util\.redirectTo/.test(m[1]),
|
||||||
|
'tourl 必须走 $util.redirectTo(鸿蒙 switchTab 静默失败兜底在内)');
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 9. 端到端: 三端分享面板按钮矩阵
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
test('端到端: 三端分享面板按钮矩阵(静态推导)', () => {
|
||||||
|
const src = read('detail.vue');
|
||||||
|
// 复制链接: 无条件(三端都有)
|
||||||
|
assert.ok(/@tap="copyLink"/.test(src), '复制链接应三端可用');
|
||||||
|
// 保存二维码: MP-WEIXIN + MP-HARMONY
|
||||||
|
// 浏览器分享: H5
|
||||||
|
// 发给好友: MP-WEIXIN, 且必须是原生 button(open-type=share) —— 普通 view 的 @tap
|
||||||
|
// 调不起转发面板(微信未开放该 API), 只能弹「请点击右上角···」的死胡同提示
|
||||||
|
const h5Btns = [];
|
||||||
|
const wxBtns = [];
|
||||||
|
const harmonyBtns = [];
|
||||||
|
// 简化推导: 逐行扫描按钮行并记录当前 cond
|
||||||
|
const blocks = parseIfdefBlocks(src);
|
||||||
|
src.split('\n').forEach((line, i) => {
|
||||||
|
const b = blocks.find(b => i >= b.start && i <= b.end);
|
||||||
|
const cond = b ? b.cond : null;
|
||||||
|
if (!/ss-btn/.test(line)) return;
|
||||||
|
// 原生转发按钮没有 @tap, 统一记成 nativeShare 便于断言
|
||||||
|
const tm = line.match(/@tap="(\w+)"/);
|
||||||
|
const token = tm ? tm[1] : (/open-type="share"/.test(line) ? 'nativeShare' : null);
|
||||||
|
if (!token) return;
|
||||||
|
if (!cond) { h5Btns.push(token); wxBtns.push(token); harmonyBtns.push(token); return; }
|
||||||
|
if (condHits(cond, 'MP-WEIXIN')) wxBtns.push(token);
|
||||||
|
if (condHits(cond, 'MP-HARMONY')) harmonyBtns.push(token);
|
||||||
|
if (condHits(cond, 'H5')) h5Btns.push(token);
|
||||||
|
});
|
||||||
|
assert.ok(wxBtns.includes('copyLink') && wxBtns.includes('saveQr') && wxBtns.includes('nativeShare'),
|
||||||
|
`微信按钮矩阵(发给好友须为 nativeShare): ${JSON.stringify(wxBtns)}`);
|
||||||
|
assert.ok(!wxBtns.includes('shareToFriend'),
|
||||||
|
'「发给好友」不能退回 @tap 弹提示的死胡同, 必须原生 button 直接拉起转发面板');
|
||||||
|
assert.ok(harmonyBtns.includes('copyLink') && harmonyBtns.includes('saveQr'),
|
||||||
|
`鸿蒙按钮矩阵: ${JSON.stringify(harmonyBtns)}`);
|
||||||
|
assert.ok(!harmonyBtns.includes('nativeShare'), '鸿蒙元服务无转发菜单, 不应出现原生转发按钮');
|
||||||
|
assert.ok(h5Btns.includes('copyLink') && h5Btns.includes('h5Share'),
|
||||||
|
`H5 按钮矩阵: ${JSON.stringify(h5Btns)}`);
|
||||||
|
assert.ok(!h5Btns.includes('saveQr'), 'H5 不应出现「保存二维码」(无 saveImageToPhotosAlbum)');
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 9.1 「发名片」主按钮: 微信端一键转发给好友
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
test('发名片: 微信端原生 button 一键转发 + 分享链接预先就绪', () => {
|
||||||
|
const src = read('detail.vue');
|
||||||
|
const btnLine = src.split('\n').find(l => /send-btn/.test(l) && /open-type="share"/.test(l));
|
||||||
|
assert.ok(btnLine, 'detail.vue 应有「发名片」按钮(open-type="share")');
|
||||||
|
assert.ok(/<button/.test(btnLine),
|
||||||
|
'「发名片」必须是原生 <button> —— 普通 view 无法调起「发送给朋友」面板');
|
||||||
|
// 分享链接必须在转发前就绪: 右上角「···」转发与原生按钮都不经过 openShare()
|
||||||
|
assert.ok(/buildShareInfo\s*\(\)/.test(src), 'shareUrl/qrUrl 应由 buildShareInfo 统一生成');
|
||||||
|
const osm = src.match(/onShareAppMessage\(\)\s*\{([\s\S]*?)\n\t\}/);
|
||||||
|
assert.ok(osm, 'detail.vue 应声明 onShareAppMessage');
|
||||||
|
assert.ok(/buildShareInfo/.test(osm[1]), 'onShareAppMessage 内需兜底生成分享链接(否则中转页拿到空 url)');
|
||||||
|
assert.ok(!/请点击右上角/.test(src), '不应保留「请点击右上角···」的提示式分享');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 1.8.0 后台「选择链接」注册: PAGE_KEY_MAP ↔ pages.json 对齐
|
||||||
|
//
|
||||||
|
// 后端 lucky_link 注册 CARD_LIST/CARD_LEADS(page_key=PAGE_CARD_LIST/
|
||||||
|
// PAGE_CARD_LEADS), 前端导航 resolveLinkUrl 优先用 page_key 经
|
||||||
|
// PAGE_KEY_MAP 解析; 两边必须与 pages.json 注册路径一致, 否则链接点了 404。
|
||||||
|
// ============================================================
|
||||||
|
test('选择链接: PAGE_KEY_MAP 含 card 两个页面常量', () => {
|
||||||
|
const utilSrc = fs.readFileSync(path.resolve(__dirname, '../common/js/util.js'), 'utf8');
|
||||||
|
const m = utilSrc.match(/PAGE_CARD_LIST:\s*'([^']+)'/);
|
||||||
|
const m2 = utilSrc.match(/PAGE_CARD_LEADS:\s*'([^']+)'/);
|
||||||
|
assert.ok(m, 'PAGE_KEY_MAP 应有 PAGE_CARD_LIST');
|
||||||
|
assert.ok(m2, 'PAGE_KEY_MAP 应有 PAGE_CARD_LEADS');
|
||||||
|
assert.equal(m[1], '/pages_tool/card/list');
|
||||||
|
assert.equal(m2[1], '/pages_tool/card/leads');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('选择链接: card 页面已在 pages.json 注册', () => {
|
||||||
|
const pagesSrc = fs.readFileSync(path.resolve(__dirname, '../pages.json'), 'utf8');
|
||||||
|
// pages.json 含 uni-app 合法 // 注释, 剥后解析
|
||||||
|
const stripped = pagesSrc.replace(/^\s*\/\/.*$/gm, '').replace(/,(\s*[}\]])/g, '$1');
|
||||||
|
const pages = JSON.parse(stripped);
|
||||||
|
const sub = (pages.subPackages || []).find(sp => sp.root === 'pages_tool');
|
||||||
|
assert.ok(sub, 'pages_tool 分包应存在');
|
||||||
|
const paths = (sub.pages || []).map(p => p.path);
|
||||||
|
assert.ok(paths.includes('card/list'), `card/list 未注册: ${paths.join(',')}`);
|
||||||
|
assert.ok(paths.includes('card/leads'), `card/leads 未注册: ${paths.join(',')}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('选择链接: card 页面不在 TabBar(NO_TAB_PAGES 语义)不影响导航兜底', () => {
|
||||||
|
// card 页不是 tabBar 根页, redirectTo 走 navigateTo 分支;
|
||||||
|
// 此处仅防回归: 确认 util.js 的 TAB_BAR_KEYS 未被误加 card 路径
|
||||||
|
const utilSrc = fs.readFileSync(path.resolve(__dirname, '../common/js/util.js'), 'utf8');
|
||||||
|
const tabBlock = utilSrc.match(/TAB_BAR_KEYS\s*=\s*\{[\s\S]*?\}/);
|
||||||
|
assert.ok(tabBlock, 'TAB_BAR_KEYS 应存在');
|
||||||
|
assert.ok(!/card/.test(tabBlock[0]), 'TAB_BAR_KEYS 不应含 card 路径(card 非 tabBar 页)');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 2.0.0 人脉库: contacts / contact-edit 注册 + 互链 + 鸿蒙安全
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
test('人脉库: PAGE_KEY_MAP 含 PAGE_CARD_CONTACTS', () => {
|
||||||
|
const utilSrc = fs.readFileSync(path.resolve(__dirname, '../common/js/util.js'), 'utf8');
|
||||||
|
const m = utilSrc.match(/PAGE_CARD_CONTACTS:\s*'([^']+)'/);
|
||||||
|
assert.ok(m, 'PAGE_KEY_MAP 应有 PAGE_CARD_CONTACTS');
|
||||||
|
assert.equal(m[1], '/pages_tool/card/contacts');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('人脉库: contacts/contact-edit 已注册进 pages.json 且不在 TabBar', () => {
|
||||||
|
const pagesSrc = fs.readFileSync(path.resolve(__dirname, '../pages.json'), 'utf8');
|
||||||
|
const stripped = pagesSrc.replace(/^\s*\/\/.*$/gm, '').replace(/,(\s*[}\]])/g, '$1');
|
||||||
|
const pages = JSON.parse(stripped);
|
||||||
|
const sub = (pages.subPackages || []).find(sp => sp.root === 'pages_tool');
|
||||||
|
assert.ok(sub, 'pages_tool 分包应存在');
|
||||||
|
const paths = (sub.pages || []).map(p => p.path);
|
||||||
|
assert.ok(paths.includes('card/contacts'), `card/contacts 未注册: ${paths.join(',')}`);
|
||||||
|
assert.ok(paths.includes('card/contact-edit'), `card/contact-edit 未注册: ${paths.join(',')}`);
|
||||||
|
// TabBar 根页不在 pages_tool 分包, 天然不在 TabBar; 防回归: tabBar 列表不得含 card
|
||||||
|
const tabPages = (pages.tabBar && pages.tabBar.list ? pages.tabBar.list : []).map(p => p.pagePath);
|
||||||
|
assert.ok(!tabPages.some(p => /card/.test(p)), 'TabBar 不应含 card 页面');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('人脉库: contacts/leads 顶部互链都走 $util.redirectTo(鸿蒙静默失败回归)', () => {
|
||||||
|
const contactsSrc = read('contacts.vue');
|
||||||
|
const leadsSrc = read('leads.vue');
|
||||||
|
// contacts → leads
|
||||||
|
assert.ok(/goLeads\(\)\s*\{[\s\S]*?\$util\.redirectTo\('\/pages_tool\/card\/leads'\)/.test(contactsSrc),
|
||||||
|
'contacts.vue 顶部「潜在客户」链接必须走 $util.redirectTo');
|
||||||
|
// leads → contacts
|
||||||
|
assert.ok(/goContacts\(\)\s*\{[\s\S]*?\$util\.redirectTo\('\/pages_tool\/card\/contacts'\)/.test(leadsSrc),
|
||||||
|
'leads.vue 顶部「我的人脉」链接必须走 $util.redirectTo');
|
||||||
|
// 两文件不得出现裸 uni.switchTab 调用
|
||||||
|
for (const [name, src] of [['contacts.vue', contactsSrc], ['leads.vue', leadsSrc]]) {
|
||||||
|
assert.ok(!/uni\.switchTab\s*\(/.test(src), `${name} 不得直接调 uni.switchTab`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('人脉库: contact-edit 保存成功回 contacts 走 $util.redirectTo(鸿蒙安全)', () => {
|
||||||
|
const src = read('contact-edit.vue');
|
||||||
|
// backToList 按来源分支: 员工端缺省回 /pages_tool/card/contacts,
|
||||||
|
// 商家端(from=admin)回 /pages_tool/card/admin/contacts(见 card-admin.test.js)
|
||||||
|
const m = src.match(/backToList\(\)\s*\{([\s\S]*?)\n\t\t\}/);
|
||||||
|
assert.ok(m, '应能提取 backToList 方法体');
|
||||||
|
assert.ok(/\$util\.redirectTo/.test(m[1]),
|
||||||
|
'保存成功回列表必须走 $util.redirectTo');
|
||||||
|
assert.ok(m[1].includes("'/pages_tool/card/contacts'"),
|
||||||
|
'员工端缺省应回 /pages_tool/card/contacts');
|
||||||
|
assert.ok(!/uni\.switchTab\s*\(/.test(src), 'contact-edit.vue 不得直接调 uni.switchTab');
|
||||||
|
// 姓名必填: 模板有 req-star + 保存前有空名校验
|
||||||
|
assert.ok(/req-star/.test(src), '姓名必填应有 req-star 红星');
|
||||||
|
assert.ok(/请填写姓名/.test(src), '保存前应校验姓名非空');
|
||||||
|
});
|
||||||
443
tests/pack-harmony-project.test.js
Normal file
443
tests/pack-harmony-project.test.js
Normal file
@@ -0,0 +1,443 @@
|
|||||||
|
/**
|
||||||
|
* scripts/pack-harmony-project.js 单元测试
|
||||||
|
*
|
||||||
|
* 跑法:
|
||||||
|
* node --test tests/pack-harmony-project.test.js
|
||||||
|
*
|
||||||
|
* 为什么这个脚本需要测试:
|
||||||
|
* 它**手写了 ZIP 编码格式**(没有用系统命令 / 第三方库), 而这类代码的典型 bug
|
||||||
|
* 都是静默的 —— 包能生成、看着正常, 但某些机器上解不开或解出畸形路径。
|
||||||
|
* 历史上已经踩过两次:
|
||||||
|
* 1. 漏算文件数据长度, 中央目录偏移偏小 -> "N extra bytes at beginning"
|
||||||
|
* 2. localOffset 记成了数据区起点而非本地头起点 -> "bad zipfile offset"
|
||||||
|
* 两者都不会抛异常, 只有真正解压/解析才会暴露。
|
||||||
|
*
|
||||||
|
* 所以测试分两类:
|
||||||
|
* - 正向: 写出来的 zip 结构合法、内容可还原
|
||||||
|
* - **反向**: 故意损坏 zip, 断言 verifyZipFile 真的能抓到
|
||||||
|
* (否则自检可能形同虚设, 永远返回 true)
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const fsp = require('node:fs/promises');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const zlib = require('node:zlib');
|
||||||
|
|
||||||
|
const {
|
||||||
|
crc32,
|
||||||
|
toDosDateTime,
|
||||||
|
zipDirectory,
|
||||||
|
verifyZipFile,
|
||||||
|
shouldSkipDir,
|
||||||
|
shouldSkipFile,
|
||||||
|
EXCLUDE_DIRS,
|
||||||
|
EXCLUDE_FILES,
|
||||||
|
} = require('../scripts/pack-harmony-project');
|
||||||
|
|
||||||
|
/** 建临时目录, 测试结束自动清理 */
|
||||||
|
function tmpDir(t) {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pack-harmony-test-'));
|
||||||
|
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 建一个夹具目录, 内含各种体积/编码的文件 */
|
||||||
|
function makeFixture(root) {
|
||||||
|
fs.mkdirSync(path.join(root, 'AppScope'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(root, 'a/b/c'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(root, 'empty-dir'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(root, 'AppScope/app.json5'), '{"app":{"bundleName":"x"}}');
|
||||||
|
fs.writeFileSync(path.join(root, 'a/b/c/deep.txt'), 'deep content');
|
||||||
|
// 可压缩内容 (应走 deflate)
|
||||||
|
fs.writeFileSync(path.join(root, 'a/repeatable.txt'), 'abc'.repeat(2000));
|
||||||
|
// 不可压缩内容 (应退回 stored)
|
||||||
|
fs.writeFileSync(path.join(root, 'a/random.bin'), require('node:crypto').randomBytes(4096));
|
||||||
|
// 中文文件名
|
||||||
|
fs.writeFileSync(path.join(root, 'a/中文名.txt'), '中文内容');
|
||||||
|
// 零字节文件
|
||||||
|
fs.writeFileSync(path.join(root, 'a/zero.txt'), '');
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------
|
||||||
|
* 独立 zip 读取器 (刻意不复用写入端实现, 避免自证其说)
|
||||||
|
* 只实现测试需要的最小功能: 按条目名取回内容
|
||||||
|
* ------------------------------------------------------------------------- */
|
||||||
|
function readZipEntries(zipPath) {
|
||||||
|
const buf = fs.readFileSync(zipPath);
|
||||||
|
let eocd = -1;
|
||||||
|
for (let i = buf.length - 22; i >= 0; i--) {
|
||||||
|
if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
|
||||||
|
}
|
||||||
|
assert.ok(eocd !== -1, '应能找到 EOCD');
|
||||||
|
|
||||||
|
const count = buf.readUInt16LE(eocd + 10);
|
||||||
|
let p = buf.readUInt32LE(eocd + 16);
|
||||||
|
const out = new Map();
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
assert.equal(buf.readUInt32LE(p), 0x02014b50, `第 ${i} 条中央目录签名应正确`);
|
||||||
|
const method = buf.readUInt16LE(p + 10);
|
||||||
|
const crc = buf.readUInt32LE(p + 16);
|
||||||
|
const compSize = buf.readUInt32LE(p + 20);
|
||||||
|
const uncompSize = buf.readUInt32LE(p + 24);
|
||||||
|
const nameLen = buf.readUInt16LE(p + 28);
|
||||||
|
const extraLen = buf.readUInt16LE(p + 30);
|
||||||
|
const commentLen = buf.readUInt16LE(p + 32);
|
||||||
|
const localOffset = buf.readUInt32LE(p + 42);
|
||||||
|
const name = buf.subarray(p + 46, p + 46 + nameLen).toString('utf8');
|
||||||
|
|
||||||
|
if (!name.endsWith('/')) {
|
||||||
|
const lNameLen = buf.readUInt16LE(localOffset + 26);
|
||||||
|
const lExtraLen = buf.readUInt16LE(localOffset + 28);
|
||||||
|
const start = localOffset + 30 + lNameLen + lExtraLen;
|
||||||
|
const raw = buf.subarray(start, start + compSize);
|
||||||
|
const data = method === 0 ? raw : zlib.inflateRawSync(raw);
|
||||||
|
assert.equal(data.length, uncompSize, `${name} 解压长度应匹配`);
|
||||||
|
assert.equal(crc32(data), crc, `${name} CRC 应匹配`);
|
||||||
|
out.set(name, data);
|
||||||
|
} else {
|
||||||
|
out.set(name, null); // 目录条目
|
||||||
|
}
|
||||||
|
p += 46 + nameLen + extraLen + commentLen;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================ 纯函数 ============================ */
|
||||||
|
|
||||||
|
test('crc32: 标准校验向量', () => {
|
||||||
|
// CRC-32/ISO-HDLC 的标准 check value
|
||||||
|
assert.equal(crc32(Buffer.from('123456789')), 0xcbf43926);
|
||||||
|
assert.equal(crc32(Buffer.from('')), 0);
|
||||||
|
assert.equal(crc32(Buffer.from('a')), 0xe8b7be43);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toDosDateTime: 字段不溢出且能反解', () => {
|
||||||
|
const d = new Date(2026, 9, 17, 14, 30, 20);
|
||||||
|
const { time, date } = toDosDateTime(d);
|
||||||
|
assert.equal((date >> 9) & 0x7f, 2026 - 1980, '年份');
|
||||||
|
assert.equal((date >> 5) & 0xf, 10, '月份');
|
||||||
|
assert.equal(date & 0x1f, 17, '日');
|
||||||
|
assert.equal((time >> 11) & 0x1f, 14, '时');
|
||||||
|
assert.equal((time >> 5) & 0x3f, 30, '分');
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ======================= 排除规则 (回归锁定) ======================= */
|
||||||
|
|
||||||
|
test('shouldSkipDir: node_modules/oh_modules/build 等一律排除', () => {
|
||||||
|
const opts = { withNodeModules: false, withCerts: true };
|
||||||
|
for (const d of ['node_modules', 'oh_modules', 'build', '.preview', '.idea', '.hvigor', '.cxx', '.test', '.git']) {
|
||||||
|
assert.equal(shouldSkipDir(d, opts, false), true, `${d} 应被排除`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldSkipDir: --with-node-modules 只放开根级 node_modules', () => {
|
||||||
|
const opts = { withNodeModules: true, withCerts: true };
|
||||||
|
assert.equal(shouldSkipDir('node_modules', opts, true), false, '根级 node_modules 应保留');
|
||||||
|
assert.equal(shouldSkipDir('node_modules', opts, false), true, '非根级 node_modules 仍应排除');
|
||||||
|
assert.equal(shouldSkipDir('oh_modules', opts, true), true, 'oh_modules 始终排除');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldSkipDir: cert-profile 受 --no-certs 控制', () => {
|
||||||
|
assert.equal(shouldSkipDir('cert-profile', { withCerts: true }, false), false);
|
||||||
|
assert.equal(shouldSkipDir('cert-profile', { withCerts: false }, false), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldSkipFile: local.properties / 编辑器临时文件排除', () => {
|
||||||
|
for (const f of ['local.properties', '.DS_Store', 'Thumbs.db', '_1.clang-format', '_1.gitignore', 'build.log']) {
|
||||||
|
assert.equal(shouldSkipFile(f), true, `${f} 应被排除`);
|
||||||
|
}
|
||||||
|
// 回归锁定: 这些是**源码**, 不能误伤
|
||||||
|
for (const f of ['module.json5', 'app.json', 'Ability.test.ets', 'List.test.js', 'oh-package.json5']) {
|
||||||
|
assert.equal(shouldSkipFile(f), false, `${f} 不应被排除`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ========================= zip 写入正确性 ========================= */
|
||||||
|
|
||||||
|
test('zipDirectory: 往返后内容与目录结构完全一致', async (t) => {
|
||||||
|
const root = tmpDir(t);
|
||||||
|
const fixture = makeFixture(path.join(root, 'mp-harmony'));
|
||||||
|
const zipPath = path.join(root, 'out.zip');
|
||||||
|
|
||||||
|
const res = await zipDirectory(fixture, zipPath);
|
||||||
|
assert.equal(res.files, 6, '夹具应有 6 个文件 (app.json5/deep.txt/repeatable.txt/random.bin/中文名.txt/zero.txt)');
|
||||||
|
|
||||||
|
const entries = readZipEntries(zipPath);
|
||||||
|
const prefix = 'mp-harmony/';
|
||||||
|
const get = (rel) => entries.get(prefix + rel);
|
||||||
|
|
||||||
|
assert.equal(get('AppScope/app.json5').toString(), '{"app":{"bundleName":"x"}}');
|
||||||
|
assert.equal(get('a/b/c/deep.txt').toString(), 'deep content');
|
||||||
|
assert.equal(get('a/repeatable.txt').toString(), 'abc'.repeat(2000));
|
||||||
|
assert.equal(get('a/中文名.txt').toString(), '中文内容');
|
||||||
|
assert.equal(get('a/zero.txt').length, 0, '零字节文件应存在且为空');
|
||||||
|
assert.ok(get('a/random.bin').equals(fs.readFileSync(path.join(fixture, 'a/random.bin'))));
|
||||||
|
|
||||||
|
// 目录条目: 空目录也要保留
|
||||||
|
assert.ok(entries.has(prefix + 'empty-dir/'), '空目录条目应保留');
|
||||||
|
assert.ok(entries.has(prefix + 'a/b/c/'), '嵌套目录条目应保留');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('zipDirectory: 条目名全部用正斜杠且置 UTF-8 标志位', async (t) => {
|
||||||
|
const root = tmpDir(t);
|
||||||
|
const fixture = makeFixture(path.join(root, 'mp-harmony'));
|
||||||
|
const zipPath = path.join(root, 'out.zip');
|
||||||
|
await zipDirectory(fixture, zipPath);
|
||||||
|
|
||||||
|
const buf = fs.readFileSync(zipPath);
|
||||||
|
let eocd = -1;
|
||||||
|
for (let i = buf.length - 22; i >= 0; i--) if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
|
||||||
|
const count = buf.readUInt16LE(eocd + 10);
|
||||||
|
let p = buf.readUInt32LE(eocd + 16);
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const flags = buf.readUInt16LE(p + 8);
|
||||||
|
const nameLen = buf.readUInt16LE(p + 28);
|
||||||
|
const extraLen = buf.readUInt16LE(p + 30);
|
||||||
|
const commentLen = buf.readUInt16LE(p + 32);
|
||||||
|
const name = buf.subarray(p + 46, p + 46 + nameLen).toString('utf8');
|
||||||
|
|
||||||
|
assert.ok(flags & 0x0800, `第 ${i} 条 ${name} 应置 UTF-8 标志位`);
|
||||||
|
assert.ok(!name.includes('\\'), `第 ${i} 条 ${name} 不应含反斜杠`);
|
||||||
|
assert.ok(!name.startsWith('/'), `第 ${i} 条 ${name} 不应是绝对路径`);
|
||||||
|
assert.ok(!name.includes('//'), `第 ${i} 条 ${name} 不应有连续斜杠`);
|
||||||
|
p += 46 + nameLen + extraLen + commentLen;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verifyZipFile: 合法的包应通过', async (t) => {
|
||||||
|
const root = tmpDir(t);
|
||||||
|
const zipPath = path.join(root, 'out.zip');
|
||||||
|
await zipDirectory(makeFixture(path.join(root, 'mp-harmony')), zipPath);
|
||||||
|
const res = await verifyZipFile(zipPath, { withCerts: false, skipRequired: true });
|
||||||
|
assert.ok(res.files > 0);
|
||||||
|
assert.equal(res.entries, res.files + res.dirs);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ==================== 反向测试: 自检必须能抓到损坏 ==================== */
|
||||||
|
|
||||||
|
test('verifyZipFile: 数据区被篡改应报 CRC 错误', async (t) => {
|
||||||
|
const root = tmpDir(t);
|
||||||
|
const zipPath = path.join(root, 'out.zip');
|
||||||
|
await zipDirectory(makeFixture(path.join(root, 'mp-harmony')), zipPath);
|
||||||
|
|
||||||
|
const buf = fs.readFileSync(zipPath);
|
||||||
|
// 找一个文件条目的数据区并改掉一个字节
|
||||||
|
let eocd = -1;
|
||||||
|
for (let i = buf.length - 22; i >= 0; i--) if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
|
||||||
|
let p = buf.readUInt32LE(eocd + 16);
|
||||||
|
const count = buf.readUInt16LE(eocd + 10);
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const compSize = buf.readUInt32LE(p + 20);
|
||||||
|
const nameLen = buf.readUInt16LE(p + 28);
|
||||||
|
const extraLen = buf.readUInt16LE(p + 30);
|
||||||
|
const commentLen = buf.readUInt16LE(p + 32);
|
||||||
|
const localOffset = buf.readUInt32LE(p + 42);
|
||||||
|
const name = buf.subarray(p + 46, p + 46 + nameLen).toString('utf8');
|
||||||
|
if (!name.endsWith('/') && compSize > 10) {
|
||||||
|
const start = localOffset + 30 + buf.readUInt16LE(localOffset + 26) + buf.readUInt16LE(localOffset + 28);
|
||||||
|
buf[start + 2] ^= 0xff; // 翻转数据区中的一个字节
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
p += 46 + nameLen + extraLen + commentLen;
|
||||||
|
}
|
||||||
|
fs.writeFileSync(zipPath, buf);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => verifyZipFile(zipPath, { withCerts: false, skipRequired: true }),
|
||||||
|
/CRC|解压/,
|
||||||
|
'数据被篡改时 verifyZipFile 必须报错',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verifyZipFile: central directory 偏移错误应被拒绝 (回归: 曾漏算数据长度)', async (t) => {
|
||||||
|
const root = tmpDir(t);
|
||||||
|
const zipPath = path.join(root, 'out.zip');
|
||||||
|
await zipDirectory(makeFixture(path.join(root, 'mp-harmony')), zipPath);
|
||||||
|
|
||||||
|
const buf = fs.readFileSync(zipPath);
|
||||||
|
let eocd = -1;
|
||||||
|
for (let i = buf.length - 22; i >= 0; i--) if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
|
||||||
|
const realOffset = buf.readUInt32LE(eocd + 16);
|
||||||
|
buf.writeUInt32LE(realOffset + 1000, eocd + 16); // 偏移挪 1000 字节
|
||||||
|
fs.writeFileSync(zipPath, buf);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => verifyZipFile(zipPath, { withCerts: false, skipRequired: true }),
|
||||||
|
/中央目录/,
|
||||||
|
'中央目录偏移错误时 verifyZipFile 必须报错',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verifyZipFile: localOffset 指向数据区而非本地头应被拒绝 (回归: 曾记成数据起点)', async (t) => {
|
||||||
|
const root = tmpDir(t);
|
||||||
|
const zipPath = path.join(root, 'out.zip');
|
||||||
|
await zipDirectory(makeFixture(path.join(root, 'mp-harmony')), zipPath);
|
||||||
|
|
||||||
|
const buf = fs.readFileSync(zipPath);
|
||||||
|
let eocd = -1;
|
||||||
|
for (let i = buf.length - 22; i >= 0; i--) if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
|
||||||
|
let p = buf.readUInt32LE(eocd + 16);
|
||||||
|
// 把第一条**文件**记录的 localOffset 加 30 (= 跳到数据区)
|
||||||
|
for (let i = 0; i < buf.readUInt16LE(eocd + 10); i++) {
|
||||||
|
const nameLen = buf.readUInt16LE(p + 28);
|
||||||
|
const extraLen = buf.readUInt16LE(p + 30);
|
||||||
|
const commentLen = buf.readUInt16LE(p + 32);
|
||||||
|
const name = buf.subarray(p + 46, p + 46 + nameLen).toString('utf8');
|
||||||
|
if (!name.endsWith('/')) {
|
||||||
|
buf.writeUInt32LE(buf.readUInt32LE(p + 42) + 30, p + 42);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
p += 46 + nameLen + extraLen + commentLen;
|
||||||
|
}
|
||||||
|
fs.writeFileSync(zipPath, buf);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => verifyZipFile(zipPath, { withCerts: false, skipRequired: true }),
|
||||||
|
/localOffset|中央目录签名/,
|
||||||
|
'localOffset 错误时 verifyZipFile 必须报错',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verifyZipFile: 混入应排除目录应被拒绝 (回归: 曾用子串匹配误伤 ohosTest/ets/test/)', async (t) => {
|
||||||
|
const root = tmpDir(t);
|
||||||
|
const fixture = makeFixture(path.join(root, 'mp-harmony'));
|
||||||
|
// 手工塞一个不该存在的目录
|
||||||
|
fs.mkdirSync(path.join(fixture, 'oh_modules/foo'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(fixture, 'oh_modules/foo/index.js'), 'x');
|
||||||
|
const zipPath = path.join(root, 'out.zip');
|
||||||
|
await zipDirectory(fixture, zipPath);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => verifyZipFile(zipPath, { withCerts: false, skipRequired: true }),
|
||||||
|
/应排除/,
|
||||||
|
'混入 oh_modules 时 verifyZipFile 必须报错',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verifyZipFile: 合法源码文件不应被误判为应排除 (回归锁定)', async (t) => {
|
||||||
|
const root = tmpDir(t);
|
||||||
|
const fixture = makeFixture(path.join(root, 'mp-harmony'));
|
||||||
|
// 这些路径含 .test / test 字样, 但是**源码**, 必须放行
|
||||||
|
fs.mkdirSync(path.join(fixture, 'entry/src/ohosTest/ets/test'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(fixture, 'entry/src/ohosTest/ets/test/Ability.test.ets'), 'x');
|
||||||
|
fs.mkdirSync(path.join(fixture, 'entry/src/test'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(fixture, 'entry/src/test/List.test.ets'), 'x');
|
||||||
|
const zipPath = path.join(root, 'out.zip');
|
||||||
|
await zipDirectory(fixture, zipPath);
|
||||||
|
|
||||||
|
// 不应抛错 (夹具缺必需文件, 用 skipRequired 跳过该断言)
|
||||||
|
const res = await verifyZipFile(zipPath, { withCerts: false, skipRequired: true });
|
||||||
|
assert.ok(res.files > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verifyZipFile: --no-certs 时出现 cert-profile 应被拒绝', async (t) => {
|
||||||
|
const root = tmpDir(t);
|
||||||
|
const fixture = makeFixture(path.join(root, 'mp-harmony'));
|
||||||
|
fs.mkdirSync(path.join(fixture, 'cert-profile/qq'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(fixture, 'cert-profile/qq/qq.p12'), 'FAKE');
|
||||||
|
const zipPath = path.join(root, 'out.zip');
|
||||||
|
await zipDirectory(fixture, zipPath);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => verifyZipFile(zipPath, { withCerts: false, skipRequired: true }),
|
||||||
|
/nocerts|cert-profile/i,
|
||||||
|
'--no-certs 下混入证书必须报错',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ============================ 导出骨架 ============================ */
|
||||||
|
|
||||||
|
test('verifyZipFile: 漏拷应被拒绝 (expectFiles 与 zip 内容不符)', async (t) => {
|
||||||
|
const root = tmpDir(t);
|
||||||
|
const fixture = makeFixture(path.join(root, 'mp-harmony'));
|
||||||
|
const zipPath = path.join(root, 'out.zip');
|
||||||
|
await zipDirectory(fixture, zipPath);
|
||||||
|
|
||||||
|
// 假装 EntryCard/entry/base/snapshot/widget-2x2.png 被复制了, 但 zip 里其实没有
|
||||||
|
const expectFiles = [
|
||||||
|
'mp-harmony/AppScope/app.json5',
|
||||||
|
'mp-harmony/a/b/c/deep.txt',
|
||||||
|
'mp-harmony/a/repeatable.txt',
|
||||||
|
'mp-harmony/a/random.bin',
|
||||||
|
'mp-harmony/a/中文名.txt',
|
||||||
|
'mp-harmony/a/zero.txt',
|
||||||
|
'mp-harmony/EntryCard/entry/base/snapshot/widget-2x2.png',
|
||||||
|
];
|
||||||
|
await assert.rejects(
|
||||||
|
() => verifyZipFile(zipPath, { withCerts: false, skipRequired: true, expectFiles }),
|
||||||
|
/漏拷/,
|
||||||
|
'expectFiles 里多出的文件必须被报为漏拷',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verifyZipFile: 多拷应被拒绝 (zip 内出现复制清单外的文件)', async (t) => {
|
||||||
|
const root = tmpDir(t);
|
||||||
|
const fixture = makeFixture(path.join(root, 'mp-harmony'));
|
||||||
|
const zipPath = path.join(root, 'out.zip');
|
||||||
|
await zipDirectory(fixture, zipPath);
|
||||||
|
|
||||||
|
// 清单里故意少写一个, zip 里却存在 -> 应报多拷
|
||||||
|
const expectFiles = [
|
||||||
|
'mp-harmony/AppScope/app.json5',
|
||||||
|
'mp-harmony/a/b/c/deep.txt',
|
||||||
|
'mp-harmony/a/repeatable.txt',
|
||||||
|
'mp-harmony/a/random.bin',
|
||||||
|
'mp-harmony/a/中文名.txt',
|
||||||
|
// 漏掉 a/zero.txt
|
||||||
|
];
|
||||||
|
await assert.rejects(
|
||||||
|
() => verifyZipFile(zipPath, { withCerts: false, skipRequired: true, expectFiles }),
|
||||||
|
/多拷/,
|
||||||
|
'清单外的 zip 文件必须被报为多拷',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verifyZipFile: 清单完全吻合时应通过 (含 README.txt 豁免)', async (t) => {
|
||||||
|
const root = tmpDir(t);
|
||||||
|
const fixture = makeFixture(path.join(root, 'mp-harmony'));
|
||||||
|
fs.writeFileSync(path.join(fixture, 'README.txt'), '# 说明');
|
||||||
|
const zipPath = path.join(root, 'out.zip');
|
||||||
|
await zipDirectory(fixture, zipPath);
|
||||||
|
|
||||||
|
const expectFiles = [
|
||||||
|
'mp-harmony/AppScope/app.json5',
|
||||||
|
'mp-harmony/a/b/c/deep.txt',
|
||||||
|
'mp-harmony/a/repeatable.txt',
|
||||||
|
'mp-harmony/a/random.bin',
|
||||||
|
'mp-harmony/a/中文名.txt',
|
||||||
|
'mp-harmony/a/zero.txt',
|
||||||
|
'mp-harmony/README.txt',
|
||||||
|
];
|
||||||
|
const res = await verifyZipFile(zipPath, { withCerts: false, skipRequired: true, expectFiles });
|
||||||
|
assert.equal(res.files, 7);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('copyDir: 排除规则在真实复制中生效', async (t) => {
|
||||||
|
const root = tmpDir(t);
|
||||||
|
const src = path.join(root, 'src');
|
||||||
|
const dest = path.join(root, 'dest');
|
||||||
|
|
||||||
|
fs.mkdirSync(path.join(src, 'keep'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(src, 'node_modules/pkg'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(src, 'build'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(src, 'keep/a.txt'), 'a');
|
||||||
|
fs.writeFileSync(path.join(src, 'node_modules/pkg/i.js'), 'i');
|
||||||
|
fs.writeFileSync(path.join(src, 'build/out.txt'), 'o');
|
||||||
|
fs.writeFileSync(path.join(src, 'local.properties'), 'sdk.dir=x');
|
||||||
|
|
||||||
|
const { copyDir } = require('../scripts/pack-harmony-project');
|
||||||
|
await copyDir(src, dest, { withNodeModules: false, withCerts: true });
|
||||||
|
|
||||||
|
assert.ok(fs.existsSync(path.join(dest, 'keep/a.txt')), '正常文件应保留');
|
||||||
|
assert.ok(!fs.existsSync(path.join(dest, 'node_modules')), 'node_modules 应排除');
|
||||||
|
assert.ok(!fs.existsSync(path.join(dest, 'build')), 'build 应排除');
|
||||||
|
assert.ok(!fs.existsSync(path.join(dest, 'local.properties')), 'local.properties 应排除');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user