Compare commits

79 Commits
main ... fix

Author SHA1 Message Date
63f2827cc9 fix: use pdf previews for documents 2026-05-19 08:44:35 +08:00
7e5a6a3f39 fix: 提醒流程指引页面等待图片上传完成 2026-05-07 09:07:40 +08:00
0e73a77b86 fix: 优化指引页面复制与流程图布局 2026-04-24 14:31:41 +08:00
e935afddfe fix: 修复文档转换与预览链路中的图片、文件名和错误处理问题 2026-04-24 14:31:41 +08:00
37dd58eff0 feat: 复制不关联线站 2026-04-23 08:45:44 +08:00
a917338d0c fix: - 引导流程改为从左至右显示 - 添加复制指引功能 - 流程图节点去掉 URL 显示,缩小节点尺寸 - 连线添加箭头方向指示,缩短节点间距 - 重构 graphUpdated 事件监听,修复添加/编辑页面后不实时更新的问题 - 预览页面隐藏图片附件文件名 2026-04-22 09:35:24 +08:00
1f9ee979f1 Fix guide flow editor layout sizing 2026-04-20 14:09:25 +08:00
0b35e54fe1 Fix rich editor image preview URLs 2026-04-20 13:41:27 +08:00
6acd0ccad0 feat: 富文本编辑 2026-04-16 16:20:52 +08:00
295cf12899 feat: API for content 2026-04-06 17:46:27 +08:00
ad0add4500 feat: prompt from station 2026-04-06 17:00:12 +08:00
d19b770ef4 feat: multi diagrams 2026-04-06 16:35:11 +08:00
42a879e961 fix: tree & guide 2026-03-24 15:33:10 +08:00
b74ba1a3f8 refactor: kb & station & terminal 2026-03-23 23:55:24 +08:00
63ea2686e1 refactor: 重构知识库文件上传和处理, 支持 pdf 2026-03-23 17:13:08 +08:00
89af7c17f1 feat: 删除 知识库-终端 关联, 简化 prompt 配置 2026-03-23 15:27:06 +08:00
81a22a2b54 feat: weakup tweet 2026-03-16 16:17:44 +08:00
8d30a0419d refactor: remove syncing 2026-03-16 13:56:10 +08:00
58f42de9df refactor: 修复知识库和操作指引 2026-03-16 00:05:06 +08:00
bbe8e60646 security: 修改 seeder 中的默认密码为安全密码
- 将所有测试账户的密码从 'password' 改为 'TRG}E^5BvPcbyErc'
- 更新 Hash::make() 调用中的密码参数
- 更新账号信息输出中的密码显示
2026-03-12 18:02:44 +08:00
06cf30130d refactor: 修改 seeder 数据以适配光束线场景
- DatabaseSeeder: 将分组改为十条光束线(BL02U1-BL16U1)
- 修改文档数据为光束线相关内容
- SopTemplateSeeder: 创建光束线操作相关的 SOP 模板
  - 光束线标准开机流程
  - 用户实验准备标准流程
  - 光束线日常维护流程(草稿)
- TerminalSeeder: 为每条光束线创建智慧屏终端
  - 每条光束线对应一个智慧屏终端
  - 配置光束线专用的 AI 助手提示词
  - 设置合理的在线/离线状态
2026-03-12 17:54:51 +08:00
f89acbb2dc fix: 修复终端统计组件字段名称错误
- 将 is_active 改为 is_online(与数据库表结构一致)
- 更新描述文本从'激活'改为'在线'
2026-03-12 17:30:34 +08:00
b5af8a8d61 fix: 修复文档预览临时目录权限问题
- 在设置 PHPWord 临时目录前先创建目录
- 确保临时目录存在并具有正确的权限(0755)
- 修复服务器环境下 tempnam() 权限错误
2026-03-12 16:30:00 +08:00
6313181658 feat: 自定义仪表板,添加知识库和终端统计组件
- 创建自定义 Dashboard 页面替换默认仪表板
- 新增 KnowledgeBaseStatsWidget 显示知识库统计信息
  - 文档总数、转换完成数、转换失败数、处理中数量
  - 知识库分组数量
  - 转换成功率计算
- 新增 TerminalStatsWidget 显示终端统计信息
  - 终端总数和激活状态
  - 知识库关联数、提示词配置数
  - 今日同步成功/失败统计
- 移除默认的 FilamentInfoWidget
- 统计卡片支持点击跳转到相关管理页面
2026-03-12 16:24:03 +08:00
578fc3be82 fix: 修复用户管理中权限数量显示为0的问题
- 使用 getAllPermissions() 获取用户所有权限
- 包括直接分配的权限和通过角色继承的权限
- 修复排序功能以支持新的计算方式
2026-03-12 16:12:12 +08:00
29c209116e feat: 增强文档转换失败处理机制
- 新增 FixStuckDocuments 命令用于修复卡住的文档
- 支持批量检测和修复超时的转换任务
- 改进重试按钮,支持 failed/processing/pending 状态
- 在确认对话框中显示当前状态和错误信息
- 提供 dry-run 模式预览修复操作
2026-03-12 15:58:24 +08:00
ed9260d5a6 refactor: 区分导航栏中的系统设置菜单
- 将 SystemSettingResource 导航标签改为'配置项管理'
- 将 ManageSystemSettings 导航标签改为'系统配置'
- 明确区分配置项管理和系统配置功能
2026-03-12 15:08:41 +08:00
32cf642f6f chore: deploy 2026-03-12 14:42:04 +08:00
0fb9b1938d fix(队列&seeder): 修复队列超时和文档创建问题
- 修复队列超时问题:
  - 将 queue:listen 改为 queue:work,提高性能和稳定性
  - 增加超时时间从 300 秒到 600 秒,确保大文件转换任务有足够时间
  - 同时更新 dev 和 dev-octane 脚本

- 修复 DatabaseSeeder 文档创建问题:
  - 不再使用 factory 创建文档,避免生成不存在的文件路径
  - 直接使用 Document::create() 明确指定所有字段
  - 所有文档状态设置为 pending,表示等待转换
  - 使用 UUID 生成唯一文件路径,便于管理
2026-03-12 14:37:15 +08:00
704d1225e6 fix(seeder): 修复 DatabaseSeeder 和 SopTemplateSeeder 的问题
- DatabaseSeeder 添加 PermissionSeeder 调用,确保权限系统正确初始化
- 为所有演示用户分配相应角色(super-admin, admin, user)
- 修复 SopTemplateSeeder 中被截断的正则表达式
- 更新测试账号信息,显示用户角色
- 验证所有 seeder 正常执行,数据创建成功
2026-03-12 13:21:02 +08:00
b3f319fc48 refactor(权限): 统一权限命名规范并精简权限数量
- 移除冗余的 viewAny 权限,统一使用 view 权限
- 简化权限描述,去掉「列表」和「详情」的区分
- 权限数量从 45 个精简到 32 个
- 更新 RolePolicy 使用统一的 role.view 权限
- 创建迁移脚本自动更新现有权限并合并关联
- 验证所有角色权限分配正确(super-admin: 32, admin: 28, user: 6)
2026-03-12 13:15:17 +08:00
ec54f0958d feat(文档): 增加转换失败报错提醒和重试功能
- 在文档列表添加「重试转换」和「查看错误」操作按钮
- 在文档详情页添加「重试转换」功能和转换错误信息展示
- 创建错误详情视图,提供友好的错误信息和解决方案
- 重试功能会重置文档状态并重新派发转换任务
- 优化用户体验,提供清晰的错误提示和操作指引
2026-03-11 15:19:58 +08:00
267bb9a36f feat(导航): 优化左侧导航菜单分组结构
调整导航分组,使菜单结构更加清晰合理:

📚 知识库管理(1个菜单)
  1. 文档管理

💼 业务管理(2个菜单)
  1. SOP模板
  2. 终端管理

🔐 权限管理(3个菜单)
  1. 用户管理
  2. 角色管理
  3. 分组管理

⚙️ 系统管理(2个菜单)
  1. 系统设置
  2. 操作日志

优化说明:
- 按照业务逻辑将菜单分为4个主要分组
- 每个分组内的菜单按照使用频率和重要性排序
- 知识库管理独立分组,突出核心功能
- 业务管理包含 SOP 和终端,体现业务流程
- 权限管理集中管理用户、角色、分组
- 系统管理包含系统配置和日志监控

导航结构更加清晰,用户可以快速找到需要的功能模块
2026-03-11 10:29:45 +08:00
3e7083d7c1 fix(权限): 修复权限编辑时的自动勾选显示问题
问题:
- 使用 Tabs 组件时,每个 Tab 中的 CheckboxList 都使用相同的字段名 'permissions'
- 导致字段冲突,无法正确加载和保存已有权限

解决方案:
- 为每个模块使用唯一的字段名(permissions_document, permissions_user 等)
- 添加 afterStateHydrated 钩子,在编辑时自动加载该模块的已有权限
- 添加隐藏字段 all_permissions 收集所有模块的权限
- 在 mutateFormDataBeforeSave/mutateFormDataBeforeCreate 中处理权限数据
- 在 afterSave/afterCreate 中使用 syncPermissions 同步权限到数据库

改进:
- RoleResource: 编辑角色时,每个模块的权限会自动勾选显示
- UserResource: 编辑用户时,直接权限会自动勾选显示
- 保存时正确收集所有模块的权限并同步到数据库
- super-admin 角色的权限字段保持禁用状态

现在编辑角色或用户时,已有的权限会正确显示为勾选状态
2026-03-11 10:25:43 +08:00
788101d21f feat(权限): 优化角色和用户详情页的权限列表显示
- ViewRole 页面优化:
  - 使用 HTML 格式按模块分组显示权限
  - 每个模块使用 Emoji 图标标识(📄 文档管理、⚙️ 系统设置等)
  - 模块名称加粗显示,权限操作用顿号分隔
  - 模块之间使用空行分隔,更加清晰
  - super-admin 角色显示特殊说明

- ViewUser 页面优化:
  - 所有权限:显示用户拥有的全部权限(角色+直接)
  - 直接权限:单独显示直接分配的权限
  - 同样使用 Emoji 图标和分组显示
  - 使用 HTML 格式提升可读性

优化后的显示效果:
📄 文档管理:查看详情、创建、编辑、删除、下载

⚙️ 系统设置:查看详情、编辑

更加直观、易读,用户可以快速了解权限分布情况
2026-03-11 10:21:01 +08:00
1843fa2883 feat(权限): 优化权限列表显示方式,按功能模块分组
- RoleResource: 使用 Tabs 组件按模块分组显示权限
  - 每个模块一个标签页,带有对应的图标
  - 权限名称简化为操作名称(如:查看详情、创建、编辑等)
  - 支持批量选择和取消选择
  - super-admin 角色的权限不可修改
- UserResource: 同样使用 Tabs 组件优化直接权限显示
  - 与角色权限保持一致的显示风格
  - 更清晰地展示权限的模块归属
- 添加 getPermissionTabs() 方法统一生成权限标签页
- 模块包括:文档管理、系统设置、操作日志、终端管理、SOP模板、分组管理、用户管理、角色管理

优化后的界面更加清晰易用,用户可以快速找到需要的权限模块
2026-03-11 10:18:10 +08:00
8018f4625c docs: 更新任务状态 - 完成 Filament 资源权限集成(导航菜单部分) 2026-03-11 10:14:37 +08:00
a100b2dce7 feat(权限): 为所有 Filament 资源添加导航菜单权限控制
- DocumentResource: 添加 document.view 权限检查
- SystemSettingResource: 添加 system-setting.view 权限检查
- ActivityLogResource: 添加 activity-log.view 权限检查
- TerminalResource: 添加 terminal.view 权限检查
- SopTemplateResource: 添加 sop-template.view 权限检查
- GroupResource: 添加 group.view 权限检查
- UserResource: 添加 user.view 权限检查
- RoleResource: 添加 role.viewAny 权限检查

所有资源都实现了 shouldRegisterNavigation() 方法
根据用户权限动态显示/隐藏导航菜单项
2026-03-11 10:14:16 +08:00
73d039bcd6 docs: 更新任务状态 - 完成权限策略实现 2026-03-11 10:09:00 +08:00
386fe42f76 feat(权限): 完善所有策略的权限检查
- 更新 DocumentPolicy 添加权限检查
  - viewAny/view/create/update/delete/download 都检查相应权限
  - 保留现有的分组访问控制逻辑
  - 保留安全日志记录功能
- 更新 TerminalPolicy 添加权限检查
  - 所有方法都基于 terminal.* 权限
  - 新增 sync 方法用于配置同步权限检查
- 更新 SopTemplatePolicy 添加权限检查
  - 所有方法都基于 sop-template.* 权限
  - 保留现有的状态检查逻辑(已发布不可编辑/删除)
- 创建 SystemSettingPolicy
  - 实现 viewAny/view/update 权限检查
- 创建 ActivityLogPolicy
  - 实现 viewAny/view/export 权限检查
- 创建 GroupPolicy
  - 实现完整的 CRUD 权限检查
  - 删除前检查关联文档和用户
- 在 AppServiceProvider 中注册所有策略
2026-03-11 10:08:22 +08:00
c2b83e7857 docs: 更新任务状态 - 完成用户权限管理功能 2026-03-11 10:04:19 +08:00
dfe0ff42bc feat(权限): 实现用户权限管理功能
- 更新 UserResource 添加角色和权限管理
  - 添加角色选择字段(多选)
  - 添加直接权限配置(按模块分组的复选框列表)
  - 在用户列表中显示角色和权限数量
  - 添加角色筛选器
  - 防止删除超级管理员
- 创建 ViewUser 页面显示用户详细权限信息
  - 显示所有权限(角色权限 + 直接权限)
  - 按模块分组展示权限
  - 区分显示直接权限
- 创建 UserPolicy 控制用户管理权限
  - 基于 user.* 权限控制访问
  - 保护超级管理员不被编辑和删除
  - 防止用户删除自己
- 在 AppServiceProvider 中注册 UserPolicy
2026-03-11 10:03:21 +08:00
a17fe167b0 feat(权限): 创建角色管理资源(RoleResource)
- 创建 RoleResource 及其所有页面类
- 实现角色列表、创建、编辑、查看功能
- 权限选择器按模块分组显示,支持批量选择
- 实现 super-admin 角色保护(不可编辑和删除)
- 实现角色删除前检查(有关联用户时不可删除)
- 创建 RolePolicy 控制角色管理权限
- 在 AppServiceProvider 中注册 RolePolicy
- 角色列表显示权限数量和用户数量
- 完整的中文界面和提示信息
2026-03-11 10:00:29 +08:00
7d13a560f3 feat(权限): 安装和配置 Spatie Permission 包
- 安装 spatie/laravel-permission 包(v6.24.1)
- 发布配置文件和迁移文件
- 运行迁移创建权限表
- 在 User 模型中添加 HasRoles trait
- 添加 isSuperAdmin 和 isAdmin 辅助方法
- 创建 PermissionSeeder 定义 45 个权限
- 创建 3 个预设角色(super-admin、admin、user)
- 为角色分配相应权限
- 为第一个用户分配超级管理员角色
2026-03-11 09:55:40 +08:00
7a4fa7cc18 docs: 更新 README.md 文档,完善功能模块说明
- 新增功能模块概览表格
- 添加 SOP 标准作业流程管理说明
- 添加终端配置管理系统说明
- 添加 AI 提示词模板系统说明
- 添加系统设置管理说明
- 添加活动日志审计系统说明
- 更新技术栈版本信息(Laravel 12.x, PHP 8.2+)
- 新增 Laravel Octane、Spatie Activity Log、Monaco Editor 等组件说明
- 完善项目结构说明
- 新增常用命令参考(开发、数据库、搜索、缓存等)
- 更新部署和配置说明
- 更新版本日志至 v1.0.0 (2026-03-09)
- 扩展文档链接列表
2026-03-09 14:32:03 +08:00
5dc6188802 fix: 修复操作日志导出功能错误
- 移除不存在的 getFilteredTableQuery() 方法调用
- 改为使用 Activity::query() 直接获取查询
- 更新导出说明文字
2026-03-09 14:13:22 +08:00
9f411b742a fix: 修复操作日志用户筛选器错误
- 将 relationship 方式改为 options 方式
- 修复 'no such column: activity_log.name' 错误
- 用户筛选器现在可以正常工作
2026-03-09 14:10:38 +08:00
93919956b7 docs: 添加 Meilisearch 配置指南
- 说明如何配置 Meilisearch
- 提供 Docker 运行命令
- 包含常见问题和解决方案
- 说明索引管理和搜索配置
- 提供生产环境配置建议
- 包含性能优化建议
2026-03-09 14:07:26 +08:00
bf002f9349 fix: 修复 Meilisearch API 密钥配置
- 将 MEILISEARCH_KEY 更新为 dev-master-key
- 与 Docker 容器中的配置保持一致
- 修复文档上传时的 'invalid API key' 错误
- 更新 .env.example 文件
2026-03-09 14:06:31 +08:00
599a917246 docs: 添加队列系统设置指南
- 创建 QUEUE_SETUP.md 文档
- 说明队列系统的配置和使用方法
- 解释文档转换状态问题的原因
- 提供开发环境和生产环境的配置方案
- 包含常见问题和解决方案
- 说明如何使用 Supervisor 管理队列
- 说明如何使用 composer run dev 启动服务
2026-03-09 14:02:53 +08:00
d37d1101fe fix: 显式注册所有策略以确保资源正确显示
- 在 AppServiceProvider 中注册 DocumentPolicy
- 在 AppServiceProvider 中注册 TerminalPolicy
- 修复文档管理、终端管理、SOP模板在导航中不显示的问题
- 确保所有资源的权限检查正常工作
2026-03-09 13:41:12 +08:00
225d04efc5 docs(阶段四): 更新任务完成状态
- 标记任务 11-15 的所有子任务为已完成
- 阶段四:SOP模板管理功能全部完成
- 共完成 5 个主任务,约 60 个子任务
2026-03-09 13:26:08 +08:00
f4ca8372c0 test(阶段四): 添加 SOP 模板功能测试
- 创建 SopTemplateTest 测试文件(13个测试)
  - 测试模板 CRUD 操作
  - 测试步骤管理和排序
  - 测试交互任务关联
  - 测试状态转换
  - 测试版本快照
  - 测试活动日志
  - 测试筛选和软删除

- 创建 SopTemplateServiceTest 测试文件(12个测试)
  - 测试 JSON 导出导入
  - 测试发布和归档
  - 测试版本管理
  - 测试模板复制
  - 测试数据验证

所有 25 个测试通过
2026-03-09 13:25:17 +08:00
74de79e4c3 feat(阶段四): 添加 SOP 模板权限策略
- 创建 SopTemplatePolicy 策略类
- 实现查看、创建、更新、删除权限
- 实现发布和归档权限
- 已发布的模板不能编辑和删除
- 只有草稿状态可以发布
- 只有已发布状态可以归档
- 在 AppServiceProvider 中注册策略
2026-03-09 13:25:05 +08:00
ebd1392580 feat(阶段四): 实现 SOP 模板导入导出功能
- 创建导出 Action(ExportSopTemplateAction)
- 创建导入 Action(ImportSopTemplateAction)
- 支持导出为 JSON 格式
- 支持从 JSON 文件导入
- 导入时验证文件格式和数据有效性
- 导入成功后跳转到编辑页面
- 文件大小限制 5MB
2026-03-09 13:24:37 +08:00
f0c207693b feat(阶段四): 创建 SOP 模板服务类
- 实现 exportToJson 方法(导出为 JSON)
- 实现 importFromJson 方法(从 JSON 导入)
- 实现 publish 方法(发布模板)
- 实现 createVersion 方法(创建版本快照)
- 实现 archive 方法(归档模板)
- 实现 duplicate 方法(复制模板)
- 导入时验证数据结构和必需字段
- 复制时包含所有步骤和交互任务
2026-03-09 13:24:24 +08:00
6102ec95d2 feat(阶段四): 实现 SOP 模板状态管理功能
- 创建发布 Action(PublishSopTemplateAction)
- 创建归档 Action(ArchiveSopTemplateAction)
- 创建预览 Action(PreviewSopTemplateAction)
- 发布前验证模板是否有步骤
- 发布时自动创建版本快照
- 预览模式显示完整模板内容
2026-03-09 13:24:14 +08:00
c4ab592fd5 feat(阶段四): 创建 SOP 模板资源和页面
- 创建 SopTemplateResource 资源类
- 实现模板列表、创建、编辑、查看页面
- 添加步骤编辑器(Repeater 组件)
- 支持富文本编辑步骤内容
- 支持拖拽排序步骤
- 添加状态筛选和分类筛选
- 显示步骤数统计
2026-03-09 13:24:02 +08:00
05b1bea2f1 docs(阶段三): 更新任务完成状态
- 标记任务7-10的所有子任务为已完成
- 阶段三:大屏配置管理功能全部完成
- 包含:终端管理、知识库关联、AI提示词编辑、配置同步
2026-03-09 10:59:59 +08:00
8bbd5dc30f test(阶段三): 添加终端管理功能测试
- TerminalResourceTest: 18个测试用例,测试终端CRUD和筛选功能
- TerminalKnowledgeBaseAssociationTest: 5个测试用例,测试知识库关联
- TerminalKnowledgeBaseFormTest: 6个测试用例,测试表单关联功能
- TerminalPromptTest: 6个测试用例,测试提示词模型
- TerminalPromptFormTest: 3个测试用例,测试提示词表单
- PromptTemplateTest: 16个测试用例,测试模板和变量功能
- TerminalSyncTest: 8个测试用例,测试配置同步功能
- 总计62个测试用例,覆盖所有核心功能
2026-03-09 10:59:54 +08:00
6b6afd1b75 feat(阶段三): 实现配置同步功能
- 创建 TerminalSyncService 服务类
- 实现配置快照生成(包含终端、知识库、提示词)
- 创建 SyncTerminalConfigJob 异步任务
- 实现重试机制(最多3次,指数退避)
- 创建 SyncConfigAction(单个和批量同步)
- 在终端列表页添加同步状态列
- 在终端详情页添加同步历史展示
- 支持同步状态追踪(pending/syncing/synced/failed)
2026-03-09 10:59:50 +08:00
1d30fb1d4c feat(阶段三): 实现AI提示词编辑功能
- 集成 Monaco Editor 用于提示词编辑
- 创建提示词变量配置(14个可用变量)
- 创建提示词模板库(5个预设模板)
- 实现 PromptTemplateService 服务类
- 创建变量替换和预览功能
- 添加 PreviewPromptAction 用于预览提示词
- 创建变量帮助文档和模板选择器视图组件
- 支持变量验证和自动替换
2026-03-09 10:59:45 +08:00
3b90d97f02 feat(阶段三): 添加终端知识库关联功能
- 在 Terminal 模型中添加 knowledgeBases 关联关系
- 在 TerminalResource 表单中添加知识库关联选择器
- 使用 Repeater 组件实现多选和优先级管理
- 支持搜索、拖拽排序、防重复选择
- 自动保存和加载关联数据
2026-03-09 10:59:37 +08:00
6a6c59e3e4 feat(阶段三): 实现终端管理基础功能
- 创建 TerminalResource 及其所有页面(列表、创建、编辑、查看)
- 实现终端基本信息管理(名称、编码、IP、线站、组态图)
- 添加显示配置管理(KeyValue 组件)
- 实现在线状态显示和筛选
- 添加按线站分组功能
- 创建 TerminalPolicy 权限策略
- 支持搜索、排序、批量删除等功能
2026-03-09 10:59:29 +08:00
333034d2f1 feat(阶段三): 添加知识库模型和迁移
- 创建 knowledge_bases 表迁移
- 创建 KnowledgeBase 模型
- 创建 KnowledgeBaseFactory 工厂类
- 支持与终端的多对多关联关系
2026-03-09 10:59:20 +08:00
29f72eb65e docs: 添加管理后台功能增强规格文档
- requirements.md: 需求文档
- design.md: 设计文档
- tasks.md: 任务列表
- validation-rules-summary.md: 验证规则总结

阶段二(系统设置与操作日志功能)已完成 ✓
2026-03-09 10:09:28 +08:00
aee27ec4c0 chore: 添加前端资源文件
- Filament Monaco Editor 静态资源
- Filament 公共资源文件
2026-03-09 10:09:10 +08:00
112aec6b09 test: 添加系统设置和操作日志测试
单元测试:
- SystemSettingServiceTest: 测试服务类方法
- SystemSettingServiceCacheTest: 测试缓存功能

功能测试:
- SystemSettingsTest: 测试系统设置基础功能
- SystemSettingValidationTest: 测试表单验证规则
- ActivityLogTest: 测试操作日志功能

测试覆盖:
- 配置的读取和保存
- 配置验证规则
- 缓存机制
- 日志自动记录
- 日志筛选功能
- 日志详情查看

所有测试通过 ✓
2026-03-09 10:08:57 +08:00
b9c897cd64 feat: 实现操作日志管理界面
- ActivityLogResource: Filament 资源类
  - 只读模式(禁用创建、编辑、删除)
  - 表格列:时间、用户、操作类型、对象、详情
  - 按时间倒序排序
  - 支持多维度筛选(时间范围、操作类型、用户、对象类型)
  - 集成导出功能(Excel/CSV)

- ViewActivityLog: 日志详情页面
  - 完整的变更信息展示
  - JSON diff 对比视图
  - 支持查看原始 JSON 数据

- activity-log-diff.blade.php: Diff 对比组件
  - 字段级别的变更对比
  - 使用颜色区分新旧值(绿色/红色)
  - 支持 JSON 数据格式化显示
2026-03-09 10:08:44 +08:00
232db047f1 feat: 创建操作日志导出功能
- ActivityLogExport: 日志导出类
  - 支持 Excel (XLSX) 格式导出
  - 支持 CSV 格式导出
  - 自动格式化中文字段名和值
  - 支持根据筛选条件导出数据
2026-03-09 10:08:29 +08:00
752dd908f0 feat: 实现系统设置管理界面
- SystemSettingResource: Filament 资源类
  - 使用 Tabs 组件按 group 分组显示配置
  - 使用 KeyValue 组件编辑 JSON 配置
  - 支持筛选、排序、搜索功能
  - 配置彩色徽章显示分组

- ManageSystemSettings: 系统设置管理页面
  - 按配置类型分组(嵌入模型/分块参数/系统配置/搜索配置)
  - 完整的表单验证规则
  - 保存和重置功能
  - 集成 SystemSettingService

- 创建对应的 Blade 视图和页面类
2026-03-09 10:08:17 +08:00
088a088b89 feat: 创建系统设置服务类
- 实现 getGroupedSettings 方法: 按分组获取配置
- 实现 updateSettings 方法: 批量更新配置
- 实现 clearCache 方法: 清除配置缓存
- 集成 Laravel Cache 提升性能(24小时缓存)
2026-03-09 10:08:04 +08:00
ef195d1ea0 feat: 创建工厂类和种子数据
- SystemSettingFactory: 系统设置工厂
- TerminalFactory: 终端工厂
- SopTemplateFactory: SOP模板工厂
- SystemSettingSeeder: 系统设置种子数据
- TerminalSeeder: 终端种子数据
- SopTemplateSeeder: SOP模板种子数据
- 更新 DatabaseSeeder 注册新的种子类
2026-03-09 10:07:49 +08:00
9d0055138c feat: 创建数据模型
- SystemSetting: 系统设置模型,支持配置管理
- Terminal: 终端模型,支持终端管理
- TerminalKnowledgeBase: 终端知识库关联模型
- TerminalPrompt: 终端提示词模型
- TerminalSyncLog: 终端同步日志模型
- SopTemplate: SOP模板模型
- SopStep: SOP步骤模型
- SopInteractiveTask: SOP交互任务模型
- SopTemplateVersion: SOP模板版本模型

所有模型集成 LogsActivity trait 用于操作日志记录
2026-03-09 10:07:31 +08:00
cedd910728 feat: 创建数据库迁移文件
- 创建 activity_log 相关表(操作日志)
- 创建 system_settings 表(系统设置)
- 创建 terminals 相关表(终端管理)
- 创建 sop_templates 相关表(SOP模板管理)
2026-03-09 10:07:16 +08:00
d1004c023f chore: 添加配置文件
- 添加 activitylog 配置
- 添加 excel 导出配置
- 添加 filament 配置
- 添加 livewire 配置
- 更新数据库配置
2026-03-09 10:06:45 +08:00
5476417c31 chore: 安装项目依赖包
- 安装 spatie/laravel-activitylog 用于操作日志
- 安装 amidesfahani/filament-monaco-editor 用于代码编辑
- 安装 maatwebsite/excel 用于数据导出
2026-03-09 10:06:25 +08:00
7d4448a912 chore: 从版本控制中移除 Docker 镜像 tar 包(体积过大)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 15:58:25 +08:00
3c206e9e06 feat: 新增 Docker 部署支持、Swoole/Octane 集成及相关优化
- 添加 Dockerfile 与多套 docker-compose 配置(开发/生产环境)
- 集成 Laravel Octane (Swoole) 提升性能
- 新增健康检查、监控脚本及部署文档
- 新增 Docker 镜像离线导入包(MySQL/Redis/Meilisearch)
- 优化文档转换、预览服务及队列任务
- 添加 CreateAdminUser 命令与路由健康检查接口
- 新增 Swoole 队列兼容性测试套件

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 15:51:19 +08:00
276 changed files with 54301 additions and 6230 deletions

56
.dockerignore Normal file
View File

@@ -0,0 +1,56 @@
# Docker构建忽略文件
# Git相关
.git
.gitignore
.gitattributes
# 开发工具
.editorconfig
.env.example
.kiro/
# 文档
README.md
CHANGELOG.md
CONTRIBUTING.md
docs/
# 测试
tests/
phpunit.xml
.phpunit.result.cache
# Node.js
node_modules/
npm-debug.log
yarn-error.log
# PHP
vendor/
composer.phar
# Laravel
storage/logs/*
storage/framework/cache/*
storage/framework/sessions/*
storage/framework/views/*
bootstrap/cache/*
# IDE
.vscode/
.idea/
*.swp
*.swo
# 系统文件
.DS_Store
Thumbs.db
# Docker
docker-compose*.yml
Dockerfile*
# 其他
*.log
*.tmp

99
.env.development Normal file
View File

@@ -0,0 +1,99 @@
# 开发环境配置模板
# 用于Docker开发环境
APP_NAME="知识库系统-开发"
APP_ENV=local
APP_KEY=base64:your-dev-app-key-here
APP_DEBUG=true
APP_URL=http://localhost:8080
APP_LOCALE=zh_CN
APP_FALLBACK_LOCALE=zh_CN
APP_FAKER_LOCALE=zh_CN
BCRYPT_ROUNDS=10
LOG_CHANNEL=stack
LOG_STACK=single
LOG_LEVEL=debug
# Octane/Swoole 配置 - 开发环境
OCTANE_SERVER=swoole
OCTANE_HOST=0.0.0.0
OCTANE_PORT=8000
OCTANE_WORKERS=2
OCTANE_TASK_WORKERS=1
OCTANE_MAX_REQUESTS=100
OCTANE_WATCH=true
OCTANE_HTTPS=false
# Swoole 高级配置 - 开发环境
OCTANE_GARBAGE_COLLECTION=25
OCTANE_MAX_EXECUTION_TIME=60
# Swoole 缓存表配置 - 开发环境
OCTANE_CACHE_ROWS=500
OCTANE_CACHE_BYTES=5000
# 数据库配置 - 开发环境使用SQLite
DB_CONNECTION=sqlite
DB_DATABASE=database/database.sqlite
# 会话和缓存配置 - 开发环境
SESSION_DRIVER=redis
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
CACHE_STORE=redis
CACHE_PREFIX=kb_dev_cache
# Redis配置 - 开发环境Docker容器
REDIS_CLIENT=phpredis
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=
# 队列配置 - 开发环境
QUEUE_CONNECTION=redis
# 文件系统配置
FILESYSTEM_DISK=local
# 邮件配置 - 开发环境使用日志
MAIL_MAILER=log
MAIL_FROM_ADDRESS="dev@knowledge-base.local"
MAIL_FROM_NAME="${APP_NAME}"
# Meilisearch配置 - 开发环境Docker容器
SCOUT_DRIVER=meilisearch
MEILISEARCH_HOST=http://meilisearch:7700
MEILISEARCH_KEY=dev-master-key
# 文档转换配置 - 开发环境
DOCUMENT_CONVERSION_DRIVER=pandoc
PANDOC_PATH=/usr/bin/pandoc
CONVERSION_TIMEOUT=300
CONVERSION_QUEUE=documents
CONVERSION_RETRY_TIMES=3
CONVERSION_RETRY_DELAY=60
# Markdown配置
MARKDOWN_RENDERER=commonmark
MARKDOWN_SANITIZE=true
MARKDOWN_PREVIEW_LENGTH=500
MARKDOWN_MAX_FILE_SIZE=10485760
# 存储配置
DOCUMENTS_DISK=documents
MARKDOWN_DISK=markdown
STORAGE_ORGANIZE_BY_DATE=true
# 开发工具配置
TELESCOPE_ENABLED=true
DEBUGBAR_ENABLED=true
VITE_APP_NAME="${APP_NAME}"
# 开发环境特定配置
PHP_IDE_CONFIG=serverName=knowledge-base-dev
XDEBUG_MODE=develop,debug
XDEBUG_CONFIG=client_host=host.docker.internal client_port=9003

View File

@@ -20,6 +20,24 @@ LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
# Octane/Swoole 配置
OCTANE_SERVER=swoole
OCTANE_HOST=0.0.0.0
OCTANE_PORT=8000
OCTANE_WORKERS=4
OCTANE_TASK_WORKERS=2
OCTANE_MAX_REQUESTS=500
OCTANE_WATCH=false
OCTANE_HTTPS=false
# Swoole 高级配置
OCTANE_GARBAGE_COLLECTION=50
OCTANE_MAX_EXECUTION_TIME=30
# Swoole 缓存表配置
OCTANE_CACHE_ROWS=1000
OCTANE_CACHE_BYTES=10000
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
@@ -67,7 +85,7 @@ VITE_APP_NAME="${APP_NAME}"
# Meilisearch Configuration
SCOUT_DRIVER=meilisearch
MEILISEARCH_HOST=http://127.0.0.1:7700
MEILISEARCH_KEY=masterKey
MEILISEARCH_KEY=dev-master-key
# Document Conversion Configuration
DOCUMENT_CONVERSION_DRIVER=pandoc

4
.gitignore vendored
View File

@@ -23,3 +23,7 @@
Homestead.json
Homestead.yaml
Thumbs.db
rr
.rr.yaml
*.tar.gz
*.tar

View File

@@ -0,0 +1,646 @@
# 管理后台功能增强 - 设计文档
## 架构设计
### 整体架构
```
┌─────────────────────────────────────────────────────────┐
│ Filament Admin Panel │
├─────────────────────────────────────────────────────────┤
│ 系统设置页面 │ 操作日志页面 │ 大屏配置 │ SOP模板 │
├─────────────────────────────────────────────────────────┤
│ Filament Resources & Pages │
├─────────────────────────────────────────────────────────┤
│ Laravel Models │
├─────────────────────────────────────────────────────────┤
│ MySQL Database │
└─────────────────────────────────────────────────────────┘
```
## 数据库设计
### 1. 系统设置表 (system_settings)
```sql
CREATE TABLE system_settings (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`key` VARCHAR(255) NOT NULL UNIQUE COMMENT '配置键',
`value` JSON NOT NULL COMMENT '配置值',
`group` VARCHAR(100) NOT NULL COMMENT '配置分组',
description TEXT COMMENT '配置说明',
is_public BOOLEAN DEFAULT FALSE COMMENT '是否公开',
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
INDEX idx_group (`group`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
### 2. 操作日志表 (activity_log)
使用 spatie/laravel-activitylog 包的标准表结构
### 3. 大屏终端表 (terminals)
```sql
CREATE TABLE terminals (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL COMMENT '终端名称',
code VARCHAR(100) NOT NULL UNIQUE COMMENT '终端编码',
ip_address VARCHAR(45) COMMENT 'IP地址',
station_id BIGINT UNSIGNED COMMENT '线站ID',
diagram_url VARCHAR(500) COMMENT '组态图URL',
display_config JSON COMMENT '显示配置',
is_online BOOLEAN DEFAULT FALSE COMMENT '在线状态',
last_online_at TIMESTAMP NULL COMMENT '最后在线时间',
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
deleted_at TIMESTAMP NULL,
INDEX idx_station (station_id),
INDEX idx_online (is_online)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
### 4. 终端知识库关联表 (terminal_knowledge_bases)
```sql
CREATE TABLE terminal_knowledge_bases (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
terminal_id BIGINT UNSIGNED NOT NULL COMMENT '终端ID',
knowledge_base_id BIGINT UNSIGNED NOT NULL COMMENT '知识库ID',
priority INTEGER DEFAULT 0 COMMENT '优先级',
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
FOREIGN KEY (terminal_id) REFERENCES terminals(id) ON DELETE CASCADE,
UNIQUE KEY uk_terminal_kb (terminal_id, knowledge_base_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
### 5. 终端提示词表 (terminal_prompts)
```sql
CREATE TABLE terminal_prompts (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
terminal_id BIGINT UNSIGNED NOT NULL COMMENT '终端ID',
prompt_template TEXT NOT NULL COMMENT '提示词模板',
variables JSON COMMENT '变量配置',
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
FOREIGN KEY (terminal_id) REFERENCES terminals(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
### 6. 终端同步日志表 (terminal_sync_logs)
```sql
CREATE TABLE terminal_sync_logs (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
terminal_id BIGINT UNSIGNED NOT NULL COMMENT '终端ID',
status ENUM('pending', 'syncing', 'synced', 'failed') DEFAULT 'pending' COMMENT '同步状态',
config_snapshot JSON COMMENT '配置快照',
synced_at TIMESTAMP NULL COMMENT '同步时间',
error_message TEXT COMMENT '错误信息',
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
FOREIGN KEY (terminal_id) REFERENCES terminals(id) ON DELETE CASCADE,
INDEX idx_status (status),
INDEX idx_synced_at (synced_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
### 7. SOP模板表 (sop_templates)
```sql
CREATE TABLE sop_templates (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL COMMENT '模板名称',
description TEXT COMMENT '模板描述',
category VARCHAR(100) COMMENT '分类',
tags JSON COMMENT '标签',
version VARCHAR(50) DEFAULT '1.0.0' COMMENT '版本号',
status ENUM('draft', 'published', 'archived') DEFAULT 'draft' COMMENT '状态',
applicable_departments JSON COMMENT '适用部门',
applicable_positions JSON COMMENT '适用岗位',
published_at TIMESTAMP NULL COMMENT '发布时间',
created_by BIGINT UNSIGNED COMMENT '创建人',
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
deleted_at TIMESTAMP NULL,
INDEX idx_status (status),
INDEX idx_category (category)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
### 8. SOP步骤表 (sop_steps)
```sql
CREATE TABLE sop_steps (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
sop_template_id BIGINT UNSIGNED NOT NULL COMMENT '模板ID',
step_number INTEGER NOT NULL COMMENT '步骤序号',
title VARCHAR(255) NOT NULL COMMENT '步骤标题',
content TEXT COMMENT '步骤内容',
sort_order INTEGER DEFAULT 0 COMMENT '排序',
is_required BOOLEAN DEFAULT TRUE COMMENT '是否必需',
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
FOREIGN KEY (sop_template_id) REFERENCES sop_templates(id) ON DELETE CASCADE,
INDEX idx_template_sort (sop_template_id, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
### 9. SOP交互任务表 (sop_interactive_tasks)
```sql
CREATE TABLE sop_interactive_tasks (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
sop_step_id BIGINT UNSIGNED NOT NULL COMMENT '步骤ID',
task_type ENUM('confirm', 'input', 'select', 'photo', 'scan') NOT NULL COMMENT '任务类型',
task_config JSON COMMENT '任务配置',
validation_rules JSON COMMENT '验证规则',
timeout_seconds INTEGER COMMENT '超时时间',
is_required BOOLEAN DEFAULT TRUE COMMENT '是否必需',
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
FOREIGN KEY (sop_step_id) REFERENCES sop_steps(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
### 10. SOP模板版本表 (sop_template_versions)
```sql
CREATE TABLE sop_template_versions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
sop_template_id BIGINT UNSIGNED NOT NULL COMMENT '模板ID',
version VARCHAR(50) NOT NULL COMMENT '版本号',
change_log TEXT COMMENT '变更说明',
content_snapshot JSON COMMENT '内容快照',
created_by BIGINT UNSIGNED COMMENT '创建人',
created_at TIMESTAMP NULL,
FOREIGN KEY (sop_template_id) REFERENCES sop_templates(id) ON DELETE CASCADE,
INDEX idx_template_version (sop_template_id, version)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
## 模型设计
### 1. SystemSetting 模型
```php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class SystemSetting extends Model
{
protected $fillable = [
'key', 'value', 'group', 'description', 'is_public'
];
protected $casts = [
'value' => 'array',
'is_public' => 'boolean',
];
// 获取配置值
public static function get(string $key, $default = null)
{
$setting = static::where('key', $key)->first();
return $setting ? $setting->value : $default;
}
// 设置配置值
public static function set(string $key, $value, string $group = 'general')
{
return static::updateOrCreate(
['key' => $key],
['value' => $value, 'group' => $group]
);
}
}
```
### 2. Terminal 模型
```php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
class Terminal extends Model
{
use SoftDeletes, LogsActivity;
protected $fillable = [
'name', 'code', 'ip_address', 'station_id',
'diagram_url', 'display_config', 'is_online', 'last_online_at'
];
protected $casts = [
'display_config' => 'array',
'is_online' => 'boolean',
'last_online_at' => 'datetime',
];
public function knowledgeBases()
{
return $this->belongsToMany(KnowledgeBase::class, 'terminal_knowledge_bases')
->withPivot('priority')
->orderBy('priority');
}
public function prompt()
{
return $this->hasOne(TerminalPrompt::class);
}
public function syncLogs()
{
return $this->hasMany(TerminalSyncLog::class);
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['name', 'code', 'station_id', 'diagram_url', 'display_config'])
->logOnlyDirty();
}
}
```
### 3. SopTemplate 模型
```php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
class SopTemplate extends Model
{
use SoftDeletes, LogsActivity;
protected $fillable = [
'name', 'description', 'category', 'tags', 'version',
'status', 'applicable_departments', 'applicable_positions',
'published_at', 'created_by'
];
protected $casts = [
'tags' => 'array',
'applicable_departments' => 'array',
'applicable_positions' => 'array',
'published_at' => 'datetime',
];
public function steps()
{
return $this->hasMany(SopStep::class)->orderBy('sort_order');
}
public function versions()
{
return $this->hasMany(SopTemplateVersion::class);
}
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['name', 'description', 'category', 'status', 'version'])
->logOnlyDirty();
}
}
```
## Filament资源设计
### 1. SystemSettingResource
- 使用Tabs组件按group分组显示配置
- 使用KeyValue字段编辑JSON配置
- 敏感配置使用Password字段
### 2. ActivityLogResource
- 只读资源,不允许创建和编辑
- 使用Tables\Filters进行筛选
- 使用Actions\ExportAction导出数据
- 自定义ViewAction显示变更对比
### 3. TerminalResource
- 使用Badge显示在线状态
- 使用Select2组件选择知识库多选+搜索)
- 使用MonacoEditor编辑提示词
- 自定义Action触发配置下发
### 4. SopTemplateResource
- 使用Repeater组件管理步骤
- 使用RichEditor编辑步骤内容
- 使用Builder组件配置交互任务
- 自定义PreviewAction预览模板
## API设计
### 终端配置同步API
```php
POST /api/terminals/{terminal}/sync
Response: {
"success": true,
"sync_log_id": 123,
"message": "配置同步已启动"
}
```
### SOP模板导出API
```php
GET /api/sop-templates/{template}/export?format=json|pdf
Response: File Download
```
## 前端组件设计
### 1. 日志对比组件
```php
// app/Filament/Components/LogDiffViewer.php
class LogDiffViewer extends Component
{
public array $oldData;
public array $newData;
public function render()
{
return view('filament.components.log-diff-viewer');
}
}
```
### 2. 终端状态指示器
```php
// app/Filament/Components/TerminalStatusBadge.php
class TerminalStatusBadge extends Component
{
public bool $isOnline;
public ?Carbon $lastOnlineAt;
public function render()
{
return view('filament.components.terminal-status-badge');
}
}
```
### 3. SOP步骤编辑器
```php
// app/Filament/Components/SopStepEditor.php
class SopStepEditor extends Component
{
public array $steps = [];
public function addStep()
{
$this->steps[] = [
'title' => '',
'content' => '',
'sort_order' => count($this->steps) + 1,
];
}
public function removeStep($index)
{
unset($this->steps[$index]);
$this->steps = array_values($this->steps);
}
public function reorderSteps($orderedIds)
{
// 重新排序逻辑
}
}
```
## 服务层设计
### 1. SystemSettingService
```php
namespace App\Services;
class SystemSettingService
{
public function getGroupedSettings(): array
{
return SystemSetting::all()
->groupBy('group')
->toArray();
}
public function updateSettings(array $settings): void
{
foreach ($settings as $key => $value) {
SystemSetting::set($key, $value);
}
}
}
```
### 2. TerminalSyncService
```php
namespace App\Services;
class TerminalSyncService
{
public function syncConfiguration(Terminal $terminal): TerminalSyncLog
{
$log = TerminalSyncLog::create([
'terminal_id' => $terminal->id,
'status' => 'pending',
'config_snapshot' => $this->getConfigSnapshot($terminal),
]);
// 触发异步同步任务
dispatch(new SyncTerminalConfigJob($terminal, $log));
return $log;
}
private function getConfigSnapshot(Terminal $terminal): array
{
return [
'terminal' => $terminal->toArray(),
'knowledge_bases' => $terminal->knowledgeBases->toArray(),
'prompt' => $terminal->prompt?->toArray(),
];
}
}
```
### 3. SopTemplateService
```php
namespace App\Services;
class SopTemplateService
{
public function publish(SopTemplate $template): void
{
// 创建版本快照
$this->createVersion($template);
// 更新状态
$template->update([
'status' => 'published',
'published_at' => now(),
]);
}
public function createVersion(SopTemplate $template): SopTemplateVersion
{
return SopTemplateVersion::create([
'sop_template_id' => $template->id,
'version' => $template->version,
'content_snapshot' => [
'template' => $template->toArray(),
'steps' => $template->steps->toArray(),
],
'created_by' => auth()->id(),
]);
}
public function export(SopTemplate $template, string $format): string
{
return match($format) {
'json' => $this->exportToJson($template),
'pdf' => $this->exportToPdf($template),
default => throw new \InvalidArgumentException("Unsupported format: $format"),
};
}
}
```
## 任务队列设计
### 1. SyncTerminalConfigJob
```php
namespace App\Jobs;
class SyncTerminalConfigJob implements ShouldQueue
{
public function __construct(
public Terminal $terminal,
public TerminalSyncLog $log
) {}
public function handle(): void
{
try {
$this->log->update(['status' => 'syncing']);
// 调用终端API同步配置
$response = Http::post($this->terminal->sync_url, [
'config' => $this->log->config_snapshot,
]);
if ($response->successful()) {
$this->log->update([
'status' => 'synced',
'synced_at' => now(),
]);
} else {
throw new \Exception($response->body());
}
} catch (\Exception $e) {
$this->log->update([
'status' => 'failed',
'error_message' => $e->getMessage(),
]);
}
}
}
```
## 权限设计
### 策略定义
```php
// app/Policies/SystemSettingPolicy.php
class SystemSettingPolicy
{
public function viewAny(User $user): bool
{
return $user->hasRole('admin');
}
public function update(User $user, SystemSetting $setting): bool
{
return $user->hasRole('admin');
}
}
// app/Policies/TerminalPolicy.php
class TerminalPolicy
{
public function viewAny(User $user): bool
{
return $user->hasAnyRole(['admin', 'terminal_manager']);
}
public function sync(User $user, Terminal $terminal): bool
{
return $user->hasRole('admin');
}
}
// app/Policies/SopTemplatePolicy.php
class SopTemplatePolicy
{
public function publish(User $user, SopTemplate $template): bool
{
return $user->hasAnyRole(['admin', 'content_manager']);
}
}
```
## 测试策略
### 单元测试
- SystemSetting模型的get/set方法
- Terminal模型的关联关系
- SopTemplate的版本管理逻辑
- 各Service类的核心方法
### 功能测试
- Filament资源的CRUD操作
- 日志筛选和导出功能
- 终端配置同步流程
- SOP模板发布流程
### 集成测试
- 操作日志自动记录
- 终端配置同步任务
- SOP模板导入导出
## 性能优化
### 数据库优化
- 为常用查询字段添加索引
- 使用Eager Loading避免N+1问题
- 大表使用分区如activity_log
### 缓存策略
- 系统设置使用缓存Cache::remember
- 终端在线状态使用Redis缓存
- SOP模板列表使用查询缓存
### 前端优化
- 使用Lazy Loading加载大型列表
- Monaco Editor按需加载
- 图片使用CDN加速
## 安全考虑
### 数据安全
- 敏感配置API密钥使用加密存储
- 操作日志不可删除
- SOP模板版本不可修改
### 访问控制
- 基于角色的权限控制
- 敏感操作需要二次确认
- API接口使用认证和授权
### 输入验证
- 所有表单输入进行验证
- 富文本内容进行XSS过滤
- 文件上传进行类型和大小限制

View File

@@ -0,0 +1,483 @@
# 管理后台功能增强 - 需求文档
## 项目概述
为知识库系统的Filament管理后台添加三个核心管理功能系统设置与操作日志、大屏配置管理、SOP模板管理。
## 功能需求
### 1. 系统设置与操作日志页面
#### 1.1 用户故事
作为系统管理员,我需要能够配置系统全局参数并查看所有用户的操作审计日志,以便管理系统配置和追踪系统变更。
#### 1.2 功能描述
实现系统全局设置和操作审计日志的前端页面。
#### 1.3 验收标准
- [ ] 系统设置页面
- [ ] 嵌入模型配置模型名称、API密钥、端点URL等
- [ ] 分块参数默认值(块大小、重叠大小等)
- [ ] 全局参数(系统名称、超时设置等)
- [ ] 配置保存和验证功能
- [ ] 操作日志列表
- [ ] 显示字段:时间、用户、操作类型、对象、详情
- [ ] 分页功能
- [ ] 排序功能(按时间倒序)
- [ ] 日志筛选功能
- [ ] 时间范围筛选(开始时间、结束时间)
- [ ] 操作类型筛选(创建、更新、删除等)
- [ ] 用户筛选(下拉选择)
- [ ] 日志详情弹窗
- [ ] 显示完整操作信息
- [ ] 变更前后数据对比JSON diff视图
- [ ] 关联对象信息
- [ ] 日志导出功能
- [ ] 支持导出为CSV格式
- [ ] 支持导出为Excel格式
- [ ] 根据当前筛选条件导出
#### 1.4 数据模型需求
- SystemSetting 模型(系统设置)
- key: string配置键
- value: json配置值
- group: string配置分组
- description: text配置说明
- ActivityLog 模型(操作日志)
- user_id: bigint操作用户
- log_name: string日志名称
- description: text操作描述
- subject_type: string对象类型
- subject_id: bigint对象ID
- causer_type: string操作者类型
- causer_id: bigint操作者ID
- properties: json变更数据
- created_at: timestamp操作时间
---
### 2. 大屏配置管理页面
#### 2.1 用户故事
作为系统管理员我需要能够管理大屏终端的配置包括终端绑定、知识库关联和AI提示词设置以便为不同的生产线站提供定制化的大屏展示。
#### 2.2 功能描述
实现大屏终端配置的前端管理页面,支持终端绑定与提示词编辑。
#### 2.3 验收标准
- [ ] 终端列表页
- [ ] 显示在线状态(在线/离线,带状态指示器)
- [ ] 按线站分组显示
- [ ] 终端基本信息名称、IP地址、最后在线时间
- [ ] 快速操作按钮(编辑、删除、查看详情)
- [ ] 终端配置编辑页
- [ ] 线站绑定(选择生产线和工作站)
- [ ] 组态图URL配置
- [ ] 终端名称和描述
- [ ] 显示参数配置(分辨率、刷新频率等)
- [ ] 知识库关联选择器
- [ ] 多选功能
- [ ] 搜索功能(按知识库名称)
- [ ] 显示已选知识库列表
- [ ] 支持拖拽排序优先级
- [ ] AI提示词编辑器
- [ ] 使用Monaco Editor语法高亮
- [ ] 变量提示功能({user}, {station}, {time}等)
- [ ] 模板预设选择
- [ ] 实时预览功能
- [ ] 配置下发与同步
- [ ] 配置下发按钮
- [ ] 同步状态展示(待同步/同步中/已同步/失败)
- [ ] 同步历史记录
- [ ] 批量配置下发
#### 2.4 数据模型需求
- Terminal 模型(大屏终端)
- name: string终端名称
- code: string终端编码
- ip_address: stringIP地址
- station_id: bigint线站ID
- diagram_url: string组态图URL
- display_config: json显示配置
- is_online: boolean在线状态
- last_online_at: timestamp最后在线时间
- TerminalKnowledgeBase 模型(终端知识库关联)
- terminal_id: bigint终端ID
- knowledge_base_id: bigint知识库ID
- priority: integer优先级
- TerminalPrompt 模型(终端提示词)
- terminal_id: bigint终端ID
- prompt_template: text提示词模板
- variables: json变量配置
- TerminalSyncLog 模型(终端同步日志)
- terminal_id: bigint终端ID
- status: enum待同步/同步中/已同步/失败)
- config_snapshot: json配置快照
- synced_at: timestamp同步时间
- error_message: text错误信息
#### 2.5 技术依赖
- 需要安装:`composer require amidesfahani/filament-monaco-editor`
---
### 3. SOP 模板管理页面
#### 3.1 用户故事
作为内容管理员我需要能够创建和管理SOP标准操作程序模板包括步骤编辑和交互任务配置以便为不同的操作场景提供标准化的指导流程。
#### 3.2 功能描述
实现SOP模板与步骤的前端管理页面支持可视化编辑。
#### 3.3 验收标准
- [ ] SOP模板列表页
- [ ] 状态筛选(草稿/已发布/已归档)
- [ ] 分类浏览(按业务分类)
- [ ] 搜索功能(按模板名称、标签)
- [ ] 模板卡片展示(名称、描述、步骤数、状态)
- [ ] 模板创建/编辑表单
- [ ] 基本信息(名称、描述、分类、标签)
- [ ] 适用范围(部门、岗位)
- [ ] 版本管理(版本号、变更说明)
- [ ] 状态管理(草稿/发布/归档)
- [ ] 步骤可视化编辑器
- [ ] 富文本编辑器(支持图片、表格、代码块)
- [ ] 拖拽排序功能
- [ ] 步骤编号自动更新
- [ ] 步骤折叠/展开
- [ ] 步骤复制/删除
- [ ] 交互任务配置组件
- [ ] 任务类型选择(确认、输入、选择、拍照、扫码等)
- [ ] 参数设定(必填项、验证规则、默认值)
- [ ] 条件分支配置
- [ ] 任务超时设置
- [ ] 模板预览与发布
- [ ] 预览模式(模拟实际使用场景)
- [ ] 发布前验证(检查必填项、步骤完整性)
- [ ] 发布确认弹窗
- [ ] 版本历史查看
- [ ] 模板导入/导出
- [ ] 导出为JSON格式
- [ ] 导出为PDF格式用于打印
- [ ] 从JSON导入
- [ ] 批量导入功能
#### 3.4 数据模型需求
- SopTemplate 模型SOP模板
- name: string模板名称
- description: text模板描述
- category: string分类
- tags: json标签
- version: string版本号
- status: enum草稿/已发布/已归档)
- applicable_departments: json适用部门
- applicable_positions: json适用岗位
- published_at: timestamp发布时间
- SopStep 模型SOP步骤
- sop_template_id: bigint模板ID
- step_number: integer步骤序号
- title: string步骤标题
- content: text步骤内容富文本
- sort_order: integer排序
- is_required: boolean是否必需
- SopInteractiveTask 模型(交互任务)
- sop_step_id: bigint步骤ID
- task_type: enum确认/输入/选择/拍照/扫码)
- task_config: json任务配置
- validation_rules: json验证规则
- timeout_seconds: integer超时时间
- is_required: boolean是否必需
- SopTemplateVersion 模型(模板版本)
- sop_template_id: bigint模板ID
- version: string版本号
- change_log: text变更说明
- content_snapshot: json内容快照
- created_by: bigint创建人
- created_at: timestamp创建时间
---
### 4. 权限管理系统
#### 4.1 用户故事
作为系统管理员,我需要能够灵活地为用户、角色和分组配置不同功能模块的访问权限,以便实现细粒度的权限控制和数据隔离。
#### 4.2 功能描述
实现基于角色、用户和分组的多维度权限管理系统,支持功能模块级和数据级权限控制。
#### 4.3 验收标准
- [ ] 角色管理
- [ ] 角色列表(名称、描述、权限数量、用户数量)
- [ ] 角色创建/编辑(名称、描述、权限配置)
- [ ] 角色删除(检查是否有关联用户)
- [ ] 预设角色(超级管理员、管理员、普通用户)
- [ ] 权限配置界面
- [ ] 按功能模块分组展示权限
- [ ] 权限类型viewAny列表、view详情、create创建、update编辑、delete删除、特殊操作
- [ ] 支持批量授权/撤销
- [ ] 权限继承关系展示
- [ ] 用户权限管理
- [ ] 用户角色分配(支持多角色)
- [ ] 用户特殊权限配置(覆盖角色权限)
- [ ] 用户分组关联
- [ ] 权限预览(显示用户的最终权限)
- [ ] 分组权限管理
- [ ] 分组数据访问权限(如专用知识库)
- [ ] 分组成员管理
- [ ] 跨分组访问控制
- [ ] 权限验证
- [ ] 菜单项根据权限动态显示/隐藏
- [ ] 操作按钮根据权限动态显示/隐藏
- [ ] API请求权限验证
- [ ] 数据查询自动应用权限过滤
#### 4.4 权限模块定义
使用 Spatie Permission 的命名约定module.action格式
- **文档管理**
- document.viewAny - 查看文档列表
- document.view - 查看文档详情
- document.create - 创建文档
- document.update - 编辑文档
- document.delete - 删除文档
- document.download - 下载文档
- **系统设置**
- system-setting.viewAny - 查看系统设置
- system-setting.view - 查看设置详情
- system-setting.update - 修改系统设置
- **操作日志**
- activity-log.viewAny - 查看操作日志
- activity-log.view - 查看日志详情
- activity-log.export - 导出日志
- **终端管理**
- terminal.viewAny - 查看终端列表
- terminal.view - 查看终端详情
- terminal.create - 创建终端
- terminal.update - 编辑终端
- terminal.delete - 删除终端
- terminal.sync - 同步终端配置
- **SOP模板**
- sop-template.viewAny - 查看SOP列表
- sop-template.view - 查看SOP详情
- sop-template.create - 创建SOP
- sop-template.update - 编辑SOP
- sop-template.delete - 删除SOP
- sop-template.publish - 发布SOP
- sop-template.archive - 归档SOP
- **分组管理**
- group.viewAny - 查看分组列表
- group.view - 查看分组详情
- group.create - 创建分组
- group.update - 编辑分组
- group.delete - 删除分组
- **用户管理**
- user.viewAny - 查看用户列表
- user.view - 查看用户详情
- user.create - 创建用户
- user.update - 编辑用户
- user.delete - 删除用户
- **角色管理**
- role.viewAny - 查看角色列表
- role.view - 查看角色详情
- role.create - 创建角色
- role.update - 编辑角色
- role.delete - 删除角色
#### 4.5 数据模型需求
使用 Spatie Laravel Permission 包提供的模型和表结构:
- **Role 模型**(角色)- 由 Spatie 包提供
- name: string角色名称如 super-admin
- guard_name: string守卫名称默认 web
- 关联关系belongsToMany(Permission)、belongsToMany(User)
- **Permission 模型**(权限)- 由 Spatie 包提供
- name: string权限名称如 document.create
- guard_name: string守卫名称默认 web
- 关联关系belongsToMany(Role)
- **model_has_permissions 表**(用户直接权限)- 由 Spatie 包提供
- permission_id: bigint
- model_type: string通常是 User
- model_id: bigint用户ID
- **model_has_roles 表**(用户角色关联)- 由 Spatie 包提供
- role_id: bigint
- model_type: string通常是 User
- model_id: bigint用户ID
- **role_has_permissions 表**(角色权限关联)- 由 Spatie 包提供
- permission_id: bigint
- role_id: bigint
Spatie 包会自动创建这些表和模型,无需手动创建。
#### 4.6 技术实现
- 使用 **Spatie Laravel Permission** 包实现权限管理
- 包提供的核心功能:
- Role角色模型和管理
- Permission权限模型和管理
- 用户角色和权限关联
- 权限检查方法hasPermissionTo、hasRole等
- 中间件支持role、permission
- Blade指令支持@role@can等
- 使用 Laravel Policy 实现业务逻辑权限验证
- 使用 Gate 定义额外的权限规则
- 在 Filament Resource 中集成权限检查
- 权限缓存自动管理
---
## 技术栈
- **后端框架**: Laravel 12
- **管理面板**: Filament 3.x
- **数据库**: MySQL 8.0
- **前端组件**: Livewire 3.x
- **代码编辑器**: Monaco Editor (via filament-monaco-editor)
- **活动日志**: spatie/laravel-activitylog
## 非功能性需求
### 性能要求
- 操作日志列表页面加载时间 < 2秒
- 大屏配置同步响应时间 < 3秒
- SOP模板预览加载时间 < 1秒
### 安全要求
- 所有操作需要身份验证
- 敏感配置API密钥需要加密存储
- 操作日志不可删除,只能归档
- **权限管理**
- 支持基于角色的权限控制RBAC
- 支持基于用户的权限控制
- 支持基于分组的权限控制
- 功能模块级别的权限控制(查看、创建、编辑、删除、特殊操作)
- 数据级别的权限控制(如文档的全局/专用访问)
### 可用性要求
- 界面响应式设计支持1920x1080及以上分辨率
- 表单验证提供清晰的错误提示
- 关键操作需要二次确认
- 支持键盘快捷键操作
### 可维护性要求
- 代码遵循Laravel最佳实践
- 使用Filament标准组件
- 数据库迁移文件完整
- 关键功能需要单元测试
## 依赖项
### Composer包
```bash
composer require spatie/laravel-activitylog
composer require spatie/laravel-permission # 权限管理包
composer require amidesfahani/filament-monaco-editor
composer require maatwebsite/excel # 用于日志导出
```
### 数据库表
- system_settings
- activity_log由 spatie/laravel-activitylog 创建)
- terminals
- terminal_knowledge_bases
- terminal_prompts
- terminal_sync_logs
- sop_templates
- sop_steps
- sop_interactive_tasks
- sop_template_versions
- roles由 spatie/laravel-permission 创建)
- permissions由 spatie/laravel-permission 创建)
- model_has_permissions由 spatie/laravel-permission 创建)
- model_has_roles由 spatie/laravel-permission 创建)
- role_has_permissions由 spatie/laravel-permission 创建)
## 实施优先级
1. **高优先级**(第一阶段)
- 系统设置页面基础功能
- 操作日志列表和筛选
- 大屏终端列表和基础配置
2. **中优先级**(第二阶段)
- 日志详情和导出功能
- 大屏知识库关联和提示词编辑
- SOP模板列表和基础编辑
3. **低优先级**(第三阶段)
- 配置下发和同步状态
- SOP步骤可视化编辑器
- SOP交互任务配置
- 模板导入/导出功能
## 验收测试计划
### 功能测试
- [ ] 系统设置保存和读取
- [ ] 操作日志记录和查询
- [ ] 日志筛选和导出
- [ ] 终端配置CRUD操作
- [ ] 知识库关联和提示词编辑
- [ ] SOP模板CRUD操作
- [ ] 步骤编辑和排序
- [ ] 模板发布和版本管理
### 集成测试
- [ ] 操作日志自动记录
- [ ] 终端配置同步
- [ ] SOP模板导入导出
### 性能测试
- [ ] 10000条日志记录的查询性能
- [ ] 100个终端的列表加载性能
- [ ] 50个步骤的SOP模板编辑性能
## 风险与限制
### 技术风险
- Monaco Editor在Filament中的集成可能需要额外配置
- 大屏终端实时同步可能需要WebSocket支持
- 富文本编辑器的内容安全性需要特别注意
### 业务风险
- SOP模板的版本管理可能导致数据量快速增长
- 操作日志的长期存储需要考虑归档策略
- 大屏配置的实时性要求可能影响系统性能
### 限制条件
- 本期不包含移动端适配
- 不包含多语言支持
- 不包含高级权限管理(如字段级权限)

View File

@@ -0,0 +1,527 @@
# 管理后台功能增强 - 任务列表
## 阶段一:基础设施和数据模型(优先级:高)
### 1. 环境准备和依赖安装
- [x] 1.1 安装spatie/laravel-activitylog包
- [x] 1.2 安装amidesfahani/filament-monaco-editor包
- [x] 1.3 安装maatwebsite/excel包
- [x] 1.4 发布配置文件和迁移文件
### 2. 数据库迁移文件创建
- [x] 2.1 创建system_settings表迁移
- [x] 2.2 创建terminals表迁移
- [x] 2.3 创建terminal_knowledge_bases表迁移
- [x] 2.4 创建terminal_prompts表迁移
- [x] 2.5 创建terminal_sync_logs表迁移
- [x] 2.6 创建sop_templates表迁移
- [x] 2.7 创建sop_steps表迁移
- [x] 2.8 创建sop_interactive_tasks表迁移
- [x] 2.9 创建sop_template_versions表迁移
- [x] 2.10 运行所有迁移
### 3. 模型创建
- [x] 3.1 创建SystemSetting模型
- [x] 3.2 创建Terminal模型及关联关系
- [x] 3.3 创建TerminalKnowledgeBase模型
- [x] 3.4 创建TerminalPrompt模型
- [x] 3.5 创建TerminalSyncLog模型
- [x] 3.6 创建SopTemplate模型及关联关系
- [x] 3.7 创建SopStep模型
- [x] 3.8 创建SopInteractiveTask模型
- [x] 3.9 创建SopTemplateVersion模型
- [x] 3.10 配置所有模型的LogsActivity trait
### 4. 模型工厂和种子数据
- [x] 4.1 创建SystemSetting工厂和种子
- [x] 4.2 创建Terminal工厂和种子
- [x] 4.3 创建SopTemplate工厂和种子
- [x] 4.4 运行种子数据填充
## 阶段二:系统设置与操作日志功能(优先级:高)
### 5. 系统设置功能
- [x] 5.1 创建SystemSettingResource
- [x] 5.1.1 定义表单字段使用Tabs按group分组
- [x] 5.1.2 配置嵌入模型配置字段
- [x] 5.1.3 配置分块参数字段
- [x] 5.1.4 配置全局参数字段
- [x] 5.1.5 添加表单验证规则
- [x] 5.2 创建SystemSettingService
- [x] 5.2.1 实现getGroupedSettings方法
- [x] 5.2.2 实现updateSettings方法
- [x] 5.2.3 实现配置缓存逻辑
- [x] 5.3 创建系统设置页面
- [x] 5.3.1 创建Filament Page
- [x] 5.3.2 集成SystemSettingResource表单
- [x] 5.3.3 添加保存和重置按钮
- [x] 5.4 测试系统设置功能
- [x] 5.4.1 测试配置保存
- [x] 5.4.2 测试配置读取
- [x] 5.4.3 测试配置验证
### 6. 操作日志功能
- [x] 6.1 创建ActivityLogResource
- [x] 6.1.1 定义表格列(时间、用户、操作类型、对象、详情)
- [x] 6.1.2 配置只读模式(禁用创建、编辑、删除)
- [x] 6.1.3 添加默认排序(按时间倒序)
- [x] 6.2 实现日志筛选功能
- [x] 6.2.1 添加时间范围筛选器
- [x] 6.2.2 添加操作类型筛选器
- [x] 6.2.3 添加用户筛选器
- [x] 6.2.4 添加对象类型筛选器
- [x] 6.3 实现日志详情功能
- [x] 6.3.1 创建LogDiffViewer组件
- [x] 6.3.2 实现JSON diff对比视图
- [x] 6.3.3 创建自定义ViewAction
- [x] 6.3.4 添加详情弹窗
- [x] 6.4 实现日志导出功能
- [x] 6.4.1 创建ExportActivityLogAction
- [x] 6.4.2 实现CSV导出
- [x] 6.4.3 实现Excel导出
- [x] 6.4.4 添加导出按钮到表格
- [x] 6.5 测试操作日志功能
- [x] 6.5.1 测试日志自动记录
- [x] 6.5.2 测试日志筛选
- [x] 6.5.3 测试日志详情查看
- [x] 6.5.4 测试日志导出
## 阶段三:大屏配置管理功能(优先级:中)
### 7. 终端管理基础功能
- [x] 7.1 创建TerminalResource
- [x] 7.1.1 定义表格列名称、编码、IP、线站、在线状态
- [x] 7.1.2 添加在线状态Badge组件
- [x] 7.1.3 配置按线站分组
- [x] 7.1.4 添加搜索功能
- [x] 7.2 创建终端表单
- [x] 7.2.1 添加基本信息字段
- [x] 7.2.2 添加线站绑定选择器
- [x] 7.2.3 添加组态图URL字段
- [x] 7.2.4 添加显示配置字段
- [x] 7.2.5 添加表单验证
- [x] 7.3 测试终端CRUD功能
- [x] 7.3.1 测试终端创建
- [x] 7.3.2 测试终端编辑
- [x] 7.3.3 测试终端删除
- [x] 7.3.4 测试终端列表
### 8. 知识库关联功能
- [x] 8.1 创建知识库关联选择器
- [x] 8.1.1 使用Select组件多选模式
- [x] 8.1.2 添加搜索功能
- [x] 8.1.3 显示已选知识库列表
- [x] 8.1.4 添加优先级排序功能
- [x] 8.2 实现关联关系保存
- [x] 8.2.1 在终端表单中集成选择器
- [x] 8.2.2 实现关联数据保存逻辑
- [x] 8.2.3 实现关联数据加载逻辑
- [x] 8.3 测试知识库关联功能
- [x] 8.3.1 测试多选功能
- [x] 8.3.2 测试搜索功能
- [x] 8.3.3 测试优先级排序
### 9. AI提示词编辑功能
- [x] 9.1 集成Monaco Editor
- [x] 9.1.1 在终端表单中添加MonacoEditor字段
- [x] 9.1.2 配置语法高亮
- [x] 9.1.3 配置编辑器主题
- [x] 9.2 实现变量提示功能
- [x] 9.2.1 定义可用变量列表
- [x] 9.2.2 实现自动补全
- [x] 9.2.3 添加变量说明文档
- [x] 9.3 创建提示词模板
- [x] 9.3.1 创建常用模板库
- [x] 9.3.2 添加模板选择器
- [x] 9.3.3 实现模板应用功能
- [x] 9.4 实现提示词预览
- [x] 9.4.1 创建预览组件
- [x] 9.4.2 实现变量替换预览
- [x] 9.4.3 添加预览按钮
- [x] 9.5 测试提示词编辑功能
- [x] 9.5.1 测试编辑器功能
- [x] 9.5.2 测试变量提示
- [x] 9.5.3 测试模板应用
- [x] 9.5.4 测试预览功能
### 10. 配置同步功能
- [x] 10.1 创建TerminalSyncService
- [x] 10.1.1 实现syncConfiguration方法
- [x] 10.1.2 实现getConfigSnapshot方法
- [x] 10.1.3 实现同步状态更新逻辑
- [x] 10.2 创建SyncTerminalConfigJob
- [x] 10.2.1 实现任务处理逻辑
- [x] 10.2.2 实现错误处理
- [x] 10.2.3 实现重试机制
- [x] 10.3 创建同步Action
- [x] 10.3.1 创建SyncConfigAction
- [x] 10.3.2 添加到终端资源
- [x] 10.3.3 添加批量同步功能
- [x] 10.4 实现同步状态展示
- [x] 10.4.1 创建同步状态Badge
- [x] 10.4.2 在列表页显示同步状态
- [x] 10.4.3 创建同步历史查看页面
- [x] 10.5 测试配置同步功能
- [x] 10.5.1 测试单个终端同步
- [x] 10.5.2 测试批量同步
- [x] 10.5.3 测试同步失败处理
- [x] 10.5.4 测试同步历史记录
## 阶段四SOP模板管理功能优先级
### 11. SOP模板基础功能
- [x] 11.1 创建SopTemplateResource
- [x] 11.1.1 定义表格列(名称、分类、版本、状态)
- [x] 11.1.2 添加状态Badge
- [x] 11.1.3 配置卡片视图
- [x] 11.1.4 添加搜索和筛选
- [x] 11.2 创建模板表单
- [x] 11.2.1 添加基本信息字段
- [x] 11.2.2 添加分类和标签字段
- [x] 11.2.3 添加适用范围字段
- [x] 11.2.4 添加版本管理字段
- [x] 11.2.5 添加表单验证
- [x] 11.3 实现状态管理
- [x] 11.3.1 创建状态转换逻辑
- [x] 11.3.2 添加状态转换Action
- [x] 11.3.3 实现发布前验证
- [x] 11.4 测试模板CRUD功能
- [x] 11.4.1 测试模板创建
- [x] 11.4.2 测试模板编辑
- [x] 11.4.3 测试模板删除
- [x] 11.4.4 测试状态转换
### 12. 步骤可视化编辑功能
- [x] 12.1 创建步骤编辑器组件
- [x] 12.1.1 使用Repeater组件
- [x] 12.1.2 配置步骤字段
- [x] 12.1.3 添加富文本编辑器
- [x] 12.1.4 实现拖拽排序
- [x] 12.2 实现步骤管理功能
- [x] 12.2.1 实现步骤添加
- [x] 12.2.2 实现步骤删除
- [x] 12.2.3 实现步骤复制
- [x] 12.2.4 实现步骤编号自动更新
- [x] 12.3 优化编辑体验
- [x] 12.3.1 实现步骤折叠/展开
- [x] 12.3.2 添加快捷操作按钮
- [x] 12.3.3 实现自动保存
- [x] 12.4 测试步骤编辑功能
- [x] 12.4.1 测试步骤CRUD
- [x] 12.4.2 测试拖拽排序
- [x] 12.4.3 测试富文本编辑
### 13. 交互任务配置功能
- [x] 13.1 创建任务配置组件
- [x] 13.1.1 使用Builder组件
- [x] 13.1.2 定义任务类型(确认、输入、选择、拍照、扫码)
- [x] 13.1.3 为每种类型创建配置表单
- [x] 13.2 实现任务参数配置
- [x] 13.2.1 实现必填项配置
- [x] 13.2.2 实现验证规则配置
- [x] 13.2.3 实现默认值配置
- [x] 13.2.4 实现超时设置
- [x] 13.3 实现条件分支配置
- [x] 13.3.1 创建条件编辑器
- [x] 13.3.2 实现条件逻辑配置
- [x] 13.3.3 实现分支跳转配置
- [x] 13.4 测试交互任务功能
- [x] 13.4.1 测试任务创建
- [x] 13.4.2 测试参数配置
- [x] 13.4.3 测试条件分支
### 14. 模板预览与发布功能
- [x] 14.1 创建模板预览功能
- [x] 14.1.1 创建PreviewAction
- [x] 14.1.2 实现预览页面
- [x] 14.1.3 模拟实际使用场景
- [x] 14.2 实现发布流程
- [x] 14.2.1 创建PublishAction
- [x] 14.2.2 实现发布前验证
- [x] 14.2.3 添加发布确认弹窗
- [x] 14.2.4 实现版本快照创建
- [x] 14.3 实现版本管理
- [x] 14.3.1 创建版本历史页面
- [x] 14.3.2 实现版本对比功能
- [x] 14.3.3 实现版本回滚功能
- [x] 14.4 测试预览和发布功能
- [x] 14.4.1 测试预览功能
- [x] 14.4.2 测试发布流程
- [x] 14.4.3 测试版本管理
### 15. 模板导入导出功能
- [x] 15.1 创建SopTemplateService
- [x] 15.1.1 实现exportToJson方法
- [x] 15.1.2 实现exportToPdf方法
- [x] 15.1.3 实现importFromJson方法
- [x] 15.2 创建导出Action
- [x] 15.2.1 创建ExportAction
- [x] 15.2.2 添加格式选择
- [x] 15.2.3 实现文件下载
- [x] 15.3 创建导入功能
- [x] 15.3.1 创建ImportAction
- [x] 15.3.2 实现文件上传
- [x] 15.3.3 实现数据验证
- [x] 15.3.4 实现批量导入
- [x] 15.4 测试导入导出功能
- [x] 15.4.1 测试JSON导出
- [x] 15.4.2 测试PDF导出
- [x] 15.4.3 测试JSON导入
- [x] 15.4.4 测试批量导入
## 阶段五:权限管理系统(优先级:高)
### 16. Spatie Permission 包安装和配置
- [x] 16.1 安装 Spatie Permission 包
- [x] 16.1.1 运行 composer require spatie/laravel-permission
- [x] 16.1.2 发布配置文件和迁移文件
- [x] 16.1.3 运行迁移创建权限表
- [x] 16.1.4 清除缓存
- [x] 16.2 配置 User 模型
- [x] 16.2.1 在 User 模型中添加 HasRoles trait
- [x] 16.2.2 配置守卫guard
- [x] 16.2.3 测试基本权限方法
- [x] 16.3 创建权限种子数据
- [x] 16.3.1 创建 PermissionSeeder
- [x] 16.3.2 定义所有功能模块的权限45个权限
- [x] 16.3.3 创建预设角色super-admin、admin、user
- [x] 16.3.4 为角色分配权限
- [x] 16.3.5 运行种子数据
### 17. 角色管理功能
- [x] 17.1 创建 RoleResource
- [x] 17.1.1 定义表格列(名称、守卫、权限数、用户数)
- [x] 17.1.2 添加搜索和筛选功能
- [x] 17.1.3 添加系统角色标识super-admin不可删除
- [x] 17.2 创建角色表单
- [x] 17.2.1 添加基本信息字段(名称、守卫)
- [x] 17.2.2 添加权限选择器(使用 CheckboxList按模块分组
- [x] 17.2.3 添加表单验证规则
- [x] 17.2.4 实现权限同步逻辑(使用 syncPermissions
- [x] 17.3 实现角色删除保护
- [x] 17.3.1 检查角色是否为 super-admin
- [x] 17.3.2 检查角色是否有关联用户
- [x] 17.3.3 添加删除确认提示
- [x] 17.4 测试角色管理功能
- [x] 17.4.1 测试角色 CRUD 操作
- [x] 17.4.2 测试权限分配syncPermissions
- [x] 17.4.3 测试删除保护
### 18. 用户权限管理功能
- [x] 18.1 更新 UserResource
- [x] 18.1.1 添加角色分配字段(使用 Select支持多选
- [x] 18.1.2 添加分组关联字段
- [x] 18.1.3 添加直接权限配置 Section使用 CheckboxList
- [x] 18.1.4 显示用户的所有权限预览(角色权限+直接权限)
- [x] 18.2 实现用户权限保存逻辑
- [x] 18.2.1 使用 syncRoles 同步角色
- [x] 18.2.2 使用 syncPermissions 同步直接权限
- [x] 18.2.3 处理权限冲突(直接权限优先)
- [x] 18.3 创建权限预览组件
- [x] 18.3.1 使用 Placeholder 组件显示权限
- [x] 18.3.2 按模块分组显示
- [x] 18.3.3 标识权限来源(角色/直接授予)
- [x] 18.3.4 支持权限搜索
- [x] 18.4 测试用户权限功能
- [x] 18.4.1 测试角色分配assignRole、syncRoles
- [x] 18.4.2 测试直接权限配置givePermissionTo、syncPermissions
- [x] 18.4.3 测试权限检查hasPermissionTo、can
### 19. 权限策略实现
- [x] 19.1 DocumentPolicy已部分实现需完善
- [x] 19.1.1 在 viewAny 中添加权限检查document.view
- [x] 19.1.2 在 view 中添加权限检查document.view
- [x] 19.1.3 在 create 中添加权限检查document.create
- [x] 19.1.4 在 update 中添加权限检查document.update
- [x] 19.1.5 在 delete 中添加权限检查document.delete
- [x] 19.1.6 在 download 中添加权限检查document.download
- [x] 19.1.7 保留现有的分组访问控制逻辑
- [x] 19.2 SystemSettingPolicy
- [x] 19.2.1 实现 viewAnysystem-setting.view
- [x] 19.2.2 实现 viewsystem-setting.view
- [x] 19.2.3 实现 updatesystem-setting.update
- [x] 19.3 ActivityLogPolicy
- [x] 19.3.1 实现 viewAnyactivity-log.view
- [x] 19.3.2 实现 viewactivity-log.view
- [x] 19.3.3 实现 exportactivity-log.export
- [x] 19.4 TerminalPolicy已部分实现需完善
- [x] 19.4.1 在所有方法中添加权限检查
- [x] 19.4.2 实现 sync 权限检查terminal.sync
- [x] 19.4.3 保留现有的管理员检查作为后备
- [x] 19.5 SopTemplatePolicy已部分实现需完善
- [x] 19.5.1 在所有方法中添加权限检查
- [x] 19.5.2 实现 publish 权限检查sop-template.publish
- [x] 19.5.3 实现 archive 权限检查sop-template.archive
- [x] 19.5.4 保留现有的状态检查逻辑
- [x] 19.6 GroupPolicy
- [x] 19.6.1 实现 viewAnygroup.view
- [x] 19.6.2 实现 viewgroup.view
- [x] 19.6.3 实现 creategroup.create
- [x] 19.6.4 实现 updategroup.update
- [x] 19.6.5 实现 deletegroup.delete需检查关联文档
- [x] 19.7 UserPolicy
- [x] 19.7.1 实现 viewAnyuser.view
- [x] 19.7.2 实现 viewuser.view
- [x] 19.7.3 实现 createuser.create
- [x] 19.7.4 实现 updateuser.update
- [x] 19.7.5 实现 deleteuser.delete不能删除自己
- [x] 19.8 RolePolicy
- [x] 19.8.1 实现 viewAnyrole.viewAny
- [x] 19.8.2 实现 viewrole.view
- [x] 19.8.3 实现 createrole.create
- [x] 19.8.4 实现 updaterole.update
- [x] 19.8.5 实现 deleterole.deletesuper-admin 保护)
- [x] 19.9 策略注册
- [x] 19.9.1 在 AppServiceProvider 中注册所有策略
- [x] 19.9.2 配置策略自动发现
### 20. Filament 资源权限集成
- [x] 20.1 更新所有 Resource 的权限检查
- [x] 20.1.1 DocumentResource 集成权限(使用 can 方法)
- [x] 20.1.2 SystemSettingResource 集成权限
- [x] 20.1.3 ActivityLogResource 集成权限
- [x] 20.1.4 TerminalResource 集成权限
- [x] 20.1.5 SopTemplateResource 集成权限
- [x] 20.1.6 GroupResource 集成权限
- [x] 20.1.7 UserResource 集成权限
- [x] 20.1.8 RoleResource 集成权限
- [x] 20.2 实现导航菜单权限控制
- [x] 20.2.1 配置 Resource 的 shouldRegisterNavigation 方法
- [x] 20.2.2 使用 auth()->user()->can() 检查权限
- [x] 20.2.3 根据权限动态显示/隐藏菜单项
- [ ] 20.3 实现操作按钮权限控制
- [ ] 20.3.1 配置 Action 的 visible 方法
- [ ] 20.3.2 使用 $this->can() 检查权限
- [ ] 20.3.3 根据权限动态显示/隐藏按钮
- [ ] 20.4 实现批量操作权限控制
- [ ] 20.4.1 配置 BulkAction 的 visible 方法
- [ ] 20.4.2 根据权限控制批量操作可用性
- [ ] 20.5 实现中间件保护
- [ ] 20.5.1 在路由中使用 permission 中间件
- [ ] 20.5.2 在路由中使用 role 中间件
- [ ] 20.5.3 测试未授权访问的重定向
### 21. 权限测试
- [ ] 21.1 单元测试
- [ ] 21.1.1 测试 User 模型的 HasRoles trait
- [ ] 21.1.2 测试 hasPermissionTo 方法
- [ ] 21.1.3 测试 hasRole 方法
- [ ] 21.1.4 测试 assignRole 和 removeRole
- [ ] 21.1.5 测试 givePermissionTo 和 revokePermissionTo
- [ ] 21.1.6 测试权限缓存
- [ ] 21.2 策略测试
- [ ] 21.2.1 测试所有 Policy 的权限检查
- [ ] 21.2.2 测试 super-admin 角色的完整权限
- [ ] 21.2.3 测试 admin 角色的权限
- [ ] 21.2.4 测试普通用户的权限限制
- [ ] 21.2.5 测试直接权限覆盖角色权限
- [ ] 21.3 集成测试
- [ ] 21.3.1 测试角色分配后的权限生效
- [ ] 21.3.2 测试权限撤销后的访问限制
- [ ] 21.3.3 测试跨分组访问控制
- [ ] 21.3.4 测试数据级权限过滤
- [ ] 21.4 UI 测试
- [ ] 21.4.1 测试菜单项权限控制
- [ ] 21.4.2 测试操作按钮权限控制
- [ ] 21.4.3 测试批量操作权限控制
- [ ] 21.4.4 测试未授权访问的错误提示
## 阶段六:测试和优化(优先级:低)
### 22. 单元测试
- [ ] 22.1 SystemSetting模型测试
- [ ] 22.1.1 测试get方法
- [ ] 22.1.2 测试set方法
- [ ] 22.2 Terminal模型测试
- [ ] 22.2.1 测试关联关系
- [ ] 22.2.2 测试作用域
- [ ] 22.3 SopTemplate模型测试
- [ ] 22.3.1 测试关联关系
- [ ] 22.3.2 测试状态转换
- [ ] 22.4 Service类测试
- [ ] 22.4.1 测试SystemSettingService
- [ ] 22.4.2 测试TerminalSyncService
- [ ] 22.4.3 测试SopTemplateService
### 23. 功能测试
- [ ] 23.1 系统设置功能测试
- [ ] 23.1.1 测试配置保存
- [ ] 23.1.2 测试配置读取
- [ ] 23.2 操作日志功能测试
- [ ] 23.2.1 测试日志记录
- [ ] 23.2.2 测试日志筛选
- [ ] 23.2.3 测试日志导出
- [ ] 23.3 终端管理功能测试
- [ ] 23.3.1 测试终端CRUD
- [ ] 23.3.2 测试配置同步
- [ ] 23.4 SOP模板功能测试
- [ ] 23.4.1 测试模板CRUD
- [ ] 23.4.2 测试步骤编辑
- [ ] 23.4.3 测试模板发布
- [ ] 23.4.4 测试导入导出
### 24. 性能优化
- [ ] 24.1 数据库优化
- [ ] 24.1.1 添加必要索引
- [ ] 24.1.2 优化查询语句
- [ ] 24.1.3 实现Eager Loading
- [ ] 24.2 缓存优化
- [ ] 24.2.1 实现系统设置缓存
- [ ] 24.2.2 实现终端状态缓存
- [ ] 24.2.3 实现模板列表缓存
- [ ] 24.2.4 实现权限缓存
- [ ] 24.3 前端优化
- [ ] 24.3.1 实现Lazy Loading
- [ ] 24.3.2 优化Monaco Editor加载
- [ ] 24.3.3 优化图片加载
- [ ] 24.4 性能测试
- [ ] 24.4.1 测试日志查询性能
- [ ] 24.4.2 测试终端列表性能
- [ ] 24.4.3 测试模板编辑性能
- [ ] 24.4.4 测试权限检查性能
## 阶段七:文档和部署(优先级:低)
### 25. 文档编写
- [ ] 25.1 编写用户手册
- [ ] 25.1.1 系统设置使用说明
- [ ] 25.1.2 操作日志使用说明
- [ ] 25.1.3 终端管理使用说明
- [ ] 25.1.4 SOP模板使用说明
- [ ] 25.1.5 权限管理使用说明
- [ ] 25.2 编写开发文档
- [ ] 25.2.1 API文档
- [ ] 25.2.2 数据库设计文档
- [ ] 25.2.3 部署文档
- [ ] 25.2.4 权限系统架构文档
- [ ] 25.3 编写测试文档
- [ ] 25.3.1 测试用例文档
- [ ] 25.3.2 测试报告模板
### 26. 部署准备
- [ ] 26.1 准备生产环境配置
- [ ] 26.1.1 更新.env.production
- [ ] 26.1.2 配置队列服务
- [ ] 26.1.3 配置缓存服务
- [ ] 26.2 数据迁移准备
- [ ] 26.2.1 准备迁移脚本
- [ ] 26.2.2 准备回滚脚本
- [ ] 26.2.3 准备种子数据
- [ ] 26.3 部署到生产环境
- [ ] 26.3.1 执行数据库迁移
- [ ] 26.3.2 发布静态资源
- [ ] 26.3.3 重启服务
- [ ] 26.4 生产环境验证
- [ ] 26.4.1 验证所有功能
- [ ] 26.4.2 验证性能指标
- [ ] 26.4.3 验证安全配置
## 任务统计
- 总任务数26个主任务
- 子任务数约250+个子任务
- 预计工作量50-70工作日
- 优先级分布:
- 高优先级阶段一、二、五约40%
- 中优先级阶段三、四约35%
- 低优先级阶段六、七约25%

View File

@@ -0,0 +1,218 @@
# 表单验证规则总结
## 任务 5.1.5 - 表单验证规则实施完成
### SystemSettingResource 验证规则
#### 基本信息字段
- **key** (配置键)
- ✅ required - 必填
- ✅ unique - 唯一性验证(忽略当前记录)
- ✅ maxLength(255) - 最大长度255字符
- ✅ minLength(3) - 最小长度3字符
- ✅ regex('/^[a-z0-9_\.]+$/') - 格式验证(只允许小写字母、数字、下划线和点)
- **group** (配置分组)
- ✅ required - 必填
- ✅ 预定义选项embedding, chunking, system, search
- **description** (配置说明)
- ✅ maxLength(65535) - 最大长度
- ✅ minLength(5) - 最小长度5字符
- **is_public** (公开配置)
- ✅ boolean - 布尔类型
- ✅ default(false) - 默认值
- **value** (配置值)
- ✅ required - 必填
- ✅ KeyValue组件 - 键值对格式
- ✅ reorderable(false) - 禁用重排序
---
### ManageSystemSettings 验证规则
#### 1. 嵌入模型配置 (Embedding)
**模型基础配置:**
- **embedding.model_name** (模型名称)
- ✅ required - 必填
- ✅ maxLength(255) - 最大长度
- ✅ minLength(3) - 最小长度
- **embedding.api_key** (API密钥)
- ✅ required - 必填
- ✅ password - 密码字段(可显示)
- ✅ maxLength(255) - 最大长度
- ✅ minLength(20) - 最小长度
- **embedding.endpoint_url** (API端点URL)
- ✅ required - 必填
- ✅ url() - URL格式验证
- ✅ maxLength(500) - 最大长度
- ✅ prefix('https://') - URL前缀提示
**模型参数配置:**
- **embedding.dimensions** (向量维度)
- ✅ required - 必填
- ✅ numeric - 数值类型
- ✅ minValue(1) - 最小值1
- ✅ maxValue(4096) - 最大值4096
- **embedding.batch_size** (批量处理大小)
- ✅ required - 必填
- ✅ numeric - 数值类型
- ✅ minValue(1) - 最小值1
- ✅ maxValue(1000) - 最大值1000
#### 2. 分块参数配置 (Chunking)
**分块基础参数:**
- **chunking.chunk_size** (分块大小)
- ✅ required - 必填
- ✅ numeric - 数值类型
- ✅ minValue(100) - 最小值100
- ✅ maxValue(10000) - 最大值10000
- ✅ default(1000) - 默认值1000
- **chunking.chunk_overlap** (分块重叠大小)
- ✅ required - 必填
- ✅ numeric - 数值类型
- ✅ minValue(0) - 最小值0
- ✅ maxValue(1000) - 最大值1000
- ✅ default(200) - 默认值200
- **chunking.min_chunk_size** (最小分块大小)
- ✅ required - 必填
- ✅ numeric - 数值类型
- ✅ minValue(10) - 最小值10
- ✅ maxValue(1000) - 最大值1000
- ✅ default(100) - 默认值100
**分块高级参数:**
- **chunking.separator** (分块分隔符)
- ✅ maxLength(100) - 最大长度100
#### 3. 系统全局配置 (System)
**系统基础信息:**
- **system.name** (系统名称)
- ✅ required - 必填
- ✅ maxLength(255) - 最大长度
- ✅ default('知识库管理系统') - 默认值
**系统运行参数:**
- **system.timeout** (请求超时时间)
- ✅ required - 必填
- ✅ numeric - 数值类型
- ✅ minValue(10) - 最小值10秒
- ✅ maxValue(300) - 最大值300秒
- ✅ default(60) - 默认值60秒
- **system.max_retries** (最大重试次数)
- ✅ required - 必填
- ✅ numeric - 数值类型
- ✅ minValue(0) - 最小值0
- ✅ maxValue(10) - 最大值10
- ✅ default(3) - 默认值3次
**文件上传配置:**
- **system.max_upload_size** (最大上传大小)
- ✅ required - 必填
- ✅ numeric - 数值类型
- ✅ minValue(1048576) - 最小值1MB
- ✅ maxValue(104857600) - 最大值100MB
- ✅ default(10485760) - 默认值10MB
- **system.allowed_file_types** (允许的文件类型)
- ✅ required - 必填
- ✅ TagsInput - 标签输入组件
- ✅ default(['pdf', 'docx', 'txt', 'md']) - 默认值
#### 4. 搜索配置 (Search)
**搜索参数:**
- **search.top_k** (最大结果数)
- ✅ required - 必填
- ✅ numeric - 数值类型
- ✅ minValue(1) - 最小值1
- ✅ maxValue(100) - 最大值100
- ✅ default(10) - 默认值10
- **search.similarity_threshold** (相似度阈值)
- ✅ required - 必填
- ✅ numeric - 数值类型
- ✅ minValue(0) - 最小值0
- ✅ maxValue(1) - 最大值1
- ✅ step(0.01) - 步进值0.01
- ✅ default(0.7) - 默认值0.7
- **search.enable_rerank** (启用重排序)
- ✅ boolean - 布尔类型
- ✅ Toggle组件
- ✅ default(false) - 默认值
---
## 测试覆盖
### 已创建测试文件
1. **tests/Feature/SystemSettingsTest.php** - 系统设置基础功能测试
2. **tests/Feature/SystemSettingValidationTest.php** - 验证规则测试
### 测试用例
✅ 必填字段验证
✅ 唯一性验证
✅ 数值范围验证(嵌入维度)
✅ 数值范围验证(分块参数)
✅ URL格式验证
✅ 超时时间范围验证
✅ 相似度阈值范围验证
✅ 数组类型验证
✅ 布尔类型验证
✅ 最大长度限制验证
### 测试结果
- 所有测试通过 ✅
- 总计13个测试113个断言
- 执行时间:< 1秒
---
## 验证规则实施总结
### 完成的工作
1. ✅ 为 SystemSettingResource 添加了完整的表单验证规则
2. ✅ 为 ManageSystemSettings 页面的所有字段添加了验证规则
3. ✅ 添加了合理的默认值
4. ✅ 添加了数值范围限制
5. ✅ 添加了格式验证URL、正则表达式
6. ✅ 添加了长度限制(最小/最大)
7. ✅ 创建了完整的测试套件
8. ✅ 所有测试通过
### 验证规则特点
- **必填字段**:所有关键配置项都标记为 required
- **数值范围**:所有数值字段都有 minValue/maxValue 限制
- **URL验证**endpoint_url 字段使用 url() 验证
- **唯一性**key 字段有 unique() 验证
- **格式验证**key 字段有正则表达式格式验证
- **默认值**:所有字段都配置了合理的默认值
- **用户友好**:所有字段都有清晰的 helperText 说明
### 符合需求文档
根据需求文档 1.3 验收标准:
- ✅ 配置保存和验证功能
- ✅ 表单验证提供清晰的错误提示
- ✅ 所有操作需要身份验证
- ✅ 关键功能需要单元测试
---
## 下一步建议
任务 5.1.5 已完成,建议继续执行:
- 任务 5.2:创建 SystemSettingService
- 任务 5.3:创建系统设置页面
- 任务 5.4:测试系统设置功能

View File

@@ -0,0 +1,290 @@
# Docker部署设计文档
## 概述
本设计文档描述了将Laravel知识库系统Docker化部署到OpenEuler服务器的完整解决方案。系统采用微服务架构通过Docker容器化技术实现应用的标准化部署和运维。
设计目标:
- 构建适用于OpenEuler服务器的amd64架构Docker镜像
- 实现完整的应用栈容器化编排
- 确保数据持久化和服务高可用性
- 支持开发和生产环境的不同配置需求
- 提供便捷的镜像打包和离线部署能力
## 架构
### 整体架构
系统采用多容器架构,包含以下核心组件:
```mermaid
graph TB
subgraph "Docker Host (OpenEuler)"
subgraph "Application Stack"
nginx[Nginx容器<br/>Web服务器]
app[Laravel应用容器<br/>PHP-FPM]
queue[队列处理容器<br/>Laravel Queue]
end
subgraph "Data Layer"
mysql[MySQL容器<br/>主数据库]
redis[Redis容器<br/>缓存/会话]
meilisearch[Meilisearch容器<br/>搜索引擎]
end
subgraph "Storage"
app_data[应用数据卷]
db_data[数据库数据卷]
search_data[搜索数据卷]
logs[日志卷]
end
end
nginx --> app
app --> mysql
app --> redis
app --> meilisearch
queue --> mysql
queue --> redis
app --> app_data
mysql --> db_data
meilisearch --> search_data
nginx --> logs
app --> logs
```
### 网络架构
- **外部网络**: 通过宿主机端口映射提供Web服务访问
- **内部网络**: 创建专用Docker网络供容器间通信
- **服务发现**: 通过容器名称进行服务间通信
### 存储架构
- **代码存储**: 项目目录映射到应用容器,支持开发时热重载
- **数据持久化**: 数据库、搜索引擎、上传文件使用Docker卷持久化
- **日志管理**: 应用日志映射到宿主机便于监控和调试
## 组件和接口
### Docker镜像组件
#### 1. Laravel应用镜像 (knowledge-base-app)
- **基础镜像**: php:8.2-fpm-alpine
- **运行时**: PHP-FPM + Nginx
- **依赖**: Composer包、NPM构建产物、Pandoc
- **配置**: PHP扩展、Nginx配置、应用配置
#### 2. 数据库组件 (MySQL)
- **镜像**: mysql:8.0
- **配置**: 字符集utf8mb4、时区设置
- **存储**: 数据目录持久化
#### 3. 缓存组件 (Redis)
- **镜像**: redis:7-alpine
- **配置**: 内存限制、持久化策略
- **用途**: 会话存储、应用缓存、队列存储
#### 4. 搜索组件 (Meilisearch)
- **镜像**: getmeili/meilisearch:v1.5
- **配置**: 主密钥、环境模式
- **存储**: 索引数据持久化
### 服务接口
#### Web服务接口
- **端口**: 80 (HTTP)
- **协议**: HTTP/1.1
- **负载均衡**: Nginx upstream配置
#### 数据库接口
- **端口**: 3306 (内部)
- **协议**: MySQL Protocol
- **连接池**: Laravel数据库连接配置
#### 缓存接口
- **端口**: 6379 (内部)
- **协议**: Redis Protocol
- **连接**: phpredis扩展
#### 搜索接口
- **端口**: 7700 (内部)
- **协议**: HTTP REST API
- **认证**: Master Key
## 数据模型
### 容器配置模型
```yaml
# docker-compose.yml结构
services:
app:
image: knowledge-base-app:latest
platform: linux/amd64
environment:
- APP_ENV=production
- DB_HOST=mysql
- REDIS_HOST=redis
- MEILISEARCH_HOST=http://meilisearch:7700
volumes:
- ./:/var/www/html
- storage_data:/var/www/html/storage
depends_on:
- mysql
- redis
- meilisearch
```
### 环境变量模型
```bash
# 生产环境变量
APP_ENV=production
APP_DEBUG=false
DB_CONNECTION=mysql
DB_HOST=mysql
DB_PORT=3306
REDIS_HOST=redis
REDIS_PORT=6379
MEILISEARCH_HOST=http://meilisearch:7700
```
### 存储卷模型
```yaml
volumes:
mysql_data:
driver: local
redis_data:
driver: local
meilisearch_data:
driver: local
storage_data:
driver: local
```
## 正确性属性
*属性是应该在系统的所有有效执行中保持为真的特征或行为——本质上是关于系统应该做什么的正式声明。属性作为人类可读规范和机器可验证正确性保证之间的桥梁。*
### 属性1: 镜像架构一致性
*对于任何*构建的Docker镜像检查其架构信息应该返回linux/amd64
**验证: 需求 1.1**
### 属性2: PHP环境完整性
*对于任何*构建的应用镜像在容器内执行PHP版本检查应该返回8.2.x版本且包含所有必需扩展
**验证: 需求 1.2**
### 属性3: 构建产物存在性
*对于任何*构建的应用镜像vendor目录和public/js、public/css目录应该存在且包含必要文件
**验证: 需求 1.3**
### 属性4: 服务启动一致性
*对于任何*docker-compose启动操作所有定义的服务容器应该成功启动并达到健康状态
**验证: 需求 2.1, 2.2, 2.3, 2.4, 2.5**
### 属性5: 数据持久化保证
*对于任何*容器重启操作,持久化存储中的数据应该保持不变且可访问
**验证: 需求 3.2, 3.3, 3.4**
### 属性6: 服务连接性
*对于任何*运行中的服务栈,应用容器应该能够成功连接到所有依赖服务
**验证: 需求 4.2, 4.3, 4.4**
### 属性7: 健康检查响应性
*对于任何*运行中的服务,健康检查端点应该在合理时间内返回成功响应
**验证: 需求 5.1, 5.2, 5.3, 5.4**
### 属性8: 容器自愈能力
*对于任何*模拟的容器故障,系统应该根据重启策略自动恢复服务
**验证: 需求 5.5**
### 属性9: 镜像导出完整性
*对于任何*导出的Docker镜像tar文件应该包含完整的镜像层和元数据信息
**验证: 需求 6.1**
### 属性10: 镜像导入兼容性
*对于任何*导出的镜像tar文件在OpenEuler环境中导入后应该能够正常运行
**验证: 需求 6.2**
### 属性11: 压缩效率
*对于任何*镜像压缩操作,压缩后的文件大小应该显著小于原始大小
**验证: 需求 6.3**
### 属性12: 完整性验证
*对于任何*镜像文件,完整性检查应该能够验证文件未被损坏且架构正确
**验证: 需求 6.4**
### 属性13: 开发环境热重载
*对于任何*开发环境中的代码修改,应用应该在合理时间内反映更改
**验证: 需求 7.1**
## 错误处理
### 容器启动失败
- **检测**: 健康检查失败或容器退出
- **处理**: 自动重启策略,最大重试次数限制
- **日志**: 详细错误日志记录到宿主机
### 服务连接失败
- **检测**: 连接超时或拒绝连接
- **处理**: 重试机制,降级服务
- **监控**: 连接状态监控和告警
### 数据持久化失败
- **检测**: 卷挂载失败或权限错误
- **处理**: 容器启动前预检查
- **恢复**: 数据备份和恢复机制
### 镜像构建失败
- **检测**: 构建过程中的错误退出
- **处理**: 分阶段构建,错误定位
- **优化**: 构建缓存和依赖管理
## 测试策略
### 单元测试方法
**容器构建测试**:
- 验证Dockerfile语法正确性
- 测试构建过程中的关键步骤
- 检查构建产物的完整性
**配置文件测试**:
- 验证docker-compose.yml语法
- 测试环境变量配置的正确性
- 检查网络和存储配置
**脚本功能测试**:
- 测试部署脚本的执行流程
- 验证健康检查脚本的准确性
- 测试备份和恢复脚本
### 属性测试方法
**测试框架**: 使用Bash脚本结合Docker命令进行属性测试每个属性测试运行100次迭代以确保可靠性。
**测试环境**:
- 本地Docker环境用于开发测试
- OpenEuler虚拟机用于兼容性测试
- CI/CD环境用于自动化测试
**测试数据生成**:
- 随机生成不同的环境配置
- 模拟各种故障场景
- 生成不同规模的测试数据
**属性测试实现要求**:
- 每个正确性属性必须实现为单独的属性测试
- 测试必须标注对应的设计文档属性编号
- 使用格式: `# Feature: docker-deployment, Property X: [属性描述]`
- 每个属性测试最少运行100次迭代
- 测试应该覆盖各种输入组合和边界条件
**集成测试**:
- 端到端部署流程测试
- 服务间通信测试
- 数据一致性测试
- 性能基准测试

View File

@@ -0,0 +1,102 @@
# Docker部署需求文档
## 介绍
本文档定义了将Laravel知识库系统Docker化部署到OpenEuler服务器的需求。系统需要支持完整的生产环境运行包括Web应用、数据库、缓存、搜索引擎和队列处理等所有组件。
## 术语表
- **Docker镜像**: 包含应用程序及其运行环境的可执行包
- **容器编排**: 使用docker-compose管理多个相关容器的技术
- **知识库系统**: 基于Laravel框架的文档管理和搜索系统
- **OpenEuler服务器**: 目标部署环境的Linux服务器
- **生产环境**: 实际运行业务的服务器环境
- **数据持久化**: 确保容器重启后数据不丢失的机制
- **健康检查**: 监控容器运行状态的机制
## 需求
### 需求1
**用户故事:** 作为系统管理员我希望能够构建包含完整运行环境的Docker镜像以便在OpenEuler服务器上部署知识库系统。
#### 验收标准
1. WHEN 构建Docker镜像时 THEN 系统应构建为linux/amd64架构以确保OpenEuler兼容性
2. WHEN 构建Docker镜像时 THEN 系统应包含PHP 8.2运行环境和所有必需的扩展
3. WHEN 构建Docker镜像时 THEN 系统应包含Composer依赖和NPM构建产物
4. WHEN 构建Docker镜像时 THEN 系统应包含Nginx Web服务器配置
5. WHEN 构建Docker镜像时 THEN 系统应包含文档转换工具Pandoc
6. WHEN 构建Docker镜像时 THEN 系统应优化镜像大小并使用多阶段构建
### 需求2
**用户故事:** 作为系统管理员我希望通过docker-compose编排所有服务以便一键启动完整的应用栈。
#### 验收标准
1. WHEN 启动服务时 THEN 系统应启动MySQL数据库服务并配置持久化存储
2. WHEN 启动服务时 THEN 系统应启动Redis缓存服务并配置内存限制
3. WHEN 启动服务时 THEN 系统应启动Meilisearch搜索引擎并配置数据持久化
4. WHEN 启动服务时 THEN 系统应启动Laravel应用容器并连接所有依赖服务
5. WHEN 启动服务时 THEN 系统应启动队列处理容器处理后台任务
### 需求3
**用户故事:** 作为系统管理员,我希望配置数据持久化和目录映射,以便数据在容器重启后不丢失。
#### 验收标准
1. WHEN 配置存储时 THEN 系统应将项目代码目录映射到容器内部
2. WHEN 配置存储时 THEN 系统应将上传文档存储目录持久化到宿主机
3. WHEN 配置存储时 THEN 系统应将数据库数据目录持久化到宿主机
4. WHEN 配置存储时 THEN 系统应将搜索引擎数据目录持久化到宿主机
5. WHEN 配置存储时 THEN 系统应将日志目录映射到宿主机便于查看
### 需求4
**用户故事:** 作为系统管理员,我希望配置环境变量和网络,以便服务间能够正确通信。
#### 验收标准
1. WHEN 配置网络时 THEN 系统应创建专用Docker网络供服务间通信
2. WHEN 配置环境时 THEN 系统应设置数据库连接参数指向MySQL容器
3. WHEN 配置环境时 THEN 系统应设置Redis连接参数指向Redis容器
4. WHEN 配置环境时 THEN 系统应设置Meilisearch连接参数指向搜索容器
5. WHEN 配置环境时 THEN 系统应配置应用密钥和调试模式
### 需求5
**用户故事:** 作为系统管理员,我希望实现健康检查和自动重启,以便确保服务的高可用性。
#### 验收标准
1. WHEN 服务运行时 THEN 系统应对Web应用进行HTTP健康检查
2. WHEN 服务运行时 THEN 系统应对数据库进行连接健康检查
3. WHEN 服务运行时 THEN 系统应对Redis进行连接健康检查
4. WHEN 服务运行时 THEN 系统应对Meilisearch进行API健康检查
5. WHEN 服务异常时 THEN 系统应自动重启失败的容器
### 需求6
**用户故事:** 作为系统管理员我希望能够导出和导入Docker镜像以便在离线环境中部署。
#### 验收标准
1. WHEN 导出镜像时 THEN 系统应将构建好的amd64架构镜像打包为tar文件
2. WHEN 导入镜像时 THEN 系统应能够从tar文件加载镜像到OpenEuler服务器的Docker
3. WHEN 传输镜像时 THEN 系统应支持压缩以减少文件大小
4. WHEN 验证镜像时 THEN 系统应提供镜像完整性和架构兼容性检查方法
5. WHEN 部署镜像时 THEN 系统应提供详细的OpenEuler部署文档和脚本
### 需求7
**用户故事:** 作为开发人员我希望有开发环境的Docker配置以便本地开发和测试。
#### 验收标准
1. WHEN 开发环境启动时 THEN 系统应启用代码热重载功能
2. WHEN 开发环境启动时 THEN 系统应启用调试模式和详细日志
3. WHEN 开发环境启动时 THEN 系统应映射源代码目录支持实时编辑
4. WHEN 开发环境启动时 THEN 系统应暴露所有必要的端口供调试
5. WHEN 开发环境启动时 THEN 系统应包含开发工具和测试数据

View File

@@ -0,0 +1,157 @@
# Docker部署实施计划
- [x] 1. 创建Docker镜像构建配置
- 创建多阶段Dockerfile优化镜像大小
- 配置PHP 8.2-fpm基础环境和必需扩展
- 安装Composer依赖和NPM构建工具
- 集成Nginx Web服务器配置
- 安装Pandoc文档转换工具
- 确保构建为linux/amd64架构
- _需求: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6_
- [ ]* 1.1 编写属性测试验证镜像架构
- **属性1: 镜像架构一致性**
- **验证: 需求 1.1**
- [ ]* 1.2 编写属性测试验证PHP环境
- **属性2: PHP环境完整性**
- **验证: 需求 1.2**
- [ ]* 1.3 编写属性测试验证构建产物
- **属性3: 构建产物存在性**
- **验证: 需求 1.3**
- [x] 2. 配置生产环境容器编排
- 创建生产环境docker-compose.yml文件
- 配置MySQL数据库服务和持久化存储
- 配置Redis缓存服务和内存限制
- 配置Meilisearch搜索引擎和数据持久化
- 配置Laravel应用容器和队列处理容器
- 设置服务间依赖关系和启动顺序
- _需求: 2.1, 2.2, 2.3, 2.4, 2.5_
- [ ]* 2.1 编写属性测试验证服务启动
- **属性4: 服务启动一致性**
- **验证: 需求 2.1, 2.2, 2.3, 2.4, 2.5**
- [x] 3. 实现数据持久化和目录映射
- 配置项目代码目录映射到容器
- 设置上传文档存储目录持久化
- 配置数据库数据目录持久化
- 设置搜索引擎数据目录持久化
- 配置日志目录映射到宿主机
- _需求: 3.1, 3.2, 3.3, 3.4, 3.5_
- [ ]* 3.1 编写属性测试验证数据持久化
- **属性5: 数据持久化保证**
- **验证: 需求 3.2, 3.3, 3.4**
- [x] 4. 配置环境变量和网络设置
- 创建专用Docker网络配置
- 设置数据库连接环境变量
- 配置Redis连接参数
- 设置Meilisearch连接参数
- 配置应用密钥和运行模式
- _需求: 4.1, 4.2, 4.3, 4.4, 4.5_
- [ ]* 4.1 编写属性测试验证服务连接
- **属性6: 服务连接性**
- **验证: 需求 4.2, 4.3, 4.4**
- [x] 5. 实现健康检查和自动重启机制
- 配置Web应用HTTP健康检查
- 实现数据库连接健康检查
- 配置Redis连接健康检查
- 设置Meilisearch API健康检查
- 配置容器自动重启策略
- _需求: 5.1, 5.2, 5.3, 5.4, 5.5_
- [ ]* 5.1 编写属性测试验证健康检查
- **属性7: 健康检查响应性**
- **验证: 需求 5.1, 5.2, 5.3, 5.4**
- [ ]* 5.2 编写属性测试验证自动重启
- **属性8: 容器自愈能力**
- **验证: 需求 5.5**
- [x] 6. 创建镜像打包和部署脚本
- 编写Docker镜像导出脚本
- 实现镜像压缩和完整性检查
- 创建OpenEuler服务器部署脚本
- 编写镜像导入和验证脚本
- 生成详细的部署文档
- _需求: 6.1, 6.2, 6.3, 6.4, 6.5_
- [ ]* 6.1 编写属性测试验证镜像导出
- **属性9: 镜像导出完整性**
- **验证: 需求 6.1**
- [ ]* 6.2 编写属性测试验证镜像导入
- **属性10: 镜像导入兼容性**
- **验证: 需求 6.2**
- [ ]* 6.3 编写属性测试验证压缩效率
- **属性11: 压缩效率**
- **验证: 需求 6.3**
- [ ]* 6.4 编写属性测试验证完整性检查
- **属性12: 完整性验证**
- **验证: 需求 6.4**
- [ ] 7. 配置开发环境支持
- 创建开发环境docker-compose.dev.yml
- 配置代码热重载功能
- 启用调试模式和详细日志
- 设置开发工具和测试数据
- 配置端口映射供调试使用
- _需求: 7.1, 7.2, 7.3, 7.4, 7.5_
- [ ]* 7.1 编写属性测试验证热重载
- **属性13: 开发环境热重载**
- **验证: 需求 7.1**
- [ ] 8. 创建Nginx配置文件
- 编写生产环境Nginx配置
- 配置PHP-FPM upstream
- 设置静态文件服务
- 配置日志格式和路径
- 优化性能参数
- _需求: 1.4_
- [ ] 9. 编写环境配置模板
- 创建生产环境.env模板
- 创建开发环境.env模板
- 配置数据库连接参数
- 设置缓存和搜索服务配置
- 添加配置说明文档
- _需求: 4.2, 4.3, 4.4, 4.5_
- [ ] 10. 实现启动和管理脚本
- 编写一键启动脚本
- 创建服务状态检查脚本
- 实现日志查看脚本
- 编写数据备份脚本
- 创建清理和重置脚本
- _需求: 2.1, 2.2, 2.3, 2.4, 2.5_
- [ ] 11. 第一次检查点 - 确保所有测试通过
- 确保所有测试通过,如有问题请询问用户
- [ ] 12. 创建部署文档
- 编写OpenEuler服务器环境准备指南
- 创建Docker安装和配置文档
- 编写应用部署步骤说明
- 添加故障排除指南
- 创建运维管理文档
- _需求: 6.5_
- [ ] 13. 优化和安全配置
- 配置容器安全策略
- 设置资源限制和配额
- 实现日志轮转和清理
- 配置网络安全规则
- 添加监控和告警配置
- _需求: 2.2, 5.1, 5.2, 5.3, 5.4_
- [ ] 14. 最终检查点 - 确保所有测试通过
- 确保所有测试通过,如有问题请询问用户

View File

@@ -0,0 +1,300 @@
# 设计文档 - Swoole 集成
## 概述
本设计文档详细描述了将 Laravel 知识库系统从传统的 PHP-FPM + Nginx 架构迁移到基于 Swoole 的高性能异步架构的技术方案。通过集成 Laravel Octane 和 Swoole系统将获得显著的性能提升和更好的并发处理能力。
## 架构
### 当前架构 vs 目标架构
**当前架构:**
```
请求 → Nginx → PHP-FPM → Laravel 应用
```
**目标架构:**
```
请求 → Swoole HTTP Server → Laravel 应用 (内存驻留)
```
### 系统架构图
```mermaid
graph TB
subgraph "Docker 容器"
subgraph "应用容器 (新架构)"
A[Swoole HTTP Server] --> B[Laravel Octane]
B --> C[Laravel 应用]
D[队列工作进程] --> C
E[定时任务] --> C
end
subgraph "数据层"
F[MySQL 容器]
G[Redis 容器]
H[Meilisearch 容器]
end
end
I[客户端请求] --> A
C --> F
C --> G
C --> H
style A fill:#e1f5fe
style B fill:#f3e5f5
style C fill:#e8f5e8
```
## 组件和接口
### 简化的集成方案
**核心原则**: 最小化代码修改,最大化利用 Laravel Octane 的默认配置和行为。
### 1. Laravel Octane 包
**使用现有组件:**
- 直接使用 `laravel/octane` 包,无需自定义接口
- 使用默认的 Swoole 配置,仅调整必要参数
- 利用 Octane 的内置命令和服务管理
**配置方式:**
```php
// config/octane.php (Laravel Octane 默认配置文件)
return [
'server' => 'swoole',
'host' => env('OCTANE_HOST', '0.0.0.0'),
'port' => env('OCTANE_PORT', 8000),
'workers' => env('OCTANE_WORKERS', 4),
'task_workers' => env('OCTANE_TASK_WORKERS', 2),
'max_requests' => env('OCTANE_MAX_REQUESTS', 500),
];
```
### 2. 现有代码兼容性
**无需修改的组件:**
- 现有的 Controllers、Models、Services 保持不变
- 队列处理逻辑无需修改
- 数据库连接和缓存逻辑保持原样
- Filament 管理界面无需调整
**需要注意的事项:**
- 避免使用全局变量和静态变量
- 确保单例服务的正确重置
- 检查文件上传和会话处理
## 数据模型
### 简化的配置模型
**使用环境变量配置 (无需新建模型类):**
```bash
# .env 文件中的 Swoole 相关配置
OCTANE_SERVER=swoole
OCTANE_HOST=0.0.0.0
OCTANE_PORT=8000
OCTANE_WORKERS=4
OCTANE_TASK_WORKERS=2
OCTANE_MAX_REQUESTS=500
OCTANE_WATCH=false
```
**现有模型保持不变:**
- Document 模型
- User 模型
- Group 模型
- DownloadLog 模型
所有现有的 Eloquent 模型和数据库操作保持完全不变Swoole 集成是透明的。
## 正确性属性
现在我需要使用 prework 工具来分析验收标准的可测试性:
*A property is a characteristic or behavior that should hold true across all valid executions of a system-essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
### 服务器启动和运行属性
**属性 1: Swoole 服务器启动一致性**
*对于任何* 有效的配置参数,当系统启动时,应该能够检测到 Swoole HTTP 服务器进程在运行并监听指定端口
**验证: 需求 1.1**
**属性 2: HTTP 请求处理一致性**
*对于任何* 有效的 HTTP 请求Swoole 服务器应该能够处理请求并返回适当的响应
**验证: 需求 1.2**
**属性 3: 内存驻留持久性**
*对于任何* 运行中的 Swoole 服务器,应用进程应该保持内存驻留状态,不会在请求间重新初始化
**验证: 需求 1.3**
### 命令行接口属性
**属性 4: Artisan 命令执行一致性**
*对于任何* 有效的 Octane 命令参数php artisan octane 命令应该正确执行相应的服务器操作
**验证: 需求 2.1, 2.4, 2.5**
**属性 5: 端口配置正确性**
*对于任何* 指定的有效端口号Swoole 服务器应该在该端口上启动并接受连接
**验证: 需求 2.2**
**属性 6: 工作进程数量一致性**
*对于任何* 指定的工作进程数量Swoole 服务器应该创建相应数量的工作进程
**验证: 需求 2.3**
### Docker 集成属性
**属性 7: Swoole 扩展安装完整性**
*对于任何* 构建的 Docker 镜像,应该包含正确安装和配置的 Swoole PHP 扩展
**验证: 需求 3.1**
**属性 8: 容器进程替换正确性**
*对于任何* 启动的应用容器,应该运行 Swoole 进程而不是 PHP-FPM 或 Nginx 进程
**验证: 需求 3.2**
**属性 9: 容器端口暴露正确性**
*对于任何* 配置的 Swoole 服务端口,容器应该正确暴露该端口供外部访问
**验证: 需求 3.3**
### 队列处理属性
**属性 10: 队列处理兼容性**
*对于任何* 队列任务,在 Swoole 环境下应该能够正常处理,与传统环境行为一致
**验证: 需求 4.1, 4.2**
**属性 11: 队列监听器自动启动**
*对于任何* 系统启动,队列监听器应该自动启动并保持运行状态
**验证: 需求 4.3**
**属性 12: 队列错误处理一致性**
*对于任何* 失败的队列任务,系统应该记录错误信息并按配置进行重试
**验证: 需求 4.4**
### 系统稳定性属性
**属性 13: 高并发处理稳定性**
*对于任何* 高并发请求负载Swoole 服务器应该保持稳定运行而不崩溃
**验证: 需求 5.1**
**属性 14: 内存使用稳定性**
*对于任何* 长时间运行的系统,内存使用应该保持在合理范围内,不出现持续增长
**验证: 需求 5.2**
**属性 15: 健康检查响应一致性**
*对于任何* 健康检查请求,系统应该返回正确的健康状态信息
**验证: 需求 5.4**
### 部署和配置属性
**属性 16: 部署脚本镜像构建正确性**
*对于任何* 执行的部署脚本,应该生成包含 Swoole 配置的有效 Docker 镜像
**验证: 需求 6.1**
**属性 17: 配置文件更新正确性**
*对于任何* 更新的 docker-compose 配置,应该正确移除 Nginx 依赖并配置 Swoole 服务
**验证: 需求 6.2**
**属性 18: 环境变量配置生效性**
*对于任何* 设置的 Swoole 相关环境变量,应该在系统运行时正确生效
**验证: 需求 6.3**
## 错误处理
### 简化的错误处理策略
**利用 Laravel Octane 内置错误处理:**
- 使用 Octane 的默认异常处理机制
- 利用 Laravel 现有的日志系统
- 保持现有的错误报告和监控
**最小化自定义错误处理:**
```php
// 仅在必要时添加 Swoole 特定的错误处理
// 在 app/Exceptions/Handler.php 中添加
public function register()
{
$this->reportable(function (Throwable $e) {
if (app()->bound('octane')) {
// 记录 Swoole 相关错误
Log::channel('swoole')->error('Swoole error: ' . $e->getMessage());
}
});
}
```
**依赖现有机制:**
- 使用现有的队列失败处理
- 保持现有的数据库连接错误处理
- 利用现有的缓存错误恢复机制
## 测试策略
### 双重测试方法
本项目将采用单元测试和基于属性的测试相结合的方法:
- **单元测试**: 验证具体的功能实现和边界条件
- **基于属性的测试**: 验证系统在各种输入下的通用属性
### 单元测试覆盖
单元测试将覆盖:
- Octane 配置加载和验证
- Swoole 服务器启动和停止
- 命令行接口功能
- 错误处理逻辑
- 队列集成功能
### 基于属性的测试
将使用 **Pest** 作为基于属性的测试框架,配置每个属性测试运行最少 100 次迭代。
每个基于属性的测试必须:
- 使用注释明确标识对应的设计文档属性
- 使用格式: `**Feature: swoole-integration, Property {number}: {property_text}**`
- 生成合适的测试数据来验证属性
- 验证系统在各种输入条件下的行为一致性
### 测试环境配置
```php
// 测试配置示例
return [
'octane' => [
'server' => 'swoole',
'host' => '127.0.0.1',
'port' => 8000,
'workers' => 2,
'task_workers' => 1,
'max_requests' => 100,
],
'swoole' => [
'options' => [
'log_file' => storage_path('logs/swoole.log'),
'log_level' => SWOOLE_LOG_INFO,
],
],
];
```
### 性能测试
除了功能测试外,还需要进行性能测试:
- 并发请求处理能力测试
- 内存使用监控测试
- 响应时间分布测试
- 长时间运行稳定性测试
### 集成测试
集成测试将验证:
- Docker 容器间的通信
- 数据库连接池管理
- 缓存系统集成
- 搜索服务集成
- 队列系统集成
这些测试确保 Swoole 集成不会破坏现有的系统功能,同时提供预期的性能改进。

View File

@@ -0,0 +1,87 @@
# 需求文档 - Swoole 集成
## 介绍
本规范旨在将现有的 Laravel 知识库系统从传统的 PHP-FPM + Nginx 架构迁移到使用 Swoole 的高性能异步架构。Swoole 是一个高性能的 PHP 异步网络通信引擎,能够显著提升应用性能和并发处理能力。
## 术语表
- **Swoole**: 高性能的 PHP 异步网络通信引擎
- **Laravel_Octane**: Laravel 官方的高性能应用服务器包,支持 Swoole 和 RoadRunner
- **PHP_Artisan**: Laravel 的命令行工具
- **Docker_Container**: 应用程序的容器化运行环境
- **Hot_Reload**: 代码变更时自动重启服务的功能
## 需求
### 需求 1
**用户故事:** 作为系统管理员,我希望使用 Swoole 替代传统的 PHP-FPM 运行方式,以便获得更高的性能和并发处理能力。
#### 验收标准
1. WHEN 系统启动时 THEN Laravel_Octane SHALL 使用 Swoole 驱动启动 HTTP 服务器
2. WHEN 接收 HTTP 请求时 THEN Swoole_Server SHALL 处理请求并返回响应
3. WHEN 系统运行时 THEN Swoole_Server SHALL 维持长连接和内存驻留
4. WHEN 配置变更时 THEN 系统 SHALL 支持热重载功能
5. WHEN 监控系统性能时 THEN Swoole_Server SHALL 提供性能指标接口
### 需求 2
**用户故事:** 作为开发人员,我希望通过 php artisan 命令启动 Swoole 服务,以便保持与 Laravel 生态系统的一致性。
#### 验收标准
1. WHEN 执行启动命令时 THEN php artisan octane:start SHALL 启动 Swoole 服务器
2. WHEN 指定端口参数时 THEN 系统 SHALL 在指定端口上启动服务
3. WHEN 指定工作进程数时 THEN Swoole_Server SHALL 创建指定数量的工作进程
4. WHEN 执行停止命令时 THEN php artisan octane:stop SHALL 优雅关闭服务器
5. WHEN 执行重启命令时 THEN php artisan octane:restart SHALL 重启服务器
### 需求 3
**用户故事:** 作为运维人员,我希望更新 Docker 镜像配置,以便支持 Swoole 运行环境和相关依赖。
#### 验收标准
1. WHEN 构建 Docker 镜像时 THEN 系统 SHALL 安装 Swoole PHP 扩展
2. WHEN 容器启动时 THEN 系统 SHALL 使用 Swoole 替代 PHP-FPM 和 Nginx
3. WHEN 配置容器时 THEN 系统 SHALL 暴露 Swoole 服务端口
4. WHEN 容器运行时 THEN 系统 SHALL 支持进程管理和监控
5. WHEN 容器重启时 THEN 系统 SHALL 自动恢复 Swoole 服务
### 需求 4
**用户故事:** 作为系统架构师,我希望保持现有的队列处理和后台任务功能,以便确保系统功能完整性。
#### 验收标准
1. WHEN Swoole 服务运行时 THEN 队列处理器 SHALL 继续正常工作
2. WHEN 处理文档转换任务时 THEN 后台队列 SHALL 正常执行任务
3. WHEN 系统启动时 THEN 队列监听器 SHALL 自动启动
4. WHEN 队列任务失败时 THEN 系统 SHALL 记录错误并支持重试
5. WHEN 监控队列状态时 THEN 系统 SHALL 提供队列健康检查接口
### 需求 5
**用户故事:** 作为质量保证工程师,我希望验证 Swoole 集成后的系统稳定性,以便确保生产环境的可靠性。
#### 验收标准
1. WHEN 系统负载测试时 THEN Swoole_Server SHALL 处理高并发请求而不崩溃
2. WHEN 长时间运行时 THEN 系统 SHALL 保持内存使用稳定
3. WHEN 发生异常时 THEN Swoole_Server SHALL 记录详细错误日志
4. WHEN 进行健康检查时 THEN 系统 SHALL 响应健康检查请求
5. WHEN 系统重启时 THEN 所有服务 SHALL 在合理时间内恢复正常
### 需求 6
**用户故事:** 作为部署工程师,我希望更新部署脚本和配置,以便支持新的 Swoole 架构部署。
#### 验收标准
1. WHEN 执行部署脚本时 THEN 系统 SHALL 构建包含 Swoole 的新镜像
2. WHEN 更新 docker-compose 配置时 THEN 系统 SHALL 移除 Nginx 容器依赖
3. WHEN 配置环境变量时 THEN 系统 SHALL 支持 Swoole 相关配置参数
4. WHEN 验证部署时 THEN 系统 SHALL 确认 Swoole 服务正常运行
5. WHEN 回滚部署时 THEN 系统 SHALL 支持回退到之前的架构

View File

@@ -0,0 +1,185 @@
# 实施计划 - Swoole 集成
## 概述
本实施计划将现有的 Laravel 知识库系统从 PHP-FPM + Nginx 架构迁移到基于 Swoole 的高性能架构。采用最小化代码修改的策略,主要通过安装 Laravel Octane 包和更新配置来实现。
## 任务列表
- [x] 1. 安装和配置 Laravel Octane
- 安装 laravel/octane 包
- 发布 Octane 配置文件
- 配置 Swoole 相关环境变量
- _需求: 1.1, 2.1_
- [ ]* 1.1 编写 Octane 启动测试
- **属性 1: Swoole 服务器启动一致性**
- **验证: 需求 1.1**
- [ ]* 1.2 编写命令行接口测试
- **属性 4: Artisan 命令执行一致性**
- **验证: 需求 2.1, 2.4, 2.5**
- [x] 2. 更新 Composer 依赖
- 添加 laravel/octane 到 composer.json
- 安装 Swoole PHP 扩展依赖
- 更新 composer 脚本以支持 Swoole 启动
- _需求: 1.1, 2.1_
- [ ]* 2.1 编写依赖安装验证测试
- **属性 7: Swoole 扩展安装完整性**
- **验证: 需求 3.1**
- [ ] 3. 更新 Docker 配置
- 修改 Dockerfile 安装 Swoole 扩展
- 移除 Nginx 和 PHP-FPM 相关配置
- 更新容器启动命令使用 Octane
- 调整端口映射配置
- _需求: 3.1, 3.2, 3.3_
- [ ]* 3.1 编写 Docker 镜像验证测试
- **属性 8: 容器进程替换正确性**
- **验证: 需求 3.2**
- [ ]* 3.2 编写端口配置测试
- **属性 5: 端口配置正确性**
- **属性 9: 容器端口暴露正确性**
- **验证: 需求 2.2, 3.3**
- [x] 4. 更新 docker-compose.yml
- 移除 Nginx 服务配置
- 更新应用服务使用 Swoole 端口
- 调整服务依赖关系
- 更新健康检查配置
- _需求: 3.2, 6.2_
- [ ]* 4.1 编写 docker-compose 配置验证测试
- **属性 17: 配置文件更新正确性**
- **验证: 需求 6.2**
- [x] 5. 配置环境变量
- 更新 .env 文件添加 Octane 配置
- 设置 Swoole 工作进程数量
- 配置最大请求数和其他性能参数
- _需求: 1.4, 2.2, 2.3, 6.3_
- [ ]* 5.1 编写环境变量配置测试
- **属性 6: 工作进程数量一致性**
- **属性 18: 环境变量配置生效性**
- **验证: 需求 2.3, 6.3**
- [x] 6. 验证队列系统兼容性
- 测试现有队列任务在 Swoole 环境下的运行
- 验证文档转换队列功能
- 确认队列监听器自动启动
- _需求: 4.1, 4.2, 4.3_
- [ ]* 6.1 编写队列兼容性测试
- **属性 10: 队列处理兼容性**
- **属性 11: 队列监听器自动启动**
- **验证: 需求 4.1, 4.2, 4.3**
- [ ]* 6.2 编写队列错误处理测试
- **属性 12: 队列错误处理一致性**
- **验证: 需求 4.4**
- [ ] 7. 更新部署脚本
- 修改 Docker 镜像构建脚本
- 更新部署验证脚本
- 调整健康检查脚本
- _需求: 6.1, 6.4_
- [ ]* 7.1 编写部署脚本验证测试
- **属性 16: 部署脚本镜像构建正确性**
- **验证: 需求 6.1**
- [ ] 8. 第一次检查点 - 确保所有测试通过
- 确保所有测试通过,如有问题请询问用户
- [ ] 9. 性能和稳定性测试
- 配置负载测试环境
- 执行并发请求测试
- 监控内存使用情况
- 验证长时间运行稳定性
- _需求: 5.1, 5.2_
- [ ]* 9.1 编写性能测试
- **属性 13: 高并发处理稳定性**
- **属性 14: 内存使用稳定性**
- **验证: 需求 5.1, 5.2**
- [ ] 10. 健康检查和监控
- 实现 Swoole 服务健康检查接口
- 配置系统监控和告警
- 验证错误日志记录功能
- _需求: 5.3, 5.4_
- [ ]* 10.1 编写健康检查测试
- **属性 15: 健康检查响应一致性**
- **验证: 需求 5.4**
- [ ] 11. 文档更新
- 更新部署指南
- 更新运维文档
- 创建 Swoole 配置说明
- _需求: 6.4_
- [ ] 12. 回滚机制准备
- 准备回滚到原架构的脚本
- 测试回滚流程
- 文档化回滚步骤
- _需求: 6.5_
- [ ]* 12.1 编写回滚功能测试
- 验证回滚机制的正确性
- _需求: 6.5_
- [ ] 13. 最终检查点 - 确保所有测试通过
- 确保所有测试通过,如有问题请询问用户
## 实施注意事项
### 最小化代码修改原则
1. **保持现有代码不变**: 所有 Controllers、Models、Services 保持原样
2. **利用 Laravel Octane 默认配置**: 避免自定义复杂的配置逻辑
3. **渐进式迁移**: 先在开发环境验证,再部署到生产环境
4. **保留回滚能力**: 确保可以快速回退到原有架构
### 关键配置参数
```bash
# 核心 Swoole 配置
OCTANE_SERVER=swoole
OCTANE_HOST=0.0.0.0
OCTANE_PORT=8000
OCTANE_WORKERS=4
OCTANE_TASK_WORKERS=2
OCTANE_MAX_REQUESTS=500
```
### 验证检查清单
- [ ] Swoole 扩展正确安装
- [ ] Octane 命令正常工作
- [ ] HTTP 请求正确处理
- [ ] 队列任务正常执行
- [ ] 数据库连接稳定
- [ ] 缓存系统正常
- [ ] 搜索功能可用
- [ ] 文件上传下载正常
- [ ] 性能指标符合预期
### 性能预期
- **响应时间**: 比原架构提升 30-50%
- **并发处理**: 支持更高的并发连接数
- **内存使用**: 更高效的内存利用
- **CPU 使用**: 更好的 CPU 利用率
### 风险缓解
1. **充分测试**: 在开发环境完整测试所有功能
2. **分阶段部署**: 先部署到测试环境,再到生产环境
3. **监控告警**: 部署后密切监控系统指标
4. **快速回滚**: 准备好快速回滚方案

View File

@@ -1,484 +0,0 @@
# 设计文档
## 概述
本设计文档描述了知识库系统UI界面美化的技术实现方案。通过集成Alpine.js和Tailwind CSS我们将为现有的Filament界面添加现代化的视觉效果和流畅的交互动画提升用户体验。
设计遵循以下原则:
- **渐进增强**:在不破坏现有功能的基础上添加视觉增强
- **性能优先**使用CSS动画和轻量级JavaScript避免性能问题
- **响应式设计**:确保在所有设备上都有良好的显示效果
- **无障碍访问**遵循WCAG 2.1标准,支持键盘导航和屏幕阅读器
- **主题一致性**与Filament的设计语言保持一致
## 架构
### 技术栈
- **Alpine.js 3.x**:用于添加交互行为和状态管理
- **Tailwind CSS 3.x**:用于样式设计和响应式布局
- **Filament 3.x**:现有的管理面板框架
- **Laravel Blade**:模板引擎
- **CSS Transitions/Animations**:用于动画效果
### 组件层次
```
┌─────────────────────────────────────┐
│ Blade Templates │
│ (搜索页面、预览模态框、文档列表) │
└──────────────┬──────────────────────┘
┌──────────────┴──────────────────────┐
│ Alpine.js Components │
│ (交互逻辑、状态管理、事件处理) │
└──────────────┬──────────────────────┘
┌──────────────┴──────────────────────┐
│ Tailwind CSS Classes │
│ (样式、动画、响应式布局) │
└─────────────────────────────────────┘
```
### 文件结构
```
resources/
├── views/
│ ├── filament/
│ │ ├── pages/
│ │ │ ├── search-page.blade.php (增强版搜索页面)
│ │ │ └── document-preview-modal.blade.php (增强版预览模态框)
│ │ └── resources/
│ │ └── document/
│ │ └── card.blade.php (新增:文档卡片组件)
│ └── components/
│ ├── ui/
│ │ ├── button.blade.php (新增:增强按钮组件)
│ │ ├── input.blade.php (新增:增强输入框组件)
│ │ ├── card.blade.php (新增:卡片组件)
│ │ └── badge.blade.php (新增:徽章组件)
│ └── animations/
│ ├── fade-in.blade.php (新增:淡入动画)
│ └── slide-in.blade.php (新增:滑入动画)
├── css/
│ └── custom/
│ ├── animations.css (新增:自定义动画)
│ ├── components.css (新增:组件样式)
│ └── utilities.css (新增:工具类)
└── js/
└── alpine/
├── search.js (新增:搜索页面逻辑)
├── preview.js (新增:预览模态框逻辑)
└── filters.js (新增:筛选器逻辑)
```
## 组件和接口
### 1. 搜索页面组件
**职责**:提供美化的搜索界面和结果展示
**Alpine.js数据结构**
```javascript
{
// 搜索状态
searchQuery: '',
isSearching: false,
hasSearched: false,
// 筛选器状态
filters: {
type: null,
groupId: null
},
showFilters: false,
// 结果状态
results: [],
resultCount: 0,
// UI状态
viewMode: 'grid', // 'grid' 或 'list'
sortBy: 'created_at',
sortOrder: 'desc'
}
```
**方法**
- `search()`:执行搜索
- `clearSearch()`:清空搜索
- `toggleFilters()`:切换筛选器显示
- `applyFilter(key, value)`:应用筛选条件
- `removeFilter(key)`:移除筛选条件
- `toggleViewMode()`:切换视图模式
- `sortResults(field)`:排序结果
### 2. 文档卡片组件
**职责**:以卡片形式展示文档信息
**Props**
- `document`:文档对象
- `showActions`:是否显示操作按钮
- `compact`:是否使用紧凑模式
**样式类**
- `document-card`:基础卡片样式
- `document-card-hover`:悬停效果
- `document-card-compact`:紧凑模式
### 3. 预览模态框组件
**职责**:提供优雅的文档预览体验
**Alpine.js数据结构**
```javascript
{
// 模态框状态
isOpen: false,
isLoading: true,
// 内容状态
content: null,
error: null,
// UI状态
scrollProgress: 0,
showScrollTop: false
}
```
**方法**
- `open()`:打开模态框
- `close()`:关闭模态框
- `loadContent()`:加载内容
- `scrollToTop()`:滚动到顶部
- `updateScrollProgress()`:更新滚动进度
### 4. 增强按钮组件
**职责**:提供统一的按钮样式和交互效果
**Props**
- `variant`按钮变体primary, secondary, danger等
- `size`按钮大小sm, md, lg
- `loading`:加载状态
- `disabled`:禁用状态
- `icon`:图标名称
**样式类**
- `btn-enhanced`:基础增强样式
- `btn-loading`:加载状态
- `btn-pulse`:脉冲效果
### 5. 增强输入框组件
**职责**:提供友好的输入交互效果
**Props**
- `label`:标签文本
- `placeholder`:占位符
- `maxLength`:最大长度
- `showCounter`:显示字符计数
- `validation`:验证规则
**Alpine.js数据结构**
```javascript
{
value: '',
isFocused: false,
hasError: false,
errorMessage: '',
charCount: 0
}
```
## 数据模型
本功能主要涉及UI增强不需要修改现有数据模型。所有数据仍使用现有的Document、Group等模型。
## 正确性属性
*属性是一个特征或行为,应该在系统的所有有效执行中保持为真。属性作为人类可读规范和机器可验证正确性保证之间的桥梁。*
### 属性 1文档类型徽章颜色一致性
*对于任何*文档,当显示为卡片时,全局文档应该使用绿色徽章,专用文档应该使用蓝色徽章
**验证需求2.3**
### 属性 2ARIA标签完整性
*对于任何*可交互元素该元素应该包含适当的ARIA标签或role属性
**验证需求10.2**
### 属性 3颜色对比度合规性
*对于任何*文本元素其前景色和背景色的对比度应该至少为4.5:1普通文本或3:1大文本
**验证需求10.5**
## 错误处理
### 1. 动画性能问题
**场景**:在低性能设备上动画可能导致卡顿
**处理策略**
- 检测设备性能,在低性能设备上禁用复杂动画
- 使用CSS `will-change`属性优化动画性能
- 遵循用户的`prefers-reduced-motion`设置
### 2. Alpine.js加载失败
**场景**CDN不可用或网络问题导致Alpine.js加载失败
**处理策略**
- 使用本地备份的Alpine.js文件
- 确保核心功能在没有JavaScript的情况下仍可用
- 显示友好的降级界面
### 3. 深色模式切换问题
**场景**:主题切换时可能出现闪烁
**处理策略**
- 在页面加载前检测主题偏好
- 使用CSS变量实现平滑过渡
- 将主题偏好保存到localStorage
### 4. 响应式布局问题
**场景**:某些设备上布局可能错乱
**处理策略**
- 使用Tailwind的响应式断点
- 在多种设备上测试
- 提供最小宽度限制
## 测试策略
### 单元测试
使用PHPUnit和Pest进行后端测试
1. **组件渲染测试**
- 测试Blade组件是否正确渲染
- 测试props是否正确传递
- 测试条件渲染逻辑
2. **样式类测试**
- 测试CSS类是否正确应用
- 测试响应式类是否存在
### 前端测试
使用Jest和Testing Library进行前端测试
1. **Alpine.js组件测试**
- 测试数据绑定
- 测试事件处理
- 测试状态变化
2. **交互测试**
- 测试按钮点击
- 测试表单输入
- 测试键盘导航
3. **视觉回归测试**
- 使用Percy或Chromatic进行截图对比
- 测试不同主题下的显示效果
- 测试不同屏幕尺寸下的布局
### 无障碍测试
1. **自动化测试**
- 使用axe-core进行无障碍扫描
- 测试ARIA标签
- 测试键盘导航
2. **手动测试**
- 使用屏幕阅读器测试
- 测试键盘完整导航
- 测试颜色对比度
### 性能测试
1. **动画性能**
- 使用Chrome DevTools测试FPS
- 测试动画是否触发重排
- 测试低性能设备表现
2. **加载性能**
- 测试CSS和JS文件大小
- 测试首次内容绘制时间
- 测试交互就绪时间
### 浏览器兼容性测试
测试以下浏览器:
- Chrome最新版本和前一版本
- Firefox最新版本和前一版本
- Safari最新版本
- Edge最新版本
- 移动浏览器iOS Safari、Chrome Mobile
## 实现细节
### 1. Tailwind CSS配置
扩展Tailwind配置以支持自定义动画和颜色
```javascript
// tailwind.config.js
module.exports = {
theme: {
extend: {
animation: {
'fade-in': 'fadeIn 0.3s ease-in-out',
'slide-in': 'slideIn 0.3s ease-out',
'scale-in': 'scaleIn 0.2s ease-out',
'shake': 'shake 0.5s ease-in-out',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideIn: {
'0%': { transform: 'translateY(-10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
scaleIn: {
'0%': { transform: 'scale(0.95)', opacity: '0' },
'100%': { transform: 'scale(1)', opacity: '1' },
},
shake: {
'0%, 100%': { transform: 'translateX(0)' },
'25%': { transform: 'translateX(-10px)' },
'75%': { transform: 'translateX(10px)' },
},
},
},
},
}
```
### 2. Alpine.js集成
在Blade模板中集成Alpine.js
```html
<div x-data="searchComponent()" x-init="init()">
<!-- 组件内容 -->
</div>
<script>
function searchComponent() {
return {
// 数据和方法
}
}
</script>
```
### 3. 自定义CSS动画
创建可复用的动画类:
```css
/* animations.css */
.animate-stagger > * {
animation: fadeIn 0.3s ease-in-out;
animation-fill-mode: both;
}
.animate-stagger > *:nth-child(1) { animation-delay: 0.05s; }
.animate-stagger > *:nth-child(2) { animation-delay: 0.1s; }
.animate-stagger > *:nth-child(3) { animation-delay: 0.15s; }
/* ... */
.hover-lift {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.hover-lift:hover {
transform: translateY(-4px);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
}
```
### 4. 深色模式支持
使用Tailwind的深色模式类
```html
<div class="bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100">
<!-- 内容 -->
</div>
```
### 5. 响应式设计
使用Tailwind的响应式前缀
```html
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- 卡片 -->
</div>
```
### 6. 无障碍支持
添加适当的ARIA属性
```html
<button
aria-label="搜索文档"
aria-pressed="false"
role="button"
tabindex="0"
>
搜索
</button>
```
### 7. 性能优化
- 使用CSS `contain`属性隔离动画
- 使用`will-change`提示浏览器优化
- 延迟加载非关键动画
- 使用`requestAnimationFrame`优化JavaScript动画
```css
.animated-card {
contain: layout style paint;
will-change: transform;
}
```
## 部署考虑
### 1. 资源打包
- 使用Laravel Mix或Vite打包CSS和JS
- 启用CSS和JS压缩
- 使用版本控制避免缓存问题
### 2. CDN配置
- 考虑使用CDN加速Alpine.js加载
- 提供本地备份文件
### 3. 浏览器支持
- 添加必要的polyfills
- 提供降级方案
### 4. 监控
- 监控动画性能
- 收集用户反馈
- 跟踪错误日志

View File

@@ -1,138 +0,0 @@
# 需求文档
## 简介
本文档定义了知识库系统UI界面美化的需求。系统当前使用Filament框架构建需要通过Alpine.js和Tailwind CSS增强用户界面的视觉效果和交互体验使界面更加现代化、美观和易用。
## 术语表
- **知识库系统Knowledge Base System**基于Laravel和Filament构建的文档管理系统
- **Alpine.js**轻量级JavaScript框架用于添加交互行为
- **Tailwind CSS**实用优先的CSS框架用于样式设计
- **Filament**Laravel的管理面板框架
- **搜索页面Search Page**:用户搜索文档的界面
- **文档预览Document Preview**:在模态框中显示文档内容的功能
- **文档列表Document List**:显示文档资源的表格界面
- **响应式设计Responsive Design**:适配不同屏幕尺寸的界面设计
## 需求
### 需求 1
**用户故事:** 作为用户,我希望搜索页面具有现代化的视觉设计,以便获得更好的使用体验。
#### 验收标准
1. WHEN 用户访问搜索页面 THEN 系统应当显示具有渐变背景和阴影效果的搜索表单卡片
2. WHEN 用户将鼠标悬停在搜索按钮上 THEN 系统应当显示平滑的过渡动画效果
3. WHEN 用户输入搜索关键词 THEN 系统应当在输入框获得焦点时显示高亮边框效果
4. WHEN 搜索表单加载完成 THEN 系统应当显示淡入动画效果
5. WHEN 用户在移动设备上访问 THEN 系统应当显示适配移动端的响应式布局
### 需求 2
**用户故事:** 作为用户,我希望搜索结果以卡片形式展示,以便更直观地浏览文档信息。
#### 验收标准
1. WHEN 搜索返回结果 THEN 系统应当以卡片网格布局显示文档列表
2. WHEN 用户将鼠标悬停在文档卡片上 THEN 系统应当显示卡片上浮和阴影增强效果
3. WHEN 文档卡片包含类型标签 THEN 系统应当使用不同颜色的徽章区分全局和专用文档
4. WHEN 搜索结果加载完成 THEN 系统应当显示交错淡入动画效果
5. WHEN 文档卡片显示内容片段 THEN 系统应当使用渐变遮罩处理文本溢出
### 需求 3
**用户故事:** 作为用户,我希望文档预览模态框具有优雅的设计,以便舒适地阅读文档内容。
#### 验收标准
1. WHEN 用户点击预览按钮 THEN 系统应当显示带有缩放淡入动画的模态框
2. WHEN 模态框打开时 THEN 系统应当显示半透明背景遮罩和模糊效果
3. WHEN 文档内容较长 THEN 系统应当提供带有自定义滚动条样式的滚动区域
4. WHEN 文档包含代码块 THEN 系统应当使用语法高亮和圆角边框样式
5. WHEN 文档包含表格 THEN 系统应当使用斑马纹和悬停高亮效果
### 需求 4
**用户故事:** 作为用户,我希望操作按钮具有清晰的视觉反馈,以便明确操作状态。
#### 验收标准
1. WHEN 用户将鼠标悬停在按钮上 THEN 系统应当显示颜色加深和轻微缩放效果
2. WHEN 用户点击按钮 THEN 系统应当显示按下动画效果
3. WHEN 按钮处于加载状态 THEN 系统应当显示旋转的加载图标
4. WHEN 按钮处于禁用状态 THEN 系统应当显示降低透明度和禁用鼠标指针
5. WHEN 操作成功完成 THEN 系统应当显示带有图标的成功通知动画
### 需求 5
**用户故事:** 作为用户,我希望表单输入具有友好的交互效果,以便更好地完成输入操作。
#### 验收标准
1. WHEN 输入框获得焦点 THEN 系统应当显示边框颜色变化和标签上移动画
2. WHEN 用户输入内容 THEN 系统应当实时显示字符计数或验证状态
3. WHEN 输入验证失败 THEN 系统应当显示红色边框和抖动动画效果
4. WHEN 下拉选择框展开 THEN 系统应当显示下滑淡入动画
5. WHEN 用户清空输入 THEN 系统应当显示清除按钮的淡入淡出效果
### 需求 6
**用户故事:** 作为用户,我希望页面加载和状态变化具有流畅的过渡效果,以便获得连贯的使用体验。
#### 验收标准
1. WHEN 页面首次加载 THEN 系统应当显示骨架屏加载动画
2. WHEN 搜索正在执行 THEN 系统应当显示加载指示器和脉冲动画
3. WHEN 内容状态改变 THEN 系统应当使用淡入淡出过渡效果
4. WHEN 列表项添加或删除 THEN 系统应当显示滑入滑出动画
5. WHEN 错误发生 THEN 系统应当显示带有图标的错误提示和抖动效果
### 需求 7
**用户故事:** 作为用户,我希望界面支持深色模式,以便在不同光线环境下舒适使用。
#### 验收标准
1. WHEN 系统检测到深色模式偏好 THEN 系统应当自动切换到深色主题
2. WHEN 深色模式激活 THEN 系统应当使用深色背景和浅色文字
3. WHEN 深色模式下显示卡片 THEN 系统应当使用深色卡片背景和适当的边框
4. WHEN 深色模式下显示按钮 THEN 系统应当调整按钮颜色以保持对比度
5. WHEN 主题切换时 THEN 系统应当显示平滑的颜色过渡动画
### 需求 8
**用户故事:** 作为用户,我希望界面元素具有微交互效果,以便获得更生动的使用体验。
#### 验收标准
1. WHEN 用户将鼠标悬停在图标上 THEN 系统应当显示图标旋转或缩放动画
2. WHEN 用户点击收藏按钮 THEN 系统应当显示心形填充动画
3. WHEN 通知消息出现 THEN 系统应当从右侧滑入并自动淡出
4. WHEN 用户滚动页面 THEN 系统应当显示返回顶部按钮的淡入效果
5. WHEN 用户拖拽元素 THEN 系统应当显示拖拽阴影和位置指示器
### 需求 9
**用户故事:** 作为用户,我希望文档列表具有高级筛选和排序界面,以便快速找到目标文档。
#### 验收标准
1. WHEN 用户点击筛选按钮 THEN 系统应当显示侧边栏滑入动画
2. WHEN 用户选择筛选条件 THEN 系统应当实时更新结果数量徽章
3. WHEN 用户应用筛选 THEN 系统应当显示已选筛选条件的标签
4. WHEN 用户点击排序选项 THEN 系统应当显示排序图标的旋转动画
5. WHEN 筛选结果为空 THEN 系统应当显示友好的空状态插图和提示
### 需求 10
**用户故事:** 作为用户,我希望界面具有无障碍访问支持,以便所有用户都能使用系统。
#### 验收标准
1. WHEN 用户使用键盘导航 THEN 系统应当显示清晰的焦点指示器
2. WHEN 屏幕阅读器访问页面 THEN 系统应当提供适当的ARIA标签
3. WHEN 用户使用Tab键切换 THEN 系统应当按逻辑顺序聚焦可交互元素
4. WHEN 动画效果播放 THEN 系统应当遵循用户的减少动画偏好设置
5. WHEN 界面显示颜色信息 THEN 系统应当确保足够的颜色对比度

View File

@@ -1,334 +0,0 @@
# 实施计划
- [ ] 1. 配置开发环境和依赖
- [ ] 1.1 确认Alpine.js和Tailwind CSS已安装
- 检查package.json中的依赖
- 如需要则安装Alpine.js 3.x
- 确认Tailwind CSS 3.x已配置
- _需求所有需求的基础_
- [ ] 1.2 扩展Tailwind配置文件
- 在tailwind.config.js中添加自定义动画
- 添加自定义关键帧fadeIn, slideIn, scaleIn, shake
- 配置动画时长和缓动函数
- _需求1.2, 1.4, 2.2, 2.4, 3.1, 4.1, 5.1, 5.3, 6.1-6.5, 8.1-8.5_
- [ ] 1.3 创建自定义CSS文件结构
- 创建resources/css/custom/animations.css
- 创建resources/css/custom/components.css
- 创建resources/css/custom/utilities.css
- 在app.css中导入自定义CSS文件
- _需求所有需求_
- [ ] 2. 创建可复用的UI组件
- [ ] 2.1 创建增强按钮组件
- 创建resources/views/components/ui/button.blade.php
- 实现按钮变体primary, secondary, danger等
- 添加悬停效果(颜色加深、轻微缩放)
- 添加点击动画效果
- 添加加载状态(旋转图标)
- 添加禁用状态样式
- _需求4.1, 4.2, 4.3, 4.4_
- [ ]* 2.2 编写属性测试验证按钮组件
- **属性 1文档类型徽章颜色一致性**
- **验证需求2.3**
- [ ] 2.3 创建增强输入框组件
- 创建resources/views/components/ui/input.blade.php
- 实现焦点状态(边框颜色变化、标签上移)
- 添加字符计数功能
- 添加验证错误状态(红色边框、抖动动画)
- 添加清除按钮(淡入淡出效果)
- _需求5.1, 5.2, 5.3, 5.5_
- [ ] 2.4 创建卡片组件
- 创建resources/views/components/ui/card.blade.php
- 实现基础卡片样式(渐变背景、阴影)
- 添加悬停效果(上浮、阴影增强)
- 支持深色模式样式
- _需求1.1, 2.1, 2.2, 7.3_
- [ ] 2.5 创建徽章组件
- 创建resources/views/components/ui/badge.blade.php
- 实现不同颜色变体success, info, warning, danger
- 为全局文档使用绿色徽章
- 为专用文档使用蓝色徽章
- 支持深色模式
- _需求2.3, 7.4_
- [ ] 3. 增强搜索页面UI
- [ ] 3.1 更新搜索表单样式
- 修改resources/views/filament/pages/search-page.blade.php
- 应用渐变背景和阴影效果到搜索卡片
- 添加表单加载时的淡入动画
- 使用增强输入框组件替换原有输入框
- 使用增强按钮组件替换原有按钮
- _需求1.1, 1.2, 1.3, 1.4_
- [ ] 3.2 实现响应式搜索表单布局
- 添加移动端适配样式
- 使用Tailwind响应式类sm:, md:, lg:
- 测试不同屏幕尺寸下的显示效果
- _需求1.5_
- [ ] 3.3 创建文档卡片视图组件
- 创建resources/views/filament/resources/document/card.blade.php
- 实现卡片网格布局
- 添加文档标题、类型徽章、内容片段
- 添加悬停效果(卡片上浮、阴影增强)
- 使用渐变遮罩处理文本溢出
- _需求2.1, 2.2, 2.3, 2.5_
- [ ] 3.4 添加搜索结果动画
- 实现交错淡入动画stagger animation
- 为每个卡片添加延迟动画
- 添加加载骨架屏
- _需求2.4, 6.1_
- [ ] 3.5 集成Alpine.js到搜索页面
- 创建resources/js/alpine/search.js
- 实现搜索状态管理isSearching, hasSearched
- 实现筛选器切换逻辑
- 实现视图模式切换(网格/列表)
- 添加搜索加载指示器
- _需求6.2, 9.1, 9.2_
- [ ] 3.6 实现高级筛选器界面
- 添加筛选按钮和侧边栏
- 实现侧边栏滑入动画
- 显示已选筛选条件的标签
- 实时更新结果数量徽章
- 添加清空筛选按钮
- _需求9.1, 9.2, 9.3_
- [ ] 3.7 实现空状态UI
- 创建友好的空状态插图
- 添加提示文本
- 提供建议操作
- _需求9.5_
- [ ] 4. 增强文档预览模态框
- [ ] 4.1 更新预览模态框样式
- 修改resources/views/filament/pages/document-preview-modal.blade.php
- 添加模态框打开动画(缩放淡入)
- 添加半透明背景遮罩和模糊效果
- 优化内容区域样式
- _需求3.1, 3.2_
- [ ] 4.2 自定义滚动条样式
- 为预览内容区域添加自定义滚动条
- 使用Tailwind的scrollbar插件或自定义CSS
- 支持深色模式滚动条
- _需求3.3_
- [ ] 4.3 增强Markdown内容样式
- 优化代码块样式(语法高亮、圆角边框)
- 优化表格样式(斑马纹、悬停高亮)
- 优化标题、列表、引用样式
- 优化图片显示(响应式、圆角)
- _需求3.4, 3.5_
- [ ] 4.4 集成Alpine.js到预览模态框
- 创建resources/js/alpine/preview.js
- 实现模态框状态管理isOpen, isLoading
- 实现滚动进度跟踪
- 添加返回顶部按钮(滚动时淡入)
- _需求8.4_
- [ ] 5. 实现深色模式支持
- [ ] 5.1 配置深色模式检测
- 在主布局中添加深色模式检测脚本
- 检测系统偏好prefers-color-scheme
- 从localStorage读取用户偏好
- 应用深色模式类到html元素
- _需求7.1_
- [ ] 5.2 更新所有组件的深色模式样式
- 为搜索页面添加深色模式样式
- 为文档卡片添加深色模式样式
- 为预览模态框添加深色模式样式
- 为按钮和输入框添加深色模式样式
- 确保颜色对比度符合标准
- _需求7.2, 7.3, 7.4_
- [ ] 5.3 实现主题切换动画
- 添加主题切换时的颜色过渡效果
- 使用CSS变量实现平滑过渡
- 避免切换时的闪烁
- _需求7.5_
- [ ] 6. 添加微交互效果
- [ ] 6.1 实现图标动画
- 为搜索图标添加悬停旋转效果
- 为下载图标添加悬停缩放效果
- 为筛选图标添加点击动画
- _需求8.1_
- [ ] 6.2 实现通知动画
- 优化Filament通知的显示动画
- 实现从右侧滑入效果
- 实现自动淡出效果
- 添加成功/错误图标动画
- _需求4.5, 8.3_
- [ ] 6.3 实现排序动画
- 为排序图标添加旋转动画
- 添加排序方向指示器
- 实现列表重排动画
- _需求9.4_
- [ ] 7. 实现页面过渡和加载状态
- [ ] 7.1 创建骨架屏组件
- 创建搜索结果骨架屏
- 创建文档卡片骨架屏
- 添加脉冲动画效果
- _需求6.1_
- [ ] 7.2 实现加载指示器
- 为搜索按钮添加加载状态
- 为预览模态框添加加载指示器
- 使用旋转动画和脉冲效果
- _需求6.2_
- [ ] 7.3 实现内容过渡动画
- 为内容状态变化添加淡入淡出效果
- 为列表项添加滑入滑出动画
- 优化动画时序
- _需求6.3, 6.4_
- [ ] 7.4 实现错误状态UI
- 创建错误提示组件
- 添加抖动动画效果
- 显示错误图标
- _需求6.5_
- [ ] 8. 实现无障碍访问支持
- [ ] 8.1 添加键盘导航支持
- 为所有交互元素添加tabindex
- 实现清晰的焦点指示器样式
- 测试Tab键导航顺序
- 添加键盘快捷键如Esc关闭模态框
- _需求10.1, 10.3_
- [ ] 8.2 添加ARIA标签
- 为按钮添加aria-label
- 为模态框添加role和aria-modal
- 为加载状态添加aria-busy
- 为展开/折叠元素添加aria-expanded
- _需求10.2_
- [ ]* 8.3 编写属性测试验证ARIA标签
- **属性 2ARIA标签完整性**
- **验证需求10.2**
- [ ] 8.4 实现动画偏好支持
- 检测prefers-reduced-motion设置
- 在用户偏好减少动画时禁用动画
- 提供静态替代方案
- _需求10.4_
- [ ] 8.5 验证颜色对比度
- 使用工具检查所有文本的对比度
- 确保至少4.5:1普通文本或3:1大文本
- 调整不符合标准的颜色
- _需求10.5_
- [ ]* 8.6 编写属性测试验证颜色对比度
- **属性 3颜色对比度合规性**
- **验证需求10.5**
- [ ] 9. 优化性能
- [ ] 9.1 优化CSS
- 移除未使用的Tailwind类
- 压缩CSS文件
- 使用PurgeCSS减小文件大小
- _需求性能优化_
- [ ] 9.2 优化JavaScript
- 延迟加载非关键JavaScript
- 使用代码分割
- 压缩JavaScript文件
- _需求性能优化_
- [ ] 9.3 优化动画性能
- 使用CSS transform和opacity避免重排
- 添加will-change提示
- 使用contain属性隔离动画
- 在低性能设备上禁用复杂动画
- _需求性能优化_
- [ ] 10. 测试和验证
- [ ]* 10.1 编写组件单元测试
- 测试按钮组件的各种状态
- 测试输入框组件的交互
- 测试卡片组件的渲染
- 测试徽章组件的颜色逻辑
- _需求所有组件相关需求_
- [ ]* 10.2 编写Alpine.js组件测试
- 测试搜索组件的状态管理
- 测试筛选器逻辑
- 测试预览模态框逻辑
- _需求所有交互相关需求_
- [ ]* 10.3 进行无障碍测试
- 使用axe-core进行自动化扫描
- 使用屏幕阅读器测试
- 测试键盘完整导航
- _需求10.1-10.5_
- [ ]* 10.4 进行视觉回归测试
- 截图对比测试使用Percy或Chromatic
- 测试深色模式显示
- 测试响应式布局
- _需求所有视觉相关需求_
- [ ]* 10.5 进行性能测试
- 使用Chrome DevTools测试动画FPS
- 测试首次内容绘制时间
- 测试交互就绪时间
- 在低性能设备上测试
- _需求性能相关需求_
- [ ]* 10.6 进行浏览器兼容性测试
- 在Chrome、Firefox、Safari、Edge上测试
- 在移动浏览器上测试
- 修复兼容性问题
- _需求所有需求_
- [ ] 11. 文档和部署
- [ ] 11.1 更新开发文档
- 记录新增的UI组件使用方法
- 记录Alpine.js组件的API
- 记录自定义CSS类的用法
- 添加样式指南
- _需求文档需求_
- [ ] 11.2 创建组件演示页面
- 创建Storybook或类似的组件展示页面
- 展示所有UI组件的各种状态
- 提供代码示例
- _需求文档需求_
- [ ] 11.3 优化生产构建
- 配置Laravel Mix或Vite进行生产构建
- 启用CSS和JS压缩
- 配置资源版本控制
- 测试生产环境构建
- _需求部署需求_
- [ ] 11.4 准备部署清单
- 列出需要部署的文件
- 列出需要运行的命令
- 列出需要检查的配置
- 创建回滚计划
- _需求部署需求_
- [ ] 12. 最终检查点
- 确保所有UI增强功能正常工作
- 验证在不同设备和浏览器上的显示效果
- 确认无障碍访问功能正常
- 验证性能指标达标
- 如有问题请咨询用户
- _需求所有需求_

138
Dockerfile Normal file
View File

@@ -0,0 +1,138 @@
# 多阶段构建Dockerfile - Laravel知识库系统 (Swoole版本)
# 确保构建为linux/amd64架构
# ================================
# 基础阶段 - 安装系统依赖
# ================================
FROM php:8.2-cli-alpine AS base
# 设置工作目录
WORKDIR /var/www/html
# 安装系统依赖
RUN apk add --no-cache \
# 基础工具
curl \
git \
unzip \
zip \
# PHP扩展依赖
libpng-dev \
libjpeg-turbo-dev \
freetype-dev \
libzip-dev \
icu-dev \
oniguruma-dev \
# Pandoc文档转换工具
pandoc \
# LibreOffice用于生成高保真PDF预览Noto CJK用于中文字体渲染
libreoffice \
font-noto-cjk \
ttf-dejavu \
# Node.js和npm (使用较小的版本)
nodejs \
npm \
# 进程管理
supervisor
# 配置和安装PHP扩展
RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j$(nproc) \
pdo_mysql \
mysqli \
zip \
gd \
intl \
mbstring \
opcache \
bcmath \
exif \
pcntl
# 安装Redis和Swoole扩展
RUN apk add --no-cache --virtual .build-deps $PHPIZE_DEPS linux-headers \
&& pecl install redis-6.0.2 swoole-5.1.1 \
&& docker-php-ext-enable redis swoole \
&& apk del .build-deps
# 安装Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# ================================
# 构建阶段 - 安装依赖和构建资源
# ================================
FROM base AS builder
# 复制composer文件
COPY composer.json composer.lock ./
# 复制源代码需要artisan文件
COPY . .
# 安装PHP依赖生产环境
RUN composer install --no-dev --optimize-autoloader --no-interaction --no-progress
# 复制package.json文件
COPY package.json package-lock.json ./
# 安装NPM依赖包括开发依赖用于构建
RUN npm ci
# 构建前端资源
RUN npm run build
# 设置Laravel缓存和优化
RUN php artisan config:cache \
&& php artisan route:cache \
&& php artisan view:cache
# ================================
# 生产阶段 - 最终镜像
# ================================
FROM base AS production
# 确保www-data用户存在Alpine中可能已存在
RUN if ! getent group www-data > /dev/null 2>&1; then \
addgroup -g 82 -S www-data; \
fi \
&& if ! getent passwd www-data > /dev/null 2>&1; then \
adduser -u 82 -D -S -G www-data www-data; \
fi
# 复制PHP配置仅保留基础PHP配置移除PHP-FPM配置
COPY docker/php/php.ini /usr/local/etc/php/php.ini
# 复制Supervisor配置更新为Swoole版本
COPY docker/supervisor/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
# 复制健康检查脚本
COPY docker/queue-health-check.sh /usr/local/bin/queue-health-check.sh
COPY docker/swoole-health-check.sh /usr/local/bin/swoole-health-check.sh
RUN chmod +x /usr/local/bin/queue-health-check.sh \
&& chmod +x /usr/local/bin/swoole-health-check.sh
# 从构建阶段复制应用文件
COPY --from=builder --chown=www-data:www-data /var/www/html /var/www/html
# 创建必要的目录并设置权限
RUN mkdir -p /var/www/html/storage/logs \
&& mkdir -p /var/www/html/storage/framework/cache \
&& mkdir -p /var/www/html/storage/framework/sessions \
&& mkdir -p /var/www/html/storage/framework/views \
&& mkdir -p /var/www/html/bootstrap/cache \
&& mkdir -p /var/log/supervisor \
&& mkdir -p /var/log \
&& chown -R www-data:www-data /var/www/html/storage \
&& chown -R www-data:www-data /var/www/html/bootstrap/cache \
&& chmod -R 775 /var/www/html/storage \
&& chmod -R 775 /var/www/html/bootstrap/cache
# 暴露Swoole端口
EXPOSE 8000
# 健康检查 - 使用Swoole健康检查脚本
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD /usr/local/bin/swoole-health-check.sh || exit 1
# 使用supervisor启动多个服务
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

362
README.md
View File

@@ -1,59 +1,113 @@
# 知识库系统
# 知识库管理系统
基于 Laravel 11 和 Filament 3.X 构建的企业级文档管理平台,支持 Word 文档上传、自动转换为 Markdown、全文搜索和基于分组的权限控制
基于 Laravel 12 和 Filament 3.X 构建的企业级智能知识库管理平台集成文档管理、SOP 标准作业流程、终端配置管理、AI 提示词模板和系统设置等功能模块,为企业提供全方位的知识管理解决方案
[![Laravel](https://img.shields.io/badge/Laravel-11.x-red.svg)](https://laravel.com)
[![Laravel](https://img.shields.io/badge/Laravel-12.x-red.svg)](https://laravel.com)
[![Filament](https://img.shields.io/badge/Filament-3.x-orange.svg)](https://filamentphp.com)
[![PHP](https://img.shields.io/badge/PHP-8.1+-blue.svg)](https://php.net)
[![PHP](https://img.shields.io/badge/PHP-8.2+-blue.svg)](https://php.net)
[![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
## ✨ 功能特性
## ✨ 核心功能
### 📄 文档管理
### 功能模块概览
| 模块 | 功能描述 | 状态 |
|------|---------|------|
| 📄 文档管理 | Word 文档上传、转换、预览、搜索 | ✅ 已完成 |
| 📋 SOP 流程 | 标准作业流程模板管理和版本控制 | ✅ 已完成 |
| 🖥️ 终端管理 | 生产终端配置和知识库关联 | ✅ 已完成 |
| 🤖 AI 提示词 | AI 助手提示词模板和变量系统 | ✅ 已完成 |
| ⚙️ 系统设置 | 动态系统配置管理 | ✅ 已完成 |
| 📊 活动日志 | 操作审计和变更追踪 | ✅ 已完成 |
| 👥 用户管理 | 用户和分组权限管理 | ✅ 已完成 |
| 🔍 全文搜索 | Meilisearch 快速搜索 | ✅ 已完成 |
### 📄 文档管理系统
- **多格式支持**:支持 .doc 和 .docx 格式的 Word 文档上传
- **智能分类**
- 全局知识库:所有用户可访问
- 专用知识库:仅特定分组用户可访问
- **自动转换**:异步将 Word 文档转换为 Markdown 格式
- **在线预览**Markdown 格式在线预览,支持富文本渲染
- **安全下载**:支持原始文档下载,自动记录下载日志
- **在线预览**Markdown 格式在线预览,无需下载
- **全文搜索**集成 Meilisearch 提供毫秒级搜索响应
### 🔄 自动转换
- **异步处**使用 Laravel Queue 异步转换文档
- **多引擎支持**:支持 Pandoc 或 PHPWord 作为转换引擎
- **状态跟踪**实时显示转换状态(待处理、处理中、已完成、失败)
- **容错机**转换失败不影响文档正常使用
### 📋 SOP 标准作业流程
- **模板管**创建和管理标准作业流程模板
- **步骤编排**:支持多步骤流程定义,可设置步骤顺序
- **交互任务**支持在步骤中添加交互式任务
- **版本控**自动记录 SOP 模板的版本历史
- **状态管理**:支持草稿、已发布、已归档等状态
- **分类标签**:支持按类别、部门、岗位分类管理
- **导入导出**:支持 SOP 模板的导入和导出功能
### 🔍 全文搜索
- **快速搜索**集成 Meilisearch 提供毫秒级搜索响应
- **多字段搜索**同时搜索标题、描述和文档内容
- **智能过滤**搜索结果自动应用权限过滤
- **高级筛选**:支持按类型、分组、上传者等条件筛选
### 🖥️ 终端配置管理
- **终端注册**管理生产现场的终端设备
- **知识库关联**为终端配置可访问的知识库,支持优先级设置
- **提示词配置**为每个终端配置专属的 AI 提示词模板
- **配置同步**:支持终端配置的异步同步和状态跟踪
- **在线状态**:实时监控终端在线状态
- **工位绑定**:支持终端与工作站的绑定关系
- **图纸关联**:可为终端关联工位图纸 URL
### 🔐 权限控制
- **灵活分组**用户可以属于多个分组
- **细粒度控制**
### 🤖 AI 提示词模板
- **模板库**预定义多种场景的 AI 提示词模板
- 通用助手:一般性问答和操作指导
- 安全专员:安全操作指导和风险提示
- 故障诊断:设备故障诊断和问题排查
- 培训教练:新员工培训和操作指导
- 质量检查:质量控制和检验指导
- **变量系统**:支持动态变量替换
- 用户信息:用户名、角色、部门
- 终端信息:终端名称、编码、工作站
- 时间信息:当前时间、日期、班次
- 知识库:关联的知识库列表
- **实时预览**:支持提示词模板的实时预览和变量替换
- **变量验证**:自动验证模板中使用的变量是否有效
### ⚙️ 系统设置管理
- **配置分组**:支持按功能模块分组管理系统配置
- **动态配置**:支持运行时动态修改系统配置
- **配置缓存**:自动缓存配置提升性能
- **公开配置**:支持标记配置是否对外公开
- **配置描述**:为每个配置项提供详细说明
### 📊 活动日志审计
- **操作记录**:自动记录所有重要操作
- **变更追踪**:详细记录数据变更前后的差异
- **用户追溯**:记录操作用户和时间信息
- **批量操作**:支持批量操作的日志记录
- **日志导出**:支持活动日志的导出功能
- **差异对比**:可视化展示数据变更的差异
### 🔐 权限与安全
- **用户分组**:用户可以属于多个分组
- **细粒度权限**
- 全局文档:所有用户可访问
- 专用文档:只有所属分组用户可访问
- **多层验证**在查询、下载、预览等操作中强制执行权限
- **安全审计**:记录所有未授权访问尝试
- **策略控制**基于 Laravel Policy 的权限控制
- **安全日志**:记录所有未授权访问尝试
- **操作审计**:完整的操作日志和审计追踪
### 🎨 用户界面
- **现代化设计**:基于 Filament 3.X 的美观管理界面
- **完整中文化**:所有界面元素使用简体中文
- **响应式布局**:完美支持桌面和移动设备
- **Monaco 编辑器**:集成 Monaco 编辑器支持代码和 Markdown 编辑
- **直观操作**:简洁的操作流程,降低学习成本
## 🚀 快速开始
### 环境要求
- PHP 8.1 或更高版本
- PHP 8.2 或更高版本
- Composer 2.x
- Node.js 18+ 和 npm
- MySQL 8.0+ 或 PostgreSQL 13+
- Redis 6.0+
- Meilisearch 1.5+
- Pandoc 2.x+(可选,用于文档转换)
- Laravel Octane可选用于高性能部署
### 安装步骤
@@ -107,19 +161,38 @@ npm run build
8. **启动服务**
在不同的终端窗口中运行
开发环境有多种启动方式
**方式一:传统方式(分别启动)**
```bash
# Laravel 开发服务器
# 终端 1Laravel 开发服务器
php artisan serve
# 队列工作进程
# 终端 2队列工作进程
php artisan queue:work
# Meilisearch如果本地安装
# 终端 3Meilisearch如果本地安装
meilisearch --master-key="your-master-key"
```
**方式二:使用 Composer 脚本(推荐)**
```bash
# 一键启动所有服务(使用 concurrently
composer dev
# 使用 Octane 启动(高性能)
composer dev-octane
```
**方式三:使用 Docker**
```bash
# 启动 Docker 容器
docker-compose up -d
# 查看日志
docker-compose logs -f
```
9. **访问系统**
打开浏览器访问:`http://localhost:8000/admin`
@@ -131,7 +204,13 @@ meilisearch --master-key="your-master-key"
- [API 参考](docs/API_REFERENCE.md) - 服务类和方法文档
- [Meilisearch 配置](docs/MEILISEARCH_SETUP.md) - 搜索引擎配置说明
- [文档转换指南](docs/DOCUMENT_CONVERSION_GUIDE.md) - 转换功能配置
- [文档预览指南](docs/DOCUMENT_PREVIEW_GUIDE.md) - 预览功能说明
- [文档搜索指南](docs/DOCUMENT_SEARCH_GUIDE.md) - 搜索功能配置
- [队列设置](docs/QUEUE_SETUP.md) - 队列系统配置
- [Octane 安装](docs/OCTANE_INSTALLATION.md) - 高性能服务器配置
- [Swoole 配置](docs/SWOOLE_CONFIGURATION.md) - Swoole 服务器配置
- [安全日志](docs/security-logging.md) - 安全审计功能说明
- [Docker 部署](docker/README.md) - Docker 容器化部署指南
## 🔧 配置
@@ -162,6 +241,17 @@ REDIS_PASSWORD=null
REDIS_PORT=6379
```
### Octane 配置(可选)
```env
OCTANE_SERVER=swoole
OCTANE_HTTPS=false
OCTANE_HOST=0.0.0.0
OCTANE_PORT=8000
OCTANE_WORKERS=auto
OCTANE_MAX_REQUESTS=500
```
详细配置说明请参考 [部署指南](docs/DEPLOYMENT.md)。
## 🧪 测试
@@ -177,6 +267,10 @@ php artisan test --filter=DocumentAccessScopePropertyTest
# 生成测试覆盖率报告
php artisan test --coverage
# 运行特定测试套件
php artisan test tests/Feature
php artisan test tests/Unit
```
### 测试类型
@@ -185,20 +279,105 @@ php artisan test --coverage
- **功能测试**:测试完整的用户流程
- **属性测试**:使用 Property-Based Testing 验证核心逻辑
## 🚀 常用命令
### 开发命令
```bash
# 启动开发服务器
composer dev # 启动所有服务(推荐)
composer dev-octane # 使用 Octane 启动
php artisan serve # 仅启动 Laravel
# Octane 相关
composer octane:start # 启动 Octane
composer octane:stop # 停止 Octane
composer octane:restart # 重启 Octane
composer swoole:start # 使用 Swoole 启动
composer swoole:watch # Swoole 监听文件变化
# 队列相关
php artisan queue:work # 启动队列工作进程
php artisan queue:listen # 监听队列
php artisan queue:restart # 重启队列工作进程
php artisan queue:failed # 查看失败的任务
```
### 数据库命令
```bash
# 迁移
php artisan migrate # 运行迁移
php artisan migrate:fresh # 清空数据库并重新迁移
php artisan migrate:rollback # 回滚迁移
# 填充数据
php artisan db:seed # 运行所有 Seeder
php artisan db:seed --class=SystemSettingSeeder # 运行特定 Seeder
# 重置数据库
php artisan migrate:fresh --seed # 重置并填充数据
```
### 搜索索引命令
```bash
# 导入搜索索引
php artisan scout:import "App\Models\Document"
# 清空搜索索引
php artisan scout:flush "App\Models\Document"
# 重建搜索索引
php artisan scout:flush "App\Models\Document"
php artisan scout:import "App\Models\Document"
```
### 缓存命令
```bash
# 清除缓存
php artisan cache:clear # 清除应用缓存
php artisan config:clear # 清除配置缓存
php artisan route:clear # 清除路由缓存
php artisan view:clear # 清除视图缓存
# 生成缓存
php artisan config:cache # 缓存配置
php artisan route:cache # 缓存路由
php artisan view:cache # 缓存视图
# 清除所有缓存
php artisan optimize:clear # 清除所有缓存
```
### 用户管理命令
```bash
# 创建管理员用户
php artisan make:filament-user
# 创建自定义管理员
php artisan app:create-admin-user
```
## 📦 技术栈
### 后端
- **Laravel 11.x** - PHP Web 应用框架
- **Laravel 12.x** - PHP Web 应用框架
- **Filament 3.X** - 管理面板框架
- **Laravel Scout** - 全文搜索集成
- **Laravel Octane** - 高性能应用服务器
- **Meilisearch** - 快速搜索引擎
- **Pandoc** - 文档格式转换工具
- **Spatie Activity Log** - 活动日志记录
### 前端
- **Blade** - Laravel 模板引擎
- **Tailwind CSS** - CSS 框架
- **Alpine.js** - JavaScript 框架Filament 内置)
- **Livewire** - 全栈框架Filament 内置)
- **Monaco Editor** - 代码编辑器
### 数据库
- **MySQL 8.0+** 或 **PostgreSQL 13+**
@@ -206,41 +385,78 @@ php artisan test --coverage
### 开发工具
- **Pest PHP** - 测试框架
- **PHPStan** - 静态分析工具
- **Laravel Pint** - 代码格式化工具
- **Composer** - PHP 依赖管理
- **npm** - 前端依赖管理
## 🗂️ 项目结构
```
knowledge-base-system/
├── app/
│ ├── Console/
│ │ └── Commands/ # Artisan 命令
│ ├── Exports/ # 数据导出类
│ ├── Filament/ # Filament 资源和页面
│ │ ├── Pages/ # 自定义页面(搜索)
│ │ ── Resources/ # 资源管理(文档、分组、用户
│ │ ├── Actions/ # 自定义操作
│ │ ── Pages/ # 自定义页面(搜索、系统设置
│ │ └── Resources/ # 资源管理
│ │ ├── ActivityLogResource/ # 活动日志
│ │ ├── DocumentResource/ # 文档管理
│ │ ├── GroupResource/ # 分组管理
│ │ ├── SopTemplateResource/ # SOP 模板
│ │ ├── SystemSettingResource/ # 系统设置
│ │ ├── TerminalResource/ # 终端管理
│ │ └── UserResource/ # 用户管理
│ ├── Http/
│ │ └── Controllers/ # 控制器(文档预览)
│ ├── Jobs/ # 队列任务(文档转换)
│ ├── Jobs/ # 队列任务
│ │ ├── ConvertDocumentToMarkdown.php # 文档转换
│ │ └── SyncTerminalConfigJob.php # 终端同步
│ ├── Models/ # Eloquent 模型
│ ├── Observers/ # 模型观察者(文档索引)
│ ├── Document.php # 文档
│ │ ├── Group.php # 分组
│ │ ├── KnowledgeBase.php # 知识库
│ │ ├── SopTemplate.php # SOP 模板
│ │ ├── SystemSetting.php # 系统设置
│ │ ├── Terminal.php # 终端
│ │ └── User.php # 用户
│ ├── Observers/ # 模型观察者
│ ├── Policies/ # 授权策略
│ └── Services/ # 业务逻辑服务
│ ├── DocumentConversionService.php # 文档转换
│ ├── DocumentPreviewService.php # 文档预览
│ ├── DocumentSearchService.php # 文档搜索
│ ├── PromptTemplateService.php # 提示词模板
│ ├── SopTemplateService.php # SOP 模板
│ ├── SystemSettingService.php # 系统设置
│ └── TerminalSyncService.php # 终端同步
├── config/
│ ├── activitylog.php # 活动日志配置
│ ├── documents.php # 文档转换配置
│ ├── filesystems.php # 文件存储配置
│ ├── octane.php # Octane 配置
│ ├── prompt_templates.php # 提示词模板配置
│ ├── prompt_variables.php # 提示词变量配置
│ └── scout.php # Meilisearch 配置
├── database/
│ ├── factories/ # 测试数据工厂
│ ├── migrations/ # 数据库迁移
│ └── seeders/ # 数据填充
├── docker/ # Docker 部署文件
├── docs/ # 项目文档
├── resources/
│ └── views/ # Blade 视图模板
│ ├── documents/ # 文档预览视图
│ └── filament/ # Filament 自定义视图
├── storage/
│ └── app/
│ └── private/
│ ├── documents/ # 原始文档存储
│ └── markdown/ # Markdown 文件存储
├── tests/ # 测试文件
│ ├── Feature/ # 功能测试
│ └── Unit/ # 单元测试
└── .kiro/
└── specs/ # 功能规格文档
```
@@ -257,29 +473,72 @@ knowledge-base-system/
## 📝 更新日志
### v1.0.0 (2025-12-05)
### v1.0.0 (2026-03-09)
#### 已实现功能
- ✅ 用户认证和授权
#### 核心功能模块
- ✅ 用户认证和授权系统
- ✅ 用户分组管理
- ✅ 文档上传和存储
- ✅ 文档分类(全局/专用)
- ✅ 基于分组的权限控制
- ✅ 文档下载和日志记录
- ✅ Word 文档自动转换为 Markdown
- ✅ 异步队列处理转换任务
- ✅ Meilisearch 全文搜索集成
- ✅ 文档 Markdown 在线预览
- ✅ 搜索结果权限过滤
- ✅ 文档管理系统
- 文档上传和存储
- 文档分类(全局/专用)
- 基于分组的权限控制
- 文档下载和日志记录
- Word 文档自动转换为 Markdown
- 异步队列处理转换任务
- Meilisearch 全文搜索集成
- 文档 Markdown 在线预览
- 搜索结果权限过滤
#### 新增功能模块
- ✅ SOP 标准作业流程管理
- SOP 模板创建和编辑
- 多步骤流程定义
- 交互式任务支持
- 版本历史记录
- 状态管理(草稿/已发布/已归档)
- 导入导出功能
- ✅ 终端配置管理
- 终端设备注册和管理
- 知识库关联配置
- AI 提示词模板配置
- 配置同步功能
- 在线状态监控
- 工位绑定管理
- ✅ AI 提示词模板系统
- 预定义模板库5 种场景)
- 动态变量系统
- 实时预览功能
- 变量验证机制
- ✅ 系统设置管理
- 配置分组管理
- 动态配置修改
- 配置缓存优化
- ✅ 活动日志审计
- 操作记录追踪
- 变更差异对比
- 用户操作审计
- 日志导出功能
#### 技术改进
- ✅ 升级到 Laravel 12.x
- ✅ 集成 Laravel Octane 支持
- ✅ 集成 Spatie Activity Log
- ✅ 集成 Monaco Editor
- ✅ 完整的测试套件
- ✅ Docker 容器化部署支持
- ✅ 安全日志记录
- ✅ Filament 管理面板
- ✅ 完整中文界面
#### 待完成功能
- ⏳ 属性基础测试Property-Based Testing
- ⏳ 完整的功能测试套件
- ⏳ 性能优化(缓存、索引优化)
- ⏳ UI 增强(Alpine.js 动画和交互)
- ⏳ UI 增强(动画和交互优化
- ⏳ 移动端适配优化
- ⏳ API 接口文档
## 🔒 安全
@@ -297,8 +556,11 @@ knowledge-base-system/
- [Laravel](https://laravel.com) - 优雅的 PHP 框架
- [Filament](https://filamentphp.com) - 强大的管理面板
- [Laravel Octane](https://laravel.com/docs/octane) - 高性能应用服务器
- [Meilisearch](https://www.meilisearch.com) - 快速搜索引擎
- [Pandoc](https://pandoc.org) - 通用文档转换器
- [Spatie Activity Log](https://spatie.be/docs/laravel-activitylog) - 活动日志记录
- [Monaco Editor](https://microsoft.github.io/monaco-editor/) - 强大的代码编辑器
- [Tailwind CSS](https://tailwindcss.com) - 实用优先的 CSS 框架
## 📞 联系方式
@@ -308,8 +570,8 @@ knowledge-base-system/
---
**开发状态**: 🚧 活跃开发中
**开发状态**: 🚀 稳定版本
**最后更新**: 2025-12-05
**最后更新**: 2026-03-09
**版本**: 1.0.0

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
class CreateAdminUser extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'admin:create {email} {password} {--name=系统管理员}';
/**
* The console command description.
*
* @var string
*/
protected $description = '创建管理员用户';
/**
* Execute the console command.
*/
public function handle()
{
$email = $this->argument('email');
$password = $this->argument('password');
$name = $this->option('name');
// 检查用户是否已存在
if (User::where('email', $email)->exists()) {
$this->error("用户 {$email} 已存在!");
return 1;
}
// 创建管理员用户
$admin = User::create([
'name' => $name,
'email' => $email,
'password' => Hash::make($password),
'email_verified_at' => now(),
]);
$this->info("管理员用户创建成功!");
$this->info("姓名: {$admin->name}");
$this->info("邮箱: {$admin->email}");
$this->info("密码: {$password}");
return 0;
}
}

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Console\Commands;
use App\Models\Document;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class FixStuckDocuments extends Command
{
/**
* 命令签名
*
* @var string
*/
protected $signature = 'documents:fix-stuck
{--timeout=30 : 超过多少分钟未完成视为卡住默认30分钟}
{--status=processing : 要修复的状态processing/pending}
{--dry-run : 仅显示将要修复的文档,不实际执行}';
/**
* 命令描述
*
* @var string
*/
protected $description = '修复卡在转换中但实际已失败的文档';
/**
* 执行命令
*/
public function handle(): int
{
$timeout = (int) $this->option('timeout');
$status = $this->option('status');
$dryRun = $this->option('dry-run');
$this->info("正在查找卡住的文档...");
$this->info("状态: {$status}");
$this->info("超时时间: {$timeout} 分钟");
// 查找卡住的文档
$stuckDocuments = Document::where('conversion_status', $status)
->where('updated_at', '<', now()->subMinutes($timeout))
->get();
if ($stuckDocuments->isEmpty()) {
$this->info('✓ 没有发现卡住的文档');
return self::SUCCESS;
}
$this->warn("发现 {$stuckDocuments->count()} 个卡住的文档:");
// 显示文档列表
$tableData = $stuckDocuments->map(function ($doc) {
return [
'ID' => $doc->id,
'标题' => \Illuminate\Support\Str::limit($doc->title, 40),
'状态' => $doc->conversion_status,
'更新时间' => $doc->updated_at->format('Y-m-d H:i:s'),
'卡住时长' => $doc->updated_at->diffForHumans(),
];
})->toArray();
$this->table(
['ID', '标题', '状态', '更新时间', '卡住时长'],
$tableData
);
if ($dryRun) {
$this->info('');
$this->info('这是预览模式,没有实际修改任何数据');
$this->info('移除 --dry-run 选项以执行修复');
return self::SUCCESS;
}
// 确认操作
if (!$this->confirm('是否要将这些文档标记为失败状态?', true)) {
$this->info('操作已取消');
return self::SUCCESS;
}
// 修复文档
$fixed = 0;
foreach ($stuckDocuments as $document) {
try {
$document->update([
'conversion_status' => 'failed',
'conversion_error' => "转换任务超时(卡在 {$status} 状态超过 {$timeout} 分钟)",
]);
$fixed++;
$this->line("✓ 已修复: [{$document->id}] {$document->title}");
} catch (\Exception $e) {
$this->error("✗ 修复失败: [{$document->id}] {$document->title} - {$e->getMessage()}");
}
}
$this->info('');
$this->info("修复完成!共修复 {$fixed} 个文档");
$this->info('现在可以在管理界面使用"重试转换"功能重新处理这些文档');
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,113 @@
<?php
namespace App\Exports;
use Maatwebsite\Excel\Concerns\FromQuery;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithMapping;
use Maatwebsite\Excel\Concerns\WithStyles;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use Spatie\Activitylog\Models\Activity;
class ActivityLogExport implements FromQuery, WithHeadings, WithMapping, WithStyles
{
protected $query;
public function __construct($query)
{
$this->query = $query;
}
/**
* 查询数据
*/
public function query()
{
return $this->query;
}
/**
* 表头
*/
public function headings(): array
{
return [
'操作时间',
'操作用户',
'操作类型',
'对象类型',
'对象ID',
'日志名称',
'变更详情',
];
}
/**
* 数据映射
*/
public function map($activity): array
{
// 格式化操作类型
$description = match ($activity->description) {
'created' => '创建',
'updated' => '更新',
'deleted' => '删除',
default => $activity->description,
};
// 格式化对象类型
$subjectType = '-';
if ($activity->subject_type) {
$className = class_basename($activity->subject_type);
$subjectType = match ($className) {
'SystemSetting' => '系统设置',
'User' => '用户',
'Document' => '文档',
'Group' => '分组',
'Terminal' => '终端',
'Guide' => '操作指引',
default => $className,
};
}
// 格式化变更详情
$changes = '';
if (is_array($activity->properties)) {
$changesArray = [];
if (isset($activity->properties['attributes'])) {
$changesArray[] = '新值: ' . json_encode($activity->properties['attributes'], JSON_UNESCAPED_UNICODE);
}
if (isset($activity->properties['old'])) {
$changesArray[] = '旧值: ' . json_encode($activity->properties['old'], JSON_UNESCAPED_UNICODE);
}
$changes = implode(' | ', $changesArray);
}
return [
$activity->created_at->format('Y-m-d H:i:s'),
$activity->causer?->name ?? '系统',
$description,
$subjectType,
$activity->subject_id ?? '-',
$activity->log_name ?? 'default',
$changes,
];
}
/**
* 样式设置
*/
public function styles(Worksheet $sheet)
{
return [
// 表头样式
1 => [
'font' => ['bold' => true],
'fill' => [
'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID,
'startColor' => ['rgb' => 'E2E8F0'],
],
],
];
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Filament\Pages;
use Filament\Pages\Dashboard as BaseDashboard;
class Dashboard extends BaseDashboard
{
protected static ?string $navigationLabel = '仪表板';
protected static ?string $title = '仪表板';
public function getWidgets(): array
{
return [
\App\Filament\Widgets\KnowledgeBaseStatsWidget::class,
\App\Filament\Widgets\TerminalStatsWidget::class,
];
}
public function getColumns(): int | string | array
{
return 2;
}
}

View File

@@ -0,0 +1,303 @@
<?php
namespace App\Filament\Pages;
use App\Models\SystemSetting;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Pages\Page;
use Filament\Notifications\Notification;
class ManageSystemSettings extends Page
{
protected static ?string $navigationIcon = 'heroicon-o-cog-6-tooth';
protected static string $view = 'filament.pages.manage-system-settings';
protected static ?string $navigationLabel = '系统配置';
protected static ?string $title = '系统配置';
protected static ?int $navigationSort = 1;
public ?array $data = [];
public function mount(): void
{
$this->form->fill($this->getSettingsData());
}
public function form(Form $form): Form
{
return $form
->schema([
Forms\Components\Tabs::make('配置分组')
->tabs([
// 嵌入模型配置
Forms\Components\Tabs\Tab::make('嵌入模型配置')
->icon('heroicon-o-cpu-chip')
->schema([
Forms\Components\Section::make('模型基础配置')
->description('配置嵌入模型的基本参数')
->schema([
Forms\Components\TextInput::make('embedding.model_name')
->label('模型名称')
->helperText('例如: text-embedding-3-small, text-embedding-ada-002')
->required()
->maxLength(255)
->minLength(3),
Forms\Components\TextInput::make('embedding.api_key')
->label('API 密钥')
->password()
->revealable()
->required()
->helperText('OpenAI API 密钥(敏感信息)')
->maxLength(255)
->minLength(20),
Forms\Components\TextInput::make('embedding.endpoint_url')
->label('API 端点 URL')
->url()
->helperText('嵌入模型的 API 端点地址')
->required()
->maxLength(500)
->prefix('https://'),
])
->columns(1),
Forms\Components\Section::make('模型参数配置')
->description('配置嵌入模型的高级参数')
->schema([
Forms\Components\TextInput::make('embedding.dimensions')
->label('向量维度')
->numeric()
->minValue(1)
->maxValue(4096)
->helperText('嵌入向量的维度大小')
->required(),
Forms\Components\TextInput::make('embedding.batch_size')
->label('批量处理大小')
->numeric()
->minValue(1)
->maxValue(1000)
->helperText('批量处理文档的数量')
->required(),
])
->columns(2),
]),
// 分块参数配置
Forms\Components\Tabs\Tab::make('分块参数配置')
->icon('heroicon-o-scissors')
->schema([
Forms\Components\Section::make('分块基础参数')
->description('配置文档分块的基本参数')
->schema([
Forms\Components\TextInput::make('chunking.chunk_size')
->label('分块大小')
->numeric()
->minValue(100)
->maxValue(10000)
->helperText('每个文档块的字符数')
->required()
->suffix('字符')
->default(1000),
Forms\Components\TextInput::make('chunking.chunk_overlap')
->label('分块重叠大小')
->numeric()
->minValue(0)
->maxValue(1000)
->helperText('相邻块之间的重叠字符数')
->required()
->suffix('字符')
->default(200),
Forms\Components\TextInput::make('chunking.min_chunk_size')
->label('最小分块大小')
->numeric()
->minValue(10)
->maxValue(1000)
->helperText('允许的最小块大小')
->required()
->suffix('字符')
->default(100),
])
->columns(3),
Forms\Components\Section::make('分块高级参数')
->description('配置文档分块的高级参数')
->schema([
Forms\Components\Textarea::make('chunking.separator')
->label('分块分隔符')
->helperText('用于分割文档的分隔符(支持转义字符如 \\n')
->rows(2)
->maxLength(100),
])
->columns(1),
]),
// 系统全局配置
Forms\Components\Tabs\Tab::make('系统全局配置')
->icon('heroicon-o-globe-alt')
->schema([
Forms\Components\Section::make('系统基础信息')
->description('配置系统的基本信息')
->schema([
Forms\Components\TextInput::make('system.name')
->label('系统名称')
->helperText('显示在系统界面上的名称')
->required()
->maxLength(255)
->default('知识库管理系统'),
])
->columns(1),
Forms\Components\Section::make('系统运行参数')
->description('配置系统的运行参数')
->schema([
Forms\Components\TextInput::make('system.timeout')
->label('请求超时时间')
->numeric()
->minValue(10)
->maxValue(300)
->helperText('API 请求的超时时间建议值60秒')
->required()
->suffix('秒')
->default(60),
Forms\Components\TextInput::make('system.max_retries')
->label('最大重试次数')
->numeric()
->minValue(0)
->maxValue(10)
->helperText('API 请求失败时的最大重试次数建议值3次')
->required()
->default(3),
])
->columns(2),
Forms\Components\Section::make('文件上传配置')
->description('配置文件上传的限制')
->schema([
Forms\Components\TextInput::make('system.max_upload_size')
->label('最大上传大小')
->numeric()
->minValue(1048576)
->maxValue(104857600)
->helperText('最大文件上传大小字节1MB = 104857610MB = 10485760100MB = 104857600')
->required()
->suffix('字节')
->default(10485760),
Forms\Components\TagsInput::make('system.allowed_file_types')
->label('允许的文件类型')
->helperText('允许上传的文件扩展名例如pdf, docx, txt, md')
->placeholder('输入文件类型后按回车')
->required()
->default(['pdf', 'docx', 'txt', 'md']),
])
->columns(1),
]),
// 搜索配置
Forms\Components\Tabs\Tab::make('搜索配置')
->icon('heroicon-o-magnifying-glass')
->schema([
Forms\Components\Section::make('搜索参数')
->description('配置搜索功能的参数')
->schema([
Forms\Components\TextInput::make('search.top_k')
->label('最大结果数')
->numeric()
->minValue(1)
->maxValue(100)
->helperText('搜索返回的最大结果数量')
->required()
->default(10),
Forms\Components\TextInput::make('search.similarity_threshold')
->label('相似度阈值')
->numeric()
->minValue(0)
->maxValue(1)
->step(0.01)
->helperText('搜索结果的最小相似度0-1')
->required()
->default(0.7),
Forms\Components\Toggle::make('search.enable_rerank')
->label('启用重排序')
->helperText('是否对搜索结果进行重新排序')
->inline(false)
->default(false),
])
->columns(3),
]),
])
->columnSpanFull(),
])
->statePath('data');
}
protected function getSettingsData(): array
{
$settings = SystemSetting::all();
$data = [];
foreach ($settings as $setting) {
// 从 value JSON 中提取实际值
$value = $setting->value;
// 获取 value 数组中的第一个值(因为种子数据中每个 value 都是单键值对)
if (is_array($value) && count($value) > 0) {
$data[$setting->key] = reset($value);
}
}
return $data;
}
public function save(): void
{
$data = $this->form->getState();
// 按配置键分组保存
foreach ($data as $key => $value) {
// 确定分组
$group = explode('.', $key)[0];
// 获取配置键的最后一部分作为 value 的键
$valueKey = explode('.', $key)[1] ?? $key;
// 更新或创建配置
SystemSetting::updateOrCreate(
['key' => $key],
[
'value' => [$valueKey => $value],
'group' => $group,
]
);
}
Notification::make()
->success()
->title('保存成功')
->body('系统设置已更新')
->send();
}
public function resetForm(): void
{
// 重新加载表单数据
$this->form->fill($this->getSettingsData());
Notification::make()
->info()
->title('已重置')
->body('表单已重置为当前保存的设置')
->send();
}
}

View File

@@ -3,7 +3,8 @@
namespace App\Filament\Pages;
use App\Models\Document;
use App\Models\Group;
use App\Models\KnowledgeBase;
use App\Models\Station;
use App\Services\DocumentSearchService;
use App\Services\DocumentService;
use Filament\Forms\Components\Select;
@@ -18,7 +19,6 @@ use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
@@ -28,39 +28,25 @@ class SearchPage extends Page implements HasForms, HasTable
use InteractsWithTable;
protected static ?string $navigationIcon = 'heroicon-o-magnifying-glass';
protected static string $view = 'filament.pages.search-page';
protected static ?string $navigationLabel = '搜索文档';
protected static ?string $title = '搜索文档';
protected static ?int $navigationSort = 2;
// 表单数据
public ?string $searchQuery = null;
public ?string $documentType = null;
public ?int $groupId = null;
// 搜索结果
public $searchResults = null;
public ?array $stationIds = [];
public ?array $knowledgeBaseIds = [];
public bool $hasSearched = false;
/**
* 挂载页面时的初始化
*/
public function mount(): void
{
$this->form->fill([
'searchQuery' => '',
'documentType' => null,
'groupId' => null,
'stationIds' => [],
'knowledgeBaseIds' => [],
]);
}
/**
* 定义搜索表单
*/
public function form(Form $form): Form
{
return $form
@@ -71,28 +57,25 @@ class SearchPage extends Page implements HasForms, HasTable
->required()
->maxLength(255),
Select::make('documentType')
->label('文档类型')
->placeholder('全部类型')
->options([
'global' => '全局知识库',
'dedicated' => '专用知识库',
])
Select::make('stationIds')
->label('线站')
->placeholder('全部线站')
->options(Station::pluck('name', 'id'))
->multiple()
->searchable()
->native(false),
Select::make('groupId')
->label('所属分组')
->placeholder('全部分组')
->options(Group::pluck('name', 'id'))
Select::make('knowledgeBaseIds')
->label('知识库')
->placeholder('全部知识库')
->options(KnowledgeBase::where('status', 'active')->pluck('name', 'id'))
->multiple()
->searchable()
->native(false),
])
->columns(3);
}
/**
* 定义搜索结果表格
*/
public function table(Table $table): Table
{
return $table
@@ -104,29 +87,12 @@ class SearchPage extends Page implements HasForms, HasTable
->sortable()
->limit(50),
TextColumn::make('markdown_preview')
->label('内容片段')
->limit(100)
->wrap()
->default('暂无内容预览'),
TextColumn::make('knowledgeBase.name')
->label('所属知识库')
->sortable(),
TextColumn::make('type')
->label('文档类型')
->badge()
->formatStateUsing(fn (string $state): string => match ($state) {
'global' => '全局知识库',
'dedicated' => '专用知识库',
default => $state,
})
->color(fn (string $state): string => match ($state) {
'global' => 'success',
'dedicated' => 'info',
default => 'gray',
}),
TextColumn::make('group.name')
->label('所属分组')
->default('无')
TextColumn::make('uploader.name')
->label('上传者')
->sortable(),
TextColumn::make('created_at')
@@ -147,7 +113,7 @@ class SearchPage extends Page implements HasForms, HasTable
->modalSubmitAction(false)
->modalCancelActionLabel('关闭')
->visible(fn (Document $record) => $record->conversion_status === 'completed'),
Action::make('download')
->label('下载')
->icon('heroicon-o-arrow-down-tray')
@@ -155,11 +121,7 @@ class SearchPage extends Page implements HasForms, HasTable
try {
$documentService = app(DocumentService::class);
$user = Auth::user();
// 记录下载日志
$documentService->logDownload($record, $user);
// 返回文件下载响应
return $documentService->downloadDocument($record, $user);
} catch (\Exception $e) {
Notification::make()
@@ -177,53 +139,39 @@ class SearchPage extends Page implements HasForms, HasTable
->emptyStateIcon('heroicon-o-magnifying-glass');
}
/**
* 获取表格查询构建器
*/
protected function getTableQuery(): Builder
{
if (!$this->hasSearched || empty($this->searchQuery)) {
// 如果还没有搜索或搜索关键词为空,返回空查询
return Document::query()->whereRaw('1 = 0');
}
// 使用 DocumentSearchService 进行搜索
$searchService = app(DocumentSearchService::class);
$user = Auth::user();
$filters = [];
if ($this->documentType) {
$filters['type'] = $this->documentType;
if (!empty($this->stationIds)) {
$filters['station_ids'] = $this->stationIds;
}
if ($this->groupId) {
$filters['group_id'] = $this->groupId;
if (!empty($this->knowledgeBaseIds)) {
$filters['knowledge_base_ids'] = $this->knowledgeBaseIds;
}
// 执行搜索
$results = $searchService->search($this->searchQuery, $user, $filters);
// 获取搜索结果的 ID 列表
$accessibleStationIds = Auth::user()->getAccessibleStationIds();
$results = $searchService->search($this->searchQuery, $accessibleStationIds, $filters);
$documentIds = $results->pluck('id')->toArray();
// 返回包含这些 ID 的查询构建器
if (empty($documentIds)) {
return Document::query()->whereRaw('1 = 0');
}
return Document::query()
->whereIn('id', $documentIds)
->with(['group', 'uploader']);
->with(['knowledgeBase', 'uploader']);
}
/**
* 执行搜索
*/
public function search(): void
{
// 验证表单
$data = $this->form->getState();
// 检查搜索关键词是否为空
if (empty($data['searchQuery'])) {
Notification::make()
->title('请输入搜索关键词')
@@ -232,13 +180,11 @@ class SearchPage extends Page implements HasForms, HasTable
return;
}
// 更新搜索参数
$this->searchQuery = $data['searchQuery'];
$this->documentType = $data['documentType'];
$this->groupId = $data['groupId'];
$this->stationIds = $data['stationIds'] ?? [];
$this->knowledgeBaseIds = $data['knowledgeBaseIds'] ?? [];
$this->hasSearched = true;
// 重置表格分页
$this->resetTable();
Notification::make()
@@ -247,20 +193,17 @@ class SearchPage extends Page implements HasForms, HasTable
->send();
}
/**
* 清空搜索
*/
public function clearSearch(): void
{
$this->form->fill([
'searchQuery' => '',
'documentType' => null,
'groupId' => null,
'stationIds' => [],
'knowledgeBaseIds' => [],
]);
$this->searchQuery = null;
$this->documentType = null;
$this->groupId = null;
$this->stationIds = [];
$this->knowledgeBaseIds = [];
$this->hasSearched = false;
$this->resetTable();
@@ -271,9 +214,6 @@ class SearchPage extends Page implements HasForms, HasTable
->send();
}
/**
* 获取页面头部操作
*/
protected function getHeaderActions(): array
{
return [];

View File

@@ -0,0 +1,260 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\ActivityLogResource\Pages;
use App\Exports\ActivityLogExport;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Maatwebsite\Excel\Facades\Excel;
use Spatie\Activitylog\Models\Activity;
class ActivityLogResource extends Resource
{
protected static ?string $model = Activity::class;
protected static ?string $navigationIcon = 'heroicon-o-clipboard-document-list';
protected static ?string $navigationLabel = '操作日志';
protected static ?string $modelLabel = '操作日志';
protected static ?string $pluralModelLabel = '操作日志';
protected static ?int $navigationSort = 2;
protected static ?string $navigationGroup = '系统管理';
/**
* 控制导航菜单是否显示
*/
public static function shouldRegisterNavigation(): bool
{
return auth()->user()?->can('activity-log.view') ?? false;
}
// 禁用创建功能
public static function canCreate(): bool
{
return false;
}
public static function form(Form $form): Form
{
return $form
->schema([
// 只读资源,不需要表单
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('created_at')
->label('操作时间')
->dateTime('Y-m-d H:i:s')
->sortable()
->searchable(),
Tables\Columns\TextColumn::make('causer.name')
->label('操作用户')
->searchable()
->sortable()
->default('系统')
->placeholder('系统'),
Tables\Columns\TextColumn::make('description')
->label('操作类型')
->badge()
->color(fn (string $state): string => match ($state) {
'created' => 'success',
'updated' => 'info',
'deleted' => 'danger',
default => 'gray',
})
->formatStateUsing(fn (string $state): string => match ($state) {
'created' => '创建',
'updated' => '更新',
'deleted' => '删除',
default => $state,
})
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('subject_type')
->label('操作对象')
->formatStateUsing(function (?string $state): string {
if (!$state) {
return '-';
}
// 提取类名
$className = class_basename($state);
// 转换为中文
return match ($className) {
'SystemSetting' => '系统设置',
'User' => '用户',
'Document' => '文档',
'Group' => '分组',
'Terminal' => '终端',
'Guide' => '操作指引',
default => $className,
};
})
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('subject_id')
->label('对象ID')
->searchable()
->sortable()
->toggleable(),
Tables\Columns\TextColumn::make('properties')
->label('详情')
->limit(50)
->tooltip(function (Tables\Columns\TextColumn $column): ?string {
$state = $column->getState();
if (is_array($state)) {
return json_encode($state, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
}
return null;
})
->formatStateUsing(function ($state): string {
if (is_array($state)) {
$summary = [];
if (isset($state['attributes'])) {
$summary[] = '新值: ' . count($state['attributes']) . '项';
}
if (isset($state['old'])) {
$summary[] = '旧值: ' . count($state['old']) . '项';
}
return implode(', ', $summary) ?: '无变更';
}
return '-';
})
->toggleable(),
])
->defaultSort('created_at', 'desc')
->filters([
// 时间范围筛选
Tables\Filters\Filter::make('created_at')
->form([
\Filament\Forms\Components\DatePicker::make('created_from')
->label('开始时间')
->placeholder('选择开始时间'),
\Filament\Forms\Components\DatePicker::make('created_until')
->label('结束时间')
->placeholder('选择结束时间'),
])
->query(function ($query, array $data) {
return $query
->when($data['created_from'], fn ($query, $date) => $query->whereDate('created_at', '>=', $date))
->when($data['created_until'], fn ($query, $date) => $query->whereDate('created_at', '<=', $date));
})
->indicateUsing(function (array $data): array {
$indicators = [];
if ($data['created_from'] ?? null) {
$indicators[] = Tables\Filters\Indicator::make('开始时间: ' . \Carbon\Carbon::parse($data['created_from'])->format('Y-m-d'))
->removeField('created_from');
}
if ($data['created_until'] ?? null) {
$indicators[] = Tables\Filters\Indicator::make('结束时间: ' . \Carbon\Carbon::parse($data['created_until'])->format('Y-m-d'))
->removeField('created_until');
}
return $indicators;
}),
// 操作类型筛选
Tables\Filters\SelectFilter::make('description')
->label('操作类型')
->options([
'created' => '创建',
'updated' => '更新',
'deleted' => '删除',
])
->placeholder('全部类型'),
// 用户筛选
Tables\Filters\SelectFilter::make('causer_id')
->label('操作用户')
->options(function () {
return \App\Models\User::pluck('name', 'id')->toArray();
})
->searchable()
->placeholder('全部用户'),
// 对象类型筛选
Tables\Filters\SelectFilter::make('subject_type')
->label('对象类型')
->options([
'App\\Models\\SystemSetting' => '系统设置',
'App\\Models\\User' => '用户',
'App\\Models\\Document' => '文档',
'App\\Models\\Group' => '分组',
'App\\Models\\Terminal' => '终端',
'App\\Models\\Guide' => '操作指引',
])
->placeholder('全部类型'),
])
->actions([
Tables\Actions\ViewAction::make()
->label('查看'),
])
->bulkActions([
// 不允许批量操作
])
->headerActions([
// 导出操作
Tables\Actions\Action::make('export')
->label('导出日志')
->icon('heroicon-o-arrow-down-tray')
->form([
\Filament\Forms\Components\Select::make('format')
->label('导出格式')
->options([
'xlsx' => 'Excel (XLSX)',
'csv' => 'CSV',
])
->default('xlsx')
->required(),
])
->action(function (array $data) {
// 获取基础查询(不包含筛选)
$query = Activity::query();
// 导出文件名
$filename = '操作日志_' . now()->format('YmdHis');
// 根据格式导出
if ($data['format'] === 'csv') {
return Excel::download(
new ActivityLogExport($query),
$filename . '.csv',
\Maatwebsite\Excel\Excel::CSV
);
}
return Excel::download(
new ActivityLogExport($query),
$filename . '.xlsx'
);
})
->color('success')
->requiresConfirmation()
->modalHeading('导出操作日志')
->modalDescription('将导出所有日志数据')
->modalSubmitActionLabel('确认导出'),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListActivityLogs::route('/'),
'view' => Pages\ViewActivityLog::route('/{record}'),
];
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Filament\Resources\ActivityLogResource\Pages;
use App\Filament\Resources\ActivityLogResource;
use Filament\Resources\Pages\ListRecords;
class ListActivityLogs extends ListRecords
{
protected static string $resource = ActivityLogResource::class;
protected function getHeaderActions(): array
{
return [
// 不允许创建操作
];
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Filament\Resources\ActivityLogResource\Pages;
use App\Filament\Resources\ActivityLogResource;
use Filament\Infolists;
use Filament\Infolists\Infolist;
use Filament\Resources\Pages\ViewRecord;
class ViewActivityLog extends ViewRecord
{
protected static string $resource = ActivityLogResource::class;
protected function getHeaderActions(): array
{
return [
// 不允许编辑和删除操作
];
}
public function infolist(Infolist $infolist): Infolist
{
return $infolist
->schema([
Infolists\Components\Section::make('基本信息')
->schema([
Infolists\Components\TextEntry::make('created_at')
->label('操作时间')
->dateTime('Y-m-d H:i:s'),
Infolists\Components\TextEntry::make('causer.name')
->label('操作用户')
->default('系统'),
Infolists\Components\TextEntry::make('description')
->label('操作类型')
->badge()
->color(fn (string $state): string => match ($state) {
'created' => 'success',
'updated' => 'info',
'deleted' => 'danger',
default => 'gray',
})
->formatStateUsing(fn (string $state): string => match ($state) {
'created' => '创建',
'updated' => '更新',
'deleted' => '删除',
default => $state,
}),
Infolists\Components\TextEntry::make('subject_type')
->label('操作对象类型')
->formatStateUsing(function (?string $state): string {
if (!$state) {
return '-';
}
$className = class_basename($state);
return match ($className) {
'SystemSetting' => '系统设置',
'User' => '用户',
'Document' => '文档',
'Group' => '分组',
'Terminal' => '终端',
'Guide' => '操作指引',
default => $className,
};
}),
Infolists\Components\TextEntry::make('subject_id')
->label('对象ID'),
Infolists\Components\TextEntry::make('log_name')
->label('日志名称')
->default('default'),
])
->columns(2),
Infolists\Components\Section::make('变更详情')
->schema([
Infolists\Components\ViewEntry::make('properties')
->label('')
->view('filament.infolists.components.activity-log-diff')
->columnSpanFull()
->visible(fn ($record) => !empty($record->properties)),
])
->collapsible(),
]);
}
}

View File

@@ -27,16 +27,26 @@ class DocumentResource extends Resource
protected static ?int $navigationSort = 1;
protected static ?string $navigationGroup = '知识库管理';
/**
* 控制导航菜单是否显示
*/
public static function shouldRegisterNavigation(): bool
{
return auth()->user()?->can('document.view') ?? false;
}
public static function getEloquentQuery(): Builder
{
$query = parent::getEloquentQuery();
// 应用 accessibleBy 作用域,确保用户只能看到有权限的文档
$user = auth()->user();
if ($user) {
$query->accessibleBy($user);
if ($user && $user->hasStationRestriction()) {
$accessibleKbIds = \App\Models\KnowledgeBase::accessibleBy($user)->pluck('id');
$query->whereIn('knowledge_base_id', $accessibleKbIds);
}
return $query;
}
@@ -50,49 +60,40 @@ class DocumentResource extends Resource
->maxLength(255)
->placeholder('请输入文档标题')
->columnSpanFull(),
Forms\Components\Textarea::make('description')
->label('文档描述')
->rows(3)
->maxLength(65535)
->placeholder('请输入文档描述(可选)')
->columnSpanFull(),
Forms\Components\FileUpload::make('file')
->label('文档文件')
->required()
->acceptedFileTypes(['application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'])
->acceptedFileTypes(config('documents.supported_formats.mime_types', [
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/pdf',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
]))
->maxSize(51200) // 50MB
->storeFileNamesIn('file_name')
->disk('local')
->directory('documents/' . date('Y/m/d'))
->visibility('private')
->downloadable()
->preserveFilenames() // 保留原始文件名
->helperText('仅支持 .doc 和 .docx 格式,最大 50MB')
->helperText('支持 .docx/.pptx/.xlsx/.pdf 格式,最大 50MB')
->columnSpanFull(),
Forms\Components\Select::make('type')
->label('文档类型')
Forms\Components\Select::make('knowledge_base_id')
->label('所属知识库')
->relationship('knowledgeBase', 'name')
->required()
->options([
'global' => '全局知识库',
'dedicated' => '专用知识库',
])
->default('global')
->reactive()
->afterStateUpdated(fn ($state, callable $set) =>
$state === 'global' ? $set('group_id', null) : null
)
->helperText('全局知识库所有用户可见,专用知识库仅指定分组可见'),
Forms\Components\Select::make('group_id')
->label('所属分组')
->relationship('group', 'name')
->searchable()
->preload()
->required(fn (Forms\Get $get): bool => $get('type') === 'dedicated')
->visible(fn (Forms\Get $get): bool => $get('type') === 'dedicated')
->helperText('专用知识库必须选择所属分组'),
->helperText('选择文档所属的知识库'),
]);
}
@@ -112,51 +113,35 @@ class DocumentResource extends Resource
}
return null;
}),
Tables\Columns\TextColumn::make('type')
->label('文档类型')
->badge()
->color(fn (string $state): string => match ($state) {
'global' => 'success',
'dedicated' => 'warning',
})
->formatStateUsing(fn (string $state): string => match ($state) {
'global' => '全局知识库',
'dedicated' => '专用知识库',
default => $state,
})
->sortable(),
Tables\Columns\TextColumn::make('group.name')
->label('所属分组')
Tables\Columns\TextColumn::make('knowledgeBase.name')
->label('所属知识库')
->searchable()
->sortable()
->placeholder('—')
->toggleable(),
->sortable(),
Tables\Columns\TextColumn::make('uploader.name')
->label('上传者')
->searchable()
->sortable()
->toggleable(),
Tables\Columns\TextColumn::make('file_size')
->label('文件大小')
->formatStateUsing(fn ($state): string => self::formatFileSize($state))
->formatStateUsing(fn($state): string => self::formatFileSize($state))
->sortable()
->toggleable(),
Tables\Columns\TextColumn::make('conversion_status')
->label('转换状态')
->badge()
->color(fn (?string $state): string => match ($state) {
->color(fn(?string $state): string => match ($state) {
'completed' => 'success',
'processing' => 'info',
'pending' => 'warning',
'failed' => 'danger',
default => 'gray',
})
->formatStateUsing(fn (?string $state): string => match ($state) {
->formatStateUsing(fn(?string $state): string => match ($state) {
'completed' => '已完成',
'processing' => '转换中',
'pending' => '等待转换',
@@ -165,13 +150,13 @@ class DocumentResource extends Resource
})
->sortable()
->toggleable(),
Tables\Columns\TextColumn::make('created_at')
->label('上传时间')
->dateTime('Y年m月d日 H:i')
->sortable()
->toggleable(),
Tables\Columns\TextColumn::make('updated_at')
->label('更新时间')
->dateTime('Y年m月d日 H:i')
@@ -179,28 +164,20 @@ class DocumentResource extends Resource
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\Filters\SelectFilter::make('type')
->label('文档类型')
->options([
'global' => '全局知识库',
'dedicated' => '专用知识库',
])
->placeholder('全部类型'),
Tables\Filters\SelectFilter::make('group_id')
->label('所属分组')
->relationship('group', 'name')
Tables\Filters\SelectFilter::make('knowledge_base_id')
->label('所属知识库')
->relationship('knowledgeBase', 'name')
->searchable()
->preload()
->placeholder('全部分组'),
->placeholder('全部知识库'),
Tables\Filters\SelectFilter::make('uploaded_by')
->label('上传者')
->relationship('uploader', 'name')
->searchable()
->preload()
->placeholder('全部上传者'),
Tables\Filters\SelectFilter::make('conversion_status')
->label('转换状态')
->options([
@@ -212,16 +189,75 @@ class DocumentResource extends Resource
->placeholder('全部状态'),
])
->actions([
Tables\Actions\Action::make('retry_conversion')
->label('重试转换')
->icon('heroicon-o-arrow-path')
->color('warning')
->visible(
fn(Document $record): bool =>
in_array($record->conversion_status, ['failed', 'processing', 'pending'])
)
->requiresConfirmation()
->modalHeading('重试文档转换')
->modalDescription(
fn(Document $record): string =>
'确定要重新转换文档 "' . $record->title . '" 吗?' .
"\n\n当前状态:" . match ($record->conversion_status) {
'failed' => '转换失败',
'processing' => '转换中(可能卡住)',
'pending' => '等待转换',
default => $record->conversion_status,
} .
($record->conversion_error ? "\n\n错误信息:" . $record->conversion_error : '')
)
->modalSubmitActionLabel('确认重试')
->action(function (Document $record) {
try {
app(\App\Services\DocumentConversionService::class)
->queueConversion($record);
\Filament\Notifications\Notification::make()
->success()
->title('重试成功')
->body('文档转换任务已重新加入队列,请稍后查看转换结果。')
->send();
} catch (\Exception $e) {
\Filament\Notifications\Notification::make()
->danger()
->title('重试失败')
->body('无法重新派发转换任务:' . $e->getMessage())
->send();
}
}),
Tables\Actions\Action::make('view_error')
->label('查看错误')
->icon('heroicon-o-exclamation-triangle')
->color('danger')
->visible(
fn(Document $record): bool =>
$record->conversion_status === 'failed' && !empty($record->conversion_error)
)
->modalHeading('转换错误详情')
->modalContent(
fn(Document $record): \Illuminate\Contracts\View\View =>
view('filament.modals.conversion-error', [
'document' => $record,
'error' => $record->conversion_error,
])
)
->modalSubmitAction(false)
->modalCancelActionLabel('关闭'),
Tables\Actions\Action::make('preview')
->label('预览 Markdown')
->label('预览 PDF')
->icon('heroicon-o-eye')
->color('info')
->visible(fn (Document $record): bool => $record->conversion_status === 'completed')
->url(fn (Document $record): string => route('documents.preview', $record))
->visible(fn(Document $record): bool => $record->conversion_status === 'completed')
->url(fn(Document $record): string => route('documents.preview', $record))
->openUrlInNewTab()
->tooltip(fn (Document $record): ?string =>
$record->conversion_status !== 'completed'
? '文档尚未完成转换'
->tooltip(
fn(Document $record): ?string =>
$record->conversion_status !== 'completed'
? '文档尚未完成转换'
: null
),
Tables\Actions\Action::make('download')
@@ -231,11 +267,11 @@ class DocumentResource extends Resource
->action(function (Document $record) {
$documentService = app(\App\Services\DocumentService::class);
$user = auth()->user();
try {
// 记录下载日志
$documentService->logDownload($record, $user);
// 返回文件下载响应
return $documentService->downloadDocument($record, $user);
} catch (\Exception $e) {
@@ -244,7 +280,7 @@ class DocumentResource extends Resource
->title('下载失败')
->body($e->getMessage())
->send();
return null;
}
}),
@@ -270,15 +306,15 @@ class DocumentResource extends Resource
public static function formatFileSize(?int $bytes): string
{
if ($bytes === null) {
return '';
return '-';
}
$units = ['B', 'KB', 'MB', 'GB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= (1 << (10 * $pow));
return round($bytes, 2) . ' ' . $units[$pow];
}

View File

@@ -3,8 +3,6 @@
namespace App\Filament\Resources\DocumentResource\Pages;
use App\Filament\Resources\DocumentResource;
use App\Services\DocumentService;
use Filament\Actions;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Support\Facades\Auth;
@@ -16,37 +14,24 @@ class CreateDocument extends CreateRecord
protected function mutateFormDataBeforeCreate(array $data): array
{
// 设置上传者为当前用户
$data['uploaded_by'] = Auth::id();
// 如果是全局文档,确保 group_id 为 null
if ($data['type'] === 'global') {
$data['group_id'] = null;
}
// 处理文件上传
if (isset($data['file'])) {
$filePath = $data['file'];
// 获取原始文件名(由于使用了 preserveFilenames()basename 就是原始文件名)
$originalFileName = basename($filePath);
// 保存文件信息
$data['file_path'] = $filePath;
$data['file_name'] = $originalFileName; // 保存原始文件名
$data['file_name'] = $data['file_name'] ?? basename($filePath);
$data['file_size'] = Storage::disk('local')->size($filePath);
$data['mime_type'] = Storage::disk('local')->mimeType($filePath);
// 移除临时的 file 字段
unset($data['file']);
}
return $data;
}
protected function afterCreate(): void
{
// 文档创建后,触发转换任务
$conversionService = app(\App\Services\DocumentConversionService::class);
$conversionService->queueConversion($this->record);
}

View File

@@ -6,12 +6,15 @@ use App\Filament\Resources\DocumentResource;
use Filament\Actions;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
class EditDocument extends EditRecord
{
protected static string $resource = DocumentResource::class;
private ?string $previousFilePath = null;
protected function getHeaderActions(): array
{
return [
@@ -24,60 +27,51 @@ class EditDocument extends EditRecord
protected function mutateFormDataBeforeFill(array $data): array
{
// 将文件路径设置到 file 字段以便显示
$this->previousFilePath = $data['file_path'] ?? null;
if (isset($data['file_path'])) {
$data['file'] = $data['file_path'];
}
return $data;
}
protected function mutateFormDataBeforeSave(array $data): array
{
// 如果是全局文档,确保 group_id 为 null
if ($data['type'] === 'global') {
$data['group_id'] = null;
}
// 处理文件更新
if (isset($data['file']) && $data['file'] !== $this->record->file_path) {
$filePath = $data['file'];
// 删除旧的 Word 文件
if ($this->record->file_path && Storage::disk('local')->exists($this->record->file_path)) {
Storage::disk('local')->delete($this->record->file_path);
$currentFile = $data['file'] ?? null;
// 检测文件是否变更:与填充时记录的原始路径比较
if ($currentFile && $currentFile !== $this->previousFilePath) {
// 删除旧文件
if ($this->previousFilePath && Storage::disk('local')->exists($this->previousFilePath)) {
Storage::disk('local')->delete($this->previousFilePath);
}
// 删除旧的 Markdown 文件
if ($this->record->markdown_path && Storage::disk('markdown')->exists($this->record->markdown_path)) {
Storage::disk('markdown')->delete($this->record->markdown_path);
}
// 获取原始文件名(由于使用了 preserveFilenames()basename 就是原始文件名)
$originalFileName = basename($filePath);
// 更新文件信息
$data['file_path'] = $filePath;
$data['file_name'] = $originalFileName; // 保存原始文件名
$data['file_size'] = Storage::disk('local')->size($filePath);
$data['mime_type'] = Storage::disk('local')->mimeType($filePath);
// 重置转换状态,准备重新转换
app(\App\Services\DocumentPdfPreviewService::class)->clearCachedPreview($this->record);
$data['file_path'] = $currentFile;
$data['file_name'] = $data['file_name'] ?? basename($currentFile);
$data['file_size'] = Storage::disk('local')->size($currentFile);
$data['mime_type'] = Storage::disk('local')->mimeType($currentFile);
// 重置转换状态,触发重新转换
$data['conversion_status'] = 'pending';
$data['markdown_path'] = null;
$data['markdown_preview'] = null;
$data['conversion_error'] = null;
}
// 移除临时的 file 字段
unset($data['file']);
return $data;
}
protected function afterSave(): void
{
// 如果文档的转换状态是 pending说明文件已更新需要触发重新转换
// 刷新模型以获取最新数据库状态
$this->record->refresh();
if ($this->record->conversion_status === 'pending') {
$conversionService = app(\App\Services\DocumentConversionService::class);
$conversionService->queueConversion($this->record);

View File

@@ -3,7 +3,6 @@
namespace App\Filament\Resources\DocumentResource\Pages;
use App\Filament\Resources\DocumentResource;
use App\Services\DocumentPreviewService;
use App\Services\DocumentService;
use Filament\Actions;
use Filament\Infolists\Components\Section;
@@ -20,8 +19,43 @@ class ViewDocument extends ViewRecord
protected function getHeaderActions(): array
{
return [
Actions\Action::make('retry_conversion')
->label('重试转换')
->icon('heroicon-o-arrow-path')
->color('warning')
->visible(fn (): bool => $this->record->conversion_status === 'failed')
->requiresConfirmation()
->modalHeading('重试文档转换')
->modalDescription(fn (): string =>
'确定要重新转换文档 "' . $this->record->title . '" 吗?' .
($this->record->conversion_error ? "\n\n上次失败原因:" . $this->record->conversion_error : '')
)
->modalSubmitActionLabel('确认重试')
->action(function () {
try {
app(\App\Services\DocumentConversionService::class)
->queueConversion($this->record);
Notification::make()
->success()
->title('重试成功')
->body('文档转换任务已重新加入队列,请稍后查看转换结果。')
->send();
$this->refreshFormData([
'conversion_status',
'conversion_error',
]);
} catch (\Exception $e) {
Notification::make()
->danger()
->title('重试失败')
->body('无法重新派发转换任务:' . $e->getMessage())
->send();
}
}),
Actions\Action::make('preview')
->label('预览 Markdown')
->label('预览 PDF')
->icon('heroicon-o-eye')
->color('info')
->visible(fn (): bool => $this->record->conversion_status === 'completed')
@@ -79,34 +113,37 @@ class ViewDocument extends ViewRecord
->placeholder('无描述')
->columnSpanFull(),
TextEntry::make('type')
->label('文档类型')
->badge()
->color(fn (string $state): string => match ($state) {
'global' => 'success',
'dedicated' => 'warning',
})
->formatStateUsing(fn (string $state): string => match ($state) {
'global' => '全局知识库',
'dedicated' => '专用知识库',
default => $state,
}),
TextEntry::make('group.name')
->label('所属分组')
->placeholder('—')
->visible(fn ($record) => $record->type === 'dedicated'),
TextEntry::make('knowledgeBase.name')
->label('所属知识库'),
TextEntry::make('uploader.name')
->label('上传者'),
TextEntry::make('file_name')
TextEntry::make('display_file_name')
->label('文件名'),
TextEntry::make('file_size')
->label('文件大小')
->formatStateUsing(fn ($state): string => DocumentResource::formatFileSize($state)),
TextEntry::make('conversion_status')
->label('转换状态')
->badge()
->color(fn (?string $state): string => match ($state) {
'completed' => 'success',
'processing' => 'info',
'pending' => 'warning',
'failed' => 'danger',
default => 'gray',
})
->formatStateUsing(fn (?string $state): string => match ($state) {
'completed' => '已完成',
'processing' => '转换中',
'pending' => '等待转换',
'failed' => '转换失败',
default => '未知',
}),
TextEntry::make('created_at')
->label('上传时间')
->dateTime('Y年m月d日 H:i:s'),
@@ -117,6 +154,19 @@ class ViewDocument extends ViewRecord
])
->columns(2),
Section::make('转换错误信息')
->schema([
ViewEntry::make('conversion_error')
->label('')
->view('filament.resources.document.conversion-error-detail')
->viewData([
'document' => $this->record,
]),
])
->visible(fn ($record) => $record->conversion_status === 'failed' && !empty($record->conversion_error))
->collapsible()
->collapsed(false),
Section::make('文档预览')
->schema([
ViewEntry::make('preview')

View File

@@ -1,114 +0,0 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\GroupResource\Pages;
use App\Filament\Resources\GroupResource\RelationManagers;
use App\Models\Group;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
class GroupResource extends Resource
{
protected static ?string $model = Group::class;
protected static ?string $navigationIcon = 'heroicon-o-user-group';
protected static ?string $navigationLabel = '分组管理';
protected static ?string $modelLabel = '分组';
protected static ?string $pluralModelLabel = '分组';
protected static ?int $navigationSort = 2;
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('name')
->label('分组名称')
->required()
->maxLength(255)
->placeholder('请输入分组名称'),
Forms\Components\Textarea::make('description')
->label('分组描述')
->rows(3)
->maxLength(65535)
->placeholder('请输入分组描述(可选)')
->columnSpanFull(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('id')
->label('ID')
->sortable(),
Tables\Columns\TextColumn::make('name')
->label('分组名称')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('description')
->label('分组描述')
->limit(50)
->searchable()
->toggleable(),
Tables\Columns\TextColumn::make('users_count')
->label('成员数量')
->counts('users')
->sortable(),
Tables\Columns\TextColumn::make('created_at')
->label('创建时间')
->dateTime('Y-m-d H:i:s')
->sortable()
->toggleable(),
Tables\Columns\TextColumn::make('updated_at')
->label('更新时间')
->dateTime('Y-m-d H:i:s')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
])
->actions([
Tables\Actions\ViewAction::make()
->label('查看'),
Tables\Actions\EditAction::make()
->label('编辑'),
Tables\Actions\DeleteAction::make()
->label('删除'),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make()
->label('批量删除'),
]),
])
->defaultSort('created_at', 'desc');
}
public static function getRelations(): array
{
return [
RelationManagers\UsersRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListGroups::route('/'),
'create' => Pages\CreateGroup::route('/create'),
'edit' => Pages\EditGroup::route('/{record}/edit'),
];
}
}

View File

@@ -1,24 +0,0 @@
<?php
namespace App\Filament\Resources\GroupResource\Pages;
use App\Filament\Resources\GroupResource;
use Filament\Actions;
use Filament\Resources\Pages\CreateRecord;
class CreateGroup extends CreateRecord
{
protected static string $resource = GroupResource::class;
protected static ?string $title = '创建分组';
protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('index');
}
protected function getCreatedNotificationTitle(): ?string
{
return '分组创建成功';
}
}

View File

@@ -1,31 +0,0 @@
<?php
namespace App\Filament\Resources\GroupResource\Pages;
use App\Filament\Resources\GroupResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditGroup extends EditRecord
{
protected static string $resource = GroupResource::class;
protected static ?string $title = '编辑分组';
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make()
->label('删除')
->modalHeading('删除分组')
->modalDescription('确定要删除此分组吗?此操作无法撤销。')
->modalSubmitActionLabel('确认删除')
->modalCancelActionLabel('取消'),
];
}
protected function getSavedNotificationTitle(): ?string
{
return '分组更新成功';
}
}

View File

@@ -1,22 +0,0 @@
<?php
namespace App\Filament\Resources\GroupResource\Pages;
use App\Filament\Resources\GroupResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListGroups extends ListRecords
{
protected static string $resource = GroupResource::class;
protected static ?string $title = '分组列表';
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make()
->label('创建分组'),
];
}
}

View File

@@ -1,88 +0,0 @@
<?php
namespace App\Filament\Resources\GroupResource\RelationManagers;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
class UsersRelationManager extends RelationManager
{
protected static string $relationship = 'users';
protected static ?string $title = '分组成员';
protected static ?string $modelLabel = '用户';
public function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('name')
->label('用户名称')
->required()
->maxLength(255),
Forms\Components\TextInput::make('email')
->label('邮箱')
->email()
->required()
->maxLength(255),
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('name')
->columns([
Tables\Columns\TextColumn::make('id')
->label('ID')
->sortable(),
Tables\Columns\TextColumn::make('name')
->label('用户名称')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('email')
->label('邮箱')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('created_at')
->label('加入时间')
->dateTime('Y-m-d H:i:s')
->sortable(),
])
->filters([
//
])
->headerActions([
Tables\Actions\AttachAction::make()
->label('添加成员')
->preloadRecordSelect()
->modalHeading('添加分组成员')
->modalSubmitActionLabel('添加')
->modalCancelActionLabel('取消'),
])
->actions([
Tables\Actions\DetachAction::make()
->label('移除')
->modalHeading('移除分组成员')
->modalDescription('确定要将此用户从分组中移除吗?')
->modalSubmitActionLabel('确认移除')
->modalCancelActionLabel('取消'),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DetachBulkAction::make()
->label('批量移除')
->modalHeading('批量移除分组成员')
->modalDescription('确定要将选中的用户从分组中移除吗?')
->modalSubmitActionLabel('确认移除')
->modalCancelActionLabel('取消'),
]),
]);
}
}

View File

@@ -0,0 +1,247 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\GuideResource\Pages;
use App\Models\Guide;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class GuideResource extends Resource
{
protected static ?string $model = Guide::class;
protected static ?string $navigationIcon = 'heroicon-o-book-open';
protected static ?string $navigationLabel = '操作指引';
protected static ?string $modelLabel = '指引';
protected static ?string $pluralModelLabel = '指引';
protected static ?int $navigationSort = 3;
protected static ?string $navigationGroup = '业务管理';
public static function shouldRegisterNavigation(): bool
{
return auth()->user()?->can('guide.view') ?? false;
}
public static function getEloquentQuery(): Builder
{
$query = parent::getEloquentQuery();
$user = auth()->user();
if ($user && $user->hasStationRestriction()) {
$query->accessibleBy($user);
}
return $query;
}
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\Section::make('基本信息')
->schema([
Forms\Components\TextInput::make('name')
->label('指引名称')
->required()
->maxLength(255)
->placeholder('例如: 如何用光'),
Forms\Components\Select::make('category')
->label('分类')
->required()
->options([
'operation' => '操作指引',
'fault_handling' => '故障处理',
'training' => '培训教程',
'safety' => '安全规范',
'maintenance' => '维护保养',
])
->default('operation'),
Forms\Components\Select::make('status')
->label('状态')
->required()
->options([
'draft' => '草稿',
'published' => '已发布',
'archived' => '已归档',
])
->default('draft'),
Forms\Components\TagsInput::make('tags')
->label('标签')
->placeholder('输入标签后回车')
->helperText('用于分类和搜索的关键词标签'),
Forms\Components\Textarea::make('description')
->label('描述')
->maxLength(1000)
->placeholder('简要描述此指引的用途')
->columnSpanFull(),
])
->columns(2),
Forms\Components\Section::make('关联线站')
->schema([
Forms\Components\CheckboxList::make('stations')
->label('适用线站')
->relationship('stations', 'name')
->searchable()
->bulkToggleable()
->helperText('选择此指引适用的线站,未关联线站的指引为全局指引')
->columns(3),
])
->description('不关联任何线站则为全局指引,对所有终端可见'),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
->label('指引名称')
->searchable()
->sortable()
->weight('bold'),
Tables\Columns\TextColumn::make('category')
->label('分类')
->badge()
->formatStateUsing(fn(string $state): string => match ($state) {
'operation' => '操作指引',
'fault_handling' => '故障处理',
'training' => '培训教程',
'safety' => '安全规范',
'maintenance' => '维护保养',
default => $state,
})
->color(fn(string $state): string => match ($state) {
'operation' => 'primary',
'fault_handling' => 'danger',
'training' => 'info',
'safety' => 'warning',
'maintenance' => 'gray',
default => 'gray',
})
->sortable(),
Tables\Columns\TextColumn::make('status')
->label('状态')
->badge()
->formatStateUsing(fn(string $state): string => match ($state) {
'draft' => '草稿',
'published' => '已发布',
'archived' => '已归档',
default => $state,
})
->color(fn(string $state): string => match ($state) {
'draft' => 'gray',
'published' => 'success',
'archived' => 'warning',
default => 'gray',
})
->sortable(),
Tables\Columns\TextColumn::make('pages_count')
->label('页数')
->counts('pages')
->sortable(),
Tables\Columns\TextColumn::make('stations_count')
->label('关联线站')
->counts('stations')
->sortable()
->badge()
->color(fn(int $state): string => $state > 0 ? 'info' : 'success')
->formatStateUsing(fn(int $state): string => $state > 0 ? "{$state}" : '全局'),
Tables\Columns\TextColumn::make('created_at')
->label('创建时间')
->dateTime('Y-m-d H:i')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\Filters\SelectFilter::make('category')
->label('分类')
->options([
'operation' => '操作指引',
'fault_handling' => '故障处理',
'training' => '培训教程',
'safety' => '安全规范',
'maintenance' => '维护保养',
]),
Tables\Filters\SelectFilter::make('status')
->label('状态')
->options([
'draft' => '草稿',
'published' => '已发布',
'archived' => '已归档',
]),
])
->actions([
Tables\Actions\EditAction::make()->label('编辑'),
Tables\Actions\Action::make('duplicate')
->label('复制')
->icon('heroicon-o-document-duplicate')
->color('info')
->requiresConfirmation()
->action(function (Guide $record) {
$newGuide = $record->replicate(['pages_count', 'stations_count']);
$newGuide->name = $record->name . ' (副本)';
$newGuide->created_by = auth()->id();
$newGuide->published_at = null;
$newGuide->save();
// 复制页面
$pageIdMap = [];
foreach ($record->pages as $page) {
$newPage = $page->replicate();
$newPage->guide_id = $newGuide->id;
$newPage->save();
$pageIdMap[$page->id] = $newPage->id;
}
// 复制边edges并更新页面 ID 映射
foreach ($record->edges as $edge) {
$newEdge = $edge->replicate();
$newEdge->guide_id = $newGuide->id;
$newEdge->from_page_id = $pageIdMap[$edge->from_page_id] ?? $edge->from_page_id;
$newEdge->to_page_id = $pageIdMap[$edge->to_page_id] ?? $edge->to_page_id;
$newEdge->save();
}
return redirect()->to(route('filament.admin.resources.guides.edit', ['record' => $newGuide]));
}),
Tables\Actions\DeleteAction::make()->label('删除'),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make()->label('批量删除'),
]),
])
->defaultSort('created_at', 'desc');
}
public static function getPages(): array
{
return [
'index' => Pages\ListGuides::route('/'),
'create' => Pages\CreateGuide::route('/create'),
'edit' => Pages\EditGuide::route('/{record}/edit'),
'manage-pages' => Pages\ManageGuidePages::route('/{record}/manage-pages'),
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Filament\Resources\GuideResource\Pages;
use App\Filament\Resources\GuideResource;
use Filament\Resources\Pages\CreateRecord;
class CreateGuide extends CreateRecord
{
protected static string $resource = GuideResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
$data['created_by'] = auth()->id();
if ($data['status'] === 'published') {
$data['published_at'] = now();
}
return $data;
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Filament\Resources\GuideResource\Pages;
use App\Filament\Resources\GuideResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditGuide extends EditRecord
{
protected static string $resource = GuideResource::class;
protected function getHeaderActions(): array
{
return [
\Filament\Actions\Action::make('managePages')
->label('编辑指引')
->icon('heroicon-o-queue-list')
->url(fn() => GuideResource::getUrl('manage-pages', ['record' => $this->record])),
Actions\DeleteAction::make()->label('删除'),
];
}
protected function mutateFormDataBeforeSave(array $data): array
{
if ($data['status'] === 'published' && !$this->record->published_at) {
$data['published_at'] = now();
}
return $data;
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Filament\Resources\GuideResource\Pages;
use App\Filament\Resources\GuideResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListGuides extends ListRecords
{
protected static string $resource = GuideResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make()
->label('创建指引'),
];
}
}

View File

@@ -0,0 +1,391 @@
<?php
namespace App\Filament\Resources\GuideResource\Pages;
use App\Filament\Resources\GuideResource;
use App\Models\GuidePage;
use App\Models\GuidePageEdge;
use Filament\Actions\Action;
use Filament\Forms;
use Filament\Resources\Pages\Concerns\InteractsWithRecord;
use Filament\Resources\Pages\Page;
class ManageGuidePages extends Page
{
use InteractsWithRecord;
protected static string $resource = GuideResource::class;
protected static string $view = 'filament.resources.guide.manage-pages';
protected ?string $maxContentWidth = 'full';
public array $nodes = [];
public array $edges = [];
public function mount(int|string $record): void
{
$this->record = $this->resolveRecord($record);
$this->loadGraph();
}
public function getTitle(): string
{
return $this->getRecord()->name.' - 页面流程';
}
public function loadGraph(): void
{
$pages = $this->getRecord()->pages()->get();
$edgeModels = $this->getRecord()->edges()->orderBy('sort')->get();
// Build adjacency list
$children = [];
foreach ($pages as $p) {
$children[$p->id] = [];
}
foreach ($edgeModels as $e) {
if (isset($children[$e->from_page_id])) {
$children[$e->from_page_id][] = $e->to_page_id;
}
}
$pageMap = $pages->keyBy('id');
$incomingEdges = [];
$outgoingEdges = [];
foreach ($pages as $p) {
$incomingEdges[$p->id] = [];
$outgoingEdges[$p->id] = [];
}
foreach ($edgeModels as $e) {
$incomingEdges[$e->to_page_id][] = $e;
$outgoingEdges[$e->from_page_id][] = $e;
}
// BFS from entry nodes (no incoming edges) to assign levels
$hasIncoming = array_flip($edgeModels->pluck('to_page_id')->toArray());
$levels = [];
$visited = [];
$queue = [];
foreach ($pages as $p) {
if (! isset($hasIncoming[$p->id])) {
$queue[] = $p->id;
$levels[$p->id] = 0;
$visited[$p->id] = true;
}
}
while (! empty($queue)) {
$cur = array_shift($queue);
foreach ($children[$cur] ?? [] as $child) {
if (! isset($visited[$child])) {
$visited[$child] = true;
$levels[$child] = $levels[$cur] + 1;
$queue[] = $child;
}
}
}
// Orphans at bottom
$maxLevel = empty($levels) ? 0 : max($levels);
foreach ($pages as $p) {
if (! isset($levels[$p->id])) {
$levels[$p->id] = $maxLevel + 1;
}
}
// Group by level
$levelGroups = [];
foreach ($pages as $p) {
$levelGroups[$levels[$p->id]][] = $p->id;
}
ksort($levelGroups);
$orders = [];
foreach ($levelGroups as $ids) {
foreach (array_values($ids) as $index => $id) {
$orders[$id] = $index;
}
}
$edgeOffset = function (GuidePageEdge $edge) use ($pageMap): float {
$page = $pageMap->get($edge->from_page_id);
$options = $page?->options ?? [];
$index = $edge->label === null ? false : array_search($edge->label, $options, true);
return $index === false ? 0 : (($index + 1) / (count($options) + 1)) * 0.4;
};
$incomingScore = function (int $id) use (&$orders, $incomingEdges, $levels, $edgeOffset): ?float {
$scores = [];
foreach ($incomingEdges[$id] ?? [] as $edge) {
if (($levels[$edge->from_page_id] ?? null) >= ($levels[$id] ?? null)) {
continue;
}
$scores[] = ($orders[$edge->from_page_id] ?? 0) + $edgeOffset($edge);
}
return empty($scores) ? null : array_sum($scores) / count($scores);
};
$outgoingScore = function (int $id) use (&$orders, $outgoingEdges, $levels): ?float {
$scores = [];
foreach ($outgoingEdges[$id] ?? [] as $edge) {
if (($levels[$edge->to_page_id] ?? null) <= ($levels[$id] ?? null)) {
continue;
}
$scores[] = $orders[$edge->to_page_id] ?? 0;
}
return empty($scores) ? null : array_sum($scores) / count($scores);
};
$sortLevel = function (array &$ids, callable $scoreResolver) use (&$orders): void {
usort($ids, function (int $a, int $b) use ($scoreResolver, $orders): int {
$scoreA = $scoreResolver($a) ?? ($orders[$a] ?? 0);
$scoreB = $scoreResolver($b) ?? ($orders[$b] ?? 0);
return $scoreA <=> $scoreB ?: ($orders[$a] ?? 0) <=> ($orders[$b] ?? 0) ?: $a <=> $b;
});
foreach ($ids as $index => $id) {
$orders[$id] = $index;
}
};
for ($i = 0; $i < 3; $i++) {
foreach ($levelGroups as $level => &$ids) {
if ($level === array_key_first($levelGroups)) {
continue;
}
$sortLevel($ids, $incomingScore);
}
unset($ids);
foreach (array_reverse(array_keys($levelGroups)) as $level) {
if ($level === array_key_last($levelGroups)) {
continue;
}
$sortLevel($levelGroups[$level], $outgoingScore);
}
}
// Compute positions: center each level vertically, stack horizontally (left-to-right)
$nodeWidth = 180; // matches CSS max-width
$nodeHeight = 80; // compact node height
$gapX = 110; // horizontal gap between levels
$gapY = 60; // vertical gap within same level
$positions = [];
foreach ($levelGroups as $level => $ids) {
$count = count($ids);
$totalHeight = $count * $nodeHeight + ($count - 1) * $gapY;
$startY = max(20, (600 - $totalHeight) / 2);
foreach ($ids as $i => $id) {
$positions[$id] = [
'x' => 40 + $level * ($nodeWidth + $gapX),
'y' => (int) ($startY + $i * ($nodeHeight + $gapY)),
];
}
}
$this->nodes = $pages->map(fn (GuidePage $p) => [
'id' => $p->id,
'title' => $p->title,
'uri' => $p->uri,
'is_entry' => ! isset($hasIncoming[$p->id]),
'options' => $p->options ?? [],
'x' => $positions[$p->id]['x'] ?? 50,
'y' => $positions[$p->id]['y'] ?? 50,
])->values()->toArray();
$this->edges = $edgeModels->map(fn (GuidePageEdge $e) => [
'id' => $e->id,
'from' => $e->from_page_id,
'to' => $e->to_page_id,
'label' => $e->label,
])->values()->toArray();
}
// -- Livewire methods called by Drawflow events --
private function dispatchGraphUpdated(): void
{
$this->dispatch('graphUpdated', nodes: $this->nodes, edges: $this->edges);
}
public function addEdge(int $fromPageId, int $toPageId, string $outputClass = 'output_1'): void
{
$guide = $this->getRecord();
if (
! $guide->pages()->where('id', $fromPageId)->exists() ||
! $guide->pages()->where('id', $toPageId)->exists()
) {
return;
}
if ($fromPageId === $toPageId) {
return;
}
$exists = $guide->edges()
->where('from_page_id', $fromPageId)
->where('to_page_id', $toPageId)
->exists();
if ($exists) {
return;
}
// Derive label from output port → page options mapping
$page = $guide->pages()->find($fromPageId);
$options = $page->options ?? [];
$label = null;
if (! empty($options)) {
$outputIndex = (int) str_replace('output_', '', $outputClass) - 1;
$label = $options[$outputIndex] ?? null;
}
$guide->edges()->create([
'from_page_id' => $fromPageId,
'to_page_id' => $toPageId,
'label' => $label,
]);
$this->loadGraph();
$this->dispatchGraphUpdated();
}
public function removeEdge(int $fromPageId, int $toPageId): void
{
$this->getRecord()->edges()
->where('from_page_id', $fromPageId)
->where('to_page_id', $toPageId)
->delete();
$this->loadGraph();
$this->dispatchGraphUpdated();
}
// -- Filament Actions --
public function createPageAction(): Action
{
return Action::make('createPage')
->label('添加页面')
->icon('heroicon-o-plus')
->form($this->getPageFormSchema())
->action(function (array $data): void {
$this->getRecord()->pages()->create($data);
$this->loadGraph();
$this->dispatchGraphUpdated();
});
}
public function editPageAction(): Action
{
return Action::make('editPage')
->label('编辑页面')
->icon('heroicon-o-pencil-square')
->mountUsing(function (Forms\Form $form, array $arguments): void {
$page = $this->getRecord()->pages()->findOrFail($arguments['id']);
$form->fill([
'title' => $page->title,
'content' => $page->normalized_content,
'options' => $page->options ?? [],
]);
})
->form($this->getPageFormSchema())
->action(function (array $data, array $arguments): void {
$page = $this->getRecord()->pages()->findOrFail($arguments['id']);
$page->update($data);
$this->loadGraph();
$this->dispatchGraphUpdated();
});
}
public function copyPageAction(): Action
{
return Action::make('copyPage')
->label('复制页面')
->icon('heroicon-o-document-duplicate')
->requiresConfirmation()
->modalHeading('复制页面')
->modalDescription('确认复制该页面?复制后会生成一个独立的新页面,不会复制连线关系。')
->modalSubmitActionLabel('确认复制')
->action(function (array $arguments): void {
$page = $this->getRecord()->pages()->findOrFail($arguments['id']);
$this->getRecord()->pages()->create([
'title' => $page->title.' - 副本',
'content' => $page->content,
'options' => $page->options ?? [],
]);
$this->loadGraph();
$this->dispatchGraphUpdated();
});
}
public function deletePageAction(): Action
{
return Action::make('deletePage')
->label('删除页面')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->action(function (array $arguments): void {
$page = $this->getRecord()->pages()->findOrFail($arguments['id']);
$page->delete();
$this->loadGraph();
$this->dispatchGraphUpdated();
});
}
public function deleteEdgeAction(): Action
{
return Action::make('deleteEdge')
->label('删除连线')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->action(function (array $arguments): void {
$edge = $this->getRecord()->edges()->findOrFail($arguments['id']);
$edge->delete();
$this->loadGraph();
$this->dispatchGraphUpdated();
});
}
private function getPageFormSchema(): array
{
return [
Forms\Components\TextInput::make('title')
->label('页面标题')
->required()
->maxLength(255),
Forms\Components\RichEditor::make('content')
->label('页面内容')
->required()
->fileAttachmentsDisk('public')
->fileAttachmentsDirectory('guide-pages')
->fileAttachmentsVisibility('public')
->getUploadedAttachmentUrlUsing(fn (string $file): string => GuidePage::uploadedAttachmentUrl($file))
->dehydrateStateUsing(fn (?string $state): string => GuidePage::normalizeRichTextContent($state))
->columnSpanFull(),
Forms\Components\TagsInput::make('options')
->label('分支选项')
->helperText('定义此页面的分支按钮(每个选项对应一个输出端口)。留空 = 顺序页面。'),
];
}
}

View File

@@ -0,0 +1,153 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\KnowledgeBaseResource\Pages;
use App\Models\KnowledgeBase;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class KnowledgeBaseResource extends Resource
{
protected static ?string $model = KnowledgeBase::class;
protected static ?string $navigationIcon = 'heroicon-o-book-open';
protected static ?string $navigationLabel = '知识库管理';
protected static ?string $modelLabel = '知识库';
protected static ?string $pluralModelLabel = '知识库';
protected static ?int $navigationSort = 1;
protected static ?string $navigationGroup = '知识管理';
public static function getEloquentQuery(): \Illuminate\Database\Eloquent\Builder
{
$query = parent::getEloquentQuery();
$user = auth()->user();
if ($user && $user->hasStationRestriction()) {
$query->accessibleBy($user);
}
return $query;
}
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('name')
->label('知识库名称')
->required()
->maxLength(255)
->placeholder('请输入知识库名称'),
Forms\Components\Select::make('status')
->label('状态')
->options([
'active' => '启用',
'inactive' => '停用',
])
->default('active')
->required(),
Forms\Components\Textarea::make('description')
->label('描述')
->rows(3)
->maxLength(65535)
->placeholder('请输入知识库描述(可选)')
->columnSpanFull(),
Forms\Components\Select::make('stations')
->label('关联线站')
->relationship('stations', 'name')
->multiple()
->searchable()
->preload()
->helperText('选择知识库对哪些线站可用')
->columnSpanFull(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
->label('知识库名称')
->searchable()
->sortable(),
Tables\Columns\BadgeColumn::make('status')
->label('状态')
->colors([
'success' => 'active',
'danger' => 'inactive',
])
->formatStateUsing(fn(string $state): string => match ($state) {
'active' => '启用',
'inactive' => '停用',
default => $state,
})
->sortable(),
Tables\Columns\TextColumn::make('stations_count')
->label('关联线站')
->counts('stations')
->sortable(),
Tables\Columns\TextColumn::make('documents_count')
->label('文档数量')
->counts('documents')
->sortable(),
Tables\Columns\TextColumn::make('created_at')
->label('创建时间')
->dateTime('Y-m-d H:i:s')
->sortable()
->toggleable(),
Tables\Columns\TextColumn::make('updated_at')
->label('更新时间')
->dateTime('Y-m-d H:i:s')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\Filters\SelectFilter::make('status')
->label('状态')
->options([
'active' => '启用',
'inactive' => '停用',
]),
])
->actions([
Tables\Actions\EditAction::make()
->label('编辑'),
Tables\Actions\DeleteAction::make()
->label('删除'),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make()
->label('批量删除'),
]),
])
->defaultSort('created_at', 'desc');
}
public static function getPages(): array
{
return [
'index' => Pages\ListKnowledgeBases::route('/'),
'create' => Pages\CreateKnowledgeBase::route('/create'),
'edit' => Pages\EditKnowledgeBase::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\KnowledgeBaseResource\Pages;
use App\Filament\Resources\KnowledgeBaseResource;
use Filament\Resources\Pages\CreateRecord;
class CreateKnowledgeBase extends CreateRecord
{
protected static string $resource = KnowledgeBaseResource::class;
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Filament\Resources\KnowledgeBaseResource\Pages;
use App\Filament\Resources\KnowledgeBaseResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditKnowledgeBase extends EditRecord
{
protected static string $resource = KnowledgeBaseResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make()
->label('删除'),
];
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Filament\Resources\KnowledgeBaseResource\Pages;
use App\Filament\Resources\KnowledgeBaseResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListKnowledgeBases extends ListRecords
{
protected static string $resource = KnowledgeBaseResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make()
->label('新建知识库'),
];
}
}

View File

@@ -0,0 +1,318 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\RoleResource\Pages;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
class RoleResource extends Resource
{
protected static ?string $model = Role::class;
protected static ?string $navigationIcon = 'heroicon-o-shield-check';
protected static ?string $navigationLabel = '角色管理';
protected static ?string $modelLabel = '角色';
protected static ?string $pluralModelLabel = '角色';
protected static ?int $navigationSort = 2;
protected static ?string $navigationGroup = '权限管理';
/**
* 控制导航菜单是否显示
*/
public static function shouldRegisterNavigation(): bool
{
return auth()->user()?->can('role.view') ?? false;
}
/**
* 获取权限分组标签页
*/
protected static function getPermissionTabs(): array
{
// 模块名称和图标映射
$moduleConfig = [
'document' => ['name' => '文档管理', 'icon' => 'heroicon-o-document-text'],
'system-setting' => ['name' => '系统设置', 'icon' => 'heroicon-o-cog-6-tooth'],
'activity-log' => ['name' => '操作日志', 'icon' => 'heroicon-o-clipboard-document-list'],
'terminal' => ['name' => '终端管理', 'icon' => 'heroicon-o-computer-desktop'],
'guide' => ['name' => '操作指引', 'icon' => 'heroicon-o-book-open'],
'group' => ['name' => '分组管理', 'icon' => 'heroicon-o-user-group'],
'user' => ['name' => '用户管理', 'icon' => 'heroicon-o-users'],
'role' => ['name' => '角色管理', 'icon' => 'heroicon-o-shield-check'],
];
// 操作名称映射
$actionNames = [
'viewAny' => '查看列表',
'view' => '查看详情',
'create' => '创建',
'update' => '编辑',
'delete' => '删除',
'download' => '下载',
'export' => '导出',
'sync' => '同步',
'publish' => '发布',
'archive' => '归档',
];
// 按模块分组权限
$groupedPermissions = Permission::all()
->groupBy(function ($permission) {
return explode('.', $permission->name)[0];
});
$tabs = [];
foreach ($groupedPermissions as $module => $permissions) {
$config = $moduleConfig[$module] ?? ['name' => $module, 'icon' => 'heroicon-o-square-3-stack-3d'];
// 构建该模块的权限选项
$options = $permissions->mapWithKeys(function ($permission) use ($actionNames) {
$action = explode('.', $permission->name)[1] ?? '';
$actionName = $actionNames[$action] ?? $action;
return [$permission->name => $actionName];
})->toArray();
$tabs[] = Forms\Components\Tabs\Tab::make($config['name'])
->icon($config['icon'])
->schema([
Forms\Components\CheckboxList::make("permissions_{$module}")
->label('')
->options($options)
->columns(2)
->bulkToggleable()
->disabled(fn (?Role $record): bool => $record?->name === 'super-admin')
->helperText(fn (?Role $record): string =>
$record?->name === 'super-admin'
? 'super-admin 角色拥有所有权限,不可修改'
: '选择该模块的权限'
)
->afterStateHydrated(function ($component, $state, ?Role $record) use ($module) {
if ($record) {
// 获取该角色在当前模块的权限
$modulePermissions = $record->permissions()
->where('name', 'like', "{$module}.%")
->pluck('name')
->toArray();
$component->state($modulePermissions);
}
})
->dehydrated(false), // 不直接保存,在下面统一处理
]);
}
return $tabs;
}
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\Section::make('基本信息')
->schema([
Forms\Components\TextInput::make('name')
->label('角色标识')
->required()
->unique(ignoreRecord: true)
->maxLength(255)
->placeholder('例如: content-manager')
->helperText('角色的唯一标识符,使用小写字母和连字符')
->regex('/^[a-z0-9\-]+$/')
->validationMessages([
'regex' => '角色标识只能包含小写字母、数字和连字符',
])
->disabled(fn (?Role $record): bool => $record?->name === 'super-admin'),
Forms\Components\Select::make('guard_name')
->label('守卫')
->options([
'web' => 'Web',
])
->default('web')
->required()
->disabled(),
])
->columns(2),
Forms\Components\Section::make('权限配置')
->schema([
Forms\Components\Hidden::make('all_permissions')
->afterStateHydrated(function ($component, ?Role $record) {
if ($record) {
$component->state($record->permissions->pluck('name')->toArray());
}
})
->dehydrateStateUsing(function ($state, $get) {
// 收集所有模块的权限
$allPermissions = [];
$modules = ['document', 'system-setting', 'activity-log', 'terminal', 'guide', 'group', 'user', 'role'];
foreach ($modules as $module) {
$modulePermissions = $get("permissions_{$module}") ?? [];
$allPermissions = array_merge($allPermissions, $modulePermissions);
}
return $allPermissions;
}),
Forms\Components\Tabs::make('权限分组')
->tabs(self::getPermissionTabs())
->columnSpanFull(),
])
->description('配置角色的权限super-admin 角色拥有所有权限且不可修改'),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
->label('角色标识')
->searchable()
->sortable()
->weight('bold')
->badge()
->color(fn (string $state): string => match ($state) {
'super-admin' => 'danger',
'admin' => 'warning',
'user' => 'success',
default => 'gray',
}),
Tables\Columns\TextColumn::make('guard_name')
->label('守卫')
->badge()
->sortable()
->toggleable(),
Tables\Columns\TextColumn::make('permissions_count')
->label('权限数量')
->counts('permissions')
->sortable()
->alignCenter()
->badge()
->color('info'),
Tables\Columns\TextColumn::make('users_count')
->label('用户数量')
->counts('users')
->sortable()
->alignCenter()
->badge()
->color('success'),
Tables\Columns\IconColumn::make('is_system')
->label('系统角色')
->boolean()
->trueIcon('heroicon-o-lock-closed')
->falseIcon('heroicon-o-lock-open')
->trueColor('danger')
->falseColor('gray')
->getStateUsing(fn (Role $record): bool => $record->name === 'super-admin')
->alignCenter()
->tooltip(fn (Role $record): string =>
$record->name === 'super-admin'
? '系统角色,不可删除'
: '可以删除'
),
Tables\Columns\TextColumn::make('created_at')
->label('创建时间')
->dateTime('Y-m-d H:i:s')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('updated_at')
->label('更新时间')
->dateTime('Y-m-d H:i:s')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\Filters\SelectFilter::make('guard_name')
->label('守卫')
->options([
'web' => 'Web',
]),
])
->actions([
Tables\Actions\ViewAction::make()
->label('查看'),
Tables\Actions\EditAction::make()
->label('编辑')
->visible(fn (Role $record): bool => $record->name !== 'super-admin'),
Tables\Actions\DeleteAction::make()
->label('删除')
->visible(fn (Role $record): bool => $record->name !== 'super-admin')
->before(function (Tables\Actions\DeleteAction $action, Role $record) {
// 检查是否有关联用户
if ($record->users()->count() > 0) {
\Filament\Notifications\Notification::make()
->danger()
->title('无法删除')
->body("该角色还有 {$record->users()->count()} 个用户,请先移除用户的角色后再删除。")
->persistent()
->send();
$action->cancel();
}
}),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make()
->label('批量删除')
->before(function (Tables\Actions\DeleteBulkAction $action, $records) {
// 检查是否包含 super-admin
if ($records->contains('name', 'super-admin')) {
\Filament\Notifications\Notification::make()
->danger()
->title('无法删除')
->body('不能删除 super-admin 角色')
->persistent()
->send();
$action->cancel();
return;
}
// 检查是否有关联用户
$rolesWithUsers = $records->filter(fn ($role) => $role->users()->count() > 0);
if ($rolesWithUsers->count() > 0) {
$roleNames = $rolesWithUsers->pluck('name')->join('、');
\Filament\Notifications\Notification::make()
->danger()
->title('无法删除')
->body("以下角色还有关联用户:{$roleNames},请先移除用户的角色后再删除。")
->persistent()
->send();
$action->cancel();
}
}),
]),
])
->defaultSort('created_at', 'desc');
}
public static function getPages(): array
{
return [
'index' => Pages\ListRoles::route('/'),
'create' => Pages\CreateRole::route('/create'),
'edit' => Pages\EditRole::route('/{record}/edit'),
'view' => Pages\ViewRole::route('/{record}'),
];
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Filament\Resources\RoleResource\Pages;
use App\Filament\Resources\RoleResource;
use Filament\Resources\Pages\CreateRecord;
class CreateRole extends CreateRecord
{
protected static string $resource = RoleResource::class;
protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('index');
}
protected function getCreatedNotificationTitle(): ?string
{
return '角色创建成功';
}
protected function mutateFormDataBeforeCreate(array $data): array
{
// 从 all_permissions 字段获取权限列表
if (isset($data['all_permissions'])) {
$permissions = $data['all_permissions'];
unset($data['all_permissions']);
// 保存权限到记录中,稍后在 afterCreate 中同步
$this->permissions = $permissions;
}
// 移除所有 permissions_* 字段
foreach ($data as $key => $value) {
if (str_starts_with($key, 'permissions_')) {
unset($data[$key]);
}
}
return $data;
}
protected function afterCreate(): void
{
// 同步权限
if (isset($this->permissions)) {
$this->record->syncPermissions($this->permissions);
}
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Filament\Resources\RoleResource\Pages;
use App\Filament\Resources\RoleResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditRole extends EditRecord
{
protected static string $resource = RoleResource::class;
protected function getHeaderActions(): array
{
return [
Actions\ViewAction::make()
->label('查看'),
Actions\DeleteAction::make()
->label('删除'),
];
}
protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('index');
}
protected function getSavedNotificationTitle(): ?string
{
return '角色更新成功';
}
protected function mutateFormDataBeforeSave(array $data): array
{
// 从 all_permissions 字段获取权限列表
if (isset($data['all_permissions'])) {
$permissions = $data['all_permissions'];
unset($data['all_permissions']);
// 保存权限到记录中,稍后在 afterSave 中同步
$this->permissions = $permissions;
}
// 移除所有 permissions_* 字段
foreach ($data as $key => $value) {
if (str_starts_with($key, 'permissions_')) {
unset($data[$key]);
}
}
return $data;
}
protected function afterSave(): void
{
// 同步权限
if (isset($this->permissions)) {
$this->record->syncPermissions($this->permissions);
}
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Filament\Resources\RoleResource\Pages;
use App\Filament\Resources\RoleResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListRoles extends ListRecords
{
protected static string $resource = RoleResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make()
->label('创建角色'),
];
}
}

View File

@@ -0,0 +1,130 @@
<?php
namespace App\Filament\Resources\RoleResource\Pages;
use App\Filament\Resources\RoleResource;
use Filament\Actions;
use Filament\Resources\Pages\ViewRecord;
use Filament\Infolists;
use Filament\Infolists\Infolist;
class ViewRole extends ViewRecord
{
protected static string $resource = RoleResource::class;
protected function getHeaderActions(): array
{
return [
Actions\EditAction::make()
->label('编辑')
->visible(fn (): bool => $this->record->name !== 'super-admin'),
];
}
public function infolist(Infolist $infolist): Infolist
{
return $infolist
->schema([
Infolists\Components\Section::make('角色信息')
->schema([
Infolists\Components\TextEntry::make('name')
->label('角色标识')
->badge()
->color(fn (string $state): string => match ($state) {
'super-admin' => 'danger',
'admin' => 'warning',
'user' => 'success',
default => 'gray',
}),
Infolists\Components\TextEntry::make('guard_name')
->label('守卫')
->badge(),
Infolists\Components\TextEntry::make('permissions_count')
->label('权限数量')
->getStateUsing(fn ($record) => $record->permissions()->count())
->badge()
->color('info'),
Infolists\Components\TextEntry::make('users_count')
->label('用户数量')
->getStateUsing(fn ($record) => $record->users()->count())
->badge()
->color('success'),
Infolists\Components\TextEntry::make('created_at')
->label('创建时间')
->dateTime('Y-m-d H:i:s'),
Infolists\Components\TextEntry::make('updated_at')
->label('更新时间')
->dateTime('Y-m-d H:i:s'),
])
->columns(2),
Infolists\Components\Section::make('权限列表')
->schema([
Infolists\Components\TextEntry::make('grouped_permissions')
->label('')
->getStateUsing(function ($record) {
// 按模块分组权限
$permissions = $record->permissions;
if ($permissions->isEmpty()) {
return '该角色暂无权限';
}
$moduleNames = [
'document' => '📄 文档管理',
'system-setting' => '⚙️ 系统设置',
'activity-log' => '📋 操作日志',
'terminal' => '🖥️ 终端管理',
'guide' => '📖 操作指引',
'group' => '👥 分组管理',
'user' => '👤 用户管理',
'role' => '🛡️ 角色管理',
];
$actionNames = [
'viewAny' => '查看列表',
'view' => '查看详情',
'create' => '创建',
'update' => '编辑',
'delete' => '删除',
'download' => '下载',
'export' => '导出',
'sync' => '同步',
'publish' => '发布',
'archive' => '归档',
];
$grouped = $permissions->groupBy(function ($permission) {
return explode('.', $permission->name)[0];
});
$result = [];
foreach ($grouped as $module => $perms) {
$moduleName = $moduleNames[$module] ?? $module;
$actions = $perms->map(function ($perm) use ($actionNames) {
$action = explode('.', $perm->name)[1] ?? '';
return $actionNames[$action] ?? $action;
})->join('、');
$result[] = "<strong>{$moduleName}</strong>{$actions}";
}
return implode('<br><br>', $result);
})
->html()
->columnSpanFull(),
])
->description(fn ($record) =>
$record->name === 'super-admin'
? 'super-admin 角色拥有系统所有权限'
: '该角色拥有以下权限'
)
->collapsible(),
]);
}
}

View File

@@ -0,0 +1,130 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\StationResource\Pages;
use App\Models\Station;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class StationResource extends Resource
{
protected static ?string $model = Station::class;
protected static ?string $navigationIcon = 'heroicon-o-building-office';
protected static ?string $navigationLabel = '线站管理';
protected static ?string $modelLabel = '线站';
protected static ?string $pluralModelLabel = '线站';
protected static ?int $navigationSort = 1;
protected static ?string $navigationGroup = '业务管理';
public static function shouldRegisterNavigation(): bool
{
return auth()->user()?->can('station.view') ?? false;
}
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('name')
->label('线站名称')
->required()
->unique(ignoreRecord: true)
->maxLength(255)
->placeholder('例如: BL02U1'),
Forms\Components\Textarea::make('description')
->label('线站描述')
->rows(3)
->maxLength(65535)
->placeholder('请输入线站描述(可选)')
->columnSpanFull(),
Forms\Components\Select::make('users')
->label('关联用户')
->relationship('users', 'name')
->multiple()
->searchable()
->preload()
->helperText('关联到此线站的用户只能看到与本线站相关的资源')
->columnSpanFull(),
Forms\Components\Select::make('guides')
->label('关联指引')
->relationship('guides', 'name')
->multiple()
->searchable()
->preload()
->helperText('选择此线站可用的操作指引')
->columnSpanFull(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
->label('线站名称')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('description')
->label('描述')
->limit(50)
->toggleable(),
Tables\Columns\TextColumn::make('users_count')
->label('用户数量')
->counts('users')
->sortable(),
Tables\Columns\TextColumn::make('terminals_count')
->label('终端数量')
->counts('terminals')
->sortable(),
Tables\Columns\TextColumn::make('knowledge_bases_count')
->label('知识库数量')
->counts('knowledgeBases')
->sortable(),
Tables\Columns\TextColumn::make('created_at')
->label('创建时间')
->dateTime('Y-m-d H:i:s')
->sortable()
->toggleable(),
])
->actions([
Tables\Actions\EditAction::make()
->label('编辑'),
Tables\Actions\DeleteAction::make()
->label('删除'),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make()
->label('批量删除'),
]),
])
->defaultSort('name');
}
public static function getPages(): array
{
return [
'index' => Pages\ListStations::route('/'),
'create' => Pages\CreateStation::route('/create'),
'edit' => Pages\EditStation::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\StationResource\Pages;
use App\Filament\Resources\StationResource;
use Filament\Resources\Pages\CreateRecord;
class CreateStation extends CreateRecord
{
protected static string $resource = StationResource::class;
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Filament\Resources\StationResource\Pages;
use App\Filament\Resources\StationResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditStation extends EditRecord
{
protected static string $resource = StationResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make()
->label('删除'),
];
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Filament\Resources\StationResource\Pages;
use App\Filament\Resources\StationResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListStations extends ListRecords
{
protected static string $resource = StationResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make()
->label('新建线站'),
];
}
}

View File

@@ -0,0 +1,203 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\SystemSettingResource\Pages;
use App\Models\SystemSetting;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class SystemSettingResource extends Resource
{
protected static ?string $model = SystemSetting::class;
protected static ?string $navigationIcon = 'heroicon-o-cog-6-tooth';
protected static ?string $navigationLabel = '配置项管理';
protected static ?string $modelLabel = '系统配置项';
protected static ?string $pluralModelLabel = '系统配置项';
protected static ?int $navigationSort = 1;
protected static ?string $navigationGroup = '系统管理';
/**
* 控制导航菜单是否显示
*/
public static function shouldRegisterNavigation(): bool
{
return auth()->user()?->can('system-setting.view') ?? false;
}
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\Tabs::make('配置表单')
->tabs([
// 基本信息标签页
Forms\Components\Tabs\Tab::make('基本信息')
->icon('heroicon-o-information-circle')
->schema([
Forms\Components\TextInput::make('key')
->label('配置键')
->required()
->unique(ignoreRecord: true)
->maxLength(255)
->minLength(3)
->regex('/^[a-z0-9_\.]+$/')
->helperText('配置的唯一标识符,只能包含小写字母、数字、下划线和点,例如: embedding.model_name')
->placeholder('例如: system.name')
->validationMessages([
'regex' => '配置键只能包含小写字母、数字、下划线和点',
]),
Forms\Components\Select::make('group')
->label('配置分组')
->required()
->options([
'embedding' => '嵌入模型',
'chunking' => '分块参数',
'system' => '系统配置',
'search' => '搜索配置',
])
->native(false)
->helperText('选择配置所属的分组'),
Forms\Components\Textarea::make('description')
->label('配置说明')
->rows(3)
->maxLength(65535)
->minLength(5)
->helperText('描述此配置项的用途至少5个字符')
->columnSpanFull(),
Forms\Components\Toggle::make('is_public')
->label('公开配置')
->helperText('公开配置可以被前端访问')
->default(false)
->inline(false),
]),
// 配置值标签页
Forms\Components\Tabs\Tab::make('配置值')
->icon('heroicon-o-cog-6-tooth')
->schema([
Forms\Components\KeyValue::make('value')
->label('配置值')
->required()
->helperText('以键值对形式输入配置内容。键名应与配置键的最后一部分匹配。')
->addActionLabel('添加配置项')
->keyLabel('配置项名称')
->valueLabel('配置项值')
->reorderable(false)
->columnSpanFull(),
]),
])
->columnSpanFull()
->contained(false),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('key')
->label('配置键')
->searchable()
->sortable()
->copyable()
->tooltip('点击复制'),
Tables\Columns\TextColumn::make('group')
->label('配置分组')
->badge()
->color(fn (string $state): string => match ($state) {
'embedding' => 'info',
'chunking' => 'success',
'system' => 'warning',
'search' => 'primary',
default => 'gray',
})
->formatStateUsing(fn (string $state): string => match ($state) {
'embedding' => '嵌入模型',
'chunking' => '分块参数',
'system' => '系统配置',
'search' => '搜索配置',
default => $state,
})
->sortable(),
Tables\Columns\TextColumn::make('description')
->label('说明')
->limit(50)
->tooltip(function (Tables\Columns\TextColumn $column): ?string {
$state = $column->getState();
if (strlen($state) > 50) {
return $state;
}
return null;
}),
Tables\Columns\IconColumn::make('is_public')
->label('公开')
->boolean()
->trueIcon('heroicon-o-check-circle')
->falseIcon('heroicon-o-x-circle')
->trueColor('success')
->falseColor('danger')
->tooltip(fn (bool $state): string => $state ? '公开配置' : '私有配置'),
Tables\Columns\TextColumn::make('updated_at')
->label('更新时间')
->dateTime('Y-m-d H:i:s')
->sortable()
->toggleable(),
])
->filters([
Tables\Filters\SelectFilter::make('group')
->label('配置分组')
->options([
'embedding' => '嵌入模型',
'chunking' => '分块参数',
'system' => '系统配置',
'search' => '搜索配置',
]),
Tables\Filters\TernaryFilter::make('is_public')
->label('公开状态')
->placeholder('全部')
->trueLabel('公开')
->falseLabel('私有'),
])
->actions([
Tables\Actions\ViewAction::make()
->label('查看'),
Tables\Actions\EditAction::make()
->label('编辑'),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make()
->label('批量删除'),
]),
])
->defaultSort('group', 'asc');
}
public static function getPages(): array
{
return [
'index' => Pages\ListSystemSettings::route('/'),
'create' => Pages\CreateSystemSetting::route('/create'),
'edit' => Pages\EditSystemSetting::route('/{record}/edit'),
'view' => Pages\ViewSystemSetting::route('/{record}'),
];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Resources\SystemSettingResource\Pages;
use App\Filament\Resources\SystemSettingResource;
use Filament\Resources\Pages\CreateRecord;
class CreateSystemSetting extends CreateRecord
{
protected static string $resource = SystemSettingResource::class;
protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('index');
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Filament\Resources\SystemSettingResource\Pages;
use App\Filament\Resources\SystemSettingResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditSystemSetting extends EditRecord
{
protected static string $resource = SystemSettingResource::class;
protected function getHeaderActions(): array
{
return [
Actions\ViewAction::make()
->label('查看'),
Actions\DeleteAction::make()
->label('删除'),
];
}
protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('index');
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Filament\Resources\SystemSettingResource\Pages;
use App\Filament\Resources\SystemSettingResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListSystemSettings extends ListRecords
{
protected static string $resource = SystemSettingResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make()
->label('新建配置'),
];
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Filament\Resources\SystemSettingResource\Pages;
use App\Filament\Resources\SystemSettingResource;
use Filament\Actions;
use Filament\Resources\Pages\ViewRecord;
class ViewSystemSetting extends ViewRecord
{
protected static string $resource = SystemSettingResource::class;
protected function getHeaderActions(): array
{
return [
Actions\EditAction::make()
->label('编辑'),
];
}
}

View File

@@ -0,0 +1,309 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\TerminalResource\Pages;
use App\Models\Terminal;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class TerminalResource extends Resource
{
protected static ?string $model = Terminal::class;
protected static ?string $navigationIcon = 'heroicon-o-computer-desktop';
protected static ?string $navigationLabel = '终端管理';
protected static ?string $modelLabel = '终端';
protected static ?string $pluralModelLabel = '终端';
protected static ?int $navigationSort = 2;
protected static ?string $navigationGroup = '业务管理';
/**
* 控制导航菜单是否显示
*/
public static function shouldRegisterNavigation(): bool
{
return auth()->user()?->can('terminal.view') ?? false;
}
public static function getEloquentQuery(): Builder
{
$query = parent::getEloquentQuery();
$user = auth()->user();
if ($user && $user->hasStationRestriction()) {
$query->whereIn('station_id', $user->getAccessibleStationIds());
}
return $query;
}
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\Section::make('基本信息')
->schema([
Forms\Components\TextInput::make('name')
->label('终端名称')
->required()
->maxLength(255)
->placeholder('例如: 生产线A-工位1')
->helperText('终端的显示名称'),
Forms\Components\TextInput::make('code')
->label('终端编码')
->required()
->unique(ignoreRecord: true)
->maxLength(100)
->placeholder('例如: TERM-0001')
->helperText('终端的唯一标识符')
->regex('/^[A-Z0-9\-]+$/')
->validationMessages([
'regex' => '终端编码只能包含大写字母、数字和连字符',
]),
Forms\Components\TextInput::make('ip_address')
->label('IP地址')
->ip()
->maxLength(45)
->placeholder('例如: 192.168.1.100')
->helperText('终端的IP地址'),
Forms\Components\TextInput::make('mac_address')
->label('MAC地址')
->maxLength(17)
->placeholder('AA:BB:CC:DD:EE:FF')
->helperText('终端的MAC地址用于自动识别终端')
->regex('/^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/')
->validationMessages([
'regex' => 'MAC地址格式不正确应为 AA:BB:CC:DD:EE:FF',
]),
Forms\Components\Select::make('station_id')
->label('所属线站')
->relationship('station', 'name')
->searchable()
->preload()
->placeholder('未绑定')
->helperText('终端所属的线站'),
])
->columns(2),
Forms\Components\Section::make('组态配置')
->schema([
Forms\Components\Repeater::make('diagram_urls')
->label('组态界面地址')
->schema([
Forms\Components\TextInput::make('title')
->label('标题')
->required()
->maxLength(100)
->placeholder('例如: 主组态'),
Forms\Components\TextInput::make('url')
->label('地址')
->required()
->url()
->maxLength(500)
->placeholder('https://example.com/diagram.html'),
])
->columns(2)
->defaultItems(0)
->addActionLabel('添加组态地址')
->itemLabel(fn (array $state): ?string => $state['title'] ?? null)
->collapsible()
->reorderable(),
]),
Forms\Components\Section::make('网关配置')
->schema([
Forms\Components\TextInput::make('scada_data_url')
->label('数据查询URL')
->url()
->maxLength(500)
->placeholder('http://gateway:8080/api/data')
->helperText('网关的数据查询地址'),
Forms\Components\TextInput::make('scada_tags_url')
->label('点位定义URL')
->url()
->maxLength(500)
->placeholder('http://gateway:8080/api/tags')
->helperText('网关的点位定义查询地址'),
])
->columns(2),
Forms\Components\Section::make('语音唤醒')
->schema([
Forms\Components\Toggle::make('voice_wakeup_enabled')
->label('启用语音唤醒')
->default(false)
->live()
->helperText('开启后终端将启用语音唤醒功能'),
Forms\Components\TextInput::make('voice_wakeup_word')
->label('唤醒词')
->maxLength(100)
->placeholder('例如: 你好小智')
->helperText('终端语音唤醒使用的唤醒词')
->visible(fn(Forms\Get $get): bool => (bool) $get('voice_wakeup_enabled')),
])
->columns(2)
->description('配置终端的语音唤醒能力'),
Forms\Components\Section::make('AI提示词配置')
->schema([
Forms\Components\Grid::make(3)
->schema([
\AbdelhamidErrahmouni\FilamentMonacoEditor\MonacoEditor::make('prompt_template')
->label('提示词模板')
->language('markdown')
->fontSize('14px')
->helperText('编辑AI提示词模板可用占位符: {station_name} {terminal_code} {terminal_name} {user} {time}')
->placeholderText('请输入AI提示词模板...')
->disablePreview()
->columnSpan(2),
Forms\Components\Placeholder::make('variable_helper')
->label('可用占位符')
->content('`{station_name}` 线站名称 · `{terminal_code}` 终端编码 · `{terminal_name}` 终端名称 · `{user}` 用户名称 · `{time}` 当前时间')
->columnSpan(1),
]),
])
->description('配置终端的AI提示词模板')
->collapsible(),
Forms\Components\Section::make('状态信息')
->schema([
Forms\Components\Toggle::make('is_online')
->label('在线状态')
->helperText('终端是否在线')
->default(false)
->disabled()
->dehydrated(false),
Forms\Components\DateTimePicker::make('last_online_at')
->label('最后在线时间')
->disabled()
->dehydrated(false),
])
->columns(2)
->visibleOn('edit'),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
->label('终端名称')
->searchable()
->sortable()
->weight('bold'),
Tables\Columns\TextColumn::make('code')
->label('终端编码')
->searchable()
->sortable()
->copyable()
->tooltip('点击复制'),
Tables\Columns\TextColumn::make('mac_address')
->label('MAC地址')
->searchable()
->copyable()
->placeholder('未设置')
->toggleable(),
Tables\Columns\TextColumn::make('ip_address')
->label('IP地址')
->searchable()
->copyable()
->placeholder('未设置'),
Tables\Columns\TextColumn::make('station.name')
->label('所属线站')
->sortable()
->placeholder('未绑定'),
Tables\Columns\IconColumn::make('is_online')
->label('在线状态')
->boolean()
->trueIcon('heroicon-o-check-circle')
->falseIcon('heroicon-o-x-circle')
->trueColor('success')
->falseColor('danger')
->sortable(),
Tables\Columns\TextColumn::make('last_online_at')
->label('最后在线时间')
->dateTime('Y-m-d H:i:s')
->sortable()
->placeholder('从未在线')
->toggleable(),
Tables\Columns\TextColumn::make('created_at')
->label('创建时间')
->dateTime('Y-m-d H:i:s')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('updated_at')
->label('更新时间')
->dateTime('Y-m-d H:i:s')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\Filters\TernaryFilter::make('is_online')
->label('在线状态')
->placeholder('全部')
->trueLabel('在线')
->falseLabel('离线'),
])
->actions([
Tables\Actions\ViewAction::make()
->label('查看'),
Tables\Actions\EditAction::make()
->label('编辑'),
Tables\Actions\DeleteAction::make()
->label('删除'),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make()
->label('批量删除'),
]),
])
->defaultSort('created_at', 'desc')
->groups([
Tables\Grouping\Group::make('station.name')
->label('按线站分组')
->collapsible(),
Tables\Grouping\Group::make('is_online')
->label('按在线状态分组')
->getTitleFromRecordUsing(fn(Terminal $record): string => $record->is_online ? '在线' : '离线')
->collapsible(),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListTerminals::route('/'),
'create' => Pages\CreateTerminal::route('/create'),
'edit' => Pages\EditTerminal::route('/{record}/edit'),
'view' => Pages\ViewTerminal::route('/{record}'),
];
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Filament\Resources\TerminalResource\Pages;
use App\Filament\Resources\TerminalResource;
use Filament\Resources\Pages\CreateRecord;
class CreateTerminal extends CreateRecord
{
protected static string $resource = TerminalResource::class;
protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('index');
}
protected function getCreatedNotificationTitle(): ?string
{
return '终端创建成功';
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Filament\Resources\TerminalResource\Pages;
use App\Filament\Resources\TerminalResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditTerminal extends EditRecord
{
protected static string $resource = TerminalResource::class;
protected function getHeaderActions(): array
{
return [
Actions\ViewAction::make()
->label('查看'),
Actions\DeleteAction::make()
->label('删除'),
];
}
protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('index');
}
protected function getSavedNotificationTitle(): ?string
{
return '终端更新成功';
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Filament\Resources\TerminalResource\Pages;
use App\Filament\Resources\TerminalResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListTerminals extends ListRecords
{
protected static string $resource = TerminalResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make()
->label('创建终端'),
];
}
}

View File

@@ -0,0 +1,116 @@
<?php
namespace App\Filament\Resources\TerminalResource\Pages;
use App\Filament\Resources\TerminalResource;
use Filament\Actions;
use Filament\Resources\Pages\ViewRecord;
use Filament\Infolists;
use Filament\Infolists\Infolist;
class ViewTerminal extends ViewRecord
{
protected static string $resource = TerminalResource::class;
protected function getHeaderActions(): array
{
return [
Actions\EditAction::make()
->label('编辑'),
Actions\DeleteAction::make()
->label('删除'),
];
}
public function infolist(Infolist $infolist): Infolist
{
return $infolist
->schema([
Infolists\Components\Section::make('基本信息')
->schema([
Infolists\Components\TextEntry::make('name')
->label('终端名称'),
Infolists\Components\TextEntry::make('code')
->label('终端编码')
->copyable(),
Infolists\Components\TextEntry::make('ip_address')
->label('IP地址')
->copyable()
->placeholder('未设置'),
Infolists\Components\TextEntry::make('station.name')
->label('所属线站')
->placeholder('未绑定'),
])
->columns(2),
Infolists\Components\Section::make('组态配置')
->schema([
Infolists\Components\RepeatableEntry::make('diagram_urls')
->label('组态界面地址')
->schema([
Infolists\Components\TextEntry::make('title')
->label('标题'),
Infolists\Components\TextEntry::make('url')
->label('地址')
->copyable()
->url(fn ($state) => $state)
->openUrlInNewTab(),
])
->columns(2)
->placeholder('未设置'),
]),
Infolists\Components\Section::make('AI提示词配置')
->schema([
Infolists\Components\TextEntry::make('prompt_template')
->label('提示词模板')
->markdown()
->placeholder('未配置提示词'),
])
->collapsible(),
Infolists\Components\Section::make('语音唤醒')
->schema([
Infolists\Components\IconEntry::make('voice_wakeup_enabled')
->label('语音唤醒')
->boolean()
->trueIcon('heroicon-o-check-circle')
->falseIcon('heroicon-o-x-circle')
->trueColor('success')
->falseColor('danger'),
Infolists\Components\TextEntry::make('voice_wakeup_word')
->label('唤醒词')
->placeholder('未设置'),
])
->columns(2),
Infolists\Components\Section::make('状态信息')
->schema([
Infolists\Components\IconEntry::make('is_online')
->label('在线状态')
->boolean()
->trueIcon('heroicon-o-check-circle')
->falseIcon('heroicon-o-x-circle')
->trueColor('success')
->falseColor('danger'),
Infolists\Components\TextEntry::make('last_online_at')
->label('最后在线时间')
->dateTime('Y-m-d H:i:s')
->placeholder('从未在线'),
])
->columns(2),
Infolists\Components\Section::make('时间信息')
->schema([
Infolists\Components\TextEntry::make('created_at')
->label('创建时间')
->dateTime('Y-m-d H:i:s'),
Infolists\Components\TextEntry::make('updated_at')
->label('更新时间')
->dateTime('Y-m-d H:i:s'),
])
->columns(2)
->collapsed(),
]);
}
}

View File

@@ -25,38 +25,180 @@ class UserResource extends Resource
protected static ?string $pluralModelLabel = '用户';
protected static ?int $navigationSort = 3;
protected static ?int $navigationSort = 1;
protected static ?string $navigationGroup = '权限管理';
public static function shouldRegisterNavigation(): bool
{
return auth()->user()?->can('user.view') ?? false;
}
public static function getEloquentQuery(): \Illuminate\Database\Eloquent\Builder
{
$query = parent::getEloquentQuery();
$user = auth()->user();
if ($user && $user->hasStationRestriction()) {
$stationIds = $user->getAccessibleStationIds();
$query->where(function ($q) use ($stationIds) {
$q->whereDoesntHave('stations')
->orWhereHas('stations', fn ($sq) => $sq->whereIn('stations.id', $stationIds));
});
}
return $query;
}
/**
* 获取权限分组标签页
*/
protected static function getPermissionTabs(): array
{
// 模块名称和图标映射
$moduleConfig = [
'document' => ['name' => '文档管理', 'icon' => 'heroicon-o-document-text'],
'system-setting' => ['name' => '系统设置', 'icon' => 'heroicon-o-cog-6-tooth'],
'activity-log' => ['name' => '操作日志', 'icon' => 'heroicon-o-clipboard-document-list'],
'terminal' => ['name' => '终端管理', 'icon' => 'heroicon-o-computer-desktop'],
'guide' => ['name' => '操作指引', 'icon' => 'heroicon-o-book-open'],
'user' => ['name' => '用户管理', 'icon' => 'heroicon-o-users'],
'role' => ['name' => '角色管理', 'icon' => 'heroicon-o-shield-check'],
];
// 操作名称映射
$actionNames = [
'viewAny' => '查看列表',
'view' => '查看详情',
'create' => '创建',
'update' => '编辑',
'delete' => '删除',
'download' => '下载',
'export' => '导出',
'sync' => '同步',
'publish' => '发布',
'archive' => '归档',
];
// 按模块分组权限
$groupedPermissions = \Spatie\Permission\Models\Permission::all()
->groupBy(function ($permission) {
return explode('.', $permission->name)[0];
});
$tabs = [];
foreach ($groupedPermissions as $module => $permissions) {
$config = $moduleConfig[$module] ?? ['name' => $module, 'icon' => 'heroicon-o-square-3-stack-3d'];
// 构建该模块的权限选项
$options = $permissions->mapWithKeys(function ($permission) use ($actionNames) {
$action = explode('.', $permission->name)[1] ?? '';
$actionName = $actionNames[$action] ?? $action;
return [$permission->name => $actionName];
})->toArray();
$tabs[] = Forms\Components\Tabs\Tab::make($config['name'])
->icon($config['icon'])
->schema([
Forms\Components\CheckboxList::make("permissions_{$module}")
->label('')
->options($options)
->columns(2)
->bulkToggleable()
->helperText('选择该模块的直接权限(会叠加到角色权限之上)')
->afterStateHydrated(function ($component, $state, ?User $record) use ($module) {
if ($record) {
// 获取该用户在当前模块的直接权限
$modulePermissions = $record->permissions()
->where('name', 'like', "{$module}.%")
->pluck('name')
->toArray();
$component->state($modulePermissions);
}
})
->dehydrated(false), // 不直接保存,在下面统一处理
]);
}
return $tabs;
}
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('name')
->label('用户名称')
->required()
->maxLength(255)
->placeholder('请输入用户名称'),
Forms\Components\TextInput::make('email')
->label('邮箱')
->email()
->required()
->maxLength(255)
->placeholder('请输入邮箱地址'),
Forms\Components\TextInput::make('password')
->label('密码')
->password()
->required(fn (string $context): bool => $context === 'create')
->dehydrated(fn ($state) => filled($state))
->minLength(8)
->placeholder('请输入密码至少8位')
->helperText('编辑时留空表示不修改密码'),
Forms\Components\Select::make('groups')
->label('所属分组')
->multiple()
->relationship('groups', 'name')
->preload()
->placeholder('请选择用户所属的分组')
->helperText('用户可以属于多个分组'),
Forms\Components\Section::make('基本信息')
->schema([
Forms\Components\TextInput::make('name')
->label('用户名称')
->required()
->maxLength(255)
->placeholder('请输入用户名称'),
Forms\Components\TextInput::make('email')
->label('邮箱')
->email()
->required()
->maxLength(255)
->placeholder('请输入邮箱地址'),
Forms\Components\TextInput::make('password')
->label('密码')
->password()
->required(fn (string $context): bool => $context === 'create')
->dehydrated(fn ($state) => filled($state))
->minLength(8)
->placeholder('请输入密码至少8位')
->helperText('编辑时留空表示不修改密码'),
])
->columns(2),
Forms\Components\Section::make('线站与角色')
->schema([
Forms\Components\Select::make('stations')
->label('关联线站')
->multiple()
->relationship('stations', 'name')
->preload()
->placeholder('不关联线站则可访问全部资源')
->helperText('关联线站后用户只能看到对应线站的资源'),
Forms\Components\Select::make('roles')
->label('角色')
->multiple()
->relationship('roles', 'name')
->preload()
->placeholder('请选择用户角色')
->helperText('角色决定用户的基础权限')
->searchable(),
])
->columns(2),
Forms\Components\Section::make('直接权限')
->description('为用户分配额外的权限,这些权限会叠加到角色权限之上')
->schema([
Forms\Components\Hidden::make('all_permissions')
->afterStateHydrated(function ($component, ?User $record) {
if ($record) {
$component->state($record->permissions->pluck('name')->toArray());
}
})
->dehydrateStateUsing(function ($state, $get) {
// 收集所有模块的权限
$allPermissions = [];
$modules = ['document', 'system-setting', 'activity-log', 'terminal', 'guide', 'user', 'role'];
foreach ($modules as $module) {
$modulePermissions = $get("permissions_{$module}") ?? [];
$allPermissions = array_merge($allPermissions, $modulePermissions);
}
return $allPermissions;
}),
Forms\Components\Tabs::make('权限分组')
->tabs(self::getPermissionTabs())
->columnSpanFull(),
])
->collapsible()
->collapsed(),
]);
}
@@ -75,11 +217,40 @@ class UserResource extends Resource
->label('邮箱')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('groups.name')
Tables\Columns\TextColumn::make('roles.name')
->label('角色')
->badge()
->color(fn (string $state): string => match ($state) {
'super-admin' => 'danger',
'admin' => 'warning',
'user' => 'success',
default => 'gray',
})
->formatStateUsing(fn (string $state): string => match ($state) {
'super-admin' => '超级管理员',
'admin' => '管理员',
'user' => '普通用户',
default => $state,
})
->searchable()
->toggleable(),
Tables\Columns\TextColumn::make('stations.name')
->label('所属分组')
->badge()
->searchable()
->toggleable(),
Tables\Columns\TextColumn::make('permissions_count')
->label('权限数量')
->getStateUsing(function (User $record): int {
// 获取用户所有权限(包括通过角色继承的)
return $record->getAllPermissions()->count();
})
->sortable(query: function ($query, string $direction): void {
// 使用子查询进行排序
$query->withCount('permissions')
->orderBy('permissions_count', $direction);
})
->toggleable(),
Tables\Columns\TextColumn::make('created_at')
->label('创建时间')
->dateTime('Y-m-d H:i:s')
@@ -92,7 +263,11 @@ class UserResource extends Resource
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
Tables\Filters\SelectFilter::make('roles')
->label('角色')
->relationship('roles', 'name')
->multiple()
->preload(),
])
->actions([
Tables\Actions\ViewAction::make()
@@ -100,12 +275,35 @@ class UserResource extends Resource
Tables\Actions\EditAction::make()
->label('编辑'),
Tables\Actions\DeleteAction::make()
->label('删除'),
->label('删除')
->before(function (Tables\Actions\DeleteAction $action, User $record) {
// 防止删除超级管理员
if ($record->isSuperAdmin()) {
\Filament\Notifications\Notification::make()
->danger()
->title('无法删除')
->body('不能删除超级管理员账户')
->send();
$action->cancel();
}
}),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make()
->label('批量删除'),
->label('批量删除')
->before(function (Tables\Actions\DeleteBulkAction $action, $records) {
// 检查是否包含超级管理员
$hasSuperAdmin = $records->contains(fn ($record) => $record->isSuperAdmin());
if ($hasSuperAdmin) {
\Filament\Notifications\Notification::make()
->danger()
->title('无法删除')
->body('选中的用户中包含超级管理员,无法批量删除')
->send();
$action->cancel();
}
}),
]),
])
->defaultSort('created_at', 'desc');
@@ -114,7 +312,6 @@ class UserResource extends Resource
public static function getRelations(): array
{
return [
RelationManagers\GroupsRelationManager::class,
];
}
@@ -123,6 +320,7 @@ class UserResource extends Resource
return [
'index' => Pages\ListUsers::route('/'),
'create' => Pages\CreateUser::route('/create'),
'view' => Pages\ViewUser::route('/{record}'),
'edit' => Pages\EditUser::route('/{record}/edit'),
];
}

View File

@@ -19,4 +19,33 @@ class CreateUser extends CreateRecord
{
return '用户创建成功';
}
protected function mutateFormDataBeforeCreate(array $data): array
{
// 从 all_permissions 字段获取权限列表
if (isset($data['all_permissions'])) {
$permissions = $data['all_permissions'];
unset($data['all_permissions']);
// 保存权限到记录中,稍后在 afterCreate 中同步
$this->permissions = $permissions;
}
// 移除所有 permissions_* 字段
foreach ($data as $key => $value) {
if (str_starts_with($key, 'permissions_')) {
unset($data[$key]);
}
}
return $data;
}
protected function afterCreate(): void
{
// 同步权限
if (isset($this->permissions)) {
$this->record->syncPermissions($this->permissions);
}
}
}

View File

@@ -26,4 +26,33 @@ class EditUser extends EditRecord
{
return '用户更新成功';
}
protected function mutateFormDataBeforeSave(array $data): array
{
// 从 all_permissions 字段获取权限列表
if (isset($data['all_permissions'])) {
$permissions = $data['all_permissions'];
unset($data['all_permissions']);
// 保存权限到记录中,稍后在 afterSave 中同步
$this->permissions = $permissions;
}
// 移除所有 permissions_* 字段
foreach ($data as $key => $value) {
if (str_starts_with($key, 'permissions_')) {
unset($data[$key]);
}
}
return $data;
}
protected function afterSave(): void
{
// 同步权限
if (isset($this->permissions)) {
$this->record->syncPermissions($this->permissions);
}
}
}

View File

@@ -0,0 +1,185 @@
<?php
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Actions;
use Filament\Resources\Pages\ViewRecord;
use Filament\Infolists;
use Filament\Infolists\Infolist;
class ViewUser extends ViewRecord
{
protected static string $resource = UserResource::class;
protected function getHeaderActions(): array
{
return [
Actions\EditAction::make()
->label('编辑'),
];
}
public function infolist(Infolist $infolist): Infolist
{
return $infolist
->schema([
Infolists\Components\Section::make('基本信息')
->schema([
Infolists\Components\TextEntry::make('name')
->label('用户名称'),
Infolists\Components\TextEntry::make('email')
->label('邮箱'),
Infolists\Components\TextEntry::make('created_at')
->label('创建时间')
->dateTime('Y-m-d H:i:s'),
Infolists\Components\TextEntry::make('updated_at')
->label('更新时间')
->dateTime('Y-m-d H:i:s'),
])
->columns(2),
Infolists\Components\Section::make('角色信息')
->schema([
Infolists\Components\TextEntry::make('roles.name')
->label('已分配角色')
->badge()
->color(fn (string $state): string => match ($state) {
'super-admin' => 'danger',
'admin' => 'warning',
'user' => 'success',
default => 'gray',
})
->formatStateUsing(fn (string $state): string => match ($state) {
'super-admin' => '超级管理员',
'admin' => '管理员',
'user' => '普通用户',
default => $state,
})
->placeholder('未分配角色'),
]),
Infolists\Components\Section::make('分组信息')
->schema([
Infolists\Components\TextEntry::make('groups.name')
->label('所属分组')
->badge()
->placeholder('未加入任何分组'),
]),
Infolists\Components\Section::make('权限详情')
->description('显示用户拥有的所有权限(包括角色权限和直接权限)')
->schema([
Infolists\Components\TextEntry::make('all_permissions')
->label('所有权限')
->getStateUsing(function ($record) {
// 获取所有权限(角色权限 + 直接权限)
$permissions = $record->getAllPermissions();
if ($permissions->isEmpty()) {
return '该用户暂无权限';
}
// 按模块分组
$grouped = $permissions->groupBy(function ($permission) {
return explode('.', $permission->name)[0];
});
$moduleNames = [
'document' => '📄 文档管理',
'system-setting' => '⚙️ 系统设置',
'activity-log' => '📋 操作日志',
'terminal' => '🖥️ 终端管理',
'guide' => '📖 操作指引',
'group' => '👥 分组管理',
'user' => '👤 用户管理',
'role' => '🛡️ 角色管理',
];
$actionNames = [
'viewAny' => '查看列表',
'view' => '查看详情',
'create' => '创建',
'update' => '编辑',
'delete' => '删除',
'download' => '下载',
'export' => '导出',
'sync' => '同步',
'publish' => '发布',
'archive' => '归档',
];
$result = [];
foreach ($grouped as $module => $perms) {
$moduleName = $moduleNames[$module] ?? $module;
$actions = $perms->map(function ($perm) use ($actionNames) {
$action = explode('.', $perm->name)[1] ?? '';
return $actionNames[$action] ?? $action;
})->join('、');
$result[] = "<strong>{$moduleName}</strong>{$actions}";
}
return implode('<br><br>', $result);
})
->html()
->columnSpanFull(),
Infolists\Components\TextEntry::make('direct_permissions')
->label('直接权限(仅显示直接分配的权限)')
->getStateUsing(function ($record) {
$permissions = $record->permissions;
if ($permissions->isEmpty()) {
return '无直接权限';
}
// 按模块分组
$grouped = $permissions->groupBy(function ($permission) {
return explode('.', $permission->name)[0];
});
$moduleNames = [
'document' => '📄 文档管理',
'system-setting' => '⚙️ 系统设置',
'activity-log' => '📋 操作日志',
'terminal' => '🖥️ 终端管理',
'guide' => '📖 操作指引',
'group' => '👥 分组管理',
'user' => '👤 用户管理',
'role' => '🛡️ 角色管理',
];
$actionNames = [
'viewAny' => '查看列表',
'view' => '查看详情',
'create' => '创建',
'update' => '编辑',
'delete' => '删除',
'download' => '下载',
'export' => '导出',
'sync' => '同步',
'publish' => '发布',
'archive' => '归档',
];
$result = [];
foreach ($grouped as $module => $perms) {
$moduleName = $moduleNames[$module] ?? $module;
$actions = $perms->map(function ($perm) use ($actionNames) {
$action = explode('.', $perm->name)[1] ?? '';
return $actionNames[$action] ?? $action;
})->join('、');
$result[] = "<strong>{$moduleName}</strong>{$actions}";
}
return implode('<br><br>', $result);
})
->html()
->columnSpanFull()
->color('info'),
]),
]);
}
}

View File

@@ -1,87 +0,0 @@
<?php
namespace App\Filament\Resources\UserResource\RelationManagers;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
class GroupsRelationManager extends RelationManager
{
protected static string $relationship = 'groups';
protected static ?string $title = '用户分组';
protected static ?string $modelLabel = '分组';
public function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('name')
->label('分组名称')
->required()
->maxLength(255),
Forms\Components\Textarea::make('description')
->label('分组描述')
->rows(3)
->maxLength(65535),
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('name')
->columns([
Tables\Columns\TextColumn::make('id')
->label('ID')
->sortable(),
Tables\Columns\TextColumn::make('name')
->label('分组名称')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('description')
->label('分组描述')
->limit(50)
->searchable(),
Tables\Columns\TextColumn::make('created_at')
->label('加入时间')
->dateTime('Y-m-d H:i:s')
->sortable(),
])
->filters([
//
])
->headerActions([
Tables\Actions\AttachAction::make()
->label('添加分组')
->preloadRecordSelect()
->modalHeading('添加用户到分组')
->modalSubmitActionLabel('添加')
->modalCancelActionLabel('取消'),
])
->actions([
Tables\Actions\DetachAction::make()
->label('移除')
->modalHeading('移除用户分组')
->modalDescription('确定要将此用户从该分组中移除吗?')
->modalSubmitActionLabel('确认移除')
->modalCancelActionLabel('取消'),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DetachBulkAction::make()
->label('批量移除')
->modalHeading('批量移除用户分组')
->modalDescription('确定要将此用户从选中的分组中移除吗?')
->modalSubmitActionLabel('确认移除')
->modalCancelActionLabel('取消'),
]),
]);
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Filament\Widgets;
use App\Models\Document;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
class KnowledgeBaseStatsWidget extends BaseWidget
{
protected static ?int $sort = 1;
protected function getStats(): array
{
$totalDocuments = Document::count();
$completedDocuments = Document::where('conversion_status', 'completed')->count();
$failedDocuments = Document::where('conversion_status', 'failed')->count();
$conversionRate = $totalDocuments > 0
? round(($completedDocuments / $totalDocuments) * 100, 1)
: 0;
return [
Stat::make('文档总数', $totalDocuments)
->description('知识库中的文档总数')
->descriptionIcon('heroicon-m-document-text')
->color('primary'),
Stat::make('转换完成', $completedDocuments)
->description("成功率: {$conversionRate}%")
->descriptionIcon('heroicon-m-check-circle')
->color('success'),
Stat::make('转换失败', $failedDocuments)
->description('需要重新处理')
->descriptionIcon('heroicon-m-x-circle')
->color('danger'),
];
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Filament\Widgets;
use App\Models\Station;
use App\Models\Terminal;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
class TerminalStatsWidget extends BaseWidget
{
protected static ?int $sort = 2;
protected function getStats(): array
{
// 统计终端数据
$totalStations = Station::count();
$totalTerminals = Terminal::count();
$onlineTerminals = Terminal::where('is_online', true)->count();
return [
Stat::make('线站数量', $totalStations)
->description('线站')
->descriptionIcon('heroicon-m-building-office')
->color('info'),
Stat::make('终端总数', $totalTerminals)
->description("{$onlineTerminals} 个在线")
->descriptionIcon('heroicon-m-computer-desktop')
->color('primary'),
];
}
}

View File

@@ -0,0 +1,248 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Document;
use App\Models\Guide;
use App\Models\GuidePage;
use App\Models\GuidePageEdge;
use App\Models\KnowledgeBase;
use App\Services\KnowledgeContextService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class TerminalApiController extends Controller
{
public function __construct(
private KnowledgeContextService $knowledgeService,
) {}
/**
* GET /api/terminal/config
* 返回终端配置
*/
public function config(Request $request): JsonResponse
{
$terminal = $request->attributes->get('terminal');
$terminal->load('station');
$systemPrompt = $terminal->prompt_template ?? '';
// 获取终端所属线站的已发布指引数量(含全局指引)
$guideCount = $this->getTerminalGuides($terminal)->count();
return response()->json([
'terminal' => [
'id' => $terminal->id,
'terminal_name' => $terminal->name,
'terminal_code' => $terminal->code,
'station_name' => $terminal->station?->name,
'diagram_urls' => collect($terminal->diagram_urls ?? [])->values()->map(fn ($item) => [
'title' => $item['title'] ?? '',
'url' => $item['url'] ?? '',
])->all(),
'scada_data_url' => $terminal->scada_data_url,
'scada_tags_url' => $terminal->scada_tags_url,
'voice_wakeup_enabled' => $terminal->voice_wakeup_enabled,
'voice_wakeup_word' => $terminal->voice_wakeup_word,
],
'system_prompt' => $systemPrompt,
'guide_count' => $guideCount,
]);
}
/**
* GET /api/knowledge?query=xxx
* RAG知识搜索由AI tool_call触发
*/
public function knowledge(Request $request): JsonResponse
{
$request->validate([
'query' => 'required|string|max:500',
]);
$terminal = $request->attributes->get('terminal');
$result = $this->knowledgeService->search($request->input('query'), $terminal);
return response()->json($result);
}
/**
* GET /api/terminal/guides?category=operation
* 已发布的指引列表
*/
public function guides(Request $request): JsonResponse
{
$terminal = $request->attributes->get('terminal');
$query = $this->getTerminalGuides($terminal)->withCount('pages');
if ($category = $request->input('category')) {
$query->where('category', $category);
}
$guides = $query->orderBy('name')->get()->map(fn(Guide $guide) => [
'id' => $guide->id,
'name' => $guide->name,
'description' => $guide->description,
'category' => $guide->category,
'tags' => $guide->tags,
'page_count' => $guide->pages_count,
]);
return response()->json(['guides' => $guides]);
}
/**
* POST /api/terminal/guides/pages
* 返回指引页面(状态机格式,每页带 next 指针)
*/
public function guidePages(Request $request): JsonResponse
{
$request->validate([
'guide_ids' => 'required|array|min:1',
'guide_ids.*' => 'integer|exists:guides,id',
]);
$terminal = $request->attributes->get('terminal');
$accessibleIds = $this->getTerminalGuides($terminal)->pluck('guides.id')->toArray();
$guideIds = collect($request->input('guide_ids'))
->intersect($accessibleIds)
->values()
->toArray();
$pages = GuidePage::whereIn('guide_id', $guideIds)->get();
$edges = GuidePageEdge::whereIn('guide_id', $guideIds)
->orderBy('from_page_id')
->orderBy('sort')
->get();
$edgesByFrom = $edges->groupBy('from_page_id');
$hasIncoming = $edges->pluck('to_page_id')->unique()->flip();
$guides = [];
foreach ($pages->groupBy('guide_id') as $guideId => $guidePages) {
$entryPage = $guidePages->first(fn($p) => !$hasIncoming->has($p->id));
$pagesMap = [];
foreach ($guidePages as $page) {
$next = $edgesByFrom->get($page->id, collect())
->map(function (GuidePageEdge $e) {
$item = ['page_id' => $e->to_page_id];
if ($e->label !== null) {
$item['label'] = $e->label;
}
return $item;
})->values()->toArray();
$pagesMap[$page->id] = [
'id' => $page->id,
'title' => $page->title,
'uri' => $page->uri,
'next' => $next,
];
}
$guides[$guideId] = [
'entry_page_id' => $entryPage?->id,
'pages' => $pagesMap,
];
}
return response()->json([
'guides' => $guides,
]);
}
/**
* 获取终端可见的指引(线站关联 + 全局)
*/
private function getTerminalGuides($terminal)
{
$stationId = $terminal->station_id;
return Guide::published()->where(function ($q) use ($stationId) {
$q->whereDoesntHave('stations'); // 全局指引
if ($stationId) {
$q->orWhereHas('stations', fn($sq) => $sq->where('stations.id', $stationId));
}
});
}
/**
* GET /api/terminal/documents/{document}/content
* 读取文档全文或指定行号区间
*/
public function documentContent(Request $request, int $documentId): JsonResponse
{
$request->validate([
'start_line' => 'sometimes|integer|min:1',
'end_line' => 'sometimes|integer|min:1',
]);
$terminal = $request->attributes->get('terminal');
// Find document and verify access through station → knowledge_base
$accessibleKbIds = KnowledgeBase::where(function ($q) use ($terminal) {
$q->whereDoesntHave('stations'); // global knowledge bases
if ($terminal->station_id) {
$q->orWhereHas('stations', fn ($sq) => $sq->where('stations.id', $terminal->station_id));
}
})->where('status', 'active')->pluck('id');
$document = Document::where('id', $documentId)
->whereIn('knowledge_base_id', $accessibleKbIds)
->where('conversion_status', 'completed')
->first();
if (!$document) {
return response()->json(['error' => 'Document not found'], 404);
}
$content = $document->getMarkdownContent();
if ($content === null) {
return response()->json(['error' => 'Document content unavailable'], 404);
}
$lines = explode("\n", $content);
$totalLines = count($lines);
$startLine = $request->integer('start_line', 1);
$endLine = $request->integer('end_line', min($startLine + 49, $totalLines));
$endLine = min($endLine, $totalLines);
if ($startLine > $totalLines) {
return response()->json([
'error' => "start_line ({$startLine}) exceeds total lines ({$totalLines})",
], 422);
}
$slice = array_slice($lines, $startLine - 1, $endLine - $startLine + 1);
return response()->json([
'id' => $document->id,
'title' => $document->title,
'total_lines' => $totalLines,
'start_line' => $startLine,
'end_line' => $endLine,
'content' => implode("\n", $slice),
]);
}
/**
* POST /api/terminal/heartbeat
* 终端心跳上报
*/
public function heartbeat(Request $request): JsonResponse
{
$terminal = $request->attributes->get('terminal');
$terminal->update([
'is_online' => true,
'last_online_at' => now(),
]);
return response()->json(['status' => 'ok']);
}
}

View File

@@ -3,27 +3,25 @@
namespace App\Http\Controllers;
use App\Models\Document;
use App\Services\DocumentPdfPreviewService;
use App\Services\DocumentService;
use App\Services\MarkdownRenderService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
class DocumentController extends Controller
{
protected DocumentService $documentService;
protected MarkdownRenderService $markdownRenderService;
protected DocumentPdfPreviewService $pdfPreviewService;
public function __construct(
DocumentService $documentService,
MarkdownRenderService $markdownRenderService
DocumentPdfPreviewService $pdfPreviewService
) {
$this->documentService = $documentService;
$this->markdownRenderService = $markdownRenderService;
$this->pdfPreviewService = $pdfPreviewService;
}
/**
* 预览文档的 Markdown 内容(支持图片显示)
* 预览文档的 PDF 内容
* 需求11.1, 11.3, 11.4
*
* @param Document $document
@@ -31,42 +29,37 @@ class DocumentController extends Controller
*/
public function preview(Document $document)
{
// 验证用户权限(使用 DocumentPolicy
// 需求11.3
if (!Gate::allows('view', $document)) {
abort(403, '您没有权限预览此文档');
}
// 检查文档是否已完成转换
if ($document->conversion_status !== 'completed') {
return view('documents.preview', [
'document' => $document,
'markdownHtml' => null,
]);
return view('documents.preview', [
'document' => $document,
'canPreviewPdf' => $this->pdfPreviewService->canPreview($document),
'previewPdfUrl' => $this->pdfPreviewService->previewUrl($document),
]);
}
public function previewPdf(Document $document)
{
if (! Gate::allows('view', $document)) {
abort(403, '您没有权限预览此文档');
}
$markdownHtml = null;
try {
// 使用 DocumentPreviewService 的 Markdown 预览方法
// 这会修复图片路径并渲染 Markdown
// 需求11.1
$previewService = app(\App\Services\DocumentPreviewService::class);
$markdownHtml = $previewService->convertMarkdownToHtml($document);
} catch (\Exception $e) {
// 记录错误但不中断流程
\Log::error('Markdown 预览失败', [
$path = $this->pdfPreviewService->getPreviewPath($document);
} catch (\Throwable $e) {
\Log::error('PDF 预览生成失败', [
'document_id' => $document->id,
'error' => $e->getMessage(),
]);
abort(500, 'PDF 预览生成失败:' . $e->getMessage());
}
// 处理内容为空的情况
// 需求11.4
// 返回渲染后的 HTML 视图
return view('documents.preview', [
'document' => $document,
'markdownHtml' => $markdownHtml,
return response()->file($path, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="document-' . $document->getKey() . '.pdf"',
]);
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Middleware;
use App\Models\Terminal;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class IdentifyTerminal
{
public function handle(Request $request, Closure $next): Response
{
$macHeader = $request->header('X-Terminal-MAC');
if (!$macHeader) {
return response()->json(['error' => 'Missing X-Terminal-MAC header'], 400);
}
// HMI sends comma-separated MACs for all active interfaces;
// match if any one corresponds to a registered terminal
$macs = array_map('trim', explode(',', $macHeader));
$terminal = Terminal::whereIn('mac_address', $macs)->first();
if (!$terminal) {
return response()->json(['error' => 'Terminal not registered'], 403);
}
$request->attributes->set('terminal', $terminal);
// Record IP address from header (for logging/diagnostics)
if ($ip = $request->header('X-Terminal-IP')) {
$request->attributes->set('terminal_ip', $ip);
}
return $next($request);
}
}

View File

@@ -18,39 +18,12 @@ class ConvertDocumentToMarkdown implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* 任务最大尝试次数
*
* @var int
*/
public $tries;
/**
* 任务超时时间(秒)
*
* @var int
*/
public $timeout;
/**
* 重试延迟(秒)
*
* @var int
*/
public $backoff;
/**
* 文档实例
*
* @var Document
*/
protected Document $document;
/**
* 创建新的任务实例
*
* @param Document $document
*/
public function __construct(Document $document)
{
$this->document = $document;
@@ -59,119 +32,71 @@ class ConvertDocumentToMarkdown implements ShouldQueue
$this->backoff = config('documents.conversion.retry_delay', 60);
}
/**
* 执行任务
*
* @param DocumentConversionService $conversionService
* @return void
*/
public function handle(DocumentConversionService $conversionService): void
{
try {
Log::info('开始转换文档', [
'document_id' => $this->document->id,
'document_title' => $this->document->title,
'file_name' => $this->document->file_name,
'attempt' => $this->attempts(),
]);
// 转换文档为 Markdown
$result = $conversionService->convertToMarkdown($this->document);
$markdown = $result['markdown'];
$mediaDir = $result['mediaDir'] ?? null;
$tempDir = $result['tempDir'];
try {
// 保存 Markdown 文件和媒体文件
$markdownPath = $conversionService->saveMarkdownToFile($this->document, $markdown, $mediaDir);
$markdownPath = $conversionService->saveMarkdownToFile(
$this->document,
$result['markdown'],
$result['media_files'] ?? []
);
// 更新文档的 Markdown 信息
$conversionService->updateDocumentMarkdown($this->document, $markdownPath);
} finally {
// 清理临时目录
if (isset($tempDir) && file_exists($tempDir)) {
$this->deleteDirectory($tempDir);
}
}
$conversionService->updateDocumentMarkdown($this->document, $markdownPath);
Log::info('文档转换成功', [
'document_id' => $this->document->id,
'document_title' => $this->document->title,
'markdown_path' => $markdownPath,
]);
} catch (\Throwable $e) {
$exception = $this->normalizeException($e);
// 转换成功后,触发索引(如果需要)
// 这将在后续任务中实现
// $this->document->searchable();
} catch (\Exception $e) {
Log::error('文档转换失败', [
'document_id' => $this->document->id,
'document_title' => $this->document->title,
'file_name' => $this->document->file_name,
'attempt' => $this->attempts(),
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
'error' => $exception->getMessage(),
]);
// 如果已达到最大重试次数,标记为失败
if ($this->attempts() >= $this->tries) {
$conversionService->handleConversionFailure($this->document, $e);
$conversionService->handleConversionFailure($this->document, $exception);
}
// 重新抛出异常以触发重试
throw $e;
throw $exception;
}
}
/**
* 任务失败时的处理
*
* @param \Throwable $exception
* @return void
*/
public function failed(\Throwable $exception): void
{
$normalized = $this->normalizeException($exception);
Log::error('文档转换任务最终失败', [
'document_id' => $this->document->id,
'document_title' => $this->document->title,
'error' => $exception->getMessage(),
'file_name' => $this->document->file_name,
'error' => $normalized->getMessage(),
]);
// 确保文档状态被标记为失败
$conversionService = app(DocumentConversionService::class);
$conversionService->handleConversionFailure(
$this->document,
$exception instanceof \Exception ? $exception : new \Exception($exception->getMessage())
);
$conversionService->handleConversionFailure($this->document, $normalized);
}
/**
* 递归删除目录
*
* @param string $dir 目录路径
* @return void
*/
protected function deleteDirectory(string $dir): void
protected function normalizeException(\Throwable $throwable): \Exception
{
if (!file_exists($dir)) {
return;
if ($throwable instanceof \Exception) {
return $throwable;
}
if (!is_dir($dir)) {
unlink($dir);
return;
}
$files = array_diff(scandir($dir), ['.', '..']);
foreach ($files as $file) {
$path = $dir . '/' . $file;
if (is_dir($path)) {
$this->deleteDirectory($path);
} else {
unlink($path);
}
}
rmdir($dir);
return new \RuntimeException($throwable->getMessage(), 0, $throwable);
}
}

View File

@@ -2,12 +2,12 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Laravel\Scout\Searchable;
class Document extends Model
@@ -24,22 +24,20 @@ class Document extends Model
'file_name',
'file_size',
'mime_type',
'type',
'group_id',
'uploaded_by',
'description',
'markdown_path',
'markdown_preview',
'conversion_status',
'conversion_error',
'knowledge_base_id',
];
/**
* 获取文档所属的分组
* 获取文档所属的知识库
*/
public function group(): BelongsTo
public function knowledgeBase(): BelongsTo
{
return $this->belongsTo(Group::class);
return $this->belongsTo(KnowledgeBase::class);
}
/**
@@ -58,57 +56,9 @@ class Document extends Model
return $this->hasMany(DownloadLog::class);
}
/**
* 查询作用域:获取用户可访问的文档
* 包含全局文档和用户分组的专用文档,排除其他分组的专用文档
*
* @param Builder $query
* @param User $user
* @return Builder
*/
public function scopeAccessibleBy(Builder $query, User $user): Builder
{
// 获取用户所属的所有分组 ID
$userGroupIds = $user->groups()->pluck('groups.id')->toArray();
return $query->where(function (Builder $query) use ($userGroupIds) {
// 包含所有全局文档
$query->where('type', 'global')
// 或者包含用户所属分组的专用文档
->orWhere(function (Builder $query) use ($userGroupIds) {
$query->where('type', 'dedicated')
->whereIn('group_id', $userGroupIds);
});
});
}
/**
* 查询作用域:仅获取全局文档
*
* @param Builder $query
* @return Builder
*/
public function scopeGlobal(Builder $query): Builder
{
return $query->where('type', 'global');
}
/**
* 查询作用域:仅获取专用文档
*
* @param Builder $query
* @return Builder
*/
public function scopeDedicated(Builder $query): Builder
{
return $query->where('type', 'dedicated');
}
/**
* 获取可搜索的数组数据
* 用于 Meilisearch 索引
*
* @return array
*/
public function toSearchableArray(): array
{
@@ -118,8 +68,7 @@ class Document extends Model
'file_name' => $this->file_name,
'description' => $this->description,
'markdown_content' => $this->getMarkdownContent(),
'type' => $this->type,
'group_id' => $this->group_id,
'knowledge_base_id' => $this->knowledge_base_id,
'uploaded_by' => $this->uploaded_by,
'created_at' => $this->created_at?->timestamp,
];
@@ -128,8 +77,6 @@ class Document extends Model
/**
* 判断文档是否应该被索引
* 只有转换完成的文档才会被索引
*
* @return bool
*/
public function shouldBeSearchable(): bool
{
@@ -139,8 +86,6 @@ class Document extends Model
/**
* 获取完整的 Markdown 内容
* 从文件系统读取 Markdown 文件
*
* @return string|null
*/
public function getMarkdownContent(): ?string
{
@@ -153,7 +98,6 @@ class Document extends Model
return Storage::disk('markdown')->get($this->markdown_path);
}
} catch (\Exception $e) {
// 记录错误但不抛出异常
\Log::warning('Failed to read markdown content', [
'document_id' => $this->id,
'markdown_path' => $this->markdown_path,
@@ -166,11 +110,38 @@ class Document extends Model
/**
* 检查文档是否已转换为 Markdown
*
* @return bool
*/
public function hasMarkdown(): bool
{
return !empty($this->markdown_path) && $this->conversion_status === 'completed';
}
/**
* 获取用于展示和下载的文件名
* 对历史上误保存为随机存储名的记录回退到“标题.扩展名”
*/
public function getDisplayFileNameAttribute(): string
{
$fileName = trim((string) $this->file_name);
if ($fileName !== '' && ! $this->looksLikeGeneratedStorageName($fileName)) {
return $fileName;
}
$extension = pathinfo($fileName ?: $this->file_path, PATHINFO_EXTENSION);
$title = trim((string) $this->title);
$title = preg_replace('/[<>:"\/\\\\|?*\x00-\x1F]+/u', '-', $title) ?? '';
$title = trim($title, " .-\t\n\r\0\x0B");
$title = $title !== '' ? $title : 'document';
return $extension !== '' ? "{$title}.{$extension}" : $title;
}
protected function looksLikeGeneratedStorageName(string $fileName): bool
{
$baseName = pathinfo($fileName, PATHINFO_FILENAME);
return Str::isUuid($baseName)
|| (bool) preg_match('/^[0-9A-HJKMNP-TV-Z]{26}$/i', $baseName);
}
}

View File

@@ -2,13 +2,11 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class DownloadLog extends Model
{
use HasFactory;
/**
* 表示模型不使用 created_at updated_at 时间戳
* 因为我们使用自定义的 downloaded_at 字段

View File

@@ -1,53 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Group extends Model
{
use HasFactory;
/**
* 可批量赋值的属性
*
* @var array<int, string>
*/
protected $fillable = [
'name',
'description',
];
/**
* 模型的启动方法
* 注册模型事件监听器
*/
protected static function boot()
{
parent::boot();
// 监听分组删除事件
static::deleting(function (Group $group) {
// 将该分组的所有专用文档的 group_id 设置为 null孤立状态
$group->documents()->update(['group_id' => null]);
});
}
/**
* 获取分组的所有用户
*/
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class);
}
/**
* 获取分组的所有文档
*/
public function documents(): HasMany
{
return $this->hasMany(Document::class);
}
}

98
app/Models/Guide.php Normal file
View File

@@ -0,0 +1,98 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
class Guide extends Model
{
use HasFactory, SoftDeletes, LogsActivity;
protected $fillable = [
'name',
'description',
'category',
'tags',
'status',
'created_by',
'published_at',
];
protected function casts(): array
{
return [
'tags' => 'array',
'published_at' => 'datetime',
];
}
public function pages()
{
return $this->hasMany(GuidePage::class);
}
public function edges()
{
return $this->hasMany(GuidePageEdge::class);
}
public function entryPage()
{
return $this->hasOne(GuidePage::class)
->whereNotIn('guide_pages.id', function ($q) {
$q->select('to_page_id')
->from('guide_page_edges')
->whereColumn('guide_page_edges.guide_id', 'guide_pages.guide_id');
});
}
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
public function stations()
{
return $this->belongsToMany(Station::class);
}
public function scopePublished($query)
{
return $query->where('status', 'published');
}
public function scopeCategory($query, string $category)
{
return $query->where('category', $category);
}
/**
* 按用户线站过滤:全局 Guide无线站关联+ 用户线站关联的 Guide
*/
public function scopeAccessibleBy(Builder $query, User $user): Builder
{
if (!$user->hasStationRestriction()) {
return $query;
}
$stationIds = $user->getAccessibleStationIds();
return $query->where(function (Builder $q) use ($stationIds) {
$q->whereDoesntHave('stations')
->orWhereHas('stations', fn($sq) => $sq->whereIn('stations.id', $stationIds));
});
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['name', 'description', 'category', 'status'])
->logOnlyDirty()
->setDescriptionForEvent(fn(string $eventName) => "指引已{$eventName}");
}
}

94
app/Models/GuidePage.php Normal file
View File

@@ -0,0 +1,94 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class GuidePage extends Model
{
protected $fillable = [
'guide_id',
'title',
'content',
'options',
];
protected $casts = [
'options' => 'array',
];
protected static function booted(): void
{
static::deleting(function (GuidePage $page) {
// CASCADE on from_page_id is handled by FK, but incoming edges need cleanup
GuidePageEdge::where('to_page_id', $page->id)->delete();
});
}
public function getUriAttribute(): string
{
return route('guides.pages.show', $this->id);
}
public function getNormalizedContentAttribute(): string
{
return static::normalizeRichTextContent($this->content);
}
public static function normalizeRichTextContent(?string $content): string
{
if (blank($content)) {
return '';
}
$content = preg_replace_callback(
'~(?:https?:)?//[^"\'\s<>()]+(?<path>/storage/guide-pages/[^"\'\s<>()]*)~i',
static fn (array $matches): string => $matches['path'],
$content,
) ?? $content;
return preg_replace(
'~(?<=["\'])storage/guide-pages/~i',
'/storage/guide-pages/',
$content,
) ?? $content;
}
public static function uploadedAttachmentUrl(string $path): string
{
return '/storage/'.ltrim($path, '/');
}
public function guide()
{
return $this->belongsTo(Guide::class);
}
public function outgoingEdges()
{
return $this->hasMany(GuidePageEdge::class, 'from_page_id')->orderBy('sort');
}
public function incomingEdges()
{
return $this->hasMany(GuidePageEdge::class, 'to_page_id');
}
public function nextPages()
{
return $this->belongsToMany(self::class, 'guide_page_edges', 'from_page_id', 'to_page_id')
->withPivot('label', 'sort')
->orderByPivot('sort');
}
public function previousPages()
{
return $this->belongsToMany(self::class, 'guide_page_edges', 'to_page_id', 'from_page_id')
->withPivot('label', 'sort');
}
public function isEntry(): bool
{
return ! $this->incomingEdges()->exists();
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class GuidePageEdge extends Model
{
protected $fillable = [
'guide_id',
'from_page_id',
'to_page_id',
'label',
'sort',
];
public function guide()
{
return $this->belongsTo(Guide::class);
}
public function fromPage()
{
return $this->belongsTo(GuidePage::class, 'from_page_id');
}
public function toPage()
{
return $this->belongsTo(GuidePage::class, 'to_page_id');
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class KnowledgeBase extends Model
{
use HasFactory, SoftDeletes;
/**
* 可批量赋值的属性
*
* @var array<string>
*/
protected $fillable = [
'name',
'description',
'status',
];
/**
* 获取知识库下的文档
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function documents()
{
return $this->hasMany(Document::class);
}
/**
* 获取知识库关联的线站
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function stations()
{
return $this->belongsToMany(Station::class);
}
/**
* 按用户线站过滤:全局 KB无线站关联+ 用户线站关联的 KB
*/
public function scopeAccessibleBy(Builder $query, User $user): Builder
{
if (!$user->hasStationRestriction()) {
return $query;
}
$stationIds = $user->getAccessibleStationIds();
return $query->where(function (Builder $q) use ($stationIds) {
$q->whereDoesntHave('stations')
->orWhereHas('stations', fn ($sq) => $sq->whereIn('stations.id', $stationIds));
});
}
}

48
app/Models/Station.php Normal file
View File

@@ -0,0 +1,48 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Station extends Model
{
use HasFactory;
protected $fillable = [
'name',
'description',
];
protected static function boot()
{
parent::boot();
static::deleting(function (Station $station) {
$station->terminals()->update(['station_id' => null]);
});
}
public function terminals(): HasMany
{
return $this->hasMany(Terminal::class);
}
public function knowledgeBases(): BelongsToMany
{
return $this->belongsToMany(KnowledgeBase::class);
}
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class);
}
public function guides(): BelongsToMany
{
return $this->belongsToMany(Guide::class);
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
class SystemSetting extends Model
{
use HasFactory, LogsActivity;
/**
* 可批量赋值的属性
*
* @var array<string>
*/
protected $fillable = [
'key',
'value',
'group',
'description',
'is_public',
];
/**
* 属性类型转换
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'value' => 'array',
'is_public' => 'boolean',
];
}
/**
* 获取配置值
*
* @param string $key 配置键
* @param mixed $default 默认值
* @return mixed
*/
public static function get(string $key, $default = null)
{
$setting = static::where('key', $key)->first();
return $setting ? $setting->value : $default;
}
/**
* 设置配置值
*
* @param string $key 配置键
* @param mixed $value 配置值
* @param string $group 配置分组
* @return \App\Models\SystemSetting
*/
public static function set(string $key, $value, string $group = 'general')
{
return static::updateOrCreate(
['key' => $key],
['value' => $value, 'group' => $group]
);
}
/**
* 配置活动日志选项
*
* @return \Spatie\Activitylog\LogOptions
*/
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['key', 'value', 'group', 'description'])
->logOnlyDirty()
->setDescriptionForEvent(fn(string $eventName) => "系统设置已{$eventName}");
}
}

73
app/Models/Terminal.php Normal file
View File

@@ -0,0 +1,73 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
class Terminal extends Model
{
use HasFactory, SoftDeletes, LogsActivity;
/**
* 可批量赋值的属性
*
* @var array<string>
*/
protected $fillable = [
'name',
'code',
'ip_address',
'mac_address',
'station_id',
'diagram_urls',
'scada_data_url',
'scada_tags_url',
'prompt_template',
'voice_wakeup_enabled',
'voice_wakeup_word',
'is_online',
'last_online_at',
];
/**
* 属性类型转换
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'diagram_urls' => 'array',
'voice_wakeup_enabled' => 'boolean',
'is_online' => 'boolean',
'last_online_at' => 'datetime',
];
}
/**
* 获取终端所属的线站
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function station()
{
return $this->belongsTo(Station::class);
}
/**
* 配置活动日志选项
*
* @return \Spatie\Activitylog\LogOptions
*/
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['name', 'code', 'station_id', 'diagram_urls'])
->logOnlyDirty()
->setDescriptionForEvent(fn(string $eventName) => "终端已{$eventName}");
}
}

View File

@@ -8,11 +8,12 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable;
use HasFactory, Notifiable, HasRoles;
/**
* The attributes that are mass assignable.
@@ -49,11 +50,28 @@ class User extends Authenticatable
}
/**
* 获取用户所属的所有分组
* 获取用户关联的线站
*/
public function groups(): BelongsToMany
public function stations(): BelongsToMany
{
return $this->belongsToMany(Group::class);
return $this->belongsToMany(Station::class);
}
/**
* 获取用户可访问的线站 IDs
* 空数组表示无限制(管理员)
*/
public function getAccessibleStationIds(): array
{
return $this->stations()->pluck('stations.id')->toArray();
}
/**
* 用户是否受线站限制
*/
public function hasStationRestriction(): bool
{
return $this->stations()->exists();
}
/**
@@ -71,4 +89,20 @@ class User extends Authenticatable
{
return $this->hasMany(DownloadLog::class);
}
/**
* 检查用户是否为超级管理员
*/
public function isSuperAdmin(): bool
{
return $this->hasRole('super-admin');
}
/**
* 检查用户是否为管理员(包括超级管理员)
*/
public function isAdmin(): bool
{
return $this->hasAnyRole(['super-admin', 'admin']);
}
}

View File

@@ -38,7 +38,7 @@ class DocumentObserver
if ($document->wasChanged('conversion_status') && $document->conversion_status === 'completed') {
// 转换完成,创建或更新索引
$this->searchService->indexDocument($document);
} elseif ($document->wasChanged(['title', 'description', 'markdown_path', 'type', 'group_id'])) {
} elseif ($document->wasChanged(['title', 'description', 'markdown_path', 'knowledge_base_id'])) {
// 其他重要字段更新时,也更新索引
$this->searchService->updateDocumentIndex($document);
}
@@ -124,6 +124,8 @@ class DocumentObserver
]);
}
}
app(\App\Services\DocumentPdfPreviewService::class)->clearCachedPreview($document);
} catch (\Exception $e) {
\Log::error('清理文档文件失败', [
'document_id' => $document->id,

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Policies;
use App\Models\User;
use Spatie\Activitylog\Models\Activity;
class ActivityLogPolicy
{
/**
* 查看操作日志列表
*/
public function viewAny(User $user): bool
{
return $user->can('activity-log.view');
}
/**
* 查看操作日志详情
*/
public function view(User $user, Activity $activity): bool
{
return $user->can('activity-log.view');
}
/**
* 导出操作日志
*/
public function export(User $user): bool
{
return $user->can('activity-log.export');
}
}

View File

@@ -24,20 +24,21 @@ class DocumentPolicy
/**
* 判断用户是否可以查看文档列表
* 所有已认证用户都可以查看文档列表(但列表会根据权限过滤)
* 需求:权限检查 + 分组访问控制
*
* @param User $user
* @return bool
*/
public function viewAny(User $user): bool
{
// 所有已认证用户都可以查看文档列表
return true;
// 检查用户是否有查看文档的权限
return $user->can('document.view');
}
/**
* 判断用户是否可以查看特定文档
* 需求3.1, 3.4, 7.1, 7.2, 7.3
* 需求3.1, 3.4, 7.1, 7.2, 7.3 + 权限检查
* - 首先检查用户是否有 document.view 权限
* - 全局文档:所有用户都可以查看
* - 专用文档:只有所属分组的用户可以查看
* - 记录未授权访问尝试
@@ -48,52 +49,26 @@ class DocumentPolicy
*/
public function view(User $user, Document $document): bool
{
// 如果是全局文档,所有用户都可以查看
if ($document->type === 'global') {
return true;
}
// 如果是专用文档,检查用户是否属于该文档的分组
if ($document->type === 'dedicated') {
// 如果文档没有关联分组,拒绝访问
if (!$document->group_id) {
$this->securityLogger->logUnauthorizedAccess($user, $document, 'view');
return false;
}
// 检查用户是否属于该文档的分组
$hasAccess = $user->groups()->where('groups.id', $document->group_id)->exists();
// 如果没有权限,记录未授权访问尝试
if (!$hasAccess) {
$this->securityLogger->logUnauthorizedAccess($user, $document, 'view');
}
return $hasAccess;
}
// 其他情况拒绝访问
$this->securityLogger->logUnauthorizedAccess($user, $document, 'view');
return false;
return $user->can('document.view');
}
/**
* 判断用户是否可以创建文档
* 假设所有已认证用户都可以创建文档(可根据实际需求调整)
* 需求:权限检查
*
* @param User $user
* @return bool
*/
public function create(User $user): bool
{
// 所有已认证用户都可以创建文档
return true;
return $user->can('document.create');
}
/**
* 判断用户是否可以更新文档
* 只有文档的上传者可以更新文档(可根据实际需求调整为管理员也可以)
* 需求7.3
* 需求7.3 + 权限检查
* - 首先检查用户是否有 document.update 权限
* - 只有文档的上传者可以更新文档
*
* @param User $user
* @param Document $document
@@ -101,6 +76,12 @@ class DocumentPolicy
*/
public function update(User $user, Document $document): bool
{
// 首先检查用户是否有更新文档的权限
if (!$user->can('document.update')) {
$this->securityLogger->logUnauthorizedAccess($user, $document, 'update');
return false;
}
// 只有文档的上传者可以更新
$canUpdate = $document->uploaded_by === $user->id;
@@ -114,8 +95,9 @@ class DocumentPolicy
/**
* 判断用户是否可以删除文档
* 只有文档的上传者可以删除文档(可根据实际需求调整为管理员也可以)
* 需求7.3
* 需求7.3 + 权限检查
* - 首先检查用户是否有 document.delete 权限
* - 只有文档的上传者可以删除文档
*
* @param User $user
* @param Document $document
@@ -123,6 +105,12 @@ class DocumentPolicy
*/
public function delete(User $user, Document $document): bool
{
// 首先检查用户是否有删除文档的权限
if (!$user->can('document.delete')) {
$this->securityLogger->logUnauthorizedAccess($user, $document, 'delete');
return false;
}
// 只有文档的上传者可以删除
$canDelete = $document->uploaded_by === $user->id;
@@ -136,10 +124,11 @@ class DocumentPolicy
/**
* 判断用户是否可以下载文档
* 需求4.1, 4.2, 7.1, 7.2, 7.3
* 下载权限与查看权限相同:
* - 全局文档:所有用户都可以下载
* - 专用文档:只有所属分组的用户可以下载
* 需求4.1, 4.2, 7.1, 7.2, 7.3 + 权限检查
* - 首先检查用户是否有 document.download 权限
* - 下载权限与查看权限相同:
* - 全局文档:所有用户可以下载
* - 专用文档:只有所属分组的用户可以下载
* - 记录未授权下载尝试
*
* @param User $user
@@ -148,13 +137,7 @@ class DocumentPolicy
*/
public function download(User $user, Document $document): bool
{
// 下载权限与查看权限相同
$canDownload = $this->view($user, $document);
// 注意view 方法已经记录了未授权访问,这里不需要重复记录
// 但如果需要区分 view 和 download 操作,可以在这里单独记录
return $canDownload;
return $user->can('document.download');
}
/**

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Policies;
use App\Models\Guide;
use App\Models\User;
class GuidePolicy
{
/**
* 查看指引列表
*/
public function viewAny(User $user): bool
{
return $user->can('guide.view');
}
/**
* 查看指引
*/
public function view(User $user, Guide $guide): bool
{
return $user->can('guide.view');
}
/**
* 创建指引
*/
public function create(User $user): bool
{
return $user->can('guide.create');
}
/**
* 更新指引
*/
public function update(User $user, Guide $guide): bool
{
return $user->can('guide.update');
}
/**
* 删除指引
*/
public function delete(User $user, Guide $guide): bool
{
return $user->can('guide.delete');
}
/**
* 发布指引
*/
public function publish(User $user, Guide $guide): bool
{
return $user->can('guide.publish');
}
/**
* 归档指引
*/
public function archive(User $user, Guide $guide): bool
{
return $user->can('guide.archive');
}
/**
* 恢复已删除的指引
*/
public function restore(User $user, Guide $guide): bool
{
return $user->can('guide.delete');
}
/**
* 永久删除指引
*/
public function forceDelete(User $user, Guide $guide): bool
{
return $user->can('guide.delete');
}
}

View File

@@ -0,0 +1,72 @@
<?php
namespace App\Policies;
use App\Models\User;
use Spatie\Permission\Models\Role;
class RolePolicy
{
/**
* 查看角色列表
*/
public function viewAny(User $user): bool
{
return $user->can('role.view');
}
/**
* 查看角色详情
*/
public function view(User $user, Role $role): bool
{
return $user->can('role.view');
}
/**
* 创建角色
*/
public function create(User $user): bool
{
return $user->can('role.create');
}
/**
* 编辑角色
*/
public function update(User $user, Role $role): bool
{
// super-admin 角色不能被编辑
if ($role->name === 'super-admin') {
return false;
}
return $user->can('role.update');
}
/**
* 删除角色
*/
public function delete(User $user, Role $role): bool
{
// super-admin 角色不能被删除
if ($role->name === 'super-admin') {
return false;
}
// 检查是否有关联用户
if ($role->users()->count() > 0) {
return false;
}
return $user->can('role.delete');
}
/**
* 批量删除角色
*/
public function deleteAny(User $user): bool
{
return $user->can('role.delete');
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Policies;
use App\Models\Station;
use App\Models\User;
class StationPolicy
{
public function viewAny(User $user): bool
{
return $user->can('station.view');
}
public function view(User $user, Station $station): bool
{
return $user->can('station.view');
}
public function create(User $user): bool
{
return $user->can('station.create');
}
public function update(User $user, Station $station): bool
{
return $user->can('station.update');
}
public function delete(User $user, Station $station): bool
{
return $user->can('station.delete');
}
public function deleteAny(User $user): bool
{
return $user->can('station.delete');
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Policies;
use App\Models\SystemSetting;
use App\Models\User;
class SystemSettingPolicy
{
/**
* 查看系统设置列表
*/
public function viewAny(User $user): bool
{
return $user->can('system-setting.view');
}
/**
* 查看系统设置详情
*/
public function view(User $user, SystemSetting $systemSetting): bool
{
return $user->can('system-setting.view');
}
/**
* 更新系统设置
*/
public function update(User $user, SystemSetting $systemSetting): bool
{
return $user->can('system-setting.update');
}
}

View File

@@ -0,0 +1,91 @@
<?php
namespace App\Policies;
use App\Models\Terminal;
use App\Models\User;
class TerminalPolicy
{
/**
* 判断用户是否可以查看终端列表
*
* @param User $user
* @return bool
*/
public function viewAny(User $user): bool
{
return $user->can('terminal.view');
}
/**
* 判断用户是否可以查看特定终端
*
* @param User $user
* @param Terminal $terminal
* @return bool
*/
public function view(User $user, Terminal $terminal): bool
{
return $user->can('terminal.view');
}
/**
* 判断用户是否可以创建终端
*
* @param User $user
* @return bool
*/
public function create(User $user): bool
{
return $user->can('terminal.create');
}
/**
* 判断用户是否可以更新终端
*
* @param User $user
* @param Terminal $terminal
* @return bool
*/
public function update(User $user, Terminal $terminal): bool
{
return $user->can('terminal.update');
}
/**
* 判断用户是否可以删除终端
*
* @param User $user
* @param Terminal $terminal
* @return bool
*/
public function delete(User $user, Terminal $terminal): bool
{
return $user->can('terminal.delete');
}
/**
* 判断用户是否可以恢复已删除的终端
*
* @param User $user
* @param Terminal $terminal
* @return bool
*/
public function restore(User $user, Terminal $terminal): bool
{
return $user->can('terminal.delete');
}
/**
* 判断用户是否可以永久删除终端
*
* @param User $user
* @param Terminal $terminal
* @return bool
*/
public function forceDelete(User $user, Terminal $terminal): bool
{
return $user->can('terminal.delete');
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Policies;
use App\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
class UserPolicy
{
use HandlesAuthorization;
/**
* 查看用户列表
*/
public function viewAny(User $user): bool
{
return $user->can('user.view');
}
/**
* 查看单个用户
*/
public function view(User $user, User $model): bool
{
return $user->can('user.view');
}
/**
* 创建用户
*/
public function create(User $user): bool
{
return $user->can('user.create');
}
/**
* 更新用户
*/
public function update(User $user, User $model): bool
{
// 超级管理员只能由超级管理员编辑
if ($model->isSuperAdmin() && !$user->isSuperAdmin()) {
return false;
}
return $user->can('user.update');
}
/**
* 删除用户
*/
public function delete(User $user, User $model): bool
{
// 不能删除超级管理员
if ($model->isSuperAdmin()) {
return false;
}
// 不能删除自己
if ($user->id === $model->id) {
return false;
}
return $user->can('user.delete');
}
/**
* 批量删除用户
*/
public function deleteAny(User $user): bool
{
return $user->can('user.delete');
}
}

View File

@@ -3,8 +3,11 @@
namespace App\Providers;
use App\Models\Document;
use App\Models\Guide;
use App\Observers\DocumentObserver;
use App\Policies\GuidePolicy;
use Carbon\Carbon;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
@@ -27,5 +30,15 @@ class AppServiceProvider extends ServiceProvider
// 注册文档观察者,用于自动管理 Meilisearch 索引
Document::observe(DocumentObserver::class);
// 注册策略
Gate::policy(\App\Models\Document::class, \App\Policies\DocumentPolicy::class);
Gate::policy(\App\Models\Terminal::class, \App\Policies\TerminalPolicy::class);
Gate::policy(Guide::class, GuidePolicy::class);
Gate::policy(\Spatie\Permission\Models\Role::class, \App\Policies\RolePolicy::class);
Gate::policy(\App\Models\User::class, \App\Policies\UserPolicy::class);
Gate::policy(\App\Models\SystemSetting::class, \App\Policies\SystemSettingPolicy::class);
Gate::policy(\Spatie\Activitylog\Models\Activity::class, \App\Policies\ActivityLogPolicy::class);
Gate::policy(\App\Models\Station::class, \App\Policies\StationPolicy::class);
}
}

View File

@@ -33,12 +33,11 @@ class AdminPanelProvider extends PanelProvider
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages')
->pages([
Pages\Dashboard::class,
\App\Filament\Pages\Dashboard::class,
])
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
->widgets([
Widgets\AccountWidget::class,
Widgets\FilamentInfoWidget::class,
])
->middleware([
EncryptCookies::class,

View File

@@ -4,290 +4,164 @@ namespace App\Services;
use App\Models\Document;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Paperdoc\Contracts\DocumentInterface;
use Paperdoc\Document\Image;
use Paperdoc\Support\DocumentManager;
/**
* 文档转换服务
* 负责将 Word 文档转换为 Markdown 格式
* 使用 paperdoc-lib 文档DOCX/PPTX/XLSX/PDF转换为 Markdown
*/
class DocumentConversionService
{
/**
* 转换驱动
*
* @var string
*/
protected string $driver;
/**
* Pandoc 可执行文件路径
*
* @var string
*/
protected string $pandocPath;
/**
* 转换超时时间(秒)
*
* @var int
*/
protected int $timeout;
/**
* Markdown 预览长度
*
* @var int
*/
protected int $previewLength;
/**
* 构造函数
*/
public function __construct()
{
$this->driver = config('documents.conversion.driver', 'pandoc');
$this->pandocPath = config('documents.conversion.pandoc_path', 'pandoc');
$this->timeout = config('documents.conversion.timeout', 300);
$this->previewLength = config('documents.markdown.preview_length', 500);
}
/**
* Word 文档转换为 Markdown
* 将文档转换为 Markdown
*
* @param Document $document
* @return array 返回 ['markdown' => string, 'mediaDir' => string|null, 'tempDir' => string]
* @throws \Exception
* @return array{markdown: string, media_files: array<string, string>}
*/
public function convertToMarkdown(Document $document): array
{
if ($this->driver === 'pandoc') {
return $this->convertWithPandoc($document);
}
$this->ensureConversionDependenciesAvailable();
throw new \Exception("不支持的转换驱动: {$this->driver}");
}
/**
* 使用 Pandoc 转换文档
*
* @param Document $document
* @return array 返回 ['markdown' => string, 'mediaDir' => string|null]
* @throws \Exception
*/
protected function convertWithPandoc(Document $document): array
{
// 获取文档的完整路径
$documentPath = Storage::disk('local')->path($document->file_path);
if (!file_exists($documentPath)) {
throw new \Exception("文档文件不存在: {$documentPath}");
}
// 创建临时工作目录
$tempDir = sys_get_temp_dir() . '/pandoc_' . uniqid();
mkdir($tempDir, 0755, true);
$tempOutputPath = $tempDir . '/output.md';
$doc = DocumentManager::open($documentPath, ['ocr' => false]);
$markdown = DocumentManager::renderAs($doc, 'md');
try {
// 在临时目录中执行 Pandoc 转换命令
$result = Process::timeout($this->timeout)
->path($tempDir)
->run([
$this->pandocPath,
$documentPath,
'-f', $this->getInputFormat($document->mime_type),
'-t', 'markdown',
'-o', $tempOutputPath,
'--wrap=none', // 不自动换行
'--extract-media=.', // 提取媒体文件到当前目录
]);
if (empty(trim($markdown))) {
throw new \Exception('文档转换后内容为空,可能是扫描件或不支持的内容格式');
}
if (!$result->successful()) {
throw new \Exception("Pandoc 转换失败: {$result->errorOutput()}");
}
return [
'markdown' => $markdown,
'media_files' => $this->extractMarkdownMediaFiles($doc),
];
}
// 读取转换后的 Markdown 内容
if (!file_exists($tempOutputPath)) {
throw new \Exception("转换后的 Markdown 文件不存在");
}
$markdown = file_get_contents($tempOutputPath);
if ($markdown === false) {
throw new \Exception("无法读取转换后的 Markdown 文件");
}
// 检查是否有提取的媒体文件
$mediaDir = $tempDir . '/media';
$hasMedia = is_dir($mediaDir) && count(glob($mediaDir . '/*')) > 0;
return [
'markdown' => $markdown,
'mediaDir' => $hasMedia ? $mediaDir : null,
'tempDir' => $tempDir,
];
} catch (\Exception $e) {
// 清理临时目录
$this->deleteDirectory($tempDir);
throw $e;
/**
* 确保文档转换依赖已经安装
*/
protected function ensureConversionDependenciesAvailable(): void
{
if (!class_exists(DocumentManager::class)) {
throw new \RuntimeException(
'文档转换依赖未安装paperdoc-dev/paperdoc-lib。请执行 composer install 后重试。'
);
}
}
/**
* 递归删除目录
* Markdown 内容保存到存储
*
* @param string $dir 目录路径
* @return void
* @param array<string, string> $mediaFiles
*/
protected function deleteDirectory(string $dir): void
public function saveMarkdownToFile(Document $document, string $markdown, array $mediaFiles = []): string
{
if (!file_exists($dir)) {
return;
}
if (!is_dir($dir)) {
unlink($dir);
return;
}
$files = array_diff(scandir($dir), ['.', '..']);
foreach ($files as $file) {
$path = $dir . '/' . $file;
if (is_dir($path)) {
$this->deleteDirectory($path);
} else {
unlink($path);
}
}
rmdir($dir);
}
/**
* 根据 MIME 类型获取 Pandoc 输入格式
*
* @param string $mimeType
* @return string
*/
protected function getInputFormat(string $mimeType): string
{
return match ($mimeType) {
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
'application/msword' => 'doc',
default => 'docx',
};
}
/**
* Markdown 内容和媒体文件保存到存储
*
* @param Document $document
* @param string $markdown
* @param string|null $mediaDir 临时媒体目录路径
* @return string 返回 Markdown 文件路径
* @throws \Exception
*/
public function saveMarkdownToFile(Document $document, string $markdown, ?string $mediaDir = null): string
{
// 生成文件路径
$path = $this->generateMarkdownPath($document);
$directory = dirname($path);
// 如果有媒体文件,先保存它们
if ($mediaDir && is_dir($mediaDir)) {
$this->saveMediaFiles($mediaDir, $directory);
}
// 保存 Markdown 文件
$saved = Storage::disk('markdown')->put($path, $markdown);
if (!$saved) {
throw new \Exception("无法保存 Markdown 文件");
throw new \Exception('无法保存 Markdown 文件');
}
$this->storeMarkdownMediaFiles(dirname($path), $mediaFiles);
return $path;
}
/**
* 保存媒体文件到 storage
* 媒体文件保存在文档的 UUID 目录下的 media 子目录中
*
* @param string $sourceDir 源媒体目录
* @param string $targetDir 目标目录(相对于 markdown disk例如2025/12/04/{uuid}
* @return void
* 为已存在的 Markdown 文档补齐缺失的图片资源
*/
protected function saveMediaFiles(string $sourceDir, string $targetDir): void
public function ensureMarkdownMediaAssets(Document $document): void
{
$files = glob($sourceDir . '/*');
foreach ($files as $file) {
if (is_file($file)) {
$filename = basename($file);
// 保存到文档目录下的 media 子目录
$targetPath = $targetDir . '/media/' . $filename;
// 读取文件内容
$content = file_get_contents($file);
// 保存到 storage
Storage::disk('markdown')->put($targetPath, $content);
Log::info('媒体文件已保存', [
'filename' => $filename,
'path' => $targetPath,
]);
$this->ensureConversionDependenciesAvailable();
if (empty($document->markdown_path)) {
return;
}
$markdown = $document->getMarkdownContent();
if (empty($markdown)) {
return;
}
if (!preg_match_all('/!\[[^\]]*]\(((?:\.\/)?media\/[^)]+)\)/', $markdown, $matches)) {
return;
}
$documentDir = dirname($document->markdown_path);
$missingRefs = [];
foreach ($matches[1] as $ref) {
$relativePath = $this->normalizeMarkdownMediaPath($ref);
if ($relativePath === null) {
continue;
}
if (!Storage::disk('markdown')->exists($documentDir . '/' . $relativePath)) {
$missingRefs[] = $relativePath;
}
}
if ($missingRefs === []) {
return;
}
$documentPath = Storage::disk('local')->path($document->file_path);
if (!file_exists($documentPath)) {
throw new \Exception("文档文件不存在: {$documentPath}");
}
$doc = DocumentManager::open($documentPath, ['ocr' => false]);
$mediaFiles = array_intersect_key(
$this->extractMarkdownMediaFiles($doc),
array_flip($missingRefs)
);
$this->storeMarkdownMediaFiles($documentDir, $mediaFiles);
}
/**
* 生成 Markdown 文件路径
* 使用 UUID 作为目录名,确保每个文档有独立的 media 目录
*
* @param Document $document
* @return string
*/
protected function generateMarkdownPath(Document $document): string
{
$organizeByDate = config('documents.storage.organize_by_date', true);
// 生成唯一的 UUID 作为文档目录
$uuid = Str::uuid()->toString();
if ($organizeByDate) {
// 按日期组织: YYYY/MM/DD/{uuid}/{uuid}.md
$date = $document->created_at ?? now();
$directory = $date->format('Y/m/d') . '/' . $uuid;
} else {
// 直接使用 UUID: {uuid}/{uuid}.md
$directory = $uuid;
}
// 文件名也使用相同的 UUID
$filename = $uuid . '.md';
return "{$directory}/{$filename}";
return "{$directory}/{$uuid}.md";
}
/**
* 获取 Markdown 内容的预览(前 N 个字符)
*
* @param string $markdown
* @param int|null $length
* @return string
*/
public function getMarkdownPreview(string $markdown, ?int $length = null): string
{
$length = $length ?? $this->previewLength;
// 移除多余的空白字符
$cleaned = preg_replace('/\s+/', ' ', $markdown);
$cleaned = trim($cleaned);
// 截取指定长度
if (mb_strlen($cleaned) <= $length) {
return $cleaned;
}
@@ -297,14 +171,9 @@ class DocumentConversionService
/**
* 更新文档的 Markdown 信息
*
* @param Document $document
* @param string $markdownPath
* @return void
*/
public function updateDocumentMarkdown(Document $document, string $markdownPath): void
{
// 读取 Markdown 内容以生成预览
$markdown = Storage::disk('markdown')->get($markdownPath);
if ($markdown === false) {
@@ -312,60 +181,152 @@ class DocumentConversionService
'document_id' => $document->id,
'markdown_path' => $markdownPath,
]);
$preview = '';
} else {
$preview = $this->getMarkdownPreview($markdown);
$this->getMarkdownPreview($markdown);
}
// 更新文档记录
$document->update([
'markdown_path' => $markdownPath,
'markdown_preview' => $preview,
'conversion_status' => 'completed',
'conversion_error' => null,
]);
Document::withoutSyncingToSearch(function () use ($document, $markdownPath): void {
$document->update([
'markdown_path' => $markdownPath,
'conversion_status' => 'completed',
'conversion_error' => null,
]);
});
}
/**
* 处理转换失败
*
* @param Document $document
* @param \Exception $exception
* @return void
*/
public function handleConversionFailure(Document $document, \Exception $exception): void
{
Log::error('文档转换失败', [
'document_id' => $document->id,
'document_title' => $document->title,
'file_name' => $document->file_name,
'error' => $exception->getMessage(),
'trace' => $exception->getTraceAsString(),
]);
// 更新文档状态
$document->update([
'conversion_status' => 'failed',
'conversion_error' => $exception->getMessage(),
]);
Document::withoutSyncingToSearch(function () use ($document, $exception): void {
$document->update([
'conversion_status' => 'failed',
'conversion_error' => $exception->getMessage(),
]);
});
}
/**
* 将转换任务加入队列
*
* @param Document $document
* @return void
*/
public function queueConversion(Document $document): void
{
// 更新文档状态为处理中
$document->update([
'conversion_status' => 'processing',
'conversion_error' => null,
]);
Document::withoutSyncingToSearch(function () use ($document): void {
$document->update([
'conversion_status' => 'processing',
'conversion_error' => null,
]);
});
// 分发队列任务
$queue = config('documents.conversion.queue', 'documents');
\App\Jobs\ConvertDocumentToMarkdown::dispatch($document)->onQueue($queue);
}
}
/**
* @return array<string, string>
*/
protected function extractMarkdownMediaFiles(DocumentInterface $document): array
{
$mediaFiles = [];
$fallbackIndex = 1;
foreach ($document->getSections() as $section) {
foreach ($section->getElements() as $element) {
if (!$element instanceof Image || !$element->hasData()) {
continue;
}
$relativePath = $this->normalizeMarkdownMediaPath($element->getSrc());
if ($relativePath === null) {
$relativePath = sprintf(
'media/image-%d.%s',
$fallbackIndex++,
$this->guessImageExtension($element)
);
}
$mediaFiles[$relativePath] = $element->getData();
}
}
return $mediaFiles;
}
/**
* @param array<string, string> $mediaFiles
*/
protected function storeMarkdownMediaFiles(string $documentDir, array $mediaFiles): void
{
foreach ($mediaFiles as $relativePath => $contents) {
$targetPath = $documentDir . '/' . ltrim($relativePath, '/');
$targetDirectory = dirname($targetPath);
if ($targetDirectory !== '.' && !Storage::disk('markdown')->exists($targetDirectory)) {
Storage::disk('markdown')->makeDirectory($targetDirectory);
}
Storage::disk('markdown')->put($targetPath, $contents);
}
}
protected function normalizeMarkdownMediaPath(string $path): ?string
{
$path = trim($path);
if ($path === '') {
return null;
}
if (str_contains($path, '://') || str_starts_with($path, 'data:')) {
return null;
}
$path = preg_replace('/^\.?\//', '', $path) ?? $path;
$path = str_replace('\\', '/', $path);
$path = ltrim($path, '/');
if ($path === '' || !str_starts_with($path, 'media/')) {
return null;
}
$segments = array_values(array_filter(
explode('/', $path),
fn (string $segment): bool => $segment !== '' && $segment !== '.'
));
if ($segments === []) {
return null;
}
foreach ($segments as $segment) {
if ($segment === '..') {
return null;
}
}
return implode('/', $segments);
}
protected function guessImageExtension(Image $image): string
{
return match ($image->getMimeType()) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
'image/bmp' => 'bmp',
'image/tiff' => 'tiff',
'image/svg+xml' => 'svg',
default => pathinfo($image->getSrc(), PATHINFO_EXTENSION) ?: 'bin',
};
}
}

View File

@@ -0,0 +1,190 @@
<?php
namespace App\Services;
use App\Models\Document;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Process;
class DocumentPdfPreviewService
{
public function canPreview(Document $document): bool
{
return $document->conversion_status === 'completed'
&& ! empty($document->file_path)
&& Storage::disk('local')->exists($document->file_path);
}
public function getPreviewPath(Document $document): string
{
if (! $this->canPreview($document)) {
throw new \RuntimeException('文档尚未完成转换或原文件不存在');
}
if ($this->isPdf($document)) {
return Storage::disk('local')->path($document->file_path);
}
$previewPath = $this->cachedPreviewPath($document);
if (! $this->cachedPreviewIsFresh($document, $previewPath)) {
$this->generatePdfPreview($document, $previewPath);
}
return Storage::disk('previews')->path($previewPath);
}
public function previewUrl(Document $document): string
{
return route('documents.preview-pdf', $document);
}
public function clearCachedPreview(Document $document): void
{
Storage::disk('previews')->deleteDirectory((string) $document->getKey());
}
protected function isPdf(Document $document): bool
{
$extension = strtolower(pathinfo($document->display_file_name ?: $document->file_path, PATHINFO_EXTENSION));
return $document->mime_type === 'application/pdf' || $extension === 'pdf';
}
protected function cachedPreviewPath(Document $document): string
{
return $document->getKey() . '/preview-libreoffice.pdf';
}
protected function cachedPreviewIsFresh(Document $document, string $previewPath): bool
{
if (! Storage::disk('previews')->exists($previewPath)) {
return false;
}
$sourceMtime = Storage::disk('local')->lastModified($document->file_path);
$previewMtime = Storage::disk('previews')->lastModified($previewPath);
return $previewMtime >= $sourceMtime;
}
protected function generatePdfPreview(Document $document, string $previewPath): void
{
$sourcePath = Storage::disk('local')->path($document->file_path);
$absolutePreviewPath = Storage::disk('previews')->path($previewPath);
$previewDirectory = dirname($absolutePreviewPath);
if (! is_dir($previewDirectory) && ! mkdir($previewDirectory, 0775, true) && ! is_dir($previewDirectory)) {
throw new \RuntimeException('无法创建 PDF 预览目录');
}
$this->convertWithLibreOffice($sourcePath, $absolutePreviewPath, $previewDirectory);
if (! file_exists($absolutePreviewPath) || filesize($absolutePreviewPath) === 0) {
throw new \RuntimeException('PDF 预览生成失败');
}
}
protected function convertWithLibreOffice(string $sourcePath, string $targetPath, string $workingDirectory): void
{
$binary = $this->resolveLibreOfficeBinary();
if ($binary === null) {
throw new \RuntimeException('无法生成准确的 PDF 预览:服务器未安装 LibreOffice/soffice。请安装 LibreOffice 和中文字体后重试。');
}
$tmpDirectory = $workingDirectory . '/tmp-' . bin2hex(random_bytes(8));
$profileDirectory = $tmpDirectory . '/profile';
if (! mkdir($profileDirectory, 0775, true) && ! is_dir($profileDirectory)) {
throw new \RuntimeException('无法创建 LibreOffice 临时目录');
}
$process = new Process([
$binary,
'--headless',
'--nologo',
'--nofirststartwizard',
'--norestore',
'-env:UserInstallation=file://' . $profileDirectory,
'--convert-to',
'pdf',
'--outdir',
$tmpDirectory,
$sourcePath,
]);
$process->setTimeout((int) config('documents.conversion.timeout', 300));
try {
$process->mustRun();
$convertedPath = $this->findConvertedPdf($tmpDirectory, $sourcePath);
if ($convertedPath === null) {
throw new \RuntimeException(trim($process->getOutput() . "\n" . $process->getErrorOutput()) ?: 'LibreOffice 未输出 PDF 文件');
}
if (! rename($convertedPath, $targetPath)) {
throw new \RuntimeException('无法保存 LibreOffice 生成的 PDF 预览');
}
} catch (ProcessFailedException $e) {
throw new \RuntimeException('LibreOffice 转换 PDF 失败:' . trim($e->getProcess()->getErrorOutput() ?: $e->getProcess()->getOutput()), 0, $e);
} finally {
$this->deleteDirectory($tmpDirectory);
}
}
protected function resolveLibreOfficeBinary(): ?string
{
$configured = env('LIBREOFFICE_BINARY');
$candidates = array_filter([
is_string($configured) && $configured !== '' ? $configured : null,
'/opt/homebrew/bin/soffice',
'/opt/homebrew/bin/libreoffice',
'/usr/bin/libreoffice',
'/usr/bin/soffice',
'/usr/local/bin/libreoffice',
'/usr/local/bin/soffice',
'/Applications/LibreOffice.app/Contents/MacOS/soffice',
]);
foreach ($candidates as $candidate) {
if (is_executable($candidate)) {
return $candidate;
}
}
return null;
}
protected function findConvertedPdf(string $directory, string $sourcePath): ?string
{
$expectedPath = $directory . '/' . pathinfo($sourcePath, PATHINFO_FILENAME) . '.pdf';
if (is_file($expectedPath)) {
return $expectedPath;
}
$pdfFiles = glob($directory . '/*.pdf') ?: [];
return $pdfFiles[0] ?? null;
}
protected function deleteDirectory(string $directory): void
{
if (! is_dir($directory)) {
return;
}
$items = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($items as $item) {
$item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
}
rmdir($directory);
}
}

Some files were not shown because too many files have changed in this diff Show More