-
+
+
+
{{ todoUndoneList.length }}
{{ $L('待办') }}
-
+
-
+
-
+
+ todoRemindEditing = val"
+ @on-ok="confirmTodoRemind(item)"
+ @on-clear="cancelTodoRemind(item)"
+ @on-open-change="v => { if (!v && todoRemindOpenUserid === item.userid) todoRemindOpenUserid = 0 }">
+
+
+ {{ todoRemindFormat(item.remind_at) }}
+
+
+
+
+
@@ -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);
+ }
+}