From b5bc4cd4054023f9a8a96226fef0fc5721c05261 Mon Sep 17 00:00:00 2001 From: 18631081161 <2088094923@qq.com> Date: Wed, 21 Jan 2026 19:36:36 +0800 Subject: [PATCH] =?UTF-8?q?=E8=81=8A=E5=A4=A9=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/src/api/config.ts | 14 + admin/src/api/user.ts | 10 + admin/src/views/system/config.vue | 91 +- admin/src/views/user/detail.vue | 41 +- .../properties/exchange.property.test.js | 5 +- miniapp/api/chat.js | 26 +- miniapp/api/interact.js | 12 + miniapp/config/index.js | 2 +- miniapp/pages.json | 7 + miniapp/pages/butler/index.vue | 304 +++++ miniapp/pages/chat/index.vue | 217 +++- miniapp/pages/index/index.vue | 100 +- miniapp/pages/message/index.vue | 1156 ++++++++--------- miniapp/pages/mine/index.vue | 187 +-- miniapp/pages/profile/detail.vue | 115 +- miniapp/store/chat.js | 13 +- miniapp/store/config.js | 2 + .../Controllers/AdminConfigController.cs | 50 + .../Controllers/AdminUserController.cs | 25 + .../Controllers/ChatController.cs | 23 + .../Controllers/InteractController.cs | 40 + .../Interfaces/IAdminUserService.cs | 9 + .../Interfaces/IConfigService.cs | 5 + .../Interfaces/ISystemConfigService.cs | 10 + .../Services/AdminUserService.cs | 26 + .../Services/ConfigService.cs | 2 + .../Services/InteractService.cs | 4 +- .../Services/SystemConfigService.cs | 17 + 28 files changed, 1802 insertions(+), 711 deletions(-) create mode 100644 miniapp/pages/butler/index.vue diff --git a/admin/src/api/config.ts b/admin/src/api/config.ts index c3e2d4c..e5f8c53 100644 --- a/admin/src/api/config.ts +++ b/admin/src/api/config.ts @@ -76,3 +76,17 @@ export function getSearchBanner() { export function setSearchBanner(imageUrl: string) { return request.post('/admin/config/searchBanner', { imageUrl }) } + +/** + * 获取管家二维码 + */ +export function getButlerQrcode() { + return request.get('/admin/config/butlerQrcode') +} + +/** + * 设置管家二维码 + */ +export function setButlerQrcode(imageUrl: string) { + return request.post('/admin/config/butlerQrcode', { imageUrl }) +} diff --git a/admin/src/api/user.ts b/admin/src/api/user.ts index 1d4c452..9028f51 100644 --- a/admin/src/api/user.ts +++ b/admin/src/api/user.ts @@ -66,3 +66,13 @@ export function createTestUsers(count: number, gender?: number): Promise { return request.delete(`/admin/users/${id}`) } + +/** + * 更新用户联系次数 + * @param id 用户ID + * @param contactCount 联系次数 + * @returns 操作结果 + */ +export function updateContactCount(id: number, contactCount: number): Promise { + return request.put(`/admin/users/${id}/contact-count`, { contactCount }) +} diff --git a/admin/src/views/system/config.vue b/admin/src/views/system/config.vue index 51c6258..f081b3e 100644 --- a/admin/src/views/system/config.vue +++ b/admin/src/views/system/config.vue @@ -57,6 +57,29 @@ + + +
+ + + + +
+

建议尺寸:300x300像素

+

支持格式:JPG、PNG

+

小程序"联系我们"页面展示的二维码

+
+
+
+ 保存配置 @@ -119,7 +142,9 @@ import { getPrivacyPolicy, setPrivacyPolicy, getSearchBanner, - setSearchBanner + setSearchBanner, + getButlerQrcode, + setButlerQrcode } from '@/api/config' import { useUserStore } from '@/stores/user' @@ -131,7 +156,8 @@ const activeTab = ref('basic') const configForm = ref({ defaultAvatar: '', - searchBanner: '' + searchBanner: '', + butlerQrcode: '' }) const agreementForm = ref({ @@ -157,9 +183,10 @@ const getFullUrl = (url) => { const loadConfig = async () => { try { - const [avatarRes, bannerRes] = await Promise.all([ + const [avatarRes, bannerRes, qrcodeRes] = await Promise.all([ getDefaultAvatar(), - getSearchBanner() + getSearchBanner(), + getButlerQrcode() ]) if (avatarRes) { configForm.value.defaultAvatar = avatarRes.avatarUrl || '' @@ -167,6 +194,9 @@ const loadConfig = async () => { if (bannerRes) { configForm.value.searchBanner = bannerRes.imageUrl || '' } + if (qrcodeRes) { + configForm.value.butlerQrcode = qrcodeRes.imageUrl || '' + } } catch (error) { console.error('加载配置失败:', error) } @@ -209,6 +239,15 @@ const handleBannerSuccess = (response) => { } } +const handleQrcodeSuccess = (response) => { + if (response.code === 0 && response.data) { + configForm.value.butlerQrcode = response.data.url + ElMessage.success('上传成功') + } else { + ElMessage.error(response.message || '上传失败') + } +} + const beforeAvatarUpload = (file) => { const isImage = file.type.startsWith('image/') const isLt2M = file.size / 1024 / 1024 < 2 @@ -234,6 +273,9 @@ const saveBasicConfig = async () => { if (configForm.value.searchBanner) { promises.push(setSearchBanner(configForm.value.searchBanner)) } + if (configForm.value.butlerQrcode) { + promises.push(setButlerQrcode(configForm.value.butlerQrcode)) + } if (promises.length > 0) { await Promise.all(promises) ElMessage.success('保存成功') @@ -401,6 +443,47 @@ onMounted(() => { object-fit: cover; } +.qrcode-upload { + display: flex; + align-items: flex-start; + gap: 20px; +} + +.qrcode-uploader { + width: 150px; + height: 150px; +} + +.qrcode-uploader :deep(.el-upload) { + border: 1px dashed var(--el-border-color); + border-radius: 6px; + cursor: pointer; + position: relative; + overflow: hidden; + transition: var(--el-transition-duration-fast); + width: 150px; + height: 150px; + display: flex; + align-items: center; + justify-content: center; +} + +.qrcode-uploader :deep(.el-upload:hover) { + border-color: var(--el-color-primary); +} + +.qrcode-uploader-icon { + font-size: 28px; + color: #8c939d; +} + +.qrcode-preview { + width: 150px; + height: 150px; + display: block; + object-fit: cover; +} + .agreement-editor { padding: 20px 0; } diff --git a/admin/src/views/user/detail.vue b/admin/src/views/user/detail.vue index 8f6e1d9..7772e57 100644 --- a/admin/src/views/user/detail.vue +++ b/admin/src/views/user/detail.vue @@ -8,7 +8,7 @@ import { useRoute, useRouter } from 'vue-router' import { ElMessage, ElMessageBox } from 'element-plus' import { ArrowLeft } from '@element-plus/icons-vue' import StatusTag from '@/components/StatusTag/index.vue' -import { getUserDetail, updateUserStatus } from '@/api/user' +import { getUserDetail, updateUserStatus, updateContactCount } from '@/api/user' import { getFullImageUrl } from '@/utils/image' import type { UserDetail } from '@/types/user.d' @@ -78,6 +78,34 @@ const handleToggleStatus = async () => { } } +// 修改联系次数 +const handleEditContactCount = async () => { + if (!userDetail.value) return + + try { + const { value } = await ElMessageBox.prompt( + '请输入新的联系次数', + '修改联系次数', + { + confirmButtonText: '确定', + cancelButtonText: '取消', + inputValue: String(userDetail.value.contactCount), + inputPattern: /^\d+$/, + inputErrorMessage: '请输入有效的数字' + } + ) + + const newCount = parseInt(value, 10) + await updateContactCount(userId.value, newCount) + ElMessage.success('修改成功') + fetchUserDetail() + } catch (error) { + if (error !== 'cancel') { + console.error('修改联系次数失败:', error) + } + } +} + // 格式化时间 const formatTime = (time: string) => { if (!time) return '-' @@ -280,7 +308,16 @@ onMounted(() => { {{ formatTime(userDetail.memberExpireTime) }} - {{ userDetail.contactCount }} + {{ userDetail.contactCount }} + + 修改 + {{ formatTime(userDetail.createTime) }} diff --git a/miniapp/__tests__/properties/exchange.property.test.js b/miniapp/__tests__/properties/exchange.property.test.js index f141dac..989ad73 100644 --- a/miniapp/__tests__/properties/exchange.property.test.js +++ b/miniapp/__tests__/properties/exchange.property.test.js @@ -9,13 +9,16 @@ import * as fc from 'fast-check' /** * Message type constants + * 与后端 MessageType 枚举保持一致 */ const MessageType = { TEXT: 1, VOICE: 2, IMAGE: 3, EXCHANGE_WECHAT: 4, - EXCHANGE_PHOTO: 5 + EXCHANGE_WECHAT_RESULT: 5, + EXCHANGE_PHOTO: 6, + EXCHANGE_PHOTO_RESULT: 7 } /** diff --git a/miniapp/api/chat.js b/miniapp/api/chat.js index 191d4ac..78a1013 100644 --- a/miniapp/api/chat.js +++ b/miniapp/api/chat.js @@ -16,9 +16,21 @@ export async function getSessions() { const response = await get('/chat/sessions') // 统一返回格式 if (response && response.code === 0) { + // 后端字段映射到前端字段 + const list = Array.isArray(response.data) ? response.data : (response.data?.list || []) + const mappedList = list.map(item => ({ + sessionId: item.sessionId, + targetUserId: item.otherUserId, + targetNickname: item.otherNickname, + targetAvatar: item.otherAvatar, + lastMessage: item.lastMessageContent, + lastMessageType: item.lastMessageType, + lastMessageTime: item.lastMessageTime, + unreadCount: item.unreadCount + })) return { success: true, - data: Array.isArray(response.data) ? response.data : (response.data?.list || []) + data: mappedList } } return { success: false, data: [] } @@ -104,6 +116,17 @@ export async function getUnreadCount() { return response } +/** + * 获取或创建会话 + * + * @param {number} targetUserId - 目标用户ID + * @returns {Promise} 会话ID + */ +export async function getOrCreateSession(targetUserId) { + const response = await get('/chat/session', { targetUserId }) + return response +} + /** * 上传语音文件 * @@ -153,5 +176,6 @@ export default { exchangePhoto, respondExchange, getUnreadCount, + getOrCreateSession, uploadVoice } diff --git a/miniapp/api/interact.js b/miniapp/api/interact.js index ee9a959..6da7c66 100644 --- a/miniapp/api/interact.js +++ b/miniapp/api/interact.js @@ -137,10 +137,22 @@ export async function getMyUnlocked(pageIndex = 1, pageSize = 20) { return response } +/** + * 检查是否已解锁目标用户 + * + * @param {number} targetUserId - 目标用户ID + * @returns {Promise} 解锁状态 { isUnlocked, remainingUnlockQuota } + */ +export async function checkUnlock(targetUserId) { + const response = await get('/interact/checkUnlock', { targetUserId }) + return response +} + export default { recordView, favorite, unlock, + checkUnlock, report, getViewedMe, getMyViewed, diff --git a/miniapp/config/index.js b/miniapp/config/index.js index 9260896..1887089 100644 --- a/miniapp/config/index.js +++ b/miniapp/config/index.js @@ -21,7 +21,7 @@ const ENV = { } // 当前环境 - 开发时使用 development,打包时改为 production -const CURRENT_ENV = 'development' +const CURRENT_ENV = 'production' // 导出配置 export const config = { diff --git a/miniapp/pages.json b/miniapp/pages.json index 0c15157..29cb823 100644 --- a/miniapp/pages.json +++ b/miniapp/pages.json @@ -141,6 +141,13 @@ "navigationStyle": "custom", "navigationBarTitleText": "协议" } + }, + { + "path": "pages/butler/index", + "style": { + "navigationStyle": "custom", + "navigationBarTitleText": "联系我们" + } } ], "globalStyle": { diff --git a/miniapp/pages/butler/index.vue b/miniapp/pages/butler/index.vue new file mode 100644 index 0000000..bda187b --- /dev/null +++ b/miniapp/pages/butler/index.vue @@ -0,0 +1,304 @@ + + + + + diff --git a/miniapp/pages/chat/index.vue b/miniapp/pages/chat/index.vue index f52d8bc..ce6e653 100644 --- a/miniapp/pages/chat/index.vue +++ b/miniapp/pages/chat/index.vue @@ -3,13 +3,29 @@ + + + + + {{ targetNickname }}的联系电话 + × + + + {{ targetUserDetail?.phone || '暂无电话' }} + + + + + - 详情资料 + 聊天 ··· @@ -21,6 +37,7 @@ { inputText.value = '' + const localId = Date.now() const localMessage = { - id: Date.now(), + id: localId, sessionId: sessionId.value, senderId: myUserId.value, receiverId: targetUserId.value, @@ -516,19 +537,25 @@ const handleSendMessage = async () => { content }) - if (res && res.code === 0) { - localMessage.status = MessageStatus.SENT - if (res.data && res.data.id) { - localMessage.id = res.data.id - } else if (res.data && res.data.messageId) { - // 后端返回 MessageId 字段 - localMessage.id = res.data.messageId + // 找到消息并更新状态 + const msgIndex = messages.value.findIndex(m => m.id === localId) + if (msgIndex !== -1) { + if (res && res.code === 0) { + messages.value[msgIndex].status = MessageStatus.SENT + if (res.data && res.data.id) { + messages.value[msgIndex].id = res.data.id + } else if (res.data && res.data.messageId) { + messages.value[msgIndex].id = res.data.messageId + } + } else { + messages.value[msgIndex].status = MessageStatus.FAILED } - } else { - localMessage.status = MessageStatus.FAILED } } catch (error) { - localMessage.status = MessageStatus.FAILED + const msgIndex = messages.value.findIndex(m => m.id === localId) + if (msgIndex !== -1) { + messages.value[msgIndex].status = MessageStatus.FAILED + } uni.showToast({ title: '发送失败', icon: 'none' }) } } @@ -537,7 +564,10 @@ const handleSendMessage = async () => { const handleRetryMessage = async (message) => { if (message.status !== MessageStatus.FAILED) return - message.status = MessageStatus.SENDING + const msgIndex = messages.value.findIndex(m => m.id === message.id) + if (msgIndex === -1) return + + messages.value[msgIndex].status = MessageStatus.SENDING try { const res = await sendMessage({ @@ -548,17 +578,17 @@ const handleRetryMessage = async (message) => { }) if (res && res.code === 0) { - message.status = MessageStatus.SENT + messages.value[msgIndex].status = MessageStatus.SENT if (res.data?.messageId) { - message.id = res.data.messageId + messages.value[msgIndex].id = res.data.messageId } uni.showToast({ title: '发送成功', icon: 'success' }) } else { - message.status = MessageStatus.FAILED + messages.value[msgIndex].status = MessageStatus.FAILED uni.showToast({ title: '发送失败', icon: 'none' }) } } catch (error) { - message.status = MessageStatus.FAILED + messages.value[msgIndex].status = MessageStatus.FAILED uni.showToast({ title: '发送失败', icon: 'none' }) } } @@ -593,8 +623,9 @@ const handleSendVoice = async (voiceData) => { const voiceUrl = uploadRes.data.url // 创建本地消息 + const localId = Date.now() const localMessage = { - id: Date.now(), + id: localId, sessionId: sessionId.value, senderId: myUserId.value, receiverId: targetUserId.value, @@ -621,13 +652,17 @@ const handleSendVoice = async (voiceData) => { uni.hideLoading() - if (res && res.code === 0) { - localMessage.status = MessageStatus.SENT - if (res.data && res.data.messageId) { - localMessage.id = res.data.messageId + // 找到消息并更新状态 + const msgIndex = messages.value.findIndex(m => m.id === localId) + if (msgIndex !== -1) { + if (res && res.code === 0) { + messages.value[msgIndex].status = MessageStatus.SENT + if (res.data && res.data.messageId) { + messages.value[msgIndex].id = res.data.messageId + } + } else { + messages.value[msgIndex].status = MessageStatus.FAILED } - } else { - localMessage.status = MessageStatus.FAILED } } else { uni.hideLoading() @@ -823,7 +858,23 @@ const previewPhotos = (photos, index) => { // 拨打电话 const handleCall = () => { - uni.showToast({ title: '请通过微信联系对方', icon: 'none' }) + showPhonePopup.value = true +} + +// 实际拨打电话 +const handleMakeCall = () => { + const phone = targetUserDetail.value?.phone + if (!phone) { + uni.showToast({ title: '暂无电话号码', icon: 'none' }) + return + } + + uni.makePhoneCall({ + phoneNumber: phone, + fail: (err) => { + console.error('拨打电话失败:', err) + } + }) } // 查看用户资料 @@ -865,7 +916,11 @@ const previewImage = (url) => { } const scrollToBottom = () => { - scrollToId.value = 'bottom-anchor' + // 先清空再设置,确保每次都能触发滚动 + scrollToId.value = '' + nextTick(() => { + scrollToId.value = 'bottom-anchor' + }) } const handleBack = () => { @@ -908,13 +963,36 @@ onMounted(async () => { // 加载目标用户详情 loadTargetUserDetail() + // 如果没有sessionId但有targetUserId,先获取或创建会话 + if (!sessionId.value && targetUserId.value) { + try { + uni.showLoading({ title: '加载中...' }) + const res = await getOrCreateSession(targetUserId.value) + uni.hideLoading() + + if (res && res.code === 0 && res.data) { + sessionId.value = res.data + } else { + uni.showToast({ title: res?.message || '创建会话失败', icon: 'none' }) + setTimeout(() => { + uni.navigateBack() + }, 1500) + return + } + } catch (error) { + uni.hideLoading() + console.error('获取会话失败:', error) + uni.showToast({ title: '网络错误', icon: 'none' }) + setTimeout(() => { + uni.navigateBack() + }, 1500) + return + } + } + if (sessionId.value) { chatStore.setCurrentSession(sessionId.value) loadMessages() - } else if (targetUserId.value) { - sessionId.value = targetUserId.value - chatStore.setCurrentSession(sessionId.value) - loadMessages() } else { uni.showToast({ title: '参数错误', icon: 'none' }) setTimeout(() => { @@ -1061,7 +1139,8 @@ onUnmounted(() => { display: flex; flex-direction: column; height: 100vh; - background-color: #f5f6fa; + background-color: #f8f8f8; + overflow: hidden; } // 自定义导航栏 @@ -1116,7 +1195,9 @@ onUnmounted(() => { // 内容滚动区域 .content-scroll { flex: 1; + height: 0; padding-bottom: 280rpx; + background-color: #f8f8f8; } // 用户信息卡片 @@ -1713,4 +1794,76 @@ onUnmounted(() => { transform: scaleY(1.5); } } + +// 拨打电话弹窗 +.phone-popup-mask { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 999; + display: flex; + align-items: flex-end; + justify-content: center; +} + +.phone-popup { + width: 100%; + background: #fff; + border-radius: 24rpx 24rpx 0 0; + padding: 40rpx; + padding-bottom: calc(40rpx + env(safe-area-inset-bottom)); + + .phone-popup-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 40rpx; + + .phone-popup-title { + font-size: 32rpx; + font-weight: 600; + color: #333; + } + + .phone-popup-close { + font-size: 48rpx; + color: #999; + line-height: 1; + } + } + + .phone-popup-content { + text-align: center; + padding: 40rpx 0; + + .phone-number { + font-size: 56rpx; + font-weight: 600; + color: #333; + letter-spacing: 4rpx; + } + } + + .phone-popup-btn { + width: 100%; + height: 96rpx; + line-height: 96rpx; + background: #4cd964; + color: #fff; + font-size: 34rpx; + border-radius: 48rpx; + border: none; + + &::after { + border: none; + } + + &[disabled] { + opacity: 0.5; + } + } +} diff --git a/miniapp/pages/index/index.vue b/miniapp/pages/index/index.vue index 40eb8d1..f9329f8 100644 --- a/miniapp/pages/index/index.vue +++ b/miniapp/pages/index/index.vue @@ -12,6 +12,16 @@ :buttonText="dailyPopup?.buttonText" :linkUrl="dailyPopup?.linkUrl" @close="handleCloseDailyPopup" @confirm="handleDailyPopupConfirm" /> + + + @@ -128,6 +138,10 @@ import { getRecommend } from '@/api/user.js' + import { + checkUnlock, + unlock + } from '@/api/interact.js' import { getFullImageUrl } from '@/utils/image.js' @@ -165,6 +179,11 @@ // 数据 const recommendList = ref([]) + // 解锁弹窗状态 + const showUnlockPopup = ref(false) + const unlockTargetUserId = ref(0) + const remainingUnlockQuota = ref(0) + // 计算属性 - 处理图片URL const banners = computed(() => { return configStore.banners.map(banner => ({ @@ -372,7 +391,7 @@ } // 联系用户 - const handleUserContact = (userId) => { + const handleUserContact = async (userId) => { if (!userStore.isLoggedIn) { uni.showModal({ title: '提示', @@ -405,9 +424,77 @@ return } - uni.navigateTo({ - url: `/pages/chat/index?targetUserId=${userId}` - }) + // 检查是否已解锁 + try { + uni.showLoading({ title: '加载中...' }) + const res = await checkUnlock(userId) + uni.hideLoading() + + if (res && res.code === 0 && res.data) { + if (res.data.isUnlocked) { + // 已解锁,直接跳转聊天 + uni.navigateTo({ + url: `/pages/chat/index?targetUserId=${userId}` + }) + } else { + // 未解锁,显示解锁弹窗 + unlockTargetUserId.value = userId + remainingUnlockQuota.value = res.data.remainingUnlockQuota || 0 + showUnlockPopup.value = true + } + } else { + uni.showToast({ title: '检查解锁状态失败', icon: 'none' }) + } + } catch (error) { + uni.hideLoading() + console.error('检查解锁状态失败:', error) + uni.showToast({ title: '网络错误', icon: 'none' }) + } + } + + // 关闭解锁弹窗 + const handleCloseUnlockPopup = () => { + showUnlockPopup.value = false + } + + // 确认解锁 + const handleConfirmUnlock = async () => { + if (remainingUnlockQuota.value <= 0) { + showUnlockPopup.value = false + uni.navigateTo({ url: '/pages/member/index' }) + return + } + + try { + uni.showLoading({ title: '解锁中...' }) + const unlockRes = await unlock(unlockTargetUserId.value) + uni.hideLoading() + + if (unlockRes && unlockRes.code === 0) { + showUnlockPopup.value = false + uni.showToast({ title: '解锁成功', icon: 'success' }) + setTimeout(() => { + uni.navigateTo({ + url: `/pages/chat/index?targetUserId=${unlockTargetUserId.value}` + }) + }, 1000) + } else { + uni.showToast({ + title: unlockRes?.message || '解锁失败', + icon: 'none' + }) + } + } catch (error) { + uni.hideLoading() + console.error('解锁失败:', error) + uni.showToast({ title: '解锁失败', icon: 'none' }) + } + } + + // 跳转会员页面 + const handleGoMember = () => { + showUnlockPopup.value = false + uni.navigateTo({ url: '/pages/member/index' }) } // 滚动到底部 - 加载更多 @@ -446,6 +533,8 @@ showDailyPopup, dailyPopup, recommendList, + showUnlockPopup, + remainingUnlockQuota, initPage, loadRecommendList, handleSearchClick, @@ -458,6 +547,9 @@ handleDailyPopupConfirm, handleUserClick, handleUserContact, + handleCloseUnlockPopup, + handleConfirmUnlock, + handleGoMember, handleScrollToLower, handleRefresh } diff --git a/miniapp/pages/message/index.vue b/miniapp/pages/message/index.vue index 5e9c2e0..19c3af3 100644 --- a/miniapp/pages/message/index.vue +++ b/miniapp/pages/message/index.vue @@ -1,656 +1,654 @@ - + .empty-tip { + font-size: 26rpx; + color: #999; + } + + .login-btn { + margin-top: 32rpx; + width: 240rpx; + height: 80rpx; + line-height: 80rpx; + background: linear-gradient(135deg, #FFBDC2 0%, #FF8A93 100%); + border-radius: 40rpx; + font-size: 30rpx; + color: #fff; + border: none; + + &::after { + border: none; + } + } + } + \ No newline at end of file diff --git a/miniapp/pages/mine/index.vue b/miniapp/pages/mine/index.vue index 8bcd754..b608443 100644 --- a/miniapp/pages/mine/index.vue +++ b/miniapp/pages/mine/index.vue @@ -50,31 +50,37 @@ - - - - - 看过我 - - +{{ interactCounts.viewedMe }} + + + + + + 看过我 + + +{{ interactCounts.viewedMe }} + - - - - - 收藏我 - - +{{ interactCounts.favoritedMe }} + + + + + + 收藏我 + + +{{ interactCounts.favoritedMe }} + - - - - - 解锁我 - - +{{ interactCounts.unlockedMe }} + + + + + + 解锁我 + + +{{ interactCounts.unlockedMe }} + @@ -125,6 +131,11 @@ + + + + 备案号:苏ICP备2026001086号-1 + @@ -251,7 +262,7 @@ export default { // 管家指导 const handleButler = () => { - uni.showToast({ title: '功能开发中', icon: 'none' }) + uni.navigateTo({ url: '/pages/butler/index' }) } // 实名认证 @@ -526,76 +537,83 @@ export default { .interact-grid { display: flex; - justify-content: space-between; + flex-direction: row; + justify-content: space-around; .interact-item { - display: flex; - flex-direction: column; - align-items: center; - position: relative; - - .interact-icon { - width: 120rpx; - height: 120rpx; + .item-card { + display: flex; + flex-direction: column; + align-items: center; + width: 150rpx; + height: 132rpx; border-radius: 24rpx; - display: flex; - align-items: center; - justify-content: center; - margin-bottom: 16rpx; - .icon-img { - width: 56rpx; - height: 56rpx; + .icon-box { + width: 64rpx; + height: 64rpx; + border-radius: 20rpx; + margin-top: -18rpx; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 6rpx; + + .icon-img { + width: 64rpx; + height: 64rpx; + } } - &.viewed { - background: linear-gradient(135deg, #e8e0ff 0%, #d4c4ff 100%); - } - - &.favorited { - background: linear-gradient(135deg, #ffe0e8 0%, #ffc4d4 100%); - } - - &.unlocked { - background: linear-gradient(135deg, #fff3e0 0%, #ffe4c4 100%); - } - } - - .interact-label { - font-size: 28rpx; - color: #333; - font-weight: 500; - } - - .interact-badge { - position: absolute; - top: -8rpx; - right: -8rpx; - min-width: 44rpx; - height: 44rpx; - padding: 0 12rpx; - border-radius: 22rpx; - display: flex; - align-items: center; - justify-content: center; - - text { - font-size: 24rpx; - color: #fff; + .item-label { + font-size: 28rpx; + color: #333; font-weight: 500; + margin-bottom: 6rpx; + } + + .item-count { + min-width: 56rpx; + height: 40rpx; + padding: 5rpx 0rpx; + border-radius: 20rpx; + display: flex; + align-items: center; + justify-content: center; + + text { + font-size: 20rpx; + color: #fff; + font-weight: 600; + } } } - &:nth-child(1) .interact-badge { - background: #9b7bff; + // 看过我 - 紫色 + &.viewed .item-card { + background: linear-gradient(0deg, rgba(237, 213, 255, 0.1) 0%, #E0D8FF 100%); + + .item-count { + background: #8b5cf6; + } } - &:nth-child(2) .interact-badge { - background: #ff6b8a; + // 收藏我 - 粉色 + &.favorited .item-card { + background: linear-gradient(0deg, rgba(255, 213, 240, 0.1) 0%, #FFC4D7 100%); + + .item-count { + background: #fb7185; + } } - &:nth-child(3) .interact-badge { - background: #ffb347; + // 解锁我 - 黄色 + &.unlocked .item-card { + background: linear-gradient(0deg, rgba(255, 239, 213, 0.1) 0%, #FFF5D8 100%); + + .item-count { + background: #f59e0b; + } } } } @@ -689,4 +707,17 @@ export default { } } } + +// ICP备案号 +.icp-section { + position: relative; + z-index: 1; + padding: 40rpx 32rpx 60rpx; + text-align: center; + + .icp-text { + font-size: 24rpx; + color: #999; + } +} diff --git a/miniapp/pages/profile/detail.vue b/miniapp/pages/profile/detail.vue index 4ba3004..9ae130e 100644 --- a/miniapp/pages/profile/detail.vue +++ b/miniapp/pages/profile/detail.vue @@ -21,6 +21,22 @@ @goMember="handleGoMember" /> + + + + + {{ userDetail?.nickname }}的联系电话 + × + + + {{ userDetail?.phone || '暂无电话' }} + + + + + @@ -282,6 +298,7 @@ const isUnlocked = ref(false) const remainingUnlockQuota = ref(0) const showProfilePopup = ref(false) const showUnlockPopup = ref(false) +const showPhonePopup = ref(false) // 获取系统信息 const getSystemInfo = () => { @@ -624,7 +641,25 @@ const handleCall = () => { showUnlockPopup.value = true return } - uni.showToast({ title: '请通过微信联系对方', icon: 'none' }) + + // 已解锁,显示电话弹窗 + showPhonePopup.value = true +} + +// 拨打电话 +const handleMakeCall = () => { + const phone = userDetail.value?.phone + if (!phone) { + uni.showToast({ title: '暂无电话号码', icon: 'none' }) + return + } + + uni.makePhoneCall({ + phoneNumber: phone, + fail: (err) => { + console.error('拨打电话失败:', err) + } + }) } // 复制微信号 @@ -672,6 +707,7 @@ onShareAppMessage(() => { .profile-detail-page { min-height: 100vh; background-color: #f5f6fa; + overflow: hidden; } // 自定义导航栏 @@ -718,12 +754,13 @@ onShareAppMessage(() => { // 内容滚动区域 .content-scroll { - height: 100vh; - padding-bottom: 160rpx; + height: calc(100vh - 160rpx); + box-sizing: border-box; } .content-wrapper { padding: 24rpx; + padding-bottom: 40rpx; } // 用户头像卡片 @@ -1114,4 +1151,76 @@ onShareAppMessage(() => { color: #fff; } } + +// 拨打电话弹窗 +.phone-popup-mask { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 999; + display: flex; + align-items: flex-end; + justify-content: center; +} + +.phone-popup { + width: 100%; + background: #fff; + border-radius: 24rpx 24rpx 0 0; + padding: 40rpx; + padding-bottom: calc(40rpx + env(safe-area-inset-bottom)); + + .phone-popup-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 40rpx; + + .phone-popup-title { + font-size: 32rpx; + font-weight: 600; + color: #333; + } + + .phone-popup-close { + font-size: 48rpx; + color: #999; + line-height: 1; + } + } + + .phone-popup-content { + text-align: center; + padding: 40rpx 0; + + .phone-number { + font-size: 56rpx; + font-weight: 600; + color: #333; + letter-spacing: 4rpx; + } + } + + .phone-popup-btn { + width: 100%; + height: 96rpx; + line-height: 96rpx; + background: #4cd964; + color: #fff; + font-size: 34rpx; + border-radius: 48rpx; + border: none; + + &::after { + border: none; + } + + &[disabled] { + opacity: 0.5; + } + } +} diff --git a/miniapp/store/chat.js b/miniapp/store/chat.js index 21e7457..5d43b27 100644 --- a/miniapp/store/chat.js +++ b/miniapp/store/chat.js @@ -7,13 +7,16 @@ import { defineStore } from 'pinia' /** * 消息类型枚举 + * 与后端 MessageType 枚举保持一致 */ export const MessageType = { - TEXT: 1, // 文本消息 - VOICE: 2, // 语音消息 - IMAGE: 3, // 图片消息 - EXCHANGE_WECHAT: 4, // 交换微信 - EXCHANGE_PHOTO: 5 // 交换照片 + TEXT: 1, // 文本消息 + VOICE: 2, // 语音消息 + IMAGE: 3, // 图片消息 + EXCHANGE_WECHAT: 4, // 交换微信请求 + EXCHANGE_WECHAT_RESULT: 5, // 交换微信结果 + EXCHANGE_PHOTO: 6, // 交换照片请求 + EXCHANGE_PHOTO_RESULT: 7 // 交换照片结果 } /** diff --git a/miniapp/store/config.js b/miniapp/store/config.js index 0db87ad..5ba3ecc 100644 --- a/miniapp/store/config.js +++ b/miniapp/store/config.js @@ -33,6 +33,7 @@ export const useConfigStore = defineStore('config', { // 系统配置 defaultAvatar: getDefaultAvatar() || '/static/logo.png', searchBanner: '', + butlerQrcode: '', // 管家指导二维码 // 弹窗配置 dailyPopup: null, @@ -77,6 +78,7 @@ export const useConfigStore = defineStore('config', { setDefaultAvatar(config.defaultAvatar) } this.searchBanner = config.searchBanner || '' + this.butlerQrcode = config.butlerQrcode || '' // 弹窗配置 this.dailyPopup = config.dailyPopup || null diff --git a/server/src/XiangYi.AdminApi/Controllers/AdminConfigController.cs b/server/src/XiangYi.AdminApi/Controllers/AdminConfigController.cs index 1d44b29..85d4ba6 100644 --- a/server/src/XiangYi.AdminApi/Controllers/AdminConfigController.cs +++ b/server/src/XiangYi.AdminApi/Controllers/AdminConfigController.cs @@ -173,6 +173,34 @@ public class AdminConfigController : ControllerBase var result = await _configService.SetSearchBannerAsync(request.ImageUrl); return result ? ApiResponse.Success("设置成功") : ApiResponse.Error(40001, "设置失败"); } + + /// + /// 获取管家二维码 + /// + [HttpGet("butlerQrcode")] + public async Task> GetButlerQrcode() + { + var imageUrl = await _configService.GetButlerQrcodeAsync(); + return ApiResponse.Success(new ButlerQrcodeResponse + { + ImageUrl = imageUrl + }); + } + + /// + /// 设置管家二维码 + /// + [HttpPost("butlerQrcode")] + public async Task SetButlerQrcode([FromBody] SetButlerQrcodeRequest request) + { + if (string.IsNullOrWhiteSpace(request.ImageUrl)) + { + return ApiResponse.Error(40001, "图片URL不能为空"); + } + + var result = await _configService.SetButlerQrcodeAsync(request.ImageUrl); + return result ? ApiResponse.Success("设置成功") : ApiResponse.Error(40001, "设置失败"); + } } /// @@ -262,3 +290,25 @@ public class SetSearchBannerRequest /// public string ImageUrl { get; set; } = string.Empty; } + +/// +/// 管家二维码响应 +/// +public class ButlerQrcodeResponse +{ + /// + /// 图片URL + /// + public string? ImageUrl { get; set; } +} + +/// +/// 设置管家二维码请求 +/// +public class SetButlerQrcodeRequest +{ + /// + /// 图片URL + /// + public string ImageUrl { get; set; } = string.Empty; +} diff --git a/server/src/XiangYi.AdminApi/Controllers/AdminUserController.cs b/server/src/XiangYi.AdminApi/Controllers/AdminUserController.cs index 438b584..da2ab10 100644 --- a/server/src/XiangYi.AdminApi/Controllers/AdminUserController.cs +++ b/server/src/XiangYi.AdminApi/Controllers/AdminUserController.cs @@ -111,6 +111,20 @@ public class AdminUserController : ControllerBase var result = await _adminUserService.DeleteUserAsync(id, adminId); return result ? ApiResponse.Success("删除成功") : ApiResponse.Error(40001, "删除失败"); } + + /// + /// 更新用户联系次数 + /// + /// 用户ID + /// 联系次数更新请求 + /// 操作结果 + [HttpPut("{id}/contact-count")] + public async Task UpdateContactCount(long id, [FromBody] UpdateContactCountRequest request) + { + var adminId = GetCurrentAdminId(); + var result = await _adminUserService.UpdateContactCountAsync(id, request.ContactCount, adminId); + return result ? ApiResponse.Success("更新成功") : ApiResponse.Error(40001, "更新失败"); + } } /// @@ -128,3 +142,14 @@ public class CreateTestUsersRequest /// public int? Gender { get; set; } } + +/// +/// 更新联系次数请求 +/// +public class UpdateContactCountRequest +{ + /// + /// 联系次数 + /// + public int ContactCount { get; set; } +} diff --git a/server/src/XiangYi.AppApi/Controllers/ChatController.cs b/server/src/XiangYi.AppApi/Controllers/ChatController.cs index 344d745..ddc18f9 100644 --- a/server/src/XiangYi.AppApi/Controllers/ChatController.cs +++ b/server/src/XiangYi.AppApi/Controllers/ChatController.cs @@ -293,6 +293,29 @@ public class ChatController : ControllerBase return ApiResponse.Success(count); } + /// + /// 获取或创建会话 + /// + /// 目标用户ID + /// 会话ID + [HttpGet("session")] + public async Task> GetOrCreateSession([FromQuery] long targetUserId) + { + if (targetUserId <= 0) + { + return ApiResponse.Error(ErrorCodes.InvalidParameter, "目标用户ID无效"); + } + + var userId = GetCurrentUserId(); + if (userId == targetUserId) + { + return ApiResponse.Error(ErrorCodes.InvalidParameter, "不能与自己创建会话"); + } + + var sessionId = await _chatService.GetOrCreateSessionAsync(userId, targetUserId); + return ApiResponse.Success(sessionId); + } + /// /// 获取当前用户ID /// diff --git a/server/src/XiangYi.AppApi/Controllers/InteractController.cs b/server/src/XiangYi.AppApi/Controllers/InteractController.cs index 720a1f3..b5e20f8 100644 --- a/server/src/XiangYi.AppApi/Controllers/InteractController.cs +++ b/server/src/XiangYi.AppApi/Controllers/InteractController.cs @@ -226,6 +226,30 @@ public class InteractController : ControllerBase return ApiResponse>.Success(result); } + /// + /// 检查是否已解锁目标用户 + /// + /// 目标用户ID + /// 解锁状态 + [HttpGet("checkUnlock")] + public async Task> CheckUnlock([FromQuery] long targetUserId) + { + if (targetUserId <= 0) + { + return ApiResponse.Error(ErrorCodes.InvalidParameter, "目标用户ID无效"); + } + + var userId = GetCurrentUserId(); + var isUnlocked = await _interactService.IsUnlockedAsync(userId, targetUserId); + var remainingQuota = await _interactService.GetRemainingUnlockQuotaAsync(userId); + + return ApiResponse.Success(new CheckUnlockResponse + { + IsUnlocked = isUnlocked, + RemainingUnlockQuota = remainingQuota + }); + } + /// /// 获取当前用户ID /// @@ -256,3 +280,19 @@ public class FavoriteResponse /// public string? Message { get; set; } } + +/// +/// 检查解锁状态响应 +/// +public class CheckUnlockResponse +{ + /// + /// 是否已解锁 + /// + public bool IsUnlocked { get; set; } + + /// + /// 剩余解锁次数 + /// + public int RemainingUnlockQuota { get; set; } +} diff --git a/server/src/XiangYi.Application/Interfaces/IAdminUserService.cs b/server/src/XiangYi.Application/Interfaces/IAdminUserService.cs index e953f55..8db7299 100644 --- a/server/src/XiangYi.Application/Interfaces/IAdminUserService.cs +++ b/server/src/XiangYi.Application/Interfaces/IAdminUserService.cs @@ -52,4 +52,13 @@ public interface IAdminUserService /// 操作管理员ID /// 是否成功 Task DeleteUserAsync(long userId, long adminId); + + /// + /// 更新用户联系次数 + /// + /// 用户ID + /// 联系次数 + /// 操作管理员ID + /// 是否成功 + Task UpdateContactCountAsync(long userId, int contactCount, long adminId); } diff --git a/server/src/XiangYi.Application/Interfaces/IConfigService.cs b/server/src/XiangYi.Application/Interfaces/IConfigService.cs index b92d440..651506b 100644 --- a/server/src/XiangYi.Application/Interfaces/IConfigService.cs +++ b/server/src/XiangYi.Application/Interfaces/IConfigService.cs @@ -89,6 +89,11 @@ public class AppConfigResponse /// public string? SearchBanner { get; set; } + /// + /// 管家指导二维码URL + /// + public string? ButlerQrcode { get; set; } + /// /// 每日弹窗配置 /// diff --git a/server/src/XiangYi.Application/Interfaces/ISystemConfigService.cs b/server/src/XiangYi.Application/Interfaces/ISystemConfigService.cs index 2952ce1..2c9be13 100644 --- a/server/src/XiangYi.Application/Interfaces/ISystemConfigService.cs +++ b/server/src/XiangYi.Application/Interfaces/ISystemConfigService.cs @@ -69,4 +69,14 @@ public interface ISystemConfigService /// 设置搜索页Banner图URL /// Task SetSearchBannerAsync(string imageUrl); + + /// + /// 获取管家指导二维码URL + /// + Task GetButlerQrcodeAsync(); + + /// + /// 设置管家指导二维码URL + /// + Task SetButlerQrcodeAsync(string imageUrl); } diff --git a/server/src/XiangYi.Application/Services/AdminUserService.cs b/server/src/XiangYi.Application/Services/AdminUserService.cs index 6eece93..ac55b3f 100644 --- a/server/src/XiangYi.Application/Services/AdminUserService.cs +++ b/server/src/XiangYi.Application/Services/AdminUserService.cs @@ -439,6 +439,32 @@ public class AdminUserService : IAdminUserService return true; } + /// + public async Task UpdateContactCountAsync(long userId, int contactCount, long adminId) + { + var user = await _userRepository.GetByIdAsync(userId); + if (user == null || user.IsDeleted) + { + throw new BusinessException(ErrorCodes.UserNotFound, "用户不存在"); + } + + if (contactCount < 0) + { + throw new BusinessException(ErrorCodes.InvalidParameter, "联系次数不能为负数"); + } + + var oldCount = user.ContactCount; + user.ContactCount = contactCount; + user.UpdateTime = DateTime.Now; + + var result = await _userRepository.UpdateAsync(user); + + _logger.LogInformation("管理员更新用户联系次数: AdminId={AdminId}, UserId={UserId}, OldCount={OldCount}, NewCount={NewCount}", + adminId, userId, oldCount, contactCount); + + return result > 0; + } + #region Private Helper Methods private static AdminUserListDto MapToUserListDto(User user, UserProfile? profile) diff --git a/server/src/XiangYi.Application/Services/ConfigService.cs b/server/src/XiangYi.Application/Services/ConfigService.cs index b75d141..7f0e478 100644 --- a/server/src/XiangYi.Application/Services/ConfigService.cs +++ b/server/src/XiangYi.Application/Services/ConfigService.cs @@ -47,6 +47,7 @@ public class ConfigService : IConfigService var kingKongs = await GetKingKongsAsync(); var defaultAvatar = await _systemConfigService.GetDefaultAvatarAsync(); var searchBanner = await _systemConfigService.GetSearchBannerAsync(); + var butlerQrcode = await _systemConfigService.GetButlerQrcodeAsync(); var dailyPopup = await GetPopupConfigAsync(1); // 每日弹窗 var memberAdPopup = await GetPopupConfigAsync(3); // 会员广告弹窗 @@ -56,6 +57,7 @@ public class ConfigService : IConfigService KingKongs = kingKongs, DefaultAvatar = defaultAvatar, SearchBanner = searchBanner, + ButlerQrcode = butlerQrcode, DailyPopup = dailyPopup, MemberAdPopup = memberAdPopup }; diff --git a/server/src/XiangYi.Application/Services/InteractService.cs b/server/src/XiangYi.Application/Services/InteractService.cs index fd35741..40121c6 100644 --- a/server/src/XiangYi.Application/Services/InteractService.cs +++ b/server/src/XiangYi.Application/Services/InteractService.cs @@ -473,8 +473,10 @@ public class InteractService : IInteractService /// public async Task IsUnlockedAsync(long userId, long targetUserId) { + // 检查双向解锁:只要任意一方解锁了对方,双方都可以聊天 return await _unlockRepository.ExistsAsync(u => - u.UserId == userId && u.TargetUserId == targetUserId); + (u.UserId == userId && u.TargetUserId == targetUserId) || + (u.UserId == targetUserId && u.TargetUserId == userId)); } /// diff --git a/server/src/XiangYi.Application/Services/SystemConfigService.cs b/server/src/XiangYi.Application/Services/SystemConfigService.cs index 95aea70..075825d 100644 --- a/server/src/XiangYi.Application/Services/SystemConfigService.cs +++ b/server/src/XiangYi.Application/Services/SystemConfigService.cs @@ -38,6 +38,11 @@ public class SystemConfigService : ISystemConfigService /// public const string SearchBannerKey = "search_banner"; + /// + /// 管家二维码配置键 + /// + public const string ButlerQrcodeKey = "butler_qrcode"; + public SystemConfigService( IRepository configRepository, ILogger logger) @@ -160,4 +165,16 @@ public class SystemConfigService : ISystemConfigService { return await SetConfigValueAsync(SearchBannerKey, imageUrl, "搜索页Banner图URL"); } + + /// + public async Task GetButlerQrcodeAsync() + { + return await GetConfigValueAsync(ButlerQrcodeKey); + } + + /// + public async Task SetButlerQrcodeAsync(string imageUrl) + { + return await SetConfigValueAsync(ButlerQrcodeKey, imageUrl, "管家指导二维码URL"); + } }