From 6b54b7b1c5dc4f2f7386021341375327b6ae773a Mon Sep 17 00:00:00 2001 From: kuaifan Date: Mon, 1 Jun 2026 12:08:34 +0000 Subject: [PATCH] =?UTF-8?q?feat(todo):=20=E8=81=8A=E5=A4=A9=E5=BE=85?= =?UTF-8?q?=E5=8A=9E=E6=94=AF=E6=8C=81=E6=8F=90=E9=86=92=E6=97=B6=E9=97=B4?= =?UTF-8?q?=EF=BC=88=E5=88=B0=E7=82=B9=E5=BC=95=E7=94=A8=E5=8E=9F=E6=B6=88?= =?UTF-8?q?=E6=81=AF+@=E6=8F=90=E5=8F=8A=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 给消息待办增加可选「提醒时间」,到点由 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 生成 文本, 接入 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) --- app/Http/Controllers/Api/DialogController.php | 62 ++++++- app/Http/Controllers/IndexController.php | 3 + app/Models/AbstractModel.php | 2 + app/Models/UserBot.php | 1 + app/Models/WebSocketDialogMsg.php | 29 +++- app/Models/WebSocketDialogMsgTodo.php | 19 +++ app/Tasks/TodoRemindTask.php | 86 ++++++++++ ..._remind_to_web_socket_dialog_msg_todos.php | 25 +++ language/original-api.txt | 4 + language/original-web.txt | 12 +- .../manage/components/DialogView/index.vue | 120 ++++++++++++- .../pages/manage/components/DialogWrapper.vue | 45 +++++ .../sass/pages/components/dialog-wrapper.scss | 161 +++++++++++++++++- tests/Feature/TodoRemindTest.php | 160 +++++++++++++++++ 14 files changed, 716 insertions(+), 13 deletions(-) create mode 100644 app/Tasks/TodoRemindTask.php create mode 100644 database/migrations/2026_06_01_131916_add_remind_to_web_socket_dialog_msg_todos.php create mode 100644 tests/Feature/TodoRemindTest.php diff --git a/app/Http/Controllers/Api/DialogController.php b/app/Http/Controllers/Api/DialogController.php index a111faabc..047d9d3c3 100755 --- a/app/Http/Controllers/Api/DialogController.php +++ b/app/Http/Controllers/Api/DialogController.php @@ -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 完成待办 * diff --git a/app/Http/Controllers/IndexController.php b/app/Http/Controllers/IndexController.php index ecfdd04d0..2c6fd38da 100755 --- a/app/Http/Controllers/IndexController.php +++ b/app/Http/Controllers/IndexController.php @@ -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 同步 diff --git a/app/Models/AbstractModel.php b/app/Models/AbstractModel.php index 5267c8a00..45863bb4e 100644 --- a/app/Models/AbstractModel.php +++ b/app/Models/AbstractModel.php @@ -51,6 +51,8 @@ class AbstractModel extends Model 'read_at', 'done_at', + 'remind_at', + 'reminded_at', 'created_at', 'updated_at', diff --git a/app/Models/UserBot.php b/app/Models/UserBot.php index e1d517110..4ee56c744 100644 --- a/app/Models/UserBot.php +++ b/app/Models/UserBot.php @@ -151,6 +151,7 @@ class UserBot extends AbstractModel $name = match ($name) { 'system-msg' => '系统消息', 'task-alert' => '任务提醒', + 'todo-alert' => '待办提醒', 'check-in' => '签到打卡', 'anon-msg' => '匿名消息', 'approval-alert' => '审批', diff --git a/app/Models/WebSocketDialogMsg.php b/app/Models/WebSocketDialogMsg.php index 4308d80cc..44705f71a 100644 --- a/app/Models/WebSocketDialogMsg.php +++ b/app/Models/WebSocketDialogMsg.php @@ -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 diff --git a/app/Models/WebSocketDialogMsgTodo.php b/app/Models/WebSocketDialogMsgTodo.php index 310906558..153896ef7 100644 --- a/app/Models/WebSocketDialogMsgTodo.php +++ b/app/Models/WebSocketDialogMsgTodo.php @@ -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(); + } } diff --git a/app/Tasks/TodoRemindTask.php b/app/Tasks/TodoRemindTask.php new file mode 100644 index 000000000..bdf76c342 --- /dev/null +++ b/app/Tasks/TodoRemindTask.php @@ -0,0 +1,86 @@ + 是因为 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 .= "@{$name} "; + } + 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() + { + } +} diff --git a/database/migrations/2026_06_01_131916_add_remind_to_web_socket_dialog_msg_todos.php b/database/migrations/2026_06_01_131916_add_remind_to_web_socket_dialog_msg_todos.php new file mode 100644 index 000000000..e75144187 --- /dev/null +++ b/database/migrations/2026_06_01_131916_add_remind_to_web_socket_dialog_msg_todos.php @@ -0,0 +1,25 @@ +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']); + }); + } +} diff --git a/language/original-api.txt b/language/original-api.txt index ecc2d07b1..12acfe474 100644 --- a/language/original-api.txt +++ b/language/original-api.txt @@ -988,3 +988,7 @@ LDAP 用户缺少邮箱属性,请联系管理员配置 单次最多导入500条 没有可导入的数据 解析完成 + +请选择成员 +待办提醒 +你有一条待办到提醒时间啦 diff --git a/language/original-web.txt b/language/original-web.txt index fa1c1ca5c..18e0eca0f 100644 --- a/language/original-web.txt +++ b/language/original-web.txt @@ -2423,4 +2423,14 @@ AI任务分析 解析失败 设置部门到选中(*)项 -成功导入(*)条 \ No newline at end of file +提醒时间 +不提醒 +1 小时后 +今晚 20:00 +明早 9:00 +成功导入(*)条 +标记完成 +暂无待办 +暂无完成 +取消提醒 +确定取消该成员的提醒时间吗? diff --git a/resources/assets/js/pages/manage/components/DialogView/index.vue b/resources/assets/js/pages/manage/components/DialogView/index.vue index f0d6ba200..8f73114da 100644 --- a/resources/assets/js/pages/manage/components/DialogView/index.vue +++ b/resources/assets/js/pages/manage/components/DialogView/index.vue @@ -86,32 +86,68 @@ -
- -
+
+ +
+ {{ $L('待办') }} {{ todoUndoneList.length }} + {{ $L('完成') }} {{ todoDoneList.length }} + + +
+ + +
{{ todoDoneList.length }} {{ $L('完成') }}
-
    +
    +
    + +

    {{ $L('暂无完成') }}

    +
    - -
    + + +
    {{ todoUndoneList.length }} {{ $L('待办') }} - +
    -
      +
      • + + + + + {{ todoRemindFormat(item.remind_at) }} + + + +
      +
      + +

      {{ $L('暂无待办') }}

      +
    @@ -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", { diff --git a/resources/assets/js/pages/manage/components/DialogWrapper.vue b/resources/assets/js/pages/manage/components/DialogWrapper.vue index 546e84bb5..c432c0e5e 100644 --- a/resources/assets/js/pages/manage/components/DialogWrapper.vue +++ b/resources/assets/js/pages/manage/components/DialogWrapper.vue @@ -552,6 +552,23 @@ + + + {{$L('不提醒')}} + {{$L('1 小时后')}} + {{$L('今晚 20:00')}} + {{$L('明早 9:00')}} + {{$L('自定义')}} + + +
    @@ -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", { diff --git a/resources/assets/sass/pages/components/dialog-wrapper.scss b/resources/assets/sass/pages/components/dialog-wrapper.scss index fbec85f27..ddc4d1878 100644 --- a/resources/assets/sass/pages/components/dialog-wrapper.scss +++ b/resources/assets/sass/pages/components/dialog-wrapper.scss @@ -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; } diff --git a/tests/Feature/TodoRemindTest.php b/tests/Feature/TodoRemindTest.php new file mode 100644 index 000000000..dc8799dfe --- /dev/null +++ b/tests/Feature/TodoRemindTest.php @@ -0,0 +1,160 @@ + 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("userid}\">@{$a->nickname}", $text); + $this->assertStringContainsString("userid}\">@{$b->nickname}", $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); + } +}