feat: add user notification center
This commit is contained in:
@@ -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' })
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-lg font-medium">站内消息</div>
|
||||
<div class="mt-1 text-sm text-gray-400">查看互动通知,并向全部或指定用户发布系统消息。</div>
|
||||
</div>
|
||||
<el-button v-perms="['notice.userNotice/send']" type="primary" @click="openSendDialog">
|
||||
发布系统消息
|
||||
</el-button>
|
||||
</div>
|
||||
<el-form class="mb-[-16px]" :model="queryParams" :inline="true">
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="queryParams.keyword" class="w-[240px]" placeholder="标题、内容、用户昵称或账号"
|
||||
clearable @keyup.enter="resetPage" />
|
||||
</el-form-item>
|
||||
<el-form-item label="消息类型">
|
||||
<el-select v-model="queryParams.scene_id" class="w-[160px]" clearable placeholder="全部">
|
||||
<el-option v-for="item in sceneOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="阅读状态">
|
||||
<el-select v-model="queryParams.read" class="w-[130px]" clearable placeholder="全部">
|
||||
<el-option label="未读" :value="0" />
|
||||
<el-option label="已读" :value="1" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="resetPage">查询</el-button>
|
||||
<el-button @click="resetParams">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
<el-table size="large" v-loading="pager.loading" :data="pager.lists">
|
||||
<el-table-column label="ID" prop="id" width="76" />
|
||||
<el-table-column label="接收用户" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<div>{{ row.user_nickname || '用户' }}</div>
|
||||
<div class="text-xs text-gray-400">ID: {{ row.user_id }} {{ row.user_account ? `· ${row.user_account}` : '' }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" prop="type_desc" min-width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small">{{ row.type_desc }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="消息内容" min-width="300" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<div class="font-medium">{{ row.title }}</div>
|
||||
<div class="mt-1 text-sm text-gray-400">{{ row.content }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="互动用户" min-width="130">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.source_user_id">{{ row.source_nickname || `用户 ${row.source_user_id}` }}</span>
|
||||
<span v-else class="text-gray-400">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="跳转路径" prop="target_url" min-width="210" show-overflow-tooltip />
|
||||
<el-table-column label="状态" min-width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.read ? 'success' : 'warning'" size="small">
|
||||
{{ row.read ? '已读' : '未读' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="发送时间" prop="create_time" min-width="165" />
|
||||
<el-table-column label="操作" width="90" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-perms="['notice.userNotice/delete']" type="danger" link @click="handleDelete(row.id)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="mt-4 flex justify-end">
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="sendDialog.visible" title="发布系统消息" width="560px" destroy-on-close>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="接收范围">
|
||||
<el-radio-group v-model="sendDialog.recipient_scope">
|
||||
<el-radio value="all">全部启用用户</el-radio>
|
||||
<el-radio value="specified">指定用户</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="sendDialog.recipient_scope === 'specified'" label="用户 ID">
|
||||
<el-input v-model="sendDialog.user_ids_text" type="textarea" :rows="2"
|
||||
placeholder="多个用户 ID 用逗号或换行分隔" />
|
||||
</el-form-item>
|
||||
<el-form-item label="消息标题" required>
|
||||
<el-input v-model="sendDialog.title" maxlength="50" show-word-limit placeholder="例如:平台公告" />
|
||||
</el-form-item>
|
||||
<el-form-item label="消息内容" required>
|
||||
<el-input v-model="sendDialog.content" type="textarea" :rows="5" maxlength="1000" show-word-limit
|
||||
placeholder="请输入消息内容" />
|
||||
</el-form-item>
|
||||
<el-form-item label="跳转路径">
|
||||
<el-input v-model="sendDialog.target_url" placeholder="可选,例如 /pages/community/community" />
|
||||
<div class="mt-1 text-xs text-gray-400">留空则只标记已读;填写时必须是站内页面路径。</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="sendDialog.visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="sendDialog.submitting" @click="handleSend">发布</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup name="messageUserNotice">
|
||||
import { deleteUserNotice, sendUserNotice, userNoticeLists, userNoticeOptions } from '@/api/message'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import feedback from '@/utils/feedback'
|
||||
|
||||
const queryParams = reactive({
|
||||
keyword: '',
|
||||
scene_id: '',
|
||||
read: '' as number | string
|
||||
})
|
||||
const sceneOptions = ref<Array<{ value: number; label: string }>>([])
|
||||
const { pager, getLists, resetPage, resetParams } = usePaging({
|
||||
fetchFun: userNoticeLists,
|
||||
params: queryParams
|
||||
})
|
||||
|
||||
const sendDialog = reactive({
|
||||
visible: false,
|
||||
submitting: false,
|
||||
recipient_scope: 'all',
|
||||
user_ids_text: '',
|
||||
title: '',
|
||||
content: '',
|
||||
target_url: ''
|
||||
})
|
||||
|
||||
const getOptions = async () => {
|
||||
const result: any = await userNoticeOptions()
|
||||
sceneOptions.value = result?.scene_options || []
|
||||
}
|
||||
|
||||
const openSendDialog = () => {
|
||||
sendDialog.visible = true
|
||||
sendDialog.recipient_scope = 'all'
|
||||
sendDialog.user_ids_text = ''
|
||||
sendDialog.title = ''
|
||||
sendDialog.content = ''
|
||||
sendDialog.target_url = ''
|
||||
}
|
||||
|
||||
const handleSend = async () => {
|
||||
const userIds = sendDialog.user_ids_text
|
||||
.split(/[,,\s]+/)
|
||||
.map((item) => Number(item))
|
||||
.filter((item) => Number.isInteger(item) && item > 0)
|
||||
if (sendDialog.recipient_scope === 'specified' && userIds.length === 0) {
|
||||
feedback.msgError('请至少填写一位指定用户ID')
|
||||
return
|
||||
}
|
||||
|
||||
sendDialog.submitting = true
|
||||
try {
|
||||
await sendUserNotice({
|
||||
recipient_scope: sendDialog.recipient_scope,
|
||||
user_ids: userIds,
|
||||
title: sendDialog.title.trim(),
|
||||
content: sendDialog.content.trim(),
|
||||
target_url: sendDialog.target_url.trim()
|
||||
})
|
||||
sendDialog.visible = false
|
||||
getLists()
|
||||
} finally {
|
||||
sendDialog.submitting = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
await feedback.confirm('确定删除这条站内消息记录?')
|
||||
await deleteUserNotice({ ids: [id] })
|
||||
getLists()
|
||||
}
|
||||
|
||||
getOptions()
|
||||
getLists()
|
||||
</script>
|
||||
@@ -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`
|
||||
);
|
||||
@@ -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` 并导致事务回滚。
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace app\adminapi\controller\notice;
|
||||
|
||||
use app\adminapi\controller\BaseAdminController;
|
||||
use app\adminapi\lists\notice\UserNoticeLists;
|
||||
use app\adminapi\validate\notice\UserNoticeValidate;
|
||||
use app\common\enum\notice\UserNoticeEnum;
|
||||
use app\common\model\notice\NoticeRecord;
|
||||
use app\common\service\notice\UserNoticeService;
|
||||
|
||||
/**
|
||||
* 站内消息管理。
|
||||
*/
|
||||
class UserNoticeController extends BaseAdminController
|
||||
{
|
||||
public function lists()
|
||||
{
|
||||
return $this->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('删除成功');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace app\adminapi\lists\notice;
|
||||
|
||||
use app\adminapi\lists\BaseAdminDataLists;
|
||||
use app\common\enum\notice\UserNoticeEnum;
|
||||
use app\common\model\notice\NoticeRecord;
|
||||
use app\common\model\user\User;
|
||||
use app\common\service\notice\UserNoticeService;
|
||||
|
||||
/**
|
||||
* 用户站内消息记录列表。
|
||||
*/
|
||||
class UserNoticeLists extends BaseAdminDataLists
|
||||
{
|
||||
public function lists(): array
|
||||
{
|
||||
$lists = $this->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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace app\adminapi\validate\notice;
|
||||
|
||||
use app\common\validate\BaseValidate;
|
||||
|
||||
class UserNoticeValidate extends BaseValidate
|
||||
{
|
||||
protected $rule = [
|
||||
'recipient_scope' => '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
|
||||
: '跳转路径必须以单个 / 开头';
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use app\common\enum\notice\UserNoticeEnum;
|
||||
use app\common\enum\YesNoEnum;
|
||||
use app\common\model\notice\NoticeRecord;
|
||||
use app\common\model\user\User;
|
||||
use app\common\service\chat\PrivateChatService;
|
||||
use app\common\service\notice\UserNoticeService;
|
||||
|
||||
/**
|
||||
* 用户站内消息中心。
|
||||
*/
|
||||
class NoticeController extends BaseApiController
|
||||
{
|
||||
public function lists()
|
||||
{
|
||||
$page = max(1, $this->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\enum\notice;
|
||||
|
||||
/**
|
||||
* 用户站内消息场景。
|
||||
*
|
||||
* 使用独立的场景编号,避免与验证码等已有通知场景混用。
|
||||
*/
|
||||
class UserNoticeEnum
|
||||
{
|
||||
public const POST_COMMENT = 1001;
|
||||
public const COMMENT_REPLY = 1002;
|
||||
public const FOLLOWED = 1003;
|
||||
public const ADMIN_POINTS = 1004;
|
||||
public const SYSTEM = 1005;
|
||||
|
||||
public static function getSceneIds(): array
|
||||
{
|
||||
return [
|
||||
self::POST_COMMENT,
|
||||
self::COMMENT_REPLY,
|
||||
self::FOLLOWED,
|
||||
self::ADMIN_POINTS,
|
||||
self::SYSTEM,
|
||||
];
|
||||
}
|
||||
|
||||
public static function getMeta(int $sceneId): array
|
||||
{
|
||||
$map = [
|
||||
self::POST_COMMENT => [
|
||||
'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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\service\notice;
|
||||
|
||||
use app\common\enum\notice\NoticeEnum;
|
||||
use app\common\enum\notice\UserNoticeEnum;
|
||||
use app\common\enum\YesNoEnum;
|
||||
use app\common\model\notice\NoticeRecord;
|
||||
use app\common\model\user\User;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 用户站内消息服务。
|
||||
*/
|
||||
class UserNoticeService
|
||||
{
|
||||
public static function postComment(int $recipientUserId, int $sourceUserId, int $postId, int $commentId, string $content): bool
|
||||
{
|
||||
return self::create(
|
||||
$recipientUserId,
|
||||
UserNoticeEnum::POST_COMMENT,
|
||||
'你的帖子收到了评论',
|
||||
self::sourceName($sourceUserId) . ' 评论了你的帖子:' . self::shortContent($content),
|
||||
[
|
||||
'source_user_id' => $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;
|
||||
}
|
||||
}
|
||||
@@ -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' })
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
<template>
|
||||
<z-paging ref="paging" v-model="noticeList" use-page-scroll @query="queryList">
|
||||
<view class="message-notice-page">
|
||||
<view class="message-notice-page__header">
|
||||
<view>
|
||||
<text class="message-notice-page__title">消息通知</text>
|
||||
<text class="message-notice-page__subtitle">互动、积分与系统消息都会在这里显示</text>
|
||||
</view>
|
||||
<text v-if="summary.notice_unread_count > 0" class="message-notice-page__read-all" @tap="handleReadAll">
|
||||
全部已读
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="private-entry" @tap="openPrivateChat">
|
||||
<view class="private-entry__icon"><u-icon name="chat" size="34" color="#1468f5" /></view>
|
||||
<view class="private-entry__body">
|
||||
<view class="private-entry__top">
|
||||
<text class="private-entry__title">私聊消息</text>
|
||||
<text v-if="summary.chat_unread_count > 0" class="private-entry__badge">
|
||||
{{ summary.chat_unread_count > 99 ? '99+' : summary.chat_unread_count }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="private-entry__desc">查看与好友的聊天消息</text>
|
||||
</view>
|
||||
<u-icon name="arrow-right" size="24" color="#9aa4b2" />
|
||||
</view>
|
||||
|
||||
<view class="message-notice-page__section-title">
|
||||
<text>互动与系统消息</text>
|
||||
<text v-if="summary.notice_unread_count > 0" class="message-notice-page__unread-count">
|
||||
{{ summary.notice_unread_count }} 条未读
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view v-if="noticeList.length === 0" class="message-notice-empty">
|
||||
<u-icon name="bell" size="56" color="#cbd5e1" />
|
||||
<text>暂时没有新的通知</text>
|
||||
</view>
|
||||
|
||||
<view v-for="item in noticeList" :key="item.id" class="notice-card"
|
||||
:class="{ 'notice-card--unread': !item.is_read }" @tap="handleNoticeTap(item)">
|
||||
<view class="notice-card__icon" :style="{ backgroundColor: `${item.color}16` }">
|
||||
<u-icon :name="item.icon" size="30" :color="item.color" />
|
||||
</view>
|
||||
<view class="notice-card__body">
|
||||
<view class="notice-card__top">
|
||||
<text class="notice-card__title">{{ item.title }}</text>
|
||||
<text class="notice-card__time">{{ formatTime(item.create_time) }}</text>
|
||||
</view>
|
||||
<text class="notice-card__content">{{ item.content }}</text>
|
||||
<text class="notice-card__type">{{ item.type_desc }}</text>
|
||||
</view>
|
||||
<view v-if="!item.is_read" class="notice-card__dot" />
|
||||
<u-icon v-if="item.target_url" name="arrow-right" size="22" color="#aeb7c4" />
|
||||
</view>
|
||||
</view>
|
||||
</z-paging>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { reactive, ref, shallowRef } from 'vue'
|
||||
import {
|
||||
getUserNotices,
|
||||
getUserNoticeSummary,
|
||||
markAllUserNoticesRead,
|
||||
markUserNoticeRead,
|
||||
type UserNoticeItem
|
||||
} from '@/api/notice'
|
||||
|
||||
const paging = shallowRef()
|
||||
const noticeList = ref<UserNoticeItem[]>([])
|
||||
const summary = reactive({
|
||||
notice_unread_count: 0,
|
||||
chat_unread_count: 0,
|
||||
unread_count: 0
|
||||
})
|
||||
|
||||
const queryList = async (pageNo: number, pageSize: number) => {
|
||||
try {
|
||||
const { lists = [] } = await getUserNotices({ page_no: pageNo, page_size: pageSize })
|
||||
paging.value.complete(lists)
|
||||
} catch {
|
||||
paging.value.complete(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadSummary = async () => {
|
||||
try {
|
||||
const result = await getUserNoticeSummary()
|
||||
summary.notice_unread_count = Number(result?.notice_unread_count) || 0
|
||||
summary.chat_unread_count = Number(result?.chat_unread_count) || 0
|
||||
summary.unread_count = Number(result?.unread_count) || 0
|
||||
} catch {
|
||||
// 消息列表仍可正常加载。
|
||||
}
|
||||
}
|
||||
|
||||
const openPrivateChat = () => {
|
||||
uni.navigateTo({ url: '/pages/private_chat/list' })
|
||||
}
|
||||
|
||||
const handleNoticeTap = async (item: UserNoticeItem) => {
|
||||
if (!item.is_read) {
|
||||
try {
|
||||
await markUserNoticeRead({ id: item.id })
|
||||
item.is_read = true
|
||||
summary.notice_unread_count = Math.max(0, summary.notice_unread_count - 1)
|
||||
summary.unread_count = Math.max(0, summary.unread_count - 1)
|
||||
} catch {
|
||||
// 已读更新失败不阻断跳转。
|
||||
}
|
||||
}
|
||||
navigateToTarget(item.target_url)
|
||||
}
|
||||
|
||||
const handleReadAll = async () => {
|
||||
try {
|
||||
await markAllUserNoticesRead()
|
||||
noticeList.value.forEach((item) => {
|
||||
item.is_read = true
|
||||
})
|
||||
summary.unread_count = Math.max(0, summary.unread_count - summary.notice_unread_count)
|
||||
summary.notice_unread_count = 0
|
||||
uni.showToast({ title: '全部消息已读', icon: 'success' })
|
||||
} catch {
|
||||
uni.showToast({ title: '操作失败,请稍后重试', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const navigateToTarget = (url: string) => {
|
||||
if (!url) return
|
||||
const tabPages = [
|
||||
'/pages/index/index',
|
||||
'/packages_match/pages/worldcup',
|
||||
'/pages/crypto/crypto',
|
||||
'/pages/lottery_analysis/lottery_analysis',
|
||||
'/pages/community/community',
|
||||
'/pages/user/user'
|
||||
]
|
||||
const pagePath = url.split('?')[0]
|
||||
if (tabPages.includes(pagePath)) {
|
||||
uni.switchTab({ url: pagePath })
|
||||
return
|
||||
}
|
||||
uni.navigateTo({ url })
|
||||
}
|
||||
|
||||
const formatTime = (value: string | number) => {
|
||||
const timestamp = typeof value === 'number'
|
||||
? value
|
||||
: new Date(String(value).replace(/-/g, '/')).getTime() / 1000
|
||||
if (!Number.isFinite(timestamp)) return ''
|
||||
const diff = Date.now() / 1000 - timestamp
|
||||
if (diff < 60) return '刚刚'
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}分钟前`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}小时前`
|
||||
if (diff < 604800) return `${Math.floor(diff / 86400)}天前`
|
||||
const date = new Date(timestamp * 1000)
|
||||
return `${date.getMonth() + 1}-${date.getDate()}`
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
paging.value?.reload()
|
||||
loadSummary()
|
||||
})
|
||||
|
||||
loadSummary()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.message-notice-page {
|
||||
min-height: 100vh;
|
||||
padding: 28rpx 24rpx 52rpx;
|
||||
box-sizing: border-box;
|
||||
background: #f6f8fb;
|
||||
|
||||
&__header,
|
||||
&__section-title,
|
||||
.private-entry,
|
||||
.private-entry__top,
|
||||
.notice-card,
|
||||
.notice-card__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__header {
|
||||
justify-content: space-between;
|
||||
margin: 0 2rpx 20rpx;
|
||||
}
|
||||
|
||||
&__title,
|
||||
&__subtitle,
|
||||
&__section-title text,
|
||||
.private-entry__title,
|
||||
.private-entry__desc,
|
||||
.notice-card__title,
|
||||
.notice-card__content,
|
||||
.notice-card__type,
|
||||
.notice-card__time,
|
||||
.message-notice-empty text {
|
||||
display: block;
|
||||
}
|
||||
|
||||
&__title {
|
||||
color: #172033;
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
margin-top: 8rpx;
|
||||
color: #8b96a7;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
&__read-all {
|
||||
padding: 10rpx 14rpx;
|
||||
border-radius: 20rpx;
|
||||
color: #1468f5;
|
||||
background: #eaf2ff;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
&__section-title {
|
||||
justify-content: space-between;
|
||||
margin: 28rpx 4rpx 14rpx;
|
||||
color: #172033;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&__unread-count {
|
||||
color: #ef4444;
|
||||
font-size: 20rpx;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.private-entry,
|
||||
.notice-card {
|
||||
position: relative;
|
||||
border: 1rpx solid #e8edf4;
|
||||
border-radius: 18rpx;
|
||||
background: #fff;
|
||||
box-shadow: 0 8rpx 24rpx rgba(31, 54, 92, 0.04);
|
||||
}
|
||||
|
||||
.private-entry {
|
||||
padding: 22rpx;
|
||||
|
||||
&__icon,
|
||||
.notice-card__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
width: 68rpx;
|
||||
height: 68rpx;
|
||||
background: #eaf2ff;
|
||||
}
|
||||
|
||||
&__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-left: 18rpx;
|
||||
}
|
||||
|
||||
&__top {
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
&__title {
|
||||
color: #1f2937;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
margin-top: 7rpx;
|
||||
color: #8b96a7;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
&__badge {
|
||||
min-width: 30rpx;
|
||||
height: 30rpx;
|
||||
padding: 0 8rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 16rpx;
|
||||
color: #fff;
|
||||
background: #ef4444;
|
||||
font-size: 18rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
.notice-card {
|
||||
margin-bottom: 14rpx;
|
||||
padding: 22rpx 20rpx;
|
||||
|
||||
&--unread {
|
||||
border-color: #cfe0ff;
|
||||
background: linear-gradient(90deg, #f8fbff 0%, #fff 36%);
|
||||
}
|
||||
|
||||
&__icon {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
}
|
||||
|
||||
&__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-left: 16rpx;
|
||||
padding-right: 10rpx;
|
||||
}
|
||||
|
||||
&__top {
|
||||
justify-content: space-between;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
&__title {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
color: #253044;
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__time {
|
||||
flex-shrink: 0;
|
||||
color: #a0a9b6;
|
||||
font-size: 20rpx;
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: -webkit-box;
|
||||
margin-top: 8rpx;
|
||||
overflow: hidden;
|
||||
color: #647084;
|
||||
font-size: 23rpx;
|
||||
line-height: 34rpx;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
&__type {
|
||||
margin-top: 8rpx;
|
||||
color: #9aa4b2;
|
||||
font-size: 20rpx;
|
||||
}
|
||||
|
||||
&__dot {
|
||||
position: absolute;
|
||||
top: 20rpx;
|
||||
right: 20rpx;
|
||||
width: 12rpx;
|
||||
height: 12rpx;
|
||||
border-radius: 50%;
|
||||
background: #ef4444;
|
||||
}
|
||||
}
|
||||
|
||||
.message-notice-empty {
|
||||
padding: 100rpx 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
text {
|
||||
margin-top: 20rpx;
|
||||
color: #9aa4b2;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -4,7 +4,7 @@
|
||||
<view class="chat-nav__btn" @tap="goBack">
|
||||
<u-icon name="arrow-left" size="34" color="#111827" />
|
||||
</view>
|
||||
<text class="chat-nav__title">消息通知</text>
|
||||
<text class="chat-nav__title">私聊消息</text>
|
||||
<view class="chat-nav__btn" @tap="loadSessions(true)">
|
||||
<u-icon name="reload" size="30" color="#111827" />
|
||||
</view>
|
||||
|
||||
@@ -84,8 +84,8 @@
|
||||
@tap="handleMenuTap(item)">
|
||||
<view class="user-list__icon">
|
||||
<u-icon :name="item.icon" color="#1468f5" size="30" />
|
||||
<view v-if="item.key === 'notice' && stats.chat_unread_count > 0" class="user-list__badge">
|
||||
<text>{{ stats.chat_unread_count > 99 ? '99+' : stats.chat_unread_count }}</text>
|
||||
<view v-if="item.key === 'notice' && stats.message_unread_count > 0" class="user-list__badge">
|
||||
<text>{{ stats.message_unread_count > 99 ? '99+' : stats.message_unread_count }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="user-list__label">{{ item.label }}</text>
|
||||
@@ -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) {
|
||||
// 静默处理
|
||||
|
||||
Reference in New Issue
Block a user