Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed2f843815 | ||
|
|
984b98e4fc | ||
|
|
4b32472d64 | ||
|
|
fc171bc71f | ||
|
|
cc80fa83e0 | ||
|
|
782ba4a151 | ||
|
|
04708cedb6 | ||
|
|
4068966700 | ||
|
|
3ce8cf381a | ||
|
|
f78d3f3aff | ||
|
|
c60dff0950 | ||
|
|
f2d49ee104 |
@@ -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
120
CLAUDE.md
@@ -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 长期记忆集成。
|
||||
|
||||
@@ -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回复
|
||||
*
|
||||
|
||||
@@ -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 获取任务详细描述
|
||||
*
|
||||
|
||||
@@ -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();
|
||||
//
|
||||
|
||||
@@ -300,6 +300,8 @@ class UsersController extends AbstractController
|
||||
* @apiGroup users
|
||||
* @apiName token__expire
|
||||
*
|
||||
* @apiParam {Number} [refresh] 是否刷新 token(1=是),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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
2
resources/assets/js/functions/web.js
vendored
2
resources/assets/js/functions/web.js
vendored
@@ -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':
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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"></i>
|
||||
<span>{{ $L('转发') }}</span>
|
||||
</li>
|
||||
<li v-if="actionPermission(operateItem, 'forward')" @click="onOperate('multiSelect')">
|
||||
<i class="taskfont"></i>
|
||||
<span>{{ $L('多选') }}</span>
|
||||
</li>
|
||||
<li v-if="operateItem.userid == userId" @click="onOperate('withdraw')">
|
||||
<i class="taskfont"></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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
//
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)">
|
||||
|
||||
13
resources/assets/js/store/actions.js
vendored
13
resources/assets/js/store/actions.js
vendored
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user