mirror of
https://github.com/kuaifan/dootask.git
synced 2026-08-08 13:58:42 +00:00
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08da0b8976 | ||
|
|
b914a99b62 | ||
|
|
4c9951c667 | ||
|
|
93c604a554 | ||
|
|
e4e1cd9b82 | ||
|
|
6f22fd2706 | ||
|
|
44bd541d00 | ||
|
|
95b20325be | ||
|
|
df4658cddf | ||
|
|
df6c1fc04d | ||
|
|
c9b3515aef | ||
|
|
48c2c16111 | ||
|
|
71ca6ba249 | ||
|
|
7ad27e3326 | ||
|
|
4b92ecb422 | ||
|
|
6666605f7d | ||
|
|
844db09c12 | ||
|
|
f7bc724e13 | ||
|
|
966c7b4ef0 | ||
|
|
f8eaab5a50 | ||
|
|
746337c2f6 | ||
|
|
e4fbf9693a | ||
|
|
13ba1d3bd7 | ||
|
|
f1ea0ed3dc | ||
|
|
7b25f243ab | ||
|
|
9b14120055 | ||
|
|
e72758f313 | ||
|
|
746a0bf9a1 | ||
|
|
18163e1e82 | ||
|
|
9a3d9d3b9c | ||
|
|
c9f5296b73 | ||
|
|
f45f86601e | ||
|
|
184fb27680 | ||
|
|
c04187fe47 | ||
|
|
c067991e3e | ||
|
|
b589307ebb | ||
|
|
4a18ff0315 | ||
|
|
09439b555c | ||
|
|
420d46d5cc | ||
|
|
eb672eaef1 | ||
|
|
bc0fe9c748 | ||
|
|
c4904bdbe2 | ||
|
|
b2d54576d0 | ||
|
|
da36d3b319 | ||
|
|
e5a88c2957 | ||
|
|
0896f09878 | ||
|
|
7ca85bfe6b |
@ -1,64 +1,79 @@
|
||||
---
|
||||
name: dootask-fix-permission
|
||||
description: 修复 DooTask 可写目录(bootstrap/cache、docker、public、storage)的属主/权限:chown 回当前用户 + 目录 chmod 775,对齐 install 的赋权逻辑,赋权不删数据。
|
||||
description: 修复 DooTask 整个项目的目录和文件权限:根目录 chmod 755,bootstrap/cache、docker、public、storage chown 回调用用户且目录 chmod 775,public 文件补充所有用户读权限。用于 Nginx 静态文件 403/Permission denied、install/build EACCES 或可写目录检测失败;优先使用 sudo ./cmd permission,赋权不删数据。
|
||||
---
|
||||
|
||||
# DooTask 目录权限修复
|
||||
|
||||
容器内进程常以 **root** 写入挂载目录(`storage`、`public/uploads`、`bootstrap/cache` 等),导致宿主机当前用户对这些文件**没有写权限**,进而触发:
|
||||
项目根目录如果缺少 `x` 穿越权限,Nginx 即使看到 `public` 中的文件也无法访问。容器内进程还常以 **root** 写入挂载目录(`storage`、`public/uploads`、`bootstrap/cache` 等),导致宿主机当前用户没有写权限。常见现象:
|
||||
|
||||
- `./cmd install` 报「目录【xxx】权限不足」/ 目录权限检测失败
|
||||
- `./cmd build`(vite)报 `EACCES: permission denied, copyfile`(复制 `public/uploads/...` 时)
|
||||
- Nginx 日志出现 `stat() failed (13: Permission denied)`,静态文件请求被回退到 Laravel 首页
|
||||
- `./cmd install` 报「目录【xxx】权限不足」/目录权限检测失败
|
||||
- `./cmd build`(vite)报 `EACCES: permission denied, copyfile`
|
||||
- Laravel 运行时写 `storage`/`bootstrap/cache` 失败
|
||||
|
||||
本技能**对齐 `./cmd install` 的目录赋权逻辑**:对四个可写目录做 `chmod 775`(目录)+ `chown` 回当前用户。
|
||||
对齐 `./cmd permission`/`./cmd install` 的赋权逻辑:项目根目录设为 `755`;四个可写目录做 `chmod 775`(仅目录)+ `chown` 回调用 sudo 的用户;`public` 普通文件用 `a+r` 补充 Nginx 所需读权限。
|
||||
|
||||
## 适用目录
|
||||
|
||||
与 install 一致的四个:
|
||||
|
||||
```
|
||||
```text
|
||||
. # 项目根目录,只修本层为 755
|
||||
bootstrap/cache
|
||||
docker
|
||||
public # 含 public/uploads(真实上传数据)
|
||||
public # 目录 775,普通文件 a+r;含真实上传数据
|
||||
storage
|
||||
```
|
||||
|
||||
## 核心原则:赋权,不删数据
|
||||
|
||||
`public/uploads` 含真实上传文件(头像、附件等)。**永远优先 `chown` 改属主,不要删数据。** 即便用户说"清理一下",也只允许清临时目录 `public/uploads/tmp`,**切勿**删 uploads 下其他内容。
|
||||
`public/uploads` 含真实上传文件。永远优先 `chown` 改属主,不要删数据。即便用户说“清理一下”,也只允许清理临时目录 `public/uploads/tmp`,切勿删除 uploads 下其他内容。
|
||||
|
||||
## 前置检查
|
||||
|
||||
1. **工作目录**:在项目根(存在 `cmd` 且这四个目录在)
|
||||
2. **sudo**:改属主需 root(当前文件多为 root 属主)。本机一般可免密 sudo;不行则经 docker 以 root 改权限
|
||||
3. 确认要修的范围:默认四个目录全修;若用户只想解 build 报错,也可只针对 `public`(含 `public/uploads`)
|
||||
1. 在项目根目录执行,确认存在 `cmd` 和上述四个目录。
|
||||
2. 用 `ls -ld .` 检查项目根目录是否缺少组/其他用户的 `x` 穿越权限。
|
||||
3. 确认可使用 sudo;改 root 属主的文件或目录需要 root 权限。
|
||||
4. 用 `find public -type f ! -perm -004 -print` 检查 Nginx 用户可能无法读取的静态文件。
|
||||
5. 默认修复项目根目录、四个可写目录和 `public` 文件;若用户只想解决 build 的 uploads 报错,可只处理 `public/uploads`。
|
||||
|
||||
检查通过后汇报将执行的命令,**向用户确认一次**再执行。
|
||||
检查通过后,汇报将执行的命令,向用户确认一次再执行。
|
||||
|
||||
## 执行
|
||||
|
||||
确认后执行(属主修回当前用户,目录权限 775):
|
||||
确认后优先执行独立权限修复命令(不依赖 Docker 正在运行):
|
||||
|
||||
```shell
|
||||
# 1) 属主修回当前用户(递归)
|
||||
sudo chown -R "$(id -u):$(id -g)" bootstrap/cache docker public storage
|
||||
|
||||
# 2) 目录权限 775(仅目录,对齐 install 的 `find -type d -exec chmod 775`)
|
||||
find bootstrap/cache docker public storage -type d -exec chmod 775 {} \;
|
||||
sudo ./cmd permission
|
||||
```
|
||||
|
||||
> 只想解 build 的 uploads 报错时,可只对 `public`:
|
||||
> ```shell
|
||||
> sudo chown -R "$(id -u):$(id -g)" public/uploads
|
||||
> ```
|
||||
旧版 `cmd` 没有 `permission` 命令时,手动执行:
|
||||
|
||||
执行后报告:改了哪些目录、属主/权限现状(可 `ls -ld` 抽查),并提示用户可重试之前失败的 install/build/update。
|
||||
```shell
|
||||
# 1) 保证 Nginx 可穿越项目根目录(不递归)
|
||||
sudo chmod 755 .
|
||||
|
||||
# 2) 可写目录属主修回当前用户(递归)
|
||||
sudo chown -R "$(id -u):$(id -g)" bootstrap/cache docker public storage
|
||||
|
||||
# 3) 可写目录权限 775(仅目录)
|
||||
find bootstrap/cache docker public storage -type d -exec chmod 775 {} \;
|
||||
|
||||
# 4) public 普通文件只补充读权限,保留现有写入/执行位
|
||||
find public -type f -exec chmod a+r {} \;
|
||||
```
|
||||
|
||||
只想解决 build 的 uploads 报错时,可只执行:
|
||||
|
||||
```shell
|
||||
sudo chown -R "$(id -u):$(id -g)" public/uploads
|
||||
```
|
||||
|
||||
执行后用 `ls -ld . bootstrap/cache docker public storage` 抽查目录,并用 `find public -type f ! -perm -004 -print` 确认不再有缺少 others 读权限的静态文件。然后重试之前失败的静态文件访问、install/build/update。
|
||||
|
||||
## 失败处理
|
||||
|
||||
- `chown` 报权限不足 → 当前用户无 sudo 权限,提示用户用有 root 权限的账户,或经 docker 以 root 执行;不要静默跳过
|
||||
- 任何步骤失败立即停止报告,不自动重试
|
||||
- `chmod`/`chown` 报权限不足:立即停止,提示使用有 root 权限的账户,或经 docker 以 root 执行;不要静默跳过。
|
||||
- 任何步骤失败都立即停止并报告,不自动重试。
|
||||
|
||||
## 禁止项
|
||||
|
||||
@ -66,11 +81,15 @@ find bootstrap/cache docker public storage -type d -exec chmod 775 {} \;
|
||||
|---------|---------|
|
||||
| build 报 uploads EACCES 就 `rm` 删文件 | `chown` 修属主,保留数据 |
|
||||
| 删整个 `public/uploads` 清场 | 最多清 `public/uploads/tmp`,别碰真实上传数据 |
|
||||
| 对文件无差别 `chmod 777` | 目录 `chmod 775` + `chown` 回当前用户即可 |
|
||||
| 对文件无差别 `chmod 777` | 可写目录 `chmod 775` + `chown` 回当前用户 |
|
||||
| 把 `public` 所有文件强制改为 `644` | 用 `chmod a+r` 只补读权限,保留现有权限位 |
|
||||
| 递归 `chmod 755` 整个项目 | 只对项目根目录执行 `chmod 755 .` |
|
||||
| 不加 sudo 直接 chown root 文件 | 改属主需 root |
|
||||
|
||||
## Red Flags —— 出现这些念头立即停下
|
||||
## Red Flags
|
||||
|
||||
- "uploads 复制失败,删掉再 build" → 不,`chown` 赋权,不丢数据
|
||||
- "777 一把梭最省事" → 不,按 install 的 775(目录)+ chown
|
||||
- "权限不够就跳过这个目录" → 不,报告交用户处理 sudo
|
||||
- “uploads 复制失败,删掉再 build” → 不,`chown` 赋权,不丢数据。
|
||||
- “777 一把梭最省事” → 不,按 install 的 775(目录)+ chown。
|
||||
- “根目录不可穿越,递归 chmod 全仓库” → 不,只修项目根目录为 755。
|
||||
- “静态文件 403,给整个项目所有文件加读权限” → 不,只对 `public` 普通文件执行 `a+r`。
|
||||
- “权限不够就跳过这个目录” → 不,报告交用户处理 sudo。
|
||||
|
||||
@ -44,6 +44,8 @@ description: 从 `pro` 分支发布 DooTask 前端新版本:翻译 → 版本
|
||||
|
||||
多语言数据流:`language/original-{web,api}.txt`(原文/简体中文)→ 经翻译写入 `language/translate.json`(含 9 种语言)→ 生成 `public/language/{web,api}/*`。
|
||||
|
||||
上下文翻译键用于保持短文案的界面宽度,同时区分不同语义,格式固定为 `[lower_snake_case].原文`,例如 `[weekday].一`、`[task_unit].个`。上下文前缀只参与翻译查找,不参与显示;运行时完整键缺失时会回退普通原文键。普通 key 的 `zh` 留空,上下文 key 的 `zh` 必须填写去掉前缀后的原文。
|
||||
|
||||
**1.1 检测差异**
|
||||
|
||||
```shell
|
||||
@ -51,14 +53,15 @@ php .claude/skills/dootask-release/scripts/language.php diff
|
||||
```
|
||||
|
||||
输出 JSON:
|
||||
- `formatErrorCount > 0`:translate.json **已有条目**含 raw `(*)`/`(**)` key、字段结构错误、非法/不连续的参数编号或规范化重复 → **停止**,报告 `formatErrors`,交用户修复
|
||||
- `regexErrorCount > 0`:translate.json **已有条目**的占位符与某语言值不一致 → **停止**,报告 `regexErrors`,交用户修复(这是历史数据问题,不要自行猜测修改)
|
||||
- `redundantCount > 0`:translate.json 里有、但原文已删除的条目 → 仅作提示(apply 时会自动剔除,不致命)
|
||||
- `needsCount == 0`:无新文案 → **跳到 1.4 直接生成**
|
||||
- `needsCount > 0`:`needs` 数组即待翻译清单,每项 `key` 已转成占位符形式(如 `(%T1)`)→ 进入 1.2
|
||||
- `needsCount > 0`:`needs` 数组即待翻译清单,每项 `key` 已转成占位符形式(如 `(%T1)`);上下文 key 还会带自动填充的 `zh` → 进入 1.2
|
||||
|
||||
**1.2 翻译**
|
||||
|
||||
对 `needs` 里的每个 `key`,翻成 8 种语言(`zh` 留空、`key` 原样保留):`zh-CHT` `en` `ko` `ja` `de` `fr` `id` `ru`。
|
||||
对 `needs` 里的每个 `key`,翻成 8 种语言(`key` 原样保留):`zh-CHT` `en` `ko` `ja` `de` `fr` `id` `ru`。普通 key 的 `zh` 留空;上下文 key 的 `zh` 使用 `needs` 已给出的原文,不得留空或改写。
|
||||
|
||||
要求:贴合「项目任务管理系统」语境;占位符 `(%T1)`/`(%M1)` 等原样保留、不可增删改,位置可随目标语言语序调整:
|
||||
|
||||
@ -68,7 +71,7 @@ php .claude/skills/dootask-release/scripts/language.php diff
|
||||
| (%T1)提交的「(%M2)」待你审批 | '(%M2)' submitted by (%T1) is waiting for your approval |
|
||||
|
||||
把结果写成一个 JSON 数组文件(建议放 `/tmp/dootask-release-translated.json`,避免污染工作区),每个元素含全部 10 个字段,顺序为:
|
||||
`key, zh, zh-CHT, en, ko, ja, de, fr, id, ru`(`zh` 写 `""`)。
|
||||
`key, zh, zh-CHT, en, ko, ja, de, fr, id, ru`。
|
||||
|
||||
```json
|
||||
[
|
||||
@ -76,13 +79,21 @@ php .claude/skills/dootask-release/scripts/language.php diff
|
||||
]
|
||||
```
|
||||
|
||||
上下文短词示例:
|
||||
|
||||
```json
|
||||
[
|
||||
{"key":"[task_unit].个","zh":"个","zh-CHT":"個","en":"tasks","ko":"개 작업","ja":"タスク","de":"Aufgaben","fr":"tâches","id":"tugas","ru":"задач"}
|
||||
]
|
||||
```
|
||||
|
||||
**1.3 合并进 translate.json**
|
||||
|
||||
```shell
|
||||
php .claude/skills/dootask-release/scripts/language.php apply /tmp/dootask-release-translated.json
|
||||
```
|
||||
|
||||
脚本会校验字段完整性与占位符完整性、追加新条目、剔除冗余项,并按项目原生格式写回 `translate.json`。任一条不合格会报错停止,按提示修正翻译后重试。
|
||||
脚本会校验字段完整性、占位符完整性与上下文键格式,追加新条目、剔除冗余项,并按项目原生格式写回 `translate.json`。参数化 key 必须使用 `(%T1)`/`(%M1)` 形式,禁止输入 raw `(*)`/`(**)`;编号须按出现顺序从 1 连续递增,各语言值可调整占位符顺序,但占位符的类型、编号和数量必须与 key 完全一致。上下文 key 必须使用 `[lower_snake_case].原文`,且 `zh` 必须与原文部分完全一致。输入 JSON 内相同或规范化后相同的 key 会被拒绝;已存在于 `translate.json` 的非待补项也会被拒绝,避免覆盖歧义。任一条不合格会明确报错并停止,按提示修正翻译后重试。
|
||||
|
||||
**1.4 生成前端/后端语言文件**
|
||||
|
||||
@ -90,7 +101,7 @@ php .claude/skills/dootask-release/scripts/language.php apply /tmp/dootask-relea
|
||||
php .claude/skills/dootask-release/scripts/language.php generate
|
||||
```
|
||||
|
||||
由 `translate.json` 字节级重新生成 `public/language/web/*.js` 与 `public/language/api/*.json`(排序/转义与项目原生工具完全一致,正常情况下 diff 只包含本次新增条目)。
|
||||
由 `translate.json` 字节级重新生成 `public/language/web/*.js` 与 `public/language/api/*.json`(排序/转义与项目原生工具完全一致,正常情况下 diff 只包含本次新增条目)。生成前会再次执行格式与占位符校验;存在 raw `(*)`/`(**)` key 或占位符错误时不会写入生成文件。
|
||||
|
||||
**1.5 报告**:用 `git status --short language public/language` 汇总本步改动,向用户报告新增了多少条翻译。
|
||||
|
||||
|
||||
@ -6,7 +6,8 @@
|
||||
//
|
||||
// 子命令:
|
||||
// language.php diff
|
||||
// —— 输出 JSON:needs(待翻译,key 已转成 (%T1)/(%M1) 形式) / redundants(冗余,提示) / regexErrors(占位符错乱,致命)
|
||||
// —— 输出 JSON:needs(待翻译,key 已转成 (%T1)/(%M1) 形式) / redundants(冗余,提示)
|
||||
// / formatErrors(raw 占位符、字段或编号错误,致命) / regexErrors(各语言占位符错乱,致命)
|
||||
// language.php apply <translated.json>
|
||||
// —— 把新翻译合并进 translate.json(追加 + 剔除冗余),不生成 public 文件
|
||||
// language.php generate
|
||||
@ -41,43 +42,169 @@ function read_generateds(): array
|
||||
return [$originals, $generateds];
|
||||
}
|
||||
|
||||
// ---- 公共:构建 translations 映射(normalizedKey -> obj),并收集冗余/占位符错乱 ----
|
||||
// ---- 公共:占位符与条目结构校验 ----
|
||||
function parameter_tokens(string $value): array
|
||||
{
|
||||
preg_match_all('/\(%[TM]\d+\)/', $value, $matches);
|
||||
return $matches[0];
|
||||
}
|
||||
|
||||
function normalize_key(string $key): string
|
||||
{
|
||||
return preg_replace(["/\(%T\d+\)/", "/\(%M\d+\)/"], ["(*)", "(**)"], $key);
|
||||
}
|
||||
|
||||
function context_source_key(string $key): ?string
|
||||
{
|
||||
if (preg_match('/^\[([a-z][a-z0-9_]*)]\.([\s\S]+)$/', $key, $matches)) {
|
||||
return $matches[2];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validate_original_contexts(array $originals): array
|
||||
{
|
||||
$errors = [];
|
||||
foreach ($originals as $index => $key) {
|
||||
if (preg_match('/^\[[^\]]+]\./', $key) && context_source_key($key) === null) {
|
||||
$errors[] = [
|
||||
'location' => "originals[index $index]",
|
||||
'message' => "上下文翻译键格式错误:$key",
|
||||
];
|
||||
}
|
||||
}
|
||||
return $errors;
|
||||
}
|
||||
|
||||
function validate_entry(array $obj, string $location, bool $requireTranslations): array
|
||||
{
|
||||
$formatErrors = [];
|
||||
$regexErrors = [];
|
||||
foreach ($GLOBALS['LANG_FIELDS'] as $field) {
|
||||
if (!array_key_exists($field, $obj)) {
|
||||
$formatErrors[] = ['location' => "$location.$field", 'message' => "缺少字段 $field"];
|
||||
} elseif (!is_string($obj[$field])) {
|
||||
$formatErrors[] = ['location' => "$location.$field", 'message' => "字段 $field 必须是字符串"];
|
||||
} elseif ($requireTranslations && $field !== 'key' && $field !== 'zh' && $obj[$field] === '') {
|
||||
$formatErrors[] = ['location' => "$location.$field", 'message' => "字段 $field 不得为空"];
|
||||
}
|
||||
}
|
||||
if (!isset($obj['key']) || !is_string($obj['key'])) {
|
||||
return [$formatErrors, $regexErrors];
|
||||
}
|
||||
|
||||
$key = $obj['key'];
|
||||
if (preg_match('/^\[[^\]]+]\./', $key)) {
|
||||
$sourceKey = context_source_key($key);
|
||||
if ($sourceKey === null) {
|
||||
$formatErrors[] = ['location' => "$location.key", 'message' => "上下文翻译键格式错误:$key"];
|
||||
} elseif (($obj['zh'] ?? null) !== $sourceKey) {
|
||||
$formatErrors[] = [
|
||||
'location' => "$location.zh",
|
||||
'message' => "上下文翻译键必须填写原文:$sourceKey",
|
||||
];
|
||||
}
|
||||
}
|
||||
if (preg_match('/\(\*{1,2}\)/', $key)) {
|
||||
$formatErrors[] = [
|
||||
'location' => "$location.key",
|
||||
'message' => "key 不得包含 raw (*)/(**),必须使用 (%T1)/(%M1):$key",
|
||||
];
|
||||
}
|
||||
$withoutValidParameters = preg_replace('/\(%[TM]\d+\)/', '', $key);
|
||||
if (str_contains($withoutValidParameters, '(%')) {
|
||||
$formatErrors[] = ['location' => "$location.key", 'message' => "存在非法参数占位符:$key"];
|
||||
}
|
||||
|
||||
$keyTokens = parameter_tokens($key);
|
||||
foreach ($keyTokens as $index => $token) {
|
||||
preg_match('/\d+/', $token, $number);
|
||||
if ((int)$number[0] !== $index + 1) {
|
||||
$formatErrors[] = [
|
||||
'location' => "$location.key",
|
||||
'message' => "参数编号必须按出现顺序从 1 连续递增:$key",
|
||||
];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$expected = $keyTokens;
|
||||
sort($expected);
|
||||
foreach ($GLOBALS['LANG_FIELDS'] as $field) {
|
||||
if ($field === 'key' || !isset($obj[$field]) || !is_string($obj[$field]) || $obj[$field] === '') {
|
||||
continue;
|
||||
}
|
||||
$actual = parameter_tokens($obj[$field]);
|
||||
sort($actual);
|
||||
if ($actual !== $expected) {
|
||||
$regexErrors[] = [
|
||||
'location' => "$location.$field",
|
||||
'key' => $key,
|
||||
'field' => $field,
|
||||
'value' => $obj[$field],
|
||||
'expected' => $expected,
|
||||
'actual' => $actual,
|
||||
'message' => "参数占位符缺失、类型或编号不一致",
|
||||
];
|
||||
}
|
||||
}
|
||||
return [$formatErrors, $regexErrors];
|
||||
}
|
||||
|
||||
function print_validation_errors(array $formatErrors, array $regexErrors): void
|
||||
{
|
||||
foreach ($formatErrors as $error) {
|
||||
fwrite(STDERR, "格式错误 {$error['location']}:{$error['message']}\n");
|
||||
}
|
||||
foreach ($regexErrors as $error) {
|
||||
fwrite(STDERR, "占位符错误 {$error['location']}:{$error['message']};key={$error['key']}\n");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 公共:构建 translations 映射(normalizedKey -> obj),并收集冗余/格式/占位符错乱 ----
|
||||
function build_translations(array $originals): array
|
||||
{
|
||||
$translations = [];
|
||||
$redundants = [];
|
||||
$regrror = [];
|
||||
$regexErrors = [];
|
||||
$formatErrors = validate_original_contexts($originals);
|
||||
if (!file_exists("translate.json")) {
|
||||
fwrite(STDERR, "translate.json not exists\n");
|
||||
exit(1);
|
||||
}
|
||||
$tmps = json_decode(file_get_contents("translate.json"), true);
|
||||
foreach ($tmps as $obj) {
|
||||
if (!isset($obj['key'])) {
|
||||
if (!is_array($tmps)) {
|
||||
$formatErrors[] = ['location' => 'translate.json', 'message' => '根数据必须是 JSON 数组'];
|
||||
return [$translations, $redundants, $regexErrors, $formatErrors];
|
||||
}
|
||||
foreach ($tmps as $index => $obj) {
|
||||
$location = "translate.json[index $index]";
|
||||
if (!is_array($obj)) {
|
||||
$formatErrors[] = ['location' => $location, 'message' => '条目必须是对象'];
|
||||
continue;
|
||||
}
|
||||
[$entryFormatErrors, $entryRegexErrors] = validate_entry($obj, $location, false);
|
||||
$formatErrors = array_merge($formatErrors, $entryFormatErrors);
|
||||
$regexErrors = array_merge($regexErrors, $entryRegexErrors);
|
||||
if (!isset($obj['key']) || !is_string($obj['key'])) {
|
||||
continue;
|
||||
}
|
||||
$currentKey = $obj['key'];
|
||||
$originalKey = preg_replace(["/\(%T\d+\)/", "/\(%M\d+\)/"], ["(*)", "(**)"], $currentKey);
|
||||
$originalKey = normalize_key($currentKey);
|
||||
if (!in_array($originalKey, $originals)) {
|
||||
$redundants[$originalKey] = $obj;
|
||||
continue;
|
||||
}
|
||||
$translations[$originalKey] = $obj;
|
||||
if (preg_match_all('/\(%[TM]\d+\)/', $currentKey, $matches)) {
|
||||
foreach ($matches[0] as $match) {
|
||||
foreach ($obj as $k => $v) {
|
||||
if (empty($v)) {
|
||||
continue;
|
||||
}
|
||||
if (!str_contains($v, $match)) {
|
||||
$regrror[$originalKey] = ['key' => $currentKey, 'field' => $k, 'value' => $v, 'match' => $match];
|
||||
continue 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isset($translations[$originalKey])) {
|
||||
$formatErrors[] = [
|
||||
'location' => $location,
|
||||
'message' => "规范化后 key 重复:$originalKey",
|
||||
];
|
||||
continue;
|
||||
}
|
||||
$translations[$originalKey] = $obj;
|
||||
}
|
||||
return [$translations, $redundants, $regrror];
|
||||
return [$translations, $redundants, $regexErrors, $formatErrors];
|
||||
}
|
||||
|
||||
// ---- 公共:由 translate.json + originals 重新生成 public 文件 ----
|
||||
@ -127,7 +254,7 @@ function generate(array $generateds, array $translations): void
|
||||
|
||||
if ($cmd === 'diff') {
|
||||
[$originals, $generateds] = read_generateds();
|
||||
[$translations, $redundants, $regrror] = build_translations($originals);
|
||||
[$translations, $redundants, $regexErrors, $formatErrors] = build_translations($originals);
|
||||
|
||||
// 需要翻译的数据(对齐 translate.php 150-169:占位符按单一计数器编号)
|
||||
$needs = [];
|
||||
@ -147,20 +274,27 @@ if ($cmd === 'diff') {
|
||||
$label = strlen($m[1]) > 1 ? "M" : "T";
|
||||
return "(%" . $label . $c++ . ")";
|
||||
}, $key);
|
||||
$needsOut[] = ['key' => $converted];
|
||||
$need = ['key' => $converted];
|
||||
$contextSource = context_source_key($converted);
|
||||
if ($contextSource !== null) {
|
||||
$need['zh'] = $contextSource;
|
||||
}
|
||||
$needsOut[] = $need;
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'needsCount' => count($needsOut),
|
||||
'redundantCount' => count($redundants),
|
||||
'regexErrorCount' => count($regrror),
|
||||
'formatErrorCount' => count($formatErrors),
|
||||
'regexErrorCount' => count($regexErrors),
|
||||
'needs' => $needsOut,
|
||||
'redundants' => array_keys($redundants),
|
||||
'regexErrors' => array_values($regrror),
|
||||
'formatErrors' => array_values($formatErrors),
|
||||
'regexErrors' => array_values($regexErrors),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n";
|
||||
|
||||
if (count($regrror) > 0) {
|
||||
exit(2); // 已有数据占位符错乱,需先修复
|
||||
if (count($formatErrors) > 0 || count($regexErrors) > 0) {
|
||||
exit(2); // 已有数据格式或占位符错乱,需先修复
|
||||
}
|
||||
exit(0);
|
||||
}
|
||||
@ -172,9 +306,10 @@ if ($cmd === 'apply') {
|
||||
exit(1);
|
||||
}
|
||||
[$originals, $generateds] = read_generateds();
|
||||
[$translations, $redundants, $regrror] = build_translations($originals);
|
||||
if (count($regrror) > 0) {
|
||||
fwrite(STDERR, "translate.json 已有条目占位符错乱,请先修复再发版。\n");
|
||||
[$translations, $redundants, $regexErrors, $formatErrors] = build_translations($originals);
|
||||
if (count($formatErrors) > 0 || count($regexErrors) > 0) {
|
||||
print_validation_errors($formatErrors, $regexErrors);
|
||||
fwrite(STDERR, "translate.json 已有条目格式或占位符错误,请先修复再发版。\n");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
@ -183,45 +318,49 @@ if ($cmd === 'apply') {
|
||||
fwrite(STDERR, "translated.json 必须是数组\n");
|
||||
exit(1);
|
||||
}
|
||||
$added = 0;
|
||||
foreach ($incoming as $raw) {
|
||||
foreach ($GLOBALS['LANG_FIELDS'] as $f) {
|
||||
if (!array_key_exists($f, $raw)) {
|
||||
fwrite(STDERR, "新翻译缺字段 \"$f\":" . json_encode($raw, JSON_UNESCAPED_UNICODE) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
$originalSet = array_flip($originals);
|
||||
$incomingSeen = [];
|
||||
$itemsToAdd = [];
|
||||
foreach ($incoming as $index => $raw) {
|
||||
if (!is_array($raw)) {
|
||||
fwrite(STDERR, "新翻译 translated.json[index $index] 必须是对象\n");
|
||||
exit(1);
|
||||
}
|
||||
// 占位符完整性:key 里每个 (%T1)/(%M1) 必须出现在每个非空语言值里
|
||||
if (preg_match_all('/\(%[TM]\d+\)/', $raw['key'], $m)) {
|
||||
foreach ($m[0] as $match) {
|
||||
foreach ($GLOBALS['LANG_FIELDS'] as $f) {
|
||||
if ($f === 'key' || $f === 'zh') {
|
||||
continue;
|
||||
}
|
||||
if (empty($raw[$f])) {
|
||||
continue;
|
||||
}
|
||||
if (!str_contains($raw[$f], $match)) {
|
||||
fwrite(STDERR, "占位符 $match 在字段 \"$f\" 缺失:{$raw['key']}\n");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
[$incomingFormatErrors, $incomingRegexErrors] = validate_entry($raw, "translated.json[index $index]", true);
|
||||
if (count($incomingFormatErrors) > 0 || count($incomingRegexErrors) > 0) {
|
||||
print_validation_errors($incomingFormatErrors, $incomingRegexErrors);
|
||||
exit(1);
|
||||
}
|
||||
// 规范化:固定字段顺序 + zh 置空
|
||||
// 规范化:固定字段顺序;上下文翻译键保留 zh 作为实际显示原文
|
||||
$item = [];
|
||||
foreach ($GLOBALS['LANG_FIELDS'] as $f) {
|
||||
$item[$f] = $f === 'zh' ? '' : $raw[$f];
|
||||
$item[$f] = $f === 'zh' && context_source_key($raw['key']) === null ? '' : $raw[$f];
|
||||
}
|
||||
$originalKey = preg_replace(["/\(%T\d+\)/", "/\(%M\d+\)/"], ["(*)", "(**)"], $item['key']);
|
||||
$originalKey = normalize_key($item['key']);
|
||||
if (!isset($originalSet[$originalKey])) {
|
||||
fwrite(STDERR, "新翻译 key 不在 original-web.txt/original-api.txt:{$item['key']}\n");
|
||||
exit(1);
|
||||
}
|
||||
if (isset($incomingSeen[$originalKey])) {
|
||||
fwrite(STDERR, "新翻译输入内部重复:translated.json[index {$incomingSeen[$originalKey]}] 与 translated.json[index $index] 规范化后均为「$originalKey」\n");
|
||||
exit(1);
|
||||
}
|
||||
if (isset($translations[$originalKey])) {
|
||||
fwrite(STDERR, "新翻译 key 已存在于 translate.json,非待补项或存在覆盖歧义:{$item['key']}(规范化:$originalKey)\n");
|
||||
exit(1);
|
||||
}
|
||||
$incomingSeen[$originalKey] = $index;
|
||||
$itemsToAdd[$originalKey] = $item;
|
||||
}
|
||||
|
||||
foreach ($itemsToAdd as $originalKey => $item) {
|
||||
$translations[$originalKey] = $item;
|
||||
$added++;
|
||||
}
|
||||
|
||||
// array_values:现有条目(去冗余)在前,新条目追加在后
|
||||
file_put_contents("translate.json", json_encode(array_values($translations), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||
echo json_encode([
|
||||
'added' => $added,
|
||||
'added' => count($itemsToAdd),
|
||||
'total' => count($translations),
|
||||
'droppedRedundant' => count($redundants),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n";
|
||||
@ -230,7 +369,12 @@ if ($cmd === 'apply') {
|
||||
|
||||
if ($cmd === 'generate') {
|
||||
[$originals, $generateds] = read_generateds();
|
||||
[$translations] = build_translations($originals);
|
||||
[$translations, $redundants, $regexErrors, $formatErrors] = build_translations($originals);
|
||||
if (count($formatErrors) > 0 || count($regexErrors) > 0) {
|
||||
print_validation_errors($formatErrors, $regexErrors);
|
||||
fwrite(STDERR, "translate.json 存在格式或占位符错误,已停止生成。\n");
|
||||
exit(2);
|
||||
}
|
||||
generate($generateds, $translations);
|
||||
exit(0);
|
||||
}
|
||||
|
||||
2
.github/workflows/tests.yml
vendored
2
.github/workflows/tests.yml
vendored
@ -36,10 +36,8 @@ jobs:
|
||||
- name: ESLint
|
||||
run: npm run lint
|
||||
|
||||
# 存量缺失文案 93 条(见 scripts/check-language.mjs 输出),清零后移除 continue-on-error 改为强制
|
||||
- name: Language Check
|
||||
run: npm run check:lang
|
||||
continue-on-error: true
|
||||
|
||||
phpunit:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
54
CHANGELOG.md
54
CHANGELOG.md
@ -2,6 +2,60 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [1.8.89]
|
||||
|
||||
### Features
|
||||
|
||||
- 管理后台仪表盘新增负责人视角,可按部门查看团队任务;个人视角的待办、逾期、今日和即将到期任务展示也更清晰。
|
||||
- 语义搜索升级为更智能的自动向量检索,搜索相关内容更准确,内容更新后会自动保持索引同步。
|
||||
- 撤回聊天文字或 Markdown 消息后支持「重新编辑」,可在有效期内恢复原内容继续修改。
|
||||
- 微应用加载失败时会显示清晰的错误提示,并提供重试和关闭入口。
|
||||
- 「我的文件」现在包含已共享文件,切换板块时优先展示缓存内容,查找文件更顺畅。
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- 修复 AI 助手模型隐藏设置未完全生效的问题,正在使用的模型被隐藏时会自动切换到可用模型。
|
||||
- 修复仪表盘待完成分组默认展开状态不一致的问题。
|
||||
- 修复撤回消息、文件切换及部分网络异常场景下的显示和状态同步问题。
|
||||
|
||||
### Performance
|
||||
|
||||
- 优化仪表盘移动端布局与任务分组样式,减少页面拥挤,浏览和操作更顺手。
|
||||
|
||||
## [1.8.69]
|
||||
|
||||
### Features
|
||||
|
||||
- 文件页全新分区:拆分「我的文件」与「共享文件」两个板块,共享板块还能进一步筛选「我共享的」和「共享给我的」;上方工具栏合并为一行,查找和切换更清爽。
|
||||
- AI 助手支持隐藏模型:可将部分模型对普通用户隐藏,选择列表更简洁;当正在使用的模型被隐藏时,会自动切换到可用模型,避免选择异常。
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- 修复自定义应用「全员可见」设置有时不生效、以及再次编辑时可见范围被重置为「仅管理员」的问题。
|
||||
- 修复已删除 / 已归档的项目仍会继续生成周期任务的问题;对这些项目下遗留的任务,相关成员现在也能正常打开详情并删除。
|
||||
|
||||
## [1.8.64]
|
||||
|
||||
### Features
|
||||
|
||||
- 大文件上传全面升级:上传单个文件不再受 1G 限制,文件柜、聊天、任务附件、头像与系统图片、编辑器粘贴等场景统一支持;相同文件可「秒传」无需重复上传,上传中断后也能从断点继续,更快更稳。
|
||||
- 新增应用菜单角标:插件与微应用可在菜单入口显示数字或红点提醒,应用未打开也能收到,打开后自动清除,手机端底部导航同样支持。
|
||||
- 登录支持多授权选择:当账号名下有多个可用授权时,登录时可弹窗选择本次要使用的授权。
|
||||
- 授权页面重新排版:正常状态只展示核心信息,出现到期提醒、已过期或设备不匹配时会浮出醒目提示,授权详情可展开查看,状态更清晰。
|
||||
- 新增一键安装 / 升级脚本:一行命令即可完成部署或升级,命令行提示同时支持中文与英文。
|
||||
- AI 助手选择模型时,将官方「Doo AI」分组排到下拉框最前,选择更方便。
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- 修复部分环境下登录验证码图片无法显示的问题。
|
||||
- 修复只读成员仍能看到并点击文件「删除」入口的问题,删除权限现与实际权限保持一致。
|
||||
- 修复手机端 AI 助手在弹窗模式下显示异常的问题,恢复为居中弹框。
|
||||
- 修复安装后网页服务偶尔未自动启动、卸载后残留数据、以管理员身份更新被拦截等部署问题。
|
||||
|
||||
### Performance
|
||||
|
||||
- 优化应用菜单角标的计算方式,减少重复读取,加载更快。
|
||||
|
||||
## [1.8.45]
|
||||
|
||||
### Features
|
||||
|
||||
@ -68,6 +68,7 @@ Laravel 13 (LaravelS/Swoole, PHP 8.4) + Vue 2 (Vite) + Electron。开源任务/
|
||||
|
||||
- 新增用户可见文本须追加原文(简体中文)到:前端 `language/original-web.txt`,后端 `language/original-api.txt`(去重)
|
||||
- 前端翻译用 `$L("文本")`,动态值用 `(*)` 占位:`$L('共(*)条', n)`——禁止拼接翻译
|
||||
- 单字或短词因上下文不同可能产生歧义时,使用 `[lower_snake_case].原文` 上下文键,例如 `$L('[weekday].一')`、`$L('[task_unit].个')`;前缀只参与翻译查找,界面仍显示原文。此类键须完整登记到 `original-web.txt`,且 `translate.json` 的 `zh` 必须填写去除前缀后的原文;普通键的 `zh` 仍留空
|
||||
|
||||
## ai-kb 同步规则
|
||||
|
||||
|
||||
27
README.md
27
README.md
@ -9,14 +9,6 @@ English | **[中文文档](./README_CN.md)**
|
||||
|
||||
- Group Number: `546574618`
|
||||
|
||||
## 📍 Migration from 0.x to 1.x
|
||||
|
||||
- Please ensure to back up your data before upgrading!
|
||||
- If the upgrade fails, try running `./cmd update` multiple times.
|
||||
- If you encounter "Container xxx not found" during upgrade, run `./cmd reup` and then execute `./cmd update`.
|
||||
- If you see a 502 error after upgrading, run `./cmd reup` to restart the services.
|
||||
- If you encounter "Application 'xxx' not installed" after upgrading, log in with the admin account and install the relevant applications from the App Store.
|
||||
|
||||
## Installation Requirements
|
||||
|
||||
- Required: `Docker v20.10+` and `Docker Compose v2.0+`
|
||||
@ -27,6 +19,16 @@ English | **[中文文档](./README_CN.md)**
|
||||
|
||||
### Deploy Project
|
||||
|
||||
**Option 1: One-line script (recommended)**
|
||||
|
||||
Run it in an empty directory to clone and install automatically; run it inside an existing installation to check and upgrade:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/kuaifan/dootask/pro/bin/install | bash
|
||||
```
|
||||
|
||||
**Option 2: Manual deployment**
|
||||
|
||||
```bash
|
||||
# 1、Clone the project to your local machine or server
|
||||
|
||||
@ -105,11 +107,18 @@ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
**Note: Please backup your data before upgrading!**
|
||||
|
||||
Recommended: use the one-line script (run it inside an existing installation; it pulls the latest code and finishes the upgrade in a single run):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/kuaifan/dootask/pro/bin/install | bash
|
||||
```
|
||||
|
||||
Or use the local command:
|
||||
|
||||
```bash
|
||||
./cmd update
|
||||
```
|
||||
|
||||
* Please retry if upgrade fails across major versions.
|
||||
* If you encounter 502 errors after upgrade, run `./cmd reup` to restart services.
|
||||
|
||||
## Project Migration
|
||||
|
||||
27
README_CN.md
27
README_CN.md
@ -9,14 +9,6 @@
|
||||
|
||||
- QQ群号: `546574618`
|
||||
|
||||
## 📍 0.x 迁移到 1.x
|
||||
|
||||
- 升级时请务必备份好数据!
|
||||
- 如果升级失败请尝试执行 `./cmd update` 重试几次。
|
||||
- 如果升级中出现 `没有找到 xxx 容器` 的提示,请运行 `./cmd reup` 后再执行 `./cmd update`。
|
||||
- 如果升级后出现502错误请运行 `./cmd reup` 重启服务即可。
|
||||
- 如果升级后出现 `应用「xxx」未安装` 的提示,请使用管理员账号进入应用商店安装相关应用。
|
||||
|
||||
## 安装程序
|
||||
|
||||
- 必须安装:`Docker v20.10+` 和 `Docker Compose v2.0+`
|
||||
@ -27,6 +19,16 @@
|
||||
|
||||
### 部署项目
|
||||
|
||||
**方式一:一键脚本(推荐)**
|
||||
|
||||
在空目录中执行即自动克隆并安装;在已安装目录中执行则自动检查并升级:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/kuaifan/dootask/pro/bin/install | bash
|
||||
```
|
||||
|
||||
**方式二:手动部署**
|
||||
|
||||
```bash
|
||||
# 1、克隆项目到您的本地或服务器
|
||||
|
||||
@ -105,11 +107,18 @@ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
**注意:在升级之前请备份好你的数据!**
|
||||
|
||||
推荐使用一键脚本升级(在已安装目录中执行,自动拉取最新代码并完成升级,无需重复执行):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/kuaifan/dootask/pro/bin/install | bash
|
||||
```
|
||||
|
||||
或使用本地命令:
|
||||
|
||||
```bash
|
||||
./cmd update
|
||||
```
|
||||
|
||||
* 跨越大版本升级失败时请重试执行一次。
|
||||
* 如果升级后出现502请运行 `./cmd reup` 重启服务即可。
|
||||
|
||||
## 迁移项目
|
||||
|
||||
@ -1,205 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Console\Commands\Traits\ManticoreSyncLock;
|
||||
use App\Models\File;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectTask;
|
||||
use App\Models\User;
|
||||
use App\Models\WebSocketDialogMsg;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Manticore\ManticoreFile;
|
||||
use App\Module\Manticore\ManticoreKeyValue;
|
||||
use App\Module\Manticore\ManticoreMsg;
|
||||
use App\Module\Manticore\ManticoreProject;
|
||||
use App\Module\Manticore\ManticoreTask;
|
||||
use App\Module\Manticore\ManticoreUser;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 异步向量生成命令
|
||||
*
|
||||
* 用于后台批量生成已索引数据的向量,与全文索引解耦
|
||||
* 使用双指针追踪:sync:xxxLastId(全文已同步)和 vector:xxxLastId(向量已生成)
|
||||
*
|
||||
* 运行模式:
|
||||
* - 持续处理直到所有待处理数据完成
|
||||
* - 每批处理完成后休眠几秒,避免 API 过载
|
||||
* - 定时器只作为兜底触发机制
|
||||
*/
|
||||
class GenerateManticoreVectors extends Command
|
||||
{
|
||||
use ManticoreSyncLock;
|
||||
|
||||
protected $signature = 'manticore:generate-vectors
|
||||
{--type=all : 类型 (msg/file/task/project/user/all)}
|
||||
{--batch=50 : 每批 embedding 数量}
|
||||
{--sleep=3 : 每批处理后休眠秒数}
|
||||
{--reset : 重置向量进度指针}';
|
||||
|
||||
protected $description = '批量生成 Manticore 已索引数据的向量';
|
||||
|
||||
/**
|
||||
* 类型配置
|
||||
*/
|
||||
private const TYPE_CONFIG = [
|
||||
'msg' => [
|
||||
'syncKey' => 'sync:manticoreMsgLastId',
|
||||
'vectorKey' => 'vector:manticoreMsgLastId',
|
||||
'class' => ManticoreMsg::class,
|
||||
'model' => WebSocketDialogMsg::class,
|
||||
'idField' => 'id',
|
||||
],
|
||||
'file' => [
|
||||
'syncKey' => 'sync:manticoreFileLastId',
|
||||
'vectorKey' => 'vector:manticoreFileLastId',
|
||||
'class' => ManticoreFile::class,
|
||||
'model' => File::class,
|
||||
'idField' => 'id',
|
||||
],
|
||||
'task' => [
|
||||
'syncKey' => 'sync:manticoreTaskLastId',
|
||||
'vectorKey' => 'vector:manticoreTaskLastId',
|
||||
'class' => ManticoreTask::class,
|
||||
'model' => ProjectTask::class,
|
||||
'idField' => 'id',
|
||||
],
|
||||
'project' => [
|
||||
'syncKey' => 'sync:manticoreProjectLastId',
|
||||
'vectorKey' => 'vector:manticoreProjectLastId',
|
||||
'class' => ManticoreProject::class,
|
||||
'model' => Project::class,
|
||||
'idField' => 'id',
|
||||
],
|
||||
'user' => [
|
||||
'syncKey' => 'sync:manticoreUserLastId',
|
||||
'vectorKey' => 'vector:manticoreUserLastId',
|
||||
'class' => ManticoreUser::class,
|
||||
'model' => User::class,
|
||||
'idField' => 'userid',
|
||||
],
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
$this->error("应用「Manticore Search」未安装");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!Apps::isInstalled("ai")) {
|
||||
$this->error("应用「AI」未安装,无法生成向量");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->registerSignalHandlers();
|
||||
|
||||
if (!$this->acquireLock()) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$type = $this->option('type');
|
||||
$batchSize = intval($this->option('batch'));
|
||||
$sleepSeconds = intval($this->option('sleep'));
|
||||
$reset = $this->option('reset');
|
||||
|
||||
if ($type === 'all') {
|
||||
$types = array_keys(self::TYPE_CONFIG);
|
||||
} else {
|
||||
if (!isset(self::TYPE_CONFIG[$type])) {
|
||||
$this->error("未知类型: {$type}。可用类型: msg, file, task, project, user, all");
|
||||
$this->releaseLock();
|
||||
return 1;
|
||||
}
|
||||
$types = [$type];
|
||||
}
|
||||
|
||||
// 持续处理直到所有类型都没有待处理数据
|
||||
$round = 0;
|
||||
do {
|
||||
$round++;
|
||||
$totalPending = 0;
|
||||
|
||||
foreach ($types as $t) {
|
||||
if ($this->shouldStop) {
|
||||
break;
|
||||
}
|
||||
$pending = $this->processType($t, $batchSize, $reset && $round === 1);
|
||||
$totalPending += $pending;
|
||||
}
|
||||
|
||||
// 如果还有待处理数据,休眠后继续
|
||||
if ($totalPending > 0 && !$this->shouldStop) {
|
||||
$this->info("\n--- 第 {$round} 轮完成,剩余 {$totalPending} 条待处理,{$sleepSeconds} 秒后继续 ---\n");
|
||||
sleep($sleepSeconds);
|
||||
$this->setLock(); // 刷新锁
|
||||
}
|
||||
} while ($totalPending > 0 && !$this->shouldStop);
|
||||
|
||||
$this->info("\n向量生成完成(共 {$round} 轮)");
|
||||
$this->releaseLock();
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理单个类型的向量生成(每次处理一批)
|
||||
*
|
||||
* @param string $type 类型
|
||||
* @param int $batchSize 每批数量
|
||||
* @param bool $reset 是否重置进度
|
||||
* @return int 剩余待处理数量
|
||||
*/
|
||||
private function processType(string $type, int $batchSize, bool $reset): int
|
||||
{
|
||||
$config = self::TYPE_CONFIG[$type];
|
||||
|
||||
// 获取进度指针
|
||||
$syncLastId = intval(ManticoreKeyValue::get($config['syncKey'], 0));
|
||||
$vectorLastId = $reset ? 0 : intval(ManticoreKeyValue::get($config['vectorKey'], 0));
|
||||
|
||||
if ($reset) {
|
||||
ManticoreKeyValue::set($config['vectorKey'], 0);
|
||||
$this->info("[{$type}] 已重置向量进度指针");
|
||||
}
|
||||
|
||||
// 计算待处理范围
|
||||
$pendingCount = $syncLastId - $vectorLastId;
|
||||
if ($pendingCount <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 获取待处理的 ID 列表(每次处理 batchSize * 5 条,让 generateVectorsBatch 内部再分批调用 API)
|
||||
$modelClass = $config['model'];
|
||||
$idField = $config['idField'];
|
||||
$fetchCount = $batchSize * 5;
|
||||
|
||||
$ids = $modelClass::where($idField, '>', $vectorLastId)
|
||||
->where($idField, '<=', $syncLastId)
|
||||
->orderBy($idField)
|
||||
->limit($fetchCount)
|
||||
->pluck($idField)
|
||||
->toArray();
|
||||
|
||||
if (empty($ids)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 批量生成向量
|
||||
$manticoreClass = $config['class'];
|
||||
$successCount = $manticoreClass::generateVectorsBatch($ids, $batchSize);
|
||||
|
||||
$currentLastId = end($ids);
|
||||
|
||||
// 更新向量进度指针
|
||||
ManticoreKeyValue::set($config['vectorKey'], $currentLastId);
|
||||
|
||||
$remaining = $pendingCount - count($ids);
|
||||
$this->info("[{$type}] 处理 " . count($ids) . " 条,成功 {$successCount},ID: {$vectorLastId} -> {$currentLastId},剩余 {$remaining}");
|
||||
|
||||
// 刷新锁
|
||||
$this->setLock();
|
||||
|
||||
return max(0, $remaining);
|
||||
}
|
||||
}
|
||||
94
app/Http/Controllers/Api/AppsController.php
Normal file
94
app/Http/Controllers/Api/AppsController.php
Normal file
@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Module\Badge;
|
||||
use App\Module\Base;
|
||||
use Request;
|
||||
|
||||
/**
|
||||
* 插件 / 微应用相关接口。
|
||||
*
|
||||
* 动态路由(routes/web.php):
|
||||
* api/apps/badge/set -> badge__set() 应用密钥鉴权,绝对设置/清除角标
|
||||
* api/apps/badge/clear -> badge__clear() 当前用户 token 鉴权,清除自己的角标
|
||||
* api/apps/badge/list -> badge__list() 当前用户 token 鉴权,拉取自己全部角标(初始同步)
|
||||
*/
|
||||
class AppsController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @api {post} api/apps/badge/set 设置角标(应用密钥鉴权)
|
||||
*
|
||||
* @apiDescription 由插件服务端使用 APP_SECRET 调用,对 (appid, 菜单, 每个 userid) 绝对设置角标(幂等覆盖)。
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup apps
|
||||
* @apiName badge__set
|
||||
*
|
||||
* @apiParam {String} appid 应用ID
|
||||
* @apiParam {String} secret 应用密钥(APP_SECRET)
|
||||
* @apiParam {Number|Number[]} userid 目标用户ID(单个或数组)
|
||||
* @apiParam {String} [menu_key] 菜单稳定标识;留空表示该应用第一个菜单
|
||||
* @apiParam {Number} [count=0] 角标数字
|
||||
* @apiParam {Boolean} [dot=false] 是否显示红点(count=0 且 dot=false 即清除)
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function badge__set()
|
||||
{
|
||||
return Base::retSuccess('success', Badge::set(
|
||||
trim(Request::input('appid', '')),
|
||||
trim(Request::input('secret', '')),
|
||||
Request::input('userid'),
|
||||
trim(Request::input('menu_key', '')),
|
||||
Request::input('count', 0),
|
||||
Request::input('dot', false)
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/apps/badge/clear 清除角标(当前用户 token 鉴权)
|
||||
*
|
||||
* @apiDescription 供前端在 badge_clear_on_open=true 的菜单打开时调用,清除当前用户在该应用该菜单的角标。
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup apps
|
||||
* @apiName badge__clear
|
||||
*
|
||||
* @apiParam {String} appid 应用ID
|
||||
* @apiParam {String} [menu_key] 菜单稳定标识;留空表示该应用第一个菜单
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function badge__clear()
|
||||
{
|
||||
$user = User::auth();
|
||||
return Base::retSuccess('success', Badge::clearForUser(
|
||||
(int)$user->userid,
|
||||
trim(Request::input('appid', '')),
|
||||
trim(Request::input('menu_key', ''))
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/apps/badge/list 拉取自己全部角标
|
||||
*
|
||||
* @apiDescription 供前端初始同步:返回当前用户全部应用(插件 + 自定义微应用)的角标快照。
|
||||
* 数据结构 app_id => menu_key => {count, dot},与前端 store 的 map 结构一致。
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup apps
|
||||
* @apiName badge__list
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function badge__list()
|
||||
{
|
||||
$user = User::auth();
|
||||
return Base::retSuccess('success', Badge::userBadges((int)$user->userid));
|
||||
}
|
||||
}
|
||||
86
app/Http/Controllers/Api/DashboardController.php
Normal file
86
app/Http/Controllers/Api/DashboardController.php
Normal file
@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Module\Base;
|
||||
use App\Module\DashboardTeam;
|
||||
use Request;
|
||||
|
||||
/**
|
||||
* @apiDefine dashboard
|
||||
*
|
||||
* 仪表盘
|
||||
*/
|
||||
class DashboardController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @api {get} api/dashboard/team/stats 负责人视角统计
|
||||
*
|
||||
* @apiDescription 需要token身份。返回所选管理部门(含下级部门)的团队任务统计。
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup dashboard
|
||||
* @apiName team__stats
|
||||
*
|
||||
* @apiParam {String} [department_owner_ids] 所选管理部门ID,逗号分隔;不传表示全部
|
||||
* @apiParam {Number} [refresh] 传 1 时主动刷新当前统计缓存
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码
|
||||
* @apiSuccess {String} msg 返回信息
|
||||
* @apiSuccess {Object} data 团队统计数据
|
||||
*/
|
||||
public function team__stats()
|
||||
{
|
||||
$user = User::auth();
|
||||
$context = DashboardTeam::context($user, Request::input('department_owner_ids'));
|
||||
return Base::retSuccess('success', DashboardTeam::stats($context, intval(Request::input('refresh')) === 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/dashboard/team/tasks 负责人视角任务列表
|
||||
*
|
||||
* @apiDescription 需要token身份。按关注类型、成员或优先级分页返回团队任务。
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup dashboard
|
||||
* @apiName team__tasks
|
||||
*
|
||||
* @apiParam {String} [department_owner_ids] 所选管理部门ID,逗号分隔;不传表示全部
|
||||
* @apiParam {String} [type] 任务类型:uncompleted/overdue/soon/hi/noowner
|
||||
* @apiParam {Number} [member_id] 成员ID;传入后优先于 type
|
||||
* @apiParam {Number} [level] 优先级;-1 表示未设置,传入后优先于 type
|
||||
* @apiParam {Number} [page] 当前页
|
||||
* @apiParam {Number} [pagesize] 每页数量,默认20,最大50
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码
|
||||
* @apiSuccess {String} msg 返回信息
|
||||
* @apiSuccess {Object} data 分页任务数据
|
||||
*/
|
||||
public function team__tasks()
|
||||
{
|
||||
$user = User::auth();
|
||||
$context = DashboardTeam::context($user, Request::input('department_owner_ids'));
|
||||
|
||||
$memberId = intval(Request::input('member_id'));
|
||||
$levelValue = Request::input('level');
|
||||
$level = $levelValue !== null && $levelValue !== '' ? intval($levelValue) : null;
|
||||
$type = trim((string)Request::input('type'));
|
||||
|
||||
if ($memberId > 0) {
|
||||
if (!in_array($memberId, $context['member_userids'], true)) {
|
||||
return Base::retError('参数错误');
|
||||
}
|
||||
} elseif ($level !== null) {
|
||||
if ($level !== -1 && !in_array($level, DashboardTeam::priorityLevels(), true)) {
|
||||
return Base::retError('参数错误');
|
||||
}
|
||||
} elseif (!in_array($type, ['uncompleted', 'overdue', 'soon', 'hi', 'noowner'], true)) {
|
||||
return Base::retError('参数错误');
|
||||
}
|
||||
|
||||
return Base::retSuccess('success', DashboardTeam::tasks($context, [
|
||||
'type' => $type,
|
||||
'member_id' => $memberId,
|
||||
'level' => $level,
|
||||
]));
|
||||
}
|
||||
}
|
||||
@ -41,6 +41,7 @@ class FileController extends AbstractController
|
||||
* @apiName lists
|
||||
*
|
||||
* @apiParam {Number} [pid] 父级ID
|
||||
* @apiParam {String} [scope] 板块范围(根目录生效):mine=我的文件、shared=共享文件、all=全部(默认)
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
@ -51,8 +52,12 @@ class FileController extends AbstractController
|
||||
$user = User::auth();
|
||||
//
|
||||
$pid = intval(Request::input('pid'));
|
||||
$scope = Request::input('scope', 'all');
|
||||
if (!in_array($scope, ['mine', 'shared', 'all'])) {
|
||||
$scope = 'all';
|
||||
}
|
||||
//
|
||||
return Base::retSuccess('success', (new File)->getFileList($user, $pid));
|
||||
return Base::retSuccess('success', (new File)->getFileList($user, $pid, 'all', true, $scope));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -13,6 +13,7 @@ use Request;
|
||||
* 动态路由(routes/web.php):
|
||||
* api/license/email/send -> email__send()
|
||||
* api/license/login -> login()
|
||||
* api/license/login/confirm -> login__confirm()
|
||||
* api/license/trial -> trial()
|
||||
* api/license/status -> status()
|
||||
* api/license/refresh -> refresh()
|
||||
@ -49,6 +50,25 @@ class LicenseController extends AbstractController
|
||||
return Base::retSuccess('授权成功', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 多条可用授权时,用户选定后确认签发(复用验证码)
|
||||
*/
|
||||
public function login__confirm()
|
||||
{
|
||||
User::auth('admin');
|
||||
$email = trim(Request::input('email'));
|
||||
$code = trim(Request::input('code'));
|
||||
$entitlementId = (int)Request::input('entitlement_id');
|
||||
if ($email === '' || $code === '') {
|
||||
return Base::retError('请输入邮箱和验证码');
|
||||
}
|
||||
if ($entitlementId <= 0) {
|
||||
return Base::retError('请选择要使用的授权');
|
||||
}
|
||||
$data = OnlineLicense::loginConfirm($email, $code, $entitlementId);
|
||||
return Base::retSuccess('授权成功', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 邮箱 + 验证码申请试用并签发
|
||||
*/
|
||||
|
||||
@ -2271,9 +2271,12 @@ class ProjectController extends AbstractController
|
||||
}
|
||||
//
|
||||
$data = $task->toArray();
|
||||
$data['department_readonly'] = UserDepartment::isDepartmentReadonlyProject($departmentView, intval($task->project_id));
|
||||
$data['project_name'] = $task->project?->name;
|
||||
$taskProject = Project::withTrashed()->find($task->project_id);
|
||||
$data['project_name'] = $taskProject?->name;
|
||||
$data['project_deleted'] = (!$taskProject || $taskProject->trashed()) ? 1 : 0;
|
||||
$data['project_archived'] = $taskProject?->archived_at ? 1 : 0;
|
||||
$data['column_name'] = $task->projectColumn?->name;
|
||||
$data['department_readonly'] = UserDepartment::isDepartmentReadonlyProject($departmentView, intval($task->project_id));
|
||||
$data['visibility_appointor'] = $task->visibility == 1 ? [0] : ProjectTaskVisibilityUser::whereTaskId($task_id)->pluck('userid');
|
||||
return Base::retSuccess('success', $data);
|
||||
}
|
||||
@ -3205,8 +3208,27 @@ class ProjectController extends AbstractController
|
||||
//
|
||||
$task = ProjectTask::userTask($task_id, null, $type !== 'recovery');
|
||||
//
|
||||
$project = Project::userProject($task->project_id);
|
||||
ProjectPermission::userTaskPermission($project, ProjectPermission::TASK_REMOVE, $task);
|
||||
try {
|
||||
$project = Project::userProject($task->project_id);
|
||||
ProjectPermission::userTaskPermission($project, ProjectPermission::TASK_REMOVE, $task);
|
||||
} catch (\Throwable $e) {
|
||||
if ($type == 'recovery') {
|
||||
throw $e;
|
||||
}
|
||||
// 项目已删除/已归档时放行删除操作(限:管理员、原项目负责人、任务负责人/协助人/创建人)
|
||||
$project = Project::withTrashed()->find($task->project_id);
|
||||
$projectInvalid = !$project || $project->trashed() || $project->archived_at;
|
||||
if (!$projectInvalid) {
|
||||
throw $e;
|
||||
}
|
||||
$isProjectOwner = ProjectUser::whereProjectId($task->project_id)
|
||||
->whereUserid(Doo::userId())
|
||||
->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY])
|
||||
->exists();
|
||||
if (!$isProjectOwner && !$task->permission(3)) {
|
||||
throw new ApiException('仅项目负责人或任务相关成员删除');
|
||||
}
|
||||
}
|
||||
//
|
||||
if ($type == 'recovery') {
|
||||
$task->restoreTask();
|
||||
|
||||
@ -154,7 +154,7 @@ class SystemController extends AbstractController
|
||||
$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['department_owner_project_view'] = $setting['department_owner_project_view'] ?: 'close';
|
||||
$setting['department_owner_project_view'] = $setting['department_owner_project_view'] ?: 'open';
|
||||
$setting['server_timezone'] = config('app.timezone');
|
||||
$setting['server_version'] = Base::getVersion();
|
||||
// 指定人员名单仅管理员可见
|
||||
@ -348,6 +348,10 @@ class SystemController extends AbstractController
|
||||
if (empty($item)) {
|
||||
continue;
|
||||
}
|
||||
// dooai_key 是官方网关 token,需原样返回供鉴权
|
||||
if ($key === 'dooai_key') {
|
||||
continue;
|
||||
}
|
||||
if (str_ends_with($key, '_key') || str_ends_with($key, '_secret')) {
|
||||
$setting[$key] = substr($item, 0, 4) . str_repeat('*', strlen($item) - 8) . substr($item, -4);
|
||||
}
|
||||
@ -774,14 +778,14 @@ class SystemController extends AbstractController
|
||||
}
|
||||
$apps = Setting::normalizeCustomMicroApps($list);
|
||||
$setting = Base::setting('microapp_menu', $apps);
|
||||
$setting = Setting::formatCustomMicroAppsForResponse($setting);
|
||||
$setting = Setting::formatCustomMicroAppsForResponse($setting, true);
|
||||
} else {
|
||||
$setting = Base::setting('microapp_menu');
|
||||
if (!is_array($setting)) {
|
||||
$setting = [];
|
||||
}
|
||||
$setting = Setting::filterCustomMicroAppsForUser($setting, $user);
|
||||
$setting = Setting::formatCustomMicroAppsForResponse($setting);
|
||||
$setting = Setting::formatCustomMicroAppsForResponse($setting, $user && $user->isAdmin());
|
||||
}
|
||||
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting);
|
||||
}
|
||||
@ -857,6 +861,14 @@ class SystemController extends AbstractController
|
||||
$type = trim(Request::input('type'));
|
||||
if ($type == 'save') {
|
||||
$license = Request::input('license');
|
||||
// 解密失败(sn 为空)视为无效 license
|
||||
$decoded = Doo::licenseDecode($license);
|
||||
if ((string)($decoded['sn'] ?? '') === '') {
|
||||
return Base::retError('LICENSE 格式错误');
|
||||
}
|
||||
if ($err = Doo::licenseBindingError($decoded)) {
|
||||
return Base::retError($err);
|
||||
}
|
||||
Doo::licenseSave($license);
|
||||
// 离线/在线互斥:保存离线 license 即退出在线模式(尽力释放座位+清在线标志,不删除刚写入的文件)
|
||||
OnlineLicense::switchToOffline();
|
||||
@ -871,8 +883,8 @@ class SystemController extends AbstractController
|
||||
'user_count' => User::whereBot(0)->whereNull('disable_at')->count(),
|
||||
'error' => []
|
||||
];
|
||||
if ($data['info']['people'] > 3) {
|
||||
// 小于3人的License不检查
|
||||
if ($data['info']['people'] == 0 || $data['info']['people'] > 3) {
|
||||
// 付费档才检查 SN/MAC
|
||||
if ($data['info']['sn'] != $data['doo_sn']) {
|
||||
$data['error'][] = '终端SN与License不匹配';
|
||||
}
|
||||
|
||||
129
app/Http/Controllers/Api/UploadController.php
Normal file
129
app/Http/Controllers/Api/UploadController.php
Normal file
@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Module\Base;
|
||||
use App\Module\ChunkUpload;
|
||||
use Request;
|
||||
|
||||
/**
|
||||
* 分片上传统一入口。
|
||||
*
|
||||
* 动态路由(routes/web.php):
|
||||
* api/upload/init -> init() 启动一个上传会话(含秒传 / 续传命中)
|
||||
* api/upload/chunk -> chunk() 接收一个分片
|
||||
* api/upload/merge -> merge() 合并分片并按 scene 入库
|
||||
*
|
||||
* 小文件(<10MB)不走此接口,前端直接调用各 scene 的老接口(透明降级)。
|
||||
*/
|
||||
class UploadController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @api {post} api/upload/init 启动上传会话
|
||||
*
|
||||
* @apiDescription 提交文件 hash/size/name/scene/scene_params,返回 upload_id 与已收分片列表;
|
||||
* 若同用户曾上传过同 hash 文件,直接返回 done=true(秒传)。
|
||||
* @apiGroup upload
|
||||
* @apiName init
|
||||
*
|
||||
* @apiParam {String} hash 文件 md5(小写 32 字符)
|
||||
* @apiParam {Number} size 文件大小(字节)
|
||||
* @apiParam {String} name 原始文件名(含扩展名)
|
||||
* @apiParam {String} scene 场景:file_cabinet | dialog_file | image | generic_file
|
||||
* @apiParam {Object} [scene_params] 场景参数(如 file_cabinet 需 pid/cover/webkit_relative_path)
|
||||
*
|
||||
* @apiSuccess {Number} ret
|
||||
* @apiSuccess {Object} data 含 done / upload_id / chunk_size / chunk_count / received 或秒传 file
|
||||
*/
|
||||
public function init()
|
||||
{
|
||||
$user = User::auth();
|
||||
$result = ChunkUpload::start($user, [
|
||||
'hash' => Request::input('hash', ''),
|
||||
'size' => Request::input('size', 0),
|
||||
'name' => Request::input('name', ''),
|
||||
'scene' => Request::input('scene', ''),
|
||||
'scene_params' => Request::input('scene_params', []),
|
||||
]);
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/upload/chunk 上传一个分片
|
||||
*
|
||||
* @apiDescription multipart 请求,blob 字段为分片数据。
|
||||
* @apiGroup upload
|
||||
* @apiName chunk
|
||||
*
|
||||
* @apiParam {String} upload_id init 返回的 upload_id
|
||||
* @apiParam {Number} index 分片序号(0-based)
|
||||
* @apiParam {File} blob 分片数据
|
||||
*
|
||||
* @apiSuccess {Number} ret
|
||||
* @apiSuccess {Object} data 含 upload_id 与最新 received[]
|
||||
*/
|
||||
public function chunk()
|
||||
{
|
||||
$user = User::auth();
|
||||
$uploadId = trim(Request::input('upload_id', ''));
|
||||
$index = intval(Request::input('index', -1));
|
||||
$blob = Request::file('blob');
|
||||
if ($uploadId === '') {
|
||||
return Base::retError('upload_id 不能为空');
|
||||
}
|
||||
return ChunkUpload::receive($user, $uploadId, $index, $blob);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/upload/merge 合并分片并入库
|
||||
*
|
||||
* @apiDescription 全部分片到齐后调用;后端按 scene 路由到对应入库逻辑,返回与该 scene 老接口对齐的数据。
|
||||
* @apiGroup upload
|
||||
* @apiName merge
|
||||
*
|
||||
* @apiParam {String} upload_id init 返回的 upload_id
|
||||
*
|
||||
* @apiSuccess {Number} ret
|
||||
* @apiSuccess {Object} data scene 入库返回数据
|
||||
*/
|
||||
public function merge()
|
||||
{
|
||||
$user = User::auth();
|
||||
$uploadId = trim(Request::input('upload_id', ''));
|
||||
if ($uploadId === '') {
|
||||
return Base::retError('upload_id 不能为空');
|
||||
}
|
||||
try {
|
||||
return ChunkUpload::merge($user, $uploadId);
|
||||
} catch (\Exception $e) {
|
||||
if (str_contains($e->getMessage(), 'Failed to acquire lock')) {
|
||||
return Base::retError('合并繁忙,请稍后再试');
|
||||
}
|
||||
return Base::retError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/upload/cancel 取消上传会话
|
||||
*
|
||||
* @apiDescription 调用方主动放弃一次分片上传时调用:删除 Redis meta/chunks/hash 索引并清掉分片目录。
|
||||
* 会话已过期或归属其他用户时静默成功,避免给前端取消按钮回写"取消失败"。
|
||||
* @apiGroup upload
|
||||
* @apiName cancel
|
||||
*
|
||||
* @apiParam {String} upload_id init 返回的 upload_id
|
||||
*
|
||||
* @apiSuccess {Number} ret
|
||||
*/
|
||||
public function cancel()
|
||||
{
|
||||
$user = User::auth();
|
||||
$uploadId = trim(Request::input('upload_id', ''));
|
||||
if ($uploadId === '') {
|
||||
return Base::retError('upload_id 不能为空');
|
||||
}
|
||||
ChunkUpload::cancelByUser($user, $uploadId);
|
||||
return Base::retSuccess('已取消');
|
||||
}
|
||||
}
|
||||
@ -276,6 +276,7 @@ class UsersController extends AbstractController
|
||||
public function login__codejson()
|
||||
{
|
||||
$captcha = Captcha::create('default', true);
|
||||
$captcha['img'] = (string)$captcha['img'];
|
||||
return Base::retSuccess('请求成功', $captcha);
|
||||
}
|
||||
|
||||
@ -417,7 +418,7 @@ class UsersController extends AbstractController
|
||||
public function info__managed_departments()
|
||||
{
|
||||
$user = User::auth();
|
||||
if (Base::settingFind('system', 'department_owner_project_view', 'close') !== 'open') {
|
||||
if (Base::settingFind('system', 'department_owner_project_view', 'open') !== 'open') {
|
||||
return Base::retSuccess('success', []);
|
||||
}
|
||||
return Base::retSuccess('success', UserDepartment::getManagedDepartments($user->userid));
|
||||
|
||||
48
app/Models/AppBadge.php
Normal file
48
app/Models/AppBadge.php
Normal file
@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
/**
|
||||
* App\Models\AppBadge
|
||||
*
|
||||
* 插件/微应用菜单角标(每个 (app_id, menu_key, userid) 一行,仅存非清除态)
|
||||
*
|
||||
* @property int $id
|
||||
* @property string $app_id 应用ID
|
||||
* @property string $menu_key 菜单稳定标识(空串=第一个菜单)
|
||||
* @property int $userid 用户ID
|
||||
* @property int $count 角标数字
|
||||
* @property bool $dot 是否显示红点
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge newModelQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge newQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge query()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge whereAppId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge whereCount($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge whereDot($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge whereId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge whereMenuKey($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge whereUpdatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge whereUserid($value)
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class AppBadge extends AbstractModel
|
||||
{
|
||||
protected $table = 'app_badges';
|
||||
|
||||
const CREATED_AT = null;
|
||||
|
||||
protected $fillable = [
|
||||
'app_id',
|
||||
'menu_key',
|
||||
'userid',
|
||||
'count',
|
||||
'dot',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'userid' => 'integer',
|
||||
'count' => 'integer',
|
||||
'dot' => 'boolean',
|
||||
];
|
||||
}
|
||||
@ -178,28 +178,26 @@ class File extends AbstractModel
|
||||
* @param int $pid
|
||||
* @param string $type
|
||||
* @param bool $isGetparent
|
||||
* @param string $scope 板块范围(根目录生效):mine=仅我的私有文件;shared=共享文件(别人共享给我的+我共享出去的);all=全部(默认,兼容旧调用)
|
||||
* @return array
|
||||
*/
|
||||
public function getFileList($user, int $pid, $type = "all", $isGetparent = true)
|
||||
public function getFileList($user, int $pid, $type = "all", $isGetparent = true, $scope = "all")
|
||||
{
|
||||
$permission = 1000;
|
||||
$userids = $user->isTemp() ? [$user->userid] : [0, $user->userid];
|
||||
$builder = File::wherePid($pid)
|
||||
->when($type == 'dir', function ($q) {
|
||||
$q->whereType('folder');
|
||||
});
|
||||
//
|
||||
if ($pid > 0) {
|
||||
// 目录内:按权限返回子级(不区分板块)
|
||||
File::permissionFind($pid, $userids, 0, $permission);
|
||||
} else {
|
||||
$builder->whereUserid($user->userid);
|
||||
}
|
||||
//
|
||||
$array = $builder->take(500)->get()->toArray();
|
||||
foreach ($array as &$item) {
|
||||
$item['permission'] = $permission;
|
||||
}
|
||||
//
|
||||
if ($pid > 0) {
|
||||
$array = File::wherePid($pid)
|
||||
->when($type == 'dir', function ($q) {
|
||||
$q->whereType('folder');
|
||||
})
|
||||
->take(500)->get()->toArray();
|
||||
foreach ($array as &$item) {
|
||||
$item['permission'] = $permission;
|
||||
}
|
||||
unset($item);
|
||||
// 遍历获取父级
|
||||
if ($isGetparent) {
|
||||
while ($pid > 0) {
|
||||
@ -230,24 +228,58 @@ class File extends AbstractModel
|
||||
$array = array_values($array);
|
||||
}
|
||||
} else {
|
||||
// 获取共享相关
|
||||
DB::statement("SET SQL_MODE=''");
|
||||
$pre = DB::connection()->getTablePrefix();
|
||||
$list = File::select(["files.*", DB::raw("MAX({$pre}file_users.permission) as permission")])
|
||||
->join('file_users', 'files.id', '=', 'file_users.file_id')
|
||||
->where('files.userid', '!=', $user->userid)
|
||||
->whereIn('file_users.userid', $userids)
|
||||
->groupBy('files.id')
|
||||
->take(100)
|
||||
->when($type == 'dir', function ($q) {
|
||||
$q->where('files.type', 'folder');
|
||||
})
|
||||
->get();
|
||||
if ($list->isNotEmpty()) {
|
||||
foreach ($list as $file) {
|
||||
$temp = $file->toArray();
|
||||
$temp['pid'] = 0;
|
||||
$array[] = $temp;
|
||||
// 根目录:按板块拆分
|
||||
$array = [];
|
||||
// 我的文件:我拥有的全部(含已共享出去的),mine 与 all 一致
|
||||
if ($scope === 'mine' || $scope === 'all') {
|
||||
$mine = File::wherePid(0)
|
||||
->whereUserid($user->userid)
|
||||
->when($type == 'dir', function ($q) {
|
||||
$q->whereType('folder');
|
||||
})
|
||||
->take(500)->get()->toArray();
|
||||
foreach ($mine as &$item) {
|
||||
$item['permission'] = $permission;
|
||||
}
|
||||
unset($item);
|
||||
$array = array_merge($array, $mine);
|
||||
}
|
||||
// 共享文件
|
||||
if ($scope === 'shared' || $scope === 'all') {
|
||||
// 别人共享给我的
|
||||
DB::statement("SET SQL_MODE=''");
|
||||
$pre = DB::connection()->getTablePrefix();
|
||||
$list = File::select(["files.*", DB::raw("MAX({$pre}file_users.permission) as permission")])
|
||||
->join('file_users', 'files.id', '=', 'file_users.file_id')
|
||||
->where('files.userid', '!=', $user->userid)
|
||||
->whereIn('file_users.userid', $userids)
|
||||
->groupBy('files.id')
|
||||
->take(100)
|
||||
->when($type == 'dir', function ($q) {
|
||||
$q->where('files.type', 'folder');
|
||||
})
|
||||
->get();
|
||||
if ($list->isNotEmpty()) {
|
||||
foreach ($list as $file) {
|
||||
$temp = $file->toArray();
|
||||
$temp['pid'] = 0;
|
||||
$array[] = $temp;
|
||||
}
|
||||
}
|
||||
// 我共享出去的(仅 shared 板块补充;all 板块已包含在“我的文件”里)
|
||||
if ($scope === 'shared') {
|
||||
$mineShared = File::wherePid(0)
|
||||
->whereUserid($user->userid)
|
||||
->where('share', 1)
|
||||
->when($type == 'dir', function ($q) {
|
||||
$q->whereType('folder');
|
||||
})
|
||||
->take(500)->get()->toArray();
|
||||
foreach ($mineShared as &$item) {
|
||||
$item['permission'] = $permission;
|
||||
}
|
||||
unset($item);
|
||||
$array = array_merge($array, $mineShared);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -267,20 +299,74 @@ class File extends AbstractModel
|
||||
* @return array
|
||||
*/
|
||||
public function contentUpload($user, int $pid, $webkitRelativePath, $overwrite = false)
|
||||
{
|
||||
[$pid, $userid, $addItem] = $this->contentUploadPrep($user, $pid, $webkitRelativePath);
|
||||
$data = Base::upload([
|
||||
"file" => Request::file('files'),
|
||||
"type" => 'more',
|
||||
"autoThumb" => false,
|
||||
"path" => 'uploads/tmp/file/' . date("Ym") . '/',
|
||||
"quality" => true,
|
||||
]);
|
||||
if (Base::isError($data)) {
|
||||
throw new ApiException($data['msg']);
|
||||
}
|
||||
return $this->contentUploadCommit($user, $userid, $pid, $data['data'], $addItem, $webkitRelativePath, null, $overwrite);
|
||||
}
|
||||
|
||||
/**
|
||||
* 与 contentUpload 同一入库链路,但接收已落盘的本地文件而非 Request 上传文件。
|
||||
* 供分片上传 merge 阶段调用。
|
||||
*
|
||||
* @param user $user
|
||||
* @param int $pid
|
||||
* @param string $localPath 合并后的完整文件绝对路径
|
||||
* @param string $originalName 原始文件名(含扩展名)
|
||||
* @param string $webkitRelativePath
|
||||
* @param string|null $hash 文件 md5,用于秒传索引
|
||||
* @param bool $overwrite
|
||||
* @return array
|
||||
*/
|
||||
public function contentUploadFromPath($user, int $pid, string $localPath, string $originalName, $webkitRelativePath, $hash = null, $overwrite = false)
|
||||
{
|
||||
[$pid, $userid, $addItem] = $this->contentUploadPrep($user, $pid, $webkitRelativePath);
|
||||
$data = Base::uploadFromPath([
|
||||
"path_local" => $localPath,
|
||||
"name" => $originalName,
|
||||
"type" => 'more',
|
||||
"autoThumb" => false,
|
||||
"path" => 'uploads/tmp/file/' . date("Ym") . '/',
|
||||
"quality" => true,
|
||||
]);
|
||||
if (Base::isError($data)) {
|
||||
throw new ApiException($data['msg']);
|
||||
}
|
||||
return $this->contentUploadCommit($user, $userid, $pid, $data['data'], $addItem, $webkitRelativePath, $hash, $overwrite);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传前置:权限/计数校验 + webkitRelativePath 拆出来的中间文件夹创建。
|
||||
* 失败抛 ApiException;成功返回 [最终 pid, 拥有者 userid, 已创建的中间文件夹列表]。
|
||||
*
|
||||
* @param user $user
|
||||
* @param int $pid
|
||||
* @param string $webkitRelativePath
|
||||
* @return array{0:int, 1:int, 2:array}
|
||||
*/
|
||||
public function contentUploadPrep($user, int $pid, $webkitRelativePath): array
|
||||
{
|
||||
$userid = $user->userid;
|
||||
if ($pid > 0) {
|
||||
if (File::wherePid($pid)->count() >= 300) {
|
||||
return Base::retError('每个文件夹里最多只能创建300个文件或文件夹');
|
||||
throw new ApiException('每个文件夹里最多只能创建300个文件或文件夹');
|
||||
}
|
||||
$row = File::permissionFind($pid, $user, 1);
|
||||
$userid = $row->userid;
|
||||
} else {
|
||||
if (File::whereUserid($user->userid)->wherePid(0)->count() >= 300) {
|
||||
return Base::retError('每个文件夹里最多只能创建300个文件或文件夹');
|
||||
throw new ApiException('每个文件夹里最多只能创建300个文件或文件夹');
|
||||
}
|
||||
}
|
||||
//
|
||||
$dirs = explode("/", $webkitRelativePath);
|
||||
$addItem = [];
|
||||
while (count($dirs) > 1) {
|
||||
@ -297,12 +383,10 @@ class File extends AbstractModel
|
||||
'created_id' => $user->userid,
|
||||
]);
|
||||
$dirRow->handleDuplicateName();
|
||||
if ($dirRow->saveBeforePP()) {
|
||||
$addItem[] = File::find($dirRow->id);
|
||||
if (!$dirRow->saveBeforePP()) {
|
||||
throw new ApiException('创建文件夹失败');
|
||||
}
|
||||
}
|
||||
if (empty($dirRow)) {
|
||||
throw new ApiException('创建文件夹失败');
|
||||
$addItem[] = File::find($dirRow->id);
|
||||
}
|
||||
$pid = $dirRow->id;
|
||||
});
|
||||
@ -311,20 +395,24 @@ class File extends AbstractModel
|
||||
}
|
||||
}
|
||||
}
|
||||
//
|
||||
$path = 'uploads/tmp/file/' . date("Ym") . '/';
|
||||
$data = Base::upload([
|
||||
"file" => Request::file('files'),
|
||||
"type" => 'more',
|
||||
"autoThumb" => false,
|
||||
"path" => $path,
|
||||
"quality" => true
|
||||
]);
|
||||
if (Base::isError($data)) {
|
||||
throw new ApiException($data['msg']);
|
||||
}
|
||||
$data = $data['data'];
|
||||
//
|
||||
return [$pid, $userid, $addItem];
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传后置:ext → type 映射 + File 记录创建 + uploadMove + FileContent 入库。
|
||||
*
|
||||
* @param user $user
|
||||
* @param int $userid 目标文件拥有者
|
||||
* @param int $pid
|
||||
* @param array $data Base::upload/uploadFromPath 返回的 data 部分
|
||||
* @param array $addItem prep 阶段累积的中间文件夹
|
||||
* @param string $webkitRelativePath
|
||||
* @param string|null $hash 可选,写入 files.hash(秒传索引)
|
||||
* @param bool $overwrite
|
||||
* @return array{data: array, addItem: array}
|
||||
*/
|
||||
private function contentUploadCommit($user, int $userid, int $pid, array $data, array $addItem, $webkitRelativePath, $hash, bool $overwrite): array
|
||||
{
|
||||
$type = match ($data['ext']) {
|
||||
'text', 'md', 'markdown' => 'document',
|
||||
'drawio' => 'drawio',
|
||||
@ -356,7 +444,6 @@ class File extends AbstractModel
|
||||
if ($data['ext'] == 'markdown') {
|
||||
$data['ext'] = 'md';
|
||||
}
|
||||
$file = null;
|
||||
$params = [
|
||||
'pid' => $pid,
|
||||
'name' => Base::rightDelete($data['name'], '.' . $data['ext']),
|
||||
@ -365,6 +452,7 @@ class File extends AbstractModel
|
||||
'userid' => $userid,
|
||||
'created_id' => $user->userid,
|
||||
];
|
||||
$file = null;
|
||||
if ($overwrite) {
|
||||
$file = self::wherePid($params['pid'])->whereExt($params['ext'])->whereName($params['name'])->first();
|
||||
}
|
||||
@ -373,11 +461,12 @@ class File extends AbstractModel
|
||||
$file = File::createInstance($params);
|
||||
$file->handleDuplicateName();
|
||||
}
|
||||
// 开始创建
|
||||
return AbstractModel::transaction(function () use ($overwrite, $addItem, $webkitRelativePath, $type, $user, $data, $file) {
|
||||
return AbstractModel::transaction(function () use ($overwrite, $addItem, $webkitRelativePath, $type, $user, $data, $file, $hash) {
|
||||
$file->size = $data['size'] * 1024;
|
||||
if ($hash) {
|
||||
$file->hash = $hash;
|
||||
}
|
||||
$file->saveBeforePP();
|
||||
//
|
||||
$data = Base::uploadMove($data, "uploads/file/" . $file->type . "/" . date("Ym") . "/" . $file->id . "/");
|
||||
$content = [
|
||||
'from' => '',
|
||||
@ -389,25 +478,20 @@ class File extends AbstractModel
|
||||
$content['width'] = $data['width'];
|
||||
$content['height'] = $data['height'];
|
||||
}
|
||||
$content = FileContent::createInstance([
|
||||
FileContent::createInstance([
|
||||
'fid' => $file->id,
|
||||
'content' => $content,
|
||||
'text' => '',
|
||||
'size' => $file->size,
|
||||
'userid' => $user->userid,
|
||||
]);
|
||||
$content->save();
|
||||
//
|
||||
])->save();
|
||||
$tmpRow = File::find($file->id);
|
||||
$tmpRow->pushMsg('add', $tmpRow);
|
||||
//
|
||||
$data = File::handleImageUrl($tmpRow->toArray());
|
||||
$data['full_name'] = $webkitRelativePath ?: ($data['name'] . '.' . $data['ext']);
|
||||
$data['overwrite'] = $overwrite ? 1 : 0;
|
||||
//
|
||||
$addItem[] = $data;
|
||||
|
||||
return ['data' => $data, 'addItem' => $addItem];
|
||||
$row = File::handleImageUrl($tmpRow->toArray());
|
||||
$row['full_name'] = $webkitRelativePath ?: ($row['name'] . '.' . $row['ext']);
|
||||
$row['overwrite'] = $overwrite ? 1 : 0;
|
||||
$addItem[] = $row;
|
||||
return ['data' => $row, 'addItem' => $addItem];
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -90,6 +90,24 @@ class ManticoreSyncFailure extends AbstractModel
|
||||
->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量清除同步成功记录(供批量写入路径使用,避免逐条删除)
|
||||
*
|
||||
* @param string $dataType 数据类型
|
||||
* @param array $dataIds 数据ID列表
|
||||
* @param string $action 操作类型
|
||||
*/
|
||||
public static function removeSuccessBatch(string $dataType, array $dataIds, string $action): void
|
||||
{
|
||||
if (empty($dataIds)) {
|
||||
return;
|
||||
}
|
||||
self::where('data_type', $dataType)
|
||||
->whereIn('data_id', $dataIds)
|
||||
->where('action', $action)
|
||||
->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取待重试的记录
|
||||
* 根据重试次数决定间隔:1次=1分钟,2次=5分钟,3次=15分钟,4次+=30分钟
|
||||
|
||||
@ -482,7 +482,7 @@ class ProjectTask extends AbstractModel
|
||||
if ($parent_id == 0) {
|
||||
$priorityList = Setting::normalizeTaskPriorityList(Base::setting('priority'));
|
||||
if ($p_level > 0) {
|
||||
$matched = reset(array_filter($priorityList, fn($item) => intval($item['priority']) === $p_level)) ?: null;
|
||||
$matched = collect($priorityList)->first(fn($item) => intval($item['priority']) === $p_level);
|
||||
} else {
|
||||
$matched = Setting::getDefaultTaskPriorityItem($priorityList);
|
||||
}
|
||||
@ -2246,7 +2246,7 @@ class ProjectTask extends AbstractModel
|
||||
$project = Project::userProject($task->project_id);
|
||||
} catch (\Throwable $e) {
|
||||
if ($task->owner !== null || $task->permission(4)) {
|
||||
$project = Project::find($task->project_id);
|
||||
$project = Project::withTrashed()->find($task->project_id);
|
||||
if (empty($project)) {
|
||||
throw new ApiException('项目不存在或已被删除', [ 'task_id' => $task_id ], -4002);
|
||||
}
|
||||
|
||||
@ -216,10 +216,11 @@ class Setting extends AbstractModel
|
||||
/**
|
||||
* AI 机器人模型转数组
|
||||
* @param $models
|
||||
* @param bool $retValue
|
||||
* @param bool $retValue 仅返回模型 value 列表
|
||||
* @param bool $visibleOnly 仅返回可见模型(跳过标记 hidden 的项,供展示给终端用户的场景)
|
||||
* @return array
|
||||
*/
|
||||
public static function AIBotModels2Array($models, $retValue = false)
|
||||
public static function AIBotModels2Array($models, $retValue = false, $visibleOnly = false)
|
||||
{
|
||||
$list = null;
|
||||
if (is_array($models)) {
|
||||
@ -244,6 +245,10 @@ class Setting extends AbstractModel
|
||||
if ($value === '') {
|
||||
continue;
|
||||
}
|
||||
// 隐藏模型:仍保存在设置中,但展示给终端用户时跳过($visibleOnly)
|
||||
if ($visibleOnly && !empty($item['hidden'])) {
|
||||
continue;
|
||||
}
|
||||
$label = trim((string)($item['name'] ?? $item['label'] ?? ''));
|
||||
$thinking = strtolower(trim((string)($item['thinking'] ?? 'off')));
|
||||
if (!in_array($thinking, ['off', 'low', 'medium', 'high'], true)) {
|
||||
@ -353,18 +358,25 @@ class Setting extends AbstractModel
|
||||
/**
|
||||
* 将存储结构转换成 appstore 接口同款格式
|
||||
* @param array $apps
|
||||
* @param bool $keepVisible 是否保留可见范围(仅管理员编辑场景需要,用于回填)
|
||||
* @return array
|
||||
*/
|
||||
public static function formatCustomMicroAppsForResponse(array $apps)
|
||||
public static function formatCustomMicroAppsForResponse(array $apps, bool $keepVisible = false)
|
||||
{
|
||||
return array_values(array_map(function ($app) {
|
||||
unset($app['visible_to']);
|
||||
return array_values(array_map(function ($app) use ($keepVisible) {
|
||||
if ($keepVisible) {
|
||||
$app['visible_to'] = self::normalizeCustomMicroVisible($app['visible_to'] ?? ['admin']);
|
||||
} else {
|
||||
unset($app['visible_to']);
|
||||
}
|
||||
if (!empty($app['menu_items']) && is_array($app['menu_items'])) {
|
||||
$app['menu_items'] = array_values(array_map(function ($menu) {
|
||||
$menu['keep_alive'] = isset($menu['keep_alive']) ? (bool)$menu['keep_alive'] : true;
|
||||
$menu['disable_scope_css'] = (bool)($menu['disable_scope_css'] ?? false);
|
||||
$menu['auto_dark_theme'] = isset($menu['auto_dark_theme']) ? (bool)$menu['auto_dark_theme'] : true;
|
||||
$menu['transparent'] = (bool)($menu['transparent'] ?? false);
|
||||
$menu['key'] = isset($menu['key']) ? (string)$menu['key'] : '';
|
||||
$menu['badge_clear_on_open'] = (bool)($menu['badge_clear_on_open'] ?? false);
|
||||
if (isset($menu['visible_to'])) {
|
||||
unset($menu['visible_to']);
|
||||
}
|
||||
@ -454,6 +466,9 @@ class Setting extends AbstractModel
|
||||
if (!empty($menu['capsule']) && is_array($menu['capsule'])) {
|
||||
$payload['capsule'] = Base::newTrim($menu['capsule']);
|
||||
}
|
||||
// 角标:菜单稳定标识 与 打开时是否自动清零
|
||||
$payload['key'] = Base::newTrim($menu['key'] ?? '');
|
||||
$payload['badge_clear_on_open'] = (bool)($menu['badge_clear_on_open'] ?? false);
|
||||
return $payload;
|
||||
}
|
||||
|
||||
@ -462,7 +477,7 @@ class Setting extends AbstractModel
|
||||
* @param mixed $value
|
||||
* @return array
|
||||
*/
|
||||
protected static function normalizeCustomMicroVisible($value)
|
||||
public static function normalizeCustomMicroVisible($value)
|
||||
{
|
||||
if (is_array($value)) {
|
||||
$list = array_filter(array_map('trim', $value));
|
||||
@ -485,7 +500,7 @@ class Setting extends AbstractModel
|
||||
* @param int $userId
|
||||
* @return bool
|
||||
*/
|
||||
protected static function isCustomMicroVisibleTo(array $visible, bool $isAdmin, int $userId)
|
||||
public static function isCustomMicroVisibleTo(array $visible, bool $isAdmin, int $userId)
|
||||
{
|
||||
if (in_array('all', $visible)) {
|
||||
return true;
|
||||
|
||||
@ -394,6 +394,10 @@ class User extends AbstractModel
|
||||
}
|
||||
// 密码
|
||||
self::passwordPolicy($password);
|
||||
// license
|
||||
if ($err = Doo::licenseBindingError(Doo::license())) {
|
||||
throw new ApiException($err);
|
||||
}
|
||||
// 开始注册
|
||||
$user = Doo::userCreate($email, $password);
|
||||
if ($other) {
|
||||
|
||||
@ -273,8 +273,11 @@ class UserBot extends AbstractModel
|
||||
if ($match[1] === "ai-") {
|
||||
$aibotSetting = Base::setting('aibotSetting');
|
||||
$aibotModel = $aibotSetting[$match[2] . '_model'];
|
||||
$aibotModels = Setting::AIBotModels2Array($aibotSetting[$match[2] . '_models']);
|
||||
$aibotModels = Setting::AIBotModels2Array($aibotSetting[$match[2] . '_models'], false, true);
|
||||
if ($aibotModels) {
|
||||
if (!in_array($aibotModel, array_column($aibotModels, 'value'), true)) {
|
||||
$aibotModel = $aibotModels[0]['value'];
|
||||
}
|
||||
$menus = array_merge(
|
||||
[
|
||||
[
|
||||
|
||||
@ -561,7 +561,7 @@ class UserDepartment extends AbstractModel
|
||||
'own_project_ids' => [],
|
||||
'own_project_id_map' => [],
|
||||
];
|
||||
if ($ids === null || $ids === '' || Base::settingFind('system', 'department_owner_project_view', 'close') !== 'open') {
|
||||
if ($ids === null || $ids === '' || Base::settingFind('system', 'department_owner_project_view', 'open') !== 'open') {
|
||||
return $empty;
|
||||
}
|
||||
$memberUserids = self::getManagedMemberUserids($user->userid, $ids);
|
||||
@ -653,7 +653,7 @@ class UserDepartment extends AbstractModel
|
||||
return $result;
|
||||
}
|
||||
// 部门负责人只读视角
|
||||
if (Base::settingFind('system', 'department_owner_project_view', 'close') !== 'open') {
|
||||
if (Base::settingFind('system', 'department_owner_project_view', 'open') !== 'open') {
|
||||
return $result;
|
||||
}
|
||||
$memberUserids = self::getManagedMemberUserids($viewer->userid, 'all');
|
||||
|
||||
@ -1100,6 +1100,46 @@ class WebSocketDialog extends AbstractModel
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 与 sendMsgFiles 同链路,但接收已落盘的本地文件(分片合并产物),跳过 Base::upload。
|
||||
*
|
||||
* @param User $user
|
||||
* @param int[] $dialogIds
|
||||
* @param string $localPath 已落盘绝对路径
|
||||
* @param string $originalName 原始文件名
|
||||
* @param int $replyId
|
||||
* @param bool $imageAttachment 任务群组中图片是否也作为附件保存
|
||||
* @return array
|
||||
*/
|
||||
public static function sendMsgFilesFromPath($user, $dialogIds, string $localPath, string $originalName, int $replyId = 0, bool $imageAttachment = false)
|
||||
{
|
||||
$first = null;
|
||||
$fileName = $originalName;
|
||||
$resolve = function ($path) use (&$first, &$fileName, $localPath, $originalName) {
|
||||
if ($first !== null) {
|
||||
return self::copyFileDataTo($first, $path);
|
||||
}
|
||||
$setting = Base::setting("system");
|
||||
$data = Base::uploadFromPath([
|
||||
"path_local" => $localPath,
|
||||
"name" => $originalName,
|
||||
"type" => 'more',
|
||||
"path" => $path,
|
||||
"fileName" => $fileName,
|
||||
"quality" => true,
|
||||
"convertVideo" => $setting['convert_video'] === 'open',
|
||||
"compressVideo" => $setting['compress_video'] === 'open',
|
||||
]);
|
||||
if (Base::isError($data)) {
|
||||
throw new ApiException($data['msg']);
|
||||
}
|
||||
$first = $data['data'];
|
||||
$fileName = $first['name'];
|
||||
return $first;
|
||||
};
|
||||
return self::dispatchFileMessages($user, $dialogIds, $replyId, $imageAttachment, $resolve);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息文件
|
||||
*
|
||||
@ -1114,24 +1154,18 @@ class WebSocketDialog extends AbstractModel
|
||||
*/
|
||||
public static function sendMsgFiles($user, $dialogIds, $files, $image64, $fileName, $replyId, $imageAttachment)
|
||||
{
|
||||
$filePath = '';
|
||||
$result = [];
|
||||
$data = [];
|
||||
foreach ($dialogIds as $dialog_id) {
|
||||
$dialog = WebSocketDialog::checkDialog($dialog_id);
|
||||
|
||||
$action = $replyId > 0 ? "reply-$replyId" : "";
|
||||
$path = "uploads/chat/" . date("Ym") . "/" . $dialog_id . "/";
|
||||
$first = null;
|
||||
$resolve = function ($path) use (&$first, &$fileName, $files, $image64) {
|
||||
if ($first !== null) {
|
||||
return self::copyFileDataTo($first, $path);
|
||||
}
|
||||
if ($image64) {
|
||||
$data = Base::image64save([
|
||||
"image64" => $image64,
|
||||
"path" => $path,
|
||||
"fileName" => $fileName,
|
||||
"quality" => true
|
||||
"quality" => true,
|
||||
]);
|
||||
} else if ($filePath) {
|
||||
Base::makeDir(public_path($path));
|
||||
copy($filePath, public_path($path) . basename($filePath));
|
||||
} else {
|
||||
$setting = Base::setting("system");
|
||||
$data = Base::upload([
|
||||
@ -1147,19 +1181,41 @@ class WebSocketDialog extends AbstractModel
|
||||
if (Base::isError($data)) {
|
||||
throw new ApiException($data['msg']);
|
||||
}
|
||||
$fileData = $data['data'];
|
||||
$filePath = $fileData['file'];
|
||||
$fileName = $fileData['name'];
|
||||
$fileData['thumb'] = Base::unFillUrl($fileData['thumb']);
|
||||
$fileData['size'] *= 1024;
|
||||
$first = $data['data'];
|
||||
$fileName = $first['name'];
|
||||
return $first;
|
||||
};
|
||||
return self::dispatchFileMessages($user, $dialogIds, $replyId, $imageAttachment, $resolve);
|
||||
}
|
||||
|
||||
// 任务群组保存文件
|
||||
/**
|
||||
* 遍历多个 dialog 发送文件消息:每个 dialog 取一份 fileData → 任务群组建附件 → sendMsg。
|
||||
* 取 fileData 的策略由 $resolve(path) 决定(首次 upload,后续 copy)。
|
||||
*
|
||||
* @param User $user
|
||||
* @param int[] $dialogIds
|
||||
* @param int $replyId
|
||||
* @param bool $imageAttachment
|
||||
* @param callable $resolve fn(string $path): array 返回该 dialog 的 fileData
|
||||
* @return array sendMsg 的最终返回(最后一个 dialog 的结果)
|
||||
*/
|
||||
private static function dispatchFileMessages($user, array $dialogIds, int $replyId, bool $imageAttachment, callable $resolve): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($dialogIds as $dialog_id) {
|
||||
$dialog = WebSocketDialog::checkDialog($dialog_id);
|
||||
$action = $replyId > 0 ? "reply-$replyId" : "";
|
||||
$path = "uploads/chat/" . date("Ym") . "/" . $dialog_id . "/";
|
||||
$fileData = $resolve($path);
|
||||
$fileData['thumb'] = Base::unFillUrl($fileData['thumb'] ?? '');
|
||||
$fileData['size'] *= 1024;
|
||||
$task = null;
|
||||
if ($dialog->group_type === 'task') {
|
||||
// 如果是图片不保存
|
||||
// 图片消息默认不作为任务附件存档,除非显式 $imageAttachment
|
||||
if ($imageAttachment || !in_array($fileData['ext'], File::imageExt)) {
|
||||
$task = ProjectTask::whereDialogId($dialog->id)->first();
|
||||
if ($task) {
|
||||
$file = ProjectTaskFile::createInstance([
|
||||
ProjectTaskFile::createInstance([
|
||||
'project_id' => $task->project_id,
|
||||
'task_id' => $task->id,
|
||||
'name' => $fileData['name'],
|
||||
@ -1168,20 +1224,30 @@ class WebSocketDialog extends AbstractModel
|
||||
'path' => $fileData['path'],
|
||||
'thumb' => $fileData['thumb'],
|
||||
'userid' => $user->userid,
|
||||
]);
|
||||
$file->save();
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 发送消息
|
||||
$result = WebSocketDialogMsg::sendMsg($action, $dialog_id, 'file', $fileData, $user->userid);
|
||||
if (Base::isSuccess($result)) {
|
||||
if (isset($task)) {
|
||||
$result['data']['task_id'] = $task->id;
|
||||
}
|
||||
if (Base::isSuccess($result) && $task) {
|
||||
$result['data']['task_id'] = $task->id;
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把首个 dialog 上传得到的物理文件 copy 到后续 dialog 的目录,返回更新后的 fileData。
|
||||
*/
|
||||
private static function copyFileDataTo(array $first, string $path): array
|
||||
{
|
||||
Base::makeDir(public_path($path));
|
||||
$target = public_path($path) . basename($first['file']);
|
||||
copy($first['file'], $target);
|
||||
$copy = $first;
|
||||
$copy['file'] = $target;
|
||||
$copy['path'] = $path . basename($first['file']);
|
||||
$copy['url'] = Base::fillUrl($copy['path']);
|
||||
return $copy;
|
||||
}
|
||||
}
|
||||
|
||||
@ -900,7 +900,84 @@ class AI
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 OpenAI 兼容接口获取文本的 Embedding 向量
|
||||
* 调用 ai 插件的 /embeddings 端点批量向量化(免费向量模型,零配置)。
|
||||
*
|
||||
* 走主程序 ↔ ai 插件的内网调用,共享主程序 APP_KEY 鉴权;
|
||||
* 向量维度由 ai 插件的模型决定,主程序不再传 dimensions。
|
||||
*
|
||||
* @param array $texts 文本数组
|
||||
* @return array retSuccess(data=向量数组的数组,与输入同序,缺失位置为 []) / retError
|
||||
*/
|
||||
protected static function requestPluginEmbeddings(array $texts)
|
||||
{
|
||||
$texts = array_values($texts);
|
||||
$count = count($texts);
|
||||
if ($count === 0) {
|
||||
return Base::retSuccess("success", []);
|
||||
}
|
||||
|
||||
$post = json_encode(["input" => $texts]);
|
||||
$headers = [
|
||||
'Content-Type' => 'application/json',
|
||||
'Authorization' => 'Bearer ' . (string) config('app.key'),
|
||||
];
|
||||
$timeout = $count > 1 ? 120 : 30;
|
||||
|
||||
$res = Ihttp::ihttp_request(self::embeddingsUrl(), $post, $headers, $timeout);
|
||||
if (Base::isError($res)) {
|
||||
return Base::retError("Embedding 接口请求失败", $res);
|
||||
}
|
||||
|
||||
$resData = Base::json2array($res['data']);
|
||||
|
||||
// 先识别端点的业务错误:Ihttp 对 401/500 等带响应体的状态同样返回成功标志,
|
||||
// 必须按响应里的 code 字段判错,否则鉴权错配等故障只会报"格式错误"难以定位
|
||||
$resCode = intval($resData['code'] ?? 0);
|
||||
if ($resCode !== 0 && $resCode !== 200) {
|
||||
return Base::retError("Embedding 接口错误 [{$resCode}]: " . ($resData['error'] ?? 'unknown'), $resData);
|
||||
}
|
||||
|
||||
if (empty($resData['data']) || !is_array($resData['data'])) {
|
||||
return Base::retError("Embedding 接口返回数据格式错误", $resData);
|
||||
}
|
||||
|
||||
// 记录当前实际生效的向量模型(查询缓存键与模型变化检测依赖它)
|
||||
$model = (string) ($resData['model'] ?? '');
|
||||
if ($model !== '' && Cache::get('ai:embedding_model') !== $model) {
|
||||
Cache::forever('ai:embedding_model', $model);
|
||||
}
|
||||
|
||||
// 按 index 回填,保证与输入顺序对齐,缺失位置留 []
|
||||
$vectors = array_fill(0, $count, []);
|
||||
foreach ($resData['data'] as $item) {
|
||||
$idx = $item['index'] ?? null;
|
||||
if ($idx === null || !isset($vectors[$idx])) {
|
||||
continue;
|
||||
}
|
||||
$embedding = $item['embedding'] ?? [];
|
||||
if (is_array($embedding) && !empty($embedding)) {
|
||||
$vectors[$idx] = $embedding;
|
||||
}
|
||||
}
|
||||
|
||||
return Base::retSuccess("success", $vectors);
|
||||
}
|
||||
|
||||
/**
|
||||
* ai 插件 /embeddings 端点地址
|
||||
*
|
||||
* 查询侧(本类)与 Manticore 表定义(ManticoreBase::vectorColumnDDL)共用的唯一来源,
|
||||
* 两侧必须指向同一端点,否则查询向量与存量向量来自不同模型导致语义搜索错乱。
|
||||
*/
|
||||
public static function embeddingsUrl(): string
|
||||
{
|
||||
$host = config('dootask.ai_host', 'ai');
|
||||
$port = (int) config('dootask.ai_port', 5001);
|
||||
return "http://{$host}:{$port}/embeddings";
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 ai 插件的免费向量模型获取文本的 Embedding 向量
|
||||
*
|
||||
* @param string $text 需要转换的文本
|
||||
* @param bool $noCache 是否禁用缓存
|
||||
@ -916,50 +993,24 @@ class AI
|
||||
return Base::retError('文本内容不能为空');
|
||||
}
|
||||
|
||||
// 截断过长的文本(OpenAI 限制 8191 tokens,约 32K 字符)
|
||||
// 截断过长的文本(约 32K 字符)
|
||||
$text = mb_substr($text, 0, 30000);
|
||||
|
||||
$cacheKey = "openAIEmbedding::" . md5($text);
|
||||
// 缓存键带上当前生效的模型标识:切换向量模型后旧查询缓存自动失效,
|
||||
// 避免最长 7 天内用旧模型向量去搜新模型索引(模型未知时为空串)
|
||||
$model = (string) Cache::get('ai:embedding_model', '');
|
||||
$cacheKey = "embeddingV2::" . md5($model . '|' . $text);
|
||||
if ($noCache) {
|
||||
Cache::forget($cacheKey);
|
||||
}
|
||||
|
||||
$provider = self::resolveEmbeddingProvider();
|
||||
if (!$provider) {
|
||||
return Base::retError("请先在「AI 助手」设置中配置支持 Embedding 的 AI 服务");
|
||||
}
|
||||
|
||||
$result = Cache::remember($cacheKey, Carbon::now()->addDays(7), function () use ($text, $provider) {
|
||||
$payload = [
|
||||
"model" => $provider['model'],
|
||||
"input" => $text,
|
||||
];
|
||||
|
||||
// 统一向量维度为 1536(与 Manticore 配置一致)
|
||||
// OpenAI、智谱等支持 dimensions 参数的厂商需要显式指定
|
||||
$supportsDimensions = in_array($provider['vendor'], ['openai', 'zhipu']);
|
||||
if ($supportsDimensions) {
|
||||
$payload['dimensions'] = 1536;
|
||||
}
|
||||
|
||||
$post = json_encode($payload);
|
||||
|
||||
$ai = new self($post);
|
||||
$ai->setProvider($provider);
|
||||
$ai->setUrlPath('/embeddings');
|
||||
$ai->setTimeout(30);
|
||||
|
||||
$res = $ai->request(true);
|
||||
$result = Cache::remember($cacheKey, Carbon::now()->addDays(7), function () use ($text) {
|
||||
$res = self::requestPluginEmbeddings([$text]);
|
||||
if (Base::isError($res)) {
|
||||
return Base::retError("Embedding 请求失败", $res);
|
||||
return $res;
|
||||
}
|
||||
|
||||
$resData = Base::json2array($res['data']);
|
||||
if (empty($resData['data'][0]['embedding'])) {
|
||||
return Base::retError("Embedding 接口返回数据格式错误", $resData);
|
||||
}
|
||||
|
||||
$embedding = $resData['data'][0]['embedding'];
|
||||
$embedding = $res['data'][0] ?? [];
|
||||
if (!is_array($embedding) || empty($embedding)) {
|
||||
return Base::retError("Embedding 向量为空");
|
||||
}
|
||||
@ -973,193 +1024,4 @@ class AI
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量获取文本的 Embedding 向量
|
||||
* OpenAI API 原生支持批量输入,一次请求处理多个文本
|
||||
*
|
||||
* @param array $texts 文本数组(最多 100 条)
|
||||
* @param bool $noCache 是否禁用缓存
|
||||
* @return array 返回结果,成功时 data 为向量数组的数组(与输入顺序对应)
|
||||
*/
|
||||
public static function getBatchEmbeddings(array $texts, $noCache = false)
|
||||
{
|
||||
if (!Apps::isInstalled('ai')) {
|
||||
return Base::retError('应用「AI Assistant」未安装');
|
||||
}
|
||||
|
||||
if (empty($texts)) {
|
||||
return Base::retSuccess("success", []);
|
||||
}
|
||||
|
||||
// 限制批量大小
|
||||
// OpenAI 限制:最多 2048 条,单次请求合计最多 300,000 tokens
|
||||
// 这里限制 500 条,假设平均每条 500 tokens,合计 250,000 tokens
|
||||
$texts = array_slice($texts, 0, 500);
|
||||
|
||||
// 准备结果数组,并检查缓存
|
||||
$results = [];
|
||||
$uncachedTexts = [];
|
||||
$uncachedIndices = [];
|
||||
|
||||
foreach ($texts as $index => $text) {
|
||||
if (empty($text)) {
|
||||
$results[$index] = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
// 截断过长的文本
|
||||
$text = mb_substr($text, 0, 30000);
|
||||
$texts[$index] = $text; // 更新截断后的文本
|
||||
|
||||
$cacheKey = "openAIEmbedding::" . md5($text);
|
||||
|
||||
if ($noCache) {
|
||||
Cache::forget($cacheKey);
|
||||
}
|
||||
|
||||
// 检查缓存
|
||||
if (!$noCache && Cache::has($cacheKey)) {
|
||||
$cached = Cache::get($cacheKey);
|
||||
if (Base::isSuccess($cached)) {
|
||||
$results[$index] = $cached['data'];
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 未命中缓存,加入待请求列表
|
||||
$uncachedTexts[] = $text;
|
||||
$uncachedIndices[] = $index;
|
||||
}
|
||||
|
||||
// 如果所有文本都在缓存中
|
||||
if (empty($uncachedTexts)) {
|
||||
// 按原始顺序返回
|
||||
ksort($results);
|
||||
return Base::retSuccess("success", array_values($results));
|
||||
}
|
||||
|
||||
// 获取 provider
|
||||
$provider = self::resolveEmbeddingProvider();
|
||||
if (!$provider) {
|
||||
return Base::retError("请先在「AI 助手」设置中配置支持 Embedding 的 AI 服务");
|
||||
}
|
||||
|
||||
// 构建批量请求
|
||||
$payload = [
|
||||
"model" => $provider['model'],
|
||||
"input" => $uncachedTexts,
|
||||
];
|
||||
|
||||
$supportsDimensions = in_array($provider['vendor'], ['openai', 'zhipu']);
|
||||
if ($supportsDimensions) {
|
||||
$payload['dimensions'] = 1536;
|
||||
}
|
||||
|
||||
$post = json_encode($payload);
|
||||
|
||||
$ai = new self($post);
|
||||
$ai->setProvider($provider);
|
||||
$ai->setUrlPath('/embeddings');
|
||||
$ai->setTimeout(120); // 批量请求需要更长超时
|
||||
|
||||
$res = $ai->request(true);
|
||||
if (Base::isError($res)) {
|
||||
return Base::retError("批量 Embedding 请求失败", $res);
|
||||
}
|
||||
|
||||
$resData = Base::json2array($res['data']);
|
||||
if (empty($resData['data'])) {
|
||||
return Base::retError("Embedding 接口返回数据格式错误", $resData);
|
||||
}
|
||||
|
||||
// 处理返回的向量并写入缓存
|
||||
foreach ($resData['data'] as $item) {
|
||||
$itemIndex = $item['index'] ?? null;
|
||||
if ($itemIndex === null || !isset($uncachedIndices[$itemIndex])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$originalIndex = $uncachedIndices[$itemIndex];
|
||||
$embedding = $item['embedding'] ?? [];
|
||||
|
||||
if (!empty($embedding) && is_array($embedding)) {
|
||||
$results[$originalIndex] = $embedding;
|
||||
} else {
|
||||
$results[$originalIndex] = [];
|
||||
}
|
||||
}
|
||||
|
||||
// 填充未获取到向量的位置
|
||||
foreach ($uncachedIndices as $originalIndex) {
|
||||
if (!isset($results[$originalIndex])) {
|
||||
$results[$originalIndex] = [];
|
||||
}
|
||||
}
|
||||
|
||||
// 按原始顺序返回
|
||||
ksort($results);
|
||||
return Base::retSuccess("success", array_values($results));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Embedding 模型配置
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
protected static function resolveEmbeddingProvider()
|
||||
{
|
||||
$setting = Base::setting('aibotSetting');
|
||||
if (!is_array($setting)) {
|
||||
$setting = [];
|
||||
}
|
||||
|
||||
// 优先使用 OpenAI(支持 embedding 接口)
|
||||
$key = trim((string)($setting['openai_key'] ?? ''));
|
||||
if ($key !== '') {
|
||||
$baseUrl = trim((string)($setting['openai_base_url'] ?? ''));
|
||||
$baseUrl = $baseUrl ?: 'https://api.openai.com/v1';
|
||||
$agency = trim((string)($setting['openai_agency'] ?? ''));
|
||||
|
||||
return [
|
||||
'vendor' => 'openai',
|
||||
'model' => 'text-embedding-3-small',
|
||||
'api_key' => $key,
|
||||
'base_url' => rtrim($baseUrl, '/'),
|
||||
'agency' => $agency,
|
||||
];
|
||||
}
|
||||
|
||||
$vendorDefaults = [
|
||||
'deepseek' => [
|
||||
'base_url' => 'https://api.deepseek.com',
|
||||
'model' => 'deepseek-embedding',
|
||||
],
|
||||
'zhipu' => [
|
||||
'base_url' => 'https://open.bigmodel.cn/api/paas/v4',
|
||||
'model' => 'embedding-3',
|
||||
],
|
||||
];
|
||||
|
||||
// 尝试其他支持 embedding 的服务(如 deepseek、zhipu、qianwen 等)
|
||||
foreach ($vendorDefaults as $vendor => $defaults) {
|
||||
$key = trim((string)($setting[$vendor . '_key'] ?? ''));
|
||||
|
||||
if ($key !== '') {
|
||||
$baseUrl = trim((string)($setting[$vendor . '_base_url'] ?? ''));
|
||||
$baseUrl = $baseUrl ?: $defaults['base_url']; // 使用配置或默认值
|
||||
$agency = trim((string)($setting[$vendor . '_agency'] ?? ''));
|
||||
|
||||
return [
|
||||
'vendor' => $vendor,
|
||||
'model' => $defaults['model'],
|
||||
'api_key' => $key,
|
||||
'base_url' => rtrim($baseUrl, '/'),
|
||||
'agency' => $agency,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
namespace App\Module;
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use App\Models\UserDepartment;
|
||||
use App\Services\RequestContext;
|
||||
@ -29,14 +30,7 @@ class Apps
|
||||
return (bool) RequestContext::get($key, false);
|
||||
}
|
||||
|
||||
$configFile = base_path('docker/appstore/config/' . $appId . '/config.yml');
|
||||
$installed = false;
|
||||
if (file_exists($configFile)) {
|
||||
$configData = Yaml::parseFile($configFile);
|
||||
$installed = $configData['status'] === 'installed';
|
||||
}
|
||||
|
||||
return RequestContext::save($key, $installed);
|
||||
return RequestContext::save($key, self::loadInstalledConfig($appId) !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -62,6 +56,180 @@ class Apps
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* appstore 目录下的绝对路径(统一 docker/appstore 前缀)。
|
||||
*
|
||||
* @param string $relative 相对 docker/appstore 的路径
|
||||
* @return string
|
||||
*/
|
||||
private static function appstorePath(string $relative): string
|
||||
{
|
||||
return base_path('docker/appstore/' . ltrim($relative, '/'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取并校验某应用的 appstore config.yml;仅当文件存在且 status=installed 时返回解析后的配置数组。
|
||||
*
|
||||
* @param string $appId
|
||||
* @return array|null
|
||||
*/
|
||||
private static function loadInstalledConfig(string $appId): ?array
|
||||
{
|
||||
$appId = trim($appId);
|
||||
if ($appId === '' || $appId === 'appstore') {
|
||||
return null;
|
||||
}
|
||||
$configFile = self::appstorePath("config/{$appId}/config.yml");
|
||||
if (!file_exists($configFile)) {
|
||||
return null;
|
||||
}
|
||||
$config = Yaml::parseFile($configFile);
|
||||
if (!is_array($config) || ($config['status'] ?? '') !== 'installed') {
|
||||
return null;
|
||||
}
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将单个 menu_items 项映射为角标用的菜单配置。
|
||||
*
|
||||
* @param array $menu
|
||||
* @param mixed $visibleDefault 应用级默认可见范围
|
||||
* @return array ['key'=>string,'visible'=>array,'badge_clear_on_open'=>bool]
|
||||
*/
|
||||
private static function mapMenuItem(array $menu, $visibleDefault): array
|
||||
{
|
||||
return [
|
||||
'key' => trim((string)($menu['key'] ?? '')),
|
||||
'visible' => Setting::normalizeCustomMicroVisible($menu['visible_to'] ?? $visibleDefault),
|
||||
'badge_clear_on_open' => (bool)($menu['badge_clear_on_open'] ?? false),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取(必要时生成并持久化)应用的独立密钥 APP_SECRET。
|
||||
*
|
||||
* 与全局 APP_KEY 不同,APP_SECRET 每个已安装应用独立、唯一,持久化在应用自身的
|
||||
* docker/appstore/config/{appid}/config.yml(与其它每应用安装参数同源),
|
||||
* 由 appstore 安装链路按内置 compose 变量 APP_SECRET 注入插件容器。
|
||||
* 此处主程序侧负责生成/持久化与校验;首次需要时若不存在则惰性生成,保证主程序可独立验证。
|
||||
*
|
||||
* @param string $appId 应用ID
|
||||
* @return string 应用密钥;应用未安装或非插件应用时返回空字符串
|
||||
*/
|
||||
public static function appSecret(string $appId): string
|
||||
{
|
||||
$appId = trim($appId);
|
||||
$config = self::loadInstalledConfig($appId);
|
||||
if ($config === null) {
|
||||
return '';
|
||||
}
|
||||
$secret = trim((string)($config['app_secret'] ?? ''));
|
||||
if ($secret !== '') {
|
||||
return $secret;
|
||||
}
|
||||
// 首次需要时生成并持久化(按 appid 唯一)
|
||||
$secret = Base::generatePassword(48);
|
||||
$config['app_secret'] = $secret;
|
||||
try {
|
||||
file_put_contents(self::appstorePath("config/{$appId}/config.yml"), Yaml::dump($config, 4, 2));
|
||||
} catch (\Throwable $e) {
|
||||
info('[app_badge] persist app_secret fail', ['appid' => $appId, 'error' => $e->getMessage()]);
|
||||
return '';
|
||||
}
|
||||
return $secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析应用的菜单角标配置(菜单 key 列表与各自的可见范围)。
|
||||
*
|
||||
* 同时覆盖两类应用:
|
||||
* - 插件应用:读取 docker/appstore/apps/{appid}/{version}/config.yml 的 menu_items
|
||||
* - 自定义微应用:读取 microapp_menu 设置
|
||||
*
|
||||
* @param string $appId 应用ID
|
||||
* @return array|null ['source'=>'plugin'|'custom', 'menus'=>[['key'=>string,'visible'=>array,'badge_clear_on_open'=>bool], ...]];应用不存在返回 null
|
||||
*/
|
||||
public static function appMenuConfig(string $appId): ?array
|
||||
{
|
||||
$appId = trim($appId);
|
||||
if ($appId === '') {
|
||||
return null;
|
||||
}
|
||||
// 插件应用
|
||||
$config = self::loadInstalledConfig($appId);
|
||||
if ($config !== null) {
|
||||
$version = trim((string)($config['install_version'] ?? ''));
|
||||
return [
|
||||
'source' => 'plugin',
|
||||
'menus' => self::readPluginMenus($appId, $version),
|
||||
];
|
||||
}
|
||||
// 自定义微应用
|
||||
$apps = Base::setting('microapp_menu');
|
||||
if (is_array($apps)) {
|
||||
foreach ($apps as $app) {
|
||||
if (!is_array($app) || trim((string)($app['id'] ?? '')) !== $appId) {
|
||||
continue;
|
||||
}
|
||||
$appVisibleDefault = $app['visible_to'] ?? 'admin';
|
||||
$menus = [];
|
||||
foreach (($app['menu_items'] ?? []) as $menu) {
|
||||
if (!is_array($menu)) {
|
||||
continue;
|
||||
}
|
||||
$menus[] = self::mapMenuItem($menu, $appVisibleDefault);
|
||||
}
|
||||
return ['source' => 'custom', 'menus' => $menus];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取插件包 config.yml 的菜单配置。
|
||||
*
|
||||
* @param string $appId
|
||||
* @param string $version 已安装版本
|
||||
* @return array
|
||||
*/
|
||||
private static function readPluginMenus(string $appId, string $version): array
|
||||
{
|
||||
$paths = [];
|
||||
if ($version !== '') {
|
||||
$paths[] = self::appstorePath("apps/{$appId}/{$version}/config.yml");
|
||||
}
|
||||
$paths[] = self::appstorePath("apps/{$appId}/config.yml");
|
||||
$pkg = null;
|
||||
foreach ($paths as $p) {
|
||||
if (file_exists($p) && is_readable($p)) {
|
||||
try {
|
||||
$pkg = Yaml::parseFile($p);
|
||||
} catch (\Throwable $e) {
|
||||
$pkg = null;
|
||||
}
|
||||
if (is_array($pkg)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
$menus = [];
|
||||
if (is_array($pkg) && !empty($pkg['menu_items']) && is_array($pkg['menu_items'])) {
|
||||
$appVisibleDefault = $pkg['visible_to'] ?? 'all';
|
||||
foreach ($pkg['menu_items'] as $menu) {
|
||||
if (!is_array($menu)) {
|
||||
continue;
|
||||
}
|
||||
$menus[] = self::mapMenuItem($menu, $appVisibleDefault);
|
||||
}
|
||||
}
|
||||
if (empty($menus)) {
|
||||
// 读不到包配置(权限/缺失)时退化为单一默认菜单,仍可对第一个菜单设角标
|
||||
$menus[] = ['key' => '', 'visible' => ['all'], 'badge_clear_on_open' => false];
|
||||
}
|
||||
return $menus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch user lifecycle hook to appstore (user_onboard/user_offboard/user_update).
|
||||
*
|
||||
|
||||
329
app/Module/Badge.php
Normal file
329
app/Module/Badge.php
Normal file
@ -0,0 +1,329 @@
|
||||
<?php
|
||||
|
||||
namespace App\Module;
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Models\AppBadge;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use App\Tasks\PushTask;
|
||||
|
||||
/**
|
||||
* 插件 / 微应用菜单角标业务编排。
|
||||
*
|
||||
* 角标真值归插件(应用密钥写入),主程序仅作为存储与分发:
|
||||
* - 绝对设置/清除 app_badges 行(仅存非清除态)
|
||||
* - 通过 WebSocket(PushTask)向在线用户实时推送 appBadge 消息
|
||||
* - 提供初始同步所需的用户角标快照
|
||||
*/
|
||||
class Badge
|
||||
{
|
||||
/**
|
||||
* 设置角标(应用密钥鉴权场景):校验密钥/菜单/可见性,绝对设置并实时推送。
|
||||
*
|
||||
* @param string $appId
|
||||
* @param string $secret 请求携带的应用密钥
|
||||
* @param mixed $userid 目标用户ID(单个或数组)
|
||||
* @param string $menuKeyInput 请求中的 menu_key(可空,留空取第一个菜单)
|
||||
* @param mixed $count 角标数字
|
||||
* @param mixed $dot 是否红点
|
||||
* @return array 响应数据
|
||||
* @throws ApiException 参数/密钥/应用/菜单校验失败
|
||||
*/
|
||||
public static function set(string $appId, string $secret, $userid, string $menuKeyInput, $count, $dot): array
|
||||
{
|
||||
if ($appId === '') {
|
||||
throw new ApiException('参数错误');
|
||||
}
|
||||
if ($secret === '') {
|
||||
throw new ApiException('密钥无效');
|
||||
}
|
||||
$expect = Apps::appSecret($appId);
|
||||
if ($expect === '' || !hash_equals($expect, $secret)) {
|
||||
throw new ApiException('密钥无效');
|
||||
}
|
||||
$menu = self::resolveAppMenu($appId, $menuKeyInput);
|
||||
$menuKey = (string)($menu['key'] ?? '');
|
||||
$userids = self::normalizeUserids($userid);
|
||||
if (empty($userids)) {
|
||||
throw new ApiException('参数错误');
|
||||
}
|
||||
// 仅保留对该应用菜单有可见权限的用户
|
||||
$userids = self::filterVisibleUserids($menu, $userids);
|
||||
if (empty($userids)) {
|
||||
return ['appid' => $appId, 'menu_key' => $menuKey, 'affected' => 0];
|
||||
}
|
||||
$count = max(0, intval($count));
|
||||
$dot = filter_var($dot, FILTER_VALIDATE_BOOLEAN);
|
||||
self::applySet($appId, $menuKey, $userids, $count, $dot);
|
||||
self::push($appId, $menuKey, $userids, $count, $dot);
|
||||
return [
|
||||
'appid' => $appId,
|
||||
'menu_key' => $menuKey,
|
||||
'count' => $count,
|
||||
'dot' => $dot,
|
||||
'affected' => count($userids),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除指定用户在某应用某菜单的角标(用户 token 鉴权场景),并推送多端一致。
|
||||
*
|
||||
* @param int $userid 当前用户ID
|
||||
* @param string $appId
|
||||
* @param string $menuKeyInput 请求中的 menu_key(可空)
|
||||
* @return array 响应数据
|
||||
* @throws ApiException 参数/应用/菜单校验失败
|
||||
*/
|
||||
public static function clearForUser(int $userid, string $appId, string $menuKeyInput): array
|
||||
{
|
||||
if ($userid <= 0) {
|
||||
throw new ApiException('参数错误');
|
||||
}
|
||||
if ($appId === '') {
|
||||
throw new ApiException('参数错误');
|
||||
}
|
||||
$menu = self::resolveAppMenu($appId, $menuKeyInput);
|
||||
$menuKey = (string)($menu['key'] ?? '');
|
||||
AppBadge::whereAppId($appId)->whereMenuKey($menuKey)->whereUserid($userid)->delete();
|
||||
// 推送给该用户的所有在线端,保证多端一致
|
||||
self::push($appId, $menuKey, [$userid], 0, false);
|
||||
return [
|
||||
'appid' => $appId,
|
||||
'menu_key' => $menuKey,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析应用菜单配置并定位目标菜单。
|
||||
*
|
||||
* @param string $appId
|
||||
* @param string $menuKeyInput
|
||||
* @return array 命中的菜单配置
|
||||
* @throws ApiException 应用未安装或菜单不存在
|
||||
*/
|
||||
private static function resolveAppMenu(string $appId, string $menuKeyInput): array
|
||||
{
|
||||
$config = Apps::appMenuConfig($appId);
|
||||
if ($config === null) {
|
||||
throw new ApiException('应用未安装');
|
||||
}
|
||||
$menu = self::resolveMenu($config['menus'], $menuKeyInput);
|
||||
if ($menu === null) {
|
||||
throw new ApiException('菜单不存在');
|
||||
}
|
||||
return $menu;
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化目标用户ID:单值/数组 -> 去重去零的整型数组。
|
||||
*
|
||||
* @param mixed $userid
|
||||
* @return int[]
|
||||
*/
|
||||
private static function normalizeUserids($userid): array
|
||||
{
|
||||
if (is_string($userid) || is_numeric($userid)) {
|
||||
$userid = [$userid];
|
||||
}
|
||||
if (!is_array($userid)) {
|
||||
return [];
|
||||
}
|
||||
return self::intIds($userid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 数组 -> 去重去零的整型数组。
|
||||
*
|
||||
* @param array $ids
|
||||
* @return int[]
|
||||
*/
|
||||
private static function intIds(array $ids): array
|
||||
{
|
||||
return array_values(array_unique(array_filter(array_map('intval', $ids), fn($v) => $v > 0)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析目标菜单:menu_key 为空时取第一个菜单;否则必须命中已声明的菜单 key。
|
||||
*
|
||||
* @param array $menus appMenuConfig 返回的 menus
|
||||
* @param string $menuKey 请求中的 menu_key(可空)
|
||||
* @return array|null 命中的菜单配置;非法 menu_key 返回 null
|
||||
*/
|
||||
private static function resolveMenu(array $menus, string $menuKey): ?array
|
||||
{
|
||||
if ($menuKey === '') {
|
||||
return $menus[0] ?? ['key' => '', 'visible' => ['all'], 'badge_clear_on_open' => false];
|
||||
}
|
||||
foreach ($menus as $menu) {
|
||||
if (($menu['key'] ?? '') === $menuKey) {
|
||||
return $menu;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按菜单可见范围过滤目标用户,仅保留对该应用菜单有权限的用户。
|
||||
*
|
||||
* @param array $menu 命中的菜单配置(含 visible)
|
||||
* @param int[] $userids
|
||||
* @return int[] 允许的用户ID
|
||||
*/
|
||||
private static function filterVisibleUserids(array $menu, array $userids): array
|
||||
{
|
||||
if (empty($userids)) {
|
||||
return [];
|
||||
}
|
||||
$visible = $menu['visible'] ?? ['all'];
|
||||
if (in_array('all', $visible)) {
|
||||
return $userids;
|
||||
}
|
||||
$allowed = [];
|
||||
$users = User::whereIn('userid', $userids)->get(['userid', 'identity']);
|
||||
foreach ($users as $user) {
|
||||
if (Setting::isCustomMicroVisibleTo($visible, $user->isAdmin(), (int)$user->userid)) {
|
||||
$allowed[] = (int)$user->userid;
|
||||
}
|
||||
}
|
||||
return $allowed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 绝对设置角标(幂等)。count=0 且 dot=false 即清除(删行)。
|
||||
*
|
||||
* @param string $appId
|
||||
* @param string $menuKey
|
||||
* @param int[] $userids
|
||||
* @param int $count
|
||||
* @param bool $dot
|
||||
* @return void
|
||||
*/
|
||||
private static function applySet(string $appId, string $menuKey, array $userids, int $count, bool $dot): void
|
||||
{
|
||||
if (empty($userids)) {
|
||||
return;
|
||||
}
|
||||
// 清除态:一条 whereIn 删除
|
||||
if ($count === 0 && !$dot) {
|
||||
AppBadge::whereAppId($appId)->whereMenuKey($menuKey)->whereIn('userid', $userids)->delete();
|
||||
return;
|
||||
}
|
||||
// 非清除态:依赖唯一键 (app_id,menu_key,userid) 批量 upsert
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$rows = array_map(fn($uid) => [
|
||||
'app_id' => $appId,
|
||||
'menu_key' => $menuKey,
|
||||
'userid' => (int)$uid,
|
||||
'count' => $count,
|
||||
'dot' => $dot,
|
||||
'updated_at' => $now,
|
||||
], $userids);
|
||||
AppBadge::upsert($rows, ['app_id', 'menu_key', 'userid'], ['count', 'dot', 'updated_at']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除某应用的全部角标(应用卸载时)。
|
||||
*
|
||||
* @param string $appId
|
||||
* @return void
|
||||
*/
|
||||
public static function clearByApp(string $appId): void
|
||||
{
|
||||
$appId = trim($appId);
|
||||
if ($appId === '') {
|
||||
return;
|
||||
}
|
||||
AppBadge::whereAppId($appId)->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除某用户的全部角标(用户离职时)。
|
||||
*
|
||||
* @param int $userid
|
||||
* @return void
|
||||
*/
|
||||
public static function clearByUser(int $userid): void
|
||||
{
|
||||
if ($userid <= 0) {
|
||||
return;
|
||||
}
|
||||
AppBadge::whereUserid($userid)->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户当前全部角标快照,用于前端初始同步。
|
||||
* 过滤掉应用已不存在(卸载 / 自定义微应用被删除)的行,避免父级聚合统计残留数据。
|
||||
*
|
||||
* @param int $userid
|
||||
* @return array app_id => menu_key => ['count'=>int,'dot'=>bool]
|
||||
*/
|
||||
public static function userBadges(int $userid): array
|
||||
{
|
||||
$map = [];
|
||||
if ($userid <= 0) {
|
||||
return $map;
|
||||
}
|
||||
$rows = AppBadge::whereUserid($userid)->get(['app_id', 'menu_key', 'count', 'dot']);
|
||||
if ($rows->isEmpty()) {
|
||||
return $map;
|
||||
}
|
||||
// 自定义微应用 id 集合一次性收,避免每行重复 foreach
|
||||
$customIds = [];
|
||||
$customApps = Base::setting('microapp_menu');
|
||||
if (is_array($customApps)) {
|
||||
foreach ($customApps as $app) {
|
||||
if (is_array($app) && !empty($app['id'])) {
|
||||
$customIds[(string)$app['id']] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 按 app_id 缓存判定结果:插件应用走 Apps::isInstalled(单 yaml + 请求级缓存),
|
||||
// 自定义微应用查 set,避免每行都读 yaml / 遍历 setting。
|
||||
$exists = [];
|
||||
foreach ($rows as $row) {
|
||||
$appId = (string)$row->app_id;
|
||||
if (!isset($exists[$appId])) {
|
||||
$exists[$appId] = isset($customIds[$appId]) || Apps::isInstalled($appId);
|
||||
}
|
||||
if (!$exists[$appId]) {
|
||||
continue;
|
||||
}
|
||||
$map[$appId][$row->menu_key] = [
|
||||
'count' => (int)$row->count,
|
||||
'dot' => (bool)$row->dot,
|
||||
];
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* 向在线用户实时推送角标变更(仅投递,不补发离线)。
|
||||
*
|
||||
* @param string $appId
|
||||
* @param string $menuKey
|
||||
* @param int[] $userids
|
||||
* @param int $count
|
||||
* @param bool $dot
|
||||
* @return void
|
||||
*/
|
||||
public static function push(string $appId, string $menuKey, array $userids, int $count, bool $dot): void
|
||||
{
|
||||
$userids = self::intIds($userids);
|
||||
if (empty($userids)) {
|
||||
return;
|
||||
}
|
||||
PushTask::push([
|
||||
'userid' => $userids,
|
||||
'msg' => [
|
||||
'type' => 'appBadge',
|
||||
'data' => [
|
||||
'appid' => $appId,
|
||||
'menu_key' => $menuKey,
|
||||
'count' => $count,
|
||||
'dot' => $dot,
|
||||
],
|
||||
],
|
||||
], false);
|
||||
}
|
||||
}
|
||||
@ -2074,8 +2074,20 @@ class Base
|
||||
*/
|
||||
public static function upload($param)
|
||||
{
|
||||
// 可选 key 默认值,下游直接访问不会 undefined index
|
||||
$param += [
|
||||
'chmod' => 0644,
|
||||
'saveName' => null,
|
||||
'scale' => null,
|
||||
'size' => 0,
|
||||
'fileName' => null,
|
||||
'quality' => null,
|
||||
'autoThumb' => null,
|
||||
'convertVideo' => null,
|
||||
'compressVideo' => null,
|
||||
];
|
||||
$file = $param['file'];
|
||||
$chmod = $param['chmod'] ?: 0644;
|
||||
$chmod = $param['chmod'];
|
||||
if (empty($file)) {
|
||||
return Base::retError("您没有选择要上传的文件");
|
||||
}
|
||||
@ -2130,6 +2142,9 @@ class Base
|
||||
$limitSize = intval($param['size']);
|
||||
if ($limitSize <= 0) {
|
||||
$fileUploadLimit = intval(Base::settingFind('system', 'file_upload_limit', 0));
|
||||
if ($fileUploadLimit <= 0) {
|
||||
$fileUploadLimit = 1024;
|
||||
}
|
||||
$limitSize = $fileUploadLimit * 1024;
|
||||
}
|
||||
try {
|
||||
@ -2317,6 +2332,27 @@ class Base
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把本地文件包装成 UploadedFile(test=true) 转给 Base::upload,复用全套上传逻辑。
|
||||
* @param array $param path_local + name 为本方法特有,其余与 Base::upload 一致
|
||||
*/
|
||||
public static function uploadFromPath(array $param)
|
||||
{
|
||||
$localPath = $param['path_local'] ?? '';
|
||||
$name = $param['name'] ?? '';
|
||||
if (!$localPath || !is_file($localPath)) {
|
||||
return Base::retError('源文件不存在');
|
||||
}
|
||||
if (!$name) {
|
||||
$name = basename($localPath);
|
||||
}
|
||||
unset($param['path_local'], $param['name']);
|
||||
// test=true → UploadedFile::move 走 rename(),绕开 move_uploaded_file 的 is_uploaded_file 校验
|
||||
$param['file'] = new \Illuminate\Http\UploadedFile($localPath, $name, null, null, true);
|
||||
$param['fileName'] = $param['fileName'] ?? $name;
|
||||
return self::upload($param);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件移动
|
||||
* @param array $uploadResult
|
||||
|
||||
511
app/Module/ChunkUpload.php
Normal file
511
app/Module/ChunkUpload.php
Normal file
@ -0,0 +1,511 @@
|
||||
<?php
|
||||
|
||||
namespace App\Module;
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Models\File as FileModel;
|
||||
use App\Models\FileContent;
|
||||
use App\Models\User;
|
||||
use App\Models\WebSocketDialog;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
/**
|
||||
* 分片上传核心:状态机与磁盘/Redis 调度。
|
||||
*
|
||||
* 流程:start → receive × N → merge → (scene dispatcher) → cleanup
|
||||
*
|
||||
* Redis key:
|
||||
* upload:{upload_id} → JSON 元数据 (TTL 24h)
|
||||
* upload:{upload_id}:chunks → SET 已收分片 index (TTL 24h)
|
||||
* upload:hash:{userid}:{hash} → upload_id 反查(续传 / 同 hash 复用) (TTL 24h)
|
||||
*
|
||||
* 磁盘:
|
||||
* uploads/tmp/chunks/{userid}/{upload_id}/{index}
|
||||
*/
|
||||
class ChunkUpload
|
||||
{
|
||||
/** 单个分片大小(5MB)。注意:要小于 Swoole package_max_length 1G */
|
||||
const CHUNK_SIZE = 5 * 1024 * 1024;
|
||||
|
||||
/** 状态/反查索引 TTL(秒):24h */
|
||||
const STATE_TTL = 86400;
|
||||
|
||||
/** 单文件硬上限(KB):系统设置之外的兜底保护,10G */
|
||||
const MAX_FILE_KB = 10 * 1024 * 1024;
|
||||
|
||||
/** 支持的 scene 枚举 */
|
||||
const SCENES = ['file_cabinet', 'dialog_file', 'image', 'generic_file'];
|
||||
|
||||
/**
|
||||
* 启动上传。
|
||||
* - 同用户同 hash 命中 files 表 → 秒传
|
||||
* - 同用户同 hash 命中 upload 反查 → 续传
|
||||
* - 否则新建 upload_id
|
||||
*
|
||||
* @param User $user
|
||||
* @param array $param [hash, size(B), name, scene, scene_params(array)]
|
||||
* @return array
|
||||
*/
|
||||
public static function start(User $user, array $param): array
|
||||
{
|
||||
$hash = strtolower(trim($param['hash'] ?? ''));
|
||||
$size = intval($param['size'] ?? 0);
|
||||
$name = trim($param['name'] ?? '');
|
||||
$scene = trim($param['scene'] ?? '');
|
||||
$sceneParams = $param['scene_params'] ?? [];
|
||||
if (!is_array($sceneParams)) {
|
||||
$sceneParams = [];
|
||||
}
|
||||
|
||||
if (strlen($hash) !== 32) {
|
||||
return Base::retError('文件 hash 格式错误');
|
||||
}
|
||||
if ($size <= 0) {
|
||||
return Base::retError('文件大小无效');
|
||||
}
|
||||
if (intval(ceil($size / 1024)) > self::MAX_FILE_KB) {
|
||||
return Base::retError('文件超过系统支持的最大尺寸');
|
||||
}
|
||||
// init 时拦截系统配置上限,避免传完分片才在 merge 阶段被 Base::upload 拒绝
|
||||
$fileUploadLimit = intval(Base::settingFind('system', 'file_upload_limit', 0));
|
||||
if ($fileUploadLimit <= 0) {
|
||||
$fileUploadLimit = 1024;
|
||||
}
|
||||
if ($size > $fileUploadLimit * 1024 * 1024) {
|
||||
return Base::retError('文件大小超限,最大限制:' . $fileUploadLimit . 'MB');
|
||||
}
|
||||
if ($name === '') {
|
||||
return Base::retError('文件名不能为空');
|
||||
}
|
||||
if (!in_array($scene, self::SCENES, true)) {
|
||||
return Base::retError('不支持的上传场景');
|
||||
}
|
||||
|
||||
// 1) 秒传:同用户已上传过同 hash 文件 → 直接复用入库
|
||||
$hit = self::trySecondPass($user, $scene, $hash, $name, $sceneParams);
|
||||
if ($hit !== null) {
|
||||
return Base::retSuccess('success', $hit);
|
||||
}
|
||||
|
||||
// 2) 续传:同用户同 hash 有未完成上传
|
||||
$reuseKey = self::keyHashIndex($user->userid, $hash);
|
||||
$existingId = Redis::get($reuseKey);
|
||||
if ($existingId) {
|
||||
$meta = self::loadMeta($existingId);
|
||||
if ($meta && $meta['userid'] === $user->userid && $meta['hash'] === $hash) {
|
||||
return Base::retSuccess('success', self::sessionView($existingId, $meta));
|
||||
}
|
||||
// 反查指向了已失效的 upload_id,清掉
|
||||
Redis::del($reuseKey);
|
||||
}
|
||||
|
||||
// 3) 新建
|
||||
$uploadId = Base::generatePassword(32);
|
||||
$chunkCount = intval(ceil($size / self::CHUNK_SIZE));
|
||||
$meta = [
|
||||
'hash' => $hash,
|
||||
'size' => $size,
|
||||
'name' => $name,
|
||||
'scene' => $scene,
|
||||
'scene_params' => $sceneParams,
|
||||
'userid' => intval($user->userid),
|
||||
'chunk_size' => self::CHUNK_SIZE,
|
||||
'chunk_count' => $chunkCount,
|
||||
'created_at' => time(),
|
||||
];
|
||||
Redis::setex(self::keyMeta($uploadId), self::STATE_TTL, json_encode($meta, JSON_UNESCAPED_UNICODE));
|
||||
Redis::setex($reuseKey, self::STATE_TTL, $uploadId);
|
||||
Base::makeDir(self::chunkDir($user->userid, $uploadId));
|
||||
|
||||
return Base::retSuccess('success', self::sessionView($uploadId, $meta));
|
||||
}
|
||||
|
||||
/**
|
||||
* 接收一个分片。
|
||||
*
|
||||
* @param User $user
|
||||
* @param string $uploadId
|
||||
* @param int $index 分片序号(0-based)
|
||||
* @param UploadedFile|null $blob
|
||||
* @return array
|
||||
*/
|
||||
public static function receive(User $user, string $uploadId, int $index, $blob): array
|
||||
{
|
||||
$meta = self::loadMeta($uploadId);
|
||||
if (!$meta) {
|
||||
return Base::retError('上传会话不存在或已过期');
|
||||
}
|
||||
if ($meta['userid'] !== intval($user->userid)) {
|
||||
return Base::retError('上传会话归属错误');
|
||||
}
|
||||
if ($index < 0 || $index >= $meta['chunk_count']) {
|
||||
return Base::retError('分片序号超出范围');
|
||||
}
|
||||
if (!$blob || !$blob->isValid()) {
|
||||
return Base::retError('分片数据无效');
|
||||
}
|
||||
// 最后一片可能小于 CHUNK_SIZE,其余必须等于
|
||||
$isLast = $index === $meta['chunk_count'] - 1;
|
||||
$chunkSize = $blob->getSize();
|
||||
if (!$isLast && $chunkSize !== self::CHUNK_SIZE) {
|
||||
return Base::retError('分片大小不符合预期');
|
||||
}
|
||||
if ($isLast) {
|
||||
$expectLast = $meta['size'] - self::CHUNK_SIZE * ($meta['chunk_count'] - 1);
|
||||
if ($chunkSize !== $expectLast) {
|
||||
return Base::retError('末尾分片大小不符合预期');
|
||||
}
|
||||
}
|
||||
$dir = self::chunkDir($user->userid, $uploadId);
|
||||
Base::makeDir($dir);
|
||||
$blob->move($dir, (string)$index);
|
||||
// 记录已收 + 续期三个相关 key
|
||||
Redis::sadd(self::keyChunks($uploadId), $index);
|
||||
Redis::expire(self::keyChunks($uploadId), self::STATE_TTL);
|
||||
Redis::expire(self::keyMeta($uploadId), self::STATE_TTL);
|
||||
Redis::expire(self::keyHashIndex($user->userid, $meta['hash']), self::STATE_TTL);
|
||||
|
||||
return Base::retSuccess('success', [
|
||||
'upload_id' => $uploadId,
|
||||
'received' => self::receivedList($uploadId),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并分片并入库。需要在 Lock 内调用。
|
||||
*
|
||||
* @param User $user
|
||||
* @param string $uploadId
|
||||
* @return array scene 入库返回结构(与 retSuccess/retError 对齐)
|
||||
*/
|
||||
public static function merge(User $user, string $uploadId): array
|
||||
{
|
||||
$meta = self::loadMeta($uploadId);
|
||||
if (!$meta) {
|
||||
return Base::retError('上传会话不存在或已过期');
|
||||
}
|
||||
if ($meta['userid'] !== intval($user->userid)) {
|
||||
return Base::retError('上传会话归属错误');
|
||||
}
|
||||
$received = self::receivedList($uploadId);
|
||||
if (count($received) !== $meta['chunk_count']) {
|
||||
return Base::retError('分片不完整,无法合并');
|
||||
}
|
||||
|
||||
return Lock::withLock("upload:merge:{$uploadId}", function () use ($user, $uploadId, $meta) {
|
||||
$dir = self::chunkDir($user->userid, $uploadId);
|
||||
$mergedPath = $dir . '/merged.' . substr($meta['hash'], 0, 8);
|
||||
$writeFp = @fopen($mergedPath, 'wb');
|
||||
if (!$writeFp) {
|
||||
return Base::retError('无法创建合并文件');
|
||||
}
|
||||
// 拼接与 md5 同步进行:一遍磁盘读完成"写文件 + 算 hash"
|
||||
$hashCtx = hash_init('md5');
|
||||
try {
|
||||
for ($i = 0; $i < $meta['chunk_count']; $i++) {
|
||||
$partPath = $dir . '/' . $i;
|
||||
$readFp = @fopen($partPath, 'rb');
|
||||
if (!$readFp) {
|
||||
return Base::retError("分片读取失败:{$i}");
|
||||
}
|
||||
while (!feof($readFp)) {
|
||||
$buf = fread($readFp, 1024 * 1024);
|
||||
if ($buf === false) {
|
||||
fclose($readFp);
|
||||
return Base::retError("分片读取失败:{$i}");
|
||||
}
|
||||
fwrite($writeFp, $buf);
|
||||
hash_update($hashCtx, $buf);
|
||||
}
|
||||
fclose($readFp);
|
||||
}
|
||||
} finally {
|
||||
fclose($writeFp);
|
||||
}
|
||||
$actualHash = hash_final($hashCtx);
|
||||
if ($actualHash !== $meta['hash']) {
|
||||
@unlink($mergedPath);
|
||||
return Base::retError('文件校验失败,请重试');
|
||||
}
|
||||
|
||||
// 调用 scene 入库
|
||||
$result = self::dispatch($user, $meta, $mergedPath);
|
||||
|
||||
// 清理(无论成功失败都清,失败用户重新启 upload)
|
||||
self::cleanup($user->userid, $uploadId, $meta['hash']);
|
||||
|
||||
return $result;
|
||||
}, 60000, 60000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户主动取消:校验归属后清理。会话不存在或归属错误一律静默成功,前端取消按钮不需要分支处理。
|
||||
*/
|
||||
public static function cancelByUser(User $user, string $uploadId): void
|
||||
{
|
||||
$meta = self::loadMeta($uploadId);
|
||||
if (!$meta || intval($meta['userid'] ?? 0) !== $user->userid) {
|
||||
return;
|
||||
}
|
||||
self::cleanup($user->userid, $uploadId, $meta['hash'] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理一个 upload_id 的所有状态。
|
||||
*/
|
||||
public static function cleanup(int $userid, string $uploadId, string $hash = ''): void
|
||||
{
|
||||
Redis::del(self::keyMeta($uploadId));
|
||||
Redis::del(self::keyChunks($uploadId));
|
||||
if ($hash) {
|
||||
Redis::del(self::keyHashIndex($userid, $hash));
|
||||
}
|
||||
$dir = self::chunkDir($userid, $uploadId);
|
||||
if (is_dir($dir)) {
|
||||
Base::deleteDirAndFile($dir);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== scene dispatcher =====
|
||||
|
||||
/**
|
||||
* 把合并后的本地文件交给对应 scene 入库。
|
||||
* 返回结构对齐各 scene 老接口的 retSuccess。
|
||||
*/
|
||||
protected static function dispatch(User $user, array $meta, string $mergedPath): array
|
||||
{
|
||||
$scene = $meta['scene'];
|
||||
$name = $meta['name'];
|
||||
$hash = $meta['hash'];
|
||||
$params = $meta['scene_params'] ?? [];
|
||||
|
||||
switch ($scene) {
|
||||
case 'file_cabinet':
|
||||
$pid = intval($params['pid'] ?? 0);
|
||||
$webkitRelativePath = strval($params['webkit_relative_path'] ?? $name);
|
||||
$overwrite = boolval($params['overwrite'] ?? false);
|
||||
// pid 锁避免与并发上传的 handleDuplicateName / 中间目录创建竞态
|
||||
try {
|
||||
return Lock::withLock("file:upload:{$user->userid}:{$pid}", function () use ($user, $pid, $mergedPath, $name, $webkitRelativePath, $hash, $overwrite) {
|
||||
$result = (new FileModel)->contentUploadFromPath($user, $pid, $mergedPath, $name, $webkitRelativePath, $hash, $overwrite);
|
||||
$outName = $result['data']['name'] ?? $name;
|
||||
return Base::retSuccess($outName . ' 上传成功', $result['addItem']);
|
||||
}, 120000, 120000);
|
||||
} catch (ApiException $e) {
|
||||
return Base::retError($e->getMessage());
|
||||
} catch (\Exception $e) {
|
||||
if (str_contains($e->getMessage(), 'Failed to acquire lock')) {
|
||||
return Base::retError('上传繁忙,请稍后再试');
|
||||
}
|
||||
return Base::retError($e->getMessage());
|
||||
}
|
||||
|
||||
case 'image':
|
||||
// 头像 / 系统图片 / 编辑器粘贴图片,对齐 system/imgupload
|
||||
$width = intval($params['width'] ?? 0);
|
||||
$height = intval($params['height'] ?? 0);
|
||||
$whcut = strval($params['whcut'] ?? 'percentage');
|
||||
$whcut = match ($whcut) {
|
||||
'1' => 'cover',
|
||||
'0' => 'contain',
|
||||
'cover', 'contain' => $whcut,
|
||||
default => 'percentage',
|
||||
};
|
||||
$scale = [$width ?: 2160, $height ?: 4160, $whcut];
|
||||
$imagePath = "uploads/user/picture/" . $user->userid . "/" . date("Ym") . "/";
|
||||
$data = Base::uploadFromPath([
|
||||
"path_local" => $mergedPath,
|
||||
"name" => $name,
|
||||
"type" => 'image',
|
||||
"path" => $imagePath,
|
||||
"scale" => $scale,
|
||||
"quality" => true,
|
||||
]);
|
||||
if (Base::isError($data)) {
|
||||
return $data;
|
||||
}
|
||||
return Base::retSuccess('success', $data['data']);
|
||||
|
||||
case 'generic_file':
|
||||
// 编辑器粘贴文件 / 系统通用文件,对齐 system/fileupload
|
||||
$filePath = "uploads/user/file/" . $user->userid . "/" . date("Ym") . "/";
|
||||
$data = Base::uploadFromPath([
|
||||
"path_local" => $mergedPath,
|
||||
"name" => $name,
|
||||
"type" => 'file',
|
||||
"path" => $filePath,
|
||||
"quality" => true,
|
||||
]);
|
||||
return $data;
|
||||
|
||||
case 'dialog_file':
|
||||
// 聊天发文件 + 任务附件共用同一接入(任务附件本质是任务对话流的一条消息)
|
||||
$dialogIds = $params['dialog_ids'] ?? [];
|
||||
if (!is_array($dialogIds)) {
|
||||
$dialogIds = [$dialogIds];
|
||||
}
|
||||
$dialogIds = array_values(array_filter(array_map('intval', $dialogIds)));
|
||||
if (empty($dialogIds)) {
|
||||
return Base::retError('dialog_ids 不能为空');
|
||||
}
|
||||
$replyId = intval($params['reply_id'] ?? 0);
|
||||
$imageAttachment = boolval($params['image_attachment'] ?? false);
|
||||
try {
|
||||
return WebSocketDialog::sendMsgFilesFromPath($user, $dialogIds, $mergedPath, $name, $replyId, $imageAttachment);
|
||||
} catch (ApiException $e) {
|
||||
return Base::retError($e->getMessage());
|
||||
}
|
||||
|
||||
default:
|
||||
return Base::retError("scene 暂未实现:{$scene}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同 hash 命中则在目标位置复用源 FileContent 指向的物理文件,零字节传输。
|
||||
* 未命中返回 null 让上层走真上传。
|
||||
*/
|
||||
protected static function trySecondPass(User $user, string $scene, string $hash, string $name, array $sceneParams): ?array
|
||||
{
|
||||
if ($scene !== 'file_cabinet') {
|
||||
return null;
|
||||
}
|
||||
$hit = FileModel::whereUserid($user->userid)->whereHash($hash)->whereNull('deleted_at')->first();
|
||||
if (!$hit) {
|
||||
return null;
|
||||
}
|
||||
$srcContent = FileContent::whereFid($hit->id)->orderByDesc('id')->first();
|
||||
if (!$srcContent) {
|
||||
return null;
|
||||
}
|
||||
$contentArr = is_array($srcContent->content)
|
||||
? $srcContent->content
|
||||
: json_decode($srcContent->content, true);
|
||||
if (empty($contentArr['url'])) {
|
||||
return null;
|
||||
}
|
||||
$rawPid = intval($sceneParams['pid'] ?? 0);
|
||||
$webkitRelativePath = strval($sceneParams['webkit_relative_path'] ?? $name);
|
||||
$overwrite = boolval($sceneParams['overwrite'] ?? false);
|
||||
|
||||
try {
|
||||
return Lock::withLock("file:upload:{$user->userid}:{$rawPid}", function () use ($user, $rawPid, $webkitRelativePath, $overwrite, $hit, $hash, $name, $contentArr) {
|
||||
[$pid, $userid, $addItem] = (new FileModel)->contentUploadPrep($user, $rawPid, $webkitRelativePath);
|
||||
|
||||
$ext = $hit->ext;
|
||||
$bareName = Base::rightDelete($name, '.' . $ext);
|
||||
$existing = null;
|
||||
if ($overwrite) {
|
||||
$existing = FileModel::wherePid($pid)->whereName($bareName)->whereExt($ext)->whereNull('deleted_at')->first();
|
||||
}
|
||||
if ($existing) {
|
||||
$existing->size = $hit->size;
|
||||
$existing->hash = $hash;
|
||||
$existing->type = $hit->type;
|
||||
if (!$existing->saveBeforePP()) {
|
||||
throw new ApiException('秒传保存失败');
|
||||
}
|
||||
FileContent::createInstance([
|
||||
'fid' => $existing->id,
|
||||
'content' => $contentArr,
|
||||
'text' => '',
|
||||
'size' => $existing->size,
|
||||
'userid' => $user->userid,
|
||||
])->save();
|
||||
$created = FileModel::find($existing->id);
|
||||
$overwriteFlag = 1;
|
||||
} else {
|
||||
$newFile = FileModel::createInstance([
|
||||
'pid' => $pid,
|
||||
'name' => $bareName,
|
||||
'type' => $hit->type,
|
||||
'ext' => $ext,
|
||||
'size' => $hit->size,
|
||||
'hash' => $hash,
|
||||
'userid' => $userid,
|
||||
'created_id' => $user->userid,
|
||||
]);
|
||||
$newFile->handleDuplicateName();
|
||||
if (!$newFile->saveBeforePP()) {
|
||||
throw new ApiException('秒传保存失败');
|
||||
}
|
||||
FileContent::createInstance([
|
||||
'fid' => $newFile->id,
|
||||
'content' => $contentArr,
|
||||
'text' => '',
|
||||
'size' => $newFile->size,
|
||||
'userid' => $user->userid,
|
||||
])->save();
|
||||
$created = FileModel::find($newFile->id);
|
||||
$overwriteFlag = 0;
|
||||
}
|
||||
$created->pushMsg($overwriteFlag ? 'update' : 'add', $created);
|
||||
$data = FileModel::handleImageUrl($created->toArray());
|
||||
$data['full_name'] = $name;
|
||||
$data['overwrite'] = $overwriteFlag;
|
||||
$addItem[] = $data;
|
||||
return [
|
||||
'done' => true,
|
||||
'instant' => true,
|
||||
'addItem' => $addItem,
|
||||
'msg' => $name . ' 秒传成功',
|
||||
];
|
||||
}, 120000, 120000);
|
||||
} catch (\Throwable $_e) {
|
||||
// 退化到真上传:错误由 dispatch 阶段权威报出,避免两条路径错误码不一致
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== helpers =====
|
||||
|
||||
protected static function keyMeta(string $uploadId): string
|
||||
{
|
||||
return "upload:{$uploadId}";
|
||||
}
|
||||
|
||||
protected static function keyChunks(string $uploadId): string
|
||||
{
|
||||
return "upload:{$uploadId}:chunks";
|
||||
}
|
||||
|
||||
protected static function keyHashIndex(int $userid, string $hash): string
|
||||
{
|
||||
return "upload:hash:{$userid}:{$hash}";
|
||||
}
|
||||
|
||||
protected static function chunkDir(int $userid, string $uploadId): string
|
||||
{
|
||||
return public_path("uploads/tmp/chunks/{$userid}/{$uploadId}");
|
||||
}
|
||||
|
||||
protected static function loadMeta(string $uploadId): ?array
|
||||
{
|
||||
$raw = Redis::get(self::keyMeta($uploadId));
|
||||
if (!$raw) {
|
||||
return null;
|
||||
}
|
||||
$data = json_decode($raw, true);
|
||||
return is_array($data) ? $data : null;
|
||||
}
|
||||
|
||||
protected static function receivedList(string $uploadId): array
|
||||
{
|
||||
$list = Redis::smembers(self::keyChunks($uploadId)) ?: [];
|
||||
$list = array_map('intval', $list);
|
||||
sort($list);
|
||||
return $list;
|
||||
}
|
||||
|
||||
protected static function sessionView(string $uploadId, array $meta): array
|
||||
{
|
||||
return [
|
||||
'done' => false,
|
||||
'upload_id' => $uploadId,
|
||||
'chunk_size' => $meta['chunk_size'],
|
||||
'chunk_count' => $meta['chunk_count'],
|
||||
'received' => self::receivedList($uploadId),
|
||||
];
|
||||
}
|
||||
}
|
||||
538
app/Module/DashboardTeam.php
Normal file
538
app/Module/DashboardTeam.php
Normal file
@ -0,0 +1,538 @@
|
||||
<?php
|
||||
|
||||
namespace App\Module;
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Models\ProjectUser;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use App\Models\UserDepartment;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 仪表盘负责人视角数据聚合。
|
||||
*
|
||||
* 口径:所选管理部门(含下级部门)的在职成员所参与、且允许负责人查看的未归档项目;
|
||||
* 仅统计主任务、全员可见任务,任务需由范围内成员负责或当前没有负责人。
|
||||
*/
|
||||
class DashboardTeam
|
||||
{
|
||||
public const SOON_DAYS = 3;
|
||||
public const HIGH_PRIORITY_COUNT = 2;
|
||||
public const STATS_CACHE_SECONDS = 30;
|
||||
|
||||
/**
|
||||
* 解析并校验负责人视角范围。
|
||||
*/
|
||||
public static function context(User $user, $selectedDepartmentIds = null): array
|
||||
{
|
||||
if (Base::settingFind('system', 'department_owner_project_view', 'open') !== 'open') {
|
||||
throw new ApiException('未开启部门负责人视角功能');
|
||||
}
|
||||
|
||||
$managedIds = UserDepartment::getManagedDepartments($user->userid)
|
||||
->pluck('id')
|
||||
->map(fn($id) => intval($id))
|
||||
->values()
|
||||
->toArray();
|
||||
if (empty($managedIds)) {
|
||||
throw new ApiException('没有可查看的部门数据');
|
||||
}
|
||||
|
||||
$selectedIds = self::normalizeSelectedDepartmentIds($selectedDepartmentIds, $managedIds);
|
||||
if (empty($selectedIds)) {
|
||||
throw new ApiException('没有可查看的部门数据');
|
||||
}
|
||||
|
||||
$departmentIds = self::expandDepartmentIds($selectedIds);
|
||||
$members = self::loadMembers($departmentIds);
|
||||
$memberUserids = $members->pluck('userid')->map(fn($id) => intval($id))->values()->toArray();
|
||||
$projectIds = self::loadProjectIds($memberUserids);
|
||||
$ownProjectIds = empty($projectIds) ? [] : ProjectUser::whereUserid($user->userid)
|
||||
->whereIn('project_id', $projectIds)
|
||||
->pluck('project_id')
|
||||
->map(fn($id) => intval($id))
|
||||
->unique()
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
return [
|
||||
'viewer_userid' => intval($user->userid),
|
||||
'selected_department_ids' => $selectedIds,
|
||||
'department_ids' => $departmentIds,
|
||||
'member_userids' => $memberUserids,
|
||||
'member_map' => $members->keyBy('userid')->map(fn(User $member) => [
|
||||
'userid' => intval($member->userid),
|
||||
'nickname' => $member->nickname,
|
||||
'userimg' => $member->userimg,
|
||||
])->toArray(),
|
||||
'project_ids' => $projectIds,
|
||||
'own_project_id_map' => array_fill_keys($ownProjectIds, true),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 团队统计。
|
||||
*/
|
||||
public static function stats(array $context, bool $refresh = false): array
|
||||
{
|
||||
$cacheKey = 'dashboard:team:stats:v3:' . $context['viewer_userid'] . ':' . sha1(json_encode([
|
||||
$context['selected_department_ids'],
|
||||
$context['department_ids'],
|
||||
$context['member_userids'],
|
||||
$context['project_ids'],
|
||||
]));
|
||||
if ($refresh) {
|
||||
Cache::forget($cacheKey);
|
||||
}
|
||||
|
||||
return Cache::remember($cacheKey, now()->addSeconds(self::STATS_CACHE_SECONDS), function () use ($context) {
|
||||
$now = Carbon::now();
|
||||
$taskAlias = DB::getTablePrefix() . 't';
|
||||
$soonEnd = $now->clone()->addDays(self::SOON_DAYS)->endOfDay();
|
||||
$weekStart = $now->clone()->startOfWeek(Carbon::MONDAY)->startOfDay();
|
||||
$nextWeekStart = $weekStart->clone()->addWeek();
|
||||
$lastWeekStart = $weekStart->clone()->subWeek();
|
||||
$base = self::baseTaskBuilder($context);
|
||||
|
||||
$core = (clone $base)->selectRaw("
|
||||
SUM(CASE WHEN {$taskAlias}.complete_at IS NULL THEN 1 ELSE 0 END) AS stat_uncompleted,
|
||||
SUM(CASE WHEN {$taskAlias}.complete_at IS NULL AND {$taskAlias}.end_at IS NOT NULL AND {$taskAlias}.end_at < ? THEN 1 ELSE 0 END) AS stat_overdue,
|
||||
SUM(CASE WHEN {$taskAlias}.complete_at IS NULL AND {$taskAlias}.end_at IS NOT NULL AND {$taskAlias}.end_at >= ? AND {$taskAlias}.end_at <= ? THEN 1 ELSE 0 END) AS stat_due_soon,
|
||||
SUM(CASE WHEN {$taskAlias}.complete_at >= ? AND {$taskAlias}.complete_at < ? THEN 1 ELSE 0 END) AS stat_week_completed,
|
||||
SUM(CASE WHEN {$taskAlias}.complete_at >= ? AND {$taskAlias}.complete_at < ? THEN 1 ELSE 0 END) AS stat_last_week_completed
|
||||
", [
|
||||
$now->toDateTimeString(),
|
||||
$now->toDateTimeString(),
|
||||
$soonEnd->toDateTimeString(),
|
||||
$weekStart->toDateTimeString(),
|
||||
$nextWeekStart->toDateTimeString(),
|
||||
$lastWeekStart->toDateTimeString(),
|
||||
$weekStart->toDateTimeString(),
|
||||
])->first();
|
||||
|
||||
$noOwner = (clone $base)
|
||||
->whereNull('t.complete_at')
|
||||
->whereNotExists(self::ownerExistsQuery('t.id'))
|
||||
->count();
|
||||
$members = self::memberDistribution($context, $now);
|
||||
|
||||
return [
|
||||
'generated_at' => $now->toDateTimeString(),
|
||||
'member_count' => count($context['member_userids']),
|
||||
'blocks' => [
|
||||
'uncompleted' => intval($core->stat_uncompleted ?? 0),
|
||||
'overdue' => intval($core->stat_overdue ?? 0),
|
||||
'overdue_owner_count' => count(array_filter($members, fn($member) => $member['overdue'] > 0)),
|
||||
'due_soon' => intval($core->stat_due_soon ?? 0),
|
||||
'week_completed' => intval($core->stat_week_completed ?? 0),
|
||||
'last_week_completed' => intval($core->stat_last_week_completed ?? 0),
|
||||
'no_owner' => intval($noOwner),
|
||||
],
|
||||
'priority' => self::priorityDistribution($context),
|
||||
'members' => $members,
|
||||
'high_levels' => self::highPriorityLevels(),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页任务列表。
|
||||
*/
|
||||
public static function tasks(array $context, array $filters)
|
||||
{
|
||||
$now = Carbon::now();
|
||||
$soonEnd = $now->clone()->addDays(self::SOON_DAYS)->endOfDay();
|
||||
$memberId = intval($filters['member_id'] ?? 0);
|
||||
$level = $filters['level'] ?? null;
|
||||
$type = $filters['type'] ?? '';
|
||||
|
||||
$builder = self::baseTaskBuilder($context)->whereNull('t.complete_at');
|
||||
if ($memberId > 0) {
|
||||
$builder->whereExists(self::ownerExistsQuery('t.id', [$memberId]));
|
||||
} elseif ($level !== null) {
|
||||
if (intval($level) === -1) {
|
||||
$levels = self::priorityLevels();
|
||||
if (!empty($levels)) {
|
||||
$builder->whereNotIn('t.p_level', $levels);
|
||||
}
|
||||
} else {
|
||||
$builder->where('t.p_level', intval($level));
|
||||
}
|
||||
} else {
|
||||
switch ($type) {
|
||||
case 'overdue':
|
||||
$builder->whereNotNull('t.end_at')->where('t.end_at', '<', $now);
|
||||
break;
|
||||
|
||||
case 'soon':
|
||||
$builder->whereNotNull('t.end_at')->whereBetween('t.end_at', [$now, $soonEnd]);
|
||||
break;
|
||||
|
||||
case 'hi':
|
||||
$levels = self::highPriorityLevels();
|
||||
$builder->whereIn('t.p_level', $levels ?: [-1]);
|
||||
break;
|
||||
|
||||
case 'noowner':
|
||||
$builder->whereNotExists(self::ownerExistsQuery('t.id'));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$taskAlias = DB::getTablePrefix() . 't';
|
||||
$builder->leftJoin('project_flow_items as fi', 'fi.id', '=', 't.flow_item_id')
|
||||
->select([
|
||||
't.id',
|
||||
't.parent_id',
|
||||
't.project_id',
|
||||
't.column_id',
|
||||
't.name',
|
||||
't.end_at',
|
||||
't.p_level',
|
||||
't.p_name',
|
||||
't.p_color',
|
||||
't.flow_item_id',
|
||||
't.flow_item_name',
|
||||
'p.name as project_name',
|
||||
'fi.status as flow_item_status',
|
||||
'fi.color as flow_item_color',
|
||||
])
|
||||
->orderByRaw("{$taskAlias}.end_at IS NULL")
|
||||
->orderBy('t.end_at')
|
||||
->orderByDesc('t.id');
|
||||
|
||||
$list = $builder->paginate(Base::getPaginate(50, 20));
|
||||
$taskIds = $list->getCollection()->pluck('id')->map(fn($id) => intval($id))->toArray();
|
||||
$owners = self::taskOwners($taskIds, $context['member_userids'], $memberId);
|
||||
$ownProjectMap = $context['own_project_id_map'];
|
||||
|
||||
$list->setCollection($list->getCollection()->map(function ($task) use ($owners, $ownProjectMap) {
|
||||
$item = (array)$task;
|
||||
$item['id'] = intval($item['id']);
|
||||
$item['parent_id'] = intval($item['parent_id']);
|
||||
$item['project_id'] = intval($item['project_id']);
|
||||
$item['column_id'] = intval($item['column_id']);
|
||||
$item['p_level'] = intval($item['p_level']);
|
||||
$flowParts = explode('|', $item['flow_item_name'] ?: '');
|
||||
if (count($flowParts) >= 2) {
|
||||
$item['flow_item_status'] = $item['flow_item_status'] ?: ($flowParts[0] ?? '');
|
||||
$item['flow_item_name'] = $flowParts[1] ?? $item['flow_item_name'];
|
||||
$item['flow_item_color'] = $item['flow_item_color'] ?: ($flowParts[2] ?? '');
|
||||
}
|
||||
$item['owners'] = $owners[$item['id']] ?? [];
|
||||
$item['owner'] = $item['owners'][0] ?? null;
|
||||
$item['department_readonly'] = !isset($ownProjectMap[$item['project_id']]);
|
||||
return $item;
|
||||
}));
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前系统优先级ID列表(保持设置顺序)。
|
||||
* @return array<int>
|
||||
*/
|
||||
public static function priorityLevels(): array
|
||||
{
|
||||
return array_map(fn($item) => intval($item['priority']), self::priorityList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 基础任务范围:主任务、全员可见、未归档项目,且由团队成员负责或没有负责人。
|
||||
*/
|
||||
protected static function baseTaskBuilder(array $context): Builder
|
||||
{
|
||||
$projectIds = $context['project_ids'] ?: [0];
|
||||
$memberUserids = $context['member_userids'];
|
||||
|
||||
return DB::table('project_tasks as t')
|
||||
->join('projects as p', 'p.id', '=', 't.project_id')
|
||||
->whereIn('t.project_id', $projectIds)
|
||||
->where('t.parent_id', 0)
|
||||
->where('t.visibility', 1)
|
||||
->whereNull('t.archived_at')
|
||||
->whereNull('t.deleted_at')
|
||||
->whereNull('p.archived_at')
|
||||
->whereNull('p.deleted_at')
|
||||
->where(function (Builder $query) {
|
||||
$query->where('p.department_owner_view', '<>', 'close')
|
||||
->orWhereNull('p.department_owner_view');
|
||||
})
|
||||
->where(function (Builder $query) use ($memberUserids) {
|
||||
if (empty($memberUserids)) {
|
||||
$query->whereRaw('1 = 0');
|
||||
return;
|
||||
}
|
||||
$query->whereExists(self::ownerExistsQuery('t.id', $memberUserids))
|
||||
->orWhereNotExists(self::ownerExistsQuery('t.id'));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 负责人存在子查询。
|
||||
*/
|
||||
protected static function ownerExistsQuery(string $taskColumn, ?array $userids = null): callable
|
||||
{
|
||||
return function (Builder $query) use ($taskColumn, $userids) {
|
||||
$query->selectRaw('1')
|
||||
->from('project_task_users as owner_scope')
|
||||
->whereColumn('owner_scope.task_id', $taskColumn)
|
||||
->where('owner_scope.owner', 1);
|
||||
if ($userids !== null) {
|
||||
$query->whereIn('owner_scope.userid', $userids ?: [0]);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 优先级分布(包含无负责人任务)。
|
||||
*/
|
||||
protected static function priorityDistribution(array $context): array
|
||||
{
|
||||
$taskAlias = DB::getTablePrefix() . 't';
|
||||
$rows = self::baseTaskBuilder($context)
|
||||
->whereNull('t.complete_at')
|
||||
->selectRaw("{$taskAlias}.p_level, COUNT(*) AS stat_num")
|
||||
->groupBy('t.p_level')
|
||||
->pluck('stat_num', 't.p_level');
|
||||
|
||||
$result = [];
|
||||
$matched = [];
|
||||
foreach (self::priorityList() as $item) {
|
||||
$level = intval($item['priority']);
|
||||
$matched[] = $level;
|
||||
$result[] = [
|
||||
'level' => $level,
|
||||
'name' => $item['name'],
|
||||
'color' => $item['color'],
|
||||
'num' => intval($rows->get($level, 0)),
|
||||
];
|
||||
}
|
||||
|
||||
$unset = 0;
|
||||
foreach ($rows as $level => $num) {
|
||||
if (!in_array(intval($level), $matched, true)) {
|
||||
$unset += intval($num);
|
||||
}
|
||||
}
|
||||
if ($unset > 0) {
|
||||
$result[] = [
|
||||
'level' => -1,
|
||||
'name' => '',
|
||||
'color' => '#c5c8ce',
|
||||
'num' => $unset,
|
||||
];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 成员任务分配。流程阶段与超期风险独立统计,无工作流的普通未完成任务归入待处理;
|
||||
* 多负责人任务会分别计入每个负责人的工作量,团队总数仍按任务去重。
|
||||
*/
|
||||
protected static function memberDistribution(array $context, Carbon $now): array
|
||||
{
|
||||
$memberUserids = $context['member_userids'];
|
||||
if (empty($memberUserids) || empty($context['project_ids'])) {
|
||||
return array_values(array_map(fn($member) => array_merge($member, [
|
||||
'total' => 0,
|
||||
'overdue' => 0,
|
||||
'segments' => ['progress' => 0, 'start' => 0, 'test' => 0],
|
||||
]), $context['member_map']));
|
||||
}
|
||||
|
||||
$nowText = $now->toDateTimeString();
|
||||
$prefix = DB::getTablePrefix();
|
||||
$taskAlias = $prefix . 't';
|
||||
$taskUserAlias = $prefix . 'tu';
|
||||
$flowAlias = $prefix . 'fi';
|
||||
$rows = DB::table('project_task_users as tu')
|
||||
->join('project_tasks as t', 't.id', '=', 'tu.task_id')
|
||||
->join('projects as p', 'p.id', '=', 't.project_id')
|
||||
->leftJoin('project_flow_items as fi', 'fi.id', '=', 't.flow_item_id')
|
||||
->whereIn('tu.userid', $memberUserids)
|
||||
->where('tu.owner', 1)
|
||||
->whereIn('t.project_id', $context['project_ids'])
|
||||
->where('t.parent_id', 0)
|
||||
->where('t.visibility', 1)
|
||||
->whereNull('t.complete_at')
|
||||
->whereNull('t.archived_at')
|
||||
->whereNull('t.deleted_at')
|
||||
->whereNull('p.archived_at')
|
||||
->whereNull('p.deleted_at')
|
||||
->where(function (Builder $query) {
|
||||
$query->where('p.department_owner_view', '<>', 'close')
|
||||
->orWhereNull('p.department_owner_view');
|
||||
})
|
||||
->selectRaw("
|
||||
{$taskUserAlias}.userid,
|
||||
COUNT(DISTINCT {$taskAlias}.id) AS stat_total,
|
||||
COUNT(DISTINCT CASE WHEN {$taskAlias}.end_at IS NOT NULL AND {$taskAlias}.end_at < ? THEN {$taskAlias}.id END) AS stat_overdue,
|
||||
COUNT(DISTINCT CASE WHEN {$flowAlias}.status = 'progress' THEN {$taskAlias}.id END) AS stat_progress,
|
||||
COUNT(DISTINCT CASE WHEN {$flowAlias}.status = 'test' THEN {$taskAlias}.id END) AS stat_test,
|
||||
COUNT(DISTINCT CASE WHEN {$flowAlias}.status IS NULL OR {$flowAlias}.status NOT IN ('progress', 'test') THEN {$taskAlias}.id END) AS stat_start
|
||||
", [$nowText])
|
||||
->groupBy('tu.userid')
|
||||
->get()
|
||||
->keyBy(fn($row) => intval($row->userid));
|
||||
|
||||
$members = [];
|
||||
foreach ($context['member_map'] as $userid => $member) {
|
||||
$row = $rows->get(intval($userid));
|
||||
$members[] = array_merge($member, [
|
||||
'total' => intval($row->stat_total ?? 0),
|
||||
'overdue' => intval($row->stat_overdue ?? 0),
|
||||
'segments' => [
|
||||
'progress' => intval($row->stat_progress ?? 0),
|
||||
'start' => intval($row->stat_start ?? 0),
|
||||
'test' => intval($row->stat_test ?? 0),
|
||||
],
|
||||
]);
|
||||
}
|
||||
usort($members, function ($a, $b) {
|
||||
return $b['overdue'] <=> $a['overdue']
|
||||
?: $b['total'] <=> $a['total']
|
||||
?: $a['userid'] <=> $b['userid'];
|
||||
});
|
||||
return $members;
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务负责人数据,范围内成员优先展示;成员筛选时将该成员置首。
|
||||
*/
|
||||
protected static function taskOwners(array $taskIds, array $memberUserids, int $preferredUserid = 0): array
|
||||
{
|
||||
if (empty($taskIds)) {
|
||||
return [];
|
||||
}
|
||||
$rows = DB::table('project_task_users')
|
||||
->whereIn('task_id', $taskIds)
|
||||
->where('owner', 1)
|
||||
->orderBy('id')
|
||||
->get(['task_id', 'userid']);
|
||||
$userids = $rows->pluck('userid')->map(fn($id) => intval($id))->unique()->values()->toArray();
|
||||
$users = empty($userids) ? collect() : User::select(User::$basicField)
|
||||
->whereIn('userid', $userids)
|
||||
->get()
|
||||
->keyBy('userid');
|
||||
$memberMap = array_fill_keys($memberUserids, true);
|
||||
$owners = [];
|
||||
foreach ($rows as $row) {
|
||||
$userid = intval($row->userid);
|
||||
$user = $users->get($userid);
|
||||
$owners[intval($row->task_id)][] = [
|
||||
'userid' => $userid,
|
||||
'nickname' => $user?->nickname ?? '',
|
||||
];
|
||||
}
|
||||
foreach ($owners as &$list) {
|
||||
usort($list, function ($a, $b) use ($memberMap, $preferredUserid) {
|
||||
if ($preferredUserid > 0) {
|
||||
$preferred = ($b['userid'] === $preferredUserid) <=> ($a['userid'] === $preferredUserid);
|
||||
if ($preferred !== 0) {
|
||||
return $preferred;
|
||||
}
|
||||
}
|
||||
$managed = isset($memberMap[$b['userid']]) <=> isset($memberMap[$a['userid']]);
|
||||
return $managed !== 0 ? $managed : $a['userid'] <=> $b['userid'];
|
||||
});
|
||||
}
|
||||
unset($list);
|
||||
return $owners;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在全部部门中一次性展开下级部门,避免递归 N+1。
|
||||
*/
|
||||
protected static function expandDepartmentIds(array $selectedIds): array
|
||||
{
|
||||
$children = UserDepartment::select(['id', 'parent_id'])
|
||||
->get()
|
||||
->groupBy(fn(UserDepartment $department) => intval($department->parent_id));
|
||||
$result = [];
|
||||
$queue = array_values(array_unique(array_map('intval', $selectedIds)));
|
||||
while (!empty($queue)) {
|
||||
$departmentId = array_shift($queue);
|
||||
if ($departmentId <= 0 || isset($result[$departmentId])) {
|
||||
continue;
|
||||
}
|
||||
$result[$departmentId] = true;
|
||||
foreach ($children->get($departmentId, collect()) as $child) {
|
||||
$queue[] = intval($child->id);
|
||||
}
|
||||
}
|
||||
return array_keys($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前范围内的在职非机器人成员。users.department 为现有权限口径的权威数据源。
|
||||
*/
|
||||
protected static function loadMembers(array $departmentIds): Collection
|
||||
{
|
||||
if (empty($departmentIds)) {
|
||||
return collect();
|
||||
}
|
||||
return User::select(User::$basicField)
|
||||
->whereNull('disable_at')
|
||||
->where('bot', 0)
|
||||
->where(function ($query) use ($departmentIds) {
|
||||
foreach ($departmentIds as $departmentId) {
|
||||
$query->orWhere('department', 'like', "%,{$departmentId},%");
|
||||
}
|
||||
})
|
||||
->orderBy('userid')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 成员参与且允许负责人查看的活动项目。
|
||||
*/
|
||||
protected static function loadProjectIds(array $memberUserids): array
|
||||
{
|
||||
if (empty($memberUserids)) {
|
||||
return [];
|
||||
}
|
||||
return DB::table('project_users as pu')
|
||||
->join('projects as p', 'p.id', '=', 'pu.project_id')
|
||||
->whereIn('pu.userid', $memberUserids)
|
||||
->whereNull('p.archived_at')
|
||||
->whereNull('p.deleted_at')
|
||||
->where(function (Builder $query) {
|
||||
$query->where('p.department_owner_view', '<>', 'close')
|
||||
->orWhereNull('p.department_owner_view');
|
||||
})
|
||||
->distinct()
|
||||
->orderBy('p.id')
|
||||
->pluck('p.id')
|
||||
->map(fn($id) => intval($id))
|
||||
->toArray();
|
||||
}
|
||||
|
||||
protected static function normalizeSelectedDepartmentIds($selectedIds, array $managedIds): array
|
||||
{
|
||||
if ($selectedIds === null || $selectedIds === '' || $selectedIds === 'all' || $selectedIds === []) {
|
||||
return $managedIds;
|
||||
}
|
||||
if (!is_array($selectedIds)) {
|
||||
$selectedIds = explode(',', (string)$selectedIds);
|
||||
}
|
||||
return array_values(array_unique(array_intersect(
|
||||
array_map('intval', $selectedIds),
|
||||
$managedIds
|
||||
)));
|
||||
}
|
||||
|
||||
protected static function highPriorityLevels(): array
|
||||
{
|
||||
return array_slice(self::priorityLevels(), 0, self::HIGH_PRIORITY_COUNT);
|
||||
}
|
||||
|
||||
protected static function priorityList(): array
|
||||
{
|
||||
return Setting::normalizeTaskPriorityList(Base::setting('priority'));
|
||||
}
|
||||
}
|
||||
@ -81,6 +81,39 @@ class Doo
|
||||
self::load()->licenseSave($license);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析License取字段
|
||||
* @param $license
|
||||
* @return array
|
||||
*/
|
||||
public static function licenseDecode($license): array
|
||||
{
|
||||
return self::load()->licenseDecode($license);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 license 的 SN/MAC 是否与本机匹配。通过返回 null,否则返回错误文案。
|
||||
* @param array $info license 信息,含 people/sn/mac(mac 兼容数组或逗号串)
|
||||
* @return string|null
|
||||
*/
|
||||
public static function licenseBindingError(array $info): ?string
|
||||
{
|
||||
$people = (int)($info['people'] ?? 0);
|
||||
if (!($people === 0 || $people > 3)) {
|
||||
return null;
|
||||
}
|
||||
if ((string)($info['sn'] ?? '') !== self::dooSN()) {
|
||||
return '终端SN与License不匹配';
|
||||
}
|
||||
$mac = $info['mac'] ?? [];
|
||||
$licenseMacs = array_filter(array_map('trim', is_array($mac) ? $mac : explode(',', (string)$mac)));
|
||||
$curMacs = self::macs();
|
||||
if ($licenseMacs && $curMacs && !array_intersect($licenseMacs, $curMacs)) {
|
||||
return '终端MAC与License不匹配';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前会员ID(来自请求的token)
|
||||
* @return int
|
||||
|
||||
@ -196,13 +196,14 @@ class Ihttp
|
||||
$rlt['status'] = $matches[3];
|
||||
$rlt['responseline'] = $split2[0];
|
||||
$header = explode("\r\n", $split2[1]);
|
||||
$rlt['headers'] = [];
|
||||
$isgzip = false;
|
||||
$ischunk = false;
|
||||
foreach ($header as $v) {
|
||||
$row = explode(':', $v);
|
||||
$key = trim($row[0]);
|
||||
$value = trim(substr($v, strlen($row[0]) + 1));
|
||||
if (is_array($rlt['headers'][$key])) {
|
||||
if (isset($rlt['headers'][$key]) && is_array($rlt['headers'][$key])) {
|
||||
$rlt['headers'][$key][] = $value;
|
||||
} elseif (!empty($rlt['headers'][$key])) {
|
||||
$temp = $rlt['headers'][$key];
|
||||
|
||||
@ -8,6 +8,7 @@ use App\Module\Base;
|
||||
use App\Module\AI;
|
||||
use PDO;
|
||||
use PDOException;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
@ -21,6 +22,30 @@ class ManticoreBase
|
||||
private static ?PDO $pdo = null;
|
||||
private static bool $initialized = false;
|
||||
|
||||
/**
|
||||
* 向量表结构版本;修改表结构/向量列参数时递增,触发已部署实例自动重建
|
||||
*/
|
||||
private const SCHEMA_VERSION = 1;
|
||||
|
||||
/**
|
||||
* Auto Embeddings 的 MODEL_NAME。必须用 Manticore 不认识的名字:
|
||||
* 已知名字(如 text-embedding-ada-002)会按引擎硬编码维度校验,
|
||||
* 未知名字才会在建表时向 API_URL 探测真实维度(免费模型为 1024)。
|
||||
* 实际模型由 ai 插件的 EMBEDDING_MODEL 决定,此处仅为路由标签。
|
||||
*/
|
||||
private const EMBEDDING_MODEL_NAME = 'openai/qwen3-embedding';
|
||||
|
||||
/**
|
||||
* 批量写入分块上限:行数与字节预算(Manticore max_allowed_packet 默认 128MB,取保守值)
|
||||
*/
|
||||
private const BATCH_CHUNK_ROWS = 30;
|
||||
private const BATCH_CHUNK_BYTES = 8388608;
|
||||
|
||||
/**
|
||||
* 5 张向量表名(键值即 VECTOR_TABLE_CONFIG 的 type)
|
||||
*/
|
||||
private const VECTOR_TABLES = ['msg', 'file', 'task', 'project', 'user'];
|
||||
|
||||
private string $host;
|
||||
private int $port;
|
||||
|
||||
@ -70,13 +95,169 @@ class ManticoreBase
|
||||
|
||||
/**
|
||||
* 初始化表结构
|
||||
*
|
||||
* 向量列使用 Manticore Auto Embeddings(MODEL_NAME/API_URL 指向 ai 插件 /embeddings),
|
||||
* 引擎在写入/更新行时自动按 FROM 字段生成向量,无需 PHP 侧生成。
|
||||
* key_values 中的 vector:schema 标记记录当前结构指纹(结构版本/模型/端点/APP_KEY 哈希),
|
||||
* 不匹配(首次安装、老版本升级、APP_KEY 轮换)即整体重建并重置同步指针,触发全量重灌。
|
||||
*/
|
||||
private function initializeTables(PDO $pdo): void
|
||||
{
|
||||
try {
|
||||
// 创建文件向量表
|
||||
// charset_table='non_cjk, cjk' 同时支持英文和中日韩文字
|
||||
// 键值表必须最先建(用于读取/持久化结构标记与同步指针)
|
||||
$pdo->exec("
|
||||
CREATE TABLE IF NOT EXISTS key_values (
|
||||
id BIGINT,
|
||||
k STRING,
|
||||
v TEXT
|
||||
)
|
||||
");
|
||||
|
||||
$expected = self::schemaMarker();
|
||||
if (self::kvGetPdo($pdo, 'vector:schema') === $expected
|
||||
&& self::allVectorTablesExist($pdo)
|
||||
&& !self::embeddingModelChanged($pdo)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 并发进程只允许一个执行重建;抢锁失败的进程本轮写入失败会进重试队列,无碍
|
||||
$lock = Cache::lock('manticore:schema-rebuild', 300);
|
||||
if (!$lock->get()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// 拿到锁时可能另一进程刚完成重建(marker 写入在锁内),先复检避免重复重建
|
||||
if (self::kvGetPdo($pdo, 'vector:schema') === $expected
|
||||
&& self::allVectorTablesExist($pdo)
|
||||
&& !self::embeddingModelChanged($pdo)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 先用一次性探针表验证 ai 端点可用(CREATE 会向端点探测维度、未就绪则抛错),
|
||||
// 通过后才 DROP 现有表——避免 ai 未就绪时销毁旧索引后建不回来
|
||||
$pdo->exec("DROP TABLE IF EXISTS _schema_probe");
|
||||
$pdo->exec("CREATE TABLE _schema_probe (t TEXT, " . self::vectorColumnDDL('t') . ")");
|
||||
$pdo->exec("DROP TABLE IF EXISTS _schema_probe");
|
||||
|
||||
foreach (self::vectorTableDDLs() as $table => $ddl) {
|
||||
$pdo->exec("DROP TABLE IF EXISTS {$table}");
|
||||
$pdo->exec($ddl);
|
||||
}
|
||||
|
||||
self::resetSyncPointersPdo($pdo);
|
||||
// 清理旧向量管道遗留键(vector:dim 与 vector:*LastId 指针)
|
||||
$legacy = ["'vector:dim'"];
|
||||
foreach (self::VECTOR_TABLES as $t) {
|
||||
$legacy[] = "'vector:manticore" . ucfirst($t) . "LastId'";
|
||||
}
|
||||
$pdo->exec("DELETE FROM key_values WHERE k IN (" . implode(',', $legacy) . ")");
|
||||
self::rememberEmbeddingModel($pdo);
|
||||
// marker 最后写入且在锁内:写入即代表重建完整成功
|
||||
self::kvSetPdo($pdo, 'vector:schema', $expected);
|
||||
Log::info("Manticore vector tables rebuilt for auto-embeddings schema {$expected}");
|
||||
} catch (\Throwable $e) {
|
||||
// 重建失败(如 ai 插件未就绪/未升级):不写 marker,下个进程重试,可自愈
|
||||
Log::error('Manticore schema rebuild failed: ' . $e->getMessage());
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Manticore initializeTables failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ai 插件向量化端点地址(唯一来源在 AI::embeddingsUrl,查询侧与表定义共用)
|
||||
*/
|
||||
private static function embeddingsApiUrl(): string
|
||||
{
|
||||
return AI::embeddingsUrl();
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入表定义的派生密钥:sha256(APP_KEY:embeddings),与 ai 插件约定一致。
|
||||
* 不直接写 APP_KEY(Laravel 主密钥),避免其落入搜索引擎元数据/数据卷;
|
||||
* 派生值不可反推,且仅授予 /embeddings 调用权限。
|
||||
*/
|
||||
private static function embeddingsApiKey(): string
|
||||
{
|
||||
return hash('sha256', config('app.key') . ':embeddings');
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前向量表结构指纹:结构版本/模型名/端点/APP_KEY 哈希任一变化都会触发整体重建
|
||||
*/
|
||||
private static function schemaMarker(): string
|
||||
{
|
||||
return md5(self::SCHEMA_VERSION . '|' . self::EMBEDDING_MODEL_NAME . '|'
|
||||
. self::embeddingsApiUrl() . '|' . hash('sha256', (string) config('app.key')));
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测 ai 插件实际生效的向量模型是否与建表时不一致(不一致需重建,否则维度可能不匹配)。
|
||||
*
|
||||
* 实际模型(EMBEDDING_MODEL env)对主程序不可见,由查询侧在成功请求后
|
||||
* 写入缓存 ai:embedding_model;建表时的模型记录在 key_values 的 vector:model。
|
||||
* 任一侧未知时不触发(返回 false),已知且存量缺失时顺手补记。
|
||||
*/
|
||||
private static function embeddingModelChanged(PDO $pdo): bool
|
||||
{
|
||||
$live = (string) Cache::get('ai:embedding_model', '');
|
||||
if ($live === '') {
|
||||
return false;
|
||||
}
|
||||
$stored = self::kvGetPdo($pdo, 'vector:model');
|
||||
if ($stored === null || $stored === '') {
|
||||
// 旧部署/首次:补记当前模型,不触发重建
|
||||
self::kvSetPdo($pdo, 'vector:model', $live);
|
||||
return false;
|
||||
}
|
||||
return $stored !== $live;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重建成功后记录当前生效的向量模型(优先取查询侧维护的缓存值)
|
||||
*/
|
||||
private static function rememberEmbeddingModel(PDO $pdo): void
|
||||
{
|
||||
$live = (string) Cache::get('ai:embedding_model', '');
|
||||
if ($live !== '') {
|
||||
self::kvSetPdo($pdo, 'vector:model', $live);
|
||||
} else {
|
||||
// 未知则清掉存量,待查询侧探得后由 embeddingModelChanged 补记
|
||||
$pdo->exec("DELETE FROM key_values WHERE k = 'vector:model'");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 Auto Embeddings 向量列定义(引擎按 FROM 字段自动生成/更新向量)
|
||||
*
|
||||
* 注意不写 KNN_DIMS(与 MODEL_NAME 互斥),维度由引擎建表时向端点探测。
|
||||
*
|
||||
* @param string $from 参与向量化的字段(镜像旧 PHP 管道的拼接字段,逗号分隔)
|
||||
*/
|
||||
private static function vectorColumnDDL(string $from): string
|
||||
{
|
||||
return "content_vector float_vector knn_type='hnsw' hnsw_similarity='cosine'"
|
||||
. " MODEL_NAME='" . self::EMBEDDING_MODEL_NAME . "'"
|
||||
. " FROM='{$from}'"
|
||||
. " API_KEY='" . self::embeddingsApiKey() . "'"
|
||||
. " API_URL='" . self::embeddingsApiUrl() . "'"
|
||||
. " API_TIMEOUT='60'";
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 5 张向量表的建表语句
|
||||
*
|
||||
* charset_table='non_cjk, cjk' 同时支持英文和中日韩文字
|
||||
*
|
||||
* @return array [table => DDL]
|
||||
*/
|
||||
private static function vectorTableDDLs(): array
|
||||
{
|
||||
$tail = "\n ) charset_table='non_cjk, cjk' morphology='icu_chinese'";
|
||||
return [
|
||||
'file_vectors' => "
|
||||
CREATE TABLE IF NOT EXISTS file_vectors (
|
||||
id BIGINT,
|
||||
file_id BIGINT,
|
||||
@ -87,21 +268,8 @@ class ManticoreBase
|
||||
file_ext STRING,
|
||||
content TEXT,
|
||||
allowed_users MULTI,
|
||||
content_vector float_vector knn_type='hnsw' knn_dims='1536' hnsw_similarity='cosine'
|
||||
) charset_table='non_cjk, cjk' morphology='icu_chinese'
|
||||
");
|
||||
|
||||
// 创建键值存储表
|
||||
$pdo->exec("
|
||||
CREATE TABLE IF NOT EXISTS key_values (
|
||||
id BIGINT,
|
||||
k STRING,
|
||||
v TEXT
|
||||
)
|
||||
");
|
||||
|
||||
// 创建用户向量表
|
||||
$pdo->exec("
|
||||
" . self::vectorColumnDDL('file_name,content') . $tail,
|
||||
'user_vectors' => "
|
||||
CREATE TABLE IF NOT EXISTS user_vectors (
|
||||
id BIGINT,
|
||||
userid BIGINT,
|
||||
@ -110,12 +278,8 @@ class ManticoreBase
|
||||
profession TEXT,
|
||||
tags TEXT,
|
||||
introduction TEXT,
|
||||
content_vector float_vector knn_type='hnsw' knn_dims='1536' hnsw_similarity='cosine'
|
||||
) charset_table='non_cjk, cjk' morphology='icu_chinese'
|
||||
");
|
||||
|
||||
// 创建项目向量表
|
||||
$pdo->exec("
|
||||
" . self::vectorColumnDDL('nickname,email,profession,tags,introduction') . $tail,
|
||||
'project_vectors' => "
|
||||
CREATE TABLE IF NOT EXISTS project_vectors (
|
||||
id BIGINT,
|
||||
project_id BIGINT,
|
||||
@ -124,12 +288,8 @@ class ManticoreBase
|
||||
project_name TEXT,
|
||||
project_desc TEXT,
|
||||
allowed_users MULTI,
|
||||
content_vector float_vector knn_type='hnsw' knn_dims='1536' hnsw_similarity='cosine'
|
||||
) charset_table='non_cjk, cjk' morphology='icu_chinese'
|
||||
");
|
||||
|
||||
// 创建任务向量表
|
||||
$pdo->exec("
|
||||
" . self::vectorColumnDDL('project_name,project_desc') . $tail,
|
||||
'task_vectors' => "
|
||||
CREATE TABLE IF NOT EXISTS task_vectors (
|
||||
id BIGINT,
|
||||
task_id BIGINT,
|
||||
@ -140,12 +300,8 @@ class ManticoreBase
|
||||
task_desc TEXT,
|
||||
task_content TEXT,
|
||||
allowed_users MULTI,
|
||||
content_vector float_vector knn_type='hnsw' knn_dims='1536' hnsw_similarity='cosine'
|
||||
) charset_table='non_cjk, cjk' morphology='icu_chinese'
|
||||
");
|
||||
|
||||
// 创建消息向量表
|
||||
$pdo->exec("
|
||||
" . self::vectorColumnDDL('task_name,task_desc,task_content') . $tail,
|
||||
'msg_vectors' => "
|
||||
CREATE TABLE IF NOT EXISTS msg_vectors (
|
||||
id BIGINT,
|
||||
msg_id BIGINT,
|
||||
@ -155,13 +311,69 @@ class ManticoreBase
|
||||
content TEXT,
|
||||
allowed_users MULTI,
|
||||
created_at BIGINT,
|
||||
content_vector float_vector knn_type='hnsw' knn_dims='1536' hnsw_similarity='cosine'
|
||||
) charset_table='non_cjk, cjk' morphology='icu_chinese'
|
||||
");
|
||||
" . self::vectorColumnDDL('content') . $tail,
|
||||
];
|
||||
}
|
||||
|
||||
// Tables initialized successfully
|
||||
} catch (PDOException $e) {
|
||||
// 表可能已存在,忽略初始化错误
|
||||
/**
|
||||
* 检查 5 张向量表是否都已存在
|
||||
*/
|
||||
private static function allVectorTablesExist(PDO $pdo): bool
|
||||
{
|
||||
try {
|
||||
$stmt = $pdo->query("SHOW TABLES");
|
||||
$existing = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_NUM) as $row) {
|
||||
$existing[$row[0]] = true;
|
||||
}
|
||||
foreach (array_keys(self::vectorTableDDLs()) as $table) {
|
||||
if (!isset($existing[$table])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接用 PDO 读取 key_values(避免初始化期通过 ManticoreKeyValue 造成递归)
|
||||
*/
|
||||
private static function kvGetPdo(PDO $pdo, string $key): ?string
|
||||
{
|
||||
try {
|
||||
$stmt = $pdo->prepare("SELECT v FROM key_values WHERE k = ?");
|
||||
$stmt->execute([$key]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return ($row && isset($row['v'])) ? (string)$row['v'] : null;
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接用 PDO 写入 key_values(同 ManticoreKeyValue::set 的 id 规则)
|
||||
*/
|
||||
private static function kvSetPdo(PDO $pdo, string $key, string $value): void
|
||||
{
|
||||
try {
|
||||
$del = $pdo->prepare("DELETE FROM key_values WHERE k = ?");
|
||||
$del->execute([$key]);
|
||||
$ins = $pdo->prepare("INSERT INTO key_values (id, k, v) VALUES (?, ?, ?)");
|
||||
$ins->execute([abs(crc32($key)), $key, $value]);
|
||||
} catch (\Throwable $e) {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置全文同步进度指针(sync:*),触发全量重灌(向量由引擎随行自动生成)
|
||||
*/
|
||||
private static function resetSyncPointersPdo(PDO $pdo): void
|
||||
{
|
||||
foreach (self::VECTOR_TABLES as $t) {
|
||||
self::kvSetPdo($pdo, "sync:manticore" . ucfirst($t) . "LastId", '0');
|
||||
}
|
||||
}
|
||||
|
||||
@ -256,13 +468,17 @@ class ManticoreBase
|
||||
*/
|
||||
public function executeRaw(string $sql): bool
|
||||
{
|
||||
// 日志上下文只保留 SQL 前 2KB:多行批量语句可达数 MB,完整写入会淹没日志
|
||||
$sqlPreview = strlen($sql) > 2048
|
||||
? substr($sql, 0, 2048) . ' ...[+' . (strlen($sql) - 2048) . ' bytes]'
|
||||
: $sql;
|
||||
return $this->runWithRetry(
|
||||
function (PDO $pdo) use ($sql) {
|
||||
$pdo->exec($sql);
|
||||
return true;
|
||||
},
|
||||
false,
|
||||
['sql' => $sql]
|
||||
['sql' => $sqlPreview]
|
||||
);
|
||||
}
|
||||
|
||||
@ -1810,10 +2026,10 @@ class ManticoreBase
|
||||
];
|
||||
|
||||
/**
|
||||
* 通用向量插入方法
|
||||
* 通用单行写入方法(REPLACE,向量由引擎 Auto Embeddings 自动生成)
|
||||
*
|
||||
* 使用 executeRaw 直接执行 SQL,避免 Manticore prepared statement
|
||||
* 无法解析 MVA 和向量字段括号语法的问题。
|
||||
* 无法解析 MVA 字段括号语法的问题。
|
||||
*
|
||||
* @param string $type 类型: msg/file/task/project/user
|
||||
* @param array $data 数据,键名对应字段名
|
||||
@ -1828,8 +2044,6 @@ class ManticoreBase
|
||||
$config = self::VECTOR_TABLE_CONFIG[$type];
|
||||
$table = $config['table'];
|
||||
$pk = $config['pk'];
|
||||
$fields = $config['fields'];
|
||||
$mvaFields = $config['mva_fields'];
|
||||
|
||||
// 检查主键
|
||||
$pkValue = $data[$pk] ?? 0;
|
||||
@ -1838,44 +2052,11 @@ class ManticoreBase
|
||||
}
|
||||
|
||||
$instance = new self();
|
||||
[$fieldList, $valueList] = $instance->buildRowValues($config, $data);
|
||||
|
||||
// 先删除已存在的记录
|
||||
$instance->execute("DELETE FROM {$table} WHERE {$pk} = ?", [$pkValue]);
|
||||
|
||||
// 构建字段列表和值
|
||||
$fieldList = [];
|
||||
$valueList = [];
|
||||
|
||||
// 处理普通字段
|
||||
foreach ($fields as $field) {
|
||||
$fieldList[] = $field;
|
||||
$value = $data[$field] ?? ($field === 'created_at' ? time() : (in_array($field, self::NUMERIC_FIELDS) ? 0 : ''));
|
||||
|
||||
if (in_array($field, self::NUMERIC_FIELDS)) {
|
||||
$valueList[] = (int)$value;
|
||||
} else {
|
||||
$valueList[] = $instance->quoteValue((string)$value);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 MVA 字段
|
||||
foreach ($mvaFields as $mvaField) {
|
||||
$fieldList[] = $mvaField;
|
||||
$mvaData = $data[$mvaField] ?? [];
|
||||
$valueList[] = !empty($mvaData)
|
||||
? '(' . implode(',', array_map('intval', $mvaData)) . ')'
|
||||
: '()';
|
||||
}
|
||||
|
||||
// 处理向量字段
|
||||
$vectorValue = $data['content_vector'] ?? null;
|
||||
if ($vectorValue) {
|
||||
$fieldList[] = 'content_vector';
|
||||
$valueList[] = str_replace(['[', ']'], ['(', ')'], $vectorValue);
|
||||
}
|
||||
|
||||
// 构建并执行 SQL
|
||||
$sql = "INSERT INTO {$table} (" . implode(', ', $fieldList) . ") VALUES (" . implode(', ', $valueList) . ")";
|
||||
// REPLACE 按 id 原子替换整行,向量列由引擎按 FROM 字段自动重新生成。
|
||||
// 前提:所有 upsertXxxVector 均强制 id = 主键值,故 REPLACE(按 id) 与按主键去重等价
|
||||
$sql = "REPLACE INTO {$table} (" . implode(', ', $fieldList) . ") VALUES (" . implode(', ', $valueList) . ")";
|
||||
|
||||
$result = $instance->executeRaw($sql);
|
||||
|
||||
@ -1885,12 +2066,46 @@ class ManticoreBase
|
||||
ManticoreSyncFailure::removeSuccess($type, $pkValue, 'sync');
|
||||
} else {
|
||||
// 失败则记录
|
||||
ManticoreSyncFailure::recordFailure($type, $pkValue, 'sync', "INSERT failed for {$table}");
|
||||
ManticoreSyncFailure::recordFailure($type, $pkValue, 'sync', "REPLACE failed for {$table}");
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建一行数据的字段列表与内联值(普通字段 + MVA 字段,向量列由引擎自动生成,不在此列)
|
||||
*
|
||||
* @param array $config VECTOR_TABLE_CONFIG 中的类型配置
|
||||
* @param array $data 行数据
|
||||
* @return array [fieldList, valueList]
|
||||
*/
|
||||
private function buildRowValues(array $config, array $data): array
|
||||
{
|
||||
$fieldList = [];
|
||||
$valueList = [];
|
||||
|
||||
foreach ($config['fields'] as $field) {
|
||||
$fieldList[] = $field;
|
||||
$value = $data[$field] ?? ($field === 'created_at' ? time() : (in_array($field, self::NUMERIC_FIELDS) ? 0 : ''));
|
||||
|
||||
if (in_array($field, self::NUMERIC_FIELDS)) {
|
||||
$valueList[] = (int)$value;
|
||||
} else {
|
||||
$valueList[] = $this->quoteValue((string)$value);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($config['mva_fields'] as $mvaField) {
|
||||
$fieldList[] = $mvaField;
|
||||
$mvaData = $data[$mvaField] ?? [];
|
||||
$valueList[] = !empty($mvaData)
|
||||
? '(' . implode(',', array_map('intval', $mvaData)) . ')'
|
||||
: '()';
|
||||
}
|
||||
|
||||
return [$fieldList, $valueList];
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用向量删除方法
|
||||
*
|
||||
@ -1924,179 +2139,92 @@ class ManticoreBase
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用批量更新向量方法(高性能版本)
|
||||
* 通用批量写入方法:每块一条多行 REPLACE,向量由引擎 Auto Embeddings 自动生成
|
||||
*
|
||||
* 优化:将 N 条记录的 3N 次操作减少为 N+2 次操作
|
||||
* 1. 批量 SELECT 获取现有记录 (1次)
|
||||
* 2. 预构建所有 INSERT SQL(验证数据完整性)
|
||||
* 3. 批量 DELETE 删除旧记录 (1次)
|
||||
* 4. 逐条 INSERT 新记录带向量 (N次,因向量字段无法批量绑定)
|
||||
* 引擎对一条多行语句只调用一次向量化接口(实测 30 行 ≈ 0.9s),
|
||||
* 多行语句失败是原子的;整块失败时回退逐行 upsertVector,
|
||||
* 使单条坏行不毒化整批、且失败按真实主键记入重试表。
|
||||
*
|
||||
* @param string $type 类型: msg/file/task/project/user
|
||||
* @param array $vectorData 向量数据 [pk_value => vectorStr, ...]
|
||||
* @return int 成功更新的数量
|
||||
* @param array $rows 行数据数组(与 upsertVector 的 $data 同构,需含 id 与主键)
|
||||
* @return int 成功写入的数量
|
||||
*/
|
||||
public static function batchUpdateVectors(string $type, array $vectorData): int
|
||||
public static function batchUpsertVectors(string $type, array $rows): int
|
||||
{
|
||||
if (empty($vectorData) || !isset(self::VECTOR_TABLE_CONFIG[$type])) {
|
||||
if (empty($rows) || !isset(self::VECTOR_TABLE_CONFIG[$type])) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$config = self::VECTOR_TABLE_CONFIG[$type];
|
||||
$table = $config['table'];
|
||||
$pk = $config['pk'];
|
||||
$fields = $config['fields'];
|
||||
$mvaFields = $config['mva_fields'];
|
||||
|
||||
$instance = new self();
|
||||
$ids = array_keys($vectorData);
|
||||
|
||||
// 1. 批量查询现有记录
|
||||
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
||||
$existingRows = $instance->query(
|
||||
"SELECT * FROM {$table} WHERE {$pk} IN ({$placeholders})",
|
||||
$ids
|
||||
);
|
||||
|
||||
if (empty($existingRows)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 建立 pk => row 的映射
|
||||
$existingMap = [];
|
||||
foreach ($existingRows as $row) {
|
||||
$existingMap[$row[$pk]] = $row;
|
||||
}
|
||||
|
||||
$idsToUpdate = array_keys($existingMap);
|
||||
if (empty($idsToUpdate)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 2. 预构建所有 INSERT 语句(在删除前验证数据完整性)
|
||||
$insertStatements = [];
|
||||
foreach ($idsToUpdate as $pkValue) {
|
||||
$existing = $existingMap[$pkValue];
|
||||
$vectorStr = $vectorData[$pkValue] ?? null;
|
||||
|
||||
if (empty($vectorStr)) {
|
||||
// 剔除无主键行;按内容长度估算分块(不在此渲染 SQL,块内惰性渲染以压低内存峰值)
|
||||
$pending = [];
|
||||
foreach ($rows as $row) {
|
||||
if (($row[$pk] ?? 0) <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Manticore 向量使用 () 格式
|
||||
$vectorStr = str_replace(['[', ']'], ['(', ')'], $vectorStr);
|
||||
|
||||
// 构建字段列表和值(直接内联值,不使用参数绑定)
|
||||
$fieldList = $fields;
|
||||
$quotedValues = [];
|
||||
foreach ($fields as $field) {
|
||||
$value = $existing[$field] ?? null;
|
||||
// 处理默认值:数值字段用 0,时间戳字段用当前时间,其他用空字符串
|
||||
if ($value === null) {
|
||||
if ($field === 'created_at') {
|
||||
$value = time();
|
||||
} elseif (in_array($field, self::NUMERIC_FIELDS)) {
|
||||
$value = 0;
|
||||
} else {
|
||||
$value = '';
|
||||
}
|
||||
}
|
||||
// 根据字段类型处理值
|
||||
if (in_array($field, self::NUMERIC_FIELDS)) {
|
||||
$quotedValues[] = (int)$value;
|
||||
} else {
|
||||
$quotedValues[] = $instance->quoteValue((string)$value);
|
||||
$bytes = 64;
|
||||
foreach ($row as $value) {
|
||||
if (is_string($value)) {
|
||||
$bytes += strlen($value);
|
||||
}
|
||||
}
|
||||
|
||||
// 构建 MVA 字段
|
||||
$mvaValuesStr = [];
|
||||
foreach ($mvaFields as $mvaField) {
|
||||
$fieldList[] = $mvaField;
|
||||
$mvaValuesStr[] = !empty($existing[$mvaField])
|
||||
? '(' . $existing[$mvaField] . ')'
|
||||
: '()';
|
||||
}
|
||||
|
||||
// 添加向量字段
|
||||
$fieldList[] = 'content_vector';
|
||||
|
||||
// 构建 SQL(所有值直接内联,使用 executeRaw 避免 prepared statement 解析问题)
|
||||
$allValues = implode(', ', array_merge($quotedValues, $mvaValuesStr, [$vectorStr]));
|
||||
$sql = "INSERT INTO {$table} (" . implode(', ', $fieldList) . ") VALUES ({$allValues})";
|
||||
|
||||
$insertStatements[] = ['sql' => $sql, 'pk' => $pkValue];
|
||||
$pending[] = ['pk' => $row[$pk], 'bytes' => $bytes, 'row' => $row];
|
||||
}
|
||||
|
||||
// 如果没有有效的插入语句,直接返回
|
||||
if (empty($insertStatements)) {
|
||||
if (empty($pending)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 3. 批量删除旧记录(只删除有有效向量的记录)
|
||||
$validPks = array_column($insertStatements, 'pk');
|
||||
$deletePlaceholders = implode(',', array_fill(0, count($validPks), '?'));
|
||||
$instance->execute(
|
||||
"DELETE FROM {$table} WHERE {$pk} IN ({$deletePlaceholders})",
|
||||
$validPks
|
||||
);
|
||||
// 分块:行数上限 + 字节预算(文件内容可达 10 万字符/行)
|
||||
$chunks = [];
|
||||
$current = [];
|
||||
$currentBytes = 0;
|
||||
foreach ($pending as $item) {
|
||||
if (!empty($current)
|
||||
&& (count($current) >= self::BATCH_CHUNK_ROWS || $currentBytes + $item['bytes'] > self::BATCH_CHUNK_BYTES)) {
|
||||
$chunks[] = $current;
|
||||
$current = [];
|
||||
$currentBytes = 0;
|
||||
}
|
||||
$current[] = $item;
|
||||
$currentBytes += $item['bytes'];
|
||||
}
|
||||
$chunks[] = $current;
|
||||
|
||||
// 4. 逐条插入新记录(使用 executeRaw 避免 prepared statement 解析问题)
|
||||
$successCount = 0;
|
||||
foreach ($insertStatements as $stmt) {
|
||||
if ($instance->executeRaw($stmt['sql'])) {
|
||||
$successCount++;
|
||||
// 成功则删除失败记录(如果有)
|
||||
ManticoreSyncFailure::removeSuccess($type, $stmt['pk'], 'sync');
|
||||
foreach ($chunks as $chunk) {
|
||||
// 块内渲染,执行后即释放,内存峰值 = 原始行 + 单块 SQL
|
||||
$fieldListRef = null;
|
||||
$valuesSql = [];
|
||||
foreach ($chunk as $item) {
|
||||
[$fieldList, $valueList] = $instance->buildRowValues($config, $item['row']);
|
||||
$fieldListRef = $fieldList;
|
||||
$valuesSql[] = '(' . implode(', ', $valueList) . ')';
|
||||
}
|
||||
$sql = "REPLACE INTO {$table} (" . implode(', ', $fieldListRef) . ") VALUES "
|
||||
. implode(', ', $valuesSql);
|
||||
unset($valuesSql);
|
||||
$ok = $instance->executeRaw($sql);
|
||||
unset($sql);
|
||||
if ($ok) {
|
||||
$successCount += count($chunk);
|
||||
ManticoreSyncFailure::removeSuccessBatch($type, array_column($chunk, 'pk'), 'sync');
|
||||
} else {
|
||||
// 失败则记录
|
||||
ManticoreSyncFailure::recordFailure($type, $stmt['pk'], 'sync', "Batch INSERT failed for {$table}");
|
||||
// 整块失败:回退逐行(REPLACE 幂等,重复写已生效行无害)
|
||||
foreach ($chunk as $item) {
|
||||
if (self::upsertVector($type, $item['row'])) {
|
||||
$successCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $successCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新消息向量(兼容方法)
|
||||
*/
|
||||
public static function batchUpdateMsgVectors(array $vectorData): int
|
||||
{
|
||||
return self::batchUpdateVectors('msg', $vectorData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新文件向量
|
||||
*/
|
||||
public static function batchUpdateFileVectors(array $vectorData): int
|
||||
{
|
||||
return self::batchUpdateVectors('file', $vectorData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新任务向量
|
||||
*/
|
||||
public static function batchUpdateTaskVectors(array $vectorData): int
|
||||
{
|
||||
return self::batchUpdateVectors('task', $vectorData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新项目向量
|
||||
*/
|
||||
public static function batchUpdateProjectVectors(array $vectorData): int
|
||||
{
|
||||
return self::batchUpdateVectors('project', $vectorData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新用户向量
|
||||
*/
|
||||
public static function batchUpdateUserVectors(array $vectorData): int
|
||||
{
|
||||
return self::batchUpdateVectors('user', $vectorData);
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 通用工具方法
|
||||
// ==============================
|
||||
|
||||
@ -8,7 +8,6 @@ use App\Models\FileUser;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Base;
|
||||
use App\Module\TextExtractor;
|
||||
use App\Module\AI;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
@ -38,7 +37,9 @@ class ManticoreFile
|
||||
public const SEARCHABLE_TYPES = ['document', 'word', 'excel', 'ppt', 'txt', 'md', 'text', 'code'];
|
||||
|
||||
/**
|
||||
* 最大内容长度(字符)- 提取后的文本内容限制
|
||||
* 最大内容长度(字符)- 提取后的文本内容限制(服务全文检索范围)。
|
||||
* 注意:向量化输入在 ai 插件侧另有 30000 字符上限(main.py _EMBEDDING_INPUT_MAX_CHARS),
|
||||
* 超出部分只参与全文检索、不参与语义向量。
|
||||
*/
|
||||
public const MAX_CONTENT_LENGTH = 100000; // 100K 字符
|
||||
|
||||
@ -214,13 +215,12 @@ class ManticoreFile
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 同步单个文件到 Manticore(含 allowed_users)
|
||||
* 同步单个文件到 Manticore(含 allowed_users,向量由引擎 Auto Embeddings 自动生成)
|
||||
*
|
||||
* @param File $file 文件模型
|
||||
* @param bool $withVector 是否同时生成向量(默认 false,向量由后台任务生成)
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function sync(File $file, bool $withVector = false): bool
|
||||
public static function sync(File $file): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return false;
|
||||
@ -240,42 +240,7 @@ class ManticoreFile
|
||||
}
|
||||
|
||||
try {
|
||||
// 提取文件内容
|
||||
$content = self::extractFileContent($file);
|
||||
|
||||
// 限制提取后的内容长度
|
||||
$content = mb_substr($content, 0, self::MAX_CONTENT_LENGTH);
|
||||
|
||||
// 只有明确要求时才生成向量(默认不生成,由后台任务处理)
|
||||
$embedding = null;
|
||||
if ($withVector && Apps::isInstalled('ai')) {
|
||||
// 向量内容包含文件名和文件内容
|
||||
$vectorContent = self::buildVectorContent($file->name, $content);
|
||||
if (!empty($vectorContent)) {
|
||||
$embeddingResult = ManticoreBase::getEmbedding($vectorContent);
|
||||
if (!empty($embeddingResult)) {
|
||||
$embedding = '[' . implode(',', $embeddingResult) . ']';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取文件的 allowed_users
|
||||
$allowedUsers = self::getAllowedUsers($file);
|
||||
|
||||
// 写入 Manticore(含 allowed_users)
|
||||
$result = ManticoreBase::upsertFileVector([
|
||||
'file_id' => $file->id,
|
||||
'userid' => $file->userid,
|
||||
'pshare' => $file->pshare ?? 0,
|
||||
'file_name' => $file->name,
|
||||
'file_type' => $file->type,
|
||||
'file_ext' => $file->ext,
|
||||
'content' => $content,
|
||||
'content_vector' => $embedding,
|
||||
'allowed_users' => $allowedUsers,
|
||||
]);
|
||||
|
||||
return $result;
|
||||
return ManticoreBase::upsertFileVector(self::buildRow($file));
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Manticore sync error: ' . $e->getMessage(), [
|
||||
'file_id' => $file->id,
|
||||
@ -285,6 +250,30 @@ class ManticoreFile
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建文件索引行数据(含提取的文件内容与 allowed_users)
|
||||
*
|
||||
* @param File $file 文件模型
|
||||
* @return array 行数据
|
||||
*/
|
||||
private static function buildRow(File $file): array
|
||||
{
|
||||
// 提取文件内容并限制长度
|
||||
$content = mb_substr(self::extractFileContent($file), 0, self::MAX_CONTENT_LENGTH);
|
||||
|
||||
return [
|
||||
'id' => $file->id,
|
||||
'file_id' => $file->id,
|
||||
'userid' => $file->userid,
|
||||
'pshare' => $file->pshare ?? 0,
|
||||
'file_name' => $file->name,
|
||||
'file_type' => $file->type,
|
||||
'file_ext' => $file->ext,
|
||||
'content' => $content,
|
||||
'allowed_users' => self::getAllowedUsers($file),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据文件扩展名获取最大文件大小限制
|
||||
*
|
||||
@ -317,25 +306,41 @@ class ManticoreFile
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量同步文件
|
||||
* 批量同步文件(每块一条多行 REPLACE,向量由引擎自动生成)
|
||||
*
|
||||
* @param iterable $files 文件列表
|
||||
* @param bool $withVector 是否同时生成向量
|
||||
* @return int 成功同步的数量
|
||||
*/
|
||||
public static function batchSync(iterable $files, bool $withVector = false): int
|
||||
public static function batchSync(iterable $files): int
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
$rows = [];
|
||||
foreach ($files as $file) {
|
||||
if (self::sync($file, $withVector)) {
|
||||
// 文件夹不索引
|
||||
if ($file->type === 'folder') {
|
||||
$count++;
|
||||
continue;
|
||||
}
|
||||
// 超限文件删除旧索引
|
||||
if ($file->size > self::getMaxFileSizeByExt($file->ext)) {
|
||||
self::delete($file->id);
|
||||
$count++;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$rows[] = self::buildRow($file);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Manticore file batchSync build error: ' . $e->getMessage(), [
|
||||
'file_id' => $file->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
return $count;
|
||||
|
||||
return $count + ManticoreBase::batchUpsertVectors('file', $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -497,27 +502,6 @@ class ManticoreFile
|
||||
return $result['data'] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建用于生成向量的内容
|
||||
* 包含文件名和文件内容,确保语义搜索能匹配文件名
|
||||
*
|
||||
* @param string $fileName 文件名
|
||||
* @param string $content 文件内容
|
||||
* @return string 用于生成向量的文本
|
||||
*/
|
||||
private static function buildVectorContent(string $fileName, string $content): string
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
if (!empty($fileName)) {
|
||||
$parts[] = $fileName;
|
||||
}
|
||||
if (!empty($content)) {
|
||||
$parts[] = $content;
|
||||
}
|
||||
|
||||
return implode(' ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有索引
|
||||
@ -577,94 +561,4 @@ class ManticoreFile
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 批量向量生成方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 批量生成文件向量
|
||||
* 用于后台异步处理,将已索引文件的向量批量生成
|
||||
*
|
||||
* @param array $fileIds 文件ID数组
|
||||
* @param int $batchSize 每批 embedding 数量(默认20)
|
||||
* @return int 成功处理的数量
|
||||
*/
|
||||
public static function generateVectorsBatch(array $fileIds, int $batchSize = 20): int
|
||||
{
|
||||
if (!Apps::isInstalled("search") || !Apps::isInstalled("ai") || empty($fileIds)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 查询文件信息
|
||||
$files = File::whereIn('id', $fileIds)
|
||||
->where('type', '!=', 'folder')
|
||||
->get();
|
||||
|
||||
if ($files->isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 2. 提取每个文件的内容(包含文件名)
|
||||
$fileContents = [];
|
||||
foreach ($files as $file) {
|
||||
// 检查文件大小限制
|
||||
$maxSize = self::getMaxFileSizeByExt($file->ext);
|
||||
if ($file->size > $maxSize) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$content = self::extractFileContent($file);
|
||||
// 向量内容包含文件名和文件内容
|
||||
$vectorContent = self::buildVectorContent($file->name, $content);
|
||||
if (!empty($vectorContent)) {
|
||||
// 限制内容长度
|
||||
$vectorContent = mb_substr($vectorContent, 0, self::MAX_CONTENT_LENGTH);
|
||||
$fileContents[$file->id] = $vectorContent;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($fileContents)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 3. 分批处理
|
||||
$successCount = 0;
|
||||
$chunks = array_chunk($fileContents, $batchSize, true);
|
||||
|
||||
foreach ($chunks as $chunk) {
|
||||
$texts = array_values($chunk);
|
||||
$ids = array_keys($chunk);
|
||||
|
||||
// 4. 批量获取 embedding
|
||||
$result = AI::getBatchEmbeddings($texts);
|
||||
if (!Base::isSuccess($result) || empty($result['data'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$embeddings = $result['data'];
|
||||
|
||||
// 5. 构建批量更新数据
|
||||
$vectorData = [];
|
||||
foreach ($ids as $index => $fileId) {
|
||||
if (!isset($embeddings[$index]) || empty($embeddings[$index])) {
|
||||
continue;
|
||||
}
|
||||
$vectorData[$fileId] = '[' . implode(',', $embeddings[$index]) . ']';
|
||||
}
|
||||
|
||||
// 6. 批量更新向量
|
||||
if (!empty($vectorData)) {
|
||||
$batchCount = ManticoreBase::batchUpdateFileVectors($vectorData);
|
||||
$successCount += $batchCount;
|
||||
}
|
||||
}
|
||||
|
||||
return $successCount;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('ManticoreFile generateVectorsBatch error: ' . $e->getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,8 +5,6 @@ namespace App\Module\Manticore;
|
||||
use App\Models\WebSocketDialogMsg;
|
||||
use App\Models\WebSocketDialogUser;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Base;
|
||||
use App\Module\AI;
|
||||
use Carbon\Carbon;
|
||||
use DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@ -39,7 +37,8 @@ class ManticoreMsg
|
||||
public const INDEXABLE_TYPES = ['text', 'file', 'record', 'meeting', 'vote'];
|
||||
|
||||
/**
|
||||
* 最大内容长度(字符)
|
||||
* 最大内容长度(字符)。向量化输入在 ai 插件侧另有 30000 字符上限
|
||||
* (main.py _EMBEDDING_INPUT_MAX_CHARS),超出部分只参与全文检索
|
||||
*/
|
||||
public const MAX_CONTENT_LENGTH = 50000; // 50K 字符
|
||||
|
||||
@ -328,13 +327,12 @@ class ManticoreMsg
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 同步单个消息到 Manticore(含 allowed_users)
|
||||
* 同步单个消息到 Manticore(含 allowed_users,向量由引擎 Auto Embeddings 自动生成)
|
||||
*
|
||||
* @param WebSocketDialogMsg $msg 消息模型
|
||||
* @param bool $withVector 是否同时生成向量(默认 false,向量由后台任务生成)
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function sync(WebSocketDialogMsg $msg, bool $withVector = false): bool
|
||||
public static function sync(WebSocketDialogMsg $msg): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return false;
|
||||
@ -347,37 +345,7 @@ class ManticoreMsg
|
||||
}
|
||||
|
||||
try {
|
||||
// 提取消息内容(使用 key 字段)
|
||||
$content = $msg->key ?? '';
|
||||
|
||||
// 限制内容长度
|
||||
$content = mb_substr($content, 0, self::MAX_CONTENT_LENGTH);
|
||||
|
||||
// 只有明确要求时才生成向量(默认不生成,由后台任务处理)
|
||||
$embedding = null;
|
||||
if ($withVector && !empty($content) && Apps::isInstalled('ai')) {
|
||||
$embeddingResult = ManticoreBase::getEmbedding($content);
|
||||
if (!empty($embeddingResult)) {
|
||||
$embedding = '[' . implode(',', $embeddingResult) . ']';
|
||||
}
|
||||
}
|
||||
|
||||
// 获取消息的 allowed_users
|
||||
$allowedUsers = self::getAllowedUsers($msg);
|
||||
|
||||
// 写入 Manticore(含 allowed_users)
|
||||
$result = ManticoreBase::upsertMsgVector([
|
||||
'msg_id' => $msg->id,
|
||||
'dialog_id' => $msg->dialog_id,
|
||||
'userid' => $msg->userid,
|
||||
'msg_type' => $msg->type,
|
||||
'content' => $content,
|
||||
'content_vector' => $embedding,
|
||||
'allowed_users' => $allowedUsers,
|
||||
'created_at' => $msg->created_at ? $msg->created_at->timestamp : time(),
|
||||
]);
|
||||
|
||||
return $result;
|
||||
return ManticoreBase::upsertMsgVector(self::buildRow($msg));
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Manticore msg sync error: ' . $e->getMessage(), [
|
||||
'msg_id' => $msg->id,
|
||||
@ -388,106 +356,59 @@ class ManticoreMsg
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量同步消息
|
||||
* 构建消息索引行数据(含 allowed_users)
|
||||
*
|
||||
* @param WebSocketDialogMsg $msg 消息模型
|
||||
* @return array 行数据
|
||||
*/
|
||||
private static function buildRow(WebSocketDialogMsg $msg): array
|
||||
{
|
||||
// 提取消息内容(使用 key 字段)并限制长度
|
||||
$content = mb_substr($msg->key ?? '', 0, self::MAX_CONTENT_LENGTH);
|
||||
|
||||
return [
|
||||
'id' => $msg->id,
|
||||
'msg_id' => $msg->id,
|
||||
'dialog_id' => $msg->dialog_id,
|
||||
'userid' => $msg->userid,
|
||||
'msg_type' => $msg->type,
|
||||
'content' => $content,
|
||||
'allowed_users' => self::getAllowedUsers($msg),
|
||||
'created_at' => $msg->created_at ? $msg->created_at->timestamp : time(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量同步消息(每块一条多行 REPLACE,向量由引擎自动生成)
|
||||
*
|
||||
* @param iterable $msgs 消息列表
|
||||
* @param bool $withVector 是否同时生成向量
|
||||
* @return int 成功同步的数量
|
||||
*/
|
||||
public static function batchSync(iterable $msgs, bool $withVector = false): int
|
||||
public static function batchSync(iterable $msgs): int
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
$rows = [];
|
||||
foreach ($msgs as $msg) {
|
||||
if (self::sync($msg, $withVector)) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量生成向量(供后台任务调用)
|
||||
*
|
||||
* @param array $msgIds 消息ID数组
|
||||
* @param int $batchSize 每批 embedding 数量
|
||||
* @return int 成功生成向量的数量
|
||||
*/
|
||||
public static function generateVectorsBatch(array $msgIds, int $batchSize = 20): int
|
||||
{
|
||||
if (!Apps::isInstalled("search") || !Apps::isInstalled('ai') || empty($msgIds)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
|
||||
// 分批处理
|
||||
foreach (array_chunk($msgIds, $batchSize) as $batchIds) {
|
||||
// 获取消息
|
||||
$msgs = WebSocketDialogMsg::whereIn('id', $batchIds)
|
||||
->whereIn('type', self::INDEXABLE_TYPES)
|
||||
->where('bot', '!=', 1)
|
||||
->whereNotNull('key')
|
||||
->where('key', '!=', '')
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
if ($msgs->isEmpty()) {
|
||||
if (!self::shouldIndex($msg)) {
|
||||
if (ManticoreBase::deleteMsgVector($msg->id)) {
|
||||
$count++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 准备文本
|
||||
$texts = [];
|
||||
$idsArray = [];
|
||||
foreach ($batchIds as $id) {
|
||||
if (isset($msgs[$id])) {
|
||||
$content = mb_substr($msgs[$id]->key ?? '', 0, self::MAX_CONTENT_LENGTH);
|
||||
if (!empty($content)) {
|
||||
$texts[] = $content;
|
||||
$idsArray[] = $id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($texts)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 批量获取 embeddings
|
||||
$result = AI::getBatchEmbeddings($texts);
|
||||
|
||||
if (Base::isError($result)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$embeddings = $result['data'] ?? [];
|
||||
|
||||
// 构建批量更新数据 [msg_id => vectorStr]
|
||||
$vectorData = [];
|
||||
foreach ($embeddings as $index => $embedding) {
|
||||
if (empty($embedding) || !is_array($embedding)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$msgId = $idsArray[$index] ?? null;
|
||||
if (!$msgId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$vectorData[$msgId] = '[' . implode(',', $embedding) . ']';
|
||||
}
|
||||
|
||||
// 批量更新向量(优化:减少数据库操作次数)
|
||||
if (!empty($vectorData)) {
|
||||
$batchCount = ManticoreBase::batchUpdateMsgVectors($vectorData);
|
||||
$count += $batchCount;
|
||||
try {
|
||||
$rows[] = self::buildRow($msg);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Manticore msg batchSync build error: ' . $e->getMessage(), [
|
||||
'msg_id' => $msg->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
return $count + ManticoreBase::batchUpsertVectors('msg', $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -5,8 +5,6 @@ namespace App\Module\Manticore;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectUser;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Base;
|
||||
use App\Module\AI;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
@ -122,13 +120,12 @@ class ManticoreProject
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步单个项目到 Manticore(含 allowed_users)
|
||||
* 同步单个项目到 Manticore(含 allowed_users,向量由引擎 Auto Embeddings 自动生成)
|
||||
*
|
||||
* @param Project $project 项目模型
|
||||
* @param bool $withVector 是否同时生成向量(默认 false,向量由后台任务生成)
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function sync(Project $project, bool $withVector = false): bool
|
||||
public static function sync(Project $project): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return false;
|
||||
@ -140,33 +137,7 @@ class ManticoreProject
|
||||
}
|
||||
|
||||
try {
|
||||
// 构建用于搜索的文本内容
|
||||
$searchableContent = self::buildSearchableContent($project);
|
||||
|
||||
// 只有明确要求时才生成向量(默认不生成,由后台任务处理)
|
||||
$embedding = null;
|
||||
if ($withVector && !empty($searchableContent) && Apps::isInstalled('ai')) {
|
||||
$embeddingResult = ManticoreBase::getEmbedding($searchableContent);
|
||||
if (!empty($embeddingResult)) {
|
||||
$embedding = '[' . implode(',', $embeddingResult) . ']';
|
||||
}
|
||||
}
|
||||
|
||||
// 获取项目成员列表(作为 allowed_users)
|
||||
$allowedUsers = self::getAllowedUsers($project->id);
|
||||
|
||||
// 写入 Manticore(含 allowed_users)
|
||||
$result = ManticoreBase::upsertProjectVector([
|
||||
'project_id' => $project->id,
|
||||
'userid' => $project->userid ?? 0,
|
||||
'personal' => $project->personal ?? 0,
|
||||
'project_name' => $project->name ?? '',
|
||||
'project_desc' => $project->desc ?? '',
|
||||
'content_vector' => $embedding,
|
||||
'allowed_users' => $allowedUsers,
|
||||
]);
|
||||
|
||||
return $result;
|
||||
return ManticoreBase::upsertProjectVector(self::buildRow($project));
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Manticore project sync error: ' . $e->getMessage(), [
|
||||
'project_id' => $project->id,
|
||||
@ -177,45 +148,55 @@ class ManticoreProject
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建可搜索的文本内容
|
||||
* 构建项目索引行数据(含 allowed_users)
|
||||
*
|
||||
* @param Project $project 项目模型
|
||||
* @return string 可搜索的文本
|
||||
* @return array 行数据
|
||||
*/
|
||||
private static function buildSearchableContent(Project $project): string
|
||||
private static function buildRow(Project $project): array
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
if (!empty($project->name)) {
|
||||
$parts[] = $project->name;
|
||||
}
|
||||
if (!empty($project->desc)) {
|
||||
$parts[] = $project->desc;
|
||||
}
|
||||
|
||||
return implode(' ', $parts);
|
||||
return [
|
||||
'id' => $project->id,
|
||||
'project_id' => $project->id,
|
||||
'userid' => $project->userid ?? 0,
|
||||
'personal' => $project->personal ?? 0,
|
||||
'project_name' => $project->name ?? '',
|
||||
'project_desc' => $project->desc ?? '',
|
||||
'allowed_users' => self::getAllowedUsers($project->id),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量同步项目
|
||||
* 批量同步项目(每块一条多行 REPLACE,向量由引擎自动生成)
|
||||
*
|
||||
* @param iterable $projects 项目列表
|
||||
* @param bool $withVector 是否同时生成向量
|
||||
* @return int 成功同步的数量
|
||||
*/
|
||||
public static function batchSync(iterable $projects, bool $withVector = false): int
|
||||
public static function batchSync(iterable $projects): int
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
$rows = [];
|
||||
foreach ($projects as $project) {
|
||||
if (self::sync($project, $withVector)) {
|
||||
$count++;
|
||||
if ($project->archived_at) {
|
||||
if (self::delete($project->id)) {
|
||||
$count++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$rows[] = self::buildRow($project);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Manticore project batchSync build error: ' . $e->getMessage(), [
|
||||
'project_id' => $project->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
return $count;
|
||||
|
||||
return $count + ManticoreBase::batchUpsertVectors('project', $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -286,84 +267,4 @@ class ManticoreProject
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 批量向量生成方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 批量生成项目向量
|
||||
* 用于后台异步处理,将已索引项目的向量批量生成
|
||||
*
|
||||
* @param array $projectIds 项目ID数组
|
||||
* @param int $batchSize 每批 embedding 数量(默认20)
|
||||
* @return int 成功处理的数量
|
||||
*/
|
||||
public static function generateVectorsBatch(array $projectIds, int $batchSize = 20): int
|
||||
{
|
||||
if (!Apps::isInstalled("search") || !Apps::isInstalled("ai") || empty($projectIds)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 查询项目信息
|
||||
$projects = Project::whereIn('id', $projectIds)
|
||||
->whereNull('archived_at')
|
||||
->get();
|
||||
|
||||
if ($projects->isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 2. 提取每个项目的内容
|
||||
$projectContents = [];
|
||||
foreach ($projects as $project) {
|
||||
$searchableContent = self::buildSearchableContent($project);
|
||||
if (!empty($searchableContent)) {
|
||||
$projectContents[$project->id] = $searchableContent;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($projectContents)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 3. 分批处理
|
||||
$successCount = 0;
|
||||
$chunks = array_chunk($projectContents, $batchSize, true);
|
||||
|
||||
foreach ($chunks as $chunk) {
|
||||
$texts = array_values($chunk);
|
||||
$ids = array_keys($chunk);
|
||||
|
||||
// 4. 批量获取 embedding
|
||||
$result = AI::getBatchEmbeddings($texts);
|
||||
if (!Base::isSuccess($result) || empty($result['data'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$embeddings = $result['data'];
|
||||
|
||||
// 5. 构建批量更新数据
|
||||
$vectorData = [];
|
||||
foreach ($ids as $index => $projectId) {
|
||||
if (!isset($embeddings[$index]) || empty($embeddings[$index])) {
|
||||
continue;
|
||||
}
|
||||
$vectorData[$projectId] = '[' . implode(',', $embeddings[$index]) . ']';
|
||||
}
|
||||
|
||||
// 6. 批量更新向量
|
||||
if (!empty($vectorData)) {
|
||||
$batchCount = ManticoreBase::batchUpdateProjectVectors($vectorData);
|
||||
$successCount += $batchCount;
|
||||
}
|
||||
}
|
||||
|
||||
return $successCount;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('ManticoreProject generateVectorsBatch error: ' . $e->getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,7 +9,6 @@ use App\Models\ProjectTaskVisibilityUser;
|
||||
use App\Models\ProjectUser;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Base;
|
||||
use App\Module\AI;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
@ -42,7 +41,8 @@ use Illuminate\Support\Facades\Log;
|
||||
class ManticoreTask
|
||||
{
|
||||
/**
|
||||
* 最大内容长度(字符)
|
||||
* 最大内容长度(字符)。向量化输入在 ai 插件侧另有 30000 字符上限
|
||||
* (main.py _EMBEDDING_INPUT_MAX_CHARS),超出部分只参与全文检索
|
||||
*/
|
||||
public const MAX_CONTENT_LENGTH = 50000; // 50K 字符
|
||||
|
||||
@ -189,13 +189,12 @@ class ManticoreTask
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 同步单个任务到 Manticore(含 allowed_users)
|
||||
* 同步单个任务到 Manticore(含 allowed_users,向量由引擎 Auto Embeddings 自动生成)
|
||||
*
|
||||
* @param ProjectTask $task 任务模型
|
||||
* @param bool $withVector 是否同时生成向量(默认 false,向量由后台任务生成)
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function sync(ProjectTask $task, bool $withVector = false): bool
|
||||
public static function sync(ProjectTask $task): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return false;
|
||||
@ -207,38 +206,7 @@ class ManticoreTask
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取任务详细内容
|
||||
$taskContent = self::getTaskContent($task);
|
||||
|
||||
// 构建用于搜索的文本内容
|
||||
$searchableContent = self::buildSearchableContent($task, $taskContent);
|
||||
|
||||
// 只有明确要求时才生成向量(默认不生成,由后台任务处理)
|
||||
$embedding = null;
|
||||
if ($withVector && !empty($searchableContent) && Apps::isInstalled('ai')) {
|
||||
$embeddingResult = ManticoreBase::getEmbedding($searchableContent);
|
||||
if (!empty($embeddingResult)) {
|
||||
$embedding = '[' . implode(',', $embeddingResult) . ']';
|
||||
}
|
||||
}
|
||||
|
||||
// 获取任务的 allowed_users
|
||||
$allowedUsers = self::getAllowedUsers($task);
|
||||
|
||||
// 写入 Manticore(含 allowed_users)
|
||||
$result = ManticoreBase::upsertTaskVector([
|
||||
'task_id' => $task->id,
|
||||
'project_id' => $task->project_id ?? 0,
|
||||
'userid' => $task->userid ?? 0,
|
||||
'visibility' => $task->visibility ?? 1,
|
||||
'task_name' => $task->name ?? '',
|
||||
'task_desc' => $task->desc ?? '',
|
||||
'task_content' => $taskContent,
|
||||
'content_vector' => $embedding,
|
||||
'allowed_users' => $allowedUsers,
|
||||
]);
|
||||
|
||||
return $result;
|
||||
return ManticoreBase::upsertTaskVector(self::buildRow($task));
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Manticore task sync error: ' . $e->getMessage(), [
|
||||
'task_id' => $task->id,
|
||||
@ -248,6 +216,27 @@ class ManticoreTask
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建任务索引行数据(含详细内容与 allowed_users)
|
||||
*
|
||||
* @param ProjectTask $task 任务模型
|
||||
* @return array 行数据
|
||||
*/
|
||||
private static function buildRow(ProjectTask $task): array
|
||||
{
|
||||
return [
|
||||
'id' => $task->id,
|
||||
'task_id' => $task->id,
|
||||
'project_id' => $task->project_id ?? 0,
|
||||
'userid' => $task->userid ?? 0,
|
||||
'visibility' => $task->visibility ?? 1,
|
||||
'task_name' => $task->name ?? '',
|
||||
'task_desc' => $task->desc ?? '',
|
||||
'task_content' => self::getTaskContent($task),
|
||||
'allowed_users' => self::getAllowedUsers($task),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务详细内容
|
||||
*
|
||||
@ -311,49 +300,36 @@ class ManticoreTask
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建可搜索的文本内容
|
||||
*
|
||||
* @param ProjectTask $task 任务模型
|
||||
* @param string $taskContent 任务详细内容
|
||||
* @return string 可搜索的文本
|
||||
*/
|
||||
private static function buildSearchableContent(ProjectTask $task, string $taskContent): string
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
if (!empty($task->name)) {
|
||||
$parts[] = $task->name;
|
||||
}
|
||||
if (!empty($task->desc)) {
|
||||
$parts[] = $task->desc;
|
||||
}
|
||||
if (!empty($taskContent)) {
|
||||
$parts[] = $taskContent;
|
||||
}
|
||||
|
||||
return implode(' ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量同步任务
|
||||
* 批量同步任务(每块一条多行 REPLACE,向量由引擎自动生成)
|
||||
*
|
||||
* @param iterable $tasks 任务列表
|
||||
* @param bool $withVector 是否同时生成向量
|
||||
* @return int 成功同步的数量
|
||||
*/
|
||||
public static function batchSync(iterable $tasks, bool $withVector = false): int
|
||||
public static function batchSync(iterable $tasks): int
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
$rows = [];
|
||||
foreach ($tasks as $task) {
|
||||
if (self::sync($task, $withVector)) {
|
||||
$count++;
|
||||
if ($task->archived_at || $task->deleted_at) {
|
||||
if (self::delete($task->id)) {
|
||||
$count++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$rows[] = self::buildRow($task);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Manticore task batchSync build error: ' . $e->getMessage(), [
|
||||
'task_id' => $task->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
return $count;
|
||||
|
||||
return $count + ManticoreBase::batchUpsertVectors('task', $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -506,88 +482,4 @@ class ManticoreTask
|
||||
Log::error('Manticore cascadeToChildren error: ' . $e->getMessage(), ['task_id' => $taskId]);
|
||||
}
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 批量向量生成方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 批量生成任务向量
|
||||
* 用于后台异步处理,将已索引任务的向量批量生成
|
||||
*
|
||||
* @param array $taskIds 任务ID数组
|
||||
* @param int $batchSize 每批 embedding 数量(默认20)
|
||||
* @return int 成功处理的数量
|
||||
*/
|
||||
public static function generateVectorsBatch(array $taskIds, int $batchSize = 20): int
|
||||
{
|
||||
if (!Apps::isInstalled("search") || !Apps::isInstalled("ai") || empty($taskIds)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 查询任务信息
|
||||
$tasks = ProjectTask::whereIn('id', $taskIds)
|
||||
->whereNull('deleted_at')
|
||||
->whereNull('archived_at')
|
||||
->get();
|
||||
|
||||
if ($tasks->isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 2. 提取每个任务的内容
|
||||
$taskContents = [];
|
||||
foreach ($tasks as $task) {
|
||||
$taskContent = self::getTaskContent($task);
|
||||
$searchableContent = self::buildSearchableContent($task, $taskContent);
|
||||
if (!empty($searchableContent)) {
|
||||
// 限制内容长度
|
||||
$searchableContent = mb_substr($searchableContent, 0, self::MAX_CONTENT_LENGTH);
|
||||
$taskContents[$task->id] = $searchableContent;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($taskContents)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 3. 分批处理
|
||||
$successCount = 0;
|
||||
$chunks = array_chunk($taskContents, $batchSize, true);
|
||||
|
||||
foreach ($chunks as $chunk) {
|
||||
$texts = array_values($chunk);
|
||||
$ids = array_keys($chunk);
|
||||
|
||||
// 4. 批量获取 embedding
|
||||
$result = AI::getBatchEmbeddings($texts);
|
||||
if (!Base::isSuccess($result) || empty($result['data'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$embeddings = $result['data'];
|
||||
|
||||
// 5. 构建批量更新数据
|
||||
$vectorData = [];
|
||||
foreach ($ids as $index => $taskId) {
|
||||
if (!isset($embeddings[$index]) || empty($embeddings[$index])) {
|
||||
continue;
|
||||
}
|
||||
$vectorData[$taskId] = '[' . implode(',', $embeddings[$index]) . ']';
|
||||
}
|
||||
|
||||
// 6. 批量更新向量
|
||||
if (!empty($vectorData)) {
|
||||
$batchCount = ManticoreBase::batchUpdateTaskVectors($vectorData);
|
||||
$successCount += $batchCount;
|
||||
}
|
||||
}
|
||||
|
||||
return $successCount;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('ManticoreTask generateVectorsBatch error: ' . $e->getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,8 +5,6 @@ namespace App\Module\Manticore;
|
||||
use App\Models\User;
|
||||
use App\Models\UserTag;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Base;
|
||||
use App\Module\AI;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
@ -123,13 +121,12 @@ class ManticoreUser
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步单个用户到 Manticore
|
||||
* 同步单个用户到 Manticore(向量由引擎 Auto Embeddings 按行内文本自动生成)
|
||||
*
|
||||
* @param User $user 用户模型
|
||||
* @param bool $withVector 是否同时生成向量(默认 false,向量由后台任务生成)
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function sync(User $user, bool $withVector = false): bool
|
||||
public static function sync(User $user): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return false;
|
||||
@ -146,33 +143,13 @@ class ManticoreUser
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取用户标签(Top 10)
|
||||
$tags = self::getUserTags($user->userid);
|
||||
|
||||
// 构建用于搜索的文本内容
|
||||
$searchableContent = self::buildSearchableContent($user, $tags);
|
||||
|
||||
// 只有明确要求时才生成向量(默认不生成,由后台任务处理)
|
||||
$embedding = null;
|
||||
if ($withVector && !empty($searchableContent) && Apps::isInstalled('ai')) {
|
||||
$embeddingResult = ManticoreBase::getEmbedding($searchableContent);
|
||||
if (!empty($embeddingResult)) {
|
||||
$embedding = '[' . implode(',', $embeddingResult) . ']';
|
||||
}
|
||||
$row = self::buildRow($user);
|
||||
// 脏检查:与已索引行完全一致则跳过。标签点赞/识别等高频事件经常不改变
|
||||
// Top-10 标签文本,跳过可省一次真实的向量化调用与整行重写
|
||||
if (self::rowUnchanged($row)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 写入 Manticore
|
||||
$result = ManticoreBase::upsertUserVector([
|
||||
'userid' => $user->userid,
|
||||
'nickname' => $user->nickname ?? '',
|
||||
'email' => $user->email ?? '',
|
||||
'profession' => $user->profession ?? '',
|
||||
'tags' => $tags,
|
||||
'introduction' => $user->introduction ?? '',
|
||||
'content_vector' => $embedding,
|
||||
]);
|
||||
|
||||
return $result;
|
||||
return ManticoreBase::upsertUserVector($row);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Manticore user sync error: ' . $e->getMessage(), [
|
||||
'userid' => $user->userid,
|
||||
@ -183,55 +160,81 @@ class ManticoreUser
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建可搜索的文本内容
|
||||
*
|
||||
* @param User $user 用户模型
|
||||
* @param string $tags 用户标签(空格分隔)
|
||||
* @return string 可搜索的文本
|
||||
* 判断待写入行与当前已索引行是否完全一致(文本字段逐一比较)
|
||||
*/
|
||||
private static function buildSearchableContent(User $user, string $tags = ''): string
|
||||
private static function rowUnchanged(array $row): bool
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
if (!empty($user->nickname)) {
|
||||
$parts[] = $user->nickname;
|
||||
$existing = (new ManticoreBase())->queryOne(
|
||||
"SELECT nickname, email, profession, tags, introduction FROM user_vectors WHERE userid = ?",
|
||||
[$row['userid']]
|
||||
);
|
||||
if (!$existing) {
|
||||
return false;
|
||||
}
|
||||
if (!empty($user->email)) {
|
||||
$parts[] = $user->email;
|
||||
foreach (['nickname', 'email', 'profession', 'tags', 'introduction'] as $field) {
|
||||
if ((string) ($existing[$field] ?? '') !== (string) $row[$field]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!empty($user->profession)) {
|
||||
$parts[] = $user->profession;
|
||||
}
|
||||
if (!empty($tags)) {
|
||||
$parts[] = $tags;
|
||||
}
|
||||
if (!empty($user->introduction)) {
|
||||
$parts[] = $user->introduction;
|
||||
}
|
||||
|
||||
return implode(' ', $parts);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量同步用户
|
||||
* 构建用户索引行数据(含标签 Top 10)
|
||||
*
|
||||
* @param User $user 用户模型
|
||||
* @return array 行数据
|
||||
*/
|
||||
private static function buildRow(User $user): array
|
||||
{
|
||||
$tags = self::getUserTags($user->userid);
|
||||
|
||||
return [
|
||||
'id' => $user->userid,
|
||||
'userid' => $user->userid,
|
||||
'nickname' => $user->nickname ?? '',
|
||||
'email' => $user->email ?? '',
|
||||
'profession' => $user->profession ?? '',
|
||||
'tags' => $tags,
|
||||
'introduction' => $user->introduction ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量同步用户(每块一条多行 REPLACE,向量由引擎自动生成)
|
||||
*
|
||||
* @param iterable $users 用户列表
|
||||
* @param bool $withVector 是否同时生成向量
|
||||
* @return int 成功同步的数量
|
||||
*/
|
||||
public static function batchSync(iterable $users, bool $withVector = false): int
|
||||
public static function batchSync(iterable $users): int
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
$rows = [];
|
||||
foreach ($users as $user) {
|
||||
if (self::sync($user, $withVector)) {
|
||||
if ($user->bot) {
|
||||
$count++;
|
||||
continue;
|
||||
}
|
||||
if ($user->disable_at) {
|
||||
if (self::delete($user->userid)) {
|
||||
$count++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$rows[] = self::buildRow($user);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Manticore user batchSync build error: ' . $e->getMessage(), [
|
||||
'userid' => $user->userid,
|
||||
]);
|
||||
}
|
||||
}
|
||||
return $count;
|
||||
|
||||
return $count + ManticoreBase::batchUpsertVectors('user', $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -276,87 +279,4 @@ class ManticoreUser
|
||||
|
||||
return ManticoreBase::getIndexedUserCount();
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 批量向量生成方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 批量生成用户向量
|
||||
* 用于后台异步处理,将已索引用户的向量批量生成
|
||||
*
|
||||
* @param array $userIds 用户ID数组
|
||||
* @param int $batchSize 每批 embedding 数量(默认20)
|
||||
* @return int 成功处理的数量
|
||||
*/
|
||||
public static function generateVectorsBatch(array $userIds, int $batchSize = 20): int
|
||||
{
|
||||
if (!Apps::isInstalled("search") || !Apps::isInstalled("ai") || empty($userIds)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 查询用户信息
|
||||
$users = User::whereIn('userid', $userIds)
|
||||
->where('bot', 0)
|
||||
->whereNull('disable_at')
|
||||
->get();
|
||||
|
||||
if ($users->isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 2. 提取每个用户的内容(包含标签)
|
||||
$userContents = [];
|
||||
foreach ($users as $user) {
|
||||
$tags = self::getUserTags($user->userid);
|
||||
$searchableContent = self::buildSearchableContent($user, $tags);
|
||||
if (!empty($searchableContent)) {
|
||||
$userContents[$user->userid] = $searchableContent;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($userContents)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 3. 分批处理
|
||||
$successCount = 0;
|
||||
$chunks = array_chunk($userContents, $batchSize, true);
|
||||
|
||||
foreach ($chunks as $chunk) {
|
||||
$texts = array_values($chunk);
|
||||
$ids = array_keys($chunk);
|
||||
|
||||
// 4. 批量获取 embedding
|
||||
$result = AI::getBatchEmbeddings($texts);
|
||||
if (!Base::isSuccess($result) || empty($result['data'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$embeddings = $result['data'];
|
||||
|
||||
// 5. 构建批量更新数据
|
||||
$vectorData = [];
|
||||
foreach ($ids as $index => $userid) {
|
||||
if (!isset($embeddings[$index]) || empty($embeddings[$index])) {
|
||||
continue;
|
||||
}
|
||||
$vectorData[$userid] = '[' . implode(',', $embeddings[$index]) . ']';
|
||||
}
|
||||
|
||||
// 6. 批量更新向量
|
||||
if (!empty($vectorData)) {
|
||||
$batchCount = ManticoreBase::batchUpdateUserVectors($vectorData);
|
||||
$successCount += $batchCount;
|
||||
}
|
||||
}
|
||||
|
||||
return $successCount;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('ManticoreUser generateVectorsBatch error: ' . $e->getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -176,6 +176,8 @@ class OnlineLicense
|
||||
|
||||
/**
|
||||
* 邮箱 + 验证码登录并签发。失败抛 ApiException。
|
||||
* 本机有多条可用授权时,appstore 返回 select_required + candidates,
|
||||
* 此处不签发、原样返回候选,由前端选定后走 loginConfirm()。
|
||||
*/
|
||||
public static function login(string $email, string $code): array
|
||||
{
|
||||
@ -183,6 +185,35 @@ class OnlineLicense
|
||||
if (!$r['ok']) {
|
||||
throw new ApiException($r['message']);
|
||||
}
|
||||
$d = $r['data'];
|
||||
if (($d['status'] ?? '') === 'select_required') {
|
||||
return [
|
||||
'select_required' => true,
|
||||
'candidates' => $d['candidates'] ?? [],
|
||||
];
|
||||
}
|
||||
$status = self::applyIssue($email, $d);
|
||||
if (!in_array($status, ['issued', 'renewed'], true)) {
|
||||
throw new ApiException(self::statusHint($status));
|
||||
}
|
||||
return self::status();
|
||||
}
|
||||
|
||||
/**
|
||||
* 多条可用授权时,用户选定 $entitlementId 后确认签发(复用同一验证码)。失败抛 ApiException。
|
||||
*/
|
||||
public static function loginConfirm(string $email, string $code, int $entitlementId): array
|
||||
{
|
||||
$payload = array_merge([
|
||||
'email' => $email,
|
||||
'code' => $code,
|
||||
'entitlement_id' => $entitlementId,
|
||||
'lang' => self::lang(),
|
||||
], self::fingerprint());
|
||||
$r = self::call('login/confirm', $payload);
|
||||
if (!$r['ok']) {
|
||||
throw new ApiException($r['message']);
|
||||
}
|
||||
$status = self::applyIssue($email, $r['data']);
|
||||
if (!in_array($status, ['issued', 'renewed'], true)) {
|
||||
throw new ApiException(self::statusHint($status));
|
||||
|
||||
@ -4,6 +4,7 @@ namespace App\Observers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Badge;
|
||||
use App\Tasks\ManticoreSyncTask;
|
||||
|
||||
class UserObserver extends AbstractObserver
|
||||
@ -80,6 +81,8 @@ class UserObserver extends AbstractObserver
|
||||
} elseif (!$originalDisableAt && $currentDisableAt) {
|
||||
// disable_at 从 null 变为有值 → 离职 (offboarded)
|
||||
Apps::dispatchUserHook($user, 'user_offboard', 'offboarded');
|
||||
// 离职清除该用户全部应用角标
|
||||
Badge::clearByUser((int)$user->userid);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@ -122,6 +125,9 @@ class UserObserver extends AbstractObserver
|
||||
if (!$user->bot) {
|
||||
Apps::dispatchUserHook($user, 'user_offboard', 'delete');
|
||||
}
|
||||
|
||||
// 清除该用户全部应用角标
|
||||
Badge::clearByUser((int)$user->userid);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -94,6 +94,27 @@ class DeleteTmpTask extends AbstractTask
|
||||
}
|
||||
break;
|
||||
|
||||
case 'tmp_chunks':
|
||||
// 分片上传残留:upload_id 目录超过 hours 小时未合并则整目录清掉
|
||||
$chunksRoot = public_path('uploads/tmp/chunks');
|
||||
if (!is_dir($chunksRoot)) {
|
||||
break;
|
||||
}
|
||||
$cutoff = time() - 3600 * $this->hours;
|
||||
foreach (glob($chunksRoot . '/*', GLOB_ONLYDIR) ?: [] as $userDir) {
|
||||
foreach (glob($userDir . '/*', GLOB_ONLYDIR) ?: [] as $uploadDir) {
|
||||
$mtime = @filemtime($uploadDir);
|
||||
if ($mtime && $mtime < $cutoff) {
|
||||
Base::deleteDirAndFile($uploadDir);
|
||||
}
|
||||
}
|
||||
// 顺手清理空 user 目录
|
||||
if (count(scandir($userDir) ?: []) <= 2) {
|
||||
@rmdir($userDir);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'user_device':
|
||||
UserDevice::where('expired_at', '<', Carbon::now()->subHours($this->hours))
|
||||
->orderBy('id')
|
||||
|
||||
@ -23,7 +23,10 @@ class LoopTask extends AbstractTask
|
||||
ProjectTask::whereBetween('loop_at', [
|
||||
Carbon::now()->subMinutes(10),
|
||||
Carbon::now()
|
||||
])->chunkById(100, function ($list) {
|
||||
])->whereHas('project', function ($query) {
|
||||
// 仅处理未删除、未归档项目的任务(Project 软删除由全局作用域排除)
|
||||
$query->whereNull('archived_at');
|
||||
})->chunkById(100, function ($list) {
|
||||
/** @var ProjectTask $item */
|
||||
foreach ($list as $item) {
|
||||
if ($item->parent_id > 0) {
|
||||
|
||||
@ -235,11 +235,8 @@ class ManticoreSyncTask extends AbstractTask
|
||||
}
|
||||
Cache::put("ManticoreSyncTask:CheckTime", time(), Carbon::now()->addMinutes(5));
|
||||
|
||||
// 执行增量全文索引同步
|
||||
// 执行增量全文索引同步(向量由 Manticore Auto Embeddings 随行自动生成)
|
||||
$this->runIncrementalSync();
|
||||
|
||||
// 执行向量生成
|
||||
$this->runVectorGeneration();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -261,22 +258,6 @@ class ManticoreSyncTask extends AbstractTask
|
||||
@shell_exec("php /var/www/artisan manticore:retry-failures 2>&1 &");
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行向量生成(兜底触发)
|
||||
*
|
||||
* 命令内部有锁机制,如果已在运行会自动跳过
|
||||
* 命令会持续处理直到无待处理数据,然后自动退出
|
||||
*/
|
||||
private function runVectorGeneration(): void
|
||||
{
|
||||
if (!Apps::isInstalled("ai")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 启动向量生成命令
|
||||
@shell_exec("php /var/www/artisan manticore:generate-vectors --type=all --batch=50 2>&1 &");
|
||||
}
|
||||
|
||||
public function end()
|
||||
{
|
||||
}
|
||||
|
||||
296
bin/install
Executable file
296
bin/install
Executable file
@ -0,0 +1,296 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# DooTask 一键安装 / 升级脚本
|
||||
#
|
||||
# 用法(在目标目录执行):
|
||||
# curl -fsSL https://raw.githubusercontent.com/kuaifan/dootask/pro/bin/install | bash
|
||||
#
|
||||
# 脚本会根据「当前目录」自动判断该做什么,无需额外参数:
|
||||
# - 空目录 : 全新安装(克隆代码到当前目录 + ./cmd install)
|
||||
# - 已克隆但未安装 : 继续安装(./cmd install)
|
||||
# - 已安装 : 检查更新,确认后用「线上最新 cmd」执行升级
|
||||
# - 非空且不是 DooTask : 拒绝操作并提示(绝不在此克隆或重置)
|
||||
#
|
||||
# 升级一次到位的关键:升级时从「线上 raw」取最新 cmd 到临时文件执行,
|
||||
# 既不依赖用户机器上那份可能过时的 cmd,也不写本地 .git(规避属主/权限问题),
|
||||
# 真正的 git pull / 依赖 / 迁移 / 重启全部交给这份最新 cmd,避免「升两次」。
|
||||
#
|
||||
# 输出语言:仅当 locale 明确是中文 UTF-8 时显示中文,否则一律英文。
|
||||
#
|
||||
|
||||
set -u
|
||||
|
||||
# ---------- 配置 ----------
|
||||
BRANCH="pro" # 全新安装默认分支(升级时跟随当前分支)
|
||||
REPO_GITHUB="https://github.com/kuaifan/dootask.git"
|
||||
REPO_GITEE="https://gitee.com/aipaw/dootask.git"
|
||||
# raw 基址:升级时取版本号与最新 cmd 用。后期可把 RAW_PRIMARY 换成官网映射的域名。
|
||||
RAW_PRIMARY="https://raw.githubusercontent.com/kuaifan/dootask" # https://<base>/<branch>/<path>
|
||||
RAW_FALLBACK="https://cdn.jsdelivr.net/gh/kuaifan/dootask" # https://<base>@<branch>/<path>
|
||||
|
||||
# ---------- 语言判定 ----------
|
||||
# 默认英文;仅当 locale 明确是「中文 UTF-8」时才用中文(中文非 UTF-8 如 GBK 也用英文以免乱码)。
|
||||
DT_LANG="en"
|
||||
__loc="${LC_ALL:-${LC_MESSAGES:-${LANG:-}}}"
|
||||
case "$__loc" in
|
||||
zh_*|zh-*|zh)
|
||||
case "$__loc" in
|
||||
*[Uu][Tt][Ff]*) DT_LANG="zh" ;; # 明确 UTF-8 → 中文
|
||||
*.*) DT_LANG="en" ;; # 其他编码(如 .GBK)→ 英文,避免乱码
|
||||
*) DT_LANG="zh" ;; # 无编码后缀(裸 zh_CN)→ 现代默认 UTF-8
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
unset __loc
|
||||
|
||||
# ---------- 文案 ----------
|
||||
# 调用处只写中文(动态值用 (*) 占位,顺序对应后续参数,与前端 $L 风格一致)。
|
||||
# 中文环境直接用原文;英文环境在此集中查表翻译,未登记的中文原样返回。
|
||||
msg() {
|
||||
local tpl="$1"; shift
|
||||
local out="$tpl"
|
||||
if [ "$DT_LANG" != "zh" ]; then
|
||||
case "$tpl" in
|
||||
"成功") out="OK" ;;
|
||||
"警告") out="WARN" ;;
|
||||
"错误") out="ERROR" ;;
|
||||
"未知") out="unknown" ;;
|
||||
"git 未安装,请先安装后重试")
|
||||
out="git is not installed. Please install it and retry." ;;
|
||||
"curl 未安装,请先安装后重试")
|
||||
out="curl is not installed. Please install it and retry." ;;
|
||||
"Docker 未安装,请先安装后重试")
|
||||
out="Docker is not installed. Please install it and retry." ;;
|
||||
"docker-compose(或 docker compose 插件)未安装,请先安装后重试")
|
||||
out="docker-compose (or the docker compose plugin) is not installed. Please install it and retry." ;;
|
||||
"当前目录为空,开始全新安装 DooTask ...")
|
||||
out="Current directory is empty. Starting a fresh DooTask installation..." ;;
|
||||
"克隆代码(GitHub)...")
|
||||
out="Cloning source (GitHub)..." ;;
|
||||
"GitHub 克隆失败,尝试 Gitee 镜像 ...")
|
||||
out="GitHub clone failed, trying the Gitee mirror..." ;;
|
||||
"代码克隆失败,请检查网络后重试")
|
||||
out="Failed to clone the source. Please check your network and retry." ;;
|
||||
"代码克隆完成")
|
||||
out="Source cloned." ;;
|
||||
"执行安装 ...")
|
||||
out="Running installation..." ;;
|
||||
"DooTask 安装完成")
|
||||
out="DooTask installation complete." ;;
|
||||
"检测到已克隆但尚未安装,执行安装 ...")
|
||||
out="Repository found but not yet installed. Running installation..." ;;
|
||||
"检测到已安装的 DooTask,正在检查更新 ...")
|
||||
out="Existing DooTask installation detected. Checking for updates..." ;;
|
||||
"无法获取远程版本信息(分支 (*)),请检查网络后重试")
|
||||
out="Unable to fetch remote version info (branch (*)). Please check your network and retry." ;;
|
||||
"当前已是最新版本(v(*))")
|
||||
out="Already up to date (v(*))." ;;
|
||||
"发现新版本:当前 v(*) → 最新 v(*)(分支 (*))")
|
||||
out="New version available: current v(*) -> latest v(*) (branch (*))." ;;
|
||||
"是否立即升级?")
|
||||
out="Upgrade now?" ;;
|
||||
"已取消升级")
|
||||
out="Upgrade cancelled." ;;
|
||||
"获取最新 cmd 失败,请检查网络后重试")
|
||||
out="Failed to fetch the latest cmd. Please check your network and retry." ;;
|
||||
"开始升级 ...")
|
||||
out="Starting upgrade..." ;;
|
||||
"DooTask 升级完成")
|
||||
out="DooTask upgrade complete." ;;
|
||||
"当前目录非空,且不是 DooTask 项目目录。")
|
||||
out="Current directory is not empty and is not a DooTask project." ;;
|
||||
"请在「空目录」中执行全新安装,或进入「已安装的 DooTask 目录」执行升级。")
|
||||
out="Run this in an empty directory for a fresh install, or inside an existing DooTask directory to upgrade." ;;
|
||||
esac
|
||||
fi
|
||||
# 动态值:依次把 (*) 替换为参数
|
||||
local a
|
||||
for a in "$@"; do
|
||||
out="${out/(\*)/$a}"
|
||||
done
|
||||
printf '%s' "$out"
|
||||
}
|
||||
|
||||
# ---------- 输出 ----------
|
||||
if [ -t 1 ]; then
|
||||
Red="\033[31m"; Green="\033[32m"; Yellow="\033[33m"; Blue="\033[36m"; Font="\033[0m"
|
||||
else
|
||||
Red=""; Green=""; Yellow=""; Blue=""; Font=""
|
||||
fi
|
||||
info() { echo -e "${Blue}==>${Font} $1"; }
|
||||
success() { echo -e "${Green}[$(msg 成功)]${Font} $1"; }
|
||||
warning() { echo -e "${Yellow}[$(msg 警告)]${Font} $1"; }
|
||||
error() { echo -e "${Red}[$(msg 错误)]${Font} $1" >&2; }
|
||||
die() { error "$1"; exit 1; }
|
||||
|
||||
# ---------- 交互输入 ----------
|
||||
# curl | bash 时 stdin 被管道占用,交互一律从 /dev/tty 读,否则 read 会读到 EOF
|
||||
has_tty() { [ -e /dev/tty ]; }
|
||||
|
||||
confirm() {
|
||||
# $1=提示语,默认 Y;无终端时返回失败(不擅自执行需确认的操作)
|
||||
local prompt="$1" ans
|
||||
has_tty || return 1
|
||||
read -r -p "$prompt [Y/n] " ans < /dev/tty
|
||||
[[ -z "$ans" || "$ans" =~ ^[Yy]([Ee][Ss])?$ ]]
|
||||
}
|
||||
|
||||
# ---------- 提权执行 ----------
|
||||
# install / update 需要 root;统一用 bash 执行脚本(规避 /tmp noexec),
|
||||
# 交互(含 cmd 内部的 read 与 sudo 密码)接到 /dev/tty。
|
||||
# git clone 不走这里,用当前用户执行,避免代码属主变成 root。
|
||||
run_cmd() {
|
||||
local script="$1"; shift
|
||||
local stdin_src="/dev/stdin"
|
||||
has_tty && stdin_src="/dev/tty"
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
bash "$script" "$@" < "$stdin_src"
|
||||
else
|
||||
sudo bash "$script" "$@" < "$stdin_src"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------- 前置检查 ----------
|
||||
precheck() {
|
||||
command -v git >/dev/null 2>&1 || die "$(msg 'git 未安装,请先安装后重试')"
|
||||
command -v curl >/dev/null 2>&1 || die "$(msg 'curl 未安装,请先安装后重试')"
|
||||
command -v docker >/dev/null 2>&1 || die "$(msg 'Docker 未安装,请先安装后重试')"
|
||||
if ! docker compose version >/dev/null 2>&1 && ! docker-compose version >/dev/null 2>&1; then
|
||||
die "$(msg 'docker-compose(或 docker compose 插件)未安装,请先安装后重试')"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------- 工具 ----------
|
||||
# 从 raw 取「指定分支的文件」到 stdout:主源失败时回退 jsdelivr 镜像
|
||||
fetch_raw() {
|
||||
# $1=branch, $2=path
|
||||
curl -fsSL "${RAW_PRIMARY}/$1/$2" 2>/dev/null \
|
||||
|| curl -fsSL "${RAW_FALLBACK}@$1/$2" 2>/dev/null
|
||||
}
|
||||
|
||||
# 从 stdin 读取 package.json 内容并提取版本号
|
||||
read_pkg_version() {
|
||||
grep -m1 '"version"' | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/'
|
||||
}
|
||||
|
||||
is_dootask_project() {
|
||||
# 只读 .git,不写;当前用户读取一般文件不受属主影响
|
||||
if [ -d .git ] && git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
local url; url="$(git config --get remote.origin.url 2>/dev/null || true)"
|
||||
[[ "$url" == *dootask* ]] && return 0
|
||||
fi
|
||||
[ -f cmd ] && [ -f docker-compose.yml ] && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
is_installed() { [ -f vendor/autoload.php ]; }
|
||||
|
||||
# 可忽略的系统垃圾文件(判断空目录时忽略;全新安装前会清除以便 git clone .)
|
||||
_IGNORABLE=".DS_Store .localized .Spotlight-V100 .fseventsd .TemporaryItems .Trashes .DocumentRevisions-V100 .VolumeIcon.icns .AppleDouble .AppleDB .AppleDesktop Thumbs.db ehthumbs.db desktop.ini .directory"
|
||||
|
||||
# 是否「可忽略的系统文件」(含 macOS AppleDouble 的 ._xxx)
|
||||
_is_ignorable() {
|
||||
case " $_IGNORABLE " in *" $1 "*) return 0 ;; esac
|
||||
case "$1" in ._*) return 0 ;; esac
|
||||
return 1
|
||||
}
|
||||
|
||||
# 当前目录是否「实质为空」:只剩可忽略的系统垃圾文件也算空
|
||||
dir_empty() {
|
||||
local f
|
||||
while IFS= read -r f; do
|
||||
_is_ignorable "${f##*/}" || return 1
|
||||
done < <(find . -maxdepth 1 -mindepth 1 2>/dev/null)
|
||||
return 0
|
||||
}
|
||||
|
||||
# 清除可忽略的系统垃圾文件(仅白名单),确保 git clone . 不被这些文件挡住
|
||||
clean_ignorable() {
|
||||
local f
|
||||
while IFS= read -r f; do
|
||||
_is_ignorable "${f##*/}" && rm -rf "$f"
|
||||
done < <(find . -maxdepth 1 -mindepth 1 2>/dev/null)
|
||||
}
|
||||
|
||||
current_branch() { git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "$BRANCH"; }
|
||||
|
||||
# ---------- 动作:全新安装 ----------
|
||||
do_fresh_install() {
|
||||
info "$(msg '当前目录为空,开始全新安装 DooTask ...')"
|
||||
clean_ignorable # 清掉 .DS_Store 等系统垃圾,确保 git clone . 不被挡住
|
||||
info "$(msg '克隆代码(GitHub)...')"
|
||||
if ! git clone --depth=1 --branch "$BRANCH" "$REPO_GITHUB" . 2>/dev/null; then
|
||||
warning "$(msg 'GitHub 克隆失败,尝试 Gitee 镜像 ...')"
|
||||
rm -rf .git # 仅清理 clone 残留;若工作树仍有残留,下一步 clone 会报错而非误删
|
||||
git clone --depth=1 --branch "$BRANCH" "$REPO_GITEE" . \
|
||||
|| die "$(msg '代码克隆失败,请检查网络后重试')"
|
||||
fi
|
||||
success "$(msg '代码克隆完成')"
|
||||
info "$(msg '执行安装 ...')"
|
||||
run_cmd ./cmd install
|
||||
success "$(msg 'DooTask 安装完成')"
|
||||
}
|
||||
|
||||
# ---------- 动作:续装 ----------
|
||||
do_install() {
|
||||
info "$(msg '检测到已克隆但尚未安装,执行安装 ...')"
|
||||
run_cmd ./cmd install
|
||||
success "$(msg 'DooTask 安装完成')"
|
||||
}
|
||||
|
||||
# ---------- 动作:升级 ----------
|
||||
do_upgrade() {
|
||||
info "$(msg '检测到已安装的 DooTask,正在检查更新 ...')"
|
||||
local branch; branch="$(current_branch)"
|
||||
|
||||
local local_ver remote_ver
|
||||
local_ver="$( [ -f package.json ] && read_pkg_version < package.json )"
|
||||
remote_ver="$(fetch_raw "$branch" package.json | read_pkg_version)"
|
||||
[ -z "$local_ver" ] && local_ver="$(msg 未知)"
|
||||
|
||||
# 取不到远程版本(网络/分支异常)→ 报错,避免误判
|
||||
[ -z "$remote_ver" ] && die "$(msg '无法获取远程版本信息(分支 (*)),请检查网络后重试' "$branch")"
|
||||
|
||||
if [ "$local_ver" = "$remote_ver" ]; then
|
||||
success "$(msg '当前已是最新版本(v(*))' "$local_ver")"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo
|
||||
info "$(msg '发现新版本:当前 v(*) → 最新 v(*)(分支 (*))' "$local_ver" "$remote_ver" "$branch")"
|
||||
if ! confirm "$(msg '是否立即升级?')"; then
|
||||
warning "$(msg '已取消升级')"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 从 raw 取「线上最新 cmd」到临时文件执行:不碰本地 .git、不依赖磁盘旧 cmd,
|
||||
# 真正的 git pull / 装依赖 / 迁移 / 重启由这份最新 cmd 完成,一次到位。
|
||||
local tmp; tmp="$(mktemp)"
|
||||
if ! fetch_raw "$branch" cmd > "$tmp" || [ ! -s "$tmp" ]; then
|
||||
rm -f "$tmp"; die "$(msg '获取最新 cmd 失败,请检查网络后重试')"
|
||||
fi
|
||||
info "$(msg '开始升级 ...')"
|
||||
run_cmd "$tmp" update
|
||||
rm -f "$tmp"
|
||||
success "$(msg 'DooTask 升级完成')"
|
||||
}
|
||||
|
||||
# ---------- 主流程 ----------
|
||||
main() {
|
||||
precheck
|
||||
if is_dootask_project; then
|
||||
if is_installed; then
|
||||
do_upgrade
|
||||
else
|
||||
do_install
|
||||
fi
|
||||
elif dir_empty; then
|
||||
do_fresh_install
|
||||
else
|
||||
error "$(msg '当前目录非空,且不是 DooTask 项目目录。')"
|
||||
error "$(msg '请在「空目录」中执行全新安装,或进入「已安装的 DooTask 目录」执行升级。')"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
main "$@"
|
||||
400
cmd
400
cmd
@ -9,10 +9,106 @@ YellowBG="\033[43;37m"
|
||||
RedBG="\033[41;37m"
|
||||
Font="\033[0m"
|
||||
|
||||
# 语言判定:默认英文;仅当 locale 明确是「中文 UTF-8」时才用中文(中文非 UTF-8 如 GBK 也用英文以免乱码)。
|
||||
DT_LANG="en"
|
||||
__loc="${LC_ALL:-${LC_MESSAGES:-${LANG:-}}}"
|
||||
case "$__loc" in
|
||||
zh_*|zh-*|zh)
|
||||
case "$__loc" in
|
||||
*[Uu][Tt][Ff]*) DT_LANG="zh" ;;
|
||||
*.*) DT_LANG="en" ;;
|
||||
*) DT_LANG="zh" ;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
unset __loc
|
||||
|
||||
# 文案:调用处只写中文(动态值用 (*) 占位,顺序对应后续参数)。
|
||||
# 中文环境直接用原文;英文环境在此集中查表翻译,未登记的中文原样返回。
|
||||
msg() {
|
||||
local tpl="$1"; shift
|
||||
local out="$tpl"
|
||||
if [ "$DT_LANG" != "zh" ]; then
|
||||
case "$tpl" in
|
||||
"警告") out="WARN" ;;
|
||||
"错误") out="ERROR" ;;
|
||||
"地址") out="URL" ;;
|
||||
"(*) 完成") out="(*) done" ;;
|
||||
"(*) 失败") out="(*) failed" ;;
|
||||
"备份数据库") out="Backing up database" ;;
|
||||
"还原数据库") out="Restoring database" ;;
|
||||
"无法创建脚本副本") out="Failed to create script copy" ;;
|
||||
"没有找到 (*) 容器!") out="Container (*) not found!" ;;
|
||||
"请使用 sudo 运行此脚本") out="Please run this script with sudo" ;;
|
||||
"未安装 Docker!") out="Docker is not installed!" ;;
|
||||
"未安装 Docker-compose!") out="Docker-compose is not installed!" ;;
|
||||
"Docker-compose 版本过低,请升级至v2+!") out="Docker-compose is too old. Please upgrade to v2+!" ;;
|
||||
"未安装 npm!") out="npm is not installed!" ;;
|
||||
"未安装 Node.js!") out="Node.js is not installed!" ;;
|
||||
"Node.js 版本过低,请升级至v20+!") out="Node.js is too old. Please upgrade to v20+!" ;;
|
||||
"备份文件:(*)") out="Backup file: (*)" ;;
|
||||
"没有备份文件!") out="No backup files found!" ;;
|
||||
"可用备份列表:") out="Available backups:" ;;
|
||||
"请输入备份文件编号还原:") out="Enter the backup number to restore: " ;;
|
||||
"编号无效,请重新输入。") out="Invalid number, please try again." ;;
|
||||
"HTTP服务端口不是80,是否修改并继续操作? [Y/n]") out="HTTP port is not 80. Change it and continue? [Y/n]" ;;
|
||||
"HTTPS服务端口不是443,是否修改并继续操作? [Y/n]") out="HTTPS port is not 443. Change it and continue? [Y/n]" ;;
|
||||
"继续操作") out="Continuing" ;;
|
||||
"操作终止") out="Operation aborted" ;;
|
||||
"任务已存在,无需添加。") out="Cron job already exists, skipped." ;;
|
||||
"任务已添加。") out="Cron job added." ;;
|
||||
"设置env参数失败!") out="Failed to set env variable!" ;;
|
||||
"APP_ID((*))已被其他实例使用:(*)") out="APP_ID ((*)) is already used by another instance: (*)" ;;
|
||||
"请先清空 .env 中的 APP_ID 和 APP_IPPR 再重新安装") out="Please clear APP_ID and APP_IPPR in .env, then reinstall" ;;
|
||||
"端口 (*) 已被占用,请指定其他端口") out="Port (*) is already in use, please specify another port" ;;
|
||||
"目录权限检测失败!请检查目录权限设置") out="Directory permission check failed! Please check directory permissions" ;;
|
||||
"目录【(*)】权限不足!") out="Directory [(*)] is not writable!" ;;
|
||||
"项目权限修复失败") out="Failed to repair project permissions" ;;
|
||||
"项目权限修复完成") out="Project permissions repaired" ;;
|
||||
"安装依赖失败") out="Failed to install dependencies" ;;
|
||||
"安装依赖失败,请重试!") out="Failed to install dependencies, please retry!" ;;
|
||||
"生成密钥失败") out="Failed to generate app key" ;;
|
||||
"数据库迁移失败") out="Database migration failed" ;;
|
||||
"安装完成") out="Installation complete" ;;
|
||||
"请先执行安装命令") out="Please run the install command first" ;;
|
||||
"检测到本地修改,是否强制更新?[Y/n]") out="Local changes detected. Force update? [Y/n]" ;;
|
||||
"取消更新,请先处理本地修改") out="Update cancelled, please handle local changes first" ;;
|
||||
"获取远程更新失败") out="Failed to fetch remote updates" ;;
|
||||
"设置远程Fetch配置失败") out="Failed to set remote fetch config" ;;
|
||||
"获取远程分支 (*) 失败") out="Failed to fetch remote branch (*)" ;;
|
||||
"切换分支到 (*) 失败") out="Failed to switch to branch (*)" ;;
|
||||
"数据库有迁移变动,执行数据库备份...") out="Database migrations changed, backing up database..." ;;
|
||||
"数据库备份失败") out="Database backup failed" ;;
|
||||
"数据库备份完成") out="Database backup complete" ;;
|
||||
"强制更新代码失败") out="Failed to force-update code" ;;
|
||||
"代码拉取失败,可能存在冲突,请使用 --force 参数") out="Failed to pull code (possible conflict), please use --force" ;;
|
||||
"更新PHP依赖失败") out="Failed to update PHP dependencies" ;;
|
||||
"执行数据库备份...") out="Backing up database..." ;;
|
||||
"重启服务失败") out="Failed to restart services" ;;
|
||||
"更新完成") out="Update complete" ;;
|
||||
"警告:此操作将永久删除以下内容:") out="WARNING: This will permanently delete:" ;;
|
||||
"- 数据库") out="- Database" ;;
|
||||
"- 应用程序") out="- Application" ;;
|
||||
"- 日志文件") out="- Log files" ;;
|
||||
"确认要继续卸载吗?(y/N): ") out="Confirm uninstall? (y/N): " ;;
|
||||
"开始卸载...") out="Uninstalling..." ;;
|
||||
"终止卸载。") out="Uninstall aborted." ;;
|
||||
"卸载完成") out="Uninstall complete" ;;
|
||||
"修改成功") out="Changed successfully" ;;
|
||||
esac
|
||||
fi
|
||||
# 动态值:依次把 (*) 替换为参数
|
||||
local a
|
||||
for a in "$@"; do
|
||||
out="${out/(\*)/$a}"
|
||||
done
|
||||
printf '%s' "$out"
|
||||
}
|
||||
|
||||
# 通知信息
|
||||
OK="${Green}[OK]${Font}"
|
||||
Warn="${Yellow}[警告]${Font}"
|
||||
Error="${Red}[错误]${Font}"
|
||||
Warn="${Yellow}[$(msg 警告)]${Font}"
|
||||
Error="${Red}[$(msg 错误)]${Font}"
|
||||
|
||||
# 基本参数
|
||||
WORK_DIR="$(pwd)"
|
||||
@ -28,7 +124,7 @@ fi
|
||||
# 缓存执行
|
||||
if [ -z "$CACHED_EXECUTION" ] && [ "$1" == "update" ]; then
|
||||
if ! cat "$0" > ._cmd 2>/dev/null; then
|
||||
error "无法创建脚本副本"
|
||||
error "$(msg '无法创建脚本副本')"
|
||||
exit 1
|
||||
fi
|
||||
chmod +x ._cmd
|
||||
@ -42,10 +138,10 @@ fi
|
||||
# 判断是否成功
|
||||
judge() {
|
||||
if [[ 0 -eq $? ]]; then
|
||||
success "$1 完成"
|
||||
success "$(msg '(*) 完成' "$1")"
|
||||
sleep 1
|
||||
else
|
||||
error "$1 失败"
|
||||
error "$(msg '(*) 失败' "$1")"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
@ -128,7 +224,50 @@ switch_debug() {
|
||||
# 检查是否有sudo
|
||||
check_sudo() {
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
error "请使用 sudo 运行此脚本"
|
||||
error "$(msg '请使用 sudo 运行此脚本')"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 修复项目权限
|
||||
permission_fix() {
|
||||
local owner_uid="${SUDO_UID:-$(id -u)}"
|
||||
local owner_gid="${SUDO_GID:-$(id -g)}"
|
||||
volumes=(
|
||||
"bootstrap/cache"
|
||||
"docker"
|
||||
"public"
|
||||
"storage"
|
||||
)
|
||||
|
||||
chmod 755 "${WORK_DIR}"
|
||||
if [ $? -ne 0 ]; then
|
||||
error "$(msg '项目权限修复失败')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for vol in "${volumes[@]}"; do
|
||||
tmp_path="${WORK_DIR}/${vol}"
|
||||
mkdir -p "${tmp_path}"
|
||||
if [ $? -ne 0 ]; then
|
||||
error "$(msg '项目权限修复失败')"
|
||||
exit 1
|
||||
fi
|
||||
chown -R "${owner_uid}:${owner_gid}" "${tmp_path}"
|
||||
if [ $? -ne 0 ]; then
|
||||
error "$(msg '项目权限修复失败')"
|
||||
exit 1
|
||||
fi
|
||||
find "${tmp_path}" -type d -exec chmod 775 {} \;
|
||||
if [ $? -ne 0 ]; then
|
||||
error "$(msg '项目权限修复失败')"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
find "${WORK_DIR}/public" -type f -exec chmod a+r {} \;
|
||||
if [ $? -ne 0 ]; then
|
||||
error "$(msg '项目权限修复失败')"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
@ -137,21 +276,21 @@ check_sudo() {
|
||||
check_docker() {
|
||||
docker --version &> /dev/null
|
||||
if [ $? -ne 0 ]; then
|
||||
error "未安装 Docker!"
|
||||
error "$(msg '未安装 Docker!')"
|
||||
exit 1
|
||||
fi
|
||||
docker-compose version &> /dev/null
|
||||
if [ $? -ne 0 ]; then
|
||||
docker compose version &> /dev/null
|
||||
if [ $? -ne 0 ]; then
|
||||
error "未安装 Docker-compose!"
|
||||
error "$(msg '未安装 Docker-compose!')"
|
||||
exit 1
|
||||
fi
|
||||
COMPOSE="docker compose"
|
||||
fi
|
||||
if [[ -n `$COMPOSE version | grep -E "\s+v1\."` ]]; then
|
||||
$COMPOSE version
|
||||
error "Docker-compose 版本过低,请升级至v2+!"
|
||||
error "$(msg 'Docker-compose 版本过低,请升级至v2+!')"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
@ -160,17 +299,17 @@ check_docker() {
|
||||
check_node() {
|
||||
npm --version &> /dev/null
|
||||
if [ $? -ne 0 ]; then
|
||||
error "未安装 npm!"
|
||||
error "$(msg '未安装 npm!')"
|
||||
exit 1
|
||||
fi
|
||||
node --version &> /dev/null
|
||||
if [ $? -ne 0 ]; then
|
||||
error "未安装 Node.js!"
|
||||
error "$(msg '未安装 Node.js!')"
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n `node --version | grep -E "v1"` ]]; then
|
||||
node --version
|
||||
error "Node.js 版本过低,请升级至v20+!"
|
||||
error "$(msg 'Node.js 版本过低,请升级至v20+!')"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
@ -180,6 +319,19 @@ docker_name() {
|
||||
echo `$COMPOSE ps | awk '{print $1}' | grep "\-$1\-"`
|
||||
}
|
||||
|
||||
# 等待 php 容器健康(最多约 90s)
|
||||
wait_php_healthy() {
|
||||
local name st wait=0
|
||||
name="$(docker_name php)"
|
||||
[ -z "$name" ] && return 0
|
||||
while [ $wait -lt 90 ]; do
|
||||
st="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$name" 2>/dev/null)"
|
||||
{ [ "$st" = "healthy" ] || [ "$st" = "running" ]; } && break
|
||||
sleep 3
|
||||
wait=$((wait + 3))
|
||||
done
|
||||
}
|
||||
|
||||
# 编译前端
|
||||
web_build() {
|
||||
local type=$1
|
||||
@ -244,12 +396,24 @@ container_exec() {
|
||||
local cmd=$@
|
||||
local name=$(docker_name "$container")
|
||||
if [ -z "$name" ]; then
|
||||
error "没有找到 ${container} 容器!"
|
||||
error "$(msg '没有找到 (*) 容器!' "$container")"
|
||||
exit 1
|
||||
fi
|
||||
docker exec $TTY_FLAG "$name" /bin/sh -c "$cmd"
|
||||
}
|
||||
|
||||
# 使用当前 docker-compose.yml 定义的服务镜像执行一次性容器命令
|
||||
container_run() {
|
||||
local container=$1
|
||||
shift 1
|
||||
local cmd=$@
|
||||
if [ -t 0 ] && [ -t 1 ]; then
|
||||
$COMPOSE run --rm --entrypoint /bin/sh "$container" -c "$cmd"
|
||||
else
|
||||
$COMPOSE run --rm -T --entrypoint /bin/sh "$container" -c "$cmd"
|
||||
fi
|
||||
}
|
||||
|
||||
# 备份数据库、还原数据库
|
||||
mysql_snapshot() {
|
||||
if [ "$1" = "backup" ]; then
|
||||
@ -260,8 +424,8 @@ mysql_snapshot() {
|
||||
mkdir -p ${WORK_DIR}/docker/mysql/backup
|
||||
filename="${WORK_DIR}/docker/mysql/backup/${database}_$(date "+%Y%m%d%H%M%S").sql.gz"
|
||||
container_exec mariadb "exec mysqldump --databases $database -u${username} -p${password}" | gzip > $filename
|
||||
judge "备份数据库"
|
||||
[ -f "$filename" ] && echo "备份文件:${filename}"
|
||||
judge "$(msg '备份数据库')"
|
||||
[ -f "$filename" ] && echo "$(msg '备份文件:(*)' "$filename")"
|
||||
elif [ "$1" = "recovery" ]; then
|
||||
database=$(env_get DB_DATABASE)
|
||||
username=$(env_get DB_USERNAME)
|
||||
@ -272,31 +436,31 @@ mysql_snapshot() {
|
||||
backup_files=("${WORK_DIR}/docker/mysql/backup/"*.sql.gz)
|
||||
shopt -u nullglob
|
||||
if [ ${#backup_files[@]} -eq 0 ]; then
|
||||
error "没有备份文件!"
|
||||
error "$(msg '没有备份文件!')"
|
||||
exit 1
|
||||
fi
|
||||
echo "可用备份列表:"
|
||||
echo "$(msg '可用备份列表:')"
|
||||
for idx in "${!backup_files[@]}"; do
|
||||
printf "%2d) %s\n" "$((idx + 1))" "$(basename "${backup_files[$idx]}")"
|
||||
done
|
||||
while true; do
|
||||
read -rp "请输入备份文件编号还原:" selection
|
||||
read -rp "$(msg '请输入备份文件编号还原:')" selection
|
||||
if [[ "$selection" =~ ^[0-9]+$ ]] && [ "$selection" -ge 1 ] && [ "$selection" -le ${#backup_files[@]} ]; then
|
||||
break
|
||||
fi
|
||||
warning "编号无效,请重新输入。"
|
||||
warning "$(msg '编号无效,请重新输入。')"
|
||||
done
|
||||
filename="${backup_files[$((selection - 1))]}"
|
||||
inputname="$(basename "$filename")"
|
||||
container_name=`docker_name mariadb`
|
||||
if [ -z "$container_name" ]; then
|
||||
error "没有找到 mariadb 容器!"
|
||||
error "$(msg '没有找到 (*) 容器!' mariadb)"
|
||||
exit 1
|
||||
fi
|
||||
docker cp "$filename" "${container_name}:/"
|
||||
container_exec mariadb "gunzip < '/${inputname}' | mysql -u${username} -p${password} $database"
|
||||
container_exec php "php artisan migrate"
|
||||
judge "还原数据库"
|
||||
judge "$(msg '还原数据库')"
|
||||
fi
|
||||
}
|
||||
|
||||
@ -327,33 +491,33 @@ remove_by_network() {
|
||||
https_auto() {
|
||||
restart_nginx="n"
|
||||
if [[ "$(env_get APP_PORT)" != "80" ]]; then
|
||||
warning "HTTP服务端口不是80,是否修改并继续操作? [Y/n]"
|
||||
warning "$(msg 'HTTP服务端口不是80,是否修改并继续操作? [Y/n]')"
|
||||
read -r continue_http
|
||||
[[ -z ${continue_http} ]] && continue_http="Y"
|
||||
case $continue_http in
|
||||
[yY][eE][sS] | [yY])
|
||||
success "继续操作"
|
||||
success "$(msg '继续操作')"
|
||||
env_set "APP_PORT" "80"
|
||||
restart_nginx="y"
|
||||
;;
|
||||
*)
|
||||
error "操作终止"
|
||||
error "$(msg '操作终止')"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
if [[ "$(env_get APP_SSL_PORT)" != "443" ]]; then
|
||||
warning "HTTPS服务端口不是443,是否修改并继续操作? [Y/n]"
|
||||
warning "$(msg 'HTTPS服务端口不是443,是否修改并继续操作? [Y/n]')"
|
||||
read -r continue_https
|
||||
[[ -z ${continue_https} ]] && continue_https="Y"
|
||||
case $continue_https in
|
||||
[yY][eE][sS] | [yY])
|
||||
success "继续操作"
|
||||
success "$(msg '继续操作')"
|
||||
env_set "APP_SSL_PORT" "443"
|
||||
restart_nginx="y"
|
||||
;;
|
||||
*)
|
||||
error "操作终止"
|
||||
error "$(msg '操作终止')"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@ -368,13 +532,13 @@ https_auto() {
|
||||
new_job="* 6 * * * docker run --rm -v $(pwd):/work nginx:alpine sh /work/bin/https renew"
|
||||
current_crontab=$(crontab -l 2>/dev/null)
|
||||
if ! echo "$current_crontab" | grep -v "https renew"; then
|
||||
echo "任务已存在,无需添加。"
|
||||
echo "$(msg '任务已存在,无需添加。')"
|
||||
else
|
||||
crontab -l |{
|
||||
cat
|
||||
echo "$new_job"
|
||||
} | crontab -
|
||||
echo "任务已添加。"
|
||||
echo "$(msg '任务已添加。')"
|
||||
fi
|
||||
}
|
||||
|
||||
@ -404,7 +568,7 @@ env_set() {
|
||||
docker run $TTY_FLAG --rm -v ${WORK_DIR}:/www nginx:alpine sh -c "sed -i "/^${key}=/c\\${key}=${val}" /www/.env"
|
||||
fi
|
||||
if [ $? -ne 0 ]; then
|
||||
error "设置env参数失败!"
|
||||
error "$(msg '设置env参数失败!')"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
@ -456,7 +620,8 @@ arg_get() {
|
||||
|
||||
# 显示帮助信息
|
||||
show_help() {
|
||||
cat << 'EOF'
|
||||
if [ "$DT_LANG" = "zh" ]; then
|
||||
cat << 'EOF'
|
||||
DooTask 管理脚本
|
||||
|
||||
用法: ./cmd <命令> [参数]
|
||||
@ -465,6 +630,7 @@ DooTask 管理脚本
|
||||
install 安装 DooTask (支持 --port <端口> --relock)
|
||||
update 更新 DooTask (支持 --branch <分支> --force --local)
|
||||
uninstall 卸载 DooTask
|
||||
permission 修复整个项目的权限
|
||||
|
||||
⚙️ 配置管理:
|
||||
port <端口> 修改服务端口
|
||||
@ -501,9 +667,62 @@ DooTask 管理脚本
|
||||
示例:
|
||||
./cmd install --port 8080 安装并指定端口 8080
|
||||
./cmd update --branch dev 切换到 dev 分支并更新
|
||||
./cmd permission 修复整个项目的权限
|
||||
./cmd mysql backup 备份数据库
|
||||
./cmd artisan migrate 执行数据库迁移
|
||||
EOF
|
||||
else
|
||||
cat << 'EOF'
|
||||
DooTask Management Script
|
||||
|
||||
Usage: ./cmd <command> [options]
|
||||
|
||||
📦 Core:
|
||||
install Install DooTask (supports --port <port> --relock)
|
||||
update Update DooTask (supports --branch <branch> --force --local)
|
||||
uninstall Uninstall DooTask
|
||||
permission Repair permissions for the whole project
|
||||
|
||||
⚙️ Configuration:
|
||||
port <port> Change service port
|
||||
url <address> Change access URL
|
||||
env <key> <value> Set environment variable
|
||||
debug [true|false] Toggle debug mode
|
||||
repassword [username] Reset database password
|
||||
|
||||
🚀 Build:
|
||||
serve, dev Start dev mode
|
||||
build, prod Production build
|
||||
electron Build desktop app
|
||||
|
||||
🔧 Services:
|
||||
up [service] Start containers
|
||||
down [service] Stop containers
|
||||
restart [service] Restart containers
|
||||
reup Rebuild and start
|
||||
|
||||
💾 Database:
|
||||
mysql backup Back up database
|
||||
mysql recovery Restore database
|
||||
|
||||
🛠️ Dev tools:
|
||||
artisan <command> Run Laravel Artisan command
|
||||
composer <command> Run Composer command
|
||||
php <command> Run PHP command
|
||||
|
||||
📚 Others:
|
||||
doc Generate API docs
|
||||
https Configure HTTPS
|
||||
--help, -h Show this help
|
||||
|
||||
Examples:
|
||||
./cmd install --port 8080 Install on port 8080
|
||||
./cmd update --branch dev Switch to dev branch and update
|
||||
./cmd permission Repair permissions for the whole project
|
||||
./cmd mysql backup Back up database
|
||||
./cmd artisan migrate Run database migration
|
||||
EOF
|
||||
fi
|
||||
}
|
||||
|
||||
# 检测APP_ID是否与其他实例冲突
|
||||
@ -512,8 +731,8 @@ check_instance() {
|
||||
local container_name="dootask-php-${app_id}"
|
||||
local mount_path=$(docker inspect "$container_name" --format '{{range .Mounts}}{{if eq .Destination "/var/www"}}{{.Source}}{{end}}{{end}}' 2>/dev/null)
|
||||
if [[ -n "$mount_path" ]] && [[ "$mount_path" != "$WORK_DIR" ]]; then
|
||||
error "APP_ID(${app_id})已被其他实例使用:${mount_path}"
|
||||
error "请先清空 .env 中的 APP_ID 和 APP_IPPR 再重新安装"
|
||||
error "$(msg 'APP_ID((*))已被其他实例使用:(*)' "$app_id" "$mount_path")"
|
||||
error "$(msg '请先清空 .env 中的 APP_ID 和 APP_IPPR 再重新安装')"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
@ -525,7 +744,7 @@ check_port() {
|
||||
local current_port=$2
|
||||
if [[ "$port" -gt 0 ]] && [[ "$port" != "$current_port" ]]; then
|
||||
if ! docker run --rm -p "${port}:80" --entrypoint true nginx:alpine 2>/dev/null; then
|
||||
error "端口 ${port} 已被占用,请指定其他端口"
|
||||
error "$(msg '端口 (*) 已被占用,请指定其他端口' "$port")"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
@ -544,20 +763,12 @@ handle_install() {
|
||||
rm -rf node_modules package-lock.json vendor composer.lock
|
||||
fi
|
||||
|
||||
# 目录权限设置
|
||||
volumes=(
|
||||
"bootstrap/cache"
|
||||
"docker"
|
||||
"public"
|
||||
"storage"
|
||||
)
|
||||
# 目录和静态文件权限设置
|
||||
permission_fix
|
||||
cmda=""
|
||||
cmdb=""
|
||||
for vol in "${volumes[@]}"; do
|
||||
tmp_path="${WORK_DIR}/${vol}"
|
||||
mkdir -p "${tmp_path}"
|
||||
find "${tmp_path}" -type d -exec chmod 775 {} \;
|
||||
|
||||
rm -f "${tmp_path}/dootask.lock"
|
||||
cmda="${cmda} -v ${tmp_path}:/usr/share/${vol}"
|
||||
cmdb="${cmdb} touch /usr/share/${vol}/dootask.lock &&"
|
||||
@ -570,13 +781,13 @@ handle_install() {
|
||||
writable="yes"
|
||||
docker run --rm ${cmda} nginx:alpine sh -c "${cmdb} touch /usr/share/docker/dootask.lock" &> /dev/null
|
||||
if [ $? -ne 0 ]; then
|
||||
error "目录权限检测失败!请检查目录权限设置"
|
||||
error "$(msg '目录权限检测失败!请检查目录权限设置')"
|
||||
exit 1
|
||||
fi
|
||||
for vol in "${volumes[@]}"; do
|
||||
if [ ! -f "${vol}/dootask.lock" ]; then
|
||||
if [ $remaining -lt 0 ]; then
|
||||
error "目录【${vol}】权限不足!"
|
||||
error "$(msg '目录【(*)】权限不足!' "$vol")"
|
||||
exit 1
|
||||
else
|
||||
writable="no"
|
||||
@ -607,28 +818,32 @@ handle_install() {
|
||||
$COMPOSE up php -d
|
||||
|
||||
# 安装PHP依赖
|
||||
exec_judge "container_exec php 'composer install --optimize-autoloader'" "安装依赖失败"
|
||||
exec_judge "container_exec php 'composer install --optimize-autoloader'" "$(msg '安装依赖失败')"
|
||||
|
||||
# 最终检查
|
||||
if [ ! -f "${WORK_DIR}/vendor/autoload.php" ]; then
|
||||
error "安装依赖失败,请重试!"
|
||||
error "$(msg '安装依赖失败,请重试!')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 生成应用密钥
|
||||
[[ -z "$(env_get APP_KEY)" ]] && exec_judge "container_exec php 'php artisan key:generate'" "生成密钥失败"
|
||||
[[ -z "$(env_get APP_KEY)" ]] && exec_judge "container_exec php 'php artisan key:generate'" "$(msg '生成密钥失败')"
|
||||
|
||||
# 设置生产模式
|
||||
switch_debug "false"
|
||||
|
||||
# 数据库迁移
|
||||
exec_judge "container_exec php 'php artisan migrate --seed'" "数据库迁移失败"
|
||||
exec_judge "container_exec php 'php artisan migrate --seed'" "$(msg '数据库迁移失败')"
|
||||
|
||||
# 启动所有容器
|
||||
$COMPOSE up -d --remove-orphans
|
||||
|
||||
success "安装完成"
|
||||
echo -e "地址: http://${GreenBG}127.0.0.1:$(env_get APP_PORT)${Font}"
|
||||
# 兜底拉起 nginx(避免首启时序竞态)
|
||||
wait_php_healthy
|
||||
[ -z "$(docker_name nginx)" ] && $COMPOSE up -d --remove-orphans
|
||||
|
||||
success "$(msg '安装完成')"
|
||||
echo -e "$(msg '地址'): http://${GreenBG}127.0.0.1:$(env_get APP_PORT)${Font}"
|
||||
container_exec mariadb "sh /etc/mysql/repassword.sh"
|
||||
}
|
||||
|
||||
@ -642,7 +857,7 @@ handle_update() {
|
||||
|
||||
# 检查是否已经安装
|
||||
if [ ! -f "${WORK_DIR}/vendor/autoload.php" ]; then
|
||||
error "请先执行安装命令"
|
||||
error "$(msg '请先执行安装命令')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@ -652,10 +867,13 @@ handle_update() {
|
||||
fi
|
||||
|
||||
if [[ -z "$is_local" ]]; then
|
||||
# 信任项目目录,避免 git 归属检查拦截
|
||||
git config --global --get-all safe.directory 2>/dev/null | grep -qxF "${WORK_DIR}" \
|
||||
|| git config --global --add safe.directory "${WORK_DIR}" 2>/dev/null
|
||||
# 检查本地修改
|
||||
if ! git diff --quiet || ! git diff --cached --quiet; then
|
||||
if [[ "$force_update" != "yes" ]]; then
|
||||
warning "检测到本地修改,是否强制更新?[Y/n]"
|
||||
warning "$(msg '检测到本地修改,是否强制更新?[Y/n]')"
|
||||
read -r confirm_force
|
||||
[[ -z ${confirm_force} ]] && confirm_force="Y"
|
||||
case $confirm_force in
|
||||
@ -663,7 +881,7 @@ handle_update() {
|
||||
force_update="yes"
|
||||
;;
|
||||
*)
|
||||
error "取消更新,请先处理本地修改"
|
||||
error "$(msg '取消更新,请先处理本地修改')"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@ -671,21 +889,21 @@ handle_update() {
|
||||
fi
|
||||
|
||||
# 远程更新模式
|
||||
exec_judge "git fetch --all" "获取远程更新失败"
|
||||
exec_judge "git fetch --all" "$(msg '获取远程更新失败')"
|
||||
|
||||
# 确定目标分支
|
||||
if [[ -n "$target_branch" ]]; then
|
||||
current_branch="$target_branch"
|
||||
if ! git config --get "branch.${current_branch}.remote" | grep -q "origin"; then
|
||||
exec_judge "git config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*'" "设置远程Fetch配置失败"
|
||||
exec_judge "git config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*'" "$(msg '设置远程Fetch配置失败')"
|
||||
fi
|
||||
if ! git show-ref --verify --quiet refs/heads/${current_branch}; then
|
||||
exec_judge "git fetch origin ${current_branch}:${current_branch}" "获取远程分支 ${current_branch} 失败"
|
||||
exec_judge "git fetch origin ${current_branch}:${current_branch}" "$(msg '获取远程分支 (*) 失败' "$current_branch")"
|
||||
fi
|
||||
if [[ "$force_update" == "yes" ]]; then
|
||||
exec_judge "git checkout -f ${current_branch}" "切换分支到 ${current_branch} 失败"
|
||||
exec_judge "git checkout -f ${current_branch}" "$(msg '切换分支到 (*) 失败' "$current_branch")"
|
||||
else
|
||||
exec_judge "git checkout ${current_branch}" "切换分支到 ${current_branch} 失败"
|
||||
exec_judge "git checkout ${current_branch}" "$(msg '切换分支到 (*) 失败' "$current_branch")"
|
||||
fi
|
||||
else
|
||||
current_branch=$(git branch | sed -n -e 's/^\* \(.*\)/\1/p')
|
||||
@ -694,27 +912,27 @@ handle_update() {
|
||||
# 检查数据库迁移变动
|
||||
db_changes=$(git diff --name-only HEAD..origin/${current_branch} 2>/dev/null | grep -E "^database/" || true)
|
||||
if [[ -n "$db_changes" ]]; then
|
||||
echo "数据库有迁移变动,执行数据库备份..."
|
||||
exec_judge "mysql_snapshot backup" "数据库备份失败" "数据库备份完成"
|
||||
echo "$(msg '数据库有迁移变动,执行数据库备份...')"
|
||||
exec_judge "mysql_snapshot backup" "$(msg '数据库备份失败')" "$(msg '数据库备份完成')"
|
||||
fi
|
||||
|
||||
# 更新代码
|
||||
if [[ "$force_update" == "yes" ]]; then
|
||||
exec_judge "git reset --hard origin/${current_branch}" "强制更新代码失败"
|
||||
exec_judge "git reset --hard origin/${current_branch}" "$(msg '强制更新代码失败')"
|
||||
else
|
||||
exec_judge "git pull --ff-only origin ${current_branch}" "代码拉取失败,可能存在冲突,请使用 --force 参数"
|
||||
exec_judge "git pull --ff-only origin ${current_branch}" "$(msg '代码拉取失败,可能存在冲突,请使用 --force 参数')"
|
||||
fi
|
||||
|
||||
# 更新依赖
|
||||
exec_judge "container_exec php 'composer install --optimize-autoloader'" "更新PHP依赖失败"
|
||||
exec_judge "container_run php 'composer install --optimize-autoloader'" "$(msg '更新PHP依赖失败')"
|
||||
else
|
||||
# 本地更新模式
|
||||
echo "执行数据库备份..."
|
||||
exec_judge "mysql_snapshot backup" "数据库备份失败" "数据库备份完成"
|
||||
echo "$(msg '执行数据库备份...')"
|
||||
exec_judge "mysql_snapshot backup" "$(msg '数据库备份失败')" "$(msg '数据库备份完成')"
|
||||
fi
|
||||
|
||||
# 数据库迁移
|
||||
exec_judge "container_exec php 'php artisan migrate'" "数据库迁移失败"
|
||||
exec_judge "container_run php 'php artisan migrate'" "$(msg '数据库迁移失败')"
|
||||
|
||||
# 停止服务
|
||||
$COMPOSE stop php nginx &> /dev/null
|
||||
@ -724,30 +942,34 @@ handle_update() {
|
||||
$COMPOSE up -d --remove-orphans
|
||||
if [[ 0 -ne $? ]]; then
|
||||
$COMPOSE down --remove-orphans
|
||||
exec_judge "$COMPOSE up -d" "重启服务失败"
|
||||
exec_judge "$COMPOSE up -d" "$(msg '重启服务失败')"
|
||||
fi
|
||||
|
||||
# 兜底拉起 nginx(避免首启时序竞态)
|
||||
wait_php_healthy
|
||||
[ -z "$(docker_name nginx)" ] && $COMPOSE up -d --remove-orphans
|
||||
|
||||
env_set UPDATE_TIME "$(date +%s)"
|
||||
success "更新完成"
|
||||
success "$(msg '更新完成')"
|
||||
}
|
||||
|
||||
# 卸载函数
|
||||
handle_uninstall() {
|
||||
check_sudo
|
||||
# 确认卸载
|
||||
echo -e "${RedBG}警告:此操作将永久删除以下内容:${Font}"
|
||||
echo "- 数据库"
|
||||
echo "- 应用程序"
|
||||
echo "- 日志文件"
|
||||
echo -e "${RedBG}$(msg '警告:此操作将永久删除以下内容:')${Font}"
|
||||
echo "$(msg '- 数据库')"
|
||||
echo "$(msg '- 应用程序')"
|
||||
echo "$(msg '- 日志文件')"
|
||||
echo ""
|
||||
read -rp "确认要继续卸载吗?(y/N): " confirm_uninstall
|
||||
read -rp "$(msg '确认要继续卸载吗?(y/N): ')" confirm_uninstall
|
||||
[[ -z ${confirm_uninstall} ]] && confirm_uninstall="N"
|
||||
case $confirm_uninstall in
|
||||
[yY][eE][sS] | [yY])
|
||||
echo -e "${RedBG}开始卸载...${Font}"
|
||||
echo -e "${RedBG}$(msg '开始卸载...')${Font}"
|
||||
;;
|
||||
*)
|
||||
echo -e "${GreenBG}终止卸载。${Font}"
|
||||
echo -e "${GreenBG}$(msg '终止卸载。')${Font}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@ -755,8 +977,8 @@ handle_uninstall() {
|
||||
# 清理网络相关容器
|
||||
remove_by_network
|
||||
|
||||
# 停止并删除容器
|
||||
$COMPOSE down --remove-orphans
|
||||
# 停止并删除容器(含命名卷)
|
||||
$COMPOSE down --remove-orphans --volumes
|
||||
|
||||
# 重置调试模式
|
||||
env_set APP_DEBUG "false"
|
||||
@ -768,7 +990,7 @@ handle_uninstall() {
|
||||
find "./docker/appstore/log" -name "*.log" -delete 2>/dev/null
|
||||
find "./storage/logs" -name "*.log" -delete 2>/dev/null
|
||||
|
||||
success "卸载完成"
|
||||
success "$(msg '卸载完成')"
|
||||
}
|
||||
|
||||
####################################################################################
|
||||
@ -781,8 +1003,8 @@ if [[ "$1" == "help" ]] || [[ "$1" == "--help" ]] || [[ "$1" == "-h" ]] || [[ $#
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 非electron命令需要检查Docker环境
|
||||
if [[ "$1" != "electron" ]]; then
|
||||
# 非electron和permission命令需要检查Docker环境
|
||||
if [[ "$1" != "electron" ]] && [[ "$1" != "permission" ]]; then
|
||||
check_docker
|
||||
env_init
|
||||
fi
|
||||
@ -801,19 +1023,25 @@ case "$1" in
|
||||
shift 1
|
||||
handle_uninstall
|
||||
;;
|
||||
"permission")
|
||||
shift 1
|
||||
check_sudo
|
||||
permission_fix
|
||||
success "$(msg '项目权限修复完成')"
|
||||
;;
|
||||
"port")
|
||||
shift 1
|
||||
check_port "$1" "$(env_get APP_PORT)"
|
||||
env_set APP_PORT "$1"
|
||||
$COMPOSE up -d
|
||||
success "修改成功"
|
||||
echo -e "地址: http://${GreenBG}127.0.0.1:$(env_get APP_PORT)${Font}"
|
||||
success "$(msg '修改成功')"
|
||||
echo -e "$(msg '地址'): http://${GreenBG}127.0.0.1:$(env_get APP_PORT)${Font}"
|
||||
;;
|
||||
"url")
|
||||
shift 1
|
||||
env_set APP_URL "$1"
|
||||
restart_php
|
||||
success "修改成功"
|
||||
success "$(msg '修改成功')"
|
||||
;;
|
||||
"env")
|
||||
shift 1
|
||||
@ -821,7 +1049,7 @@ case "$1" in
|
||||
env_set $1 "$2"
|
||||
fi
|
||||
restart_php
|
||||
success "修改成功"
|
||||
success "$(msg '修改成功')"
|
||||
;;
|
||||
"repassword")
|
||||
shift 1
|
||||
|
||||
@ -26,6 +26,12 @@ return [
|
||||
// Manticore 全文搜索服务端口(ManticoreBase)
|
||||
'search_port' => env('SEARCH_PORT', 9306),
|
||||
|
||||
// AI 插件服务主机(AI::getEmbedding 走 ai 插件 /embeddings 免费向量模型)
|
||||
'ai_host' => env('AI_HOST', 'ai'),
|
||||
|
||||
// AI 插件服务端口(AI::getEmbedding)
|
||||
'ai_port' => env('AI_PORT', 5001),
|
||||
|
||||
// 文件回收站自动清空天数(DeleteTmpTask)
|
||||
'auto_empty_file_recycle' => env('AUTO_EMPTY_FILE_RECYCLE', 365),
|
||||
|
||||
|
||||
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class CreateAppBadgesTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
if (Schema::hasTable('app_badges')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::create('app_badges', function (Blueprint $table) {
|
||||
$table->bigIncrements('id');
|
||||
$table->string('app_id', 100)->default('')->comment('应用ID(appstore 插件 appid 或自定义微应用 id)');
|
||||
$table->string('menu_key', 100)->default('')->comment('菜单稳定标识;空串表示该应用的第一个菜单');
|
||||
$table->bigInteger('userid')->comment('用户ID');
|
||||
$table->integer('count')->default(0)->comment('角标数字');
|
||||
$table->boolean('dot')->default(false)->comment('是否显示红点');
|
||||
$table->timestamp('updated_at')->nullable()->comment('更新时间');
|
||||
//
|
||||
$table->unique(['app_id', 'menu_key', 'userid'], 'app_badges_unique');
|
||||
$table->index('userid', 'app_badges_userid');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::dropIfExists('app_badges');
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
Schema::table('files', function (Blueprint $table) {
|
||||
$table->char('hash', 32)->nullable()->after('size')->comment('文件内容 md5(分片上传秒传用)');
|
||||
$table->index('hash', 'files_hash_index');
|
||||
});
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
Schema::table('files', function (Blueprint $table) {
|
||||
$table->dropIndex('files_hash_index');
|
||||
$table->dropColumn('hash');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
Schema::table('project_tasks', function (Blueprint $table) {
|
||||
$table->index(
|
||||
['project_id', 'parent_id', 'visibility', 'archived_at', 'deleted_at', 'complete_at', 'end_at'],
|
||||
'idx_pt_dashboard_due'
|
||||
);
|
||||
$table->index(
|
||||
['project_id', 'parent_id', 'visibility', 'archived_at', 'deleted_at', 'complete_at', 'p_level'],
|
||||
'idx_pt_dashboard_priority'
|
||||
);
|
||||
});
|
||||
|
||||
Schema::table('project_task_users', function (Blueprint $table) {
|
||||
$table->index(['task_id', 'owner', 'userid'], 'idx_ptu_task_owner_user');
|
||||
$table->index(['userid', 'owner', 'task_id'], 'idx_ptu_user_owner_task');
|
||||
});
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
Schema::table('project_tasks', function (Blueprint $table) {
|
||||
$table->dropIndex('idx_pt_dashboard_due');
|
||||
$table->dropIndex('idx_pt_dashboard_priority');
|
||||
});
|
||||
|
||||
Schema::table('project_task_users', function (Blueprint $table) {
|
||||
$table->dropIndex('idx_ptu_task_owner_user');
|
||||
$table->dropIndex('idx_ptu_user_owner_task');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -99,7 +99,7 @@ services:
|
||||
appstore:
|
||||
container_name: "dootask-appstore-${APP_ID}"
|
||||
privileged: true
|
||||
image: "dootask/appstore:0.5.2"
|
||||
image: "dootask/appstore:0.5.4"
|
||||
volumes:
|
||||
- shared_data:/usr/share/dootask
|
||||
- ${HOST_DOCKER_SOCK:-/var/run/docker.sock}:/var/run/docker.sock
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
"allowJs": true,
|
||||
"checkJs": false,
|
||||
"jsx": "preserve",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
|
||||
@ -1018,3 +1018,28 @@ AI 助手
|
||||
在线授权已失效,已回落到基础版
|
||||
请输入邮箱
|
||||
请输入邮箱和验证码
|
||||
密钥无效
|
||||
应用未安装
|
||||
菜单不存在
|
||||
源文件不存在
|
||||
文件 hash 格式错误
|
||||
文件大小无效
|
||||
文件超过系统支持的最大尺寸
|
||||
文件名不能为空
|
||||
不支持的上传场景
|
||||
上传会话不存在或已过期
|
||||
上传会话归属错误
|
||||
分片序号超出范围
|
||||
分片数据无效
|
||||
分片大小不符合预期
|
||||
末尾分片大小不符合预期
|
||||
分片读取失败:(*)
|
||||
分片不完整,无法合并
|
||||
无法创建合并文件
|
||||
文件校验失败,请重试
|
||||
scene 暂未实现:(*)
|
||||
upload_id 不能为空
|
||||
合并繁忙,请稍后再试
|
||||
仅项目负责人或任务相关成员删除
|
||||
未开启部门负责人视角功能
|
||||
没有可查看的部门数据
|
||||
|
||||
@ -1,10 +1,5 @@
|
||||
模版满足多种团队协作场景,同时支持自定义模版,满足团队个性化场景管理需求,可直观的查看项目的进展情况,团队协作更方便。
|
||||
2、若你是团队的所有者,请在删除您的帐号前转移所有权。例如该帐号所创建的项目(可将项目移交他人或删除项目)以及文件夹。
|
||||
汇集文档、电子表格、思维笔记等多种在线工具,汇聚企业知识资源于一处,支持多人实时协同编辑,让团队协作更便捷。
|
||||
多平台应用支持,打开客户端即可跟进项目任务进度, 同时让你在工作中每一个步骤都能拥有更高效愉悦的体验。
|
||||
1、您将无法查看该帐号内的任何信息,包括帐号信息、文件记录、聊天记录、项目信息、团队成员信息等。
|
||||
通过灵活的任务日历,轻松安排每一天的日程,把任务拆解到每天,让工作目标更清晰,时间分配更合理。
|
||||
针对项目和任务建立群组,工作问题可及时沟通,促进团队快速协作,提高团队工作效率。
|
||||
帐号删除后,该帐号将无法正常登录且无法恢复,帐号下的所有数据也将被删除。
|
||||
复杂:大于或等于6个字符,包含数字、字母大小写或者特殊字符。
|
||||
不会向忽略的邮箱地址发送邮件,可使用换行分割多个地址。
|
||||
@ -24,7 +19,6 @@
|
||||
开启后可以直接使用 LDAP 帐号密码登录
|
||||
必填:发送聊天内容前必须设置昵称。
|
||||
移除成员负责的任务将变成无负责人,
|
||||
首页底部:首页底部网站备案号等信息
|
||||
为确保帐号安全,请确认是本人操作
|
||||
修改邮箱和删除帐号需要邮箱验证码
|
||||
关闭签到功能再开启需要重新安装。
|
||||
@ -42,7 +36,6 @@
|
||||
+ 输入子任务,回车添加子任务
|
||||
4、请保证帐号未被暂停使用。
|
||||
只能设置单个状态为验收/测试
|
||||
以下是你当前的任务统计数据
|
||||
可通过此链接直接加入项目。
|
||||
项目不存在或不在成员列表内
|
||||
简单:大于或等于6个字符。
|
||||
@ -54,14 +47,12 @@
|
||||
任务列表不存在或已被删除
|
||||
密码错误,请输入正确密码
|
||||
开放:所有人都可以发言。
|
||||
强大易用的协同创作云文档
|
||||
注销前,请确认一下事项
|
||||
签到前后时间收到消息通知
|
||||
签到提醒对象:3天内有签到的成员(法定工作日)
|
||||
该状态下任务自动标记完成
|
||||
输入您的信息以创建帐户。
|
||||
仅支持Openwrt系统的路由器
|
||||
便捷易用的项目管理模板
|
||||
允许成员自己修改MAC地址
|
||||
可设置多个状态为进行中
|
||||
可通过此链接浏览文件。
|
||||
@ -70,7 +61,6 @@
|
||||
注意:离职操作不可逆!
|
||||
请输入正确的邮箱地址!
|
||||
项目负责人无法退出项目
|
||||
高效便捷的团队沟通工具
|
||||
以 http:// 或 https:// 开头
|
||||
请开启您PHP环境的openssl
|
||||
两次密码输入不一致!
|
||||
@ -90,17 +80,13 @@
|
||||
仅限项目负责人修改
|
||||
仅限项目负责人删除
|
||||
仅限项目负责人操作
|
||||
任务描述,回车创建
|
||||
你好,扫码确认登录
|
||||
你确定要登出系统吗?
|
||||
你确认领取任务吗?
|
||||
列表名称,回车创建
|
||||
同步修改子任务时间
|
||||
子任务不支持此功能
|
||||
最后在线于很久以前
|
||||
注意此操作不可逆!
|
||||
浏览图片空间的图片
|
||||
清晰直观的任务日历
|
||||
确认密码输入不一致
|
||||
请先修改登录密码!
|
||||
请填写正确的旧密码
|
||||
@ -108,7 +94,6 @@
|
||||
请输入正确的邀请码
|
||||
请输入正确的验证码
|
||||
请重新输入新密码!
|
||||
轻量级任务管理工具
|
||||
输入群名称(选填)
|
||||
电话长度至少6位!
|
||||
不支持复制文件夹
|
||||
@ -139,8 +124,6 @@
|
||||
超出文件大小限制
|
||||
输入您的电子邮件
|
||||
选择任务协助人员
|
||||
选择转发指定成员
|
||||
选择转发最近聊天
|
||||
邮箱、昵称、职位
|
||||
请输入会议频道ID
|
||||
第二次任务提醒
|
||||
@ -158,9 +141,7 @@
|
||||
帐号或密码错误
|
||||
我的待完成任务
|
||||
拖动到这里发送
|
||||
支持多平台应用
|
||||
文件格式不正确
|
||||
最后在线于刚刚
|
||||
最近打开的任务
|
||||
未保存计划时间
|
||||
正在上传文件...
|
||||
@ -263,14 +244,9 @@ ID、名称、描述...
|
||||
验证码已失效
|
||||
使用 SSO 登录
|
||||
PPT 演示文稿
|
||||
同步滚动:关
|
||||
同步滚动:开
|
||||
Excel 工作表
|
||||
html转markdown
|
||||
上传文件夹
|
||||
不是发送人
|
||||
今日待完成
|
||||
代码块主题
|
||||
任务不存在
|
||||
任务优先级
|
||||
任务已完成
|
||||
@ -296,7 +272,6 @@ html转markdown
|
||||
工作流设置
|
||||
已删除任务
|
||||
已完成任务
|
||||
已完成列表
|
||||
已归档任务
|
||||
开启工作流
|
||||
归档的任务
|
||||
@ -314,7 +289,6 @@ html转markdown
|
||||
最晚可延后
|
||||
服务器版本
|
||||
未完成任务
|
||||
未完成列表
|
||||
未开放注册
|
||||
未知的消息
|
||||
标记未完成
|
||||
@ -357,6 +331,21 @@ SMTP服务器
|
||||
任务提醒
|
||||
职位/职称
|
||||
验收/测试
|
||||
(*) 超期
|
||||
未来 (*) 天内暂无到期任务
|
||||
暂无高优先级任务
|
||||
所有任务均已分配负责人
|
||||
当前筛选条件下暂无任务
|
||||
可以切换上方条件查看其他任务
|
||||
数据更新说明
|
||||
页面统计数据在 (*) 秒内复用,重点关注任务列表除外。
|
||||
上次更新:(*)
|
||||
立即刷新
|
||||
刷新成功
|
||||
查看全部 (*) 个优先级
|
||||
任务概况
|
||||
流程阶段
|
||||
未完成总数
|
||||
上传图片
|
||||
上传失败
|
||||
上传成功
|
||||
@ -392,14 +381,11 @@ SMTP服务器
|
||||
允许修改
|
||||
允许注册
|
||||
全员群组
|
||||
全屏查看
|
||||
全屏编辑
|
||||
全部文件
|
||||
全部禁言
|
||||
共享权限
|
||||
共享设置
|
||||
准备发布
|
||||
分屏显示
|
||||
创建时间
|
||||
创建群组
|
||||
创建项目
|
||||
@ -560,7 +546,6 @@ SMTP服务器
|
||||
紧凑经典
|
||||
经典天盘
|
||||
结束状态
|
||||
缩小查看
|
||||
群组设置
|
||||
聊天昵称
|
||||
聊天资料
|
||||
@ -628,7 +613,6 @@ LDAP 地址
|
||||
LDAP 端口
|
||||
MD编辑器
|
||||
Word 文档
|
||||
导出XLSX
|
||||
MAC地址
|
||||
扫一扫
|
||||
上个月
|
||||
@ -648,9 +632,6 @@ MAC地址
|
||||
到期后
|
||||
天空蓝
|
||||
子任务
|
||||
导出CSV
|
||||
导出TXT
|
||||
导出XLS
|
||||
已删除
|
||||
已取消
|
||||
已完成
|
||||
@ -870,36 +851,19 @@ Pro版
|
||||
颜色
|
||||
黄色
|
||||
默认
|
||||
一
|
||||
三
|
||||
二
|
||||
五
|
||||
你
|
||||
六
|
||||
周
|
||||
四
|
||||
天
|
||||
或
|
||||
日
|
||||
月
|
||||
秒
|
||||
(*)秒
|
||||
给
|
||||
(*)是一款轻量级的开源在线项目任务管理工具,提供各类文档协作工具、在线思维导图、在线流程图、项目管理、任务分发、即时IM,文件管理等工具。
|
||||
(*)负责的部门、项目、任务和文件将移交给交接人;同时退出所有群(如果是群主则转让给交接人)
|
||||
流转到【(*)】时,[任务负责人] 和 [项目管理员] 可以修改状态。
|
||||
文件(*)格式不正确,请上传(*)格式的图片。
|
||||
流转到【(*)】时添加状态负责人至任务负责人。
|
||||
每个文件夹里最多只能创建(*)个文件或文件夹
|
||||
文件(*)格式不正确,仅支持上传:(*)
|
||||
正在进行帐号【(*)】MAC地址修改。
|
||||
正在进行帐号【(*)】离职操作。
|
||||
正在进行帐号【(*)】部门修改。
|
||||
文件大小超限,最大限制:(*)KB
|
||||
职位/职称最多只能设置(*)个字
|
||||
任务描述最多只能设置(*)个字
|
||||
你确定要归档项目【(*)】吗?
|
||||
文件(*)太大,不能超过:(*)'
|
||||
文件名称最多只能设置(*)个字
|
||||
文件格式错误,限制类型:(*)
|
||||
项目介绍最多只能设置(*)个字
|
||||
@ -913,8 +877,6 @@ Pro版
|
||||
消息内容最大不能超过(*)字
|
||||
项目列表最多不能超过(*)个
|
||||
项目名称不可以少于(*)个字
|
||||
最多只能上传(*)张图片。
|
||||
最多只能选择(*)张图片。
|
||||
密码最多只能设置(*)位数
|
||||
密码设置不能小于(*)位数
|
||||
描述最多只能设置(*)个字
|
||||
@ -1044,8 +1006,6 @@ Pro版
|
||||
删除人员
|
||||
删除的任务
|
||||
你确定要还原删除吗?
|
||||
转换成markdown
|
||||
请输入html代码...
|
||||
请输入License...
|
||||
详细信息
|
||||
域名
|
||||
@ -1347,9 +1307,7 @@ APP 推送
|
||||
你确定取消待办吗?
|
||||
取消成功
|
||||
请等待打包完成
|
||||
选择一个项目查看更多任务
|
||||
首页
|
||||
无相关数据
|
||||
权限设置
|
||||
任务列权限
|
||||
添加列
|
||||
@ -1374,7 +1332,6 @@ APP 推送
|
||||
接龙
|
||||
参与接龙
|
||||
发起接龙
|
||||
由
|
||||
发起接龙,参与接龙目前共(*)人
|
||||
请输入接龙主题
|
||||
请输入接龙内容
|
||||
@ -1382,7 +1339,6 @@ APP 推送
|
||||
重复内容将不再计入接龙结果
|
||||
返回编辑
|
||||
继续发送
|
||||
例
|
||||
接龙结果
|
||||
选择群组发起接龙
|
||||
来自
|
||||
@ -1399,7 +1355,6 @@ APP 推送
|
||||
单选
|
||||
请选择后投票
|
||||
立即投票
|
||||
票
|
||||
再次发送
|
||||
再次发送投票?
|
||||
结束投票
|
||||
@ -1434,6 +1389,16 @@ License Key
|
||||
私聊禁言
|
||||
群聊禁言
|
||||
默认不限制
|
||||
默认 1G
|
||||
准备中...
|
||||
上传中...
|
||||
合并中...
|
||||
上传已暂停
|
||||
网络异常,重试中...
|
||||
上传初始化失败
|
||||
分片上传失败
|
||||
合并失败
|
||||
分片重试耗尽
|
||||
开放:所有人都可以在全员群组发言。
|
||||
开放:所有人都可以相互发起个人聊天。
|
||||
开放:允许个人群组聊天发言。
|
||||
@ -1712,11 +1677,11 @@ WiFi签到延迟时长为±1分钟。
|
||||
注意:此操作不可恢复,部门下的成员将移至默认部门。
|
||||
维护中...
|
||||
|
||||
(*)评论了(*)的「(**)」审批
|
||||
抄送(*)提交的「(**)」记录
|
||||
(*)提交的「(**)」待你审批
|
||||
您发起的「(**)」已通过
|
||||
您发起的「(**)」被(*)拒绝
|
||||
(*)评论了(*)的「(*)」审批
|
||||
抄送(*)提交的「(*)」记录
|
||||
(*)提交的「(*)」待你审批
|
||||
您发起的「(*)」已通过
|
||||
您发起的「(*)」被(*)拒绝
|
||||
|
||||
翻译
|
||||
从不
|
||||
@ -1776,7 +1741,6 @@ WiFi签到延迟时长为±1分钟。
|
||||
|
||||
签到半径设置
|
||||
半径
|
||||
米
|
||||
经度
|
||||
纬度
|
||||
地图类型
|
||||
@ -2082,7 +2046,6 @@ OKR群组
|
||||
邀请地址不存在或已被删除!
|
||||
|
||||
会话名称
|
||||
值
|
||||
结果
|
||||
命令
|
||||
接口地址
|
||||
@ -2326,7 +2289,6 @@ AI 消息助手
|
||||
留空则不修改密码
|
||||
职位
|
||||
请输入电话号码
|
||||
正在编辑帐号【ID:(*)】的信息。
|
||||
编辑用户信息
|
||||
|
||||
AI任务分析
|
||||
@ -2489,3 +2451,208 @@ AI任务分析
|
||||
(*)秒后重发
|
||||
请输入邮箱和验证码
|
||||
刷新中
|
||||
诊断详情
|
||||
授权 SN
|
||||
当前 SN
|
||||
授权 MAC
|
||||
当前 MAC
|
||||
授权与当前设备不匹配
|
||||
检测到设备标识(SN)已变更,在线授权可能已失效。请重新登录授权,或先在原设备退出以释放座位。
|
||||
在线授权已过期
|
||||
新增用户已受限,请尽快联网以自动续期恢复。
|
||||
检测到网卡(MAC)变化
|
||||
系统会在下次续期时自动恢复授权,通常无需处理。
|
||||
续期失败,请检查网络
|
||||
授权仍然有效,联网后会自动续期恢复。
|
||||
授权即将到期
|
||||
请保持联网,系统会自动为你续期。
|
||||
重新登录授权
|
||||
匹配
|
||||
不匹配
|
||||
收起
|
||||
更多信息
|
||||
提示
|
||||
将释放当前设备占用的授权座位并回到登录,确定继续?
|
||||
已过期
|
||||
选择要使用的授权
|
||||
当前设备使用中
|
||||
确定授权
|
||||
当前任务所属项目已被删除
|
||||
当前任务所属项目已归档
|
||||
我的文件
|
||||
共享文件
|
||||
我共享的
|
||||
共享给我的
|
||||
服务器返回错误(HTTP (*))
|
||||
你撤回了一条消息
|
||||
重新编辑
|
||||
四象限
|
||||
个人视角
|
||||
部门任务总览
|
||||
(*) 人
|
||||
最久 (*) 天
|
||||
今天 (*) 项到期
|
||||
还有 (*) 项
|
||||
待完成
|
||||
我协助的
|
||||
(*)月(*)日
|
||||
有 (*) 项已超期,建议先处理
|
||||
有 (*) 项今日到期
|
||||
今日无到期任务
|
||||
团队有 (*) 项已超期,涉及 (*) 人
|
||||
团队有 (*) 项已超期
|
||||
团队本周已完成 (*) 项
|
||||
查看任务
|
||||
涉及 (*) 人
|
||||
(*) 天内到期
|
||||
本周完成
|
||||
较上周
|
||||
与上周持平
|
||||
成员任务分配
|
||||
(*) 项
|
||||
未分配
|
||||
查看全部 (*) 人
|
||||
优先级分布
|
||||
未完成 (*) 项
|
||||
点击任一档位可下钻到任务列表
|
||||
高优先级
|
||||
重点关注任务
|
||||
未分配负责人
|
||||
待分配
|
||||
全部显示完毕
|
||||
逾期 (*) 天
|
||||
逾期 (*) 小时
|
||||
夜深了,(*)
|
||||
早上好,(*)
|
||||
中午好,(*)
|
||||
下午好,(*)
|
||||
晚上好,(*)
|
||||
服务器时间
|
||||
暂无已超期任务
|
||||
暂无今日到期任务
|
||||
暂无待完成任务
|
||||
暂无协助的任务
|
||||
没有超期任务,保持住
|
||||
今天只有这一项,处理完就轻松了
|
||||
数量较多,仅显示最近 (*) 项,展开其余 (*) 项
|
||||
我的部门
|
||||
待开始
|
||||
暂无待开始任务
|
||||
太棒了,任务全部清空
|
||||
本周完成了 (*) 项任务
|
||||
欢迎使用 DooTask
|
||||
项目是任务与协作的起点,创建一个开始
|
||||
创建第一个项目
|
||||
已有团队?请同事把你加入项目即可
|
||||
近期完成
|
||||
本周 (*) 项
|
||||
当前没有待处理任务
|
||||
可以新建一项任务,或等待新的工作安排
|
||||
选择需要统计的部门,包含所选部门及其所有下级部门。
|
||||
部门范围与负责人仪表盘保持一致;开启后,可只读查看范围内成员参与的项目和任务。
|
||||
项目负责人视角
|
||||
开启后在项目列表中显示额外的只读项目
|
||||
选择团队范围
|
||||
在项目列表中启用
|
||||
+(*)位
|
||||
(*)条回复
|
||||
AgoraIO 声网
|
||||
会话中断
|
||||
确定要清空所有历史会话吗?
|
||||
新窗口打开
|
||||
组件加载失败!
|
||||
没有更新描述。
|
||||
已全选
|
||||
已选部分
|
||||
你的姓名
|
||||
请输入你的姓名
|
||||
会议中
|
||||
未命名应用
|
||||
应用 ID
|
||||
应用名称
|
||||
菜单标题
|
||||
菜单位置
|
||||
应用中心 - 常用
|
||||
应用中心 - 管理
|
||||
主导航
|
||||
可见范围
|
||||
仅管理员
|
||||
图标地址
|
||||
菜单 URL
|
||||
背景颜色
|
||||
保持激活状态
|
||||
自动暗黑模式
|
||||
沉浸式
|
||||
透明背景
|
||||
禁用作用域样式
|
||||
可能要发的照片
|
||||
其他任务
|
||||
点击查看
|
||||
处理
|
||||
没有符合条件的数据
|
||||
正常
|
||||
暂无介绍
|
||||
关联子任务
|
||||
最多只能选择(*)个标签
|
||||
来源
|
||||
未知
|
||||
分享时间
|
||||
分享人
|
||||
标题
|
||||
任务状态
|
||||
已还原
|
||||
下个周期
|
||||
LDAP 用户禁止修改邮箱
|
||||
共(*)个
|
||||
标签名称最多只能设置(*)个字
|
||||
添加失败
|
||||
未开启通知权限
|
||||
使用端到端加密传输数据。
|
||||
删除前,请确认以下事项:
|
||||
已清楚风险,确定删除
|
||||
只能输入字母或数字
|
||||
授权
|
||||
不支持单独查看此消息
|
||||
报告详情
|
||||
验证邮箱
|
||||
您的邮箱已通过验证
|
||||
今后您可以通过此邮箱重置您的帐号密码
|
||||
链接已过期,已重新发送
|
||||
请设置昵称
|
||||
请设置联系电话
|
||||
文件 (*) 上传失败 (*)
|
||||
文件 (*) 格式不正确,请上传 jpg、jpeg、webp、gif、png 格式的图片。
|
||||
文件 (*) 太大,不能超过:(*)
|
||||
最多只能上传 (*) 张图片。
|
||||
最多只能选择 (*) 张图片。
|
||||
文件 (*) 上传失败,(*)
|
||||
文件 (*) 格式不正确,仅支持上传:(*)
|
||||
很久以前
|
||||
@我的消息
|
||||
确定要删除记录"(*)"吗?
|
||||
添加(*)
|
||||
共(*)个文件,仅显示最新50个
|
||||
正在进行帐号【ID:(*), (*)】离职操作。
|
||||
(*) 负责的部门、项目、任务和文件将移交给交接人;同时退出所有群(如果是群主则转让给交接人)
|
||||
正在编辑帐号【ID:(*), (*)】的信息。
|
||||
打包下载(*)
|
||||
[calendar_view].日
|
||||
[calendar_view].周
|
||||
[calendar_view].月
|
||||
[day_unit].天
|
||||
[distance_unit].米
|
||||
[example_label].例
|
||||
[initiator_label].由
|
||||
[recurrence_prefix].每
|
||||
[self_sender].你
|
||||
[task_unit].个
|
||||
[task_unit].项
|
||||
[todo_target].给
|
||||
[vote_unit].票
|
||||
[weekday].日
|
||||
[weekday].一
|
||||
[weekday].二
|
||||
[weekday].三
|
||||
[weekday].四
|
||||
[weekday].五
|
||||
[weekday].六
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "DooTask",
|
||||
"version": "1.8.45",
|
||||
"codeVerson": 238,
|
||||
"version": "1.8.89",
|
||||
"codeVerson": 241,
|
||||
"description": "DooTask is task management system.",
|
||||
"scripts": {
|
||||
"start": "./cmd dev",
|
||||
@ -62,6 +62,7 @@
|
||||
"resolve-url-loader": "^4.0.0",
|
||||
"sass": "1.77.4",
|
||||
"sass-loader": "14.2.1",
|
||||
"spark-md5": "^3.0.2",
|
||||
"stylus": "^0.59.0",
|
||||
"stylus-loader": "^7.1.0",
|
||||
"tinymce": "^5.10.3",
|
||||
|
||||
@ -1 +1 @@
|
||||
import{n as m}from"./app.f8ae8cc3.js";import"./jquery.24b9d090.js";import"./@babel.9410f858.js";import"./dayjs.19659d6c.js";import"./localforage.8c2def53.js";import"./markdown-it.0450edb4.js";import"./mdurl.ce6c1dd8.js";import"./uc.micro.8d343c98.js";import"./entities.48a44fec.js";import"./linkify-it.c5e8196e.js";import"./punycode.js.4b3f125a.js";import"./highlight.js.cbbfb885.js";import"./markdown-it-link-attributes.e1d5d151.js";import"./@traptitech.acea8861.js";import"./vue.adba9046.js";import"./vuex.cc7cb26e.js";import"./openpgp_hi.15f91b1d.js";import"./axios.37c7f908.js";import"./mitt.1ea0a2a3.js";import"./quill-hi.ca2ea0cc.js";import"./parchment.d5c5924e.js";import"./quill-delta.385a10bf.js";import"./fast-diff.f17881f3.js";import"./lodash.clonedeep.3cc09a31.js";import"./lodash.isequal.dbdc2157.js";import"./eventemitter3.78b735ad.js";import"./lodash-es.76e3a28b.js";import"./quill-mention-hi.4eeb5a2d.js";import"./view-design-hi.f1128b4d.js";import"./html-to-md.f297036e.js";import"./lodash.8fcd6fd4.js";import"./vue-router.2d566cd7.js";import"./vue-clipboard2.fd43a5bc.js";import"./clipboard.37b37361.js";import"./vuedraggable.f464b992.js";import"./sortablejs.3488b922.js";import"./vue-resize-observer.5af23a43.js";import"./element-sea.f8a64907.js";import"./deepmerge.cecf392e.js";import"./resize-observer-polyfill.5d591c5f.js";import"./throttle-debounce.7c3948b2.js";import"./babel-helper-vue-jsx-merge-props.5ed215c3.js";import"./normalize-wheel.2a034b9f.js";import"./async-validator.dca2b951.js";import"./babel-runtime.4773988a.js";import"./core-js.314b4a1d.js";var p=function(){var t=this,r=t.$createElement;return t._self._c,t._m(0)},e=[function(){var t=this,r=t.$createElement,i=t._self._c||r;return i("div",{staticClass:"page-404"},[i("div",{staticClass:"flex-center position-ref full-height"},[i("div",{staticClass:"code"},[t._v("404")]),i("div",{staticClass:"message"},[t._v("Not Found")])])])}];const s={},o={};var _=m(s,p,e,!1,n,"7d7154a8",null,null);function n(t){for(let r in o)this[r]=o[r]}var it=function(){return _.exports}();export{it as default};
|
||||
import{n as m}from"./app.1e00fc89.js";import"./jquery.99163cb9.js";import"./@babel.9410f858.js";import"./dayjs.0bb0f368.js";import"./localforage.6e50a401.js";import"./markdown-it.0450edb4.js";import"./mdurl.ce6c1dd8.js";import"./uc.micro.8d343c98.js";import"./entities.48a44fec.js";import"./linkify-it.c5e8196e.js";import"./punycode.js.4b3f125a.js";import"./highlight.js.cbbfb885.js";import"./markdown-it-link-attributes.e1d5d151.js";import"./@traptitech.acea8861.js";import"./vue.adba9046.js";import"./vuex.cc7cb26e.js";import"./openpgp_hi.15f91b1d.js";import"./axios.37c7f908.js";import"./mitt.1ea0a2a3.js";import"./quill-hi.ca2ea0cc.js";import"./parchment.d5c5924e.js";import"./quill-delta.385a10bf.js";import"./fast-diff.f17881f3.js";import"./lodash.clonedeep.3cc09a31.js";import"./lodash.isequal.dbdc2157.js";import"./eventemitter3.78b735ad.js";import"./lodash-es.76e3a28b.js";import"./quill-mention-hi.4eeb5a2d.js";import"./view-design-hi.f1128b4d.js";import"./html-to-md.f297036e.js";import"./lodash.8fcd6fd4.js";import"./vue-router.2d566cd7.js";import"./vue-clipboard2.fd43a5bc.js";import"./clipboard.37b37361.js";import"./vuedraggable.f464b992.js";import"./sortablejs.3488b922.js";import"./vue-resize-observer.5af23a43.js";import"./element-sea.f8a64907.js";import"./deepmerge.cecf392e.js";import"./resize-observer-polyfill.5d591c5f.js";import"./throttle-debounce.7c3948b2.js";import"./babel-helper-vue-jsx-merge-props.5ed215c3.js";import"./normalize-wheel.2a034b9f.js";import"./async-validator.dca2b951.js";import"./babel-runtime.4773988a.js";import"./core-js.314b4a1d.js";var p=function(){var t=this,r=t.$createElement;return t._self._c,t._m(0)},e=[function(){var t=this,r=t.$createElement,i=t._self._c||r;return i("div",{staticClass:"page-404"},[i("div",{staticClass:"flex-center position-ref full-height"},[i("div",{staticClass:"code"},[t._v("404")]),i("div",{staticClass:"message"},[t._v("Not Found")])])])}];const s={},o={};var _=m(s,p,e,!1,n,"7d7154a8",null,null);function n(t){for(let r in o)this[r]=o[r]}var it=function(){return _.exports}();export{it as default};
|
||||
File diff suppressed because one or more lines are too long
1
public/js/build/CheckinExport.4adb8d2d.js
vendored
Normal file
1
public/js/build/CheckinExport.4adb8d2d.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
public/js/build/CheckinExport.4c7a80a8.js
vendored
1
public/js/build/CheckinExport.4c7a80a8.js
vendored
File diff suppressed because one or more lines are too long
@ -1 +1 @@
|
||||
.checkin-field .ivu-form-item-label{color:#f90;font-weight:500}.checkin-mac-header[data-v-eb58f07c]{margin-bottom:8px;font-weight:500;color:#606266}.checkin-mac-item[data-v-eb58f07c]{margin-bottom:8px}.checkin-mac-item .ivu-col[data-v-eb58f07c]{padding-right:8px}.checkin-mac-item .ivu-col[data-v-eb58f07c]:last-child{padding-right:0}.checkin-mac-del[data-v-eb58f07c]{display:flex;align-items:center;justify-content:center;cursor:pointer;color:red}.checkin-mac-del[data-v-eb58f07c]:hover{opacity:.8}.form-tip[data-v-eb58f07c]{font-size:12px;color:#999;margin-top:4px}.user-tags-preview[data-v-eb58f07c]{display:flex;align-items:center;flex-wrap:wrap;gap:8px;min-height:32px}.user-tags-preview .tag-pill[data-v-eb58f07c]{cursor:pointer;padding:6px 12px;border-radius:12px;font-size:13px;-webkit-user-select:none;-moz-user-select:none;user-select:none;background-color:#f5f5f5;color:#606266;line-height:14px;height:26px;max-width:160px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.user-tags-preview .tag-pill.is-recognized[data-v-eb58f07c]{color:#67c23a}.user-tags-preview .tag-pill span[data-v-eb58f07c]{padding-left:8px;position:relative}.user-tags-preview .tag-pill span[data-v-eb58f07c]:before{content:"";position:absolute;left:2px;top:50%;transform:translateY(-50%);width:2px;height:2px;border-radius:50%;background-color:currentColor}.user-tags-preview .tags-empty[data-v-eb58f07c]{color:#909399}.user-tags-preview .tags-total[data-v-eb58f07c]{color:#909399;font-size:12px}.user-tags-preview .manage-button[data-v-eb58f07c]{margin-left:auto;display:inline-flex;align-items:center;gap:4px}.import-user-modal .import-tip[data-v-9d8f7ae8]{color:#808695;margin-bottom:12px}.import-user-modal .import-actions[data-v-9d8f7ae8]{display:flex;gap:12px;align-items:center}.import-user-modal .import-option[data-v-9d8f7ae8]{margin-top:12px}.import-user-modal .import-batch-label[data-v-9d8f7ae8]{flex-shrink:0;min-width:64px;color:#515a6e}.import-user-modal .import-setdept[data-v-9d8f7ae8]{display:flex;align-items:center;gap:8px;margin-top:12px}.import-user-modal .import-setdept .import-setdept-select[data-v-9d8f7ae8]{width:auto}.import-user-modal .import-setverity[data-v-9d8f7ae8]{display:flex;align-items:center;gap:8px;margin-top:12px}.import-user-modal .import-preview[data-v-9d8f7ae8],.import-user-modal .import-result[data-v-9d8f7ae8]{margin-top:16px}.import-user-modal[data-v-9d8f7ae8] .ivu-table-cell{white-space:nowrap}.import-user-modal[data-v-9d8f7ae8] .pwd-cell{cursor:pointer;letter-spacing:1px;-webkit-user-select:none;-moz-user-select:none;user-select:none}.import-user-modal[data-v-9d8f7ae8] .pwd-cell:hover{color:#2d8cf0}.import-user-modal[data-v-9d8f7ae8] .import-row-error td{background-color:#fff2f0}
|
||||
.checkin-field .ivu-form-item-label{color:#f90;font-weight:500}.checkin-mac-header[data-v-7e50b959]{margin-bottom:8px;font-weight:500;color:#606266}.checkin-mac-item[data-v-7e50b959]{margin-bottom:8px}.checkin-mac-item .ivu-col[data-v-7e50b959]{padding-right:8px}.checkin-mac-item .ivu-col[data-v-7e50b959]:last-child{padding-right:0}.checkin-mac-del[data-v-7e50b959]{display:flex;align-items:center;justify-content:center;cursor:pointer;color:red}.checkin-mac-del[data-v-7e50b959]:hover{opacity:.8}.form-tip[data-v-7e50b959]{font-size:12px;color:#999;margin-top:4px}.user-tags-preview[data-v-7e50b959]{display:flex;align-items:center;flex-wrap:wrap;gap:8px;min-height:32px}.user-tags-preview .tag-pill[data-v-7e50b959]{cursor:pointer;padding:6px 12px;border-radius:12px;font-size:13px;-webkit-user-select:none;-moz-user-select:none;user-select:none;background-color:#f5f5f5;color:#606266;line-height:14px;height:26px;max-width:160px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.user-tags-preview .tag-pill.is-recognized[data-v-7e50b959]{color:#67c23a}.user-tags-preview .tag-pill span[data-v-7e50b959]{padding-left:8px;position:relative}.user-tags-preview .tag-pill span[data-v-7e50b959]:before{content:"";position:absolute;left:2px;top:50%;transform:translateY(-50%);width:2px;height:2px;border-radius:50%;background-color:currentColor}.user-tags-preview .tags-empty[data-v-7e50b959]{color:#909399}.user-tags-preview .tags-total[data-v-7e50b959]{color:#909399;font-size:12px}.user-tags-preview .manage-button[data-v-7e50b959]{margin-left:auto;display:inline-flex;align-items:center;gap:4px}.import-user-modal .import-tip[data-v-9d8f7ae8]{color:#808695;margin-bottom:12px}.import-user-modal .import-actions[data-v-9d8f7ae8]{display:flex;gap:12px;align-items:center}.import-user-modal .import-option[data-v-9d8f7ae8]{margin-top:12px}.import-user-modal .import-batch-label[data-v-9d8f7ae8]{flex-shrink:0;min-width:64px;color:#515a6e}.import-user-modal .import-setdept[data-v-9d8f7ae8]{display:flex;align-items:center;gap:8px;margin-top:12px}.import-user-modal .import-setdept .import-setdept-select[data-v-9d8f7ae8]{width:auto}.import-user-modal .import-setverity[data-v-9d8f7ae8]{display:flex;align-items:center;gap:8px;margin-top:12px}.import-user-modal .import-preview[data-v-9d8f7ae8],.import-user-modal .import-result[data-v-9d8f7ae8]{margin-top:16px}.import-user-modal[data-v-9d8f7ae8] .ivu-table-cell{white-space:nowrap}.import-user-modal[data-v-9d8f7ae8] .pwd-cell{cursor:pointer;letter-spacing:1px;-webkit-user-select:none;-moz-user-select:none;user-select:none}.import-user-modal[data-v-9d8f7ae8] .pwd-cell:hover{color:#2d8cf0}.import-user-modal[data-v-9d8f7ae8] .import-row-error td{background-color:#fff2f0}
|
||||
@ -1 +0,0 @@
|
||||
import{m as i}from"./vuex.cc7cb26e.js";import{n as o}from"./app.f8ae8cc3.js";var d=function(){var t=this,a=t.$createElement,e=t._self._c||a;return e("Modal",{attrs:{value:t.value,title:t.$L("\u8D1F\u8D23\u4EBA\u89C6\u89D2"),"mask-closable":!1,width:"520"},on:{input:function(s){return t.$emit("input",s)}}},[e("div",{staticClass:"department-owner-view-modal"},[e("Alert",{attrs:{type:"info","show-icon":""}},[t._v(" "+t._s(t.$L("\u53EF\u67E5\u770B\u6240\u9009\u90E8\u95E8\u53CA\u6240\u6709\u4E0B\u7EA7\u90E8\u95E8\u6210\u5458\u53C2\u4E0E\u7684\u9879\u76EE\u548C\u4EFB\u52A1\uFF0C\u4EC5\u652F\u6301\u53EA\u8BFB\u67E5\u770B\u3002"))+" ")]),t.managedDepartments.length>1?e("div",{staticClass:"department-owner-view-actions"},[e("a",{attrs:{href:"javascript:void(0)"},on:{click:function(s){t.draftIds=[]}}},[t._v(t._s(t.$L("\u6E05\u7A7A")))]),e("a",{attrs:{href:"javascript:void(0)"},on:{click:function(s){t.draftIds=t.managedDepartments.map(function(n){return n.id})}}},[t._v(t._s(t.$L("\u5168\u9009")))]),e("a",{attrs:{href:"javascript:void(0)"},on:{click:t.reverseDraft}},[t._v(t._s(t.$L("\u53CD\u9009")))])]):t._e(),e("CheckboxGroup",{staticClass:"department-owner-view-list",model:{value:t.draftIds,callback:function(s){t.draftIds=s},expression:"draftIds"}},t._l(t.managedDepartments,function(s){return e("div",{key:s.id,class:["department-owner-view-item",t.draftIds.includes(s.id)?"active":""],on:{click:function(n){return t.toggleDraft(s.id)}}},[e("div",{staticClass:"department-owner-view-icon"},[e("i",{staticClass:"taskfont"},[t._v("\uE75C")])]),e("div",{staticClass:"department-owner-view-name"},[t._v(t._s(s.name))]),e("Checkbox",{staticClass:"department-owner-view-checkbox",attrs:{label:s.id},nativeOn:{click:function(n){n.stopPropagation()}}},[e("span")])],1)}),0)],1),e("div",{staticClass:"adaption",attrs:{slot:"footer"},slot:"footer"},[e("Button",{attrs:{type:"default",disabled:t.applyLoading},on:{click:function(s){return t.$emit("input",!1)}}},[t._v(t._s(t.$L("\u53D6\u6D88")))]),e("Button",{attrs:{type:"primary",loading:t.applyLoading},on:{click:t.apply}},[t._v(t._s(t.$L("\u786E\u5B9A")))])],1)])},l=[];const c={name:"DepartmentOwnerView",props:{value:Boolean},data(){return{draftIds:[],applyLoading:!1}},computed:{...i(["userInfo","cacheDepartmentOwnerIds"]),managedDepartments(){return(this.userInfo.managed_departments||[]).map(t=>({...t,id:parseInt(t.id)}))}},watch:{value:{immediate:!0,handler(t){t?this.draftIds=(this.cacheDepartmentOwnerIds||[]).slice():this.applyLoading=!1}}},methods:{toggleDraft(t){t=parseInt(t);const a=this.draftIds.indexOf(t);a>-1?this.draftIds.splice(a,1):this.draftIds.push(t)},reverseDraft(){const t=this.draftIds.map(a=>parseInt(a));this.draftIds=this.managedDepartments.map(a=>a.id).filter(a=>!t.includes(a))},async apply(){if(!this.applyLoading){this.applyLoading=!0;try{await this.$store.dispatch("setDepartmentOwnerIds",this.draftIds),this.$emit("input",!1)}catch(t){$A.modalError((t==null?void 0:t.msg)||this.$L("\u5207\u6362\u5931\u8D25"))}finally{this.applyLoading=!1}}}}},r={};var p=o(c,d,l,!1,f,"624ab3e4",null,null);function f(t){for(let a in r)this[a]=r[a]}var u=function(){return p.exports}();export{u as D};
|
||||
1
public/js/build/DepartmentOwnerView.40de0e2f.js
vendored
Normal file
1
public/js/build/DepartmentOwnerView.40de0e2f.js
vendored
Normal file
@ -0,0 +1 @@
|
||||
import{m as i}from"./vuex.cc7cb26e.js";import{n as d}from"./app.1e00fc89.js";var o=function(){var t=this,a=t.$createElement,e=t._self._c||a;return e("Modal",{attrs:{value:t.value,title:t.modalTitle,"mask-closable":!1,width:"520"},on:{input:function(s){return t.$emit("input",s)}}},[e("div",{staticClass:"department-owner-view-modal"},[e("Alert",{attrs:{type:"info","show-icon":""}},[t.scopeOnly?[t._v(t._s(t.$L("\u9009\u62E9\u9700\u8981\u7EDF\u8BA1\u7684\u90E8\u95E8\uFF0C\u5305\u542B\u6240\u9009\u90E8\u95E8\u53CA\u5176\u6240\u6709\u4E0B\u7EA7\u90E8\u95E8\u3002")))]:[t._v(t._s(t.$L("\u90E8\u95E8\u8303\u56F4\u4E0E\u8D1F\u8D23\u4EBA\u4EEA\u8868\u76D8\u4FDD\u6301\u4E00\u81F4\uFF1B\u5F00\u542F\u540E\uFF0C\u53EF\u53EA\u8BFB\u67E5\u770B\u8303\u56F4\u5185\u6210\u5458\u53C2\u4E0E\u7684\u9879\u76EE\u548C\u4EFB\u52A1\u3002")))]],2),t.scopeOnly?t._e():e("div",{staticClass:"department-owner-view-switch"},[e("div",[e("strong",[t._v(t._s(t.$L("\u5728\u9879\u76EE\u5217\u8868\u4E2D\u542F\u7528")))]),e("span",{staticClass:"department-owner-view-switch-desc"},[t._v(t._s(t.$L("\u5F00\u542F\u540E\u5728\u9879\u76EE\u5217\u8868\u4E2D\u663E\u793A\u989D\u5916\u7684\u53EA\u8BFB\u9879\u76EE")))])]),e("iSwitch",{model:{value:t.draftProjectViewEnabled,callback:function(s){t.draftProjectViewEnabled=s},expression:"draftProjectViewEnabled"}})],1),t.scopeOnly||t.draftProjectViewEnabled?[t.managedDepartments.length>1?e("div",{staticClass:"department-owner-view-actions"},[e("a",{attrs:{href:"javascript:void(0)"},on:{click:function(s){t.draftIds=[]}}},[t._v(t._s(t.$L("\u6E05\u7A7A")))]),e("a",{attrs:{href:"javascript:void(0)"},on:{click:function(s){t.draftIds=t.managedDepartments.map(function(n){return n.id})}}},[t._v(t._s(t.$L("\u5168\u9009")))]),e("a",{attrs:{href:"javascript:void(0)"},on:{click:t.reverseDraft}},[t._v(t._s(t.$L("\u53CD\u9009")))])]):t._e(),e("CheckboxGroup",{staticClass:"department-owner-view-list",model:{value:t.draftIds,callback:function(s){t.draftIds=s},expression:"draftIds"}},t._l(t.managedDepartments,function(s){return e("div",{key:s.id,class:["department-owner-view-item",t.draftIds.includes(s.id)?"active":""],on:{click:function(n){return t.toggleDraft(s.id)}}},[e("div",{staticClass:"department-owner-view-icon"},[e("i",{staticClass:"taskfont"},[t._v("\uE75C")])]),e("div",{staticClass:"department-owner-view-name"},[t._v(t._s(s.name))]),e("Checkbox",{staticClass:"department-owner-view-checkbox",attrs:{label:s.id},nativeOn:{click:function(n){n.stopPropagation()}}},[e("span")])],1)}),0)]:t._e()],2),e("div",{staticClass:"adaption",attrs:{slot:"footer"},slot:"footer"},[e("Button",{attrs:{type:"default",disabled:t.applyLoading},on:{click:function(s){return t.$emit("input",!1)}}},[t._v(t._s(t.$L("\u53D6\u6D88")))]),e("Button",{attrs:{type:"primary",disabled:t.draftIds.length===0,loading:t.applyLoading},on:{click:t.apply}},[t._v(t._s(t.$L("\u786E\u5B9A")))])],1)])},l=[];const c={name:"DepartmentOwnerView",props:{value:Boolean,scopeOnly:Boolean},data(){return{draftIds:[],draftProjectViewEnabled:!1,applyLoading:!1}},computed:{...i(["userInfo","cacheDepartmentOwnerIds","departmentOwnerProjectViewEnabled"]),modalTitle(){return this.scopeOnly?this.$L("\u9009\u62E9\u56E2\u961F\u8303\u56F4"):this.$L("\u9879\u76EE\u8D1F\u8D23\u4EBA\u89C6\u89D2")},managedDepartments(){return(this.userInfo.managed_departments||[]).map(t=>({...t,id:parseInt(t.id)}))}},watch:{value:{immediate:!0,handler(t){if(t){const a=(this.cacheDepartmentOwnerIds||[]).slice();this.draftIds=a.length>0?a:this.managedDepartments.map(e=>e.id),this.draftProjectViewEnabled=this.departmentOwnerProjectViewEnabled}else this.applyLoading=!1}}},methods:{toggleDraft(t){t=parseInt(t);const a=this.draftIds.indexOf(t);a>-1?this.draftIds.splice(a,1):this.draftIds.push(t)},reverseDraft(){const t=this.draftIds.map(a=>parseInt(a));this.draftIds=this.managedDepartments.map(a=>a.id).filter(a=>!t.includes(a))},async apply(){if(!this.applyLoading){this.applyLoading=!0;try{await this.$store.dispatch("setDepartmentOwnerIds",this.scopeOnly?this.draftIds:{ids:this.draftIds,projectViewEnabled:this.draftProjectViewEnabled}),this.$emit("input",!1)}catch(t){$A.modalError((t==null?void 0:t.msg)||this.$L("\u5207\u6362\u5931\u8D25"))}finally{this.applyLoading=!1}}}}},r={};var p=d(c,o,l,!1,f,"61d09b1f",null,null);function f(t){for(let a in r)this[a]=r[a]}var u=function(){return p.exports}();export{u as D};
|
||||
1
public/js/build/DepartmentOwnerView.9cbc9146.css
vendored
Normal file
1
public/js/build/DepartmentOwnerView.9cbc9146.css
vendored
Normal file
@ -0,0 +1 @@
|
||||
.department-owner-view-modal .department-owner-view-actions[data-v-61d09b1f]{display:flex;justify-content:flex-end;gap:14px;margin:12px 8px 0}.department-owner-view-modal .department-owner-view-switch[data-v-61d09b1f]{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:14px 8px 4px}.department-owner-view-modal .department-owner-view-switch>div[data-v-61d09b1f]{display:flex;flex-direction:column;gap:3px;min-width:0}.department-owner-view-modal .department-owner-view-switch strong[data-v-61d09b1f]{font-weight:500}.department-owner-view-modal .department-owner-view-switch .department-owner-view-switch-desc[data-v-61d09b1f]{color:#999;font-size:12px;line-height:18px}.department-owner-view-modal .department-owner-view-list[data-v-61d09b1f]{display:flex;flex-direction:column;margin-top:10px}.department-owner-view-modal .department-owner-view-item[data-v-61d09b1f]{display:flex;align-items:center;padding:10px 12px;cursor:pointer}.department-owner-view-modal .department-owner-view-icon[data-v-61d09b1f]{width:28px;height:28px;border-radius:50%;background-color:#5bc7b0;color:#fff;display:flex;align-items:center;justify-content:center;margin-right:10px}.department-owner-view-modal .department-owner-view-name[data-v-61d09b1f]{flex:1}.department-owner-view-modal .department-owner-view-checkbox[data-v-61d09b1f]{margin-right:0}
|
||||
@ -1 +0,0 @@
|
||||
.department-owner-view-modal .department-owner-view-actions[data-v-624ab3e4]{display:flex;justify-content:flex-end;gap:14px;margin:12px 8px 0}.department-owner-view-modal .department-owner-view-list[data-v-624ab3e4]{display:flex;flex-direction:column;margin-top:10px}.department-owner-view-modal .department-owner-view-item[data-v-624ab3e4]{display:flex;align-items:center;padding:10px 12px;cursor:pointer}.department-owner-view-modal .department-owner-view-icon[data-v-624ab3e4]{width:28px;height:28px;border-radius:50%;background-color:#5bc7b0;color:#fff;display:flex;align-items:center;justify-content:center;margin-right:10px}.department-owner-view-modal .department-owner-view-name[data-v-624ab3e4]{flex:1}.department-owner-view-modal .department-owner-view-checkbox[data-v-624ab3e4]{margin-right:0}
|
||||
4
public/js/build/DialogWrapper.1505b441.js
vendored
4
public/js/build/DialogWrapper.1505b441.js
vendored
File diff suppressed because one or more lines are too long
4
public/js/build/DialogWrapper.d635cc01.js
vendored
Normal file
4
public/js/build/DialogWrapper.d635cc01.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
public/js/build/Drawio.6950c35e.css
vendored
Normal file
1
public/js/build/Drawio.6950c35e.css
vendored
Normal file
@ -0,0 +1 @@
|
||||
.drawio-content[data-v-86e491c2]{position:absolute;top:0;left:0;width:100%;height:100%}.drawio-content .drawio-iframe[data-v-86e491c2]{position:absolute;top:0;left:0;width:100%;height:100%;background:0 0;border:0;float:none;margin:-1px 0 0;max-width:none;outline:0;padding:0}.drawio-content .drawio-loading[data-v-86e491c2]{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}
|
||||
1
public/js/build/Drawio.6a04e353.css
vendored
1
public/js/build/Drawio.6a04e353.css
vendored
@ -1 +0,0 @@
|
||||
.drawio-content[data-v-39021859]{position:absolute;top:0;left:0;width:100%;height:100%}.drawio-content .drawio-iframe[data-v-39021859]{position:absolute;top:0;left:0;width:100%;height:100%;background:0 0;border:0;float:none;margin:-1px 0 0;max-width:none;outline:0;padding:0}.drawio-content .drawio-loading[data-v-39021859]{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}
|
||||
@ -1 +1 @@
|
||||
import{m as s}from"./vuex.cc7cb26e.js";import{I as m}from"./IFrame.c85a6115.js";import{n as p,l as o}from"./app.f8ae8cc3.js";import"./jquery.24b9d090.js";import"./@babel.9410f858.js";import"./dayjs.19659d6c.js";import"./localforage.8c2def53.js";import"./markdown-it.0450edb4.js";import"./mdurl.ce6c1dd8.js";import"./uc.micro.8d343c98.js";import"./entities.48a44fec.js";import"./linkify-it.c5e8196e.js";import"./punycode.js.4b3f125a.js";import"./highlight.js.cbbfb885.js";import"./markdown-it-link-attributes.e1d5d151.js";import"./@traptitech.acea8861.js";import"./vue.adba9046.js";import"./openpgp_hi.15f91b1d.js";import"./axios.37c7f908.js";import"./mitt.1ea0a2a3.js";import"./quill-hi.ca2ea0cc.js";import"./parchment.d5c5924e.js";import"./quill-delta.385a10bf.js";import"./fast-diff.f17881f3.js";import"./lodash.clonedeep.3cc09a31.js";import"./lodash.isequal.dbdc2157.js";import"./eventemitter3.78b735ad.js";import"./lodash-es.76e3a28b.js";import"./quill-mention-hi.4eeb5a2d.js";import"./view-design-hi.f1128b4d.js";import"./html-to-md.f297036e.js";import"./lodash.8fcd6fd4.js";import"./vue-router.2d566cd7.js";import"./vue-clipboard2.fd43a5bc.js";import"./clipboard.37b37361.js";import"./vuedraggable.f464b992.js";import"./sortablejs.3488b922.js";import"./vue-resize-observer.5af23a43.js";import"./element-sea.f8a64907.js";import"./deepmerge.cecf392e.js";import"./resize-observer-polyfill.5d591c5f.js";import"./throttle-debounce.7c3948b2.js";import"./babel-helper-vue-jsx-merge-props.5ed215c3.js";import"./normalize-wheel.2a034b9f.js";import"./async-validator.dca2b951.js";import"./babel-runtime.4773988a.js";import"./core-js.314b4a1d.js";var l=function(){var t=this,e=t.$createElement,i=t._self._c||e;return i("div",{staticClass:"drawio-content"},[i("IFrame",{ref:"frame",staticClass:"drawio-iframe",attrs:{src:t.url},on:{"on-message":t.onMessage}}),t.loadIng?i("div",{staticClass:"drawio-loading"},[i("Loading")],1):t._e()],1)},d=[];const u={name:"Drawio",components:{IFrame:m},props:{value:{type:Object,default:function(){return{}}},title:{type:String,default:""},readOnly:{type:Boolean,default:!1}},data(){return{loadIng:!0,url:null,bakData:""}},created(){let t=o;switch(o){case"zh-CHT":t="zh-tw";break}let e=this.readOnly?1:0,i=this.readOnly?0:1,n=this.themeName==="dark"?"dark":"kennedy",r=`?title=${this.title?encodeURIComponent(this.title):""}&chrome=${i}&lightbox=${e}&ui=${n}&lang=${t}&offline=1&pwa=0&embed=1&noLangIcon=1&noExitBtn=1&noSaveBtn=1&saveAndExit=0&spin=1&proto=json`;this.$Electron?this.url=$A.originUrl(`drawio/webapp/index.html${r}`):this.url=$A.mainUrl(`drawio/webapp/${r}`)},mounted(){window.addEventListener("message",this.handleMessage)},beforeDestroy(){window.removeEventListener("message",this.handleMessage)},watch:{value:{handler(t){this.bakData!=$A.jsonStringify(t)&&(this.bakData=$A.jsonStringify(t),this.updateContent())},deep:!0}},computed:{...s(["themeName"])},methods:{formatZoom(t){return t+"%"},updateContent(){this.$refs.frame.postMessage(JSON.stringify({action:"load",autosave:1,xml:this.value.xml}))},onMessage(t){switch(t.event){case"init":this.loadIng=!1,this.updateContent();break;case"load":typeof this.value.xml=="undefined"&&this.$refs.frame.postMessage(JSON.stringify({action:"template"}));break;case"autosave":const e={xml:t.xml};this.bakData=$A.jsonStringify(e),this.$emit("input",e);break;case"save":this.$emit("saveData");break}}}},a={};var c=p(u,l,d,!1,h,"39021859",null,null);function h(t){for(let e in a)this[e]=a[e]}var pt=function(){return c.exports}();export{pt as default};
|
||||
import{m as s}from"./vuex.cc7cb26e.js";import{I as m}from"./IFrame.02468084.js";import{n as p,l as o}from"./app.1e00fc89.js";import"./jquery.99163cb9.js";import"./@babel.9410f858.js";import"./dayjs.0bb0f368.js";import"./localforage.6e50a401.js";import"./markdown-it.0450edb4.js";import"./mdurl.ce6c1dd8.js";import"./uc.micro.8d343c98.js";import"./entities.48a44fec.js";import"./linkify-it.c5e8196e.js";import"./punycode.js.4b3f125a.js";import"./highlight.js.cbbfb885.js";import"./markdown-it-link-attributes.e1d5d151.js";import"./@traptitech.acea8861.js";import"./vue.adba9046.js";import"./openpgp_hi.15f91b1d.js";import"./axios.37c7f908.js";import"./mitt.1ea0a2a3.js";import"./quill-hi.ca2ea0cc.js";import"./parchment.d5c5924e.js";import"./quill-delta.385a10bf.js";import"./fast-diff.f17881f3.js";import"./lodash.clonedeep.3cc09a31.js";import"./lodash.isequal.dbdc2157.js";import"./eventemitter3.78b735ad.js";import"./lodash-es.76e3a28b.js";import"./quill-mention-hi.4eeb5a2d.js";import"./view-design-hi.f1128b4d.js";import"./html-to-md.f297036e.js";import"./lodash.8fcd6fd4.js";import"./vue-router.2d566cd7.js";import"./vue-clipboard2.fd43a5bc.js";import"./clipboard.37b37361.js";import"./vuedraggable.f464b992.js";import"./sortablejs.3488b922.js";import"./vue-resize-observer.5af23a43.js";import"./element-sea.f8a64907.js";import"./deepmerge.cecf392e.js";import"./resize-observer-polyfill.5d591c5f.js";import"./throttle-debounce.7c3948b2.js";import"./babel-helper-vue-jsx-merge-props.5ed215c3.js";import"./normalize-wheel.2a034b9f.js";import"./async-validator.dca2b951.js";import"./babel-runtime.4773988a.js";import"./core-js.314b4a1d.js";var l=function(){var t=this,e=t.$createElement,i=t._self._c||e;return i("div",{staticClass:"drawio-content"},[i("IFrame",{ref:"frame",staticClass:"drawio-iframe",attrs:{src:t.url},on:{"on-message":t.onMessage}}),t.loadIng?i("div",{staticClass:"drawio-loading"},[i("Loading")],1):t._e()],1)},d=[];const u={name:"Drawio",components:{IFrame:m},props:{value:{type:Object,default:function(){return{}}},title:{type:String,default:""},readOnly:{type:Boolean,default:!1}},data(){return{loadIng:!0,url:null,bakData:""}},created(){let t=o;switch(o){case"zh-CHT":t="zh-tw";break}let e=this.readOnly?1:0,i=this.readOnly?0:1,n=this.themeName==="dark"?"dark":"kennedy",r=`?title=${this.title?encodeURIComponent(this.title):""}&chrome=${i}&lightbox=${e}&ui=${n}&lang=${t}&offline=1&pwa=0&embed=1&noLangIcon=1&noExitBtn=1&noSaveBtn=1&saveAndExit=0&spin=1&proto=json`;this.$Electron?this.url=$A.originUrl(`drawio/webapp/index.html${r}`):this.url=$A.mainUrl(`drawio/webapp/${r}`)},mounted(){window.addEventListener("message",this.handleMessage)},beforeDestroy(){window.removeEventListener("message",this.handleMessage)},watch:{value:{handler(t){this.bakData!=$A.jsonStringify(t)&&(this.bakData=$A.jsonStringify(t),this.updateContent())},deep:!0}},computed:{...s(["themeName"])},methods:{formatZoom(t){return t+"%"},updateContent(){this.$refs.frame.postMessage(JSON.stringify({action:"load",autosave:1,xml:this.value.xml}))},onMessage(t){switch(t.event){case"init":this.loadIng=!1,this.updateContent();break;case"load":typeof this.value.xml=="undefined"&&this.$refs.frame.postMessage(JSON.stringify({action:"template"}));break;case"autosave":const e={xml:t.xml};this.bakData=$A.jsonStringify(e),this.$emit("input",e);break;case"save":this.$emit("saveData");break}}}},a={};var c=p(u,l,d,!1,h,"86e491c2",null,null);function h(t){for(let e in a)this[e]=a[e]}var pt=function(){return c.exports}();export{pt as default};
|
||||
1
public/js/build/FileContent.60aac46f.js
vendored
1
public/js/build/FileContent.60aac46f.js
vendored
File diff suppressed because one or more lines are too long
1
public/js/build/FileContent.60bd2228.js
vendored
Normal file
1
public/js/build/FileContent.60bd2228.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
public/js/build/FilePreview.54755748.js
vendored
Normal file
1
public/js/build/FilePreview.54755748.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
public/js/build/FilePreview.666a4546.js
vendored
1
public/js/build/FilePreview.666a4546.js
vendored
File diff suppressed because one or more lines are too long
@ -1 +1 @@
|
||||
import{n}from"./app.f8ae8cc3.js";var i=function(){var e=this,s=e.$createElement,r=e._self._c||s;return r("iframe",{directives:[{name:"show",rawName:"v-show",value:e.src,expression:"src"}],ref:"iframe",attrs:{src:e.src}})},a=[];const o={name:"IFrame",props:{src:{type:String,default:""}},mounted(){this.$refs.iframe.addEventListener("load",this.handleLoad),window.addEventListener("message",this.handleMessage)},beforeDestroy(){this.$refs.iframe.removeEventListener("load",this.handleLoad),window.removeEventListener("message",this.handleMessage)},methods:{handleLoad(){this.$emit("on-load")},handleMessage({data:e,source:s}){var r;s===((r=this.$refs.iframe)==null?void 0:r.contentWindow)&&(e=$A.jsonParse(e),e.source==="fileView"&&e.action==="picture"&&this.$store.dispatch("previewImage",{index:e.params.index,list:e.params.array}),this.$emit("on-message",e))},postMessage(e,s="*"){this.$refs.iframe&&this.$refs.iframe.contentWindow.postMessage(e,s)}}},t={};var m=n(o,i,a,!1,c,null,null,null);function c(e){for(let s in t)this[s]=t[s]}var l=function(){return m.exports}();export{l as I};
|
||||
import{n}from"./app.1e00fc89.js";var i=function(){var e=this,s=e.$createElement,r=e._self._c||s;return r("iframe",{directives:[{name:"show",rawName:"v-show",value:e.src,expression:"src"}],ref:"iframe",attrs:{src:e.src}})},a=[];const o={name:"IFrame",props:{src:{type:String,default:""}},mounted(){this.$refs.iframe.addEventListener("load",this.handleLoad),window.addEventListener("message",this.handleMessage)},beforeDestroy(){this.$refs.iframe.removeEventListener("load",this.handleLoad),window.removeEventListener("message",this.handleMessage)},methods:{handleLoad(){this.$emit("on-load")},handleMessage({data:e,source:s}){var r;s===((r=this.$refs.iframe)==null?void 0:r.contentWindow)&&(e=$A.jsonParse(e),e.source==="fileView"&&e.action==="picture"&&this.$store.dispatch("previewImage",{index:e.params.index,list:e.params.array}),this.$emit("on-message",e))},postMessage(e,s="*"){this.$refs.iframe&&this.$refs.iframe.contentWindow.postMessage(e,s)}}},t={};var m=n(o,i,a,!1,c,null,null,null);function c(e){for(let s in t)this[s]=t[s]}var l=function(){return m.exports}();export{l as I};
|
||||
1
public/js/build/ImgUpload.be73943e.js
vendored
Normal file
1
public/js/build/ImgUpload.be73943e.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
public/js/build/ImgUpload.fed3b06e.js
vendored
1
public/js/build/ImgUpload.fed3b06e.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -1 +1 @@
|
||||
.component-only-office[data-v-60cfc883]{position:absolute;top:0;left:0;right:0;bottom:0;display:flex;align-items:center;justify-content:center}.component-only-office .placeholder[data-v-60cfc883]{flex:1;width:100%;height:100%}.component-only-office .office-loading[data-v-60cfc883]{position:absolute;top:0;left:0;right:0;bottom:0;display:flex;align-items:center;justify-content:center;z-index:2}.component-only-office .load-error{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);z-index:1;padding:8px;display:flex;align-items:center}.component-only-office .load-error .ivu-alert-icon{position:static;margin-right:8px;margin-left:4px}
|
||||
.component-only-office[data-v-5e3aa26e]{position:absolute;top:0;left:0;right:0;bottom:0;display:flex;align-items:center;justify-content:center}.component-only-office .placeholder[data-v-5e3aa26e]{flex:1;width:100%;height:100%}.component-only-office .office-loading[data-v-5e3aa26e]{position:absolute;top:0;left:0;right:0;bottom:0;display:flex;align-items:center;justify-content:center;z-index:2}.component-only-office .load-error{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);z-index:1;padding:8px;display:flex;align-items:center}.component-only-office .load-error .ivu-alert-icon{position:static;margin-right:8px;margin-left:4px}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
5
public/js/build/ReportEdit.8c2fafce.js
vendored
Normal file
5
public/js/build/ReportEdit.8c2fafce.js
vendored
Normal file
File diff suppressed because one or more lines are too long
5
public/js/build/ReportEdit.d3496ba4.js
vendored
5
public/js/build/ReportEdit.d3496ba4.js
vendored
File diff suppressed because one or more lines are too long
@ -1 +1 @@
|
||||
import{n as r}from"./app.f8ae8cc3.js";var a=function(){var t=this,n=t.$createElement,e=t._self._c||n;return t.windowTouch?e("div",[e("Button",{attrs:{loading:t.loading,type:"primary",icon:"ios-search"},on:{click:t.onSearch}},[t._v(t._s(t.$L("\u641C\u7D22")))]),t.filtering?e("Button",{attrs:{type:"text"},on:{click:t.onCancelFilter}},[t._v(t._s(t.$L("\u53D6\u6D88\u7B5B\u9009")))]):e("Button",{attrs:{loading:t.loading,type:"text",icon:"md-refresh"},on:{click:t.onRefresh}},[t._v(t._s(t.$L("\u5237\u65B0")))])],1):e("Tooltip",{attrs:{theme:"light",placement:t.placement,"transfer-class-name":"search-button-clear",transfer:""}},[e("Button",{attrs:{loading:t.loading,type:"primary",icon:"ios-search"},on:{click:t.onSearch}},[t._v(t._s(t.$L("\u641C\u7D22")))]),e("div",{attrs:{slot:"content"},slot:"content"},[t.filtering?e("Button",{attrs:{type:"text"},on:{click:t.onCancelFilter}},[t._v(t._s(t.$L("\u53D6\u6D88\u7B5B\u9009")))]):e("Button",{attrs:{loading:t.loading,type:"text"},on:{click:t.onRefresh}},[t._v(t._s(t.$L("\u5237\u65B0")))])],1)],1)},i=[];const l={name:"SearchButton",props:{loading:{type:Boolean,default:!1},filtering:{type:Boolean,default:!1},placement:{type:String,default:"bottom"}},methods:{onSearch(){this.$emit("search")},onRefresh(){this.$emit("refresh")},onCancelFilter(){this.$emit("cancelFilter")}}},o={};var s=r(l,a,i,!1,c,null,null,null);function c(t){for(let n in o)this[n]=o[n]}var h=function(){return s.exports}();export{h as S};
|
||||
import{n as r}from"./app.1e00fc89.js";var a=function(){var t=this,n=t.$createElement,e=t._self._c||n;return t.windowTouch?e("div",[e("Button",{attrs:{loading:t.loading,type:"primary",icon:"ios-search"},on:{click:t.onSearch}},[t._v(t._s(t.$L("\u641C\u7D22")))]),t.filtering?e("Button",{attrs:{type:"text"},on:{click:t.onCancelFilter}},[t._v(t._s(t.$L("\u53D6\u6D88\u7B5B\u9009")))]):e("Button",{attrs:{loading:t.loading,type:"text",icon:"md-refresh"},on:{click:t.onRefresh}},[t._v(t._s(t.$L("\u5237\u65B0")))])],1):e("Tooltip",{attrs:{theme:"light",placement:t.placement,"transfer-class-name":"search-button-clear",transfer:""}},[e("Button",{attrs:{loading:t.loading,type:"primary",icon:"ios-search"},on:{click:t.onSearch}},[t._v(t._s(t.$L("\u641C\u7D22")))]),e("div",{attrs:{slot:"content"},slot:"content"},[t.filtering?e("Button",{attrs:{type:"text"},on:{click:t.onCancelFilter}},[t._v(t._s(t.$L("\u53D6\u6D88\u7B5B\u9009")))]):e("Button",{attrs:{loading:t.loading,type:"text"},on:{click:t.onRefresh}},[t._v(t._s(t.$L("\u5237\u65B0")))])],1)],1)},i=[];const l={name:"SearchButton",props:{loading:{type:Boolean,default:!1},filtering:{type:Boolean,default:!1},placement:{type:String,default:"bottom"}},methods:{onSearch(){this.$emit("search")},onRefresh(){this.$emit("refresh")},onCancelFilter(){this.$emit("cancelFilter")}}},o={};var s=r(l,a,i,!1,c,null,null,null);function c(t){for(let n in o)this[n]=o[n]}var h=function(){return s.exports}();export{h as S};
|
||||
1
public/js/build/TEditor.37a62d41.js
vendored
Normal file
1
public/js/build/TEditor.37a62d41.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
public/js/build/TEditor.8dec398d.js
vendored
1
public/js/build/TEditor.8dec398d.js
vendored
File diff suppressed because one or more lines are too long
@ -1 +1 @@
|
||||
.task-editor[data-v-4e70a0a5]{position:relative;word-break:break-all}.task-editor[data-v-4e70a0a5] .mce-content-body,.task-editor[data-v-4e70a0a5] .task-editor-content{line-height:1.6}.task-editor[data-v-4e70a0a5] p{margin:.3em 0}.task-editor[data-v-4e70a0a5] blockquote,.task-editor[data-v-4e70a0a5] pre,.task-editor[data-v-4e70a0a5] ul,.task-editor[data-v-4e70a0a5] ol{margin:1em 0}.task-editor[data-v-4e70a0a5] ul,.task-editor[data-v-4e70a0a5] ol{margin-left:1.5em;padding-left:1.5em}.task-editor[data-v-4e70a0a5] li{margin:.25em 0}.task-editor[data-v-4e70a0a5] h1{margin:.67em 0}.task-editor[data-v-4e70a0a5] h2{margin:.83em 0}.task-editor[data-v-4e70a0a5] h3{margin:1em 0}.task-editor[data-v-4e70a0a5] h4{margin:1.33em 0}.task-editor[data-v-4e70a0a5] h5{margin:1.67em 0}.task-editor[data-v-4e70a0a5] h6{margin:2.33em 0}.task-editor .task-editor-operate[data-v-4e70a0a5]{position:absolute;top:0;left:0;width:1px;opacity:0;visibility:hidden;pointer-events:none}.task-tag-select[data-v-e09d999e]{width:100%;display:flex;flex-direction:column}.task-tag-select.no-search .search-box[data-v-e09d999e]{display:none}.task-tag-select.no-search .tag-list .tag-item[data-v-e09d999e]:first-child{margin-top:0}.task-tag-select .search-box[data-v-e09d999e]{padding-bottom:8px;border-bottom:1px solid #eee}.task-tag-select .search-box .search-input[data-v-e09d999e]{width:100%;height:34px;padding:0 12px;border:1px solid #dcdfe6;border-radius:4px;outline:none}.task-tag-select .search-box .search-input[data-v-e09d999e]:focus{border-color:#84c56a}.task-tag-select .tag-list[data-v-e09d999e]{flex:1;overflow-y:auto;max-height:300px;margin:0 -12px;padding:0 12px}.task-tag-select .tag-list .tag-item[data-v-e09d999e]{display:flex;align-items:flex-start;padding:8px 12px;cursor:pointer;border-radius:6px;margin-bottom:6px}.task-tag-select .tag-list .tag-item[data-v-e09d999e]:first-child{margin-top:12px}.task-tag-select .tag-list .tag-item[data-v-e09d999e]:last-child{margin-bottom:12px}.task-tag-select .tag-list .tag-item[data-v-e09d999e]:hover{background-color:#f5f7fa}.task-tag-select .tag-list .tag-item.is-selected[data-v-e09d999e]{background-color:#ecf5ff}.task-tag-select .tag-list .tag-item .tag-color[data-v-e09d999e]{width:16px;height:16px;border-radius:4px;margin-right:8px;margin-top:2px}.task-tag-select .tag-list .tag-item .tag-info[data-v-e09d999e]{flex:1}.task-tag-select .tag-list .tag-item .tag-info .tag-name[data-v-e09d999e]{line-height:20px;font-size:14px;color:#303133}.task-tag-select .tag-list .tag-item .tag-info .tag-desc[data-v-e09d999e]{font-size:12px;color:#909399;margin-top:2px}.task-tag-select .tag-list .tag-item .tag-check[data-v-e09d999e]{color:#84c56a;margin-left:12px;height:20px;display:flex;align-items:center}.task-tag-select .tag-list .no-data[data-v-e09d999e]{text-align:center;color:#909399;padding:24px 0;margin-bottom:12px}.task-tag-select .footer-box[data-v-e09d999e]{border-top:1px solid #eee;padding-top:8px}.task-tag-select .footer-box .add-button[data-v-e09d999e]{display:flex;align-items:center;justify-content:center;padding:4px 0 2px;cursor:pointer;color:#84c56a;border-radius:6px;transition:color .2s}.task-tag-select .footer-box .add-button[data-v-e09d999e]:hover{color:#a2d98d}.task-tag-select .footer-box .add-button i[data-v-e09d999e]{margin-right:4px}.task-content-history .ivu-page[data-v-a0030d34]{margin-top:12px;display:flex;align-items:center;justify-content:center}
|
||||
.task-editor[data-v-4e70a0a5]{position:relative;word-break:break-all}.task-editor[data-v-4e70a0a5] .mce-content-body,.task-editor[data-v-4e70a0a5] .task-editor-content{line-height:1.6}.task-editor[data-v-4e70a0a5] p{margin:.3em 0}.task-editor[data-v-4e70a0a5] blockquote,.task-editor[data-v-4e70a0a5] pre,.task-editor[data-v-4e70a0a5] ul,.task-editor[data-v-4e70a0a5] ol{margin:1em 0}.task-editor[data-v-4e70a0a5] ul,.task-editor[data-v-4e70a0a5] ol{margin-left:1.5em;padding-left:1.5em}.task-editor[data-v-4e70a0a5] li{margin:.25em 0}.task-editor[data-v-4e70a0a5] h1{margin:.67em 0}.task-editor[data-v-4e70a0a5] h2{margin:.83em 0}.task-editor[data-v-4e70a0a5] h3{margin:1em 0}.task-editor[data-v-4e70a0a5] h4{margin:1.33em 0}.task-editor[data-v-4e70a0a5] h5{margin:1.67em 0}.task-editor[data-v-4e70a0a5] h6{margin:2.33em 0}.task-editor .task-editor-operate[data-v-4e70a0a5]{position:absolute;top:0;left:0;width:1px;opacity:0;visibility:hidden;pointer-events:none}.task-tag-select[data-v-5700cf7a]{width:100%;display:flex;flex-direction:column}.task-tag-select.no-search .search-box[data-v-5700cf7a]{display:none}.task-tag-select.no-search .tag-list .tag-item[data-v-5700cf7a]:first-child{margin-top:0}.task-tag-select .search-box[data-v-5700cf7a]{padding-bottom:8px;border-bottom:1px solid #eee}.task-tag-select .search-box .search-input[data-v-5700cf7a]{width:100%;height:34px;padding:0 12px;border:1px solid #dcdfe6;border-radius:4px;outline:none}.task-tag-select .search-box .search-input[data-v-5700cf7a]:focus{border-color:#84c56a}.task-tag-select .tag-list[data-v-5700cf7a]{flex:1;overflow-y:auto;max-height:300px;margin:0 -12px;padding:0 12px}.task-tag-select .tag-list .tag-item[data-v-5700cf7a]{display:flex;align-items:flex-start;padding:8px 12px;cursor:pointer;border-radius:6px;margin-bottom:6px}.task-tag-select .tag-list .tag-item[data-v-5700cf7a]:first-child{margin-top:12px}.task-tag-select .tag-list .tag-item[data-v-5700cf7a]:last-child{margin-bottom:12px}.task-tag-select .tag-list .tag-item[data-v-5700cf7a]:hover{background-color:#f5f7fa}.task-tag-select .tag-list .tag-item.is-selected[data-v-5700cf7a]{background-color:#ecf5ff}.task-tag-select .tag-list .tag-item .tag-color[data-v-5700cf7a]{width:16px;height:16px;border-radius:4px;margin-right:8px;margin-top:2px}.task-tag-select .tag-list .tag-item .tag-info[data-v-5700cf7a]{flex:1}.task-tag-select .tag-list .tag-item .tag-info .tag-name[data-v-5700cf7a]{line-height:20px;font-size:14px;color:#303133}.task-tag-select .tag-list .tag-item .tag-info .tag-desc[data-v-5700cf7a]{font-size:12px;color:#909399;margin-top:2px}.task-tag-select .tag-list .tag-item .tag-check[data-v-5700cf7a]{color:#84c56a;margin-left:12px;height:20px;display:flex;align-items:center}.task-tag-select .tag-list .no-data[data-v-5700cf7a]{text-align:center;color:#909399;padding:24px 0;margin-bottom:12px}.task-tag-select .footer-box[data-v-5700cf7a]{border-top:1px solid #eee;padding-top:8px}.task-tag-select .footer-box .add-button[data-v-5700cf7a]{display:flex;align-items:center;justify-content:center;padding:4px 0 2px;cursor:pointer;color:#84c56a;border-radius:6px;transition:color .2s}.task-tag-select .footer-box .add-button[data-v-5700cf7a]:hover{color:#a2d98d}.task-tag-select .footer-box .add-button i[data-v-5700cf7a]{margin-right:4px}.task-content-history .ivu-page[data-v-a0030d34]{margin-top:12px;display:flex;align-items:center;justify-content:center}
|
||||
1
public/js/build/TaskDetail.c2a72552.js
vendored
Normal file
1
public/js/build/TaskDetail.c2a72552.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
public/js/build/TaskDetail.d626eed5.js
vendored
1
public/js/build/TaskDetail.d626eed5.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
7
public/js/build/app.08442188.css
vendored
7
public/js/build/app.08442188.css
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
7
public/js/build/app.fcecaabb.css
vendored
Normal file
7
public/js/build/app.fcecaabb.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
public/js/build/application.29cc7ef9.css
vendored
Normal file
1
public/js/build/application.29cc7ef9.css
vendored
Normal file
@ -0,0 +1 @@
|
||||
.map-select-container[data-v-b1904dc8]{display:flex;gap:20px;height:500px}@media (width < 768px){.map-select-container[data-v-b1904dc8]{flex-direction:column;height:700px}.map-select-container .map-radius-control[data-v-b1904dc8]{width:100%;border-left:0;padding-left:0}}.map-select-iframe-container[data-v-b1904dc8]{flex:1}.map-select-point-iframe[data-v-b1904dc8]{width:100%;height:100%;border:0;border-radius:12px}.map-radius-control[data-v-b1904dc8]{width:280px;border-left:1px solid #e8e8e8;padding-left:20px;display:flex;flex-direction:column}.radius-control-header[data-v-b1904dc8]{margin-bottom:15px}.radius-control-header h4[data-v-b1904dc8]{margin:0;font-size:16px;font-weight:600;color:#333}.radius-control-body[data-v-b1904dc8]{flex:1;display:flex;flex-direction:column}.location-info[data-v-b1904dc8]{margin:15px 0;padding:12px;background:#f8f9fa;border-radius:6px}.info-item[data-v-b1904dc8]{display:flex;justify-content:space-between;margin-bottom:8px;font-size:14px}.info-item[data-v-b1904dc8]:last-child{margin-bottom:0}.info-label[data-v-b1904dc8]{color:#666}.info-value[data-v-b1904dc8]{color:#333;font-weight:500}.radius-control-tip[data-v-b1904dc8]{background:#f0f8ff;padding:12px;border-radius:6px;border-left:3px solid #007cff;font-size:13px;color:#333;line-height:1.4;margin-top:auto}
|
||||
1
public/js/build/application.a558e2fe.css
vendored
1
public/js/build/application.a558e2fe.css
vendored
@ -1 +0,0 @@
|
||||
.map-select-container[data-v-6135b0b6]{display:flex;gap:20px;height:500px}@media (width < 768px){.map-select-container[data-v-6135b0b6]{flex-direction:column;height:700px}.map-select-container .map-radius-control[data-v-6135b0b6]{width:100%;border-left:0;padding-left:0}}.map-select-iframe-container[data-v-6135b0b6]{flex:1}.map-select-point-iframe[data-v-6135b0b6]{width:100%;height:100%;border:0;border-radius:12px}.map-radius-control[data-v-6135b0b6]{width:280px;border-left:1px solid #e8e8e8;padding-left:20px;display:flex;flex-direction:column}.radius-control-header[data-v-6135b0b6]{margin-bottom:15px}.radius-control-header h4[data-v-6135b0b6]{margin:0;font-size:16px;font-weight:600;color:#333}.radius-control-body[data-v-6135b0b6]{flex:1;display:flex;flex-direction:column}.location-info[data-v-6135b0b6]{margin:15px 0;padding:12px;background:#f8f9fa;border-radius:6px}.info-item[data-v-6135b0b6]{display:flex;justify-content:space-between;margin-bottom:8px;font-size:14px}.info-item[data-v-6135b0b6]:last-child{margin-bottom:0}.info-label[data-v-6135b0b6]{color:#666}.info-value[data-v-6135b0b6]{color:#333;font-weight:500}.radius-control-tip[data-v-6135b0b6]{background:#f0f8ff;padding:12px;border-radius:6px;border-left:3px solid #007cff;font-size:13px;color:#333;line-height:1.4;margin-top:auto}
|
||||
1
public/js/build/application.c01235a2.js
vendored
1
public/js/build/application.c01235a2.js
vendored
File diff suppressed because one or more lines are too long
1
public/js/build/application.f7cea0dc.js
vendored
Normal file
1
public/js/build/application.f7cea0dc.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
public/js/build/apps.2030d910.js
vendored
Normal file
1
public/js/build/apps.2030d910.js
vendored
Normal file
@ -0,0 +1 @@
|
||||
import{m}from"./vuex.cc7cb26e.js";import{M as e}from"./index.4ff51c76.js";import{n as a}from"./app.1e00fc89.js";import"./vue.adba9046.js";import"./@babel.9410f858.js";import"./view-design-hi.f1128b4d.js";import"./@micro-zoe.39406924.js";import"./DialogWrapper.d635cc01.js";import"./chunkedUpload.b0e8368a.js";import"./axios.37c7f908.js";import"./spark-md5.0443325f.js";import"./index.978d6d4c.js";import"./vue-virtual-scroll-list-hi.74ad83f0.js";import"./lodash.8fcd6fd4.js";import"./ImgUpload.be73943e.js";import"./webhook.378987f3.js";import"./jquery.99163cb9.js";import"./dayjs.0bb0f368.js";import"./localforage.6e50a401.js";import"./markdown-it.0450edb4.js";import"./mdurl.ce6c1dd8.js";import"./uc.micro.8d343c98.js";import"./entities.48a44fec.js";import"./linkify-it.c5e8196e.js";import"./punycode.js.4b3f125a.js";import"./highlight.js.cbbfb885.js";import"./markdown-it-link-attributes.e1d5d151.js";import"./@traptitech.acea8861.js";import"./openpgp_hi.15f91b1d.js";import"./mitt.1ea0a2a3.js";import"./quill-hi.ca2ea0cc.js";import"./parchment.d5c5924e.js";import"./quill-delta.385a10bf.js";import"./fast-diff.f17881f3.js";import"./lodash.clonedeep.3cc09a31.js";import"./lodash.isequal.dbdc2157.js";import"./eventemitter3.78b735ad.js";import"./lodash-es.76e3a28b.js";import"./quill-mention-hi.4eeb5a2d.js";import"./html-to-md.f297036e.js";import"./vue-router.2d566cd7.js";import"./vue-clipboard2.fd43a5bc.js";import"./clipboard.37b37361.js";import"./vuedraggable.f464b992.js";import"./sortablejs.3488b922.js";import"./vue-resize-observer.5af23a43.js";import"./element-sea.f8a64907.js";import"./deepmerge.cecf392e.js";import"./resize-observer-polyfill.5d591c5f.js";import"./throttle-debounce.7c3948b2.js";import"./babel-helper-vue-jsx-merge-props.5ed215c3.js";import"./normalize-wheel.2a034b9f.js";import"./async-validator.dca2b951.js";import"./babel-runtime.4773988a.js";import"./core-js.314b4a1d.js";var n=function(){var t=this,o=t.$createElement,r=t._self._c||o;return r("MicroApps",{ref:"app",attrs:{"window-type":"popout"}})},s=[];const u={components:{MicroApps:e},computed:{...m(["userIsAdmin"])},async mounted(){const{name:t}=this.$route.params;if(!t){$A.modalError("\u5E94\u7528\u4E0D\u5B58\u5728");return}if(t==="iframe-test"){if(!this.userIsAdmin){$A.modalError("\u4EC5\u7BA1\u7406\u5458\u53EF\u4F7F\u7528\u6B64\u529F\u80FD");return}let{url:r}=this.$route.query;if(!r){if(r=await this.promptIframeUrl(),!r)return;this.$router.replace({path:this.$route.path,query:{...this.$route.query,url:r}}).catch(()=>{})}await this.$refs.app.onOpen({id:"iframe-test",name:"iframe-test",url:r,type:"iframe",transparent:!0,keep_alive:!1});return}const o=(await $A.IDBArray("cacheMicroApps")).reverse().find(r=>r.name===t);if(!o){$A.modalError("\u5E94\u7528\u4E0D\u5B58\u5728");return}await this.$refs.app.onOpen(o)},methods:{promptIframeUrl(){return new Promise((t,o)=>{$A.modalInput({title:this.$L("\u8BF7\u8F93\u5165 URL"),placeholder:"https://example.com",onOk:r=>{const i=(r||"").trim();if(!i)return this.$L("URL\u4E0D\u80FD\u4E3A\u7A7A");t(i)},onCancel:()=>o()})}).catch(()=>null)}}},p={};var c=a(u,n,s,!1,l,null,null,null);function l(t){for(let o in p)this[o]=p[o]}var hr=function(){return c.exports}();export{hr as default};
|
||||
1
public/js/build/apps.f2464932.js
vendored
1
public/js/build/apps.f2464932.js
vendored
@ -1 +0,0 @@
|
||||
import{m}from"./vuex.cc7cb26e.js";import{M as e}from"./index.30bc8b66.js";import{n as a}from"./app.f8ae8cc3.js";import"./vue.adba9046.js";import"./@babel.9410f858.js";import"./view-design-hi.f1128b4d.js";import"./@micro-zoe.39406924.js";import"./DialogWrapper.1505b441.js";import"./index.775d359c.js";import"./vue-virtual-scroll-list-hi.74ad83f0.js";import"./lodash.8fcd6fd4.js";import"./ImgUpload.fed3b06e.js";import"./webhook.378987f3.js";import"./jquery.24b9d090.js";import"./dayjs.19659d6c.js";import"./localforage.8c2def53.js";import"./markdown-it.0450edb4.js";import"./mdurl.ce6c1dd8.js";import"./uc.micro.8d343c98.js";import"./entities.48a44fec.js";import"./linkify-it.c5e8196e.js";import"./punycode.js.4b3f125a.js";import"./highlight.js.cbbfb885.js";import"./markdown-it-link-attributes.e1d5d151.js";import"./@traptitech.acea8861.js";import"./openpgp_hi.15f91b1d.js";import"./axios.37c7f908.js";import"./mitt.1ea0a2a3.js";import"./quill-hi.ca2ea0cc.js";import"./parchment.d5c5924e.js";import"./quill-delta.385a10bf.js";import"./fast-diff.f17881f3.js";import"./lodash.clonedeep.3cc09a31.js";import"./lodash.isequal.dbdc2157.js";import"./eventemitter3.78b735ad.js";import"./lodash-es.76e3a28b.js";import"./quill-mention-hi.4eeb5a2d.js";import"./html-to-md.f297036e.js";import"./vue-router.2d566cd7.js";import"./vue-clipboard2.fd43a5bc.js";import"./clipboard.37b37361.js";import"./vuedraggable.f464b992.js";import"./sortablejs.3488b922.js";import"./vue-resize-observer.5af23a43.js";import"./element-sea.f8a64907.js";import"./deepmerge.cecf392e.js";import"./resize-observer-polyfill.5d591c5f.js";import"./throttle-debounce.7c3948b2.js";import"./babel-helper-vue-jsx-merge-props.5ed215c3.js";import"./normalize-wheel.2a034b9f.js";import"./async-validator.dca2b951.js";import"./babel-runtime.4773988a.js";import"./core-js.314b4a1d.js";var n=function(){var t=this,o=t.$createElement,r=t._self._c||o;return r("MicroApps",{ref:"app",attrs:{"window-type":"popout"}})},s=[];const u={components:{MicroApps:e},computed:{...m(["userIsAdmin"])},async mounted(){const{name:t}=this.$route.params;if(!t){$A.modalError("\u5E94\u7528\u4E0D\u5B58\u5728");return}if(t==="iframe-test"){if(!this.userIsAdmin){$A.modalError("\u4EC5\u7BA1\u7406\u5458\u53EF\u4F7F\u7528\u6B64\u529F\u80FD");return}let{url:r}=this.$route.query;if(!r){if(r=await this.promptIframeUrl(),!r)return;this.$router.replace({path:this.$route.path,query:{...this.$route.query,url:r}}).catch(()=>{})}await this.$refs.app.onOpen({id:"iframe-test",name:"iframe-test",url:r,type:"iframe",transparent:!0,keep_alive:!1});return}const o=(await $A.IDBArray("cacheMicroApps")).reverse().find(r=>r.name===t);if(!o){$A.modalError("\u5E94\u7528\u4E0D\u5B58\u5728");return}await this.$refs.app.onOpen(o)},methods:{promptIframeUrl(){return new Promise((t,o)=>{$A.modalInput({title:this.$L("\u8BF7\u8F93\u5165 URL"),placeholder:"https://example.com",onOk:r=>{const i=(r||"").trim();if(!i)return this.$L("URL\u4E0D\u80FD\u4E3A\u7A7A");t(i)},onCancel:()=>o()})}).catch(()=>null)}}},p={};var c=a(u,n,s,!1,l,null,null,null);function l(t){for(let o in p)this[o]=p[o]}var lr=function(){return c.exports}();export{lr as default};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user