mirror of
https://github.com/kuaifan/dootask.git
synced 2026-07-27 08:27:56 +00:00
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:
parent
adc7fb0d07
commit
6b54b7b1c5
@ -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 完成待办
|
||||
*
|
||||
|
||||
@ -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 同步
|
||||
|
||||
@ -51,6 +51,8 @@ class AbstractModel extends Model
|
||||
|
||||
'read_at',
|
||||
'done_at',
|
||||
'remind_at',
|
||||
'reminded_at',
|
||||
|
||||
'created_at',
|
||||
'updated_at',
|
||||
|
||||
@ -151,6 +151,7 @@ class UserBot extends AbstractModel
|
||||
$name = match ($name) {
|
||||
'system-msg' => '系统消息',
|
||||
'task-alert' => '任务提醒',
|
||||
'todo-alert' => '待办提醒',
|
||||
'check-in' => '签到打卡',
|
||||
'anon-msg' => '匿名消息',
|
||||
'approval-alert' => '审批',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
86
app/Tasks/TodoRemindTask.php
Normal file
86
app/Tasks/TodoRemindTask.php
Normal 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()
|
||||
{
|
||||
}
|
||||
}
|
||||
@ -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']);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -988,3 +988,7 @@ LDAP 用户缺少邮箱属性,请联系管理员配置
|
||||
单次最多导入500条
|
||||
没有可导入的数据
|
||||
解析完成
|
||||
|
||||
请选择成员
|
||||
待办提醒
|
||||
你有一条待办到提醒时间啦
|
||||
|
||||
@ -2423,4 +2423,14 @@ AI任务分析
|
||||
解析失败
|
||||
|
||||
设置部门到选中(*)项
|
||||
成功导入(*)条
|
||||
提醒时间
|
||||
不提醒
|
||||
1 小时后
|
||||
今晚 20:00
|
||||
明早 9:00
|
||||
成功导入(*)条
|
||||
标记完成
|
||||
暂无待办
|
||||
暂无完成
|
||||
取消提醒
|
||||
确定取消该成员的提醒时间吗?
|
||||
|
||||
@ -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", {
|
||||
|
||||
@ -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", {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
160
tests/Feature/TodoRemindTest.php
Normal file
160
tests/Feature/TodoRemindTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user