feat(todo): 聊天待办支持提醒时间(到点引用原消息+@提及)

给消息待办增加可选「提醒时间」,到点由 todo-alert 机器人对原消息发起
reply、正文 @ 仍在群内的被指派成员,完全复用原生回复/提及链路(定向未读、
红点、绕过会话免打扰、App 推送);被指派人全部退群则跳过发送并标记已提醒。
设/改/取消提醒的权限沿用 todo_set_permission 开关与 checkTodoOwnerPermission。

后端:
- 迁移:web_socket_dialog_msg_todos 增加 remind_at/reminded_at 及索引,
  注册为日期字段
- WebSocketDialogMsgTodo::dueReminders() 选取到点(未提醒/未完成)待办(limit 500)
- WebSocketDialogMsg::setTodoRemind() 纯数据写入(改时间重置 reminded_at),
  接入 toggleTodoMsg($remindAt) 与 msg__todo 透传
- 接口 msg__todoremind 设置/修改/取消提醒(权限闸门、消息类型校验、
  pushMsg 同步 todo_done)
- TodoRemindTask 到点按消息发提醒(reminded_at 防重复、迟发补发、原消息/
  会话删除兜底),buildRemindText 生成 <span class="mention user"> 文本,
  接入 crontab;登记 todo-alert 机器人
- msgJoinGroup 从提醒文本中提取被 @ 成员

前端:
- 设待办弹窗新增「提醒时间」(预设 + 自定义 DatePicker)
- 待办详情浮层每条待办可查看/修改/取消提醒:DatePicker on-clear「清空」
  二次确认后取消,无时间时仅关闭面板不发请求
- 待办浮层窄屏(≤500px)改为 待办/完成 tab 切换,宽屏维持双列;列表为空
  展示空状态占位;提醒时间用 Icon 替换 emoji
- 时间读写对齐项目任务时间的时区约定

测试:tests/Feature/TodoRemindTest(数据/选取/写入/权限决策/buildRemindText/
text mention 提取),TodoSetPermissionTest 无回归。

任务 #124 后续增强。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
kuaifan 2026-06-01 12:08:34 +00:00
parent adc7fb0d07
commit 6b54b7b1c5
14 changed files with 716 additions and 13 deletions

View File

@ -1670,6 +1670,7 @@ class DialogController extends AbstractController
if (!in_array($botType, [
'system-msg',
'task-alert',
'todo-alert',
'check-in',
'approval-alert',
'meeting-alert',
@ -2571,7 +2572,8 @@ class DialogController extends AbstractController
} else {
$userids = is_array($userids) ? $userids : [];
}
return $msg->toggleTodoMsg($user->userid, $userids);
$remindAt = Request::exists('remind_at') ? (trim(Request::input('remind_at', '')) ?: null) : false;
return $msg->toggleTodoMsg($user->userid, $userids, $remindAt);
}
/**
@ -2604,6 +2606,64 @@ class DialogController extends AbstractController
return Base::retSuccess('success', $todo ?: []);
}
/**
* @api {post} api/dialog/msg/todoremind 设置/修改/取消待办提醒时间
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup dialog
* @apiName msg__todoremind
*
* @apiParam {Number} msg_id 消息ID
* @apiParam {Array} userids 目标成员ID组
* @apiParam {String} remind_at 提醒时间(空表示取消提醒)
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function msg__todoremind()
{
$user = User::auth();
//
$msg_id = intval(Request::input("msg_id"));
$userids = Request::input('userids');
$userids = is_array($userids) ? array_values(array_filter(array_map('intval', $userids))) : [];
$remindAt = trim(Request::input('remind_at', '')) ?: null;
//
$msg = WebSocketDialogMsg::whereId($msg_id)->first();
if (empty($msg)) {
return Base::retError("消息不存在或已被删除");
}
if (in_array($msg->type, ['tag', 'todo', 'notice'])) {
return Base::retError('此消息不支持设待办');
}
$dialog = WebSocketDialog::checkDialog($msg->dialog_id);
//
if (empty($userids)) {
return Base::retError("请选择成员");
}
// 权限管控(与设/取消待办同一开关与放行规则)
if (Base::settingFind('system', 'todo_set_permission') === 'close') {
$others = array_diff($userids, [$user->userid]);
if ($others && !$dialog->checkTodoOwnerPermission($user->userid)) {
return Base::retError('仅群主、项目/任务负责人可设置或取消他人待办');
}
}
//
$msg->setTodoRemind($userids, $remindAt);
//
$upData = [
'id' => $msg->id,
'todo' => $msg->todo,
'todo_done' => $msg->isTodoDone(true),
'dialog_id' => $msg->dialog_id,
];
$dialog->pushMsg('update', $upData);
//
return Base::retSuccess($remindAt ? '设置成功' : '取消成功', $upData);
}
/**
* @api {get} api/dialog/msg/done 完成待办
*

View File

@ -23,6 +23,7 @@ use App\Tasks\CheckinRemindTask;
use App\Tasks\CloseMeetingRoomTask;
use App\Tasks\ManticoreSyncTask;
use App\Tasks\UnclaimedTaskRemindTask;
use App\Tasks\TodoRemindTask;
use App\Tasks\AiTaskLoopTask;
use Hhxsv5\LaravelS\Swoole\Task\Task;
use Laravolt\Avatar\Avatar;
@ -270,6 +271,8 @@ class IndexController extends InvokeController
Task::deliver(new JokeSoupTask());
// 未领取任务通知
Task::deliver(new UnclaimedTaskRemindTask());
// 待办提醒
Task::deliver(new TodoRemindTask());
// 关闭会议室
Task::deliver(new CloseMeetingRoomTask());
// Manticore Search 同步

View File

@ -51,6 +51,8 @@ class AbstractModel extends Model
'read_at',
'done_at',
'remind_at',
'reminded_at',
'created_at',
'updated_at',

View File

@ -151,6 +151,7 @@ class UserBot extends AbstractModel
$name = match ($name) {
'system-msg' => '系统消息',
'task-alert' => '任务提醒',
'todo-alert' => '待办提醒',
'check-in' => '签到打卡',
'anon-msg' => '匿名消息',
'approval-alert' => '审批',

View File

@ -414,7 +414,7 @@ class WebSocketDialogMsg extends AbstractModel
* @param array $userids 设置给指定会员
* @return mixed
*/
public function toggleTodoMsg($sender, $userids = [])
public function toggleTodoMsg($sender, $userids = [], $remindAt = false)
{
if (in_array($this->type, ['tag', 'todo', 'notice'])) {
return Base::retError('此消息不支持设待办');
@ -485,12 +485,39 @@ class WebSocketDialogMsg extends AbstractModel
];
$dialog->pushMsg('update', $upData);
//
// 提醒时间仅当调用方显式传入时处理false=不传则不动既有提醒)
if ($remindAt !== false) {
$this->setTodoRemind($userids, $remindAt ?: null);
}
//
return Base::retSuccess($this->todo ? '设置成功' : '取消成功', [
'add' => $addData,
'update' => $upData,
]);
}
/**
* 设置/取消本消息指定成员待办的提醒时间(纯数据,无推送)。
* 改动会把 reminded_at 重置为 null,使其可再次到点提醒。
*
* @param array $userids 目标成员
* @param string|null $remindAt 提醒时间字符串null/ 表示取消提醒
* @return int 受影响行数
*/
public function setTodoRemind(array $userids, $remindAt = null)
{
$userids = array_values(array_filter(array_map('intval', $userids)));
if (empty($userids)) {
return 0;
}
return WebSocketDialogMsgTodo::whereMsgId($this->id)
->whereIn('userid', $userids)
->update([
'remind_at' => $remindAt ?: null,
'reminded_at' => null,
]);
}
/**
* 转发消息
* @param array|int $dialogids

View File

@ -2,6 +2,8 @@
namespace App\Models;
use Carbon\Carbon;
/**
* App\Models\WebSocketDialogMsgTodo
*
@ -50,4 +52,21 @@ class WebSocketDialogMsgTodo extends AbstractModel
}
return $this->appendattrs['msgData'];
}
/**
* 取到点待提醒的待办行:有提醒时间、未提醒、未完成、提醒时间已到。
* 纯查询,无副作用,供 TodoRemindTask 使用。
* @return \Illuminate\Database\Eloquent\Collection
*/
public static function dueReminders()
{
return self::whereNotNull('remind_at')
->whereNull('reminded_at')
->whereNull('done_at')
->where('remind_at', '<=', Carbon::now())
->orderBy('msg_id')
->orderBy('id')
->limit(500)
->get();
}
}

View File

@ -0,0 +1,86 @@
<?php
namespace App\Tasks;
use App\Models\User;
use App\Models\WebSocketDialog;
use App\Models\WebSocketDialogMsg;
use App\Models\WebSocketDialogMsgTodo;
use App\Module\Doo;
use Carbon\Carbon;
/**
* 待办提醒:到点由 todo-alert 机器人在原会话发一条「引用原消息 + @被指派成员」的普通文本
* (同一消息同批到点的成员合并一条)。
*/
class TodoRemindTask extends AbstractTask
{
public function __construct()
{
parent::__construct();
}
/**
* 构造提醒文本:每个被提醒成员一个 @ span + 提示语。
* 直接拼 <span class="mention user" data-id> 是因为 sendMsg 不会调用 formatMsg
* 文本会原样入库msgJoinGroup 据此 span 正则提取 @
*/
public static function buildRemindText(array $mentionUserids): string
{
$nicknames = User::whereIn('userid', $mentionUserids)->pluck('nickname', 'userid');
$mentionText = '';
foreach ($mentionUserids as $uid) {
$name = $nicknames[$uid] ?? $uid;
$mentionText .= "<span class=\"mention user\" data-id=\"{$uid}\">@{$name}</span> ";
}
return $mentionText . Doo::translate('你有一条待办到提醒时间啦');
}
public function start()
{
$rows = WebSocketDialogMsgTodo::dueReminders();
if ($rows->isEmpty()) {
return;
}
$botUser = User::botGetOrCreate('todo-alert');
if (empty($botUser)) {
return;
}
foreach ($rows->groupBy('msg_id') as $msgId => $group) {
$rowIds = $group->pluck('id')->toArray();
$userids = $group->pluck('userid')->map('intval')->values()->toArray();
//
$msg = WebSocketDialogMsg::find($msgId);
$dialog = $msg ? WebSocketDialog::find($msg->dialog_id) : null;
if (empty($msg) || empty($dialog)) {
// 原消息/会话已不存在:标记已提醒,避免空转重复扫描
WebSocketDialogMsgTodo::whereIn('id', $rowIds)->update(['reminded_at' => Carbon::now()]);
continue;
}
//
$memberIds = $dialog->dialogUser->pluck('userid')->map('intval')->values()->toArray();
$mentionUserids = array_values(array_intersect($userids, $memberIds));
if (empty($mentionUserids)) {
// 被指派人都已退群:没人可 @,标记已提醒避免空转重复扫描
WebSocketDialogMsgTodo::whereIn('id', $rowIds)->update(['reminded_at' => Carbon::now()]);
continue;
}
$res = WebSocketDialogMsg::sendMsg(
"reply-{$msg->id}", // 引用原消息 → reply_data 自动填充
$dialog->id,
'text', // 普通文本
['text' => self::buildRemindText($mentionUserids)],
$botUser->userid,
false, false, false // push_self / push_retry / push_silence
);
//
if (\App\Module\Base::isSuccess($res)) {
WebSocketDialogMsgTodo::whereIn('id', $rowIds)->update(['reminded_at' => Carbon::now()]);
}
}
}
public function end()
{
}
}

View File

@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddRemindToWebSocketDialogMsgTodos extends Migration
{
public function up()
{
Schema::table('web_socket_dialog_msg_todos', function (Blueprint $table) {
$table->timestamp('remind_at')->nullable()->comment('提醒时间')->after('done_at');
$table->timestamp('reminded_at')->nullable()->comment('已提醒时间')->after('remind_at');
$table->index(['remind_at', 'reminded_at', 'done_at'], 'idx_todo_remind');
});
}
public function down()
{
Schema::table('web_socket_dialog_msg_todos', function (Blueprint $table) {
$table->dropIndex('idx_todo_remind');
$table->dropColumn(['remind_at', 'reminded_at']);
});
}
}

View File

@ -988,3 +988,7 @@ LDAP 用户缺少邮箱属性,请联系管理员配置
单次最多导入500条
没有可导入的数据
解析完成
请选择成员
待办提醒
你有一条待办到提醒时间啦

View File

@ -2423,4 +2423,14 @@ AI任务分析
解析失败
设置部门到选中(*)项
成功导入(*)条
提醒时间
不提醒
1 小时后
今晚 20:00
明早 9:00
成功导入(*)条
标记完成
暂无待办
暂无完成
取消提醒
确定取消该成员的提醒时间吗?

View File

@ -86,32 +86,68 @@
<EPopover
v-model="todoShow"
ref="todo"
popper-class="dialog-wrapper-read-poptip"
popper-class="dialog-wrapper-read-poptip dialog-wrapper-todo-poptip"
:placement="isRightMsg ? 'bottom-end' : 'bottom-start'">
<div class="read-poptip-content">
<Scrollbar class-name="read">
<div class="read-title">
<div class="read-poptip-content" :class="{'is-tab': todoNarrow}">
<!-- 窄屏 tab 不平分标签靠左添加仅待办显示 -->
<div v-if="todoNarrow" class="todo-tabbar">
<span class="todo-tab" :class="{on: todoTab === 'undone'}" @click.stop="todoTab = 'undone'">{{ $L('待办') }} <em>{{ todoUndoneList.length }}</em></span>
<span class="todo-tab" :class="{on: todoTab === 'done'}" @click.stop="todoTab = 'done'">{{ $L('完成') }} <em>{{ todoDoneList.length }}</em></span>
<span class="space"></span>
<Button v-if="todoTab === 'undone'" type="primary" size="small" @click.stop="handleTodoAdd">{{ $L('添加') }}</Button>
</div>
<!-- 完成 -->
<Scrollbar v-if="!todoNarrow || todoTab === 'done'" class-name="read">
<div v-if="!todoNarrow" class="read-title">
<em>{{ todoDoneList.length }}</em>
{{ $L('完成') }}
</div>
<ul>
<ul v-if="todoDoneList.length">
<li v-for="item in todoDoneList" :key="`todo-done-${item.userid}`">
<UserAvatar :userid="item.userid" :size="26" showName/>
</li>
</ul>
<div v-else class="read-empty">
<Icon type="ios-checkmark-circle-outline"/>
<p>{{ $L('暂无完成') }}</p>
</div>
</Scrollbar>
<Scrollbar class-name="unread">
<div class="read-title">
<!-- 待办 -->
<Scrollbar v-if="!todoNarrow || todoTab === 'undone'" class-name="unread">
<div v-if="!todoNarrow" class="read-title">
<em>{{ todoUndoneList.length }}</em>
{{ $L('待办') }}
<span class="space"></span>
<Button type="primary" size="small" @click="handleTodoAdd">{{ $L('添加') }}</Button>
<Button type="primary" size="small" @click.stop="handleTodoAdd">{{ $L('添加') }}</Button>
</div>
<ul>
<ul v-if="todoUndoneList.length">
<li v-for="item in todoUndoneList" :key="`todo-undone-${item.userid}`">
<UserAvatar :userid="item.userid" :size="26" showName/>
<span class="todo-remind" @click.stop>
<DatePicker
:open="todoRemindOpenUserid === item.userid"
:value="todoRemindOpenUserid === item.userid ? todoRemindEditing : item.remind_at"
type="datetime"
format="yyyy-MM-dd HH:mm"
:editable="false"
transfer
@on-change="val => todoRemindEditing = val"
@on-ok="confirmTodoRemind(item)"
@on-clear="cancelTodoRemind(item)"
@on-open-change="v => { if (!v && todoRemindOpenUserid === item.userid) todoRemindOpenUserid = 0 }">
<span v-if="item.remind_at" class="todo-remind-time" @click.stop="openTodoRemind(item)">
<Icon type="ios-alarm-outline"/>
{{ todoRemindFormat(item.remind_at) }}
</span>
<Icon v-else type="ios-clock-outline" class="todo-remind-add" @click.stop="openTodoRemind(item)"/>
</DatePicker>
</span>
</li>
</ul>
<div v-else class="read-empty">
<Icon type="ios-list-box-outline"/>
<p>{{ $L('暂无待办') }}</p>
</div>
</Scrollbar>
</div>
<div slot="reference" class="popover-reference"></div>
@ -274,6 +310,10 @@ export default {
todoLoad: 0,
todoShow: false,
todoList: [],
todoTab: 'undone',
todoRemindOpenUserid: 0,
todoRemindEditing: '',
emojiUsersNum: 5,
@ -324,6 +364,10 @@ export default {
return this.todoList.filter(({done_at}) => !done_at)
},
todoNarrow() {
return this.windowWidth <= 500;
},
viewClass() {
const {msgData} = this;
const classArray = [];
@ -460,11 +504,69 @@ export default {
}).finally(_ => {
setTimeout(() => {
this.todoLoad--;
this.todoTab = 'undone';
this.todoShow = true
}, 100)
});
},
// remind_at
todoRemindFormat(val) {
return val ? $A.dayjs(val).format("MM-DD HH:mm") : ''
},
//
openTodoRemind(item) {
this.todoRemindEditing = item.remind_at || ''
this.todoRemindOpenUserid = item.userid
},
// OK
confirmTodoRemind(item) {
this.todoRemindOpenUserid = 0
this.setTodoRemind(item, this.todoRemindEditing)
},
//
cancelTodoRemind(item) {
this.todoRemindOpenUserid = 0
if (!item.remind_at) {
return
}
$A.modalConfirm({
title: '取消提醒',
content: '确定取消该成员的提醒时间吗?',
onOk: () => {
this.setTodoRemind(item, '')
}
})
},
// //val =
setTodoRemind(item, val) {
if (item._remindLoading) {
return
}
this.$set(item, '_remindLoading', true)
const remind_at = val ? $A.dayjs(val).second(0).format("YYYY-MM-DD HH:mm:ss") : ''
this.$store.dispatch("call", {
method: 'post',
url: 'dialog/msg/todoremind',
data: {
msg_id: this.msgData.id,
userids: [item.userid],
remind_at,
},
}).then(({msg}) => {
this.$set(item, 'remind_at', remind_at || null)
$A.messageSuccess(msg)
}).catch(({msg}) => {
$A.messageError(msg)
}).finally(() => {
this.$set(item, '_remindLoading', false)
})
},
handleTodoAdd() {
this.$refs.todo.doClose();
this.$emit("on-other", {

View File

@ -552,6 +552,23 @@
<FormItem prop="userids" :label="$L('指定成员')" v-if="todoSettingData.type === 'user'">
<UserSelect ref="userSelect" v-model="todoSettingData.userids" :dialog-id="dialogId" :title="$L('选择指定成员')"/>
</FormItem>
<FormItem prop="remind_at" :label="$L('提醒时间')">
<RadioGroup v-model="todoRemindPreset">
<Radio label="none">{{$L('不提醒')}}</Radio>
<Radio label="1h">{{$L('1 小时后')}}</Radio>
<Radio label="tonight">{{$L('今晚 20:00')}}</Radio>
<Radio label="tomorrow">{{$L('明早 9:00')}}</Radio>
<Radio label="custom">{{$L('自定义')}}</Radio>
</RadioGroup>
<DatePicker
v-if="todoRemindPreset === 'custom'"
v-model="todoRemindCustom"
type="datetime"
format="yyyy-MM-dd HH:mm"
:placeholder="$L('请选择提醒时间')"
style="margin-top:8px;width:200px"
transfer/>
</FormItem>
</Form>
<div slot="footer" class="adaption">
<Button type="default" @click="todoSettingShow=false">{{$L('取消')}}</Button>
@ -871,6 +888,8 @@ export default {
userids: [],
quick_value: [],
},
todoRemindPreset: 'none',
todoRemindCustom: '',
todoSpecifyShow: false,
todoSpecifyData: {
type: 'user',
@ -4201,6 +4220,7 @@ export default {
$A.messageWarning("选择指定成员");
return
}
todoData.remind_at = this.computeTodoRemindAt()
this.todoSettingLoad++
this.onTodoSubmit(todoData).then(msg => {
$A.messageSuccess(msg)
@ -4249,6 +4269,8 @@ export default {
quick_value: [],
quick_list: Object.values(quickList),
}
this.todoRemindPreset = 'none'
this.todoRemindCustom = ''
this.todoSettingShow = true
}
}
@ -4266,6 +4288,29 @@ export default {
});
},
// /=
computeTodoRemindAt() {
const fmt = 'YYYY-MM-DD HH:mm:ss'
switch (this.todoRemindPreset) {
case '1h':
return $A.daytz().add(1, 'hour').second(0).format(fmt)
case 'tonight': {
let t = $A.daytz().hour(20).minute(0).second(0)
if (t.isBefore($A.daytz())) {
t = t.add(1, 'day')
}
return t.format(fmt)
}
case 'tomorrow':
return $A.daytz().add(1, 'day').hour(9).minute(0).second(0).format(fmt)
case 'custom':
// DatePicker
return this.todoRemindCustom ? $A.dayjs(this.todoRemindCustom).second(0).format(fmt) : ''
default:
return ''
}
},
onTodoSubmit(data) {
return new Promise((resolve, reject) => {
this.$store.dispatch("setLoad", {

View File

@ -2378,6 +2378,136 @@ body:not(.window-touch) {
}
}
// 待办浮层专属样式 todo popover 有此 class不影响已读/未读浮层
// 用组合选择器提高特异性确保覆盖 .dialog-wrapper-read-poptip 的基础宽度与定义顺序无关
.dialog-wrapper-read-poptip.dialog-wrapper-todo-poptip {
// 浮层宽度由内容决定宽屏 460 / 窄屏 300避免窄屏容器仍按宽屏宽度导致过宽
width: auto;
max-width: 90vw;
.read-poptip-content {
// 宽屏默认双列加宽让名字+时间有足够空间
width: 460px;
max-width: 90vw;
// 窄屏单列
&.is-tab {
display: block;
width: 300px;
max-width: 90vw;
margin: -12px;
padding: 0 12px 12px;
// 隐藏双列分隔线
&:before {
display: none;
}
// 窄屏待办列取消左侧 padding原双列右栏缩进
.unread {
.read-title,
ul > li {
padding-left: 0;
}
}
}
// tab 窄屏显示
.todo-tabbar {
display: flex;
align-items: center;
border-bottom: 1px solid #F4F4F5;
margin-bottom: 16px;
> .space {
flex: 1;
}
> button {
margin-right: 8px;
transform: scale(0.96);
transform-origin: right center;
}
.todo-tab {
padding: 11px 12px;
font-size: 14px;
color: #808695;
cursor: pointer;
position: relative;
white-space: nowrap;
em {
font-style: normal;
font-size: 13px;
color: inherit;
}
&.on {
color: #2d8cf0;
font-weight: 600;
&::after {
content: "";
position: absolute;
bottom: 0;
left: 12px;
right: 12px;
height: 2px;
border-radius: 2px;
background-color: #2d8cf0;
}
}
}
}
// 空状态占位完成/待办列表为空时显示
.read-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px 0;
color: #c5c8ce;
> i {
font-size: 32px;
line-height: 1;
}
> p {
margin: 6px 0 0;
font-size: 12px;
}
}
// 行内名字省略时间固定靠右
ul > li {
.common-avatar {
.avatar-name {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
.todo-remind {
flex: none;
margin-left: 8px;
.todo-remind-time {
display: flex;
align-items: center;
gap: 3px;
> i {
font-size: 16px;
}
}
}
}
}
}
.dialog-wrapper-read-poptip {
width: 360px;
max-width: 72%;
@ -2422,9 +2552,12 @@ body:not(.window-touch) {
ul > li {
min-height: 26px;
list-style: none;
display: flex;
align-items: center;
.common-avatar {
width: 100%;
flex: 1;
width: 0;
.avatar-name {
flex: 1;
width: 0;
@ -2432,6 +2565,32 @@ body:not(.window-touch) {
}
}
.todo-remind {
margin-left: auto;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #999;
cursor: pointer;
.todo-remind-time {
color: #515a6e;
white-space: nowrap;
&:hover {
color: #2d8cf0;
}
}
.todo-remind-add {
font-size: 16px;
&:hover {
color: #2d8cf0;
}
}
}
&:last-child {
padding-bottom: 6px;
}

View File

@ -0,0 +1,160 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Models\WebSocketDialog;
use App\Models\WebSocketDialogMsgTodo;
use App\Tasks\TodoRemindTask;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;
/**
* 待办提醒时间测试
*/
class TodoRemindTest extends TestCase
{
use DatabaseTransactions;
private function makeTodo(array $attr = []): WebSocketDialogMsgTodo
{
$todo = WebSocketDialogMsgTodo::createInstance(array_merge([
'dialog_id' => 1001,
'msg_id' => 2001,
'userid' => 3001,
], $attr));
$todo->save();
return $todo;
}
public function test_remind_columns_persist()
{
$todo = $this->makeTodo(['remind_at' => '2026-06-02 09:00:00']);
$fresh = WebSocketDialogMsgTodo::whereId($todo->id)->first();
$this->assertEquals('2026-06-02 09:00:00', $fresh->remind_at->format('Y-m-d H:i:s'));
$this->assertNull($fresh->reminded_at);
}
public function test_due_reminders_selects_only_due_unreminded_undone()
{
$past = Carbon::now()->subMinutes(5)->format('Y-m-d H:i:s');
$future = Carbon::now()->addHours(2)->format('Y-m-d H:i:s');
$due = $this->makeTodo(['userid' => 1, 'remind_at' => $past]);
$future1 = $this->makeTodo(['userid' => 2, 'remind_at' => $future]);
$already = $this->makeTodo(['userid' => 3, 'remind_at' => $past, 'reminded_at' => Carbon::now()]);
$done = $this->makeTodo(['userid' => 4, 'remind_at' => $past, 'done_at' => Carbon::now()]);
$noRemind = $this->makeTodo(['userid' => 5]);
$ids = WebSocketDialogMsgTodo::dueReminders()->pluck('id')->toArray();
$this->assertContains($due->id, $ids);
$this->assertNotContains($future1->id, $ids);
$this->assertNotContains($already->id, $ids);
$this->assertNotContains($done->id, $ids);
$this->assertNotContains($noRemind->id, $ids);
}
public function test_set_todo_remind_sets_and_resets_and_clears()
{
// 同一消息两人,预置已提醒状态以验证会被重置
$a = $this->makeTodo(['msg_id' => 5001, 'userid' => 11, 'reminded_at' => Carbon::now()]);
$b = $this->makeTodo(['msg_id' => 5001, 'userid' => 12, 'reminded_at' => Carbon::now()]);
$msg = new \App\Models\WebSocketDialogMsg();
$msg->id = 5001;
// 设提醒:写入 remind_at并把 reminded_at 重置为 null
$affected = $msg->setTodoRemind([11, 12], '2026-06-05 10:00:00');
$this->assertSame(2, $affected);
foreach ([$a, $b] as $row) {
$fresh = WebSocketDialogMsgTodo::whereId($row->id)->first();
$this->assertStringStartsWith('2026-06-05 10:00:00', $fresh->remind_at->format('Y-m-d H:i:s'));
$this->assertNull($fresh->reminded_at, '改时间后应允许再次提醒');
}
// 取消提醒remind_at 置 null
$msg->setTodoRemind([11], null);
$this->assertNull(WebSocketDialogMsgTodo::whereId($a->id)->first()->remind_at);
// 未传 userid 时不动任何行
$this->assertSame(0, $msg->setTodoRemind([], '2026-06-05 10:00:00'));
}
private function makeUser(string $email): User
{
$user = User::createInstance([
'email' => $email,
'userimg' => '',
'nickname' => 'TestUser_' . substr(md5($email), 0, 6),
'profession' => '',
'password' => md5('123456'),
]);
$user->save();
return $user;
}
/**
* 镜像 msg__todoremind 的权限闸门:
* 开关 close 且改到「自己以外的人」时,需操作者命中 checkTodoOwnerPermission。
*
* 注意:此逻辑为 DialogController::msg__todoremind() 权限逻辑的镜像,
* msg__todoremind 权限逻辑改动需同步更新此方法。
*/
private function remindGateAllow(WebSocketDialog $dialog, string $switch, int $sender, array $userids): bool
{
if ($switch !== 'close') {
return true;
}
$others = array_diff(array_map('intval', $userids), [$sender]);
if (!$others) {
return true; // 只改自己
}
return $dialog->checkTodoOwnerPermission($sender);
}
public function test_remind_edit_permission_follows_todo_gate()
{
$owner = $this->makeUser('r_o@test.local');
$member = $this->makeUser('r_m@test.local');
$dialog = WebSocketDialog::createGroup('Test_remind', [$owner->userid, $member->userid], 'user', $owner->userid)->fresh();
$this->assertTrue($this->remindGateAllow($dialog, 'close', $member->userid, [$member->userid])); // 改自己→放行
$this->assertFalse($this->remindGateAllow($dialog, 'close', $member->userid, [$owner->userid])); // 改他人→拒绝
$this->assertTrue($this->remindGateAllow($dialog, 'close', $owner->userid, [$member->userid])); // 群主改他人→放行
$this->assertTrue($this->remindGateAllow($dialog, 'open', $member->userid, [$owner->userid])); // open→放行
}
public function test_build_remind_text_produces_mention_spans()
{
$a = $this->makeUser('rt_a@test.local');
$b = $this->makeUser('rt_b@test.local');
$text = TodoRemindTask::buildRemindText([$a->userid, $b->userid]);
$this->assertStringContainsString("<span class=\"mention user\" data-id=\"{$a->userid}\">@{$a->nickname}</span>", $text);
$this->assertStringContainsString("<span class=\"mention user\" data-id=\"{$b->userid}\">@{$b->nickname}</span>", $text);
$this->assertStringContainsString('你有一条待办到提醒时间啦', $text);
}
public function test_msg_join_group_extracts_text_mention_from_spans()
{
$owner = $this->makeUser('tx_o@test.local');
$a = $this->makeUser('tx_a@test.local');
$b = $this->makeUser('tx_b@test.local');
$dialog = WebSocketDialog::createGroup('Test_text_mention', [$owner->userid, $a->userid, $b->userid], 'user', $owner->userid)->fresh();
$msg = new \App\Models\WebSocketDialogMsg();
$msg->dialog_id = $dialog->id;
$msg->userid = $owner->userid;
$msg->type = 'text';
$msg->msg = ['text' => TodoRemindTask::buildRemindText([$a->userid, $b->userid])];
$result = $msg->msgJoinGroup($dialog);
$mentions = array_map('intval', $result['mentions']);
sort($mentions);
$expected = [$a->userid, $b->userid];
sort($expected);
$this->assertEquals($expected, $mentions);
}
}