Compare commits

...

10 Commits

Author SHA1 Message Date
kuaifan
ff53e1fac3 fix: enforce positive rounded size in normalizeSize 2025-11-27 10:40:45 +08:00
kuaifan
cf4894b7c3 no message 2025-11-27 02:24:40 +00:00
kuaifan
678dfd2d5c feat: 更新 appstore 镜像版本 2025-11-27 02:24:34 +00:00
kuaifan
bf4a62ae04 feat: 更新文档,添加前端弹窗文案处理说明 2025-11-24 01:23:39 +00:00
kuaifan
7e6f3f92cf feat: 添加 URL 输入提示,优化 iframe 测试功能的用户体验 2025-11-24 01:23:22 +00:00
kuaifan
df382dafb4 no message 2025-11-24 00:38:16 +00:00
kuaifan
10925d3a47 no message 2025-11-20 06:19:29 +00:00
kuaifan
66252072c7 feat: 添加 iframe 测试功能,支持通过 URL 加载外部内容 2025-11-20 06:18:56 +00:00
kuaifan
29918882bd no message 2025-11-19 07:54:56 +00:00
kuaifan
4983fe8feb feat: 添加自定义微应用菜单功能,支持管理员配置和保存菜单项 2025-11-19 07:54:47 +00:00
15 changed files with 807 additions and 16 deletions

4
.gitignore vendored
View File

@ -23,6 +23,9 @@
vars.yaml
# IDE and editor files
.cursor/*
!.cursor/rules/
!.cursor/rules/**
.idea
.vscode
.windsurfrules
@ -57,5 +60,4 @@ laravels.pid
.DS_Store
# Documentation
AGENTS.md
README_LOCAL.md

127
AGENTS.md Normal file
View File

@ -0,0 +1,127 @@
# DooTask 项目说明
## 一、项目总览
- **项目定位**DooTask 是一套开源的任务 / 项目管理系统,支持看板、任务、子任务、评论、对话、文件、报表等协作能力。
- **后端技术栈**
- 基于 Laravel运行在 LaravelS / Swoole 常驻进程上),代码集中在 `app/``routes/``config/` 等目录。
- 数据库通过 Laravel Eloquent 模型访问,所有表结构变更必须通过 migration 完成,禁止直接手工改库。
- **前端技术栈**
- 主 Web 前端基于 Vue2 + Vite代码集中在 `resources/assets/js`
- 打包与开发通过根目录的 `./cmd` 脚本间接调用 Vite。
- **桌面端**
- 使用 Electron 作为桌面壳,核心业务逻辑仍在 Web 前端与 Laravel 后端中。
更多安装、升级、迁移说明见根目录 `README.md`
## 二、开发与运行(命令约定)
- 开发 / 构建命令统一通过根目录的 `./cmd` 脚本执行,以保证与 Docker / 容器环境一致:
- 启动服务:`./cmd up`
- 停止服务:`./cmd down`
- 重启服务:`./cmd reup``./cmd restart`
- Laravel 工具:`./cmd artisan ...`
- 前端开发:`./cmd dev`
- 前端构建:`./cmd prod``./cmd build`
- 其他工具:`./cmd composer ...``./cmd php ...``./cmd doc`
- 在示例、脚本与回答中,优先使用 `./cmd ...` 形式,而不是直接调用 `php``composer``npm` 等命令。
## 三、代码结构(后端 + 前端)
- **Controller`app/Http/Controllers`**
- 负责路由入口、参数接收与基础校验,编排调用模型 / 模块,并组装 API 响应。
- 原则:控制器尽量保持「薄」,复杂业务逻辑不要堆积在控制器中。
- 业务异常优先使用 `App\Exceptions\ApiException` 抛出,由全局 Handler 统一转换为标准 JSON 响应。
- **Model`app/Models`**
- 负责数据表结构映射、关系relations、访问器 / 修改器、自定义查询 Scope 等「数据层」逻辑。
- 避免在模型方法中塞入大量跨业务的流程控制逻辑,复杂业务应下沉到模块中。
- **Module`app/Module`**
- 承载跨控制器 / 跨模型的业务逻辑与独立功能子域,例如:
- 外部服务集成:`AgoraIO/*``ZincSearch/*` 等;
- 通用工具:`Lock.php``TextExtractor.php``Image.php` 等;
- 项目 / 任务 / 对话等领域里的复杂协作逻辑。
- 原则:
- 新增较复杂的业务功能时,优先考虑创建 / 扩展 Module而不是在 Controller 或 Model 中堆砌流程。
- Module 尽量保持单一职责与可复用,命名能直接反映其业务或能力作用。
- **运行环境注意事项LaravelS / Swoole**
- 避免在静态属性、单例、全局变量中存储请求级状态或可变数据,防止请求间数据串联和内存泄漏。
- 不要假设构造函数、服务提供者或 `boot()` 方法会在每个请求重新执行;涉及配置、路由等改动时,通常需要通过 `./cmd php restart` 或容器重启后才能生效。
- 编写长连接、定时任务、WebSocket 等长生命周期逻辑时,优先复用现有模式,并避免长时间阻塞协程 / 事件循环的操作。
- **前端(`resources/assets/js`Vue2 + Vite**
- 结构大致包括:
- `app.js``App.vue`:应用入口与根组件;
- `components/`:通用与业务组件(任务看板、文件预览、聊天等);
- `pages/`:页面级组件(登录、项目、任务视图、消息、报表等);
- `store/`Vuex 全局状态管理;
- `routes.js`:前端路由配置。
- 构建与开发:
- 开发模式:使用 `./cmd dev` 或类似子命令,内部通过 Vite 启动开发服务器。
- 生产构建:使用 `./cmd prod``./cmd build`,内部通过 Vite 产出前端静态资源。
- 与后端接口协作:
- 接口调用默认通过已有的 Vuex 封装发起请求,新增接口时优先扩展集中封装,而不是在组件中直接散落 `axios/fetch`
- **Electron**
- Electron 主要作为桌面入口壳,核心业务逻辑仍在 Web/Vue2 前端与 PHP/Laravel 后端。
- 日常开发与调试优先使用 `./cmd electron ...`;需要构建 App 端资源时使用 `./cmd appbuild`
- 原则:优先保证 Web 端行为正确,再通过 Electron 壳复用 Web 逻辑;桌面专有能力(本地文件、托盘等)需在代码中明确边界。
## 四、在本项目中使用 Graphiti 作为长期记忆
- **角色与 group_id**
- Graphiti 作为本项目的「长期记忆层」,用于持久化:
- 用户偏好Preferences、工作流程 / 习惯Procedures、重要约束Requirements、关键事实 / 关系Facts
- 目标是:跨对话、跨任务保持一致的行为和决策,而不是简单堆积信息。
- 本项目统一使用的 `group_id``dootask-main`
- **任务开始前(读)**
- 在进行实质性工作(写代码、设计方案、做大改动)前,应先通过 Graphiti 查询已有记忆:
- 使用节点搜索(如 `search_nodes`)在 `group_id = "dootask-main"` 下查找与当前任务相关的 Preference / Procedure / Requirement
- 使用事实搜索(如 `search_facts`)查找相关事实与实体关系;
- 查询语句中可包含任务类型Bug 修复 / 重构 / 新功能等、涉及模块任务、项目、对话、WebSocket、报表等以及关键字 `dootask`
- 发现与当前任务高度相关的偏好 / 流程 / 约束时,应优先遵守;如存在冲突,应在回答中说明并做合理选择。
- **什么时候写入 Graphiti**
- **偏好Preferences**:用户表达持续性偏好时(语言、输出格式、技术选型等),应尽快写入;
- **流程 / 习惯Procedures**:形成「以后都按这个流程来」的稳定开发 / 发布 / 调试流程时,应记录为可复用步骤;
- **约束 / 决策Requirements**:项目长期有效的决策,如不再支持某版本、某模块的架构约定等;
- **事实 / 关系Facts**:模块边界约定、服务之间的调用关系、与外部系统(如 AgoraIO、ZincSearch集成方式等。
- 写入建议:
- 默认使用 `source: "text"`,在 `episode_body` 中用简洁结构化自然语言描述背景、类型、范围、具体内容;
- 需要结构化数据时可用 `source: "json"`,保证 `episode_body` 是合法 JSON 字符串;
- 所有写入默认使用 `group_id: "dootask-main"`
- **更新与更正**
- 偏好 / 流程发生变化时,新增一条 episode 说明新约定,并标明这是对旧习惯的更新,后续以最新、最明确的为准;
- 用户要求「忘记」某些记忆时,可通过删除或更正相关 episode / 关系的方式处理;
- 尽量通过新增 episode 记录「更正 / 废弃说明」,而不是直接改写历史事实。
- **在工作中的使用方式**
- 尊重已存偏好:编码风格、回答结构、工具选择等应对齐已知偏好;
- 遵循已有流程:若图谱中已有与当前任务匹配的 Procedure应尽量按步骤执行
- 利用事实:理解系统行为、模块边界、历史决策时优先查已存 Facts减少重新摸索
- 如 Graphiti 与当前代码实际冲突,应以代码实际为准,并视情况新增 episode 更新事实。
- **不要写入 Graphiti 的内容**
- 含敏感信息(密钥、密码、隐私数据等);
- 只与当前一次任务相关、未来不会复用的临时信息(调试日志、一次性命令输出等);
- 体量巨大的原始数据(完整日志、长脚本全文等),应只存摘要和关键结论。
- **最佳实践小结**
- 先查再做:在提出方案或改动架构前,优先查阅 Graphiti 中已有的设计、偏好和约束;
- 能复用就沉淀:只要发现某个偏好 / 流程 / 约束未来会反复用到,就尽快写入 Graphiti而不是只放在当前对话里
- 保持项目内外一致:确保 Graphiti 中的记忆与实际代码长期保持一致,避免「记忆漂移」。
## 五、前端弹窗文案
- 在前端 Vue 代码中调用 `$A.modalXXX``$A.messageXXX``$A.noticeXXX` 时,这些方法内部会统一处理 `$L` 翻译,调用方默认不要再额外包一层 `$L`
- 仅当 `modalXXX` 特殊场景显式传入 `language: false`(关闭内部自动翻译)时,才由调用方在传入前自行决定是否使用 `$L` 处理文案。
## 六、AI 回复风格与语言偏好
- 总体说明与重要总结(尤其是最终回答的 recap 部分),在不影响技术表达准确性的前提下,应优先使用简体中文进行回复。
- 如用户在对话中明确要求使用其他语言(例如英文),则以用户的显式指令为最高优先级。
- 当本次协作的改动已经较为完整且自然形成一个提交单元时,应在最终回答中附带一条或数条推荐的 Git 提交 message方便用户直接复制使用。

View File

@ -722,6 +722,47 @@ class SystemController extends AbstractController
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting);
}
/**
* @api {post} api/system/microapp_menu 自定义应用菜单
*
* @apiDescription 获取或保存自定义微应用菜单,仅管理员可配置
* @apiVersion 1.0.0
* @apiGroup system
* @apiName microapp_menu
*
* @apiParam {String} type
* - get: 获取(默认)
* - save: 保存(限管理员)
* @apiParam {Array} list 菜单列表,格式:[{id,name,version,menu_items}]
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function microapp_menu()
{
$type = trim(Request::input('type'));
$user = User::auth();
if ($type == 'save') {
User::auth('admin');
$list = Request::input('list');
if (empty($list) || !is_array($list)) {
$list = [];
}
$apps = Setting::normalizeCustomMicroApps($list);
$setting = Base::setting('microapp_menu', $apps);
$setting = Setting::formatCustomMicroAppsForResponse($setting);
} else {
$setting = Base::setting('microapp_menu');
if (!is_array($setting)) {
$setting = [];
}
$setting = Setting::filterCustomMicroAppsForUser($setting, $user);
$setting = Setting::formatCustomMicroAppsForResponse($setting);
}
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting);
}
/**
* @api {post} api/system/column/template 创建项目模板
*

View File

@ -164,6 +164,213 @@ class Setting extends AbstractModel
return $array;
}
/**
* 规范自定义微应用配置
* @param array $list
* @return array
*/
public static function normalizeCustomMicroApps($list)
{
if (!is_array($list)) {
return [];
}
$apps = [];
foreach ($list as $item) {
$app = self::normalizeCustomMicroAppItem($item);
if ($app) {
$apps[] = $app;
}
}
return $apps;
}
/**
* 根据用户身份过滤可见的自定义微应用
* @param array $apps
* @param \App\Models\User|null $user
* @return array
*/
public static function filterCustomMicroAppsForUser(array $apps, $user)
{
if (empty($apps)) {
return [];
}
$isAdmin = $user ? $user->isAdmin() : false;
$userId = $user ? intval($user->userid) : 0;
$filtered = [];
foreach ($apps as $app) {
$visible = self::normalizeCustomMicroVisible($app['visible_to'] ?? ['admin']);
if (!self::isCustomMicroVisibleTo($visible, $isAdmin, $userId)) {
continue;
}
if (empty($app['menu_items']) || !is_array($app['menu_items'])) {
continue;
}
$menus = array_values(array_filter($app['menu_items'], function ($menu) use ($isAdmin, $userId) {
if (!isset($menu['visible_to'])) {
return true;
}
$visible = self::normalizeCustomMicroVisible($menu['visible_to']);
return self::isCustomMicroVisibleTo($visible, $isAdmin, $userId);
}));
if (empty($menus)) {
continue;
}
$app['menu_items'] = $menus;
$filtered[] = $app;
}
return $filtered;
}
/**
* 将存储结构转换成 appstore 接口同款格式
* @param array $apps
* @return array
*/
public static function formatCustomMicroAppsForResponse(array $apps)
{
return array_values(array_map(function ($app) {
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);
if (isset($menu['visible_to'])) {
unset($menu['visible_to']);
}
return $menu;
}, $app['menu_items']));
}
return $app;
}, $apps));
}
/**
* 规范自定义微应用
* @param array $item
* @return array|null
*/
protected static function normalizeCustomMicroAppItem($item)
{
if (!is_array($item)) {
return null;
}
$id = trim($item['id'] ?? '');
if ($id === '') {
return null;
}
$name = Base::newTrim($item['name'] ?? '');
$version = Base::newTrim($item['version'] ?? '') ?: 'custom';
$menuItems = [];
if (isset($item['menu_items']) && is_array($item['menu_items'])) {
$menuItems = $item['menu_items'];
} elseif (isset($item['menu']) && is_array($item['menu'])) {
$menuItems = [$item['menu']];
}
if (empty($menuItems)) {
return null;
}
$normalizedMenus = [];
foreach ($menuItems as $menu) {
$formattedMenu = self::normalizeCustomMicroMenuItem($menu, $name ?: $id);
if ($formattedMenu) {
$normalizedMenus[] = $formattedMenu;
}
}
if (empty($normalizedMenus)) {
return null;
}
return Base::newTrim([
'id' => $id,
'name' => $name,
'version' => $version,
'menu_items' => $normalizedMenus,
'visible_to' => self::normalizeCustomMicroVisible($item['visible_to'] ?? 'admin'),
]);
}
/**
* 规范自定义微应用菜单项
* @param array $menu
* @param string $fallbackLabel
* @return array|null
*/
protected static function normalizeCustomMicroMenuItem($menu, $fallbackLabel = '')
{
if (!is_array($menu)) {
return null;
}
$url = trim($menu['url'] ?? '');
if ($url === '') {
return null;
}
$location = trim($menu['location'] ?? 'application');
$label = trim($menu['label'] ?? $fallbackLabel);
$urlType = strtolower(trim($menu['url_type'] ?? 'iframe'));
$payload = [
'location' => $location,
'label' => $label,
'icon' => Base::newTrim($menu['icon'] ?? ''),
'url' => $url,
'url_type' => $urlType,
'keep_alive' => isset($menu['keep_alive']) ? (bool)$menu['keep_alive'] : true,
'disable_scope_css' => (bool)($menu['disable_scope_css'] ?? false),
'auto_dark_theme' => isset($menu['auto_dark_theme']) ? (bool)$menu['auto_dark_theme'] : true,
'transparent' => (bool)($menu['transparent'] ?? false),
];
if (!empty($menu['background'])) {
$payload['background'] = Base::newTrim($menu['background']);
}
if (!empty($menu['capsule']) && is_array($menu['capsule'])) {
$payload['capsule'] = Base::newTrim($menu['capsule']);
}
return $payload;
}
/**
* 规范自定义微应用可见范围
* @param mixed $value
* @return array
*/
protected static function normalizeCustomMicroVisible($value)
{
if (is_array($value)) {
$list = array_filter(array_map('trim', $value));
} else {
$list = array_filter(array_map('trim', explode(',', (string)$value)));
}
if (empty($list)) {
return ['admin'];
}
if (in_array('all', $list)) {
return ['all'];
}
return array_values($list);
}
/**
* 判断自定义微应用是否可见
* @param array $visible
* @param bool $isAdmin
* @param int $userId
* @return bool
*/
protected static function isCustomMicroVisibleTo(array $visible, bool $isAdmin, int $userId)
{
if (in_array('all', $visible)) {
return true;
}
if ($isAdmin && in_array('admin', $visible)) {
return true;
}
if ($userId > 0 && in_array((string)$userId, $visible, true)) {
return true;
}
return false;
}
/**
* 验证邮箱地址(过滤忽略地址)
* @param $array

View File

@ -1301,7 +1301,7 @@ class Base
/**
* 获取或设置
* @param $setname // 配置名称
* @param bool $array // 保存内容
* @param bool|array $array // 保存内容
* @param bool $isUpdate // 保存内容为更新模式,默认否
* @return array
*/

View File

@ -96,10 +96,10 @@ services:
appstore:
container_name: "dootask-appstore-${APP_ID}"
privileged: true
image: "dootask/appstore:0.3.2"
image: "dootask/appstore:0.3.3"
volumes:
- shared_data:/usr/share/dootask
- /var/run/docker.sock:/var/run/docker.sock
- ${HOST_DOCKER_SOCK:-/var/run/docker.sock}:/var/run/docker.sock
- ./:/var/www
environment:
HOST_PWD: "${PWD}"

14
electron/lib/utils.js vendored
View File

@ -109,14 +109,22 @@ const utils = {
},
/**
* 兜底处理尺寸类数值确保传入的是有限数字
* 兜底处理尺寸类数值返回四舍五入后的正整数
* @param value
* @param fallback
* @returns {number}
*/
normalizeSize(value, fallback) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
const toPositiveNumber = (candidate) => {
const num = Number(candidate);
return Number.isFinite(num) && num > 0 ? num : null;
};
const primary = toPositiveNumber(value);
const secondary = toPositiveNumber(fallback);
const safeValue = primary ?? secondary ?? 1;
return Math.max(1, Math.round(safeValue));
},
/**

View File

@ -2267,4 +2267,8 @@ AI 分析已更新
归档已完成任务
你确定将列表【(*)】中所有已完成的任务归档吗?
已归档列表中所有已完成任务
归档失败,请稍后再试
归档失败,请稍后再试
请输入 URL
URL不能为空
仅管理员可使用此功能

View File

@ -0,0 +1,5 @@
<svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="24" height="24">
<path d="M0 0m349.206261 0l325.587478 0q349.206261 0 349.206261 349.206261l0 325.587478q0 349.206261-349.206261 349.206261l-325.587478 0q-349.206261 0-349.206261-349.206261l0-325.587478q0-349.206261 349.206261-349.206261Z" fill="#84c56a" class="selected"></path>
<path d="M442.189913 298.284522c9.728 0 18.053565 3.372522 24.976696 10.128695 6.912 6.745043 10.373565 14.981565 10.373565 24.731826v140.566261c0 9.750261-3.450435 18.086957-10.373565 25.021218a34.003478 34.003478 0 0 1-24.976696 10.395826H301.924174a33.090783 33.090783 0 0 1-24.698435-10.395826A34.626783 34.626783 0 0 1 267.130435 473.711304V333.145043c0-9.750261 3.361391-17.986783 10.095304-24.742956 6.733913-6.745043 14.970435-10.117565 24.698435-10.117565h140.265739zM442.189913 579.417043c9.728 0 18.053565 3.372522 24.976696 10.128696 6.912 6.733913 10.373565 14.981565 10.373565 24.731826v141.133913c0 9.73913-3.450435 17.986783-10.373565 24.731826-6.92313 6.745043-15.248696 10.128696-24.976696 10.128696H301.924174c-9.728 0-17.964522-3.383652-24.698435-10.128696C270.491826 773.398261 267.130435 765.150609 267.130435 755.400348V614.266435c0-9.73913 3.361391-17.986783 10.095304-24.731826 6.733913-6.745043 14.970435-10.128696 24.698435-10.128696h140.265739zM723.311304 579.417043c9.71687 0 17.953391 3.372522 24.687305 10.128696 6.733913 6.733913 10.095304 14.981565 10.095304 24.731826v141.133913c0 9.73913-3.361391 17.986783-10.095304 24.731826-6.733913 6.745043-14.970435 10.128696-24.687305 10.128696H583.034435a34.482087 34.482087 0 0 1-24.976696-10.128696 33.224348 33.224348 0 0 1-10.373565-24.742956V614.266435c0-9.73913 3.450435-17.986783 10.373565-24.731826a34.482087 34.482087 0 0 1 24.976696-10.128696h140.276869z" fill="#FFFFFF"></path>
<path d="M667.826087 243.287534m23.611218 23.611218l110.185682 110.185682q23.611218 23.611218 0 47.222436l-110.185682 110.185683q-23.611218 23.611218-47.222436 0l-110.185683-110.185683q-23.611218-23.611218 0-47.222436l110.185683-110.185682q23.611218-23.611218 47.222436 0Z" fill="#FFFFFF" fill-opacity=".7"></path>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -15,6 +15,7 @@
<DropdownMenu slot="list">
<DropdownItem v-if="!sortingMode" name="sort">{{ $L('调整排序') }}</DropdownItem>
<DropdownItem v-else name="cancelSort">{{ $L('退出排序') }}</DropdownItem>
<DropdownItem v-if="userIsAdmin" divided name="customMicro">{{ $L('自定义应用菜单') }}</DropdownItem>
</DropdownMenu>
</Dropdown>
</div>
@ -111,6 +112,114 @@
</div>
</div>
<!--自定义应用菜单-->
<Modal
v-if="userIsAdmin"
v-model="customMicroModalVisible"
:title="$L('自定义应用菜单')"
:mask-closable="false"
width="760">
<Alert type="info" show-icon class="custom-micro-alert">
{{ $L('仅管理员可配置,保存后会在应用列表中生成对应菜单。') }}
</Alert>
<div v-if="customMicroLoading" class="custom-micro-loading">
<Loading/>
</div>
<div v-else class="custom-micro-body">
<div v-if="!customMicroMenus.length" class="custom-micro-empty">
{{ $L('暂无自定义菜单,请点击下方按钮新增。') }}
</div>
<Collapse v-else v-model="customMicroCollapsed" accordion simple>
<Panel v-for="(item, index) in customMicroMenus" :key="item.uid" :name="item.uid">
<div class="custom-micro-card__header">
<div class="custom-micro-card__title">
{{ item.id || $L('未命名应用') }}
</div>
<div class="custom-micro-card__actions">
<Button @click.stop="duplicateCustomMenu(index)">{{ $L('复制') }}</Button>
<Button type="error" @click.stop="removeCustomMenu(index)">{{ $L('删除') }}</Button>
</div>
</div>
<div slot="content">
<Form label-position="top">
<Row :gutter="16">
<Col :sm="12" :xs="24">
<FormItem :label="$L('应用 ID')" required>
<Input v-model.trim="item.id" placeholder="custom-okr"/>
</FormItem>
</Col>
<Col :sm="12" :xs="24">
<FormItem :label="$L('应用名称')">
<Input v-model.trim="item.name" placeholder="OKR 开发"/>
</FormItem>
</Col>
</Row>
<FormItem :label="$L('菜单标题')" required>
<Input v-model.trim="item.menu.label" placeholder="OKR 开发入口"/>
</FormItem>
<Row :gutter="16">
<Col :sm="12" :xs="24">
<FormItem :label="$L('菜单位置')">
<Select v-model="item.menu.location" transfer>
<Option value="application">{{ $L('应用中心 - 常用') }}</Option>
<Option value="application/admin">{{ $L('应用中心 - 管理') }}</Option>
<Option value="main/menu">{{ $L('主导航') }}</Option>
</Select>
</FormItem>
</Col>
<Col :sm="12" :xs="24">
<FormItem :label="$L('可见范围')">
<Select v-model="item.menu.visible_to" transfer>
<Option value="admin">{{ $L('仅管理员') }}</Option>
<Option value="all">{{ $L('所有成员') }}</Option>
</Select>
</FormItem>
</Col>
</Row>
<FormItem :label="$L('图标地址')">
<Input v-model.trim="item.menu.icon" placeholder="https://example.com/icon.png"/>
</FormItem>
<FormItem :label="$L('菜单 URL')" required>
<Input v-model.trim="item.menu.url" placeholder="https://example.com/app?token={user_token}"/>
</FormItem>
<Row :gutter="16">
<Col :sm="12" :xs="24">
<FormItem :label="$L('URL 类型')">
<Select v-model="item.menu.url_type" transfer>
<Option value="iframe">iframe</Option>
<Option value="iframe_blank">iframe_blank</Option>
<Option value="inline">inline</Option>
<Option value="inline_blank">inline_blank</Option>
<Option value="external">external</Option>
</Select>
</FormItem>
</Col>
<Col :sm="12" :xs="24">
<FormItem :label="$L('背景颜色')">
<Input v-model.trim="item.menu.background" placeholder="#FFFFFF 或 #FFFFFF|#000000"/>
</FormItem>
</Col>
</Row>
<div class="custom-micro-checkbox-group">
<Checkbox v-model="item.menu.keep_alive">{{ $L('保持激活状态 (keep_alive)') }}</Checkbox>
<Checkbox v-model="item.menu.disable_scope_css">{{ $L('禁用作用域样式') }}</Checkbox>
<Checkbox v-model="item.menu.transparent">{{ $L('透明背景') }}</Checkbox>
<Checkbox v-model="item.menu.auto_dark_theme">{{ $L('自动暗黑模式') }}</Checkbox>
</div>
</Form>
</div>
</Panel>
</Collapse>
<Button class="custom-micro-add-btn" type="dashed" long icon="md-add" @click="addCustomMenu">
{{ $L('新增菜单') }}
</Button>
</div>
<div slot="footer" class="adaption">
<Button @click="customMicroModalVisible=false">{{ $L('关闭') }}</Button>
<Button type="primary" :loading="customMicroSaving" @click="saveCustomMenus">{{ $L('保存') }}</Button>
</div>
</Modal>
<!--MY BOT-->
<DrawerOverlay v-model="mybotShow" placement="right" :size="720">
<template v-if="mybotShow" #title>
@ -323,6 +432,20 @@ import ImgUpload from "../../components/ImgUpload.vue";
import {webhookEventOptions} from "../../utils/webhook";
import Draggable from "vuedraggable";
const createCustomMicroMenu = () => ({
uid: `custom_${Math.random().toString(36).slice(2, 10)}`,
id: '',
name: '',
version: 'custom',
menu: {
location: 'application',
url_type: 'iframe',
visible_to: 'admin',
keep_alive: true,
auto_dark_theme: true,
}
});
export default {
components: {
Draggable,
@ -383,6 +506,12 @@ export default {
//
sendData: [],
sendType: '',
//
customMicroModalVisible: false,
customMicroMenus: [],
customMicroLoading: false,
customMicroSaving: false,
customMicroCollapsed: '',
}
},
created() {
@ -494,8 +623,122 @@ export default {
this.enterSortMode();
} else if (action === 'cancelSort') {
this.exitSortMode();
} else if (action === 'customMicro') {
this.openCustomMicroModal();
}
},
openCustomMicroModal() {
if (!this.userIsAdmin) {
return;
}
this.customMicroModalVisible = true;
this.loadCustomMicroMenus();
},
loadCustomMicroMenus() {
this.customMicroLoading = true;
this.$store.dispatch("call", {
url: 'system/microapp_menu?type=get',
method: 'post',
}).then(({data}) => {
this.customMicroMenus = this.normalizeCustomMenus(data);
this.customMicroCollapsed = this.customMicroMenus.length > 0 ? this.customMicroMenus[0].uid : '';
}).catch(({msg}) => {
if (msg) {
$A.modalError(msg);
}
}).finally(() => {
this.customMicroLoading = false;
});
},
normalizeCustomMenus(list = []) {
if (!$A.isArray(list)) {
return [];
}
return list.map(app => {
const draft = createCustomMicroMenu();
return Object.assign({}, draft, app, {
menu: Object.assign({}, draft.menu, $A.isArray(app.menu_items) && app.menu_items.length > 0 ? app.menu_items[0] : {}),
});
});
},
pickCustomMenuLabel(label, fallback = '') {
if (typeof label === 'string') {
return label || fallback;
}
if ($A.isJson(label)) {
return label.zh || label.en || fallback;
}
return fallback;
},
addCustomMenu() {
const draft = createCustomMicroMenu();
this.customMicroMenus.push(draft);
this.customMicroCollapsed = draft.uid;
},
duplicateCustomMenu(index) {
const target = this.customMicroMenus[index];
if (!target) {
return;
}
const copy = $A.cloneJSON(target);
copy.uid = createCustomMicroMenu().uid;
copy.id = copy.id ? `${copy.id}_copy` : '';
copy.name = copy.name ? `${copy.name} copy` : '';
copy.menu.label = copy.menu.label ? `${copy.menu.label} copy` : '';
this.customMicroMenus.splice(index + 1, 0, copy);
this.customMicroCollapsed = copy.uid;
},
removeCustomMenu(index) {
this.customMicroMenus.splice(index, 1);
},
saveCustomMenus() {
if (this.customMicroSaving) {
return;
}
const payload = [];
for (const item of this.customMicroMenus) {
const formatted = this.formatCustomMenuForSave(item);
if (!formatted) {
$A.modalWarning({
title: '提示',
content: '请为每个菜单填写应用ID、菜单标题和有效的 URL。',
});
return;
}
payload.push(formatted);
}
this.customMicroSaving = true;
this.$store.dispatch("call", {
url: 'system/microapp_menu?type=save',
method: 'post',
data: {
list: payload
},
}).then(_ => {
$A.messageSuccess('保存成功');
this.$store.dispatch("updateMicroAppsStatus");
}).catch(({msg}) => {
if (msg) {
$A.modalError(msg);
}
}).finally(() => {
this.customMicroSaving = false;
});
},
formatCustomMenuForSave(item) {
const id = (item.id || '').trim();
const url = (item.menu.url || '').trim();
const label = (item.menu.label || item.name || item.id || '').trim();
if (!id || !url || !label) {
return null;
}
return {
id,
name: (item.name || '').trim(),
version: item.version || 'custom',
menu_items: [Object.assign({}, item.menu, { url, label })],
};
},
currentCards(type) {
return this.sortingMode ? (this.sortLists[type] || []) : this.getDisplayItems(type);
},
@ -682,9 +925,9 @@ export default {
}).then(({data, msg}) => {
this.appSorts = this.normalizeSortPayload(data?.sorts || payload);
this.exitSortMode();
$A.messageSuccess(msg || this.$L('保存成功'));
$A.messageSuccess(msg || '保存成功');
}).catch(({msg}) => {
$A.modalError(msg || this.$L('保存失败'));
$A.modalError(msg || '保存失败');
}).finally(() => {
this.appSortSaving = false;
});
@ -876,7 +1119,7 @@ export default {
//
chatMybot(userid) {
this.$store.dispatch("openDialogUserid", userid).catch(({msg}) => {
$A.modalError(msg || this.$L('打开会话失败'))
$A.modalError(msg || '打开会话失败')
});
},
//
@ -985,7 +1228,7 @@ export default {
} else {
//
$A.modalInfo({
title: this.$L('扫描结果'),
title: '扫描结果',
content: text,
width: 400,
});

View File

@ -3,10 +3,14 @@
</template>
<script>
import {mapState} from "vuex";
import MicroApps from "../../components/MicroApps";
export default {
components: { MicroApps },
computed: {
...mapState(['userIsAdmin']),
},
async mounted() {
const {name} = this.$route.params;
@ -15,6 +19,37 @@ export default {
return
}
// iframe
if (name === 'iframe-test') {
if (!this.userIsAdmin) {
$A.modalError("仅管理员可使用此功能")
return
}
let {url} = this.$route.query;
if (!url) {
url = await this.promptIframeUrl();
if (!url) {
return
}
this.$router.replace({
path: this.$route.path,
query: {
...this.$route.query,
url
}
}).catch(() => {});
}
await this.$refs.app.onOpen({
id: 'iframe-test',
name: 'iframe-test',
url: url,
url_type: 'iframe',
transparent: true,
keep_alive: false,
})
return
}
const app = (await $A.IDBArray("cacheMicroApps")).reverse().find(item => item.name === name);
if (!app) {
$A.modalError("应用不存在");
@ -22,6 +57,24 @@ export default {
}
await this.$refs.app.onOpen(app)
},
methods: {
promptIframeUrl() {
return new Promise((resolve, reject) => {
$A.modalInput({
title: this.$L("请输入 URL"),
placeholder: "https://example.com",
onOk: (val) => {
const input = (val || "").trim();
if (!input) {
return this.$L("URL不能为空");
}
resolve(input);
},
onCancel: () => reject()
});
}).catch(() => null);
}
}
}
</script>

View File

@ -5144,7 +5144,7 @@ export default {
* @param commit
* @param dispatch
*/
async updateMicroAppsStatus({commit, state}) {
async updateMicroAppsStatus({commit, state, dispatch}) {
const {data: {code, data}} = await axios.get($A.mainUrl('appstore/api/v1/internal/installed'), {
headers: {
Token: state.userToken,
@ -5152,7 +5152,23 @@ export default {
}
})
if (code === 200) {
commit("microApps/data", data|| [])
let apps = Array.isArray(data) ? data : [];
try {
const {data: customData} = await dispatch('call', {
url: 'system/microapp_menu?type=get',
});
if ($A.isArray(customData) && customData.length > 0) {
customData.forEach(item => {
item.menu_items.forEach(menu => {
menu.icon = menu.icon || $A.mainUrl("images/application/appstore-default.svg");
});
});
apps = apps.concat(customData);
}
} catch (e) {
// 忽略自定义菜单加载失败
}
commit("microApps/data", apps || [])
}
},

View File

@ -420,8 +420,8 @@ export default {
// 更新菜单
const menus = [];
data.forEach((item) => {
if (item.menu_items) {
menus.push(...item.menu_items.map(m => Object.assign(m, {id: item.id})));
if (Array.isArray(item.menu_items) && item.menu_items.length > 0) {
menus.push(...item.menu_items.map(menu => Object.assign({}, menu, {id: item.id})));
}
})
menus.forEach(item => {

View File

@ -582,6 +582,86 @@
}
}
.custom-micro-alert {
margin-bottom: 16px;
}
.custom-micro-loading {
min-height: 160px;
display: flex;
align-items: center;
justify-content: center;
}
.custom-micro-body {
margin: 0 -24px;
padding: 0 24px;
display: flex;
flex-direction: column;
gap: 16px;
.ivu-collapse {
> .ivu-collapse-item {
> .ivu-collapse-header {
display: flex;
align-items: center;
height: 60px;
padding-left: 0
}
}
}
}
.custom-micro-empty {
text-align: center;
color: #909399;
padding: 40px 0;
}
.custom-micro-card {
border: 1px solid #e5e6eb;
border-radius: 8px;
padding: 16px;
background-color: #fff;
}
.custom-micro-card__header {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
.custom-micro-card__title {
font-weight: 600;
font-size: 15px;
color: #333;
}
.custom-micro-card__actions {
display: flex;
gap: 8px;
.ivu-btn {
font-size: 13px;
padding: 0 10px;
height: 28px;
}
}
}
.custom-micro-checkbox-group {
display: flex;
flex-wrap: wrap;
gap: 12px 24px;
margin-top: 8px;
}
.custom-micro-add-btn {
flex-shrink: 0;
height: 38px;
}
body.window-portrait {
.page-apply {
.apply-wrapper {

View File

@ -0,0 +1,5 @@
<svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="24" height="24">
<path d="M0 0m349.206261 0l325.587478 0q349.206261 0 349.206261 349.206261l0 325.587478q0 349.206261-349.206261 349.206261l-325.587478 0q-349.206261 0-349.206261-349.206261l0-325.587478q0-349.206261 349.206261-349.206261Z" fill="#84c56a" class="selected"></path>
<path d="M442.189913 298.284522c9.728 0 18.053565 3.372522 24.976696 10.128695 6.912 6.745043 10.373565 14.981565 10.373565 24.731826v140.566261c0 9.750261-3.450435 18.086957-10.373565 25.021218a34.003478 34.003478 0 0 1-24.976696 10.395826H301.924174a33.090783 33.090783 0 0 1-24.698435-10.395826A34.626783 34.626783 0 0 1 267.130435 473.711304V333.145043c0-9.750261 3.361391-17.986783 10.095304-24.742956 6.733913-6.745043 14.970435-10.117565 24.698435-10.117565h140.265739zM442.189913 579.417043c9.728 0 18.053565 3.372522 24.976696 10.128696 6.912 6.733913 10.373565 14.981565 10.373565 24.731826v141.133913c0 9.73913-3.450435 17.986783-10.373565 24.731826-6.92313 6.745043-15.248696 10.128696-24.976696 10.128696H301.924174c-9.728 0-17.964522-3.383652-24.698435-10.128696C270.491826 773.398261 267.130435 765.150609 267.130435 755.400348V614.266435c0-9.73913 3.361391-17.986783 10.095304-24.731826 6.733913-6.745043 14.970435-10.128696 24.698435-10.128696h140.265739zM723.311304 579.417043c9.71687 0 17.953391 3.372522 24.687305 10.128696 6.733913 6.733913 10.095304 14.981565 10.095304 24.731826v141.133913c0 9.73913-3.361391 17.986783-10.095304 24.731826-6.733913 6.745043-14.970435 10.128696-24.687305 10.128696H583.034435a34.482087 34.482087 0 0 1-24.976696-10.128696 33.224348 33.224348 0 0 1-10.373565-24.742956V614.266435c0-9.73913 3.450435-17.986783 10.373565-24.731826a34.482087 34.482087 0 0 1 24.976696-10.128696h140.276869z" fill="#FFFFFF"></path>
<path d="M667.826087 243.287534m23.611218 23.611218l110.185682 110.185682q23.611218 23.611218 0 47.222436l-110.185682 110.185683q-23.611218 23.611218-47.222436 0l-110.185683-110.185683q-23.611218-23.611218 0-47.222436l110.185683-110.185682q23.611218-23.611218 47.222436 0Z" fill="#FFFFFF" fill-opacity=".7"></path>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB