diff --git a/common/js/http.js b/common/js/http.js index 84f37f3..70804e3 100644 --- a/common/js/http.js +++ b/common/js/http.js @@ -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(/(? { + 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) { console.log('[HTTP][REQUEST] upload', Config.baseUrl + params.url, 'name=', params.name || 'file'); uni.uploadFile({ diff --git a/common/js/util.js b/common/js/util.js index 4ba077c..a5005f4 100644 --- a/common/js/util.js +++ b/common/js/util.js @@ -200,6 +200,10 @@ export const PAGE_KEY_MAP = { // === 电子名片 === PAGE_CONTACT_CARD: '/pages/contact/contact', + // === 名片云(card 插件) === + PAGE_CARD_LIST: '/pages_tool/card/list', + PAGE_CARD_LEADS: '/pages_tool/card/leads', + // === VR === PAGE_VR_INDEX: '/pages_tool/vr/index', diff --git a/pages.json b/pages.json index d35d7be..cc3d79a 100644 --- a/pages.json +++ b/pages.json @@ -1480,6 +1480,26 @@ "navigationBarTitleText": "名片详情" } }, + { + "path": "card/edit", + "style": { + "navigationBarTitleText": "编辑名片" + } + }, + { + "path": "card/leads", + "style": { + "navigationBarTitleText": "我的线索", + "enablePullDownRefresh": true + } + }, + { + "path": "card/share", + "style": { + "navigationBarTitleText": "名片分享", + "navigationStyle": "custom" + } + }, { "path": "member/card", "style": { diff --git a/pages_tool/card/detail.vue b/pages_tool/card/detail.vue index 3884526..923b411 100644 --- a/pages_tool/card/detail.vue +++ b/pages_tool/card/detail.vue @@ -3,7 +3,8 @@ - {{ avatarText(relation.name) }} + + {{ avatarText(relation.name) }} {{ relation.name || '未命名名片' }} @@ -11,13 +12,17 @@ {{ [relation.job_title, relation.company].filter(Boolean).join(' · ') }} + + 分享 + 编辑 + - + {{ row.label }} - {{ row.value }} + {{ row.value }} {{ row.action.text }} @@ -25,6 +30,38 @@ + + + + 加载中… @@ -43,7 +80,10 @@ export default { source: null, shareList: [], isLoading: true, - errorMsg: '' + errorMsg: '', + showShare: false, + shareUrl: '', + qrUrl: '' }; }, computed: { @@ -68,6 +108,27 @@ export default { push('Telegram', r.telegram); push('Messenger', r.messenger); + // 附加手机号(mobiles_list, 后端 CardRichFields 解码) → 可拨打 + (r.mobiles_list || []).forEach((m, i) => { + push('手机' + (i + 2), m, { type: 'call', text: '拨打' }); + }); + + // 社交账号(socials_list: {type,label,value}) → label 作行名, 值可复制 + // (完整主页链接与纯账号统一复制 value, 链接可粘贴到浏览器打开) + (r.socials_list || []).forEach(s => { + push(s.label || s.type, s.value, { type: 'copy', text: '复制' }); + }); + + // 自定义字段(custom_list: {label,value}) + (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; } }, @@ -75,7 +136,30 @@ export default { this.cardId = decodeURIComponent(options.card_id || ''); this.loadDetail(); }, + onShow() { + // 首次进入由 onLoad 负责加载; 从编辑页返回时(onShow 再次触发)刷新 + if (this._loadedOnce) { + this.loadDetail(); + } + this._loadedOnce = true; + }, + // 小程序端转发: 分享 web-view 中转页(其内部加载 wap H5 名片页), + // 因为 onShareAppMessage 的 path 只能指向小程序页面, 不能直接分享 H5 链接 + onShareAppMessage() { + const name = (this.relation && this.relation.name) || '我的名片'; + return { + title: name + '的名片', + path: '/pages_tool/card/share?url=' + encodeURIComponent(this.shareUrl || ''), + imageUrl: (this.relation && this.relation.avatar) ? this.$util.img(this.relation.avatar) : '' + }; + }, methods: { + goEdit() { + if (!this.cardId) return; + uni.navigateTo({ + url: '/pages_tool/card/edit?card_id=' + encodeURIComponent(this.cardId) + }); + }, avatarText(name) { return (name || '?').trim().charAt(0).toUpperCase(); }, @@ -120,6 +204,68 @@ export default { uni.showToast({ title: '已复制', icon: 'success' }); } }); + }, + + /* ---------- 分享 ---------- */ + siteId() { + return uni.getStorageSync('uniacid') || this.$config.uniacid || 0; + }, + openShare() { + if (!this.relation) return; + const sid = this.siteId(); + const rid = this.relation.id; + const base = this.$config.baseUrl || ''; + // wap 公开名片页(客户视角, 无需登录) + this.shareUrl = base + '/card/wap/index/detail?site_id=' + sid + '&id=' + rid; + // 二维码由后端同源生成(项目内置 extend\QRcode), 与链接一一对应 + this.qrUrl = base + '/card/wap/index/qr?site_id=' + sid + '&id=' + rid; + this.showShare = true; + }, + closeShare() { + this.showShare = false; + }, + copyLink() { + if (!this.shareUrl) return; + uni.setClipboardData({ + data: this.shareUrl, + success: () => { + uni.showToast({ title: '链接已复制', icon: 'success' }); + } + }); + }, + saveQr() { + if (!this.qrUrl) return; + uni.downloadFile({ + url: this.qrUrl, + success: res => { + if (res.statusCode !== 200 || !res.tempFilePath) { + uni.showToast({ title: '二维码下载失败', icon: 'none' }); + return; + } + uni.saveImageToPhotosAlbum({ + filePath: res.tempFilePath, + success: () => uni.showToast({ title: '已保存到相册', icon: 'success' }), + // 文案不提「长按保存」: 鸿蒙元服务的原生 image 无长按菜单, 只微信/H5 有 + fail: () => uni.showToast({ title: '保存失败,可复制链接分享', icon: 'none' }) + }); + }, + fail: () => uni.showToast({ title: '二维码下载失败', icon: 'none' }) + }); + }, + shareToFriend() { + // 小程序右上角菜单转发(onshareappmessage 已配置)或提示用户点击右上角 + uni.showToast({ title: '请点击右上角「···」转发给好友', icon: 'none' }); + }, + h5Share() { + // H5: 优先 navigator.share(移动浏览器), 不支持则复制链接兜底 + if (navigator && typeof navigator.share === 'function') { + navigator.share({ + title: ((this.relation && this.relation.name) || '我的名片') + '的名片', + url: this.shareUrl + }).catch(() => {}); + return; + } + this.copyLink(); } } }; @@ -152,9 +298,16 @@ export default { justify-content: center; flex-shrink: 0; margin-right: 28rpx; + overflow: hidden; + + .head-img { + width: 100%; + height: 100%; + } } .head-info { + flex: 1; min-width: 0; .head-name { @@ -168,6 +321,139 @@ export default { margin-top: 12rpx; } } + + .head-actions { + flex-shrink: 0; + margin-left: 20rpx; + display: flex; + align-items: center; + } + + .head-btn { + font-size: 24rpx; + color: #ffffff; + border: 1rpx solid rgba(255, 255, 255, 0.8); + border-radius: 28rpx; + padding: 8rpx 24rpx; + + & + .head-btn { + margin-left: 12rpx; + } + } +} + +/* 分享面板(微信式底部抽屉, 与 wap 留资抽屉同风格) */ +.share-mask { + position: fixed; + left: 0; + right: 0; + top: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + z-index: 100; + opacity: 0; + pointer-events: none; + transition: opacity 0.25s; +} + +.share-mask { + opacity: 1; + pointer-events: auto; +} + +.share-sheet { + position: fixed; + left: 0; + right: 0; + bottom: 0; + background-color: #ffffff; + border-radius: 24rpx 24rpx 0 0; + padding: 20rpx 32rpx 40rpx; + z-index: 101; + transform: translateY(100%); + transition: transform 0.25s; + + &.share-sheet-show { + transform: translateY(0); + } + + .ss-bar { + width: 36rpx; + height: 4rpx; + border-radius: 2rpx; + background-color: #e3e7ee; + margin: 0 auto 20rpx; + } + + .ss-title { + font-size: 32rpx; + font-weight: 600; + color: #1a1f36; + text-align: center; + } + + .ss-sub { + font-size: 24rpx; + color: #9aa4b8; + text-align: center; + margin: 8rpx 0 24rpx; + } + + .ss-qr-box { + display: flex; + justify-content: center; + margin-bottom: 20rpx; + + .ss-qr { + width: 320rpx; + height: 320rpx; + } + } + + .ss-link { + font-size: 22rpx; + color: #9aa4b8; + background-color: #f6f7f9; + border-radius: 8rpx; + padding: 14rpx 20rpx; + margin-bottom: 24rpx; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + .ss-btns { + display: flex; + justify-content: space-between; + + .ss-btn { + flex: 1; + height: 80rpx; + line-height: 80rpx; + text-align: center; + font-size: 28rpx; + color: #333; + border: 1rpx solid #dcdfe6; + border-radius: 12rpx; + + & + .ss-btn { + margin-left: 20rpx; + } + } + + .ss-btn-main { + background-color: var(--main-color, #0054a5); + border-color: var(--main-color, #0054a5); + color: #ffffff; + } + } + + .ss-tip { + text-align: center; + font-size: 22rpx; + color: #c0c4cc; + margin-top: 20rpx; + } } .detail-fields { @@ -182,6 +468,10 @@ export default { padding: 28rpx 24rpx; border-bottom: 1rpx solid #f0f0f0; + &.field-row-multi { + align-items: flex-start; + } + &:last-child { border-bottom: none; } @@ -200,6 +490,11 @@ export default { word-break: break-all; } + .field-value-multi { + line-height: 1.6; + white-space: pre-wrap; + } + .field-action { margin-left: 20rpx; flex-shrink: 0; diff --git a/pages_tool/card/edit.vue b/pages_tool/card/edit.vue new file mode 100644 index 0000000..30edf76 --- /dev/null +++ b/pages_tool/card/edit.vue @@ -0,0 +1,570 @@ +