Compare commits

..

12 Commits

Author SHA1 Message Date
kuaifan
ed2f843815 feat(middleware): 优化 WebApi 中的 HTTPS 强制设置逻辑 2026-04-04 07:48:04 +08:00
kuaifan
984b98e4fc feat(task): 实现消息合并转发功能,支持批量选择和转发消息 2026-04-04 07:43:26 +08:00
kuaifan
4b32472d64 feat(task): 增加AI自动分析开关(系统级+项目级)
系统设置新增 task_ai_auto_analyze 开关控制全局AI任务分析;项目设置新增 ai_auto_analyze 开关,系统关闭时项目无法开启。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:51:38 +08:00
kuaifan
fc171bc71f chore: 更新子项目提交哈希 2026-04-02 14:34:06 +08:00
kuaifan
cc80fa83e0 chore: 删除 Graphiti 长期记忆集成文档 2026-03-31 16:23:52 +08:00
kuaifan
782ba4a151 docs: optimize CLAUDE.md — remove discoverable content, add critical gotchas
Remove self-description header, 38-line command listing, directory trees,
and standard Laravel patterns that Claude can infer from code.

Add 6 project-specific gotchas Claude would get wrong: non-REST routing
(InvokeController), custom response envelope (Base::retSuccess/retError),
AbstractModel::createInstance, Doo::userId auth, manual validation (no
FormRequest), and Swoole Task (not Laravel Queue).

122 lines → 46 lines.
2026-03-13 10:49:03 +00:00
kuaifan
04708cedb6 feat(task): 增加解除任务关联功能
支持用户在任务详情中解除误关联的任务,权限与修改任务一致(项目负责人、任务负责人、任务协助人)。

- 新增 ProjectTaskRelation::deleteRelation() 删除双向关联并推送 WebSocket
- 新增 API POST /api/project/task/related/delete 接口
- 前端关联任务列表 hover 显示删除按钮,点击确认后解除关联

Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-09 06:38:13 +00:00
kuaifan
4068966700 feat(auth): token/expire 接口支持 refresh 参数刷新 token
- token/expire 接口新增可选参数 refresh=1,当 token 剩余有效期不足总有效期
  的 1/3 时返回新 token
- 将 users/info 移动端的硬编码 7 天刷新阈值统一改为总有效期的 1/3

Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-04 14:49:41 +00:00
kuaifan
3ce8cf381a chore: 更新子项目提交哈希 2026-03-04 11:40:00 +00:00
kuaifan
f78d3f3aff feat(dialog): add send_ai_assistant endpoint for AI assistant identity messaging
New endpoint POST api/dialog/msg/send_ai_assistant sends messages
as the AI assistant identity (userid=-1). Supports both dialog_id
(direct) and task_id (with auto-creation) parameters.

Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-22 09:05:56 +00:00
kuaifan
c60dff0950 feat(api): add with_extend param to task/lists endpoint
Supports optional `with_extend` query parameter (comma-separated).
When `project_name` or `column_name` is included, the API returns
these fields inline with each task via eager loading.

Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-22 02:44:05 +00:00
kuaifan
f2d49ee104 feat(task): 支持根据项目所有者筛选任务 2026-02-22 01:45:14 +00:00
25 changed files with 1044 additions and 217 deletions

View File

@@ -1,55 +0,0 @@
# Graphiti 长期记忆集成
本项目使用 Graphiti 作为「长期记忆层」,用于持久化用户偏好、工作流程、重要约束和关键事实。
**统一 group_id**: `dootask-main`
## 任务开始前(读取记忆)
在进行实质性工作(写代码、设计方案、做大改动)前,应先通过 Graphiti 查询已有记忆:
- 使用节点搜索(如 `search_nodes`)在 `group_id = "dootask-main"` 下查找与当前任务相关的 Preference / Procedure / Requirement
- 使用事实搜索(如 `search_facts`)查找相关事实与实体关系
- 查询语句中可包含任务类型Bug 修复 / 重构 / 新功能等、涉及模块任务、项目、对话、WebSocket、报表等以及关键字 `dootask`
发现与当前任务高度相关的偏好 / 流程 / 约束时,应优先遵守;如存在冲突,应在回答中说明并做合理选择。
## 什么时候写入 Graphiti
| 类型 | 说明 | 示例 |
|------|------|------|
| **偏好 (Preferences)** | 用户表达持续性偏好时 | 语言、输出格式、技术选型 |
| **流程 (Procedures)** | 形成稳定的开发/发布/调试流程时 | 可复用步骤 |
| **约束 (Requirements)** | 项目长期有效的决策 | 不再支持某版本、架构约定 |
| **事实 (Facts)** | 模块边界、服务调用关系、外部集成方式 | AgoraIO、Manticore Search |
### 写入建议
- 默认使用 `source: "text"`,在 `episode_body` 中用简洁结构化自然语言描述背景、类型、范围、具体内容
- 需要结构化数据时可用 `source: "json"`,保证 `episode_body` 是合法 JSON 字符串
- 所有写入默认使用 `group_id: "dootask-main"`
## 更新与更正
- 偏好 / 流程发生变化时,新增一条 episode 说明新约定,并标明这是对旧习惯的更新
- 用户要求「忘记」某些记忆时,可通过删除或更正相关 episode / 关系的方式处理
- 尽量通过新增 episode 记录「更正 / 废弃说明」,而不是直接改写历史事实
## 使用原则
- **尊重已存偏好**:编码风格、回答结构、工具选择等应对齐已知偏好
- **遵循已有流程**:若图谱中已有与当前任务匹配的 Procedure应尽量按步骤执行
- **利用事实**:理解系统行为、模块边界、历史决策时优先查已存 Facts
- **代码优先**:如 Graphiti 与当前代码实际冲突,应以代码实际为准,并视情况新增 episode 更新事实
## 不要写入的内容
- 敏感信息(密钥、密码、隐私数据)
- 只与当前一次任务相关、未来不会复用的临时信息
- 体量巨大的原始数据(完整日志、长脚本全文),应只存摘要和关键结论
## 最佳实践
1. **先查再做**:在提出方案或改动架构前,优先查阅 Graphiti 中已有的设计、偏好和约束
2. **能复用就沉淀**:只要发现某个偏好 / 流程 / 约束未来会反复用到,就尽快写入 Graphiti
3. **保持一致**:确保 Graphiti 中的记忆与实际代码长期保持一致,避免「记忆漂移」

120
CLAUDE.md
View File

@@ -1,116 +1,40 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目概述
DooTask 是一套开源任务/项目管理系统,支持看板、任务、子任务、评论、对话、文件、报表等协作能力
- **后端**Laravel 8运行在 LaravelS/Swoole 常驻进程上
- **前端**Vue 2 + Vite
- **桌面端**Electron 壳,核心逻辑复用 Web 前端
Laravel 8 (LaravelS/Swoole) + Vue 2 (Vite) + Electron。开源任务/项目管理系统。
## 开发命令
所有命令通过 `./cmd` 脚本执行,确保与 Docker/容器环境一致
所有命令通过 `./cmd` 脚本执行(不要直接运行 `php artisan` 等)
```bash
# 服务管理
./cmd up # 启动容器
./cmd down # 停止容器
./cmd restart # 重启容器
./cmd reup # 重新构建并启动
- `./cmd dev` — 前端开发服务器Node.js 20+
- `./cmd prod` — 构建前端生产版本
- `./cmd artisan ...` / `./cmd composer ...` / `./cmd php ...` — PHP 相关命令
# 开发构建
./cmd dev # 启动前端开发服务器(需要 Node.js 20+
./cmd serve # dev 别名
./cmd prod # 构建前端生产版本
./cmd build # prod 别名
## Gotchas
# Laravel/PHP
./cmd artisan ... # 运行 Laravel Artisan 命令
./cmd composer ... # 运行 Composer 命令
./cmd php ... # 运行 PHP 命令
### LaravelS/Swoole
# Electron
./cmd electron # 构建桌面应用
# 配置管理
./cmd port <端口> # 修改服务端口
./cmd url <地址> # 修改访问地址
./cmd env <键> <值> # 设置环境变量
./cmd debug [true|false] # 切换调试模式
# 数据库
./cmd mysql backup # 备份数据库
./cmd mysql recovery # 恢复数据库
# 其他
./cmd install # 一键安装
./cmd update # 升级项目
./cmd repassword # 重置管理员密码
./cmd doc # 生成 API 文档
./cmd https # 配置 HTTPS
```
## 代码架构
### 后端 (`app/`)
**Controller (`app/Http/Controllers/Api/`)**API 控制器,负责路由入口、参数校验、编排调用模型/模块、组装响应。保持控制器「薄」,业务异常通过 `App\Exceptions\ApiException` 抛出。
**Model (`app/Models/`)**Eloquent 模型,负责表结构映射、关系、访问器/修改器、查询 Scope。避免在模型中堆积复杂业务逻辑。
**Module (`app/Module/`)**:跨控制器/跨模型的业务逻辑与独立功能子域:
- 外部集成:`AgoraIO/``Manticore/`
- 通用工具:`Lock.php``TextExtractor.php``Image.php``AI.php`
- 复杂业务逻辑:`Base.php`(核心业务)、`Doo.php``Timer.php`
**Tasks (`app/Tasks/`)**Swoole 异步任务,用于后台处理:
- WebSocket 消息推送:`WebSocketDialogMsgTask.php``PushTask.php`
- 定时任务:`LoopTask.php``AutoArchivedTask.php`
- 搜索同步:`ManticoreSyncTask.php`
**Observers (`app/Observers/`)**Eloquent 观察者监听模型事件created/updated/deleted自动触发相关逻辑。
**Services (`app/Services/`)**:服务类,如 `WebSocketService.php``RequestContext.php`
### 前端 (`resources/assets/js/`)
```
├── app.js, App.vue # 应用入口与根组件
├── components/ # 通用与业务组件(看板、文件预览、聊天)
├── pages/ # 页面级组件(登录、项目、任务、消息、报表)
├── store/ # Vuex 状态管理
│ ├── state.js # 状态定义
│ ├── mutations.js # 同步修改
│ ├── actions.js # 异步操作(含 API 调用封装)
│ └── getters.js # 计算属性
├── routes.js # 前端路由
├── functions/ # 业务函数
├── utils/ # 工具函数
├── directives/ # Vue 自定义指令
├── mixins/ # Vue 混入
└── language/ # 国际化翻译
```
API 调用应使用 `store/actions.js` 中已有的封装,避免在组件中散落 axios/fetch。
### LaravelS/Swoole 注意事项
- **避免在静态属性、单例、全局变量中存储请求级状态**——防止请求间数据串联和内存泄漏
- **避免在静态属性、单例、全局变量中存储请求级状态**——请求间共享进程,会导致数据串联和内存泄漏
- 构造函数、服务提供者、`boot()` 方法不会在每个请求重新执行
- 配置/路由变更需要 `./cmd php restart` 或容器重启才能生效
- 长生命周期逻辑WebSocket、定时器应复用现有模式避免阻塞协程/事件循环
## 数据库
### 后端
- **非 REST 路由**:所有 API 通过 `Route::any('api/{resource}/{method}')` 路由到 `InvokeController`URL 段映射为控制器方法(如 `api/project/lists``lists()`,带 action 则用双下划线:`api/project/invite/join``invite__join()`
- **响应格式**:统一使用 `Base::retSuccess($msg, $data)` / `Base::retError($msg)`,返回 `{"ret": 1, "msg": "...", "data": {...}}`——不要用 `response()->json()`
- 业务异常通过 `App\Exceptions\ApiException` 抛出,不要用通用 Exception
- 模型继承 `AbstractModel`,使用 `Model::createInstance($params)` 创建——不要用 `new Model()``Model::create()`
- 认证使用 `Doo::userId()`——不要用 `auth()->user()`
- 参数校验在控制器方法中手动进行——不要创建 FormRequest 类
- 异步任务使用 Swoole Task`app/Tasks/`)——不要用 Laravel Queue
- `app/Module/` 存放跨控制器/跨模型的业务逻辑(非标准 Laravel 目录)
- 所有表结构变更必须通过 Laravel migration禁止直接改库
- 使用 Eloquent 模型访问数据库
## 前端弹窗文案
### 前端
调用 `$A.modalXXX``$A.messageXXX``$A.noticeXXX` 时,内部会自动处理 `$L` 翻译,调用方不要额外包 `$L`。仅当显式传入 `language: false` 时,才由调用方自行处理翻译。
- API 调用使用 `store.dispatch("call", params)`,不要在组件中直接 axios/fetch
- `$A.modalXXX``$A.messageXXX``$A.noticeXXX` 内部自动处理 `$L` 翻译,调用方不要额外包 `$L`。仅当传入 `language: false` 时由调用方自行处理翻译
## 交互规范
@@ -119,7 +43,3 @@ API 调用应使用 `store/actions.js` 中已有的封装,避免在组件中
## 语言偏好
- 技术总结和关键结论优先使用简体中文,除非用户明确要求其他语言
## 扩展规则
详见 @.claude/rules/graphiti.md 了解 Graphiti 长期记忆集成。

View File

@@ -21,6 +21,9 @@ use App\Module\TimeRange;
use App\Module\MsgTool;
use App\Models\FileContent;
use App\Models\ProjectTask;
use App\Models\ProjectTaskUser;
use App\Models\ProjectTaskVisibilityUser;
use App\Models\ProjectUser;
use App\Models\AbstractModel;
use App\Models\WebSocketDialog;
use App\Models\WebSocketDialogMsg;
@@ -1696,6 +1699,106 @@ class DialogController extends AbstractController
return WebSocketDialogMsg::sendMsg(null, $dialog->id, 'text', $msgData, $botUser->userid, false, false, $silence);
}
/**
* @api {post} api/dialog/msg/send_ai_assistant 以AI助手身份发送消息到对话
*
* @apiDescription 需要token身份以AI助手身份(userid=-1)发送消息到对话。支持两种方式:
* 1. 通过 dialog_id 直接发送到指定对话
* 2. 通过 task_id 发送到任务对话(自动创建对话如不存在)
* 两个参数至少提供一个,同时提供时优先使用 dialog_id
* @apiVersion 1.0.0
* @apiGroup dialog
* @apiName msg__send_ai_assistant
*
* @apiParam {Number} [dialog_id] 对话ID与task_id二选一
* @apiParam {Number} [task_id] 任务ID与dialog_id二选一自动创建对话
* @apiParam {String} text 消息内容
* @apiParam {String} [text_type=md] 消息格式md 或 html
* @apiParam {String} [silence=no] 是否静默发送yes/no
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function msg__send_ai_assistant()
{
$user = User::auth();
//
$dialog_id = intval(Request::input('dialog_id'));
$task_id = intval(Request::input('task_id'));
$text = trim(Request::input('text'));
$text_type = strtolower(trim(Request::input('text_type'))) ?: 'md';
$silence = in_array(strtolower(trim(Request::input('silence'))), ['yes', 'true', '1']);
$markdown = in_array($text_type, ['md', 'markdown']);
//
if (empty($dialog_id) && empty($task_id)) {
return Base::retError('dialog_id 或 task_id 至少提供一个');
}
if (empty($text)) {
return Base::retError('消息内容不能为空');
}
if (mb_strlen($text) > 200000) {
return Base::retError('消息内容最大不能超过200000字');
}
//
if ($dialog_id) {
// Direct dialog mode: verify user is a member
WebSocketDialog::checkDialog($dialog_id);
} else {
// Task mode: resolve task -> dialog_id (auto-create if needed)
$task = ProjectTask::find($task_id);
if (!$task) {
return Base::retError('任务不存在');
}
if (!ProjectUser::whereProjectId($task->project_id)->whereUserid($user->userid)->exists()) {
return Base::retError('没有权限操作此任务');
}
// 任务可见性校验(与 task__one 一致)
if ($task->visibility != 1) {
$project_userid = ProjectUser::whereProjectId($task->project_id)->whereOwner(1)->value('userid');
if ($user->userid != $project_userid) {
$visibleUserids = array_merge(
ProjectTaskUser::whereTaskId($task_id)->pluck('userid')->toArray(),
ProjectTaskUser::whereTaskPid($task_id)->pluck('userid')->toArray(),
ProjectTaskVisibilityUser::whereTaskId($task_id)->pluck('userid')->toArray()
);
if (!in_array($user->userid, $visibleUserids)) {
return Base::retError('没有权限操作此任务');
}
}
}
if (!$task->dialog_id) {
$dialog = WebSocketDialog::createGroup($task->name, $task->relationUserids(), 'task');
if ($dialog) {
$task->dialog_id = $dialog->id;
$task->save();
$task->pushMsg('dialog');
} else {
return Base::retError('无法创建任务对话');
}
}
$dialog_id = $task->dialog_id;
}
//
$msgData = ['text' => $text];
if ($markdown) {
$msgData['type'] = 'md';
}
//
$result = WebSocketDialogMsg::sendMsg(
null,
$dialog_id,
'text',
$msgData,
\App\Module\AiTaskSuggestion::AI_ASSISTANT_USERID,
true, // push_self
false, // push_retry
$silence
);
//
return $result;
}
/**
* @api {post} api/dialog/msg/sendlocation 发送位置消息
*
@@ -2208,6 +2311,7 @@ class DialogController extends AbstractController
{
$user = User::auth();
//
$msg_ids = Request::input('msg_ids');
$msg_id = intval(Request::input("msg_id"));
$dialogids = Request::input('dialogids');
$userids = Request::input('userids');
@@ -2218,6 +2322,30 @@ class DialogController extends AbstractController
return Base::retError("请选择对话或成员");
}
//
// 支持批量逐条转发
if (!empty($msg_ids) && is_array($msg_ids)) {
if (count($msg_ids) > 100) {
return Base::retError("最多转发100条消息");
}
$allMsgs = [];
$msgs = WebSocketDialogMsg::whereIn('id', $msg_ids)->orderBy('created_at')->get();
if ($msgs->isEmpty()) {
return Base::retError("消息不存在或已被删除");
}
WebSocketDialog::checkDialog($msgs->first()->dialog_id);
foreach ($msgs as $msg) {
$res = $msg->forwardMsg($dialogids, $userids, $user, $show_source, $leave_message);
if (Base::isSuccess($res)) {
$allMsgs = array_merge($allMsgs, $res['data']['msgs']);
}
// 留言只在第一条时发送,后续不再重复
$leave_message = '';
}
return Base::retSuccess('转发成功', [
'msgs' => $allMsgs
]);
}
//
$msg = WebSocketDialogMsg::whereId($msg_id)->first();
if (empty($msg)) {
return Base::retError("消息不存在或已被删除");
@@ -2227,6 +2355,47 @@ class DialogController extends AbstractController
return $msg->forwardMsg($dialogids, $userids, $user, $show_source, $leave_message);
}
/**
* @api {get} api/dialog/msg/merge-forward 合并转发消息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup dialog
* @apiName msg__merge_forward
*
* @apiParam {Array} msg_ids 消息ID数组最多100条
* @apiParam {Array} dialogids 转发给的对话ID
* @apiParam {Array} userids 转发给的成员ID
* @apiParam {Number} show_source 是否显示原发送者信息
* @apiParam {String} leave_message 转发留言
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function msg__merge_forward()
{
$user = User::auth();
//
$msg_ids = Request::input('msg_ids');
$dialogids = Request::input('dialogids');
$userids = Request::input('userids');
$show_source = intval(Request::input("show_source"));
$leave_message = Request::input('leave_message');
//
if (empty($dialogids) && empty($userids)) {
return Base::retError("请选择对话或成员");
}
if (empty($msg_ids) || !is_array($msg_ids)) {
return Base::retError("请选择要转发的消息");
}
if (count($msg_ids) > 100) {
return Base::retError("最多转发100条消息");
}
//
return WebSocketDialogMsg::mergeForwardMsg($msg_ids, $dialogids, $userids, $user, $show_source, $leave_message);
}
/**
* @api {get} api/dialog/msg/emoji emoji回复
*

View File

@@ -301,6 +301,7 @@ class ProjectController extends AbstractController
* @apiParam {String} [desc] 项目介绍
* @apiParam {String} [archive_method] 归档方式
* @apiParam {Number} [archive_days] 自动归档天数
* @apiParam {String} [ai_auto_analyze] AI自动分析open|close
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
@@ -315,6 +316,7 @@ class ProjectController extends AbstractController
$desc = trim(Request::input('desc', ''));
$archive_method = Request::input('archive_method');
$archive_days = intval(Request::input('archive_days'));
$ai_auto_analyze = Request::input('ai_auto_analyze');
if (mb_strlen($name) < 2) {
return Base::retError('项目名称不可以少于2个字');
} elseif (mb_strlen($name) > 32) {
@@ -330,7 +332,7 @@ class ProjectController extends AbstractController
}
//
$project = Project::userProject($project_id, true, true);
AbstractModel::transaction(function () use ($archive_days, $archive_method, $desc, $name, $project) {
AbstractModel::transaction(function () use ($archive_days, $archive_method, $ai_auto_analyze, $desc, $name, $project) {
if ($project->name != $name) {
$project->addLog("修改项目名称", [
'change' => [$project->name, $name]
@@ -356,6 +358,12 @@ class ProjectController extends AbstractController
]);
$project->archive_days = $archive_days;
}
if (in_array($ai_auto_analyze, ['open', 'close']) && $project->ai_auto_analyze != $ai_auto_analyze) {
$project->addLog("修改AI自动分析", [
'change' => [$project->ai_auto_analyze, $ai_auto_analyze]
]);
$project->ai_auto_analyze = $ai_auto_analyze;
}
$project->save();
});
$project->pushMsg('update');
@@ -999,6 +1007,10 @@ class ProjectController extends AbstractController
* - 等于-1仅主任务可与 project_id 组合)
* @apiParam {String} [scope] 查询范围(仅在未指定 project_id 且 parent_id ≤ 0 时生效)
* - all_project查询“我参与的项目”下的所有任务仍受可见性限制
* @apiParam {Number} [owner] 任务身份筛选(按当前登录用户在任务中的身份)
* - 1我负责的任务
* - 0我协助的任务
* - 不传:不过滤(默认)
*
* @apiParam {String} [time] 指定时间范围today, week, month, year, 2020-12-12,2020-12-30
* - today: 今天
@@ -1043,10 +1055,20 @@ class ProjectController extends AbstractController
$keys = Request::input('keys');
$sorts = Request::input('sorts');
$scope = Request::input('scope');
$owner = Request::input('owner');
$owner = is_numeric($owner) ? intval($owner) : null;
$keys = is_array($keys) ? $keys : [];
$sorts = is_array($sorts) ? $sorts : [];
$with_extend = array_filter(explode(',', Request::input('with_extend', '')));
$builder = ProjectTask::with(['taskUser', 'taskTag']);
$withs = ['taskUser', 'taskTag'];
if (in_array('project_name', $with_extend)) {
$withs[] = 'project:id,name';
}
if (in_array('column_name', $with_extend)) {
$withs[] = 'projectColumn:id,name';
}
$builder = ProjectTask::with($withs);
//
if ($keys['name']) {
if (Base::isNumber($keys['name'])) {
@@ -1108,8 +1130,11 @@ class ProjectController extends AbstractController
}
if ($scopeAll) {
$builder->allData();
if ($owner !== null) {
$builder->where('project_task_users.owner', $owner);
}
} else {
$builder->authData();
$builder->authData(null, $owner);
}
//
if ($name) {
@@ -1238,6 +1263,14 @@ class ProjectController extends AbstractController
unset($item['_sub_num']);
unset($item['_sub_complete']);
unset($item['_percent']);
if (in_array('project_name', $with_extend)) {
$item['project_name'] = $item['project']['name'] ?? '';
unset($item['project']);
}
if (in_array('column_name', $with_extend)) {
$item['column_name'] = $item['project_column']['name'] ?? '';
unset($item['project_column']);
}
}
//
if ($list->currentPage() === 1) {
@@ -1962,6 +1995,44 @@ class ProjectController extends AbstractController
]);
}
/**
* @api {post} api/project/task/related/delete 删除任务关联
*
* @apiDescription 需要token身份项目、任务负责人
* @apiVersion 1.0.0
* @apiGroup project
* @apiName task__related__delete
*
* @apiParam {Number} task_id 任务ID
* @apiParam {Number} related_task_id 关联任务ID
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function task__related__delete()
{
User::auth();
//
$task_id = intval(Request::input('task_id'));
$related_task_id = intval(Request::input('related_task_id'));
if ($task_id <= 0 || $related_task_id <= 0) {
return Base::retError('参数错误');
}
//
$task = ProjectTask::userTask($task_id);
//
$project = Project::userProject($task->project_id);
ProjectPermission::userTaskPermission($project, ProjectPermission::TASK_UPDATE, $task);
//
$success = ProjectTaskRelation::deleteRelation($task_id, $related_task_id);
if (!$success) {
return Base::retError('关联不存在');
}
//
return Base::retSuccess('操作成功');
}
/**
* @api {get} api/project/task/content 获取任务详细描述
*

View File

@@ -93,6 +93,7 @@ class SystemController extends AbstractController
'file_upload_limit',
'unclaimed_task_reminder',
'unclaimed_task_reminder_time',
'task_ai_auto_analyze',
])) {
unset($all[$key]);
}
@@ -146,6 +147,7 @@ class SystemController extends AbstractController
$setting['file_upload_limit'] = $setting['file_upload_limit'] ?: '';
$setting['unclaimed_task_reminder'] = $setting['unclaimed_task_reminder'] ?: 'close';
$setting['unclaimed_task_reminder_time'] = $setting['unclaimed_task_reminder_time'] ?: '';
$setting['task_ai_auto_analyze'] = $setting['task_ai_auto_analyze'] ?: 'open';
$setting['server_timezone'] = config('app.timezone');
$setting['server_version'] = Base::getVersion();
//

View File

@@ -300,6 +300,8 @@ class UsersController extends AbstractController
* @apiGroup users
* @apiName token__expire
*
* @apiParam {Number} [refresh] 是否刷新 token1=是token 剩余有效期不足总有效期的 1/3 时才会刷新
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
@@ -307,10 +309,11 @@ class UsersController extends AbstractController
* @apiSuccess {Number|null} data.remaining_seconds 距离过期剩余秒数(负值表示已过期)
* @apiSuccess {Boolean} data.expired token 是否已过期
* @apiSuccess {String} data.server_time 当前服务器时间
* @apiSuccess {String} [data.token] 刷新后的新 token仅当 refresh=1 且 token 即将过期时返回)
*/
public function token__expire()
{
User::auth();
$user = User::auth();
$expiredAt = Doo::userExpiredAt();
$expired = Doo::userExpired();
$expiredAtCarbon = $expiredAt ? Carbon::parse($expiredAt) : null;
@@ -320,6 +323,14 @@ class UsersController extends AbstractController
'expired' => $expired,
'server_time' => Carbon::now()->toDateTimeString(),
];
// 请求刷新 token剩余有效期不足总有效期的 1/3 时才刷新
if (Request::input('refresh') && $expiredAtCarbon) {
$tokenValidDays = max(1, intval(Base::settingFind('system', 'token_valid_days', 30)));
$refreshThresholdDays = ceil($tokenValidDays / 3);
if ($expiredAtCarbon->isBefore(Carbon::now()->addDays($refreshThresholdDays))) {
$data['token'] = User::generateToken($user, true);
}
}
return Base::retSuccess('success', $data);
}
@@ -377,10 +388,14 @@ class UsersController extends AbstractController
//
$refreshToken = false;
if (in_array(Base::platform(), ['ios', 'android'])) {
// 移动端token还剩7天到期时获取新的token
// 移动端token剩余有效期不足总有效期的1/3时获取新的token
$expiredAt = Doo::userExpiredAt();
if ($expiredAt && Carbon::parse($expiredAt)->isBefore(Carbon::now()->addDays(7))) {
$refreshToken = true;
if ($expiredAt) {
$tokenValidDays = max(1, intval(Base::settingFind('system', 'token_valid_days', 30)));
$refreshThresholdDays = ceil($tokenValidDays / 3);
if (Carbon::parse($expiredAt)->isBefore(Carbon::now()->addDays($refreshThresholdDays))) {
$refreshToken = true;
}
}
}
User::generateToken($user, $refreshToken);

View File

@@ -25,6 +25,14 @@ class WebApi
RequestContext::set('start_time', microtime(true));
RequestContext::set('header_language', $request->header('language'));
// 强制 https
$APP_SCHEME = env('APP_SCHEME', 'auto');
if (in_array(strtolower($APP_SCHEME), ['https', 'on', 'ssl', '1', 'true', 'yes'], true)) {
$request->server->set('HTTPS', 'on');
$request->headers->set('X-Forwarded-Proto', 'https');
$request->setTrustedProxies([$request->getClientIp()], $request::HEADER_X_FORWARDED_PROTO);
}
// 更新请求的基本URL
RequestContext::updateBaseUrl($request);
@@ -56,12 +64,6 @@ class WebApi
}
}
// 强制 https
$APP_SCHEME = env('APP_SCHEME', 'auto');
if (in_array(strtolower($APP_SCHEME), ['https', 'on', 'ssl', '1', 'true', 'yes'], true)) {
$request->setTrustedProxies([$request->getClientIp()], $request::HEADER_X_FORWARDED_PROTO);
}
// 执行下一个中间件
$response = $next($request);

View File

@@ -143,6 +143,41 @@ class ProjectTaskRelation extends AbstractModel
return true;
}
/**
* 删除双向任务关联
*
* @param int $taskId 任务ID
* @param int $relatedTaskId 关联任务ID
* @return bool 是否删除成功
*/
public static function deleteRelation(int $taskId, int $relatedTaskId): bool
{
// 删除正向关联
$deleted1 = static::whereTaskId($taskId)
->whereRelatedTaskId($relatedTaskId)
->delete();
// 删除反向关联
$deleted2 = static::whereTaskId($relatedTaskId)
->whereRelatedTaskId($taskId)
->delete();
if ($deleted1 || $deleted2) {
// 推送关联更新
$sourceTask = ProjectTask::with('project')->find($taskId);
$targetTask = ProjectTask::with('project')->find($relatedTaskId);
if ($sourceTask?->project) {
$sourceTask->pushMsg('relation', null, null, false);
}
if ($targetTask?->project) {
$targetTask->pushMsg('relation', null, null, false);
}
return true;
}
return false;
}
public static function recordMentionsFromMessage(WebSocketDialogMsg $msg): void
{
if ($msg->type !== 'text') {

View File

@@ -492,6 +492,47 @@ class WebSocketDialogMsg extends AbstractModel
* @param string $leaveMessage 转发留言
* @return mixed
*/
/**
* 收集目标对话
* @param array|int $userids 转发给的成员ID
* @param array|int $dialogids 转发给的对话ID
* @param User $user 当前用户
* @return array
*/
private static function collectTargetDialogs($userids, $dialogids, $user)
{
$dialogs = [];
if ($userids) {
if (!is_array($userids)) {
$userids = [$userids];
}
foreach ($userids as $userid) {
if (!User::whereUserid($userid)->exists()) {
continue;
}
$dialog = WebSocketDialog::checkUserDialog($user, $userid);
if ($dialog) {
$dialogs[$dialog->id] = $dialog;
}
}
}
if ($dialogids) {
if (!is_array($dialogids)) {
$dialogids = [$dialogids];
}
foreach ($dialogids as $dialogid) {
if (isset($dialogs[$dialogid])) {
continue;
}
$dialog = WebSocketDialog::find($dialogid);
if ($dialog) {
$dialogs[$dialog->id] = $dialog;
}
}
}
return $dialogs;
}
public function forwardMsg($dialogids, $userids, $user, $showSource = 1, $leaveMessage = '')
{
return AbstractModel::transaction(function () use ($dialogids, $user, $userids, $showSource, $leaveMessage) {
@@ -513,35 +554,7 @@ class WebSocketDialogMsg extends AbstractModel
'leave' => $leaveMessage ? 1 : 0, // 是否留言用于判断是否发给AI
];
$msgs = [];
$dialogs = [];
if ($userids) {
if (!is_array($userids)) {
$userids = [$userids];
}
foreach ($userids as $userid) {
if (!User::whereUserid($userid)->exists()) {
continue;
}
$dialog = WebSocketDialog::checkUserDialog($user, $userid);
if ($dialog) {
$dialogs[$dialog->id] = $dialog;
}
}
}
if ($dialogids) {
if (!is_array($dialogids)) {
$dialogids = [$dialogids];
}
foreach ($dialogids as $dialogid) {
if (isset($dialogs[$dialogid])) {
continue;
}
$dialog = WebSocketDialog::find($dialogid);
if ($dialog) {
$dialogs[$dialog->id] = $dialog;
}
}
}
$dialogs = self::collectTargetDialogs($userids, $dialogids, $user);
foreach ($dialogs as $dialog) {
$res = self::sendMsg('forward-' . $forwardId, $dialog->id, $this->type, $msgData, $user->userid);
if (Base::isSuccess($res)) {
@@ -564,6 +577,81 @@ class WebSocketDialogMsg extends AbstractModel
});
}
/**
* 合并转发消息
* @param array $msgIds 消息ID数组
* @param array|int $dialogids 转发给的对话ID
* @param array|int $userids 转发给的成员ID
* @param User $user 当前用户
* @param int $showSource 是否显示原发送者信息
* @param string $leaveMessage 转发留言
* @return array
*/
public static function mergeForwardMsg($msgIds, $dialogids, $userids, $user, $showSource = 1, $leaveMessage = '')
{
return AbstractModel::transaction(function () use ($msgIds, $dialogids, $userids, $user, $showSource, $leaveMessage) {
// 查询并验证所有消息
$msgs = self::whereIn('id', $msgIds)->orderBy('created_at')->get();
if ($msgs->isEmpty()) {
throw new ApiException('消息不存在或已被删除');
}
// 验证所有消息属于同一对话
$dialogId = $msgs->first()->dialog_id;
if ($msgs->pluck('dialog_id')->unique()->count() > 1) {
throw new ApiException('只能合并转发同一对话的消息');
}
WebSocketDialog::checkDialog($dialogId);
// 收集发送者生成标题
$senderIds = $msgs->pluck('userid')->unique()->values()->toArray();
$senderNames = User::whereIn('userid', array_slice($senderIds, 0, 2))
->pluck('nickname')
->toArray();
$title = implode(Doo::translate('和'), $senderNames);
if (count($senderIds) > 2) {
$title .= Doo::translate('等人');
}
$title .= Doo::translate('的聊天记录');
// 组装消息列表
$list = [];
foreach ($msgs as $msg) {
$list[] = [
'userid' => $msg->userid,
'type' => $msg->type,
'msg' => Base::json2array($msg->getRawOriginal('msg')),
'created_at' => $msg->created_at->toDateTimeString(),
];
}
// 构建合并转发消息体
$msgData = [
'title' => $title,
'list' => $list,
'count' => count($list),
'forward_data' => [
'show' => $showSource,
'leave' => $leaveMessage ? 1 : 0,
],
];
$dialogs = self::collectTargetDialogs($userids, $dialogids, $user);
// 发送到每个目标对话
$result = [];
foreach ($dialogs as $dialog) {
$res = self::sendMsg(null, $dialog->id, 'merge-forward', $msgData, $user->userid);
if (Base::isSuccess($res)) {
$result[] = $res['data'];
}
if ($leaveMessage) {
$res = self::sendMsg(null, $dialog->id, 'text', ['text' => $leaveMessage], $user->userid);
if (Base::isSuccess($res)) {
$result[] = $res['data'];
}
}
}
return Base::retSuccess('转发成功', [
'msgs' => $result
]);
});
}
/**
* 删除消息
* @param array|int $ids
@@ -695,6 +783,10 @@ class WebSocketDialogMsg extends AbstractModel
case 'template':
return self::previewTemplateMsg($data['msg']);
case 'merge-forward':
$action = Doo::translate("聊天记录");
return "[{$action}] " . Base::cutStr($data['msg']['title'] ?? '', 50);
case 'preview':
return $data['msg']['preview'];
@@ -1262,6 +1354,9 @@ class WebSocketDialogMsg extends AbstractModel
$msg['height'] = $imageSize[1];
}
}
if ($type === 'merge-forward') {
$mtype = 'merge-forward';
}
if ($push_silence === null) {
$push_silence = !in_array($type, ["text", "file", "record", "meeting"]);
}

View File

@@ -37,10 +37,20 @@ class AiTaskLoopTask extends AbstractTask
return;
}
// 检查系统级 AI 自动分析开关
if (Base::settingFind('system', 'task_ai_auto_analyze', 'open') === 'close') {
return;
}
// 查询待处理的任务
$tasks = $this->findPendingTasks();
foreach ($tasks as $task) {
// 检查项目级 AI 自动分析开关
if ($task->project && $task->project->ai_auto_analyze === 'close') {
continue;
}
// 为任务创建事件记录
$this->createEventRecords($task);
@@ -62,7 +72,8 @@ class AiTaskLoopTask extends AbstractTask
->pluck('task_id');
// 查询新建任务(未处理过的)
$newTasks = ProjectTask::where('parent_id', 0) // 只处理主任务
$newTasks = ProjectTask::with('project')
->where('parent_id', 0) // 只处理主任务
->whereNull('deleted_at')
->whereNull('archived_at')
->where('created_at', '<=', $delayTime) // 创建超过延迟时间
@@ -81,7 +92,8 @@ class AiTaskLoopTask extends AbstractTask
->take(self::BATCH_SIZE - $newTasks->count())
->pluck('task_id');
$retryTasks = ProjectTask::whereIn('id', $retryTaskIds)
$retryTasks = ProjectTask::with('project')
->whereIn('id', $retryTaskIds)
->whereNull('deleted_at')
->get();

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddAiAutoAnalyzeToProjectsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('projects', function (Blueprint $table) {
$table->string('ai_auto_analyze', 20)->default('open')->after('archive_days');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('projects', function (Blueprint $table) {
$table->dropColumn('ai_auto_analyze');
});
}
}

View File

@@ -461,6 +461,8 @@ import {convertLocalResourcePath} from "../components/Replace/utils";
case 'notice':
const notice = data.msg.source === 'api' ? data.msg.notice : $A.L(data.msg.notice);
return $A.cutString(notice, 50)
case 'merge-forward':
return `[${$A.L('聊天记录')}] ${$A.cutString(data.msg.title || '', 50)}`
case 'template':
return $A.templateMsgSimpleDesc(data.msg)
case 'preview':

View File

@@ -40,6 +40,9 @@
{{source.msg.source === 'api' ? source.msg.notice : $L(source.msg.notice)}}
</div>
<template v-else>
<div v-if="multiSelectMode && isSelectableMsg" class="dialog-multi-check" @click.stop="onMultiSelectToggle">
<Icon :type="isSelected ? 'ios-checkmark-circle' : 'ios-radio-button-off'" :class="{checked: isSelected}"/>
</div>
<div
class="dialog-avatar"
@pointerdown="handleOperation">
@@ -132,6 +135,14 @@ export default {
type: Boolean,
default: false
},
multiSelectMode: {
type: Boolean,
default: false
},
selectedMsgIdsSet: {
type: Set,
default: () => new Set()
},
},
computed: {
@@ -165,12 +176,22 @@ export default {
return this.simpleView || this.msgId > 0
},
isSelected() {
return this.multiSelectMode && this.selectedMsgIdsSet.has(this.source.id);
},
isSelectableMsg() {
return !['tag', 'top', 'todo', 'notice'].includes(this.source.type);
},
classArray() {
return {
'dialog-item': true,
'reply-item': this.isReply,
'unread-start': this.isUnreadStart,
'self': this.isRightMsg,
'multi-select-mode': this.multiSelectMode,
'multi-selected': this.isSelected,
}
},
},
@@ -262,6 +283,10 @@ export default {
})
},
onMultiSelectToggle() {
this.dispatch("on-multi-select-toggle", this.source.id)
},
onViewReply(data) {
this.dispatch("on-view-reply", data)
},

View File

@@ -43,6 +43,8 @@
<WordChainMsg v-else-if="msgData.type === 'word-chain'" :msg="msgData.msg" :msgId="msgData.id" :unfoldWordChainData="unfoldWordChainData" @unfoldWordChain="unfoldWordChain(msgData)" @onWordChain="onWordChain"/>
<!--投票-->
<VoteMsg v-else-if="msgData.type === 'vote'" :msg="msgData.msg" :voteData="voteData" @onVote="onVote($event, msgData)"/>
<!--合并转发-->
<MergeForwardMsg v-else-if="msgData.type === 'merge-forward'" :msg="msgData.msg"/>
<!--模板-->
<TemplateMsg v-else-if="msgData.type === 'template'" :msg="msgData.msg" @viewText="viewText"/>
<!--等待-->
@@ -190,6 +192,7 @@ import MeetingMsg from "./meet.vue";
import WordChainMsg from "./word-chain.vue";
import VoteMsg from "./vote.vue";
import TemplateMsg from "./template";
import MergeForwardMsg from "./merge-forward.vue";
import LoadMsg from "./load.vue";
import UnknownMsg from "./unknown.vue";
import emitter from "../../../../store/events";
@@ -208,6 +211,7 @@ export default {
components: {
UnknownMsg,
LoadMsg,
MergeForwardMsg,
TemplateMsg,
VoteMsg,
WordChainMsg,

View File

@@ -0,0 +1,78 @@
<template>
<div class="content-merge-forward" @click="openDetail">
<div class="merge-title">{{ msg.title }}</div>
<div class="merge-list">
<div v-for="(item, index) in displayList" :key="index" class="merge-item">
<UserAvatar :userid="item.userid" :show-icon="false" :show-name="true" :size="14"/>
<span class="item-colon">:</span>
<span class="item-desc" v-html="$A.getMsgSimpleDesc(item)"></span>
</div>
</div>
<div class="merge-footer">{{ $L('共') }} {{ msg.count || msg.list.length }} {{ $L('条消息') }}</div>
<!-- 详情弹窗 -->
<Modal
v-model="detailShow"
:title="msg.title"
class-name="merge-forward-detail-modal"
:mask-closable="true"
width="500"
@click.native.stop>
<Scrollbar class-name="merge-detail-scroller" :style="{maxHeight: '60vh'}">
<div class="merge-detail-list">
<div v-for="(item, index) in msg.list" :key="index" class="merge-detail-item">
<UserAvatar :userid="item.userid" :size="28" :show-name="false"/>
<div class="detail-content">
<div class="detail-header">
<UserAvatar :userid="item.userid" :show-icon="false" :show-name="true" :size="0"/>
<span class="detail-time">{{ item.created_at }}</span>
</div>
<div class="detail-body">
<template v-if="item.type === 'text'">
<pre v-html="$A.formatTextMsg(item.msg.text)"></pre>
</template>
<template v-else-if="item.type === 'file'">
<span>[{{ $L('文件') }}] {{ item.msg.name }}</span>
</template>
<template v-else-if="item.type === 'merge-forward'">
<span>[{{ $L('聊天记录') }}] {{ item.msg.title }}</span>
</template>
<template v-else>
<span v-html="$A.getMsgSimpleDesc(item)"></span>
</template>
</div>
</div>
</div>
</div>
</Scrollbar>
</Modal>
</div>
</template>
<script>
export default {
name: "MergeForwardMsg",
props: {
msg: {
type: Object,
default: () => ({})
}
},
data() {
return {
detailShow: false
}
},
computed: {
displayList() {
if (!this.msg || !this.msg.list) return [];
return this.msg.list.slice(0, 4);
}
},
methods: {
openDetail() {
this.detailShow = true;
}
}
}
</script>

View File

@@ -201,7 +201,7 @@
:data-sources="allMsgs"
:data-component="msgItem"
:extra-props="{dialogData, operateVisible, operateItem, pointerMouse, isMyDialog, msgId, unreadOne, scrollIng, readEnabled}"
:extra-props="{dialogData, operateVisible, operateItem, pointerMouse, isMyDialog, msgId, unreadOne, scrollIng, readEnabled, multiSelectMode, selectedMsgIdsSet}"
:estimate-size="dialogData.type=='group' ? 105 : 77"
:keeps="dialogMsgKeep"
:disabled="scrollDisabled"
@@ -219,7 +219,8 @@
@on-error="onError"
@on-emoji="onEmoji"
@on-other="onOther"
@on-show-emoji-user="onShowEmojiUser">
@on-show-emoji-user="onShowEmojiUser"
@on-multi-select-toggle="onMultiSelectToggle">
<template #header v-if="!isChildComponent">
<div class="dialog-item head-box">
<div v-if="loadIng > 0 || prevId > 0" class="loading" :class="{filled: allMsgs.length === 0}">
@@ -231,8 +232,20 @@
</VirtualList>
</div>
<!--多选操作栏-->
<div v-if="multiSelectMode" class="dialog-multi-select-bar">
<div class="multi-select-info">
<span>{{ $L('已选') }} {{ selectedMsgIds.length }} {{ $L('条') }}</span>
<span v-if="selectedMsgIds.length >= 100" class="multi-select-max">{{ $L('(最多100条)') }}</span>
</div>
<div class="multi-select-actions">
<Button type="primary" size="small" :disabled="selectedMsgIds.length === 0" @click="onMultiForward">{{ $L('转发') }}</Button>
<Button size="small" @click="onMultiSelectCancel">{{ $L('取消') }}</Button>
</div>
</div>
<!--底部输入-->
<div ref="footer" class="dialog-footer" @click="onClickFooter">
<div v-show="!multiSelectMode" ref="footer" class="dialog-footer" @click="onClickFooter">
<!--滚动到底部-->
<div
v-if="scrollTail > 500 || (msgNew > 0 && allMsgs.length > 0)"
@@ -355,6 +368,10 @@
<i class="taskfont">&#xe638;</i>
<span>{{ $L('转发') }}</span>
</li>
<li v-if="actionPermission(operateItem, 'forward')" @click="onOperate('multiSelect')">
<i class="taskfont">&#xe7b7;</i>
<span>{{ $L('多选') }}</span>
</li>
<li v-if="operateItem.userid == userId" @click="onOperate('withdraw')">
<i class="taskfont">&#xe637;</i>
<span>{{ $L('撤回') }}</span>
@@ -503,7 +520,9 @@
:title="$L('转发')"
:confirm-title="$L('确认转发')"
:multiple-max="50"
:msg-detail="operateItem"
:msg-detail="multiSelectMode ? null : operateItem"
:msg-ids="multiSelectMode ? selectedMsgIds : []"
:msg-list="multiSelectMsgList"
:before-submit="onForward"/>
<!-- 设置待办 -->
@@ -801,6 +820,9 @@ export default {
operateStyles: {},
operateItem: {},
multiSelectMode: false,
selectedMsgIds: [],
recordState: '',
pointerMouse: false,
@@ -946,6 +968,15 @@ export default {
return this.dialogData.group_type === 'user'
},
selectedMsgIdsSet() {
return new Set(this.selectedMsgIds);
},
multiSelectMsgList() {
if (!this.multiSelectMode || this.selectedMsgIds.length === 0) return [];
return this.allMsgs.filter(m => this.selectedMsgIdsSet.has(m.id));
},
dialogList() {
return this.cacheDialogs.filter(dialog => {
return !(dialog.name === undefined || dialog.dialog_delete === 1);
@@ -1254,6 +1285,7 @@ export default {
window.localStorage.removeItem('__cache:vote__')
window.localStorage.removeItem('__cache:unfoldWordChain__')
//
this.onMultiSelectCancel()
this.handlerMsgTransfer()
},
immediate: true
@@ -2993,20 +3025,27 @@ export default {
},
onForward(forwardData) {
const isMulti = forwardData.msg_ids && forwardData.msg_ids.length > 0;
const url = isMulti
? (forwardData.forward_mode === 'merge' ? 'dialog/msg/merge-forward' : 'dialog/msg/forward')
: 'dialog/msg/forward';
const data = {
dialogids: forwardData.dialogids,
userids: forwardData.userids,
show_source: forwardData.sender ? 1 : 0,
leave_message: forwardData.message
};
if (isMulti) {
data.msg_ids = forwardData.msg_ids;
} else {
data.msg_id = forwardData.msg_id;
}
return new Promise((resolve, reject) => {
this.$store.dispatch("call", {
url: 'dialog/msg/forward',
data: {
dialogids: forwardData.dialogids,
userids: forwardData.userids,
msg_id: forwardData.msg_id,
show_source: forwardData.sender ? 1 : 0,
leave_message: forwardData.message
}
}).then(({data, msg}) => {
this.$store.dispatch("call", {url, data}).then(({data, msg}) => {
this.$store.dispatch("saveDialogMsg", data.msgs);
this.$store.dispatch("updateDialogLastMsg", data.msgs);
$A.messageSuccess(msg);
if (isMulti) this.onMultiSelectCancel();
resolve()
}).catch(({msg}) => {
$A.modalError(msg);
@@ -3015,6 +3054,27 @@ export default {
});
},
onMultiSelectToggle(msgId) {
const index = this.selectedMsgIds.indexOf(msgId);
if (index > -1) {
this.selectedMsgIds.splice(index, 1);
} else if (this.selectedMsgIds.length < 100) {
this.selectedMsgIds.push(msgId);
} else {
$A.messageWarning(this.$L('最多选择100条消息'));
}
},
onMultiForward() {
if (this.selectedMsgIds.length === 0) return;
this.$refs.forwarder.onSelection();
},
onMultiSelectCancel() {
this.multiSelectMode = false;
this.selectedMsgIds = [];
},
onActivity(activity) {
if (this.msgActivity === false) {
if (activity) {
@@ -3135,6 +3195,10 @@ export default {
// 长按触发消息操作
case "operateMsg":
if (this.multiSelectMode && $A.isJson(data) && data.id) {
this.onMultiSelectToggle(data.id);
return;
}
this.operateVisible = $A.isJson(data) && this.operateItem.id === data.id;
this.operateItem = $A.isJson(data) ? data : {};
this.operateCopys = []
@@ -3330,6 +3394,11 @@ export default {
this.$refs.forwarder.onSelection()
break;
case "multiSelect":
this.multiSelectMode = true;
this.selectedMsgIds = [this.operateItem.id];
break;
case "withdraw":
this.onWithdraw()
break;

View File

@@ -28,7 +28,42 @@
</div>
<div class="twice-affirm-body-extend">
<div class="forwarder-wrapper-body">
<div v-if="msgDetail" class="dialog-wrapper inde-list">
<!--多选转发方式-->
<div v-if="isMultiMode" class="forward-mode-select">
<RadioGroup v-model="forwardMode" size="small">
<Radio label="one-by-one">{{ $L('逐条转发') }}</Radio>
<Radio label="merge">{{ $L('合并转发') }}</Radio>
</RadioGroup>
</div>
<!--多选消息预览-->
<div v-if="isMultiMode" class="dialog-wrapper inde-list">
<Scrollbar class-name="dialog-scroller">
<template v-if="forwardMode === 'merge'">
<div class="merge-forward-preview">
<div class="merge-preview-title">{{ $L('聊天记录') }}</div>
<div v-for="(item, index) in previewMsgList" :key="item.id" class="merge-preview-item">
<UserAvatar :userid="item.userid" :show-icon="false" :show-name="true" :size="16"/>
<span class="preview-desc" v-html="$A.getMsgSimpleDesc(item)"></span>
</div>
<div class="merge-preview-count">{{ $L('共') }} {{ msgIds.length }} {{ $L('条消息') }}</div>
</div>
</template>
<template v-else>
<DialogItem
v-for="item in previewMsgList"
:key="item.id"
:source="item"
@on-view-text="onViewText"
@on-view-file="onViewFile"
@on-down-file="onDownFile"
@on-emoji="onEmoji"
@on-other="onOther"
simpleView/>
</template>
</Scrollbar>
</div>
<!--单条消息预览-->
<div v-else-if="msgDetail" class="dialog-wrapper inde-list">
<Scrollbar class-name="dialog-scroller">
<DialogItem
:source="msgDetail"
@@ -127,6 +162,16 @@ export default {
type: Object,
default: null
},
// 多选消息ID数组
msgIds: {
type: Array,
default: () => []
},
// 多选消息详情列表
msgList: {
type: Array,
default: () => []
},
},
data() {
@@ -135,6 +180,7 @@ export default {
loading: false,
message: '', // 留言
forwardMode: 'one-by-one', // 转发方式: one-by-one | merge
ainew: $A.getStorageBoolean('forwarder.ainew', true), // 是否AI开启新会话
sender: $A.getStorageBoolean('forwarder.sender', true), // 是否隐藏原发送者信息
@@ -144,6 +190,15 @@ export default {
computed: {
...mapState(['cacheUserBasic']),
isMultiMode() {
return this.msgIds && this.msgIds.length > 0;
},
previewMsgList() {
if (!this.isMultiMode) return [];
return this.msgList.slice(0, this.forwardMode === 'merge' ? 4 : 10);
},
aiUser({forwardTo, cacheUserBasic}) {
const users = forwardTo.filter(item => item.type !== 'group');
return users.filter(user => {
@@ -214,6 +269,9 @@ export default {
const data = {
message: this.message,
}
if (this.isMultiMode) {
data.forward_mode = this.forwardMode;
}
if (!this.senderHidden) {
data.sender = this.sender
}

View File

@@ -22,7 +22,9 @@
:dialog-id="forwardDialogId"
:forward-to="forwardTo"
:msg-detail="msgDetail"/>
:msg-detail="msgDetail"
:msg-ids="msgIds"
:msg-list="msgList"/>
</div>
</template>
@@ -72,6 +74,16 @@ export default {
type: Object,
default: null
},
// 多选消息ID数组
msgIds: {
type: Array,
default: () => []
},
// 多选消息详情列表
msgList: {
type: Array,
default: () => []
},
},
data() {
@@ -117,7 +129,9 @@ export default {
//
data.dialogids = selects.filter(value => $A.leftExists(value, 'd:')).map(value => value.replace('d:', ''));
data.userids = selects.filter(value => !$A.leftExists(value, 'd:'));
if (this.msgDetail) {
if (this.msgIds && this.msgIds.length > 0) {
data.msg_ids = this.msgIds;
} else if (this.msgDetail) {
data.msg_id = this.msgDetail.id;
}
//

View File

@@ -416,6 +416,15 @@
</template>
</FormItem>
<FormItem :label="$L('AI任务分析')" prop="ai_auto_analyze">
<RadioGroup v-model="settingData.ai_auto_analyze">
<Radio label="open" :disabled="systemConfig.task_ai_auto_analyze === 'close'">{{$L('开启')}}</Radio>
<Radio label="close">{{$L('关闭')}}</Radio>
</RadioGroup>
<div v-if="systemConfig.task_ai_auto_analyze === 'close'" class="form-tip">{{$L('系统已关闭AI任务分析功能')}}</div>
<div v-else-if="settingData.ai_auto_analyze === 'open'" class="form-tip">{{$L('新建任务后AI自动分析并给出建议')}}</div>
<div v-else class="form-tip">{{$L('关闭后本项目将不再自动分析任务。')}}</div>
</FormItem>
</Form>
<div slot="footer" class="adaption">
<Button type="default" @click="settingShow=false">{{$L('取消')}}</Button>
@@ -696,6 +705,8 @@ export default {
'cacheUserBasic',
'formOptions',
'systemConfig',
]),
...mapGetters(['projectData', 'transforTasks']),
@@ -1508,7 +1519,8 @@ export default {
name: this.projectData.name,
desc: this.projectData.desc,
archive_method: this.projectData.archive_method,
archive_days: this.projectData.archive_days
archive_days: this.projectData.archive_days,
ai_auto_analyze: this.projectData.ai_auto_analyze || 'open'
});
this.settingShow = true;
this.$nextTick(() => {

View File

@@ -376,6 +376,10 @@
class="related-status archived">
{{$L('已归档')}}
</span>
<Icon
type="md-close"
class="related-remove"
@click.native.stop="removeRelatedTask(item)"/>
</li>
</ul>
</FormItem>
@@ -1597,6 +1601,26 @@ export default {
this.$store.dispatch('openTask', item.related_task_id);
},
removeRelatedTask(item) {
if (!item || !item.related_task_id) {
return;
}
$A.modalConfirm({
title: '温馨提示',
content: '确定要解除与任务 #' + item.related_task_id + ' 的关联吗?',
onOk: () => {
this.$store.dispatch('deleteTaskRelated', {
taskId: this.taskId,
relatedTaskId: item.related_task_id,
}).then(() => {
this.loadRelatedTasks();
}).catch(({msg}) => {
$A.modalError(msg);
});
},
});
},
onTaskRelationUpdate(taskId) {
if (!taskId || taskId !== this.taskId) {
return;

View File

@@ -124,6 +124,14 @@
:placeholder="$L('请选择提醒时间')"
transfer/>
</FormItem>
<FormItem :label="$L('AI任务分析')" prop="taskAiAutoAnalyze">
<RadioGroup v-model="formDatum.task_ai_auto_analyze">
<Radio label="open">{{$L('开启')}}</Radio>
<Radio label="close">{{$L('关闭')}}</Radio>
</RadioGroup>
<div v-if="formDatum.task_ai_auto_analyze == 'open'" class="form-tip">{{$L('新建任务后AI自动分析并给出建议')}}</div>
<div v-else class="form-tip">{{$L('关闭后所有项目将不再自动分析任务。')}}</div>
</FormItem>
<FormItem :label="$L('个人任务上限')" prop="taskUserLimit">
<div style="width: 110px;">
<Input type="number" number v-model="formDatum.task_user_limit" @on-keyup="$A.inputNumberLimit($event, 1, 2000)">

View File

@@ -2635,6 +2635,19 @@ export default {
});
},
deleteTaskRelated({commit, dispatch}, {taskId, relatedTaskId}) {
return new Promise((resolve, reject) => {
dispatch("call", {
url: 'project/task/related/delete',
data: {task_id: taskId, related_task_id: relatedTaskId},
}).then(({msg}) => {
commit('task/related/clear', taskId);
commit('task/related/clear', relatedTaskId);
resolve(msg);
}).catch(reject);
});
},
/**
* 添加子任务
* @param dispatch

View File

@@ -651,6 +651,31 @@
}
}
.dialog-multi-check {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
flex-shrink: 0;
margin-right: 4px;
cursor: pointer;
.ivu-icon {
font-size: 22px;
color: #c5c5c5;
transition: color 0.2s;
&.checked {
color: #2d8cf0;
}
}
}
&.multi-selected {
background-color: rgba(45, 140, 240, 0.06);
border-radius: 8px;
}
.dialog-avatar {
position: relative;
margin-bottom: 20px;
@@ -954,6 +979,58 @@
}
}
.content-merge-forward {
background: #f7f7f7;
border-radius: 8px;
padding: 12px;
cursor: pointer;
min-width: 200px;
max-width: 300px;
transition: background-color 0.2s;
&:hover {
background: #efefef;
}
.merge-title {
font-size: 13px;
font-weight: 500;
color: #333;
margin-bottom: 8px;
}
.merge-list {
.merge-item {
display: flex;
align-items: center;
font-size: 12px;
color: #666;
line-height: 22px;
overflow: hidden;
white-space: nowrap;
.item-colon {
margin: 0 2px;
flex-shrink: 0;
}
.item-desc {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.merge-footer {
font-size: 12px;
color: #999;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid #e8e8e8;
}
}
.content-file {
position: relative;
@@ -1977,6 +2054,31 @@
background-color: rgba(255, 255, 255, 0.8);
}
.dialog-multi-select-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 24px;
border-top: 1px solid #e8e8e8;
background-color: #fafafa;
.multi-select-info {
font-size: 14px;
color: #515a6e;
.multi-select-max {
color: #ed4014;
margin-left: 4px;
font-size: 12px;
}
}
.multi-select-actions {
display: flex;
gap: 8px;
}
}
.dialog-footer {
position: relative;
padding: 0 24px;
@@ -2814,3 +2916,56 @@ body.window-portrait {
opacity: 1;
}
}
.merge-forward-detail-modal {
.merge-detail-scroller {
padding: 0 4px;
}
.merge-detail-list {
.merge-detail-item {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px 0;
& + .merge-detail-item {
border-top: 1px solid #f0f0f0;
}
.detail-content {
flex: 1;
min-width: 0;
.detail-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
.detail-time {
font-size: 12px;
color: #999;
}
}
.detail-body {
font-size: 14px;
color: #333;
word-wrap: break-word;
word-break: break-word;
pre {
display: block;
margin: 0;
padding: 0;
white-space: pre-wrap;
word-wrap: break-word;
font-family: inherit;
font-size: inherit;
}
}
}
}
}
}

View File

@@ -79,6 +79,55 @@
}
}
.forward-mode-select {
padding-bottom: 12px;
.ivu-radio-group {
display: flex;
gap: 16px;
}
}
.merge-forward-preview {
background: #f7f7f7;
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
.merge-preview-title {
font-size: 13px;
font-weight: 500;
color: #333;
margin-bottom: 8px;
}
.merge-preview-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #666;
line-height: 22px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
.preview-desc {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.merge-preview-count {
font-size: 12px;
color: #999;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid #e8e8e8;
}
}
.leave-message {
position: relative;
z-index: 2;

View File

@@ -674,6 +674,24 @@
}
}
.related-remove {
flex-shrink: 0;
font-size: 14px;
color: #c5c8ce;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s ease, color 0.2s ease;
margin-left: 4px;
&:hover {
color: #ed4014;
}
}
&:hover .related-remove {
opacity: 1;
}
.ivu-tag {
margin-left: 8px;
}