From cb4c207ca8f7218fb612a2aafc0ad46a4d30d004 Mon Sep 17 00:00:00 2001 From: 18631081161 <2088094923@qq.com> Date: Tue, 27 Jan 2026 20:09:30 +0800 Subject: [PATCH] =?UTF-8?q?=E9=80=BB=E8=BE=91=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- miniapp/api/interact.js | 25 +- miniapp/components/Popup/index.vue | 3 +- miniapp/pages/index/index.vue | 1891 +++++++++-------- miniapp/pages/message/index.vue | 84 +- miniapp/pages/mine/index.vue | 85 +- miniapp/pages/realname/index.vue | 21 +- miniapp/store/user.js | 6 +- miniapp/utils/navigate.js | 200 ++ .../Controllers/InteractController.cs | 30 + .../DTOs/Responses/InteractResponses.cs | 21 + .../Interfaces/IInteractService.cs | 14 + .../Services/InteractService.cs | 68 + server/src/XiangYi.Core/Entities/Biz/User.cs | 15 + 13 files changed, 1503 insertions(+), 960 deletions(-) create mode 100644 miniapp/utils/navigate.js diff --git a/miniapp/api/interact.js b/miniapp/api/interact.js index 6da7c66..439ea5b 100644 --- a/miniapp/api/interact.js +++ b/miniapp/api/interact.js @@ -148,6 +148,27 @@ export async function checkUnlock(targetUserId) { return response } +/** + * 获取互动新增数量统计 + * + * @returns {Promise} 各类互动的新增数量 { viewedMeCount, favoritedMeCount, unlockedMeCount } + */ +export async function getInteractCounts() { + const response = await get('/interact/counts') + return response +} + +/** + * 标记互动为已读 + * + * @param {string} type - 互动类型:viewedMe, favoritedMe, unlockedMe + * @returns {Promise} 操作结果 + */ +export async function markInteractAsRead(type) { + const response = await post(`/interact/markRead/${type}`) + return response +} + export default { recordView, favorite, @@ -159,5 +180,7 @@ export default { getFavoritedMe, getMyFavorite, getUnlockedMe, - getMyUnlocked + getMyUnlocked, + getInteractCounts, + markInteractAsRead } diff --git a/miniapp/components/Popup/index.vue b/miniapp/components/Popup/index.vue index 125f689..a05dbd2 100644 --- a/miniapp/components/Popup/index.vue +++ b/miniapp/components/Popup/index.vue @@ -186,7 +186,8 @@ export default { } }, handleButtonClick() { - if (this.linkUrl) { + // 服务号弹窗由父组件处理跳转逻辑,不在这里处理 + if (this.type !== 'serviceAccount' && this.linkUrl) { this.navigateToLink() } this.$emit('confirm') diff --git a/miniapp/pages/index/index.vue b/miniapp/pages/index/index.vue index 86e5e7d..b54e697 100644 --- a/miniapp/pages/index/index.vue +++ b/miniapp/pages/index/index.vue @@ -1,18 +1,32 @@ diff --git a/miniapp/pages/message/index.vue b/miniapp/pages/message/index.vue index ad5b25f..5f4a0f2 100644 --- a/miniapp/pages/message/index.vue +++ b/miniapp/pages/message/index.vue @@ -158,9 +158,8 @@ deleteSession } from '@/api/chat.js' import { - getViewedMe, - getFavoritedMe, - getUnlockedMe + getInteractCounts, + markInteractAsRead } from '@/api/interact.js' import { formatTimestamp @@ -173,7 +172,6 @@ // 页面状态 const pageLoading = ref(true) - const listLoading = ref(false) const isRefreshing = ref(false) // 系统信息 @@ -222,52 +220,20 @@ }).exec() } - // 加载互动统计 + /** + * 加载互动统计(从后端获取新增数量) + */ const loadInteractCounts = async () => { if (!userStore.isLoggedIn) return try { - // 获取本地存储的最后查看时间 - const lastViewedTime = uni.getStorageSync('lastViewedMeTime') || 0 - const lastFavoritedTime = uni.getStorageSync('lastFavoritedMeTime') || 0 - const lastUnlockedTime = uni.getStorageSync('lastUnlockedMeTime') || 0 - - const [viewedRes, favoritedRes, unlockedRes] = await Promise.all([ - getViewedMe(1, 100).catch(() => null), - getFavoritedMe(1, 100).catch(() => null), - getUnlockedMe(1, 100).catch(() => null) - ]) - - // 计算新增数量(在最后查看时间之后的记录) - let viewedCount = 0 - let favoritedCount = 0 - let unlockedCount = 0 - - if (viewedRes?.data?.items) { - viewedCount = viewedRes.data.items.filter(item => { - const itemTime = new Date(item.viewTime || item.createTime).getTime() - return itemTime > lastViewedTime - }).length - } - - if (favoritedRes?.data?.items) { - favoritedCount = favoritedRes.data.items.filter(item => { - const itemTime = new Date(item.favoriteTime || item.createTime).getTime() - return itemTime > lastFavoritedTime - }).length - } - - if (unlockedRes?.data?.items) { - unlockedCount = unlockedRes.data.items.filter(item => { - const itemTime = new Date(item.unlockTime || item.createTime).getTime() - return itemTime > lastUnlockedTime - }).length - } - - interactCounts.value = { - viewedMe: viewedCount, - favoritedMe: favoritedCount, - unlockedMe: unlockedCount + const res = await getInteractCounts() + if (res?.code === 0 && res.data) { + interactCounts.value = { + viewedMe: res.data.viewedMeCount || 0, + favoritedMe: res.data.favoritedMeCount || 0, + unlockedMe: res.data.unlockedMeCount || 0 + } } } catch (error) { console.error('加载互动统计失败:', error) @@ -278,7 +244,6 @@ const loadSessions = async () => { if (!userStore.isLoggedIn) return - listLoading.value = true try { const res = await getSessions() if (res?.success || res?.code === 0) { @@ -291,8 +256,6 @@ title: '加载失败', icon: 'none' }) - } finally { - listLoading.value = false } } @@ -328,23 +291,28 @@ const formatTime = (timestamp) => formatTimestamp(timestamp) // 导航到互动页面 - const navigateTo = (url) => { - // 记录最后查看时间 - const now = Date.now() + const navigateTo = async (url) => { + // 根据 URL 确定互动类型并标记已读 + let interactType = '' if (url.includes('viewedMe')) { - uni.setStorageSync('lastViewedMeTime', now) + interactType = 'viewedMe' interactCounts.value.viewedMe = 0 } else if (url.includes('favoritedMe')) { - uni.setStorageSync('lastFavoritedMeTime', now) + interactType = 'favoritedMe' interactCounts.value.favoritedMe = 0 } else if (url.includes('unlockedMe')) { - uni.setStorageSync('lastUnlockedMeTime', now) + interactType = 'unlockedMe' interactCounts.value.unlockedMe = 0 } - uni.navigateTo({ - url - }) + // 调用后端标记已读(异步,不阻塞跳转) + if (interactType) { + markInteractAsRead(interactType).catch(err => { + console.error('标记已读失败:', err) + }) + } + + uni.navigateTo({ url }) } // 点击会话 diff --git a/miniapp/pages/mine/index.vue b/miniapp/pages/mine/index.vue index 63c1f1a..818fc65 100644 --- a/miniapp/pages/mine/index.vue +++ b/miniapp/pages/mine/index.vue @@ -147,9 +147,7 @@ import { ref, computed, onMounted } from 'vue' import { useUserStore } from '@/store/user.js' import { useConfigStore } from '@/store/config.js' -import { getMyProfile } from '@/api/profile.js' -import { login } from '@/api/auth.js' -import { getViewedMe, getFavoritedMe, getUnlockedMe } from '@/api/interact.js' +import { getInteractCounts, markInteractAsRead } from '@/api/interact.js' import { getFullImageUrl } from '@/utils/image.js' import Loading from '@/components/Loading/index.vue' @@ -211,52 +209,20 @@ export default { return configStore.memberEntryImage ? getFullImageUrl(configStore.memberEntryImage) : '' }) - // 加载互动统计 + /** + * 加载互动统计(从后端获取新增数量) + */ const loadInteractCounts = async () => { if (!userStore.isLoggedIn) return try { - // 获取本地存储的最后查看时间 - const lastViewedTime = uni.getStorageSync('lastViewedMeTime') || 0 - const lastFavoritedTime = uni.getStorageSync('lastFavoritedMeTime') || 0 - const lastUnlockedTime = uni.getStorageSync('lastUnlockedMeTime') || 0 - - const [viewedRes, favoritedRes, unlockedRes] = await Promise.all([ - getViewedMe(1, 100).catch(() => null), - getFavoritedMe(1, 100).catch(() => null), - getUnlockedMe(1, 100).catch(() => null) - ]) - - // 计算新增数量(在最后查看时间之后的记录) - let viewedCount = 0 - let favoritedCount = 0 - let unlockedCount = 0 - - if (viewedRes?.data?.items) { - viewedCount = viewedRes.data.items.filter(item => { - const itemTime = new Date(item.viewTime || item.createTime).getTime() - return itemTime > lastViewedTime - }).length - } - - if (favoritedRes?.data?.items) { - favoritedCount = favoritedRes.data.items.filter(item => { - const itemTime = new Date(item.favoriteTime || item.createTime).getTime() - return itemTime > lastFavoritedTime - }).length - } - - if (unlockedRes?.data?.items) { - unlockedCount = unlockedRes.data.items.filter(item => { - const itemTime = new Date(item.unlockTime || item.createTime).getTime() - return itemTime > lastUnlockedTime - }).length - } - - interactCounts.value = { - viewedMe: viewedCount, - favoritedMe: favoritedCount, - unlockedMe: unlockedCount + const res = await getInteractCounts() + if (res?.code === 0 && res.data) { + interactCounts.value = { + viewedMe: res.data.viewedMeCount || 0, + favoritedMe: res.data.favoritedMeCount || 0, + unlockedMe: res.data.unlockedMeCount || 0 + } } } catch (error) { console.error('加载互动统计失败:', error) @@ -288,15 +254,6 @@ export default { uni.navigateTo({ url: '/pages/profile/personal' }) } - // 预览资料 - const handlePreviewProfile = () => { - if (!userStore.isProfileCompleted) { - handleEditProfile() - return - } - uni.navigateTo({ url: '/pages/profile/preview' }) - } - // 编辑资料 const handleEditProfile = () => { uni.navigateTo({ url: '/pages/profile/edit' }) @@ -347,20 +304,27 @@ export default { } // 导航到互动页面 - const navigateTo = (url) => { - // 记录最后查看时间 - const now = Date.now() + const navigateTo = async (url) => { + // 根据 URL 确定互动类型并标记已读 + let interactType = '' if (url.includes('viewedMe')) { - uni.setStorageSync('lastViewedMeTime', now) + interactType = 'viewedMe' interactCounts.value.viewedMe = 0 } else if (url.includes('favoritedMe')) { - uni.setStorageSync('lastFavoritedMeTime', now) + interactType = 'favoritedMe' interactCounts.value.favoritedMe = 0 } else if (url.includes('unlockedMe')) { - uni.setStorageSync('lastUnlockedMeTime', now) + interactType = 'unlockedMe' interactCounts.value.unlockedMe = 0 } + // 调用后端标记已读(异步,不阻塞跳转) + if (interactType) { + markInteractAsRead(interactType).catch(err => { + console.error('标记已读失败:', err) + }) + } + uni.navigateTo({ url }) } @@ -388,7 +352,6 @@ export default { onAvatarError, handleLogin, handlePersonalProfile, - handlePreviewProfile, handleEditProfile, handleMember, handleButler, diff --git a/miniapp/pages/realname/index.vue b/miniapp/pages/realname/index.vue index 3960bbc..f158ac0 100644 --- a/miniapp/pages/realname/index.vue +++ b/miniapp/pages/realname/index.vue @@ -396,7 +396,8 @@ console.log('调起微信支付...') await requestPayment(paymentParams) - // 支付成功,进入上传步骤 (Requirements 12.2) + // 支付成功,更新状态并进入上传步骤 (Requirements 12.2) + isPaid.value = true currentStep.value = 2 uni.showToast({ @@ -685,9 +686,25 @@ handleDone } }, - onShow() { + async onShow() { const userStore = useUserStore() userStore.restoreFromStorage() + + // 如果已登录,重新获取实名认证状态(用户可能从会员页面返回,已开通会员) + const { getToken } = require('@/utils/storage.js') + if (getToken()) { + const { get } = require('@/api/request.js') + try { + const res = await get('/realname/status') + if (res && (res.success || res.code === 0) && res.data) { + if (res.data.isRealName) { + userStore.setRealNameStatus(true) + } + } + } catch (error) { + console.error('刷新实名状态失败:', error) + } + } } } diff --git a/miniapp/store/user.js b/miniapp/store/user.js index ee8cfc7..ee4d0b7 100644 --- a/miniapp/store/user.js +++ b/miniapp/store/user.js @@ -37,7 +37,9 @@ export const useUserStore = defineStore('user', { isMember: false, memberLevel: 0, isRealName: false, - genderPreference: getGenderPreference() || 0 + genderPreference: getGenderPreference() || 0, + /** 上次从服务器刷新用户信息的时间戳(用于节流) */ + lastRefreshTime: 0 }), getters: { @@ -215,6 +217,8 @@ export const useUserStore = defineStore('user', { memberLevel: data.memberLevel, isRealName: data.isRealName }) + // 更新刷新时间戳 + this.lastRefreshTime = Date.now() } } catch (error) { console.error('刷新用户信息失败:', error) diff --git a/miniapp/utils/navigate.js b/miniapp/utils/navigate.js new file mode 100644 index 0000000..0af1058 --- /dev/null +++ b/miniapp/utils/navigate.js @@ -0,0 +1,200 @@ +/** + * 页面导航工具函数 + * 统一处理小程序内的各种跳转逻辑 + */ + +/** + * TabBar 页面路径列表 + * 这些页面需要使用 switchTab 而不是 navigateTo + */ +export const TABBAR_PAGES = [ + '/pages/index/index', + '/pages/message/index', + '/pages/mine/index' +] + +/** + * 链接类型枚举 + */ +export const LINK_TYPE = { + /** 内部页面 */ + INTERNAL: 1, + /** 外部链接(H5) */ + EXTERNAL: 2, + /** 其他小程序 */ + MINI_PROGRAM: 3 +} + +/** + * 判断是否为 TabBar 页面 + * @param {string} url - 页面路径 + * @returns {boolean} + */ +export function isTabBarPage(url) { + return TABBAR_PAGES.includes(url) +} + +/** + * 统一页面跳转函数 + * 根据链接类型自动选择合适的跳转方式 + * + * @param {string} url - 跳转地址 + * @param {number} linkType - 链接类型:1=内部页面, 2=外部链接, 3=小程序 + * @param {Object} options - 额外配置 + * @param {Function} options.onFail - 跳转失败回调 + * @param {Function} options.onSuccess - 跳转成功回调 + */ +export function navigateToPage(url, linkType = LINK_TYPE.INTERNAL, options = {}) { + if (!url) { + console.warn('[navigate] url 为空,跳过跳转') + return + } + + const { onFail, onSuccess } = options + + switch (linkType) { + case LINK_TYPE.EXTERNAL: + // 外部链接:复制到剪贴板(小程序限制无法直接打开外部链接) + handleExternalLink(url) + break + + case LINK_TYPE.MINI_PROGRAM: + // 跳转其他小程序 + handleMiniProgramLink(url, { onFail, onSuccess }) + break + + case LINK_TYPE.INTERNAL: + default: + // 内部页面跳转 + handleInternalLink(url, { onFail, onSuccess }) + break + } +} + +/** + * 处理内部页面跳转 + * 自动判断是否为 TabBar 页面 + * + * @param {string} url - 页面路径 + * @param {Object} callbacks - 回调函数 + */ +function handleInternalLink(url, { onFail, onSuccess } = {}) { + const navigateMethod = isTabBarPage(url) ? uni.switchTab : uni.navigateTo + + navigateMethod({ + url, + success: () => { + onSuccess && onSuccess() + }, + fail: (err) => { + console.error('[navigate] 页面跳转失败:', url, err) + if (onFail) { + onFail(err) + } else { + uni.showToast({ + title: '页面跳转失败', + icon: 'none' + }) + } + } + }) +} + +/** + * 处理外部链接 + * 由于小程序限制,外部链接只能复制到剪贴板 + * + * @param {string} url - 外部链接地址 + */ +function handleExternalLink(url) { + uni.setClipboardData({ + data: url, + success: () => { + uni.showToast({ + title: '链接已复制', + icon: 'success' + }) + }, + fail: () => { + uni.showToast({ + title: '复制失败', + icon: 'none' + }) + } + }) +} + +/** + * 处理小程序跳转 + * URL 格式:appId:path(path 可选) + * + * @param {string} url - 格式为 "appId:path" 的字符串 + * @param {Object} callbacks - 回调函数 + */ +function handleMiniProgramLink(url, { onFail, onSuccess } = {}) { + const [appId, path] = url.split(':') + + if (!appId) { + console.error('[navigate] 小程序 appId 为空') + uni.showToast({ + title: '跳转配置错误', + icon: 'none' + }) + return + } + + uni.navigateToMiniProgram({ + appId, + path: path || '', + success: () => { + onSuccess && onSuccess() + }, + fail: (err) => { + console.error('[navigate] 跳转小程序失败:', appId, err) + if (onFail) { + onFail(err) + } else { + uni.showToast({ + title: '跳转失败', + icon: 'none' + }) + } + } + }) +} + +/** + * 跳转到 WebView 页面 + * 用于在小程序内打开 H5 页面 + * + * @param {string} url - H5 页面地址 + */ +export function navigateToWebView(url) { + if (!url) return + + uni.navigateTo({ + url: `/pages/webview/index?url=${encodeURIComponent(url)}` + }) +} + +/** + * 返回上一页 + * @param {number} delta - 返回的页面数,默认 1 + */ +export function navigateBack(delta = 1) { + uni.navigateBack({ delta }) +} + +/** + * 重定向到指定页面(关闭当前页) + * @param {string} url - 页面路径 + */ +export function redirectTo(url) { + if (!url) return + + if (isTabBarPage(url)) { + uni.switchTab({ url }) + } else { + uni.redirectTo({ url }) + } +} diff --git a/server/src/XiangYi.AppApi/Controllers/InteractController.cs b/server/src/XiangYi.AppApi/Controllers/InteractController.cs index b5e20f8..cad3e76 100644 --- a/server/src/XiangYi.AppApi/Controllers/InteractController.cs +++ b/server/src/XiangYi.AppApi/Controllers/InteractController.cs @@ -250,6 +250,36 @@ public class InteractController : ControllerBase }); } + /// + /// 获取互动新增数量统计 + /// + /// 各类互动的新增数量 + [HttpGet("counts")] + public async Task> GetInteractCounts() + { + var userId = GetCurrentUserId(); + var result = await _interactService.GetInteractCountsAsync(userId); + return ApiResponse.Success(result); + } + + /// + /// 标记互动为已读 + /// + /// 互动类型:viewedMe, favoritedMe, unlockedMe + /// 操作结果 + [HttpPost("markRead/{type}")] + public async Task> MarkInteractAsRead(string type) + { + if (string.IsNullOrWhiteSpace(type)) + { + return ApiResponse.Error(ErrorCodes.InvalidParameter, "互动类型不能为空"); + } + + var userId = GetCurrentUserId(); + await _interactService.MarkInteractAsReadAsync(userId, type); + return ApiResponse.Success(true); + } + /// /// 获取当前用户ID /// diff --git a/server/src/XiangYi.Application/DTOs/Responses/InteractResponses.cs b/server/src/XiangYi.Application/DTOs/Responses/InteractResponses.cs index d1b5d30..b75a01e 100644 --- a/server/src/XiangYi.Application/DTOs/Responses/InteractResponses.cs +++ b/server/src/XiangYi.Application/DTOs/Responses/InteractResponses.cs @@ -328,3 +328,24 @@ public class UnlockRecordResponse /// public DateTime CreateTime { get; set; } } + +/// +/// 互动新增数量统计响应 +/// +public class InteractCountsResponse +{ + /// + /// "看过我"新增数量 + /// + public int ViewedMeCount { get; set; } + + /// + /// "收藏我"新增数量 + /// + public int FavoritedMeCount { get; set; } + + /// + /// "解锁我"新增数量 + /// + public int UnlockedMeCount { get; set; } +} diff --git a/server/src/XiangYi.Application/Interfaces/IInteractService.cs b/server/src/XiangYi.Application/Interfaces/IInteractService.cs index c1b4c6c..f1fc150 100644 --- a/server/src/XiangYi.Application/Interfaces/IInteractService.cs +++ b/server/src/XiangYi.Application/Interfaces/IInteractService.cs @@ -86,4 +86,18 @@ public interface IInteractService /// 用户ID /// 剩余解锁次数 Task GetRemainingUnlockQuotaAsync(long userId); + + /// + /// 获取互动新增数量统计 + /// + /// 用户ID + /// 各类互动的新增数量 + Task GetInteractCountsAsync(long userId); + + /// + /// 标记互动为已读 + /// + /// 用户ID + /// 互动类型:viewedMe, favoritedMe, unlockedMe + Task MarkInteractAsReadAsync(long userId, string type); } diff --git a/server/src/XiangYi.Application/Services/InteractService.cs b/server/src/XiangYi.Application/Services/InteractService.cs index 64e425c..d486c3e 100644 --- a/server/src/XiangYi.Application/Services/InteractService.cs +++ b/server/src/XiangYi.Application/Services/InteractService.cs @@ -776,4 +776,72 @@ public class InteractService : IInteractService } #endregion + + #region 互动统计 + + /// + public async Task GetInteractCountsAsync(long userId) + { + var user = await _userRepository.GetByIdAsync(userId); + if (user == null) + { + return new InteractCountsResponse(); + } + + // 获取用户最后查看各类互动的时间,如果为空则使用用户创建时间 + var lastViewedMeTime = user.LastViewedMeReadTime ?? user.CreateTime; + var lastFavoritedMeTime = user.LastFavoritedMeReadTime ?? user.CreateTime; + var lastUnlockedMeTime = user.LastUnlockedMeReadTime ?? user.CreateTime; + + // 统计各类互动的新增数量 + var viewedMeCount = await _viewRepository.CountAsync(v => + v.TargetUserId == userId && v.LastViewTime > lastViewedMeTime); + + var favoritedMeCount = await _favoriteRepository.CountAsync(f => + f.TargetUserId == userId && f.CreateTime > lastFavoritedMeTime); + + var unlockedMeCount = await _unlockRepository.CountAsync(u => + u.TargetUserId == userId && u.CreateTime > lastUnlockedMeTime); + + return new InteractCountsResponse + { + ViewedMeCount = (int)viewedMeCount, + FavoritedMeCount = (int)favoritedMeCount, + UnlockedMeCount = (int)unlockedMeCount + }; + } + + /// + public async Task MarkInteractAsReadAsync(long userId, string type) + { + var user = await _userRepository.GetByIdAsync(userId); + if (user == null) + { + throw new BusinessException(ErrorCodes.UserNotFound, "用户不存在"); + } + + var now = DateTime.Now; + + switch (type.ToLower()) + { + case "viewedme": + user.LastViewedMeReadTime = now; + break; + case "favoritedme": + user.LastFavoritedMeReadTime = now; + break; + case "unlockedme": + user.LastUnlockedMeReadTime = now; + break; + default: + throw new BusinessException(ErrorCodes.InvalidParameter, "无效的互动类型"); + } + + user.UpdateTime = now; + await _userRepository.UpdateAsync(user); + + _logger.LogInformation("标记互动已读: UserId={UserId}, Type={Type}", userId, type); + } + + #endregion } diff --git a/server/src/XiangYi.Core/Entities/Biz/User.cs b/server/src/XiangYi.Core/Entities/Biz/User.cs index 0da305f..5cbc12a 100644 --- a/server/src/XiangYi.Core/Entities/Biz/User.cs +++ b/server/src/XiangYi.Core/Entities/Biz/User.cs @@ -106,6 +106,21 @@ public class User : SoftDeleteEntity /// public DateTime? LastLoginTime { get; set; } + /// + /// 最后查看"看过我"的时间 + /// + public DateTime? LastViewedMeReadTime { get; set; } + + /// + /// 最后查看"收藏我"的时间 + /// + public DateTime? LastFavoritedMeReadTime { get; set; } + + /// + /// 最后查看"解锁我"的时间 + /// + public DateTime? LastUnlockedMeReadTime { get; set; } + #region 导航属性 ///