diff --git a/admin/src/api/message.ts b/admin/src/api/message.ts index 9c0ba848..edc463d5 100644 --- a/admin/src/api/message.ts +++ b/admin/src/api/message.ts @@ -15,6 +15,26 @@ export function setNoticeConfig(params: any) { return request.post({ url: '/notice.notice/set', params }) } +// 用户站内消息列表 +export function userNoticeLists(params: any) { + return request.get({ url: '/notice.userNotice/lists', params }) +} + +// 用户站内消息类型选项 +export function userNoticeOptions() { + return request.get({ url: '/notice.userNotice/options' }) +} + +// 发布系统站内消息 +export function sendUserNotice(params: any) { + return request.post({ url: '/notice.userNotice/send', params }) +} + +// 删除站内消息记录 +export function deleteUserNotice(params: { ids: number[] }) { + return request.post({ url: '/notice.userNotice/delete', params }) +} + // 短信设置列表 export function smsLists() { return request.get({ url: '/notice.sms_config/getConfig' }) diff --git a/admin/src/views/message/user_notice/index.vue b/admin/src/views/message/user_notice/index.vue new file mode 100644 index 00000000..3da31946 --- /dev/null +++ b/admin/src/views/message/user_notice/index.vue @@ -0,0 +1,190 @@ + + + + + + 站内消息 + 查看互动通知,并向全部或指定用户发布系统消息。 + + + 发布系统消息 + + + + + + + + + + + + + + + + + + + 查询 + 重置 + + + + + + + + + + {{ row.user_nickname || '用户' }} + ID: {{ row.user_id }} {{ row.user_account ? `· ${row.user_account}` : '' }} + + + + + {{ row.type_desc }} + + + + + {{ row.title }} + {{ row.content }} + + + + + {{ row.source_nickname || `用户 ${row.source_user_id}` }} + — + + + + + + + {{ row.read ? '已读' : '未读' }} + + + + + + + + 删除 + + + + + + + + + + + + + + 全部启用用户 + 指定用户 + + + + + + + + + + + + + + 留空则只标记已读;填写时必须是站内页面路径。 + + + + 取消 + 发布 + + + + + + diff --git a/docs/sql/create_user_notice.sql b/docs/sql/create_user_notice.sql new file mode 100644 index 00000000..596bb4e7 --- /dev/null +++ b/docs/sql/create_user_notice.sql @@ -0,0 +1,71 @@ +-- 用户站内消息 +-- 复用现有 la_notice_record 表:scene_id 1001-1005 为社区互动、积分到账和后台系统消息。 + +SET @notice_index_exists := ( + SELECT COUNT(*) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'la_notice_record' + AND index_name = 'idx_user_scene_read_id' +); +SET @notice_index_sql := IF( + @notice_index_exists = 0, + 'ALTER TABLE `la_notice_record` ADD INDEX `idx_user_scene_read_id` (`user_id`, `scene_id`, `read`, `id`)', + 'SELECT 1' +); +PREPARE notice_index_stmt FROM @notice_index_sql; +EXECUTE notice_index_stmt; +DEALLOCATE PREPARE notice_index_stmt; + +-- 管理后台:应用管理 > 消息管理 > 站内消息。 +SET @message_parent_id := ( + SELECT `id` + FROM `la_system_menu` + WHERE `type` = 'M' AND `paths` = 'message' + LIMIT 1 +); + +INSERT INTO `la_system_menu` + (`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`) +SELECT + @message_parent_id, 'C', '站内消息', '', 0, 'notice.userNotice/lists', 'user_notice', 'message/user_notice/index', '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP() +WHERE @message_parent_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM `la_system_menu` WHERE `perms` = 'notice.userNotice/lists' + ); + +SET @user_notice_menu_id := ( + SELECT `id` + FROM `la_system_menu` + WHERE `perms` = 'notice.userNotice/lists' + LIMIT 1 +); + +INSERT INTO `la_system_menu` + (`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`) +SELECT @user_notice_menu_id, permission.`name`, '', 0, permission.`perms`, '', '', '', '', 0, 0, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP() +FROM ( + SELECT '消息类型选项' AS `name`, 'notice.userNotice/options' AS `perms` + UNION ALL SELECT '发布系统消息', 'notice.userNotice/send' + UNION ALL SELECT '删除站内消息', 'notice.userNotice/delete' +) permission +WHERE @user_notice_menu_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM `la_system_menu` existing_menu WHERE existing_menu.`perms` = permission.`perms` + ); + +INSERT INTO `la_system_role_menu` (`role_id`, `menu_id`) +SELECT 1, menu.`id` +FROM `la_system_menu` menu +WHERE menu.`perms` IN ( + 'notice.userNotice/lists', + 'notice.userNotice/options', + 'notice.userNotice/send', + 'notice.userNotice/delete' +) +AND NOT EXISTS ( + SELECT 1 + FROM `la_system_role_menu` role_menu + WHERE role_menu.`role_id` = 1 + AND role_menu.`menu_id` = menu.`id` +); diff --git a/docs/业务进度管理.md b/docs/业务进度管理.md index 54c72879..85876b8c 100644 --- a/docs/业务进度管理.md +++ b/docs/业务进度管理.md @@ -2575,6 +2575,26 @@ - `uniapp/src/pages/my_comments/my_comments.vue` - `uniapp/src/pages.json` +### 132. 用户站内消息中心 + +- 状态:代码已完成,待 SQL 执行及多账号验收 - 时间:2026-08-03 + +完成内容: + +- 复用 `la_notice_record` 记录社区帖子评论、评论回复、关注、后台加积分和后台系统消息,并按接收用户独立保存已读状态。 +- 用户中心的“消息通知”接入消息中心;私聊消息独立保留入口,未读角标合并私聊与站内消息数量。 +- 消息卡片支持标记已读,并按记录跳转帖子详情、用户主页和积分明细等对应页面。 +- 管理后台新增“站内消息”管理页,可筛选、删除记录,及向全部启用用户或指定用户发布可跳转的系统消息。 + +涉及模块: + +- `server/app/common/service/notice/UserNoticeService.php` +- `server/app/api/controller/NoticeController.php` +- `server/app/adminapi/controller/notice/UserNoticeController.php` +- `admin/src/views/message/user_notice/index.vue` +- `uniapp/src/pages/message_notice/message_notice.vue` +- `docs/sql/create_user_notice.sql` + ## 四、避坑记录 - **跨模型批量更新前必须逐表核对字段**:`la_community_post`、`la_community_post_category` 有 `update_time`,但 `la_community_tag` 只有 `create_time`;复用计数刷新写法时不能默认所有业务表都有更新时间字段,否则 ThinkPHP 模型会直接返回 `fields not exists` 并导致事务回滚。 diff --git a/server/app/adminapi/controller/notice/UserNoticeController.php b/server/app/adminapi/controller/notice/UserNoticeController.php new file mode 100644 index 00000000..3863d9c0 --- /dev/null +++ b/server/app/adminapi/controller/notice/UserNoticeController.php @@ -0,0 +1,64 @@ +dataLists(new UserNoticeLists()); + } + + public function options() + { + return $this->data([ + 'scene_options' => UserNoticeEnum::getOptions(), + ]); + } + + public function send() + { + $params = (new UserNoticeValidate())->post()->goCheck('send'); + $scope = (string) $params['recipient_scope']; + $userIds = $scope === 'specified' + ? array_values(array_unique(array_map('intval', $params['user_ids']))) + : []; + $sentCount = UserNoticeService::sendSystem( + trim((string) $params['title']), + trim((string) $params['content']), + trim((string) ($params['target_url'] ?? '')), + $userIds + ); + + if ($sentCount === 0) { + return $this->fail('没有可接收消息的启用用户'); + } + + return $this->success("已发送给 {$sentCount} 位用户"); + } + + public function delete() + { + $ids = $this->request->post('ids/a', []); + $ids = array_values(array_unique(array_filter(array_map('intval', $ids)))); + if (empty($ids)) { + return $this->fail('请选择需要删除的消息'); + } + + NoticeRecord::whereIn('id', $ids) + ->whereIn('scene_id', UserNoticeEnum::getSceneIds()) + ->delete(); + + return $this->success('删除成功'); + } +} diff --git a/server/app/adminapi/lists/notice/UserNoticeLists.php b/server/app/adminapi/lists/notice/UserNoticeLists.php new file mode 100644 index 00000000..acad5331 --- /dev/null +++ b/server/app/adminapi/lists/notice/UserNoticeLists.php @@ -0,0 +1,85 @@ +buildQuery() + ->field('notice.id,notice.user_id,notice.title,notice.content,notice.scene_id,notice.read,notice.extra,notice.create_time,user.nickname AS user_nickname,user.account AS user_account') + ->order('notice.id desc') + ->limit($this->limitOffset, $this->limitLength) + ->select() + ->toArray(); + + $sourceUserIds = []; + foreach ($lists as $item) { + $extra = UserNoticeService::decodeExtra($item['extra'] ?? ''); + if (!empty($extra['source_user_id'])) { + $sourceUserIds[] = (int) $extra['source_user_id']; + } + } + $sourceUsers = []; + $sourceUserIds = array_values(array_unique(array_filter($sourceUserIds))); + if (!empty($sourceUserIds)) { + $sourceUsers = User::whereIn('id', $sourceUserIds)->column('nickname', 'id'); + } + + foreach ($lists as &$item) { + $extra = UserNoticeService::decodeExtra($item['extra'] ?? ''); + $meta = UserNoticeEnum::getMeta((int) $item['scene_id']); + $sourceUserId = (int) ($extra['source_user_id'] ?? 0); + $item['type_desc'] = $meta['type_desc']; + $item['source_user_id'] = $sourceUserId; + $item['source_nickname'] = $sourceUsers[$sourceUserId] ?? ''; + $item['target_url'] = UserNoticeService::sanitizeTargetUrl((string) ($extra['target_url'] ?? '')); + $item['create_time'] = is_numeric($item['create_time']) + ? date('Y-m-d H:i:s', (int) $item['create_time']) + : $item['create_time']; + } + unset($item); + + return $lists; + } + + public function count(): int + { + return $this->buildQuery()->count(); + } + + private function buildQuery() + { + $query = NoticeRecord::alias('notice') + ->leftJoin('user user', 'user.id = notice.user_id') + ->whereIn('notice.scene_id', UserNoticeEnum::getSceneIds()); + + $sceneId = (int) ($this->params['scene_id'] ?? 0); + if (in_array($sceneId, UserNoticeEnum::getSceneIds(), true)) { + $query->where('notice.scene_id', $sceneId); + } + if (array_key_exists('read', $this->params) && $this->params['read'] !== '') { + $query->where('notice.read', (int) $this->params['read']); + } + $keyword = trim((string) ($this->params['keyword'] ?? '')); + if ($keyword !== '') { + $query->where(function ($query) use ($keyword) { + $query->whereLike('notice.title', '%' . $keyword . '%') + ->whereOr('notice.content', 'like', '%' . $keyword . '%') + ->whereOr('user.nickname', 'like', '%' . $keyword . '%') + ->whereOr('user.account', 'like', '%' . $keyword . '%'); + }); + } + + return $query; + } +} diff --git a/server/app/adminapi/logic/user/UserLogic.php b/server/app/adminapi/logic/user/UserLogic.php index ba0efe16..a6d623a7 100644 --- a/server/app/adminapi/logic/user/UserLogic.php +++ b/server/app/adminapi/logic/user/UserLogic.php @@ -16,6 +16,7 @@ namespace app\adminapi\logic\user; use app\common\enum\user\AccountLogEnum; use app\common\enum\user\UserTerminalEnum; use app\common\logic\AccountLogLogic; +use app\common\service\notice\UserNoticeService; use app\common\logic\BaseLogic; use app\common\model\user\User; use think\facade\Db; @@ -178,6 +179,9 @@ class UserLogic extends BaseLogic '', $remark ); + if ($type === 'points' && $action == AccountLogEnum::INC) { + UserNoticeService::adminPoints((int) $user->id, (int) $num, $remark); + } Db::commit(); return true; diff --git a/server/app/adminapi/validate/notice/UserNoticeValidate.php b/server/app/adminapi/validate/notice/UserNoticeValidate.php new file mode 100644 index 00000000..84074950 --- /dev/null +++ b/server/app/adminapi/validate/notice/UserNoticeValidate.php @@ -0,0 +1,57 @@ + 'require|in:all,specified', + 'user_ids' => 'checkUserIds', + 'title' => 'require|max:50', + 'content' => 'require|max:1000', + 'target_url' => 'max:200|checkTargetUrl', + ]; + + protected $message = [ + 'recipient_scope.require' => '请选择接收范围', + 'recipient_scope.in' => '接收范围错误', + 'title.require' => '请输入消息标题', + 'title.max' => '消息标题不能超过50个字符', + 'content.require' => '请输入消息内容', + 'content.max' => '消息内容不能超过1000个字符', + 'target_url.max' => '跳转路径不能超过200个字符', + ]; + + protected function sceneSend() + { + return $this->only(['recipient_scope', 'user_ids', 'title', 'content', 'target_url']); + } + + protected function checkUserIds($value, $rule, array $data) + { + if (($data['recipient_scope'] ?? '') !== 'specified') { + return true; + } + if (!is_array($value) || empty($value)) { + return '请至少填写一位指定用户ID'; + } + foreach ($value as $userId) { + if (!is_numeric($userId) || (int) $userId <= 0) { + return '指定用户ID格式错误'; + } + } + return true; + } + + protected function checkTargetUrl($value) + { + if ($value === null || $value === '') { + return true; + } + return is_string($value) && str_starts_with($value, '/') && !str_starts_with($value, '//') + ? true + : '跳转路径必须以单个 / 开头'; + } +} diff --git a/server/app/api/controller/CommunityController.php b/server/app/api/controller/CommunityController.php index d6c8df47..97b18eb6 100644 --- a/server/app/api/controller/CommunityController.php +++ b/server/app/api/controller/CommunityController.php @@ -18,6 +18,7 @@ use app\common\service\ai\AiService; use app\common\service\ai\KbSyncService; use app\common\service\chat\PrivateChatService; use app\common\service\FileService; +use app\common\service\notice\UserNoticeService; use app\common\service\VipService; use think\facade\Db; @@ -416,6 +417,19 @@ class CommunityController extends BaseApiController // 更新帖子评论数 CommunityPost::where('id', $postId)->inc('comment_count')->update(); + if ($parentId > 0) { + $parentComment = CommunityComment::where('id', $parentId) + ->where('post_id', $postId) + ->where('status', 1) + ->findOrEmpty(); + $recipientUserId = $replyUserId > 0 ? $replyUserId : (int) ($parentComment->user_id ?? 0); + if (!$parentComment->isEmpty()) { + UserNoticeService::commentReply($recipientUserId, $this->userId, $postId, (int) $comment->id, $content); + } + } else { + UserNoticeService::postComment((int) $post->user_id, $this->userId, $postId, (int) $comment->id, $content); + } + return $this->data(['id' => $comment->id]); } @@ -495,6 +509,11 @@ class CommunityController extends BaseApiController return $this->fail('不能关注自己'); } + $followUser = User::where('id', $followUserId)->findOrEmpty(); + if ($followUser->isEmpty()) { + return $this->fail('用户不存在'); + } + $exists = CommunityFollow::where([ 'user_id' => $this->userId, 'follow_user_id' => $followUserId @@ -506,6 +525,7 @@ class CommunityController extends BaseApiController 'follow_user_id' => $followUserId, 'create_time' => time(), ]); + UserNoticeService::followed($followUserId, $this->userId); return $this->data(['is_followed' => true]); } else { $exists->delete(); @@ -1079,6 +1099,7 @@ class CommunityController extends BaseApiController $userPoints = User::where('id', $userId)->value('user_points') ?: 0; $chatUnreadCount = PrivateChatService::unreadCount($userId); + $noticeUnreadCount = UserNoticeService::unreadCount($userId); return $this->data([ 'post_count' => $postCount, @@ -1087,6 +1108,8 @@ class CommunityController extends BaseApiController 'collect_count' => $collectCount, 'user_points' => $userPoints, 'chat_unread_count' => $chatUnreadCount, + 'notice_unread_count' => $noticeUnreadCount, + 'message_unread_count' => $chatUnreadCount + $noticeUnreadCount, ]); } diff --git a/server/app/api/controller/NoticeController.php b/server/app/api/controller/NoticeController.php new file mode 100644 index 00000000..d3d584d2 --- /dev/null +++ b/server/app/api/controller/NoticeController.php @@ -0,0 +1,122 @@ +request->get('page_no/d', 1)); + $size = min(50, max(1, $this->request->get('page_size/d', 20))); + $query = NoticeRecord::where('user_id', $this->userId) + ->whereIn('scene_id', UserNoticeEnum::getSceneIds()); + + $total = (clone $query)->count(); + $records = $query->order('id desc') + ->page($page, $size) + ->select() + ->toArray(); + + return $this->data([ + 'lists' => $this->formatRecords($records), + 'count' => $total, + 'page_no' => $page, + 'page_size' => $size, + ]); + } + + public function summary() + { + $noticeUnreadCount = UserNoticeService::unreadCount($this->userId); + $chatUnreadCount = PrivateChatService::unreadCount($this->userId); + + return $this->data([ + 'notice_unread_count' => $noticeUnreadCount, + 'chat_unread_count' => $chatUnreadCount, + 'unread_count' => $noticeUnreadCount + $chatUnreadCount, + ]); + } + + public function read() + { + $id = $this->request->post('id/d'); + if ($id <= 0) { + return $this->fail('消息参数错误'); + } + + NoticeRecord::where('id', $id) + ->where('user_id', $this->userId) + ->whereIn('scene_id', UserNoticeEnum::getSceneIds()) + ->update([ + 'read' => YesNoEnum::YES, + 'update_time' => time(), + ]); + + return $this->success('已读'); + } + + public function readAll() + { + NoticeRecord::where('user_id', $this->userId) + ->whereIn('scene_id', UserNoticeEnum::getSceneIds()) + ->where('read', YesNoEnum::NO) + ->update([ + 'read' => YesNoEnum::YES, + 'update_time' => time(), + ]); + + return $this->success('全部消息已读'); + } + + private function formatRecords(array $records): array + { + $sourceUserIds = []; + foreach ($records as $record) { + $extra = UserNoticeService::decodeExtra($record['extra'] ?? ''); + if (!empty($extra['source_user_id'])) { + $sourceUserIds[] = (int) $extra['source_user_id']; + } + } + + $userMap = []; + $sourceUserIds = array_values(array_unique(array_filter($sourceUserIds))); + if (!empty($sourceUserIds)) { + $users = User::whereIn('id', $sourceUserIds) + ->field('id,nickname,avatar') + ->select() + ->toArray(); + foreach ($users as $user) { + $userMap[(int) $user['id']] = $user; + } + } + + foreach ($records as &$record) { + $extra = UserNoticeService::decodeExtra($record['extra'] ?? ''); + $meta = UserNoticeEnum::getMeta((int) $record['scene_id']); + $sourceUserId = (int) ($extra['source_user_id'] ?? 0); + $record = [ + 'id' => (int) $record['id'], + 'title' => $record['title'], + 'content' => $record['content'], + 'is_read' => (int) $record['read'] === YesNoEnum::YES, + 'create_time' => $record['create_time'], + 'target_url' => UserNoticeService::sanitizeTargetUrl((string) ($extra['target_url'] ?? '')), + 'source_user' => $sourceUserId > 0 ? ($userMap[$sourceUserId] ?? null) : null, + ...$meta, + ]; + } + unset($record); + + return $records; + } +} diff --git a/server/app/common/enum/notice/UserNoticeEnum.php b/server/app/common/enum/notice/UserNoticeEnum.php new file mode 100644 index 00000000..1f439601 --- /dev/null +++ b/server/app/common/enum/notice/UserNoticeEnum.php @@ -0,0 +1,84 @@ + [ + 'type' => 'post_comment', + 'type_desc' => '帖子评论', + 'icon' => 'chat', + 'color' => '#1468f5', + ], + self::COMMENT_REPLY => [ + 'type' => 'comment_reply', + 'type_desc' => '评论回复', + 'icon' => 'chat', + 'color' => '#7c4dff', + ], + self::FOLLOWED => [ + 'type' => 'followed', + 'type_desc' => '新增关注', + 'icon' => 'account', + 'color' => '#10b981', + ], + self::ADMIN_POINTS => [ + 'type' => 'admin_points', + 'type_desc' => '积分到账', + 'icon' => 'coupon', + 'color' => '#f59e0b', + ], + self::SYSTEM => [ + 'type' => 'system', + 'type_desc' => '系统消息', + 'icon' => 'bell', + 'color' => '#64748b', + ], + ]; + + return $map[$sceneId] ?? [ + 'type' => 'system', + 'type_desc' => '系统消息', + 'icon' => 'bell', + 'color' => '#64748b', + ]; + } + + public static function getOptions(): array + { + $options = []; + foreach (self::getSceneIds() as $sceneId) { + $meta = self::getMeta($sceneId); + $options[] = [ + 'value' => $sceneId, + 'label' => $meta['type_desc'], + ]; + } + return $options; + } +} diff --git a/server/app/common/service/notice/UserNoticeService.php b/server/app/common/service/notice/UserNoticeService.php new file mode 100644 index 00000000..2e835ace --- /dev/null +++ b/server/app/common/service/notice/UserNoticeService.php @@ -0,0 +1,213 @@ + $sourceUserId, + 'target_type' => 'community_post', + 'target_id' => $postId, + 'comment_id' => $commentId, + 'target_url' => "/packages_community/pages/post_detail?id={$postId}&comment_id={$commentId}", + ] + ); + } + + public static function commentReply(int $recipientUserId, int $sourceUserId, int $postId, int $commentId, string $content): bool + { + return self::create( + $recipientUserId, + UserNoticeEnum::COMMENT_REPLY, + '你的评论收到了回复', + self::sourceName($sourceUserId) . ' 回复了你的评论:' . self::shortContent($content), + [ + 'source_user_id' => $sourceUserId, + 'target_type' => 'community_comment', + 'target_id' => $commentId, + 'post_id' => $postId, + 'target_url' => "/packages_community/pages/post_detail?id={$postId}&comment_id={$commentId}", + ] + ); + } + + public static function followed(int $recipientUserId, int $sourceUserId): bool + { + return self::create( + $recipientUserId, + UserNoticeEnum::FOLLOWED, + '你有一位新关注者', + self::sourceName($sourceUserId) . ' 关注了你', + [ + 'source_user_id' => $sourceUserId, + 'target_type' => 'user_profile', + 'target_id' => $sourceUserId, + 'target_url' => "/packages_community/pages/user_profile?user_id={$sourceUserId}", + ] + ); + } + + public static function adminPoints(int $recipientUserId, int $points, string $remark = ''): bool + { + $content = '管理员为你增加了 ' . $points . ' 积分'; + $remark = self::shortContent($remark, 80); + if ($remark !== '') { + $content .= ',备注:' . $remark; + } + + return self::create( + $recipientUserId, + UserNoticeEnum::ADMIN_POINTS, + '积分到账', + $content, + [ + 'target_type' => 'points', + 'target_url' => '/packages/pages/points_log/points_log', + ] + ); + } + + /** + * 向全部启用用户或指定用户发布后台系统消息。 + */ + public static function sendSystem(string $title, string $content, string $targetUrl = '', array $userIds = []): int + { + $query = User::where('is_disable', 0); + if (!empty($userIds)) { + $query->whereIn('id', $userIds); + } + + $userIds = array_map('intval', $query->column('id')); + if (empty($userIds)) { + return 0; + } + + $now = time(); + $extra = self::encodeExtra([ + 'target_type' => 'system', + 'target_url' => self::sanitizeTargetUrl($targetUrl), + ]); + $sentCount = 0; + + foreach (array_chunk($userIds, 300) as $chunk) { + $records = []; + foreach ($chunk as $userId) { + $records[] = self::recordData($userId, UserNoticeEnum::SYSTEM, $title, $content, $extra, $now); + } + Db::name('notice_record')->insertAll($records); + $sentCount += count($records); + } + + return $sentCount; + } + + public static function unreadCount(int $userId): int + { + if ($userId <= 0) { + return 0; + } + + return NoticeRecord::where('user_id', $userId) + ->whereIn('scene_id', UserNoticeEnum::getSceneIds()) + ->where('read', YesNoEnum::NO) + ->count(); + } + + public static function decodeExtra($value): array + { + if (!is_string($value) || $value === '') { + return []; + } + + $data = json_decode($value, true); + return is_array($data) ? $data : []; + } + + public static function sanitizeTargetUrl(string $targetUrl): string + { + $targetUrl = trim($targetUrl); + if ($targetUrl === '' || !str_starts_with($targetUrl, '/') || str_starts_with($targetUrl, '//')) { + return ''; + } + + return mb_substr($targetUrl, 0, 200); + } + + private static function create(int $recipientUserId, int $sceneId, string $title, string $content, array $extra = []): bool + { + $sourceUserId = (int) ($extra['source_user_id'] ?? 0); + if ($recipientUserId <= 0 || $recipientUserId === $sourceUserId || !in_array($sceneId, UserNoticeEnum::getSceneIds(), true)) { + return false; + } + + $now = time(); + NoticeRecord::create(self::recordData( + $recipientUserId, + $sceneId, + $title, + $content, + self::encodeExtra($extra), + $now + )); + + return true; + } + + private static function recordData(int $userId, int $sceneId, string $title, string $content, string $extra, int $now): array + { + return [ + 'user_id' => $userId, + 'title' => mb_substr(trim($title), 0, 50), + 'content' => mb_substr(trim($content), 0, 1000), + 'scene_id' => $sceneId, + 'read' => YesNoEnum::NO, + 'recipient' => 1, + 'send_type' => NoticeEnum::SYSTEM, + 'notice_type' => NoticeEnum::BUSINESS_NOTIFICATION, + 'extra' => $extra, + 'create_time' => $now, + 'update_time' => $now, + ]; + } + + private static function encodeExtra(array $extra): string + { + if (isset($extra['target_url'])) { + $extra['target_url'] = self::sanitizeTargetUrl((string) $extra['target_url']); + } + $encoded = json_encode($extra, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + return is_string($encoded) ? mb_substr($encoded, 0, 255) : ''; + } + + private static function sourceName(int $userId): string + { + $name = User::where('id', $userId)->value('nickname'); + return $name ? (string) $name : '一位用户'; + } + + private static function shortContent(string $content, int $length = 60): string + { + $content = trim(preg_replace('/\s+/u', ' ', strip_tags($content)) ?: ''); + if ($content === '') { + return ''; + } + return mb_strlen($content) > $length ? mb_substr($content, 0, $length) . '…' : $content; + } +} diff --git a/uniapp/src/api/notice.ts b/uniapp/src/api/notice.ts new file mode 100644 index 00000000..6648965a --- /dev/null +++ b/uniapp/src/api/notice.ts @@ -0,0 +1,44 @@ +import request from '@/utils/request' + +export interface UserNoticeItem { + id: number + type: string + type_desc: string + icon: string + color: string + title: string + content: string + is_read: boolean + create_time: number | string + target_url: string + source_user: { + id: number + nickname: string + avatar: string + } | null +} + +export function getUserNotices(data?: { page_no?: number; page_size?: number }) { + return request.get<{ + lists: UserNoticeItem[] + count: number + page_no: number + page_size: number + }>({ url: '/notice/lists', data }) +} + +export function getUserNoticeSummary() { + return request.get<{ + notice_unread_count: number + chat_unread_count: number + unread_count: number + }>({ url: '/notice/summary' }) +} + +export function markUserNoticeRead(data: { id: number }) { + return request.post({ url: '/notice/read', data }) +} + +export function markAllUserNoticesRead() { + return request.post({ url: '/notice/readAll' }) +} diff --git a/uniapp/src/pages.json b/uniapp/src/pages.json index e3f34346..8eb01b48 100644 --- a/uniapp/src/pages.json +++ b/uniapp/src/pages.json @@ -167,6 +167,16 @@ "auth": true } }, + { + "path": "pages/message_notice/message_notice", + "style": { + "navigationStyle": "default", + "navigationBarTitleText": "消息通知" + }, + "meta": { + "auth": true + } + }, { "path": "pages/private_chat/room", "style": { diff --git a/uniapp/src/pages/message_notice/message_notice.vue b/uniapp/src/pages/message_notice/message_notice.vue new file mode 100644 index 00000000..61fefef6 --- /dev/null +++ b/uniapp/src/pages/message_notice/message_notice.vue @@ -0,0 +1,387 @@ + + + + + + 消息通知 + 互动、积分与系统消息都会在这里显示 + + + 全部已读 + + + + + + + + 私聊消息 + + {{ summary.chat_unread_count > 99 ? '99+' : summary.chat_unread_count }} + + + 查看与好友的聊天消息 + + + + + + 互动与系统消息 + + {{ summary.notice_unread_count }} 条未读 + + + + + + 暂时没有新的通知 + + + + + + + + + {{ item.title }} + {{ formatTime(item.create_time) }} + + {{ item.content }} + {{ item.type_desc }} + + + + + + + + + + + diff --git a/uniapp/src/pages/private_chat/list.vue b/uniapp/src/pages/private_chat/list.vue index fa39023f..8882b0fe 100644 --- a/uniapp/src/pages/private_chat/list.vue +++ b/uniapp/src/pages/private_chat/list.vue @@ -4,7 +4,7 @@ - 消息通知 + 私聊消息 diff --git a/uniapp/src/pages/user/user.vue b/uniapp/src/pages/user/user.vue index 227a0f53..311d787a 100644 --- a/uniapp/src/pages/user/user.vue +++ b/uniapp/src/pages/user/user.vue @@ -84,8 +84,8 @@ @tap="handleMenuTap(item)"> - - {{ stats.chat_unread_count > 99 ? '99+' : stats.chat_unread_count }} + + {{ stats.message_unread_count > 99 ? '99+' : stats.message_unread_count }} {{ item.label }} @@ -136,14 +136,15 @@ const stats = reactive({ fans_count: 0, collect_count: 0, user_points: 0, - chat_unread_count: 0 + chat_unread_count: 0, + message_unread_count: 0 }) const contentMenus = [ { key: 'posts', label: '我的帖子', icon: 'file-text', url: '/pages/my_posts/my_posts' }, { key: 'comments', label: '我的评论', icon: 'chat', url: '/pages/my_comments/my_comments' }, { key: 'collection', label: '我的收藏', icon: 'star', url: '/pages/collection/collection' }, - { key: 'notice', label: '消息通知', icon: 'bell', url: '/pages/private_chat/list' } + { key: 'notice', label: '消息通知', icon: 'bell', url: '/pages/message_notice/message_notice' } ] const accountMenus = [ @@ -214,6 +215,7 @@ const fetchStats = async () => { stats.collect_count = res.data.collect_count || 0 stats.user_points = res.data.user_points || 0 stats.chat_unread_count = Number(res.data.chat_unread_count) || 0 + stats.message_unread_count = Number(res.data.message_unread_count) || stats.chat_unread_count } } catch (e) { // 静默处理