Compare commits
79 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 63f2827cc9 | |||
| 7e5a6a3f39 | |||
| 0e73a77b86 | |||
| e935afddfe | |||
| 37dd58eff0 | |||
| a917338d0c | |||
| 1f9ee979f1 | |||
| 0b35e54fe1 | |||
| 6acd0ccad0 | |||
| 295cf12899 | |||
| ad0add4500 | |||
| d19b770ef4 | |||
| 42a879e961 | |||
| b74ba1a3f8 | |||
| 63ea2686e1 | |||
| 89af7c17f1 | |||
| 81a22a2b54 | |||
| 8d30a0419d | |||
| 58f42de9df | |||
| bbe8e60646 | |||
| 06cf30130d | |||
| f89acbb2dc | |||
| b5af8a8d61 | |||
| 6313181658 | |||
| 578fc3be82 | |||
| 29c209116e | |||
| ed9260d5a6 | |||
| 32cf642f6f | |||
| 0fb9b1938d | |||
| 704d1225e6 | |||
| b3f319fc48 | |||
| ec54f0958d | |||
| 267bb9a36f | |||
| 3e7083d7c1 | |||
| 788101d21f | |||
| 1843fa2883 | |||
| 8018f4625c | |||
| a100b2dce7 | |||
| 73d039bcd6 | |||
| 386fe42f76 | |||
| c2b83e7857 | |||
| dfe0ff42bc | |||
| a17fe167b0 | |||
| 7d13a560f3 | |||
| 7a4fa7cc18 | |||
| 5dc6188802 | |||
| 9f411b742a | |||
| 93919956b7 | |||
| bf002f9349 | |||
| 599a917246 | |||
| d37d1101fe | |||
| 225d04efc5 | |||
| f4ca8372c0 | |||
| 74de79e4c3 | |||
| ebd1392580 | |||
| f0c207693b | |||
| 6102ec95d2 | |||
| c4ab592fd5 | |||
| 05b1bea2f1 | |||
| 8bbd5dc30f | |||
| 6b6afd1b75 | |||
| 1d30fb1d4c | |||
| 3b90d97f02 | |||
| 6a6c59e3e4 | |||
| 333034d2f1 | |||
| 29f72eb65e | |||
| aee27ec4c0 | |||
| 112aec6b09 | |||
| b9c897cd64 | |||
| 232db047f1 | |||
| 752dd908f0 | |||
| 088a088b89 | |||
| ef195d1ea0 | |||
| 9d0055138c | |||
| cedd910728 | |||
| d1004c023f | |||
| 5476417c31 | |||
| 7d4448a912 | |||
| 3c206e9e06 |
56
.dockerignore
Normal file
56
.dockerignore
Normal 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
99
.env.development
Normal 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
|
||||||
20
.env.example
20
.env.example
@@ -20,6 +20,24 @@ LOG_STACK=single
|
|||||||
LOG_DEPRECATIONS_CHANNEL=null
|
LOG_DEPRECATIONS_CHANNEL=null
|
||||||
LOG_LEVEL=debug
|
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_CONNECTION=mysql
|
||||||
DB_HOST=127.0.0.1
|
DB_HOST=127.0.0.1
|
||||||
DB_PORT=3306
|
DB_PORT=3306
|
||||||
@@ -67,7 +85,7 @@ VITE_APP_NAME="${APP_NAME}"
|
|||||||
# Meilisearch Configuration
|
# Meilisearch Configuration
|
||||||
SCOUT_DRIVER=meilisearch
|
SCOUT_DRIVER=meilisearch
|
||||||
MEILISEARCH_HOST=http://127.0.0.1:7700
|
MEILISEARCH_HOST=http://127.0.0.1:7700
|
||||||
MEILISEARCH_KEY=masterKey
|
MEILISEARCH_KEY=dev-master-key
|
||||||
|
|
||||||
# Document Conversion Configuration
|
# Document Conversion Configuration
|
||||||
DOCUMENT_CONVERSION_DRIVER=pandoc
|
DOCUMENT_CONVERSION_DRIVER=pandoc
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -23,3 +23,7 @@
|
|||||||
Homestead.json
|
Homestead.json
|
||||||
Homestead.yaml
|
Homestead.yaml
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
rr
|
||||||
|
.rr.yaml
|
||||||
|
*.tar.gz
|
||||||
|
*.tar
|
||||||
|
|||||||
646
.kiro/specs/admin-management-features/design.md
Normal file
646
.kiro/specs/admin-management-features/design.md
Normal 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过滤
|
||||||
|
- 文件上传进行类型和大小限制
|
||||||
483
.kiro/specs/admin-management-features/requirements.md
Normal file
483
.kiro/specs/admin-management-features/requirements.md
Normal 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: string(IP地址)
|
||||||
|
- 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模板的版本管理可能导致数据量快速增长
|
||||||
|
- 操作日志的长期存储需要考虑归档策略
|
||||||
|
- 大屏配置的实时性要求可能影响系统性能
|
||||||
|
|
||||||
|
### 限制条件
|
||||||
|
- 本期不包含移动端适配
|
||||||
|
- 不包含多语言支持
|
||||||
|
- 不包含高级权限管理(如字段级权限)
|
||||||
527
.kiro/specs/admin-management-features/tasks.md
Normal file
527
.kiro/specs/admin-management-features/tasks.md
Normal 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 实现 viewAny(system-setting.view)
|
||||||
|
- [x] 19.2.2 实现 view(system-setting.view)
|
||||||
|
- [x] 19.2.3 实现 update(system-setting.update)
|
||||||
|
- [x] 19.3 ActivityLogPolicy
|
||||||
|
- [x] 19.3.1 实现 viewAny(activity-log.view)
|
||||||
|
- [x] 19.3.2 实现 view(activity-log.view)
|
||||||
|
- [x] 19.3.3 实现 export(activity-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 实现 viewAny(group.view)
|
||||||
|
- [x] 19.6.2 实现 view(group.view)
|
||||||
|
- [x] 19.6.3 实现 create(group.create)
|
||||||
|
- [x] 19.6.4 实现 update(group.update)
|
||||||
|
- [x] 19.6.5 实现 delete(group.delete,需检查关联文档)
|
||||||
|
- [x] 19.7 UserPolicy
|
||||||
|
- [x] 19.7.1 实现 viewAny(user.view)
|
||||||
|
- [x] 19.7.2 实现 view(user.view)
|
||||||
|
- [x] 19.7.3 实现 create(user.create)
|
||||||
|
- [x] 19.7.4 实现 update(user.update)
|
||||||
|
- [x] 19.7.5 实现 delete(user.delete,不能删除自己)
|
||||||
|
- [x] 19.8 RolePolicy
|
||||||
|
- [x] 19.8.1 实现 viewAny(role.viewAny)
|
||||||
|
- [x] 19.8.2 实现 view(role.view)
|
||||||
|
- [x] 19.8.3 实现 create(role.create)
|
||||||
|
- [x] 19.8.4 实现 update(role.update)
|
||||||
|
- [x] 19.8.5 实现 delete(role.delete,super-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%)
|
||||||
@@ -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:测试系统设置功能
|
||||||
290
.kiro/specs/docker-deployment/design.md
Normal file
290
.kiro/specs/docker-deployment/design.md
Normal 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次迭代
|
||||||
|
- 测试应该覆盖各种输入组合和边界条件
|
||||||
|
|
||||||
|
**集成测试**:
|
||||||
|
- 端到端部署流程测试
|
||||||
|
- 服务间通信测试
|
||||||
|
- 数据一致性测试
|
||||||
|
- 性能基准测试
|
||||||
102
.kiro/specs/docker-deployment/requirements.md
Normal file
102
.kiro/specs/docker-deployment/requirements.md
Normal 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 系统应包含开发工具和测试数据
|
||||||
157
.kiro/specs/docker-deployment/tasks.md
Normal file
157
.kiro/specs/docker-deployment/tasks.md
Normal 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. 最终检查点 - 确保所有测试通过
|
||||||
|
- 确保所有测试通过,如有问题请询问用户
|
||||||
300
.kiro/specs/swoole-integration/design.md
Normal file
300
.kiro/specs/swoole-integration/design.md
Normal 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 集成不会破坏现有的系统功能,同时提供预期的性能改进。
|
||||||
87
.kiro/specs/swoole-integration/requirements.md
Normal file
87
.kiro/specs/swoole-integration/requirements.md
Normal 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 支持回退到之前的架构
|
||||||
185
.kiro/specs/swoole-integration/tasks.md
Normal file
185
.kiro/specs/swoole-integration/tasks.md
Normal 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. **快速回滚**: 准备好快速回滚方案
|
||||||
@@ -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**
|
|
||||||
|
|
||||||
### 属性 2:ARIA标签完整性
|
|
||||||
|
|
||||||
*对于任何*可交互元素,该元素应该包含适当的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. 监控
|
|
||||||
|
|
||||||
- 监控动画性能
|
|
||||||
- 收集用户反馈
|
|
||||||
- 跟踪错误日志
|
|
||||||
@@ -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 系统应当确保足够的颜色对比度
|
|
||||||
@@ -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标签
|
|
||||||
- **属性 2:ARIA标签完整性**
|
|
||||||
- **验证需求: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
138
Dockerfile
Normal 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
362
README.md
@@ -1,59 +1,113 @@
|
|||||||
# 知识库系统
|
# 知识库管理系统
|
||||||
|
|
||||||
基于 Laravel 11 和 Filament 3.X 构建的企业级文档管理平台,支持 Word 文档上传、自动转换为 Markdown、全文搜索和基于分组的权限控制。
|
基于 Laravel 12 和 Filament 3.X 构建的企业级智能知识库管理平台,集成文档管理、SOP 标准作业流程、终端配置管理、AI 提示词模板和系统设置等功能模块,为企业提供全方位的知识管理解决方案。
|
||||||
|
|
||||||
[](https://laravel.com)
|
[](https://laravel.com)
|
||||||
[](https://filamentphp.com)
|
[](https://filamentphp.com)
|
||||||
[](https://php.net)
|
[](https://php.net)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
|
|
||||||
## ✨ 功能特性
|
## ✨ 核心功能
|
||||||
|
|
||||||
### 📄 文档管理
|
### 功能模块概览
|
||||||
|
|
||||||
|
| 模块 | 功能描述 | 状态 |
|
||||||
|
|------|---------|------|
|
||||||
|
| 📄 文档管理 | Word 文档上传、转换、预览、搜索 | ✅ 已完成 |
|
||||||
|
| 📋 SOP 流程 | 标准作业流程模板管理和版本控制 | ✅ 已完成 |
|
||||||
|
| 🖥️ 终端管理 | 生产终端配置和知识库关联 | ✅ 已完成 |
|
||||||
|
| 🤖 AI 提示词 | AI 助手提示词模板和变量系统 | ✅ 已完成 |
|
||||||
|
| ⚙️ 系统设置 | 动态系统配置管理 | ✅ 已完成 |
|
||||||
|
| 📊 活动日志 | 操作审计和变更追踪 | ✅ 已完成 |
|
||||||
|
| 👥 用户管理 | 用户和分组权限管理 | ✅ 已完成 |
|
||||||
|
| 🔍 全文搜索 | Meilisearch 快速搜索 | ✅ 已完成 |
|
||||||
|
|
||||||
|
### 📄 文档管理系统
|
||||||
- **多格式支持**:支持 .doc 和 .docx 格式的 Word 文档上传
|
- **多格式支持**:支持 .doc 和 .docx 格式的 Word 文档上传
|
||||||
- **智能分类**:
|
- **智能分类**:
|
||||||
- 全局知识库:所有用户可访问
|
- 全局知识库:所有用户可访问
|
||||||
- 专用知识库:仅特定分组用户可访问
|
- 专用知识库:仅特定分组用户可访问
|
||||||
|
- **自动转换**:异步将 Word 文档转换为 Markdown 格式
|
||||||
|
- **在线预览**:Markdown 格式在线预览,支持富文本渲染
|
||||||
- **安全下载**:支持原始文档下载,自动记录下载日志
|
- **安全下载**:支持原始文档下载,自动记录下载日志
|
||||||
- **在线预览**:Markdown 格式在线预览,无需下载
|
- **全文搜索**:集成 Meilisearch 提供毫秒级搜索响应
|
||||||
|
|
||||||
### 🔄 自动转换
|
### 📋 SOP 标准作业流程
|
||||||
- **异步处理**:使用 Laravel Queue 异步转换文档
|
- **模板管理**:创建和管理标准作业流程模板
|
||||||
- **多引擎支持**:支持 Pandoc 或 PHPWord 作为转换引擎
|
- **步骤编排**:支持多步骤流程定义,可设置步骤顺序
|
||||||
- **状态跟踪**:实时显示转换状态(待处理、处理中、已完成、失败)
|
- **交互任务**:支持在步骤中添加交互式任务
|
||||||
- **容错机制**:转换失败不影响文档正常使用
|
- **版本控制**:自动记录 SOP 模板的版本历史
|
||||||
|
- **状态管理**:支持草稿、已发布、已归档等状态
|
||||||
|
- **分类标签**:支持按类别、部门、岗位分类管理
|
||||||
|
- **导入导出**:支持 SOP 模板的导入和导出功能
|
||||||
|
|
||||||
### 🔍 全文搜索
|
### 🖥️ 终端配置管理
|
||||||
- **快速搜索**:集成 Meilisearch 提供毫秒级搜索响应
|
- **终端注册**:管理生产现场的终端设备
|
||||||
- **多字段搜索**:同时搜索标题、描述和文档内容
|
- **知识库关联**:为终端配置可访问的知识库,支持优先级设置
|
||||||
- **智能过滤**:搜索结果自动应用权限过滤
|
- **提示词配置**:为每个终端配置专属的 AI 提示词模板
|
||||||
- **高级筛选**:支持按类型、分组、上传者等条件筛选
|
- **配置同步**:支持终端配置的异步同步和状态跟踪
|
||||||
|
- **在线状态**:实时监控终端在线状态
|
||||||
|
- **工位绑定**:支持终端与工作站的绑定关系
|
||||||
|
- **图纸关联**:可为终端关联工位图纸 URL
|
||||||
|
|
||||||
### 🔐 权限控制
|
### 🤖 AI 提示词模板
|
||||||
- **灵活分组**:用户可以属于多个分组
|
- **模板库**:预定义多种场景的 AI 提示词模板
|
||||||
- **细粒度控制**:
|
- 通用助手:一般性问答和操作指导
|
||||||
|
- 安全专员:安全操作指导和风险提示
|
||||||
|
- 故障诊断:设备故障诊断和问题排查
|
||||||
|
- 培训教练:新员工培训和操作指导
|
||||||
|
- 质量检查:质量控制和检验指导
|
||||||
|
- **变量系统**:支持动态变量替换
|
||||||
|
- 用户信息:用户名、角色、部门
|
||||||
|
- 终端信息:终端名称、编码、工作站
|
||||||
|
- 时间信息:当前时间、日期、班次
|
||||||
|
- 知识库:关联的知识库列表
|
||||||
|
- **实时预览**:支持提示词模板的实时预览和变量替换
|
||||||
|
- **变量验证**:自动验证模板中使用的变量是否有效
|
||||||
|
|
||||||
|
### ⚙️ 系统设置管理
|
||||||
|
- **配置分组**:支持按功能模块分组管理系统配置
|
||||||
|
- **动态配置**:支持运行时动态修改系统配置
|
||||||
|
- **配置缓存**:自动缓存配置提升性能
|
||||||
|
- **公开配置**:支持标记配置是否对外公开
|
||||||
|
- **配置描述**:为每个配置项提供详细说明
|
||||||
|
|
||||||
|
### 📊 活动日志审计
|
||||||
|
- **操作记录**:自动记录所有重要操作
|
||||||
|
- **变更追踪**:详细记录数据变更前后的差异
|
||||||
|
- **用户追溯**:记录操作用户和时间信息
|
||||||
|
- **批量操作**:支持批量操作的日志记录
|
||||||
|
- **日志导出**:支持活动日志的导出功能
|
||||||
|
- **差异对比**:可视化展示数据变更的差异
|
||||||
|
|
||||||
|
### 🔐 权限与安全
|
||||||
|
- **用户分组**:用户可以属于多个分组
|
||||||
|
- **细粒度权限**:
|
||||||
- 全局文档:所有用户可访问
|
- 全局文档:所有用户可访问
|
||||||
- 专用文档:只有所属分组用户可访问
|
- 专用文档:只有所属分组用户可访问
|
||||||
- **多层验证**:在查询、下载、预览等操作中强制执行权限
|
- **策略控制**:基于 Laravel Policy 的权限控制
|
||||||
- **安全审计**:记录所有未授权访问尝试
|
- **安全日志**:记录所有未授权访问尝试
|
||||||
|
- **操作审计**:完整的操作日志和审计追踪
|
||||||
|
|
||||||
### 🎨 用户界面
|
### 🎨 用户界面
|
||||||
- **现代化设计**:基于 Filament 3.X 的美观管理界面
|
- **现代化设计**:基于 Filament 3.X 的美观管理界面
|
||||||
- **完整中文化**:所有界面元素使用简体中文
|
- **完整中文化**:所有界面元素使用简体中文
|
||||||
- **响应式布局**:完美支持桌面和移动设备
|
- **响应式布局**:完美支持桌面和移动设备
|
||||||
|
- **Monaco 编辑器**:集成 Monaco 编辑器支持代码和 Markdown 编辑
|
||||||
- **直观操作**:简洁的操作流程,降低学习成本
|
- **直观操作**:简洁的操作流程,降低学习成本
|
||||||
|
|
||||||
## 🚀 快速开始
|
## 🚀 快速开始
|
||||||
|
|
||||||
### 环境要求
|
### 环境要求
|
||||||
|
|
||||||
- PHP 8.1 或更高版本
|
- PHP 8.2 或更高版本
|
||||||
- Composer 2.x
|
- Composer 2.x
|
||||||
- Node.js 18+ 和 npm
|
- Node.js 18+ 和 npm
|
||||||
- MySQL 8.0+ 或 PostgreSQL 13+
|
- MySQL 8.0+ 或 PostgreSQL 13+
|
||||||
- Redis 6.0+
|
- Redis 6.0+
|
||||||
- Meilisearch 1.5+
|
- Meilisearch 1.5+
|
||||||
- Pandoc 2.x+(可选,用于文档转换)
|
- Pandoc 2.x+(可选,用于文档转换)
|
||||||
|
- Laravel Octane(可选,用于高性能部署)
|
||||||
|
|
||||||
### 安装步骤
|
### 安装步骤
|
||||||
|
|
||||||
@@ -107,19 +161,38 @@ npm run build
|
|||||||
|
|
||||||
8. **启动服务**
|
8. **启动服务**
|
||||||
|
|
||||||
在不同的终端窗口中运行:
|
开发环境有多种启动方式:
|
||||||
|
|
||||||
|
**方式一:传统方式(分别启动)**
|
||||||
```bash
|
```bash
|
||||||
# Laravel 开发服务器
|
# 终端 1:Laravel 开发服务器
|
||||||
php artisan serve
|
php artisan serve
|
||||||
|
|
||||||
# 队列工作进程
|
# 终端 2:队列工作进程
|
||||||
php artisan queue:work
|
php artisan queue:work
|
||||||
|
|
||||||
# Meilisearch(如果本地安装)
|
# 终端 3:Meilisearch(如果本地安装)
|
||||||
meilisearch --master-key="your-master-key"
|
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. **访问系统**
|
9. **访问系统**
|
||||||
|
|
||||||
打开浏览器访问:`http://localhost:8000/admin`
|
打开浏览器访问:`http://localhost:8000/admin`
|
||||||
@@ -131,7 +204,13 @@ meilisearch --master-key="your-master-key"
|
|||||||
- [API 参考](docs/API_REFERENCE.md) - 服务类和方法文档
|
- [API 参考](docs/API_REFERENCE.md) - 服务类和方法文档
|
||||||
- [Meilisearch 配置](docs/MEILISEARCH_SETUP.md) - 搜索引擎配置说明
|
- [Meilisearch 配置](docs/MEILISEARCH_SETUP.md) - 搜索引擎配置说明
|
||||||
- [文档转换指南](docs/DOCUMENT_CONVERSION_GUIDE.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) - 安全审计功能说明
|
- [安全日志](docs/security-logging.md) - 安全审计功能说明
|
||||||
|
- [Docker 部署](docker/README.md) - Docker 容器化部署指南
|
||||||
|
|
||||||
## 🔧 配置
|
## 🔧 配置
|
||||||
|
|
||||||
@@ -162,6 +241,17 @@ REDIS_PASSWORD=null
|
|||||||
REDIS_PORT=6379
|
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)。
|
详细配置说明请参考 [部署指南](docs/DEPLOYMENT.md)。
|
||||||
|
|
||||||
## 🧪 测试
|
## 🧪 测试
|
||||||
@@ -177,6 +267,10 @@ php artisan test --filter=DocumentAccessScopePropertyTest
|
|||||||
|
|
||||||
# 生成测试覆盖率报告
|
# 生成测试覆盖率报告
|
||||||
php artisan test --coverage
|
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 验证核心逻辑
|
- **属性测试**:使用 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** - 管理面板框架
|
- **Filament 3.X** - 管理面板框架
|
||||||
- **Laravel Scout** - 全文搜索集成
|
- **Laravel Scout** - 全文搜索集成
|
||||||
|
- **Laravel Octane** - 高性能应用服务器
|
||||||
- **Meilisearch** - 快速搜索引擎
|
- **Meilisearch** - 快速搜索引擎
|
||||||
- **Pandoc** - 文档格式转换工具
|
- **Pandoc** - 文档格式转换工具
|
||||||
|
- **Spatie Activity Log** - 活动日志记录
|
||||||
|
|
||||||
### 前端
|
### 前端
|
||||||
- **Blade** - Laravel 模板引擎
|
- **Blade** - Laravel 模板引擎
|
||||||
- **Tailwind CSS** - CSS 框架
|
- **Tailwind CSS** - CSS 框架
|
||||||
- **Alpine.js** - JavaScript 框架(Filament 内置)
|
- **Alpine.js** - JavaScript 框架(Filament 内置)
|
||||||
- **Livewire** - 全栈框架(Filament 内置)
|
- **Livewire** - 全栈框架(Filament 内置)
|
||||||
|
- **Monaco Editor** - 代码编辑器
|
||||||
|
|
||||||
### 数据库
|
### 数据库
|
||||||
- **MySQL 8.0+** 或 **PostgreSQL 13+**
|
- **MySQL 8.0+** 或 **PostgreSQL 13+**
|
||||||
@@ -206,41 +385,78 @@ php artisan test --coverage
|
|||||||
|
|
||||||
### 开发工具
|
### 开发工具
|
||||||
- **Pest PHP** - 测试框架
|
- **Pest PHP** - 测试框架
|
||||||
- **PHPStan** - 静态分析工具
|
|
||||||
- **Laravel Pint** - 代码格式化工具
|
- **Laravel Pint** - 代码格式化工具
|
||||||
|
- **Composer** - PHP 依赖管理
|
||||||
|
- **npm** - 前端依赖管理
|
||||||
|
|
||||||
## 🗂️ 项目结构
|
## 🗂️ 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
knowledge-base-system/
|
knowledge-base-system/
|
||||||
├── app/
|
├── app/
|
||||||
|
│ ├── Console/
|
||||||
|
│ │ └── Commands/ # Artisan 命令
|
||||||
|
│ ├── Exports/ # 数据导出类
|
||||||
│ ├── Filament/ # Filament 资源和页面
|
│ ├── Filament/ # Filament 资源和页面
|
||||||
│ │ ├── Pages/ # 自定义页面(搜索)
|
│ │ ├── Actions/ # 自定义操作
|
||||||
│ │ └── Resources/ # 资源管理(文档、分组、用户)
|
│ │ ├── Pages/ # 自定义页面(搜索、系统设置)
|
||||||
|
│ │ └── Resources/ # 资源管理
|
||||||
|
│ │ ├── ActivityLogResource/ # 活动日志
|
||||||
|
│ │ ├── DocumentResource/ # 文档管理
|
||||||
|
│ │ ├── GroupResource/ # 分组管理
|
||||||
|
│ │ ├── SopTemplateResource/ # SOP 模板
|
||||||
|
│ │ ├── SystemSettingResource/ # 系统设置
|
||||||
|
│ │ ├── TerminalResource/ # 终端管理
|
||||||
|
│ │ └── UserResource/ # 用户管理
|
||||||
│ ├── Http/
|
│ ├── Http/
|
||||||
│ │ └── Controllers/ # 控制器(文档预览)
|
│ │ └── Controllers/ # 控制器(文档预览)
|
||||||
│ ├── Jobs/ # 队列任务(文档转换)
|
│ ├── Jobs/ # 队列任务
|
||||||
|
│ │ ├── ConvertDocumentToMarkdown.php # 文档转换
|
||||||
|
│ │ └── SyncTerminalConfigJob.php # 终端同步
|
||||||
│ ├── Models/ # Eloquent 模型
|
│ ├── Models/ # Eloquent 模型
|
||||||
│ ├── Observers/ # 模型观察者(文档索引)
|
│ │ ├── Document.php # 文档
|
||||||
|
│ │ ├── Group.php # 分组
|
||||||
|
│ │ ├── KnowledgeBase.php # 知识库
|
||||||
|
│ │ ├── SopTemplate.php # SOP 模板
|
||||||
|
│ │ ├── SystemSetting.php # 系统设置
|
||||||
|
│ │ ├── Terminal.php # 终端
|
||||||
|
│ │ └── User.php # 用户
|
||||||
|
│ ├── Observers/ # 模型观察者
|
||||||
│ ├── Policies/ # 授权策略
|
│ ├── Policies/ # 授权策略
|
||||||
│ └── Services/ # 业务逻辑服务
|
│ └── Services/ # 业务逻辑服务
|
||||||
|
│ ├── DocumentConversionService.php # 文档转换
|
||||||
|
│ ├── DocumentPreviewService.php # 文档预览
|
||||||
|
│ ├── DocumentSearchService.php # 文档搜索
|
||||||
|
│ ├── PromptTemplateService.php # 提示词模板
|
||||||
|
│ ├── SopTemplateService.php # SOP 模板
|
||||||
|
│ ├── SystemSettingService.php # 系统设置
|
||||||
|
│ └── TerminalSyncService.php # 终端同步
|
||||||
├── config/
|
├── config/
|
||||||
|
│ ├── activitylog.php # 活动日志配置
|
||||||
│ ├── documents.php # 文档转换配置
|
│ ├── documents.php # 文档转换配置
|
||||||
│ ├── filesystems.php # 文件存储配置
|
│ ├── filesystems.php # 文件存储配置
|
||||||
|
│ ├── octane.php # Octane 配置
|
||||||
|
│ ├── prompt_templates.php # 提示词模板配置
|
||||||
|
│ ├── prompt_variables.php # 提示词变量配置
|
||||||
│ └── scout.php # Meilisearch 配置
|
│ └── scout.php # Meilisearch 配置
|
||||||
├── database/
|
├── database/
|
||||||
│ ├── factories/ # 测试数据工厂
|
│ ├── factories/ # 测试数据工厂
|
||||||
│ ├── migrations/ # 数据库迁移
|
│ ├── migrations/ # 数据库迁移
|
||||||
│ └── seeders/ # 数据填充
|
│ └── seeders/ # 数据填充
|
||||||
|
├── docker/ # Docker 部署文件
|
||||||
├── docs/ # 项目文档
|
├── docs/ # 项目文档
|
||||||
├── resources/
|
├── resources/
|
||||||
│ └── views/ # Blade 视图模板
|
│ └── views/ # Blade 视图模板
|
||||||
|
│ ├── documents/ # 文档预览视图
|
||||||
|
│ └── filament/ # Filament 自定义视图
|
||||||
├── storage/
|
├── storage/
|
||||||
│ └── app/
|
│ └── app/
|
||||||
│ └── private/
|
│ └── private/
|
||||||
│ ├── documents/ # 原始文档存储
|
│ ├── documents/ # 原始文档存储
|
||||||
│ └── markdown/ # Markdown 文件存储
|
│ └── markdown/ # Markdown 文件存储
|
||||||
├── tests/ # 测试文件
|
├── tests/ # 测试文件
|
||||||
|
│ ├── Feature/ # 功能测试
|
||||||
|
│ └── Unit/ # 单元测试
|
||||||
└── .kiro/
|
└── .kiro/
|
||||||
└── specs/ # 功能规格文档
|
└── specs/ # 功能规格文档
|
||||||
```
|
```
|
||||||
@@ -257,29 +473,72 @@ knowledge-base-system/
|
|||||||
|
|
||||||
## 📝 更新日志
|
## 📝 更新日志
|
||||||
|
|
||||||
### v1.0.0 (2025-12-05)
|
### v1.0.0 (2026-03-09)
|
||||||
|
|
||||||
#### 已实现功能
|
#### 核心功能模块
|
||||||
- ✅ 用户认证和授权
|
- ✅ 用户认证和授权系统
|
||||||
- ✅ 用户分组管理
|
- ✅ 用户分组管理
|
||||||
- ✅ 文档上传和存储
|
- ✅ 文档管理系统
|
||||||
- ✅ 文档分类(全局/专用)
|
- 文档上传和存储
|
||||||
- ✅ 基于分组的权限控制
|
- 文档分类(全局/专用)
|
||||||
- ✅ 文档下载和日志记录
|
- 基于分组的权限控制
|
||||||
- ✅ Word 文档自动转换为 Markdown
|
- 文档下载和日志记录
|
||||||
- ✅ 异步队列处理转换任务
|
- Word 文档自动转换为 Markdown
|
||||||
- ✅ Meilisearch 全文搜索集成
|
- 异步队列处理转换任务
|
||||||
- ✅ 文档 Markdown 在线预览
|
- Meilisearch 全文搜索集成
|
||||||
- ✅ 搜索结果权限过滤
|
- 文档 Markdown 在线预览
|
||||||
|
- 搜索结果权限过滤
|
||||||
|
|
||||||
|
#### 新增功能模块
|
||||||
|
- ✅ SOP 标准作业流程管理
|
||||||
|
- SOP 模板创建和编辑
|
||||||
|
- 多步骤流程定义
|
||||||
|
- 交互式任务支持
|
||||||
|
- 版本历史记录
|
||||||
|
- 状态管理(草稿/已发布/已归档)
|
||||||
|
- 导入导出功能
|
||||||
|
|
||||||
|
- ✅ 终端配置管理
|
||||||
|
- 终端设备注册和管理
|
||||||
|
- 知识库关联配置
|
||||||
|
- AI 提示词模板配置
|
||||||
|
- 配置同步功能
|
||||||
|
- 在线状态监控
|
||||||
|
- 工位绑定管理
|
||||||
|
|
||||||
|
- ✅ AI 提示词模板系统
|
||||||
|
- 预定义模板库(5 种场景)
|
||||||
|
- 动态变量系统
|
||||||
|
- 实时预览功能
|
||||||
|
- 变量验证机制
|
||||||
|
|
||||||
|
- ✅ 系统设置管理
|
||||||
|
- 配置分组管理
|
||||||
|
- 动态配置修改
|
||||||
|
- 配置缓存优化
|
||||||
|
|
||||||
|
- ✅ 活动日志审计
|
||||||
|
- 操作记录追踪
|
||||||
|
- 变更差异对比
|
||||||
|
- 用户操作审计
|
||||||
|
- 日志导出功能
|
||||||
|
|
||||||
|
#### 技术改进
|
||||||
|
- ✅ 升级到 Laravel 12.x
|
||||||
|
- ✅ 集成 Laravel Octane 支持
|
||||||
|
- ✅ 集成 Spatie Activity Log
|
||||||
|
- ✅ 集成 Monaco Editor
|
||||||
|
- ✅ 完整的测试套件
|
||||||
|
- ✅ Docker 容器化部署支持
|
||||||
- ✅ 安全日志记录
|
- ✅ 安全日志记录
|
||||||
- ✅ Filament 管理面板
|
- ✅ Filament 管理面板
|
||||||
- ✅ 完整中文界面
|
- ✅ 完整中文界面
|
||||||
|
|
||||||
#### 待完成功能
|
#### 待完成功能
|
||||||
- ⏳ 属性基础测试(Property-Based Testing)
|
|
||||||
- ⏳ 完整的功能测试套件
|
|
||||||
- ⏳ 性能优化(缓存、索引优化)
|
- ⏳ 性能优化(缓存、索引优化)
|
||||||
- ⏳ UI 增强(Alpine.js 动画和交互)
|
- ⏳ UI 增强(动画和交互优化)
|
||||||
|
- ⏳ 移动端适配优化
|
||||||
|
- ⏳ API 接口文档
|
||||||
|
|
||||||
## 🔒 安全
|
## 🔒 安全
|
||||||
|
|
||||||
@@ -297,8 +556,11 @@ knowledge-base-system/
|
|||||||
|
|
||||||
- [Laravel](https://laravel.com) - 优雅的 PHP 框架
|
- [Laravel](https://laravel.com) - 优雅的 PHP 框架
|
||||||
- [Filament](https://filamentphp.com) - 强大的管理面板
|
- [Filament](https://filamentphp.com) - 强大的管理面板
|
||||||
|
- [Laravel Octane](https://laravel.com/docs/octane) - 高性能应用服务器
|
||||||
- [Meilisearch](https://www.meilisearch.com) - 快速搜索引擎
|
- [Meilisearch](https://www.meilisearch.com) - 快速搜索引擎
|
||||||
- [Pandoc](https://pandoc.org) - 通用文档转换器
|
- [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 框架
|
- [Tailwind CSS](https://tailwindcss.com) - 实用优先的 CSS 框架
|
||||||
|
|
||||||
## 📞 联系方式
|
## 📞 联系方式
|
||||||
@@ -308,8 +570,8 @@ knowledge-base-system/
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**开发状态**: 🚧 活跃开发中
|
**开发状态**: 🚀 稳定版本
|
||||||
|
|
||||||
**最后更新**: 2025-12-05
|
**最后更新**: 2026-03-09
|
||||||
|
|
||||||
**版本**: 1.0.0
|
**版本**: 1.0.0
|
||||||
|
|||||||
55
app/Console/Commands/CreateAdminUser.php
Normal file
55
app/Console/Commands/CreateAdminUser.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
103
app/Console/Commands/FixStuckDocuments.php
Normal file
103
app/Console/Commands/FixStuckDocuments.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
113
app/Exports/ActivityLogExport.php
Normal file
113
app/Exports/ActivityLogExport.php
Normal 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'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/Filament/Pages/Dashboard.php
Normal file
25
app/Filament/Pages/Dashboard.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
303
app/Filament/Pages/ManageSystemSettings.php
Normal file
303
app/Filament/Pages/ManageSystemSettings.php
Normal 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 = 1048576,10MB = 10485760,100MB = 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
namespace App\Filament\Pages;
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
use App\Models\Document;
|
use App\Models\Document;
|
||||||
use App\Models\Group;
|
use App\Models\KnowledgeBase;
|
||||||
|
use App\Models\Station;
|
||||||
use App\Services\DocumentSearchService;
|
use App\Services\DocumentSearchService;
|
||||||
use App\Services\DocumentService;
|
use App\Services\DocumentService;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
@@ -18,7 +19,6 @@ use Filament\Tables\Columns\TextColumn;
|
|||||||
use Filament\Tables\Concerns\InteractsWithTable;
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
use Filament\Tables\Contracts\HasTable;
|
use Filament\Tables\Contracts\HasTable;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Contracts\View\View;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
@@ -28,39 +28,25 @@ class SearchPage extends Page implements HasForms, HasTable
|
|||||||
use InteractsWithTable;
|
use InteractsWithTable;
|
||||||
|
|
||||||
protected static ?string $navigationIcon = 'heroicon-o-magnifying-glass';
|
protected static ?string $navigationIcon = 'heroicon-o-magnifying-glass';
|
||||||
|
|
||||||
protected static string $view = 'filament.pages.search-page';
|
protected static string $view = 'filament.pages.search-page';
|
||||||
|
|
||||||
protected static ?string $navigationLabel = '搜索文档';
|
protected static ?string $navigationLabel = '搜索文档';
|
||||||
|
|
||||||
protected static ?string $title = '搜索文档';
|
protected static ?string $title = '搜索文档';
|
||||||
|
|
||||||
protected static ?int $navigationSort = 2;
|
protected static ?int $navigationSort = 2;
|
||||||
|
|
||||||
// 表单数据
|
|
||||||
public ?string $searchQuery = null;
|
public ?string $searchQuery = null;
|
||||||
public ?string $documentType = null;
|
public ?array $stationIds = [];
|
||||||
public ?int $groupId = null;
|
public ?array $knowledgeBaseIds = [];
|
||||||
|
|
||||||
// 搜索结果
|
|
||||||
public $searchResults = null;
|
|
||||||
public bool $hasSearched = false;
|
public bool $hasSearched = false;
|
||||||
|
|
||||||
/**
|
|
||||||
* 挂载页面时的初始化
|
|
||||||
*/
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$this->form->fill([
|
$this->form->fill([
|
||||||
'searchQuery' => '',
|
'searchQuery' => '',
|
||||||
'documentType' => null,
|
'stationIds' => [],
|
||||||
'groupId' => null,
|
'knowledgeBaseIds' => [],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 定义搜索表单
|
|
||||||
*/
|
|
||||||
public function form(Form $form): Form
|
public function form(Form $form): Form
|
||||||
{
|
{
|
||||||
return $form
|
return $form
|
||||||
@@ -71,28 +57,25 @@ class SearchPage extends Page implements HasForms, HasTable
|
|||||||
->required()
|
->required()
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
|
|
||||||
Select::make('documentType')
|
Select::make('stationIds')
|
||||||
->label('文档类型')
|
->label('线站')
|
||||||
->placeholder('全部类型')
|
->placeholder('全部线站')
|
||||||
->options([
|
->options(Station::pluck('name', 'id'))
|
||||||
'global' => '全局知识库',
|
->multiple()
|
||||||
'dedicated' => '专用知识库',
|
->searchable()
|
||||||
])
|
|
||||||
->native(false),
|
->native(false),
|
||||||
|
|
||||||
Select::make('groupId')
|
Select::make('knowledgeBaseIds')
|
||||||
->label('所属分组')
|
->label('知识库')
|
||||||
->placeholder('全部分组')
|
->placeholder('全部知识库')
|
||||||
->options(Group::pluck('name', 'id'))
|
->options(KnowledgeBase::where('status', 'active')->pluck('name', 'id'))
|
||||||
|
->multiple()
|
||||||
->searchable()
|
->searchable()
|
||||||
->native(false),
|
->native(false),
|
||||||
])
|
])
|
||||||
->columns(3);
|
->columns(3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 定义搜索结果表格
|
|
||||||
*/
|
|
||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
@@ -104,29 +87,12 @@ class SearchPage extends Page implements HasForms, HasTable
|
|||||||
->sortable()
|
->sortable()
|
||||||
->limit(50),
|
->limit(50),
|
||||||
|
|
||||||
TextColumn::make('markdown_preview')
|
TextColumn::make('knowledgeBase.name')
|
||||||
->label('内容片段')
|
->label('所属知识库')
|
||||||
->limit(100)
|
->sortable(),
|
||||||
->wrap()
|
|
||||||
->default('暂无内容预览'),
|
|
||||||
|
|
||||||
TextColumn::make('type')
|
TextColumn::make('uploader.name')
|
||||||
->label('文档类型')
|
->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('无')
|
|
||||||
->sortable(),
|
->sortable(),
|
||||||
|
|
||||||
TextColumn::make('created_at')
|
TextColumn::make('created_at')
|
||||||
@@ -147,7 +113,7 @@ class SearchPage extends Page implements HasForms, HasTable
|
|||||||
->modalSubmitAction(false)
|
->modalSubmitAction(false)
|
||||||
->modalCancelActionLabel('关闭')
|
->modalCancelActionLabel('关闭')
|
||||||
->visible(fn (Document $record) => $record->conversion_status === 'completed'),
|
->visible(fn (Document $record) => $record->conversion_status === 'completed'),
|
||||||
|
|
||||||
Action::make('download')
|
Action::make('download')
|
||||||
->label('下载')
|
->label('下载')
|
||||||
->icon('heroicon-o-arrow-down-tray')
|
->icon('heroicon-o-arrow-down-tray')
|
||||||
@@ -155,11 +121,7 @@ class SearchPage extends Page implements HasForms, HasTable
|
|||||||
try {
|
try {
|
||||||
$documentService = app(DocumentService::class);
|
$documentService = app(DocumentService::class);
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
|
||||||
// 记录下载日志
|
|
||||||
$documentService->logDownload($record, $user);
|
$documentService->logDownload($record, $user);
|
||||||
|
|
||||||
// 返回文件下载响应
|
|
||||||
return $documentService->downloadDocument($record, $user);
|
return $documentService->downloadDocument($record, $user);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
@@ -177,53 +139,39 @@ class SearchPage extends Page implements HasForms, HasTable
|
|||||||
->emptyStateIcon('heroicon-o-magnifying-glass');
|
->emptyStateIcon('heroicon-o-magnifying-glass');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取表格查询构建器
|
|
||||||
*/
|
|
||||||
protected function getTableQuery(): Builder
|
protected function getTableQuery(): Builder
|
||||||
{
|
{
|
||||||
if (!$this->hasSearched || empty($this->searchQuery)) {
|
if (!$this->hasSearched || empty($this->searchQuery)) {
|
||||||
// 如果还没有搜索或搜索关键词为空,返回空查询
|
|
||||||
return Document::query()->whereRaw('1 = 0');
|
return Document::query()->whereRaw('1 = 0');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用 DocumentSearchService 进行搜索
|
|
||||||
$searchService = app(DocumentSearchService::class);
|
$searchService = app(DocumentSearchService::class);
|
||||||
$user = Auth::user();
|
|
||||||
|
|
||||||
$filters = [];
|
$filters = [];
|
||||||
if ($this->documentType) {
|
if (!empty($this->stationIds)) {
|
||||||
$filters['type'] = $this->documentType;
|
$filters['station_ids'] = $this->stationIds;
|
||||||
}
|
}
|
||||||
if ($this->groupId) {
|
if (!empty($this->knowledgeBaseIds)) {
|
||||||
$filters['group_id'] = $this->groupId;
|
$filters['knowledge_base_ids'] = $this->knowledgeBaseIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 执行搜索
|
$accessibleStationIds = Auth::user()->getAccessibleStationIds();
|
||||||
$results = $searchService->search($this->searchQuery, $user, $filters);
|
$results = $searchService->search($this->searchQuery, $accessibleStationIds, $filters);
|
||||||
|
|
||||||
// 获取搜索结果的 ID 列表
|
|
||||||
$documentIds = $results->pluck('id')->toArray();
|
$documentIds = $results->pluck('id')->toArray();
|
||||||
|
|
||||||
// 返回包含这些 ID 的查询构建器
|
|
||||||
if (empty($documentIds)) {
|
if (empty($documentIds)) {
|
||||||
return Document::query()->whereRaw('1 = 0');
|
return Document::query()->whereRaw('1 = 0');
|
||||||
}
|
}
|
||||||
|
|
||||||
return Document::query()
|
return Document::query()
|
||||||
->whereIn('id', $documentIds)
|
->whereIn('id', $documentIds)
|
||||||
->with(['group', 'uploader']);
|
->with(['knowledgeBase', 'uploader']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 执行搜索
|
|
||||||
*/
|
|
||||||
public function search(): void
|
public function search(): void
|
||||||
{
|
{
|
||||||
// 验证表单
|
|
||||||
$data = $this->form->getState();
|
$data = $this->form->getState();
|
||||||
|
|
||||||
// 检查搜索关键词是否为空
|
|
||||||
if (empty($data['searchQuery'])) {
|
if (empty($data['searchQuery'])) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('请输入搜索关键词')
|
->title('请输入搜索关键词')
|
||||||
@@ -232,13 +180,11 @@ class SearchPage extends Page implements HasForms, HasTable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新搜索参数
|
|
||||||
$this->searchQuery = $data['searchQuery'];
|
$this->searchQuery = $data['searchQuery'];
|
||||||
$this->documentType = $data['documentType'];
|
$this->stationIds = $data['stationIds'] ?? [];
|
||||||
$this->groupId = $data['groupId'];
|
$this->knowledgeBaseIds = $data['knowledgeBaseIds'] ?? [];
|
||||||
$this->hasSearched = true;
|
$this->hasSearched = true;
|
||||||
|
|
||||||
// 重置表格分页
|
|
||||||
$this->resetTable();
|
$this->resetTable();
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
@@ -247,20 +193,17 @@ class SearchPage extends Page implements HasForms, HasTable
|
|||||||
->send();
|
->send();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 清空搜索
|
|
||||||
*/
|
|
||||||
public function clearSearch(): void
|
public function clearSearch(): void
|
||||||
{
|
{
|
||||||
$this->form->fill([
|
$this->form->fill([
|
||||||
'searchQuery' => '',
|
'searchQuery' => '',
|
||||||
'documentType' => null,
|
'stationIds' => [],
|
||||||
'groupId' => null,
|
'knowledgeBaseIds' => [],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->searchQuery = null;
|
$this->searchQuery = null;
|
||||||
$this->documentType = null;
|
$this->stationIds = [];
|
||||||
$this->groupId = null;
|
$this->knowledgeBaseIds = [];
|
||||||
$this->hasSearched = false;
|
$this->hasSearched = false;
|
||||||
|
|
||||||
$this->resetTable();
|
$this->resetTable();
|
||||||
@@ -271,9 +214,6 @@ class SearchPage extends Page implements HasForms, HasTable
|
|||||||
->send();
|
->send();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取页面头部操作
|
|
||||||
*/
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
260
app/Filament/Resources/ActivityLogResource.php
Normal file
260
app/Filament/Resources/ActivityLogResource.php
Normal 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}'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 [
|
||||||
|
// 不允许创建操作
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,16 +27,26 @@ class DocumentResource extends Resource
|
|||||||
|
|
||||||
protected static ?int $navigationSort = 1;
|
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
|
public static function getEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
$query = parent::getEloquentQuery();
|
$query = parent::getEloquentQuery();
|
||||||
|
|
||||||
// 应用 accessibleBy 作用域,确保用户只能看到有权限的文档
|
|
||||||
$user = auth()->user();
|
$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;
|
return $query;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,49 +60,40 @@ class DocumentResource extends Resource
|
|||||||
->maxLength(255)
|
->maxLength(255)
|
||||||
->placeholder('请输入文档标题')
|
->placeholder('请输入文档标题')
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
|
|
||||||
Forms\Components\Textarea::make('description')
|
Forms\Components\Textarea::make('description')
|
||||||
->label('文档描述')
|
->label('文档描述')
|
||||||
->rows(3)
|
->rows(3)
|
||||||
->maxLength(65535)
|
->maxLength(65535)
|
||||||
->placeholder('请输入文档描述(可选)')
|
->placeholder('请输入文档描述(可选)')
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
|
|
||||||
Forms\Components\FileUpload::make('file')
|
Forms\Components\FileUpload::make('file')
|
||||||
->label('文档文件')
|
->label('文档文件')
|
||||||
->required()
|
->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
|
->maxSize(51200) // 50MB
|
||||||
|
->storeFileNamesIn('file_name')
|
||||||
->disk('local')
|
->disk('local')
|
||||||
->directory('documents/' . date('Y/m/d'))
|
->directory('documents/' . date('Y/m/d'))
|
||||||
->visibility('private')
|
->visibility('private')
|
||||||
->downloadable()
|
->downloadable()
|
||||||
->preserveFilenames() // 保留原始文件名
|
->helperText('支持 .docx/.pptx/.xlsx/.pdf 格式,最大 50MB')
|
||||||
->helperText('仅支持 .doc 和 .docx 格式,最大 50MB')
|
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
|
|
||||||
Forms\Components\Select::make('type')
|
Forms\Components\Select::make('knowledge_base_id')
|
||||||
->label('文档类型')
|
->label('所属知识库')
|
||||||
|
->relationship('knowledgeBase', 'name')
|
||||||
->required()
|
->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()
|
->searchable()
|
||||||
->preload()
|
->preload()
|
||||||
->required(fn (Forms\Get $get): bool => $get('type') === 'dedicated')
|
->helperText('选择文档所属的知识库'),
|
||||||
->visible(fn (Forms\Get $get): bool => $get('type') === 'dedicated')
|
|
||||||
->helperText('专用知识库必须选择所属分组'),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,51 +113,35 @@ class DocumentResource extends Resource
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
Tables\Columns\TextColumn::make('type')
|
Tables\Columns\TextColumn::make('knowledgeBase.name')
|
||||||
->label('文档类型')
|
->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('所属分组')
|
|
||||||
->searchable()
|
->searchable()
|
||||||
->sortable()
|
->sortable(),
|
||||||
->placeholder('—')
|
|
||||||
->toggleable(),
|
|
||||||
|
|
||||||
Tables\Columns\TextColumn::make('uploader.name')
|
Tables\Columns\TextColumn::make('uploader.name')
|
||||||
->label('上传者')
|
->label('上传者')
|
||||||
->searchable()
|
->searchable()
|
||||||
->sortable()
|
->sortable()
|
||||||
->toggleable(),
|
->toggleable(),
|
||||||
|
|
||||||
Tables\Columns\TextColumn::make('file_size')
|
Tables\Columns\TextColumn::make('file_size')
|
||||||
->label('文件大小')
|
->label('文件大小')
|
||||||
->formatStateUsing(fn ($state): string => self::formatFileSize($state))
|
->formatStateUsing(fn($state): string => self::formatFileSize($state))
|
||||||
->sortable()
|
->sortable()
|
||||||
->toggleable(),
|
->toggleable(),
|
||||||
|
|
||||||
Tables\Columns\TextColumn::make('conversion_status')
|
Tables\Columns\TextColumn::make('conversion_status')
|
||||||
->label('转换状态')
|
->label('转换状态')
|
||||||
->badge()
|
->badge()
|
||||||
->color(fn (?string $state): string => match ($state) {
|
->color(fn(?string $state): string => match ($state) {
|
||||||
'completed' => 'success',
|
'completed' => 'success',
|
||||||
'processing' => 'info',
|
'processing' => 'info',
|
||||||
'pending' => 'warning',
|
'pending' => 'warning',
|
||||||
'failed' => 'danger',
|
'failed' => 'danger',
|
||||||
default => 'gray',
|
default => 'gray',
|
||||||
})
|
})
|
||||||
->formatStateUsing(fn (?string $state): string => match ($state) {
|
->formatStateUsing(fn(?string $state): string => match ($state) {
|
||||||
'completed' => '已完成',
|
'completed' => '已完成',
|
||||||
'processing' => '转换中',
|
'processing' => '转换中',
|
||||||
'pending' => '等待转换',
|
'pending' => '等待转换',
|
||||||
@@ -165,13 +150,13 @@ class DocumentResource extends Resource
|
|||||||
})
|
})
|
||||||
->sortable()
|
->sortable()
|
||||||
->toggleable(),
|
->toggleable(),
|
||||||
|
|
||||||
Tables\Columns\TextColumn::make('created_at')
|
Tables\Columns\TextColumn::make('created_at')
|
||||||
->label('上传时间')
|
->label('上传时间')
|
||||||
->dateTime('Y年m月d日 H:i')
|
->dateTime('Y年m月d日 H:i')
|
||||||
->sortable()
|
->sortable()
|
||||||
->toggleable(),
|
->toggleable(),
|
||||||
|
|
||||||
Tables\Columns\TextColumn::make('updated_at')
|
Tables\Columns\TextColumn::make('updated_at')
|
||||||
->label('更新时间')
|
->label('更新时间')
|
||||||
->dateTime('Y年m月d日 H:i')
|
->dateTime('Y年m月d日 H:i')
|
||||||
@@ -179,28 +164,20 @@ class DocumentResource extends Resource
|
|||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
Tables\Filters\SelectFilter::make('type')
|
Tables\Filters\SelectFilter::make('knowledge_base_id')
|
||||||
->label('文档类型')
|
->label('所属知识库')
|
||||||
->options([
|
->relationship('knowledgeBase', 'name')
|
||||||
'global' => '全局知识库',
|
|
||||||
'dedicated' => '专用知识库',
|
|
||||||
])
|
|
||||||
->placeholder('全部类型'),
|
|
||||||
|
|
||||||
Tables\Filters\SelectFilter::make('group_id')
|
|
||||||
->label('所属分组')
|
|
||||||
->relationship('group', 'name')
|
|
||||||
->searchable()
|
->searchable()
|
||||||
->preload()
|
->preload()
|
||||||
->placeholder('全部分组'),
|
->placeholder('全部知识库'),
|
||||||
|
|
||||||
Tables\Filters\SelectFilter::make('uploaded_by')
|
Tables\Filters\SelectFilter::make('uploaded_by')
|
||||||
->label('上传者')
|
->label('上传者')
|
||||||
->relationship('uploader', 'name')
|
->relationship('uploader', 'name')
|
||||||
->searchable()
|
->searchable()
|
||||||
->preload()
|
->preload()
|
||||||
->placeholder('全部上传者'),
|
->placeholder('全部上传者'),
|
||||||
|
|
||||||
Tables\Filters\SelectFilter::make('conversion_status')
|
Tables\Filters\SelectFilter::make('conversion_status')
|
||||||
->label('转换状态')
|
->label('转换状态')
|
||||||
->options([
|
->options([
|
||||||
@@ -212,16 +189,75 @@ class DocumentResource extends Resource
|
|||||||
->placeholder('全部状态'),
|
->placeholder('全部状态'),
|
||||||
])
|
])
|
||||||
->actions([
|
->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')
|
Tables\Actions\Action::make('preview')
|
||||||
->label('预览 Markdown')
|
->label('预览 PDF')
|
||||||
->icon('heroicon-o-eye')
|
->icon('heroicon-o-eye')
|
||||||
->color('info')
|
->color('info')
|
||||||
->visible(fn (Document $record): bool => $record->conversion_status === 'completed')
|
->visible(fn(Document $record): bool => $record->conversion_status === 'completed')
|
||||||
->url(fn (Document $record): string => route('documents.preview', $record))
|
->url(fn(Document $record): string => route('documents.preview', $record))
|
||||||
->openUrlInNewTab()
|
->openUrlInNewTab()
|
||||||
->tooltip(fn (Document $record): ?string =>
|
->tooltip(
|
||||||
$record->conversion_status !== 'completed'
|
fn(Document $record): ?string =>
|
||||||
? '文档尚未完成转换'
|
$record->conversion_status !== 'completed'
|
||||||
|
? '文档尚未完成转换'
|
||||||
: null
|
: null
|
||||||
),
|
),
|
||||||
Tables\Actions\Action::make('download')
|
Tables\Actions\Action::make('download')
|
||||||
@@ -231,11 +267,11 @@ class DocumentResource extends Resource
|
|||||||
->action(function (Document $record) {
|
->action(function (Document $record) {
|
||||||
$documentService = app(\App\Services\DocumentService::class);
|
$documentService = app(\App\Services\DocumentService::class);
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 记录下载日志
|
// 记录下载日志
|
||||||
$documentService->logDownload($record, $user);
|
$documentService->logDownload($record, $user);
|
||||||
|
|
||||||
// 返回文件下载响应
|
// 返回文件下载响应
|
||||||
return $documentService->downloadDocument($record, $user);
|
return $documentService->downloadDocument($record, $user);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
@@ -244,7 +280,7 @@ class DocumentResource extends Resource
|
|||||||
->title('下载失败')
|
->title('下载失败')
|
||||||
->body($e->getMessage())
|
->body($e->getMessage())
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -270,15 +306,15 @@ class DocumentResource extends Resource
|
|||||||
public static function formatFileSize(?int $bytes): string
|
public static function formatFileSize(?int $bytes): string
|
||||||
{
|
{
|
||||||
if ($bytes === null) {
|
if ($bytes === null) {
|
||||||
return '—';
|
return '-';
|
||||||
}
|
}
|
||||||
|
|
||||||
$units = ['B', 'KB', 'MB', 'GB'];
|
$units = ['B', 'KB', 'MB', 'GB'];
|
||||||
$bytes = max($bytes, 0);
|
$bytes = max($bytes, 0);
|
||||||
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
|
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
|
||||||
$pow = min($pow, count($units) - 1);
|
$pow = min($pow, count($units) - 1);
|
||||||
$bytes /= (1 << (10 * $pow));
|
$bytes /= (1 << (10 * $pow));
|
||||||
|
|
||||||
return round($bytes, 2) . ' ' . $units[$pow];
|
return round($bytes, 2) . ' ' . $units[$pow];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
namespace App\Filament\Resources\DocumentResource\Pages;
|
namespace App\Filament\Resources\DocumentResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\DocumentResource;
|
use App\Filament\Resources\DocumentResource;
|
||||||
use App\Services\DocumentService;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
@@ -16,37 +14,24 @@ class CreateDocument extends CreateRecord
|
|||||||
|
|
||||||
protected function mutateFormDataBeforeCreate(array $data): array
|
protected function mutateFormDataBeforeCreate(array $data): array
|
||||||
{
|
{
|
||||||
// 设置上传者为当前用户
|
|
||||||
$data['uploaded_by'] = Auth::id();
|
$data['uploaded_by'] = Auth::id();
|
||||||
|
|
||||||
// 如果是全局文档,确保 group_id 为 null
|
|
||||||
if ($data['type'] === 'global') {
|
|
||||||
$data['group_id'] = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理文件上传
|
|
||||||
if (isset($data['file'])) {
|
if (isset($data['file'])) {
|
||||||
$filePath = $data['file'];
|
$filePath = $data['file'];
|
||||||
|
|
||||||
// 获取原始文件名(由于使用了 preserveFilenames(),basename 就是原始文件名)
|
|
||||||
$originalFileName = basename($filePath);
|
|
||||||
|
|
||||||
// 保存文件信息
|
|
||||||
$data['file_path'] = $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['file_size'] = Storage::disk('local')->size($filePath);
|
||||||
$data['mime_type'] = Storage::disk('local')->mimeType($filePath);
|
$data['mime_type'] = Storage::disk('local')->mimeType($filePath);
|
||||||
|
|
||||||
// 移除临时的 file 字段
|
|
||||||
unset($data['file']);
|
unset($data['file']);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function afterCreate(): void
|
protected function afterCreate(): void
|
||||||
{
|
{
|
||||||
// 文档创建后,触发转换任务
|
|
||||||
$conversionService = app(\App\Services\DocumentConversionService::class);
|
$conversionService = app(\App\Services\DocumentConversionService::class);
|
||||||
$conversionService->queueConversion($this->record);
|
$conversionService->queueConversion($this->record);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,15 @@ use App\Filament\Resources\DocumentResource;
|
|||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\EditRecord;
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
class EditDocument extends EditRecord
|
class EditDocument extends EditRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = DocumentResource::class;
|
protected static string $resource = DocumentResource::class;
|
||||||
|
|
||||||
|
private ?string $previousFilePath = null;
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@@ -24,60 +27,51 @@ class EditDocument extends EditRecord
|
|||||||
|
|
||||||
protected function mutateFormDataBeforeFill(array $data): array
|
protected function mutateFormDataBeforeFill(array $data): array
|
||||||
{
|
{
|
||||||
// 将文件路径设置到 file 字段以便显示
|
$this->previousFilePath = $data['file_path'] ?? null;
|
||||||
|
|
||||||
if (isset($data['file_path'])) {
|
if (isset($data['file_path'])) {
|
||||||
$data['file'] = $data['file_path'];
|
$data['file'] = $data['file_path'];
|
||||||
}
|
}
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function mutateFormDataBeforeSave(array $data): array
|
protected function mutateFormDataBeforeSave(array $data): array
|
||||||
{
|
{
|
||||||
// 如果是全局文档,确保 group_id 为 null
|
$currentFile = $data['file'] ?? null;
|
||||||
if ($data['type'] === 'global') {
|
|
||||||
$data['group_id'] = null;
|
// 检测文件是否变更:与填充时记录的原始路径比较
|
||||||
}
|
if ($currentFile && $currentFile !== $this->previousFilePath) {
|
||||||
|
// 删除旧文件
|
||||||
// 处理文件更新
|
if ($this->previousFilePath && Storage::disk('local')->exists($this->previousFilePath)) {
|
||||||
if (isset($data['file']) && $data['file'] !== $this->record->file_path) {
|
Storage::disk('local')->delete($this->previousFilePath);
|
||||||
$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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除旧的 Markdown 文件
|
|
||||||
if ($this->record->markdown_path && Storage::disk('markdown')->exists($this->record->markdown_path)) {
|
if ($this->record->markdown_path && Storage::disk('markdown')->exists($this->record->markdown_path)) {
|
||||||
Storage::disk('markdown')->delete($this->record->markdown_path);
|
Storage::disk('markdown')->delete($this->record->markdown_path);
|
||||||
}
|
}
|
||||||
|
app(\App\Services\DocumentPdfPreviewService::class)->clearCachedPreview($this->record);
|
||||||
// 获取原始文件名(由于使用了 preserveFilenames(),basename 就是原始文件名)
|
|
||||||
$originalFileName = basename($filePath);
|
$data['file_path'] = $currentFile;
|
||||||
|
$data['file_name'] = $data['file_name'] ?? basename($currentFile);
|
||||||
// 更新文件信息
|
$data['file_size'] = Storage::disk('local')->size($currentFile);
|
||||||
$data['file_path'] = $filePath;
|
$data['mime_type'] = Storage::disk('local')->mimeType($currentFile);
|
||||||
$data['file_name'] = $originalFileName; // 保存原始文件名
|
|
||||||
$data['file_size'] = Storage::disk('local')->size($filePath);
|
// 重置转换状态,触发重新转换
|
||||||
$data['mime_type'] = Storage::disk('local')->mimeType($filePath);
|
|
||||||
|
|
||||||
// 重置转换状态,准备重新转换
|
|
||||||
$data['conversion_status'] = 'pending';
|
$data['conversion_status'] = 'pending';
|
||||||
$data['markdown_path'] = null;
|
$data['markdown_path'] = null;
|
||||||
$data['markdown_preview'] = null;
|
|
||||||
$data['conversion_error'] = null;
|
$data['conversion_error'] = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 移除临时的 file 字段
|
|
||||||
unset($data['file']);
|
unset($data['file']);
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function afterSave(): void
|
protected function afterSave(): void
|
||||||
{
|
{
|
||||||
// 如果文档的转换状态是 pending,说明文件已更新,需要触发重新转换
|
// 刷新模型以获取最新数据库状态
|
||||||
|
$this->record->refresh();
|
||||||
|
|
||||||
if ($this->record->conversion_status === 'pending') {
|
if ($this->record->conversion_status === 'pending') {
|
||||||
$conversionService = app(\App\Services\DocumentConversionService::class);
|
$conversionService = app(\App\Services\DocumentConversionService::class);
|
||||||
$conversionService->queueConversion($this->record);
|
$conversionService->queueConversion($this->record);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
namespace App\Filament\Resources\DocumentResource\Pages;
|
namespace App\Filament\Resources\DocumentResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\DocumentResource;
|
use App\Filament\Resources\DocumentResource;
|
||||||
use App\Services\DocumentPreviewService;
|
|
||||||
use App\Services\DocumentService;
|
use App\Services\DocumentService;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Infolists\Components\Section;
|
use Filament\Infolists\Components\Section;
|
||||||
@@ -20,8 +19,43 @@ class ViewDocument extends ViewRecord
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
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')
|
Actions\Action::make('preview')
|
||||||
->label('预览 Markdown')
|
->label('预览 PDF')
|
||||||
->icon('heroicon-o-eye')
|
->icon('heroicon-o-eye')
|
||||||
->color('info')
|
->color('info')
|
||||||
->visible(fn (): bool => $this->record->conversion_status === 'completed')
|
->visible(fn (): bool => $this->record->conversion_status === 'completed')
|
||||||
@@ -79,34 +113,37 @@ class ViewDocument extends ViewRecord
|
|||||||
->placeholder('无描述')
|
->placeholder('无描述')
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
|
|
||||||
TextEntry::make('type')
|
TextEntry::make('knowledgeBase.name')
|
||||||
->label('文档类型')
|
->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('uploader.name')
|
TextEntry::make('uploader.name')
|
||||||
->label('上传者'),
|
->label('上传者'),
|
||||||
|
|
||||||
TextEntry::make('file_name')
|
TextEntry::make('display_file_name')
|
||||||
->label('文件名'),
|
->label('文件名'),
|
||||||
|
|
||||||
TextEntry::make('file_size')
|
TextEntry::make('file_size')
|
||||||
->label('文件大小')
|
->label('文件大小')
|
||||||
->formatStateUsing(fn ($state): string => DocumentResource::formatFileSize($state)),
|
->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')
|
TextEntry::make('created_at')
|
||||||
->label('上传时间')
|
->label('上传时间')
|
||||||
->dateTime('Y年m月d日 H:i:s'),
|
->dateTime('Y年m月d日 H:i:s'),
|
||||||
@@ -117,6 +154,19 @@ class ViewDocument extends ViewRecord
|
|||||||
])
|
])
|
||||||
->columns(2),
|
->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('文档预览')
|
Section::make('文档预览')
|
||||||
->schema([
|
->schema([
|
||||||
ViewEntry::make('preview')
|
ViewEntry::make('preview')
|
||||||
|
|||||||
@@ -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'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 '分组创建成功';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 '分组更新成功';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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('创建分组'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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('取消'),
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
247
app/Filament/Resources/GuideResource.php
Normal file
247
app/Filament/Resources/GuideResource.php
Normal 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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/Filament/Resources/GuideResource/Pages/CreateGuide.php
Normal file
22
app/Filament/Resources/GuideResource/Pages/CreateGuide.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/Filament/Resources/GuideResource/Pages/EditGuide.php
Normal file
32
app/Filament/Resources/GuideResource/Pages/EditGuide.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/Filament/Resources/GuideResource/Pages/ListGuides.php
Normal file
20
app/Filament/Resources/GuideResource/Pages/ListGuides.php
Normal 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('创建指引'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
391
app/Filament/Resources/GuideResource/Pages/ManageGuidePages.php
Normal file
391
app/Filament/Resources/GuideResource/Pages/ManageGuidePages.php
Normal 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('定义此页面的分支按钮(每个选项对应一个输出端口)。留空 = 顺序页面。'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
153
app/Filament/Resources/KnowledgeBaseResource.php
Normal file
153
app/Filament/Resources/KnowledgeBaseResource.php
Normal 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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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('删除'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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('新建知识库'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
318
app/Filament/Resources/RoleResource.php
Normal file
318
app/Filament/Resources/RoleResource.php
Normal 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}'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/Filament/Resources/RoleResource/Pages/CreateRole.php
Normal file
50
app/Filament/Resources/RoleResource/Pages/CreateRole.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
61
app/Filament/Resources/RoleResource/Pages/EditRole.php
Normal file
61
app/Filament/Resources/RoleResource/Pages/EditRole.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/Filament/Resources/RoleResource/Pages/ListRoles.php
Normal file
20
app/Filament/Resources/RoleResource/Pages/ListRoles.php
Normal 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('创建角色'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
130
app/Filament/Resources/RoleResource/Pages/ViewRole.php
Normal file
130
app/Filament/Resources/RoleResource/Pages/ViewRole.php
Normal 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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
130
app/Filament/Resources/StationResource.php
Normal file
130
app/Filament/Resources/StationResource.php
Normal 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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
20
app/Filament/Resources/StationResource/Pages/EditStation.php
Normal file
20
app/Filament/Resources/StationResource/Pages/EditStation.php
Normal 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('删除'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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('新建线站'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
203
app/Filament/Resources/SystemSettingResource.php
Normal file
203
app/Filament/Resources/SystemSettingResource.php
Normal 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}'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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('新建配置'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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('编辑'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
309
app/Filament/Resources/TerminalResource.php
Normal file
309
app/Filament/Resources/TerminalResource.php
Normal 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}'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 '终端创建成功';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 '终端更新成功';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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('创建终端'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
116
app/Filament/Resources/TerminalResource/Pages/ViewTerminal.php
Normal file
116
app/Filament/Resources/TerminalResource/Pages/ViewTerminal.php
Normal 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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,38 +25,180 @@ class UserResource extends Resource
|
|||||||
|
|
||||||
protected static ?string $pluralModelLabel = '用户';
|
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
|
public static function form(Form $form): Form
|
||||||
{
|
{
|
||||||
return $form
|
return $form
|
||||||
->schema([
|
->schema([
|
||||||
Forms\Components\TextInput::make('name')
|
Forms\Components\Section::make('基本信息')
|
||||||
->label('用户名称')
|
->schema([
|
||||||
->required()
|
Forms\Components\TextInput::make('name')
|
||||||
->maxLength(255)
|
->label('用户名称')
|
||||||
->placeholder('请输入用户名称'),
|
->required()
|
||||||
Forms\Components\TextInput::make('email')
|
->maxLength(255)
|
||||||
->label('邮箱')
|
->placeholder('请输入用户名称'),
|
||||||
->email()
|
Forms\Components\TextInput::make('email')
|
||||||
->required()
|
->label('邮箱')
|
||||||
->maxLength(255)
|
->email()
|
||||||
->placeholder('请输入邮箱地址'),
|
->required()
|
||||||
Forms\Components\TextInput::make('password')
|
->maxLength(255)
|
||||||
->label('密码')
|
->placeholder('请输入邮箱地址'),
|
||||||
->password()
|
Forms\Components\TextInput::make('password')
|
||||||
->required(fn (string $context): bool => $context === 'create')
|
->label('密码')
|
||||||
->dehydrated(fn ($state) => filled($state))
|
->password()
|
||||||
->minLength(8)
|
->required(fn (string $context): bool => $context === 'create')
|
||||||
->placeholder('请输入密码(至少8位)')
|
->dehydrated(fn ($state) => filled($state))
|
||||||
->helperText('编辑时留空表示不修改密码'),
|
->minLength(8)
|
||||||
Forms\Components\Select::make('groups')
|
->placeholder('请输入密码(至少8位)')
|
||||||
->label('所属分组')
|
->helperText('编辑时留空表示不修改密码'),
|
||||||
->multiple()
|
])
|
||||||
->relationship('groups', 'name')
|
->columns(2),
|
||||||
->preload()
|
|
||||||
->placeholder('请选择用户所属的分组')
|
Forms\Components\Section::make('线站与角色')
|
||||||
->helperText('用户可以属于多个分组'),
|
->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('邮箱')
|
->label('邮箱')
|
||||||
->searchable()
|
->searchable()
|
||||||
->sortable(),
|
->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('所属分组')
|
->label('所属分组')
|
||||||
->badge()
|
->badge()
|
||||||
->searchable()
|
->searchable()
|
||||||
->toggleable(),
|
->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')
|
Tables\Columns\TextColumn::make('created_at')
|
||||||
->label('创建时间')
|
->label('创建时间')
|
||||||
->dateTime('Y-m-d H:i:s')
|
->dateTime('Y-m-d H:i:s')
|
||||||
@@ -92,7 +263,11 @@ class UserResource extends Resource
|
|||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
//
|
Tables\Filters\SelectFilter::make('roles')
|
||||||
|
->label('角色')
|
||||||
|
->relationship('roles', 'name')
|
||||||
|
->multiple()
|
||||||
|
->preload(),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Tables\Actions\ViewAction::make()
|
Tables\Actions\ViewAction::make()
|
||||||
@@ -100,12 +275,35 @@ class UserResource extends Resource
|
|||||||
Tables\Actions\EditAction::make()
|
Tables\Actions\EditAction::make()
|
||||||
->label('编辑'),
|
->label('编辑'),
|
||||||
Tables\Actions\DeleteAction::make()
|
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([
|
->bulkActions([
|
||||||
Tables\Actions\BulkActionGroup::make([
|
Tables\Actions\BulkActionGroup::make([
|
||||||
Tables\Actions\DeleteBulkAction::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');
|
->defaultSort('created_at', 'desc');
|
||||||
@@ -114,7 +312,6 @@ class UserResource extends Resource
|
|||||||
public static function getRelations(): array
|
public static function getRelations(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
RelationManagers\GroupsRelationManager::class,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +320,7 @@ class UserResource extends Resource
|
|||||||
return [
|
return [
|
||||||
'index' => Pages\ListUsers::route('/'),
|
'index' => Pages\ListUsers::route('/'),
|
||||||
'create' => Pages\CreateUser::route('/create'),
|
'create' => Pages\CreateUser::route('/create'),
|
||||||
|
'view' => Pages\ViewUser::route('/{record}'),
|
||||||
'edit' => Pages\EditUser::route('/{record}/edit'),
|
'edit' => Pages\EditUser::route('/{record}/edit'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,4 +19,33 @@ class CreateUser extends CreateRecord
|
|||||||
{
|
{
|
||||||
return '用户创建成功';
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,4 +26,33 @@ class EditUser extends EditRecord
|
|||||||
{
|
{
|
||||||
return '用户更新成功';
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
185
app/Filament/Resources/UserResource/Pages/ViewUser.php
Normal file
185
app/Filament/Resources/UserResource/Pages/ViewUser.php
Normal 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'),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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('取消'),
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
40
app/Filament/Widgets/KnowledgeBaseStatsWidget.php
Normal file
40
app/Filament/Widgets/KnowledgeBaseStatsWidget.php
Normal 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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/Filament/Widgets/TerminalStatsWidget.php
Normal file
33
app/Filament/Widgets/TerminalStatsWidget.php
Normal 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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
248
app/Http/Controllers/Api/TerminalApiController.php
Normal file
248
app/Http/Controllers/Api/TerminalApiController.php
Normal 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,27 +3,25 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\Document;
|
use App\Models\Document;
|
||||||
|
use App\Services\DocumentPdfPreviewService;
|
||||||
use App\Services\DocumentService;
|
use App\Services\DocumentService;
|
||||||
use App\Services\MarkdownRenderService;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
|
|
||||||
class DocumentController extends Controller
|
class DocumentController extends Controller
|
||||||
{
|
{
|
||||||
protected DocumentService $documentService;
|
protected DocumentService $documentService;
|
||||||
protected MarkdownRenderService $markdownRenderService;
|
protected DocumentPdfPreviewService $pdfPreviewService;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
DocumentService $documentService,
|
DocumentService $documentService,
|
||||||
MarkdownRenderService $markdownRenderService
|
DocumentPdfPreviewService $pdfPreviewService
|
||||||
) {
|
) {
|
||||||
$this->documentService = $documentService;
|
$this->documentService = $documentService;
|
||||||
$this->markdownRenderService = $markdownRenderService;
|
$this->pdfPreviewService = $pdfPreviewService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 预览文档的 Markdown 内容(支持图片显示)
|
* 预览文档的 PDF 内容
|
||||||
* 需求:11.1, 11.3, 11.4
|
* 需求:11.1, 11.3, 11.4
|
||||||
*
|
*
|
||||||
* @param Document $document
|
* @param Document $document
|
||||||
@@ -31,42 +29,37 @@ class DocumentController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function preview(Document $document)
|
public function preview(Document $document)
|
||||||
{
|
{
|
||||||
// 验证用户权限(使用 DocumentPolicy)
|
|
||||||
// 需求:11.3
|
|
||||||
if (!Gate::allows('view', $document)) {
|
if (!Gate::allows('view', $document)) {
|
||||||
abort(403, '您没有权限预览此文档');
|
abort(403, '您没有权限预览此文档');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查文档是否已完成转换
|
return view('documents.preview', [
|
||||||
if ($document->conversion_status !== 'completed') {
|
'document' => $document,
|
||||||
return view('documents.preview', [
|
'canPreviewPdf' => $this->pdfPreviewService->canPreview($document),
|
||||||
'document' => $document,
|
'previewPdfUrl' => $this->pdfPreviewService->previewUrl($document),
|
||||||
'markdownHtml' => null,
|
]);
|
||||||
]);
|
}
|
||||||
|
|
||||||
|
public function previewPdf(Document $document)
|
||||||
|
{
|
||||||
|
if (! Gate::allows('view', $document)) {
|
||||||
|
abort(403, '您没有权限预览此文档');
|
||||||
}
|
}
|
||||||
|
|
||||||
$markdownHtml = null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 使用 DocumentPreviewService 的 Markdown 预览方法
|
$path = $this->pdfPreviewService->getPreviewPath($document);
|
||||||
// 这会修复图片路径并渲染 Markdown
|
} catch (\Throwable $e) {
|
||||||
// 需求:11.1
|
\Log::error('PDF 预览生成失败', [
|
||||||
$previewService = app(\App\Services\DocumentPreviewService::class);
|
|
||||||
$markdownHtml = $previewService->convertMarkdownToHtml($document);
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
// 记录错误但不中断流程
|
|
||||||
\Log::error('Markdown 预览失败', [
|
|
||||||
'document_id' => $document->id,
|
'document_id' => $document->id,
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
abort(500, 'PDF 预览生成失败:' . $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理内容为空的情况
|
return response()->file($path, [
|
||||||
// 需求:11.4
|
'Content-Type' => 'application/pdf',
|
||||||
// 返回渲染后的 HTML 视图
|
'Content-Disposition' => 'inline; filename="document-' . $document->getKey() . '.pdf"',
|
||||||
return view('documents.preview', [
|
|
||||||
'document' => $document,
|
|
||||||
'markdownHtml' => $markdownHtml,
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
38
app/Http/Middleware/IdentifyTerminal.php
Normal file
38
app/Http/Middleware/IdentifyTerminal.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,39 +18,12 @@ class ConvertDocumentToMarkdown implements ShouldQueue
|
|||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
/**
|
|
||||||
* 任务最大尝试次数
|
|
||||||
*
|
|
||||||
* @var int
|
|
||||||
*/
|
|
||||||
public $tries;
|
public $tries;
|
||||||
|
|
||||||
/**
|
|
||||||
* 任务超时时间(秒)
|
|
||||||
*
|
|
||||||
* @var int
|
|
||||||
*/
|
|
||||||
public $timeout;
|
public $timeout;
|
||||||
|
|
||||||
/**
|
|
||||||
* 重试延迟(秒)
|
|
||||||
*
|
|
||||||
* @var int
|
|
||||||
*/
|
|
||||||
public $backoff;
|
public $backoff;
|
||||||
|
|
||||||
/**
|
|
||||||
* 文档实例
|
|
||||||
*
|
|
||||||
* @var Document
|
|
||||||
*/
|
|
||||||
protected Document $document;
|
protected Document $document;
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建新的任务实例
|
|
||||||
*
|
|
||||||
* @param Document $document
|
|
||||||
*/
|
|
||||||
public function __construct(Document $document)
|
public function __construct(Document $document)
|
||||||
{
|
{
|
||||||
$this->document = $document;
|
$this->document = $document;
|
||||||
@@ -59,119 +32,71 @@ class ConvertDocumentToMarkdown implements ShouldQueue
|
|||||||
$this->backoff = config('documents.conversion.retry_delay', 60);
|
$this->backoff = config('documents.conversion.retry_delay', 60);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 执行任务
|
|
||||||
*
|
|
||||||
* @param DocumentConversionService $conversionService
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function handle(DocumentConversionService $conversionService): void
|
public function handle(DocumentConversionService $conversionService): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
Log::info('开始转换文档', [
|
Log::info('开始转换文档', [
|
||||||
'document_id' => $this->document->id,
|
'document_id' => $this->document->id,
|
||||||
'document_title' => $this->document->title,
|
'document_title' => $this->document->title,
|
||||||
|
'file_name' => $this->document->file_name,
|
||||||
'attempt' => $this->attempts(),
|
'attempt' => $this->attempts(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 转换文档为 Markdown
|
|
||||||
$result = $conversionService->convertToMarkdown($this->document);
|
$result = $conversionService->convertToMarkdown($this->document);
|
||||||
$markdown = $result['markdown'];
|
|
||||||
$mediaDir = $result['mediaDir'] ?? null;
|
|
||||||
$tempDir = $result['tempDir'];
|
|
||||||
|
|
||||||
try {
|
$markdownPath = $conversionService->saveMarkdownToFile(
|
||||||
// 保存 Markdown 文件和媒体文件
|
$this->document,
|
||||||
$markdownPath = $conversionService->saveMarkdownToFile($this->document, $markdown, $mediaDir);
|
$result['markdown'],
|
||||||
|
$result['media_files'] ?? []
|
||||||
|
);
|
||||||
|
|
||||||
// 更新文档的 Markdown 信息
|
$conversionService->updateDocumentMarkdown($this->document, $markdownPath);
|
||||||
$conversionService->updateDocumentMarkdown($this->document, $markdownPath);
|
|
||||||
} finally {
|
|
||||||
// 清理临时目录
|
|
||||||
if (isset($tempDir) && file_exists($tempDir)) {
|
|
||||||
$this->deleteDirectory($tempDir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Log::info('文档转换成功', [
|
Log::info('文档转换成功', [
|
||||||
'document_id' => $this->document->id,
|
'document_id' => $this->document->id,
|
||||||
'document_title' => $this->document->title,
|
'document_title' => $this->document->title,
|
||||||
'markdown_path' => $markdownPath,
|
'markdown_path' => $markdownPath,
|
||||||
]);
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$exception = $this->normalizeException($e);
|
||||||
|
|
||||||
// 转换成功后,触发索引(如果需要)
|
|
||||||
// 这将在后续任务中实现
|
|
||||||
// $this->document->searchable();
|
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
Log::error('文档转换失败', [
|
Log::error('文档转换失败', [
|
||||||
'document_id' => $this->document->id,
|
'document_id' => $this->document->id,
|
||||||
'document_title' => $this->document->title,
|
'document_title' => $this->document->title,
|
||||||
|
'file_name' => $this->document->file_name,
|
||||||
'attempt' => $this->attempts(),
|
'attempt' => $this->attempts(),
|
||||||
'error' => $e->getMessage(),
|
'error' => $exception->getMessage(),
|
||||||
'trace' => $e->getTraceAsString(),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 如果已达到最大重试次数,标记为失败
|
|
||||||
if ($this->attempts() >= $this->tries) {
|
if ($this->attempts() >= $this->tries) {
|
||||||
$conversionService->handleConversionFailure($this->document, $e);
|
$conversionService->handleConversionFailure($this->document, $exception);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重新抛出异常以触发重试
|
throw $exception;
|
||||||
throw $e;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 任务失败时的处理
|
|
||||||
*
|
|
||||||
* @param \Throwable $exception
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function failed(\Throwable $exception): void
|
public function failed(\Throwable $exception): void
|
||||||
{
|
{
|
||||||
|
$normalized = $this->normalizeException($exception);
|
||||||
|
|
||||||
Log::error('文档转换任务最终失败', [
|
Log::error('文档转换任务最终失败', [
|
||||||
'document_id' => $this->document->id,
|
'document_id' => $this->document->id,
|
||||||
'document_title' => $this->document->title,
|
'document_title' => $this->document->title,
|
||||||
'error' => $exception->getMessage(),
|
'file_name' => $this->document->file_name,
|
||||||
|
'error' => $normalized->getMessage(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 确保文档状态被标记为失败
|
|
||||||
$conversionService = app(DocumentConversionService::class);
|
$conversionService = app(DocumentConversionService::class);
|
||||||
$conversionService->handleConversionFailure(
|
$conversionService->handleConversionFailure($this->document, $normalized);
|
||||||
$this->document,
|
|
||||||
$exception instanceof \Exception ? $exception : new \Exception($exception->getMessage())
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
protected function normalizeException(\Throwable $throwable): \Exception
|
||||||
* 递归删除目录
|
|
||||||
*
|
|
||||||
* @param string $dir 目录路径
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
protected function deleteDirectory(string $dir): void
|
|
||||||
{
|
{
|
||||||
if (!file_exists($dir)) {
|
if ($throwable instanceof \Exception) {
|
||||||
return;
|
return $throwable;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!is_dir($dir)) {
|
return new \RuntimeException($throwable->getMessage(), 0, $throwable);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Laravel\Scout\Searchable;
|
use Laravel\Scout\Searchable;
|
||||||
|
|
||||||
class Document extends Model
|
class Document extends Model
|
||||||
@@ -24,22 +24,20 @@ class Document extends Model
|
|||||||
'file_name',
|
'file_name',
|
||||||
'file_size',
|
'file_size',
|
||||||
'mime_type',
|
'mime_type',
|
||||||
'type',
|
|
||||||
'group_id',
|
|
||||||
'uploaded_by',
|
'uploaded_by',
|
||||||
'description',
|
'description',
|
||||||
'markdown_path',
|
'markdown_path',
|
||||||
'markdown_preview',
|
|
||||||
'conversion_status',
|
'conversion_status',
|
||||||
'conversion_error',
|
'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);
|
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 索引
|
* 用于 Meilisearch 索引
|
||||||
*
|
|
||||||
* @return array
|
|
||||||
*/
|
*/
|
||||||
public function toSearchableArray(): array
|
public function toSearchableArray(): array
|
||||||
{
|
{
|
||||||
@@ -118,8 +68,7 @@ class Document extends Model
|
|||||||
'file_name' => $this->file_name,
|
'file_name' => $this->file_name,
|
||||||
'description' => $this->description,
|
'description' => $this->description,
|
||||||
'markdown_content' => $this->getMarkdownContent(),
|
'markdown_content' => $this->getMarkdownContent(),
|
||||||
'type' => $this->type,
|
'knowledge_base_id' => $this->knowledge_base_id,
|
||||||
'group_id' => $this->group_id,
|
|
||||||
'uploaded_by' => $this->uploaded_by,
|
'uploaded_by' => $this->uploaded_by,
|
||||||
'created_at' => $this->created_at?->timestamp,
|
'created_at' => $this->created_at?->timestamp,
|
||||||
];
|
];
|
||||||
@@ -128,8 +77,6 @@ class Document extends Model
|
|||||||
/**
|
/**
|
||||||
* 判断文档是否应该被索引
|
* 判断文档是否应该被索引
|
||||||
* 只有转换完成的文档才会被索引
|
* 只有转换完成的文档才会被索引
|
||||||
*
|
|
||||||
* @return bool
|
|
||||||
*/
|
*/
|
||||||
public function shouldBeSearchable(): bool
|
public function shouldBeSearchable(): bool
|
||||||
{
|
{
|
||||||
@@ -139,8 +86,6 @@ class Document extends Model
|
|||||||
/**
|
/**
|
||||||
* 获取完整的 Markdown 内容
|
* 获取完整的 Markdown 内容
|
||||||
* 从文件系统读取 Markdown 文件
|
* 从文件系统读取 Markdown 文件
|
||||||
*
|
|
||||||
* @return string|null
|
|
||||||
*/
|
*/
|
||||||
public function getMarkdownContent(): ?string
|
public function getMarkdownContent(): ?string
|
||||||
{
|
{
|
||||||
@@ -153,7 +98,6 @@ class Document extends Model
|
|||||||
return Storage::disk('markdown')->get($this->markdown_path);
|
return Storage::disk('markdown')->get($this->markdown_path);
|
||||||
}
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
// 记录错误但不抛出异常
|
|
||||||
\Log::warning('Failed to read markdown content', [
|
\Log::warning('Failed to read markdown content', [
|
||||||
'document_id' => $this->id,
|
'document_id' => $this->id,
|
||||||
'markdown_path' => $this->markdown_path,
|
'markdown_path' => $this->markdown_path,
|
||||||
@@ -166,11 +110,38 @@ class Document extends Model
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查文档是否已转换为 Markdown
|
* 检查文档是否已转换为 Markdown
|
||||||
*
|
|
||||||
* @return bool
|
|
||||||
*/
|
*/
|
||||||
public function hasMarkdown(): bool
|
public function hasMarkdown(): bool
|
||||||
{
|
{
|
||||||
return !empty($this->markdown_path) && $this->conversion_status === 'completed';
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,11 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
class DownloadLog extends Model
|
class DownloadLog extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
|
||||||
/**
|
/**
|
||||||
* 表示模型不使用 created_at 和 updated_at 时间戳
|
* 表示模型不使用 created_at 和 updated_at 时间戳
|
||||||
* 因为我们使用自定义的 downloaded_at 字段
|
* 因为我们使用自定义的 downloaded_at 字段
|
||||||
|
|||||||
@@ -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
98
app/Models/Guide.php
Normal 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
94
app/Models/GuidePage.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/Models/GuidePageEdge.php
Normal file
31
app/Models/GuidePageEdge.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
61
app/Models/KnowledgeBase.php
Normal file
61
app/Models/KnowledgeBase.php
Normal 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
48
app/Models/Station.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
80
app/Models/SystemSetting.php
Normal file
80
app/Models/SystemSetting.php
Normal 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
73
app/Models/Terminal.php
Normal 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,11 +8,12 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
|||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Spatie\Permission\Traits\HasRoles;
|
||||||
|
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable
|
||||||
{
|
{
|
||||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||||
use HasFactory, Notifiable;
|
use HasFactory, Notifiable, HasRoles;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The attributes that are mass assignable.
|
* 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);
|
return $this->hasMany(DownloadLog::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查用户是否为超级管理员
|
||||||
|
*/
|
||||||
|
public function isSuperAdmin(): bool
|
||||||
|
{
|
||||||
|
return $this->hasRole('super-admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查用户是否为管理员(包括超级管理员)
|
||||||
|
*/
|
||||||
|
public function isAdmin(): bool
|
||||||
|
{
|
||||||
|
return $this->hasAnyRole(['super-admin', 'admin']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ class DocumentObserver
|
|||||||
if ($document->wasChanged('conversion_status') && $document->conversion_status === 'completed') {
|
if ($document->wasChanged('conversion_status') && $document->conversion_status === 'completed') {
|
||||||
// 转换完成,创建或更新索引
|
// 转换完成,创建或更新索引
|
||||||
$this->searchService->indexDocument($document);
|
$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);
|
$this->searchService->updateDocumentIndex($document);
|
||||||
}
|
}
|
||||||
@@ -124,6 +124,8 @@ class DocumentObserver
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app(\App\Services\DocumentPdfPreviewService::class)->clearCachedPreview($document);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
\Log::error('清理文档文件失败', [
|
\Log::error('清理文档文件失败', [
|
||||||
'document_id' => $document->id,
|
'document_id' => $document->id,
|
||||||
|
|||||||
33
app/Policies/ActivityLogPolicy.php
Normal file
33
app/Policies/ActivityLogPolicy.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,20 +24,21 @@ class DocumentPolicy
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 判断用户是否可以查看文档列表
|
* 判断用户是否可以查看文档列表
|
||||||
* 所有已认证用户都可以查看文档列表(但列表会根据权限过滤)
|
* 需求:权限检查 + 分组访问控制
|
||||||
*
|
*
|
||||||
* @param User $user
|
* @param User $user
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
public function viewAny(User $user): 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
|
public function view(User $user, Document $document): bool
|
||||||
{
|
{
|
||||||
// 如果是全局文档,所有用户都可以查看
|
return $user->can('document.view');
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 判断用户是否可以创建文档
|
* 判断用户是否可以创建文档
|
||||||
* 假设所有已认证用户都可以创建文档(可根据实际需求调整)
|
* 需求:权限检查
|
||||||
*
|
*
|
||||||
* @param User $user
|
* @param User $user
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
public function create(User $user): bool
|
public function create(User $user): bool
|
||||||
{
|
{
|
||||||
// 所有已认证用户都可以创建文档
|
return $user->can('document.create');
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 判断用户是否可以更新文档
|
* 判断用户是否可以更新文档
|
||||||
* 只有文档的上传者可以更新文档(可根据实际需求调整为管理员也可以)
|
* 需求:7.3 + 权限检查
|
||||||
* 需求:7.3
|
* - 首先检查用户是否有 document.update 权限
|
||||||
|
* - 只有文档的上传者可以更新文档
|
||||||
*
|
*
|
||||||
* @param User $user
|
* @param User $user
|
||||||
* @param Document $document
|
* @param Document $document
|
||||||
@@ -101,6 +76,12 @@ class DocumentPolicy
|
|||||||
*/
|
*/
|
||||||
public function update(User $user, Document $document): bool
|
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;
|
$canUpdate = $document->uploaded_by === $user->id;
|
||||||
|
|
||||||
@@ -114,8 +95,9 @@ class DocumentPolicy
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 判断用户是否可以删除文档
|
* 判断用户是否可以删除文档
|
||||||
* 只有文档的上传者可以删除文档(可根据实际需求调整为管理员也可以)
|
* 需求:7.3 + 权限检查
|
||||||
* 需求:7.3
|
* - 首先检查用户是否有 document.delete 权限
|
||||||
|
* - 只有文档的上传者可以删除文档
|
||||||
*
|
*
|
||||||
* @param User $user
|
* @param User $user
|
||||||
* @param Document $document
|
* @param Document $document
|
||||||
@@ -123,6 +105,12 @@ class DocumentPolicy
|
|||||||
*/
|
*/
|
||||||
public function delete(User $user, Document $document): bool
|
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;
|
$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
|
* @param User $user
|
||||||
@@ -148,13 +137,7 @@ class DocumentPolicy
|
|||||||
*/
|
*/
|
||||||
public function download(User $user, Document $document): bool
|
public function download(User $user, Document $document): bool
|
||||||
{
|
{
|
||||||
// 下载权限与查看权限相同
|
return $user->can('document.download');
|
||||||
$canDownload = $this->view($user, $document);
|
|
||||||
|
|
||||||
// 注意:view 方法已经记录了未授权访问,这里不需要重复记录
|
|
||||||
// 但如果需要区分 view 和 download 操作,可以在这里单独记录
|
|
||||||
|
|
||||||
return $canDownload;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
81
app/Policies/GuidePolicy.php
Normal file
81
app/Policies/GuidePolicy.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
72
app/Policies/RolePolicy.php
Normal file
72
app/Policies/RolePolicy.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
39
app/Policies/StationPolicy.php
Normal file
39
app/Policies/StationPolicy.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/Policies/SystemSettingPolicy.php
Normal file
33
app/Policies/SystemSettingPolicy.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
91
app/Policies/TerminalPolicy.php
Normal file
91
app/Policies/TerminalPolicy.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
74
app/Policies/UserPolicy.php
Normal file
74
app/Policies/UserPolicy.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,11 @@
|
|||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use App\Models\Document;
|
use App\Models\Document;
|
||||||
|
use App\Models\Guide;
|
||||||
use App\Observers\DocumentObserver;
|
use App\Observers\DocumentObserver;
|
||||||
|
use App\Policies\GuidePolicy;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
@@ -27,5 +30,15 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
|
|
||||||
// 注册文档观察者,用于自动管理 Meilisearch 索引
|
// 注册文档观察者,用于自动管理 Meilisearch 索引
|
||||||
Document::observe(DocumentObserver::class);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,12 +33,11 @@ class AdminPanelProvider extends PanelProvider
|
|||||||
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
|
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
|
||||||
->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages')
|
->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages')
|
||||||
->pages([
|
->pages([
|
||||||
Pages\Dashboard::class,
|
\App\Filament\Pages\Dashboard::class,
|
||||||
])
|
])
|
||||||
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
|
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
|
||||||
->widgets([
|
->widgets([
|
||||||
Widgets\AccountWidget::class,
|
Widgets\AccountWidget::class,
|
||||||
Widgets\FilamentInfoWidget::class,
|
|
||||||
])
|
])
|
||||||
->middleware([
|
->middleware([
|
||||||
EncryptCookies::class,
|
EncryptCookies::class,
|
||||||
|
|||||||
@@ -4,290 +4,164 @@ namespace App\Services;
|
|||||||
|
|
||||||
use App\Models\Document;
|
use App\Models\Document;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Process;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Str;
|
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
|
class DocumentConversionService
|
||||||
{
|
{
|
||||||
/**
|
|
||||||
* 转换驱动
|
|
||||||
*
|
|
||||||
* @var string
|
|
||||||
*/
|
|
||||||
protected string $driver;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pandoc 可执行文件路径
|
|
||||||
*
|
|
||||||
* @var string
|
|
||||||
*/
|
|
||||||
protected string $pandocPath;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 转换超时时间(秒)
|
|
||||||
*
|
|
||||||
* @var int
|
|
||||||
*/
|
|
||||||
protected int $timeout;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Markdown 预览长度
|
|
||||||
*
|
|
||||||
* @var int
|
|
||||||
*/
|
|
||||||
protected int $previewLength;
|
protected int $previewLength;
|
||||||
|
|
||||||
/**
|
|
||||||
* 构造函数
|
|
||||||
*/
|
|
||||||
public function __construct()
|
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);
|
$this->previewLength = config('documents.markdown.preview_length', 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将 Word 文档转换为 Markdown
|
* 将文档转换为 Markdown
|
||||||
*
|
*
|
||||||
* @param Document $document
|
* @return array{markdown: string, media_files: array<string, string>}
|
||||||
* @return array 返回 ['markdown' => string, 'mediaDir' => string|null, 'tempDir' => string]
|
|
||||||
* @throws \Exception
|
|
||||||
*/
|
*/
|
||||||
public function convertToMarkdown(Document $document): array
|
public function convertToMarkdown(Document $document): array
|
||||||
{
|
{
|
||||||
if ($this->driver === 'pandoc') {
|
$this->ensureConversionDependenciesAvailable();
|
||||||
return $this->convertWithPandoc($document);
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
$documentPath = Storage::disk('local')->path($document->file_path);
|
||||||
|
|
||||||
if (!file_exists($documentPath)) {
|
if (!file_exists($documentPath)) {
|
||||||
throw new \Exception("文档文件不存在: {$documentPath}");
|
throw new \Exception("文档文件不存在: {$documentPath}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建临时工作目录
|
$doc = DocumentManager::open($documentPath, ['ocr' => false]);
|
||||||
$tempDir = sys_get_temp_dir() . '/pandoc_' . uniqid();
|
$markdown = DocumentManager::renderAs($doc, 'md');
|
||||||
mkdir($tempDir, 0755, true);
|
|
||||||
|
|
||||||
$tempOutputPath = $tempDir . '/output.md';
|
|
||||||
|
|
||||||
try {
|
if (empty(trim($markdown))) {
|
||||||
// 在临时目录中执行 Pandoc 转换命令
|
throw new \Exception('文档转换后内容为空,可能是扫描件或不支持的内容格式');
|
||||||
$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 (!$result->successful()) {
|
return [
|
||||||
throw new \Exception("Pandoc 转换失败: {$result->errorOutput()}");
|
'markdown' => $markdown,
|
||||||
}
|
'media_files' => $this->extractMarkdownMediaFiles($doc),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
// 读取转换后的 Markdown 内容
|
/**
|
||||||
if (!file_exists($tempOutputPath)) {
|
* 确保文档转换依赖已经安装
|
||||||
throw new \Exception("转换后的 Markdown 文件不存在");
|
*/
|
||||||
}
|
protected function ensureConversionDependenciesAvailable(): void
|
||||||
|
{
|
||||||
$markdown = file_get_contents($tempOutputPath);
|
if (!class_exists(DocumentManager::class)) {
|
||||||
|
throw new \RuntimeException(
|
||||||
if ($markdown === false) {
|
'文档转换依赖未安装:paperdoc-dev/paperdoc-lib。请执行 composer install 后重试。'
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 递归删除目录
|
* 将 Markdown 内容保存到存储
|
||||||
*
|
*
|
||||||
* @param string $dir 目录路径
|
* @param array<string, string> $mediaFiles
|
||||||
* @return void
|
|
||||||
*/
|
*/
|
||||||
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);
|
$path = $this->generateMarkdownPath($document);
|
||||||
$directory = dirname($path);
|
|
||||||
|
|
||||||
// 如果有媒体文件,先保存它们
|
|
||||||
if ($mediaDir && is_dir($mediaDir)) {
|
|
||||||
$this->saveMediaFiles($mediaDir, $directory);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存 Markdown 文件
|
|
||||||
$saved = Storage::disk('markdown')->put($path, $markdown);
|
$saved = Storage::disk('markdown')->put($path, $markdown);
|
||||||
|
|
||||||
if (!$saved) {
|
if (!$saved) {
|
||||||
throw new \Exception("无法保存 Markdown 文件");
|
throw new \Exception('无法保存 Markdown 文件');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->storeMarkdownMediaFiles(dirname($path), $mediaFiles);
|
||||||
|
|
||||||
return $path;
|
return $path;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 保存媒体文件到 storage
|
* 为已存在的 Markdown 文档补齐缺失的图片资源
|
||||||
* 媒体文件保存在文档的 UUID 目录下的 media 子目录中
|
|
||||||
*
|
|
||||||
* @param string $sourceDir 源媒体目录
|
|
||||||
* @param string $targetDir 目标目录(相对于 markdown disk,例如:2025/12/04/{uuid})
|
|
||||||
* @return void
|
|
||||||
*/
|
*/
|
||||||
protected function saveMediaFiles(string $sourceDir, string $targetDir): void
|
public function ensureMarkdownMediaAssets(Document $document): void
|
||||||
{
|
{
|
||||||
$files = glob($sourceDir . '/*');
|
$this->ensureConversionDependenciesAvailable();
|
||||||
|
|
||||||
foreach ($files as $file) {
|
if (empty($document->markdown_path)) {
|
||||||
if (is_file($file)) {
|
return;
|
||||||
$filename = basename($file);
|
}
|
||||||
// 保存到文档目录下的 media 子目录
|
|
||||||
$targetPath = $targetDir . '/media/' . $filename;
|
$markdown = $document->getMarkdownContent();
|
||||||
|
if (empty($markdown)) {
|
||||||
// 读取文件内容
|
return;
|
||||||
$content = file_get_contents($file);
|
}
|
||||||
|
|
||||||
// 保存到 storage
|
if (!preg_match_all('/!\[[^\]]*]\(((?:\.\/)?media\/[^)]+)\)/', $markdown, $matches)) {
|
||||||
Storage::disk('markdown')->put($targetPath, $content);
|
return;
|
||||||
|
}
|
||||||
Log::info('媒体文件已保存', [
|
|
||||||
'filename' => $filename,
|
$documentDir = dirname($document->markdown_path);
|
||||||
'path' => $targetPath,
|
$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 文件路径
|
* 生成 Markdown 文件路径
|
||||||
* 使用 UUID 作为目录名,确保每个文档有独立的 media 目录
|
|
||||||
*
|
|
||||||
* @param Document $document
|
|
||||||
* @return string
|
|
||||||
*/
|
*/
|
||||||
protected function generateMarkdownPath(Document $document): string
|
protected function generateMarkdownPath(Document $document): string
|
||||||
{
|
{
|
||||||
$organizeByDate = config('documents.storage.organize_by_date', true);
|
$organizeByDate = config('documents.storage.organize_by_date', true);
|
||||||
|
|
||||||
// 生成唯一的 UUID 作为文档目录
|
|
||||||
$uuid = Str::uuid()->toString();
|
$uuid = Str::uuid()->toString();
|
||||||
|
|
||||||
if ($organizeByDate) {
|
if ($organizeByDate) {
|
||||||
// 按日期组织: YYYY/MM/DD/{uuid}/{uuid}.md
|
|
||||||
$date = $document->created_at ?? now();
|
$date = $document->created_at ?? now();
|
||||||
$directory = $date->format('Y/m/d') . '/' . $uuid;
|
$directory = $date->format('Y/m/d') . '/' . $uuid;
|
||||||
} else {
|
} else {
|
||||||
// 直接使用 UUID: {uuid}/{uuid}.md
|
|
||||||
$directory = $uuid;
|
$directory = $uuid;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 文件名也使用相同的 UUID
|
return "{$directory}/{$uuid}.md";
|
||||||
$filename = $uuid . '.md';
|
|
||||||
|
|
||||||
return "{$directory}/{$filename}";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取 Markdown 内容的预览(前 N 个字符)
|
* 获取 Markdown 内容的预览(前 N 个字符)
|
||||||
*
|
|
||||||
* @param string $markdown
|
|
||||||
* @param int|null $length
|
|
||||||
* @return string
|
|
||||||
*/
|
*/
|
||||||
public function getMarkdownPreview(string $markdown, ?int $length = null): string
|
public function getMarkdownPreview(string $markdown, ?int $length = null): string
|
||||||
{
|
{
|
||||||
$length = $length ?? $this->previewLength;
|
$length = $length ?? $this->previewLength;
|
||||||
|
|
||||||
// 移除多余的空白字符
|
|
||||||
$cleaned = preg_replace('/\s+/', ' ', $markdown);
|
$cleaned = preg_replace('/\s+/', ' ', $markdown);
|
||||||
$cleaned = trim($cleaned);
|
$cleaned = trim($cleaned);
|
||||||
|
|
||||||
// 截取指定长度
|
|
||||||
if (mb_strlen($cleaned) <= $length) {
|
if (mb_strlen($cleaned) <= $length) {
|
||||||
return $cleaned;
|
return $cleaned;
|
||||||
}
|
}
|
||||||
@@ -297,14 +171,9 @@ class DocumentConversionService
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新文档的 Markdown 信息
|
* 更新文档的 Markdown 信息
|
||||||
*
|
|
||||||
* @param Document $document
|
|
||||||
* @param string $markdownPath
|
|
||||||
* @return void
|
|
||||||
*/
|
*/
|
||||||
public function updateDocumentMarkdown(Document $document, string $markdownPath): void
|
public function updateDocumentMarkdown(Document $document, string $markdownPath): void
|
||||||
{
|
{
|
||||||
// 读取 Markdown 内容以生成预览
|
|
||||||
$markdown = Storage::disk('markdown')->get($markdownPath);
|
$markdown = Storage::disk('markdown')->get($markdownPath);
|
||||||
|
|
||||||
if ($markdown === false) {
|
if ($markdown === false) {
|
||||||
@@ -312,60 +181,152 @@ class DocumentConversionService
|
|||||||
'document_id' => $document->id,
|
'document_id' => $document->id,
|
||||||
'markdown_path' => $markdownPath,
|
'markdown_path' => $markdownPath,
|
||||||
]);
|
]);
|
||||||
$preview = '';
|
|
||||||
} else {
|
} else {
|
||||||
$preview = $this->getMarkdownPreview($markdown);
|
$this->getMarkdownPreview($markdown);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新文档记录
|
Document::withoutSyncingToSearch(function () use ($document, $markdownPath): void {
|
||||||
$document->update([
|
$document->update([
|
||||||
'markdown_path' => $markdownPath,
|
'markdown_path' => $markdownPath,
|
||||||
'markdown_preview' => $preview,
|
'conversion_status' => 'completed',
|
||||||
'conversion_status' => 'completed',
|
'conversion_error' => null,
|
||||||
'conversion_error' => null,
|
]);
|
||||||
]);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理转换失败
|
* 处理转换失败
|
||||||
*
|
|
||||||
* @param Document $document
|
|
||||||
* @param \Exception $exception
|
|
||||||
* @return void
|
|
||||||
*/
|
*/
|
||||||
public function handleConversionFailure(Document $document, \Exception $exception): void
|
public function handleConversionFailure(Document $document, \Exception $exception): void
|
||||||
{
|
{
|
||||||
Log::error('文档转换失败', [
|
Log::error('文档转换失败', [
|
||||||
'document_id' => $document->id,
|
'document_id' => $document->id,
|
||||||
'document_title' => $document->title,
|
'document_title' => $document->title,
|
||||||
|
'file_name' => $document->file_name,
|
||||||
'error' => $exception->getMessage(),
|
'error' => $exception->getMessage(),
|
||||||
'trace' => $exception->getTraceAsString(),
|
'trace' => $exception->getTraceAsString(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 更新文档状态
|
Document::withoutSyncingToSearch(function () use ($document, $exception): void {
|
||||||
$document->update([
|
$document->update([
|
||||||
'conversion_status' => 'failed',
|
'conversion_status' => 'failed',
|
||||||
'conversion_error' => $exception->getMessage(),
|
'conversion_error' => $exception->getMessage(),
|
||||||
]);
|
]);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将转换任务加入队列
|
* 将转换任务加入队列
|
||||||
*
|
|
||||||
* @param Document $document
|
|
||||||
* @return void
|
|
||||||
*/
|
*/
|
||||||
public function queueConversion(Document $document): void
|
public function queueConversion(Document $document): void
|
||||||
{
|
{
|
||||||
// 更新文档状态为处理中
|
Document::withoutSyncingToSearch(function () use ($document): void {
|
||||||
$document->update([
|
$document->update([
|
||||||
'conversion_status' => 'processing',
|
'conversion_status' => 'processing',
|
||||||
'conversion_error' => null,
|
'conversion_error' => null,
|
||||||
]);
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
// 分发队列任务
|
|
||||||
$queue = config('documents.conversion.queue', 'documents');
|
$queue = config('documents.conversion.queue', 'documents');
|
||||||
\App\Jobs\ConvertDocumentToMarkdown::dispatch($document)->onQueue($queue);
|
\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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
190
app/Services/DocumentPdfPreviewService.php
Normal file
190
app/Services/DocumentPdfPreviewService.php
Normal 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
Reference in New Issue
Block a user