This commit is contained in:
2025-08-12 23:59:32 +08:00
parent ffe1677630
commit 01c73efb1e
20 changed files with 6125 additions and 2 deletions

View File

@@ -0,0 +1,385 @@
# 控制器方法注释示例
think-plugs-recorder 中间件可以自动读取控制器方法的注释来获取准确的操作类型和描述,提供更精确的操作记录。
## 支持的注释格式
### 1. 使用专用注解
#### @operation - 推荐使用
```php
<?php
class UserController extends Controller
{
/**
* @operation 创建用户
*/
public function save()
{
// 保存用户逻辑
}
/**
* @operation 删除用户
*/
public function delete()
{
// 删除用户逻辑
}
/**
* @operation 导出用户数据
*/
public function export()
{
// 导出逻辑
}
}
```
#### @recorder - 专用于记录插件
```php
/**
* @recorder 审核用户申请
*/
public function audit()
{
// 审核逻辑
}
/**
* @recorder 批量启用用户
*/
public function batchEnable()
{
// 批量启用逻辑
}
```
#### @record_type - 仅指定操作类型
```php
/**
* @record_type 更新
*/
public function updateProfile()
{
// 更新个人信息
}
```
#### @action - 通用动作注解
```php
/**
* @action 发布文章
*/
public function publish()
{
// 发布文章逻辑
}
```
#### @title - 使用标题作为描述
```php
/**
* @title 用户密码重置
*/
public function resetPassword()
{
// 重置密码逻辑
}
```
#### 中文注解
```php
/**
* @操作类型 创建
*/
public function create()
{
// 创建逻辑
}
/**
* @操作描述 生成统计报表
*/
public function generateReport()
{
// 生成报表逻辑
}
```
### 2. 使用注释首行
如果没有专用注解,插件会尝试解析注释的第一行:
```php
/**
* 创建新用户
* 该方法用于创建新的用户账户
*/
public function create()
{
// 创建用户逻辑
}
/**
* 删除用户账户
*/
public function delete()
{
// 删除用户逻辑
}
/**
* 导出用户列表到Excel
*/
public function exportExcel()
{
// 导出逻辑
}
```
## 操作类型识别
中间件支持识别以下操作类型关键词:
### 基础操作
- **创建相关**: 创建、新增、添加、新建
- **更新相关**: 更新、修改、编辑、保存
- **删除相关**: 删除、移除、清除
- **查询相关**: 查看、读取、获取、查询、列表
### 高级操作
- **数据处理**: 导出、导入、上传、下载
- **用户操作**: 登录、登出、注销
- **审核流程**: 审核、审批、通过、拒绝
- **发布管理**: 发布、取消发布
- **状态管理**: 启用、禁用、开启、关闭
- **内容管理**: 排序、移动、复制
- **搜索功能**: 搜索、检索、筛选
- **分析统计**: 统计、分析、报表
## 实际应用示例
### 用户管理控制器
```php
<?php
namespace app\admin\controller;
use think\admin\Controller;
class UserController extends Controller
{
/**
* @operation 查看用户列表
*/
public function index()
{
// 用户列表逻辑
}
/**
* @operation 创建用户
*/
public function add()
{
// 添加用户页面
}
/**
* @operation 保存用户信息
*/
public function save()
{
// 保存用户逻辑
}
/**
* @operation 编辑用户
*/
public function edit()
{
// 编辑用户页面
}
/**
* @operation 删除用户
*/
public function delete()
{
// 删除用户逻辑
}
/**
* @operation 批量启用用户
*/
public function enable()
{
// 批量启用逻辑
}
/**
* @operation 导出用户数据
*/
public function export()
{
// 导出Excel
}
/**
* @operation 重置用户密码
*/
public function resetPassword()
{
// 重置密码逻辑
}
}
```
### 订单管理控制器
```php
<?php
namespace app\admin\controller;
use think\admin\Controller;
class OrderController extends Controller
{
/**
* @operation 查看订单列表
*/
public function index()
{
// 订单列表
}
/**
* @operation 查看订单详情
*/
public function detail()
{
// 订单详情
}
/**
* @operation 审核订单
*/
public function audit()
{
// 审核订单
}
/**
* @operation 发货处理
*/
public function ship()
{
// 发货逻辑
}
/**
* @operation 取消订单
*/
public function cancel()
{
// 取消订单
}
/**
* @operation 生成订单报表
*/
public function report()
{
// 生成报表
}
}
```
### 内容管理控制器
```php
<?php
namespace app\admin\controller;
use think\admin\Controller;
class ArticleController extends Controller
{
/**
* @recorder 创建文章
*/
public function create()
{
// 创建文章
}
/**
* @recorder 发布文章
*/
public function publish()
{
// 发布文章
}
/**
* @recorder 撤回文章
*/
public function revoke()
{
// 撤回发布
}
/**
* @recorder 置顶文章
*/
public function setTop()
{
// 设置置顶
}
}
```
## 优先级说明
中间件按以下优先级获取操作信息:
1. **专用注解** (最高优先级)
- @operation@recorder@record_type 等专用注解
2. **通用注解**
- @action@title@desc@description 等通用注解
3. **注释首行**
- 解析注释第一行内容
4. **路由特征**
- 根据URL路径特征判断 (export、import、login等)
5. **HTTP方法**
- 根据请求方法推断 (GET->读取, POST->创建等)
## 注意事项
1. **注释格式**: 使用标准的PHPDoc格式注释
2. **操作类型**: 建议使用中文操作类型,更直观
3. **描述长度**: 操作描述建议控制在100字符以内
4. **关键词匹配**: 操作类型会自动匹配预定义关键词
5. **性能考虑**: 注释解析使用反射,有轻微性能开销,但可接受
## 配置示例
如果要在项目中全面使用注释驱动的操作记录,建议的配置:
```php
// config/recorder.php
return [
'enabled' => true,
'auto_record' => [
'enabled' => true,
'exclude_operations' => [], // 不排除任何操作,全部记录
'exclude_controllers' => ['captcha', 'upload'], // 排除验证码和上传控制器
],
// ...其他配置
];
```
通过合理使用方法注释,可以让操作记录更加准确和有意义,为系统审计和分析提供更好的数据支持。

507
DESIGN.md Normal file
View File

@@ -0,0 +1,507 @@
# think-plugs-recorder 插件设计文档
## 1. 功能需求分析
### 1.1 项目概述
think-plugs-recorder 是一个基于 ThinkAdmin v6.1 的操作记录插件,用于记录用户在系统中的各种操作行为,提供完整的操作日志追踪功能。
### 1.2 核心功能需求
#### 1.2.1 操作记录功能
- 记录用户的读取操作
- 记录用户的自定义操作
- 支持记录操作数据的类型和ID
- 支持记录关联数据的类型和ID
#### 1.2.2 记录字段要求
1. **操作类型** (operation_type): 如 create, read, update, delete, custom 等
2. **操作说明** (operation_desc): 具体的操作描述
3. **用户ID** (user_id): 操作用户的唯一标识
4. **用户昵称** (user_nickname): 操作用户的显示名称
5. **操作时间** (operation_time): 操作发生的时间戳
6. **当前操作数据类型** (data_type): 如 user, order, product 等
7. **当前操作数据ID** (data_id): 具体数据记录的ID
8. **关联数据类型** (related_type): 关联数据的类型(可选)
9. **关联数据ID** (related_id): 关联数据的ID(可选)
#### 1.2.3 功能扩展需求
- 支持批量记录操作
- 支持按多种条件查询操作记录
- 支持操作记录的导出功能
- 支持自定义操作类型配置
- 支持敏感操作的特殊标记
### 1.3 非功能性需求
- **性能要求**: 记录操作不应影响主业务性能
- **存储要求**: 支持大量历史记录存储
- **安全要求**: 操作记录不可被随意修改或删除
- **可维护性**: 提供清晰的接口和配置选项
## 2. 数据库设计
### 2.1 主要数据表设计
#### 2.1.1 操作记录表 (jl_recorder_log)
```sql
CREATE TABLE `jl_recorder_log` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '记录ID',
`operation_type` varchar(50) NOT NULL DEFAULT '' COMMENT '操作类型',
`operation_desc` varchar(500) NOT NULL DEFAULT '' COMMENT '操作说明',
`user_id` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '操作用户ID',
`user_nickname` varchar(100) NOT NULL DEFAULT '' COMMENT '操作用户昵称',
`data_type` varchar(100) NOT NULL DEFAULT '' COMMENT '操作数据类型',
`data_id` varchar(100) NOT NULL DEFAULT '' COMMENT '操作数据ID',
`related_type` varchar(100) NOT NULL DEFAULT '' COMMENT '关联数据类型',
`related_id` varchar(100) NOT NULL DEFAULT '' COMMENT '关联数据ID',
`request_method` varchar(10) NOT NULL DEFAULT '' COMMENT '请求方法',
`request_url` varchar(500) NOT NULL DEFAULT '' COMMENT '请求URL',
`request_ip` varchar(50) NOT NULL DEFAULT '' COMMENT '请求IP',
`user_agent` varchar(500) NOT NULL DEFAULT '' COMMENT '用户代理',
`extra_data` text COMMENT '额外数据(JSON格式)',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_operation_type` (`operation_type`),
KEY `idx_data_type_id` (`data_type`, `data_id`),
KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='操作记录表';
```
#### 2.1.2 预置操作类型说明
操作类型直接使用中文字符串,常用的操作类型包括:
- **创建**: 新增数据记录
- **读取**: 查看数据详情
- **更新**: 修改数据记录
- **删除**: 删除数据记录
- **导出**: 导出数据
- **登录**: 用户登录
- **登出**: 用户登出
- **审核**: 审核操作
- **发布**: 发布操作
### 2.2 索引设计说明
- `idx_user_id`: 按用户查询操作记录
- `idx_operation_type`: 按操作类型查询
- `idx_data_type_id`: 按数据类型和ID查询特定数据的操作记录
- `idx_created_at`: 按时间范围查询操作记录
## 3. 架构设计
### 3.1 插件架构
```
think-plugs-recorder/
├── composer.json # Composer配置
├── DESIGN.md # 设计文档
├── README.md # 使用说明
├── src/ # 源码目录
│ ├── Service.php # 插件服务类
│ ├── model/ # 模型目录
│ │ └── RecorderLog.php # 操作记录模型
│ ├── service/ # 服务目录
│ │ └── RecorderService.php # 核心服务类
│ ├── controller/ # 控制器目录 (可选)
│ │ └── RecorderController.php # 记录管理控制器
│ ├── middleware/ # 中间件目录
│ │ └── RecorderMiddleware.php # 操作记录中间件
│ └── helper/ # 辅助工具目录
│ └── ViewHelper.php # 视图辅助类
├── stc/ # 静态资源
│ └── database/ # 数据库迁移脚本
│ └── 20241201000001_create_recorder_logs_table.php
└── view/ # 视图文件
└── recorder/
├── components/ # 视图组件
│ ├── record_list.html # 记录列表组件
│ ├── record_item.html # 记录项组件
│ └── record_timeline.html # 时间线组件
├── index.html # 管理页面
└── detail.html # 详情页面
```
### 3.2 核心类设计
#### 3.2.1 RecorderService 核心服务类
```php
<?php
namespace jerryyan\recorder\service;
class RecorderService
{
// 记录操作
public function record(array $data): bool;
// 批量记录操作
public function batchRecord(array $records): bool;
// 查询操作记录
public function query(array $conditions = []): array;
// 获取用户操作记录
public function getUserRecords(int $userId, array $conditions = []): array;
// 获取数据操作记录
public function getDataRecords(string $dataType, string $dataId): array;
// 导出操作记录
public function export(array $conditions = []): string;
// 清理过期记录
public function cleanup(int $days = 90): int;
}
```
#### 3.2.2 RecorderLog 数据模型
```php
<?php
namespace jerryyan\recorder\model;
class RecorderLog extends \think\admin\Model
{
// 自动时间戳
protected $autoWriteTimestamp = true;
// 字段类型转换
protected $type = [
'id' => 'integer',
'user_id' => 'integer',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
// 字段验证规则
protected function getRules(): array;
// 获取额外数据
public function getExtraDataAttr($value): array;
// 设置额外数据
public function setExtraDataAttr($value): string;
}
```
#### 3.2.3 ViewHelper 视图辅助类
```php
<?php
namespace jerryyan\recorder\helper;
class ViewHelper
{
// 获取操作记录数据
public static function getRecords(array $conditions = []): array;
// 渲染记录列表HTML
public static function renderList(array $conditions = [], array $options = []): string;
// 渲染记录时间线HTML
public static function renderTimeline(array $conditions = [], array $options = []): string;
// 渲染单条记录HTML
public static function renderItem(array $record, array $options = []): string;
// 注册模板函数
public static function registerTemplateFunctions(): void;
}
```
### 3.3 接口设计
#### 3.3.1 记录接口
```php
// 基础记录接口
RecorderService::record([
'operation_type' => '读取',
'operation_desc' => '查看用户信息',
'data_type' => 'user',
'data_id' => '123',
'related_type' => 'department',
'related_id' => '456'
]);
// 自动记录当前用户信息
RecorderService::autoRecord([
'operation_type' => '创建',
'operation_desc' => '创建新订单',
'data_type' => 'order',
'data_id' => '789'
]);
```
#### 3.3.2 查询接口
```php
// 查询用户操作记录
$records = RecorderService::getUserRecords(123, [
'operation_type' => '读取',
'start_time' => '2024-01-01',
'end_time' => '2024-12-31'
]);
// 查询数据操作记录
$records = RecorderService::getDataRecords('user', '123');
// 复杂条件查询
$records = RecorderService::query([
'user_id' => 123,
'operation_type' => ['读取', '更新'],
'data_type' => 'user',
'date_range' => ['2024-01-01', '2024-12-31']
]);
```
#### 3.3.3 视图调用接口
```php
// 在模板中获取操作记录
{:recorder_get_records(['data_type' => 'user', 'data_id' => $user_id])}
// 渲染记录列表
{:recorder_render_list(['user_id' => $user_id], ['limit' => 10])}
// 渲染时间线
{:recorder_render_timeline(['data_type' => 'order', 'data_id' => $order_id])}
// 渲染单条记录
{:recorder_render_item($record)}
```
## 4. 使用方式设计
### 4.1 手动记录
在业务代码中手动调用记录方法:
```php
use jerryyan\recorder\service\RecorderService;
// 在控制器或服务中记录操作
RecorderService::record([
'operation_type' => '读取',
'operation_desc' => '查看用户详情',
'data_type' => 'user',
'data_id' => $userId
]);
```
### 4.2 中间件自动记录
通过中间件自动记录请求操作:
```php
// 在应用配置中启用中间件
'middleware' => [
'jerryyan\recorder\middleware\RecorderMiddleware'
]
```
### 4.3 模型事件记录
在模型事件中自动记录CRUD操作:
```php
// 在模型中添加事件监听
protected static function onAfterInsert($model) {
RecorderService::autoRecord([
'operation_type' => '创建',
'operation_desc' => '创建' . $model->getTable() . '记录',
'data_type' => $model->getTable(),
'data_id' => $model->getPk()
]);
}
```
### 4.4 视图模板调用
在ThinkPHP模板中直接调用操作记录功能:
#### 4.4.1 获取记录数据
```html
<!-- 获取用户操作记录 -->
{assign name="user_records" value=":recorder_get_records(['user_id' => $user_id, 'limit' => 5])" /}
<!-- 获取特定数据的操作记录 -->
{assign name="data_records" value=":recorder_get_records(['data_type' => 'order', 'data_id' => $order_id])" /}
<!-- 遍历显示记录 -->
{volist name="user_records" id="record"}
<div class="record-item">
<span class="operation">{$record.operation_type}</span>
<span class="desc">{$record.operation_desc}</span>
<span class="time">{$record.created_at}</span>
</div>
{/volist}
```
#### 4.4.2 渲染HTML组件
```html
<!-- 渲染操作记录列表 -->
<div class="record-section">
<h4>操作记录</h4>
{:recorder_render_list(['user_id' => $user_id], ['limit' => 10, 'show_user' => false])}
</div>
<!-- 渲染时间线样式 -->
<div class="timeline-section">
<h4>操作时间线</h4>
{:recorder_render_timeline(['data_type' => 'order', 'data_id' => $order_id], ['theme' => 'vertical'])}
</div>
<!-- 在用户详情页显示最近操作 -->
<div class="recent-actions">
<h5>最近操作</h5>
{:recorder_render_list(['user_id' => $user_id], ['limit' => 3, 'compact' => true])}
</div>
```
#### 4.4.3 AJAX异步加载
```html
<!-- 异步加载更多记录 -->
<div id="record-container">
<div class="record-list">
{:recorder_render_list(['data_type' => 'user', 'data_id' => $user_id], ['limit' => 10])}
</div>
<button onclick="loadMoreRecords()" class="load-more">加载更多</button>
</div>
<script>
function loadMoreRecords() {
$.post('/recorder/load_more', {
data_type: 'user',
data_id: {$user_id},
offset: $('.record-item').length
}, function(html) {
$('.record-list').append(html);
});
}
</script>
```
#### 4.4.4 可配置的显示选项
```html
<!-- 带配置选项的记录列表 -->
{assign name="list_options" value="[
'limit' => 20,
'show_user' => true,
'show_ip' => false,
'date_format' => 'Y-m-d H:i:s',
'theme' => 'card',
'show_extra' => false
]" /}
{:recorder_render_list(['user_id' => $user_id], $list_options)}
<!-- 简洁模式的时间线 -->
{:recorder_render_timeline(['data_type' => 'project', 'data_id' => $project_id], ['compact' => true, 'limit' => 5])}
```
## 5. 配置设计
### 5.1 插件配置
```php
return [
'recorder' => [
// 是否启用操作记录
'enabled' => true,
// 自动记录模式
'auto_record' => [
'enabled' => true,
'exclude_operations' => ['读取'], // 排除的操作类型
'exclude_controllers' => [], // 排除的控制器
],
// 记录保留天数
'retention_days' => 90,
// 敏感操作标记
'sensitive_operations' => ['删除', '导出'],
// 视图组件配置
'view' => [
'default_limit' => 10,
'date_format' => 'Y-m-d H:i:s',
'theme' => 'default', // default, card, timeline
'show_user' => true,
'show_ip' => false,
'compact_mode' => false,
]
]
];
```
## 6. 安全考虑
### 6.1 数据安全
- 记录表只允许插入和查询,不允许更新和删除
- 敏感操作需要特殊权限才能查看
- 支持数据脱敏显示
### 6.2 性能考虑
- 异步记录操作日志,不影响主业务
- 定期清理过期日志
- 合理的索引设计
### 6.3 权限控制
- 基于 ThinkAdmin 的权限系统
- 不同用户只能查看自己的操作记录
- 管理员可以查看所有用户的操作记录
## 7. 扩展计划
### 7.1 功能扩展
- 支持操作记录的统计分析
- 支持异常操作的告警机制
- 支持操作记录的可视化展示
### 7.2 集成扩展
- 支持与其他日志系统集成
- 支持导出到第三方审计系统
- 支持API接口调用记录
### 7.3 视图功能扩展
- 支持更多主题样式和布局
- 支持自定义模板和组件
- 支持实时刷新和WebSocket推送
- 支持操作记录的图表统计
## 8. 视图模板函数设计
### 8.1 注册的模板函数
插件将自动注册以下模板函数供视图调用:
```php
// 获取操作记录数据
function recorder_get_records(array $conditions = []): array
// 渲染记录列表
function recorder_render_list(array $conditions = [], array $options = []): string
// 渲染时间线
function recorder_render_timeline(array $conditions = [], array $options = []): string
// 渲染单条记录
function recorder_render_item(array $record, array $options = []): string
// 获取记录统计
function recorder_get_stats(array $conditions = []): array
```
### 8.2 可用的显示选项
```php
$options = [
'limit' => 10, // 显示数量限制
'show_user' => true, // 是否显示用户信息
'show_ip' => false, // 是否显示IP地址
'show_extra' => false, // 是否显示额外数据
'date_format' => 'Y-m-d H:i:s', // 日期格式
'theme' => 'default', // 主题样式 (default, card, timeline, compact)
'compact' => false, // 是否紧凑模式
'css_class' => '', // 自定义CSS类
'template' => '', // 自定义模板文件
];
```
### 8.3 视图组件样式
插件提供多种预置样式主题:
- **default**: 默认列表样式
- **card**: 卡片式布局
- **timeline**: 时间线布局
- **compact**: 紧凑式布局
---
**注意**: 此设计文档需要在开始编码前经过确认,确保需求理解准确无误。

388
README.md Normal file
View File

@@ -0,0 +1,388 @@
# ThinkAdmin 操作记录插件
基于 ThinkAdmin v6.1 的操作记录插件,提供完整的用户操作追踪功能,支持手动记录和自动记录,包含丰富的视图展示功能。
## 功能特性
### 核心功能
-**灵活记录**: 支持手动记录和自动记录两种模式
-**完整追踪**: 记录操作类型、用户信息、数据信息、时间等
-**关联数据**: 支持记录操作数据和关联数据
-**视图集成**: 提供模板函数,可在视图中直接调用
-**多种样式**: 支持列表、卡片、时间线等多种展示样式
-**自动清理**: 支持定期清理过期记录
### 记录内容
- 操作类型:创建、读取、更新、删除、导出、登录、登出等
- 操作说明:具体的操作描述
- 用户信息:用户ID、用户昵称
- 数据信息:操作的数据类型和ID
- 关联信息:关联的数据类型和ID(可选)
- 请求信息:请求方法、URL、IP地址、用户代理
- 额外数据:JSON格式的扩展信息
## 安装配置
### 1. 数据库迁移
```bash
# 运行数据库迁移脚本
php think migrate:run
```
### 2. 插件发布
```bash
# 发布插件资源
php think xadmin:publish --migrate
```
### 3. 配置文件
`config/` 目录下创建 `recorder.php` 配置文件:
```php
<?php
return [
// 是否启用操作记录
'enabled' => true,
// 自动记录配置
'auto_record' => [
'enabled' => true,
'exclude_operations' => ['读取'], // 排除的操作类型
'exclude_controllers' => [], // 排除的控制器
],
// 记录保留天数
'retention_days' => 90,
// 敏感操作标记
'sensitive_operations' => ['删除', '导出'],
// 视图组件配置
'view' => [
'default_limit' => 10,
'date_format' => 'Y-m-d H:i:s',
'theme' => 'default',
'show_user' => true,
'show_ip' => false,
'compact_mode' => false,
]
];
```
### 4. 注册中间件(可选)
`app/middleware.php` 中注册中间件实现自动记录:
```php
<?php
return [
// 其他中间件...
\jerryyan\recorder\middleware\RecorderMiddleware::class,
];
```
### 5. 控制器方法注释(推荐)
中间件支持读取控制器方法注释来获取准确的操作类型,详见 [控制器注释示例](CONTROLLER_ANNOTATION_EXAMPLE.md):
```php
/**
* @operation 创建用户
*/
public function save()
{
// 保存用户逻辑
}
/**
* @recorder 审核用户申请
*/
public function audit()
{
// 审核逻辑
}
```
## 使用方法
### 1. 手动记录操作
```php
use jerryyan\recorder\service\RecorderService;
// 基础记录
RecorderService::record([
'operation_type' => '创建',
'operation_desc' => '创建新用户',
'data_type' => 'user',
'data_id' => '123'
]);
// 完整记录
RecorderService::record([
'operation_type' => '更新',
'operation_desc' => '修改用户信息',
'user_id' => 1,
'user_nickname' => '管理员',
'data_type' => 'user',
'data_id' => '123',
'related_type' => 'department',
'related_id' => '456',
'extra_data' => ['old_name' => '张三', 'new_name' => '李四']
]);
// 自动记录(自动获取当前用户和请求信息)
RecorderService::autoRecord([
'operation_type' => '导出',
'operation_desc' => '导出用户列表',
'data_type' => 'user'
]);
```
### 2. 查询操作记录
```php
use jerryyan\recorder\service\RecorderService;
// 查询用户操作记录
$userRecords = RecorderService::getUserRecords(123, [
'limit' => 10,
'operation_type' => '创建'
]);
// 查询数据操作记录
$dataRecords = RecorderService::getDataRecords('user', '123');
// 复杂查询
$records = RecorderService::query([
'user_id' => 123,
'operation_type' => ['创建', '更新'],
'data_type' => 'user',
'start_time' => '2024-01-01',
'end_time' => '2024-12-31',
'keyword' => '用户管理'
]);
// 获取统计数据
$stats = RecorderService::getStats([
'start_time' => '2024-01-01',
'end_time' => '2024-12-31'
]);
```
### 3. 视图中调用
**注意**: 如果提示函数未定义,请参考 [模板函数使用指南](TEMPLATE_FUNCTIONS_GUIDE.md) 解决。
首先确保函数已正确注册:
```bash
# 更新Composer自动加载
composer dump-autoload
```
#### 3.1 获取记录数据
```html
<!-- 获取用户操作记录 -->
{assign name="records" value=":recorder_get_records(['user_id' => $user_id, 'limit' => 5])" /}
<!-- 遍历显示 -->
{volist name="records" id="record"}
<div>
{$record.operation_type} - {$record.operation_desc} - {$record.created_at}
</div>
{/volist}
```
#### 3.2 渲染HTML组件
```html
<!-- 默认列表样式 -->
<div class="user-actions">
<h4>操作记录</h4>
{:recorder_render_list(['user_id' => $user_id], ['limit' => 10])}
</div>
<!-- 卡片样式 -->
{:recorder_render_list(['data_type' => 'order'], ['theme' => 'card', 'show_user' => true])}
<!-- 时间线样式 -->
{:recorder_render_timeline(['data_type' => 'project', 'data_id' => $project_id])}
<!-- 单条记录 -->
{:recorder_render_item($record, ['show_extra' => true])}
```
#### 3.3 配置选项
```html
<!-- 带完整配置的记录列表 -->
{assign name="options" value="[
'limit' => 20,
'show_user' => true,
'show_ip' => false,
'date_format' => 'Y-m-d H:i:s',
'theme' => 'card',
'compact' => false,
'css_class' => 'my-custom-class'
]" /}
{:recorder_render_list(['user_id' => $user_id], $options)}
```
#### 3.4 备用方案(如果函数未注册)
```html
<!-- 使用类静态方法替代函数 -->
{assign name="records" value=":jerryyan\recorder\helper\ViewHelper::getRecords(['user_id' => $user_id])" /}
<!-- 渲染列表 -->
{:jerryyan\recorder\helper\ViewHelper::renderList(['user_id' => $user_id], ['limit' => 10])}
<!-- 渲染时间线 -->
{:jerryyan\recorder\helper\ViewHelper::renderTimeline(['data_type' => 'order', 'data_id' => $order_id])}
```
### 4. 模型事件自动记录
```php
use jerryyan\recorder\service\RecorderService;
class User extends \think\admin\Model
{
// 创建后自动记录
protected static function onAfterInsert($user)
{
RecorderService::autoRecord([
'operation_type' => '创建',
'operation_desc' => '创建用户:' . $user->username,
'data_type' => 'user',
'data_id' => $user->id
]);
}
// 更新后自动记录
protected static function onAfterUpdate($user)
{
RecorderService::autoRecord([
'operation_type' => '更新',
'operation_desc' => '更新用户:' . $user->username,
'data_type' => 'user',
'data_id' => $user->id
]);
}
}
```
### 5. 数据管理
```php
use jerryyan\recorder\service\RecorderService;
// 导出记录
$filepath = RecorderService::export([
'start_time' => '2024-01-01',
'end_time' => '2024-12-31'
]);
// 清理过期记录
$deletedCount = RecorderService::cleanup(90); // 清理90天前的记录
```
## 视图样式
插件提供多种内置样式:
### 1. 默认列表样式
简洁的列表展示,适合在管理页面中使用。
### 2. 卡片样式
美观的卡片布局,适合在首页或仪表板中展示。
### 3. 时间线样式
时间线形式展示,清晰展示操作的时间顺序。
### 4. 紧凑样式
紧凑的显示模式,适合空间有限的场景。
## 数据库结构
插件使用单表存储所有操作记录:
```sql
CREATE TABLE `jl_recorder_log` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '记录ID',
`operation_type` varchar(50) NOT NULL DEFAULT '' COMMENT '操作类型',
`operation_desc` varchar(500) NOT NULL DEFAULT '' COMMENT '操作说明',
`user_id` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '操作用户ID',
`user_nickname` varchar(100) NOT NULL DEFAULT '' COMMENT '操作用户昵称',
`data_type` varchar(100) NOT NULL DEFAULT '' COMMENT '操作数据类型',
`data_id` varchar(100) NOT NULL DEFAULT '' COMMENT '操作数据ID',
`related_type` varchar(100) NOT NULL DEFAULT '' COMMENT '关联数据类型',
`related_id` varchar(100) NOT NULL DEFAULT '' COMMENT '关联数据ID',
`request_method` varchar(10) NOT NULL DEFAULT '' COMMENT '请求方法',
`request_url` varchar(500) NOT NULL DEFAULT '' COMMENT '请求URL',
`request_ip` varchar(50) NOT NULL DEFAULT '' COMMENT '请求IP',
`user_agent` varchar(500) NOT NULL DEFAULT '' COMMENT '用户代理',
`extra_data` text COMMENT '额外数据(JSON格式)',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_operation_type` (`operation_type`),
KEY `idx_data_type_id` (`data_type`, `data_id`),
KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='操作记录表';
```
## 性能优化
1. **索引优化**: 针对常用查询条件建立了合适的数据库索引
2. **异步记录**: 记录操作不会影响主业务的性能
3. **数据限制**: 自动限制存储数据的长度,避免过大数据影响性能
4. **定期清理**: 支持自动清理过期记录,保持表的性能
## 安全考虑
1. **数据脱敏**: 自动过滤敏感信息如密码、密钥等
2. **权限控制**: 基于ThinkAdmin的权限系统
3. **只读模式**: 操作记录支持只读模式,防止恶意修改
4. **错误隔离**: 记录失败不会影响主业务逻辑
## 常见问题
### Q: 插件安装后没有生效怎么办?
A: 检查以下几点:
1. 确认数据库迁移是否成功执行
2. 检查配置文件是否正确
3. 确认插件是否已经正确注册
### Q: 如何自定义操作类型?
A: 操作类型由代码直接控制,可以使用任意中文字符串作为操作类型。
### Q: 如何实现自定义的视图样式?
A: 可以通过以下方式:
1. 在配置中指定自定义CSS类
2. 创建自定义模板文件
3. 修改内置的CSS样式
### Q: 记录过多会影响性能吗?
A: 插件已经进行了性能优化:
1. 使用了合适的数据库索引
2. 支持定期清理过期记录
3. 异步记录不影响主业务
## 更新日志
### v1.0.0 (2024-12-12)
- 🎉 初始版本发布
- ✅ 支持手动和自动操作记录
- ✅ 提供完整的视图展示功能
- ✅ 支持多种展示样式
- ✅ 包含中间件自动记录功能
## 许可证
WTFPL
## 作者
Jerry Yan (792602257@qq.com)
## 技术支持
如有问题,请提交 Issue 或联系作者。

193
TEMPLATE_FUNCTIONS_GUIDE.md Normal file
View File

@@ -0,0 +1,193 @@
# 模板函数使用指南
如果在使用模板函数时提示 `recorder_get_records` 等函数未定义,请按照以下步骤解决:
## 问题原因
模板函数需要在ThinkPHP启动时正确注册到全局作用域,可能因为以下原因导致函数未注册:
1. Composer自动加载未正确更新
2. 插件服务未正确启动
3. 函数文件未被正确加载
## 解决方案
### 方案1: 更新Composer自动加载(推荐)
在项目根目录执行:
```bash
composer dump-autoload
```
这会重新生成自动加载文件,确保 `src/functions.php` 被正确加载。
### 方案2: 手动引入函数文件
#### 在应用启动时引入
`app/provider.php``config/app.php` 中添加:
```php
// 引入操作记录模板函数
$functionsFile = app_path('plugs/think-plugs-recorder/src/functions.php');
if (file_exists($functionsFile)) {
require_once $functionsFile;
}
```
#### 在模板中临时引入
在需要使用的模板文件顶部添加:
```html
{php}
$functionsFile = app_path('plugs/think-plugs-recorder/src/functions.php');
if (file_exists($functionsFile)) {
require_once $functionsFile;
}
{/php}
<!-- 现在可以正常使用函数了 -->
{:recorder_render_list(['user_id' => $user_id])}
```
#### 在控制器中引入
在控制器的构造函数或方法中:
```php
<?php
class UserController extends Controller
{
public function __construct()
{
parent::__construct();
// 确保模板函数可用
$functionsFile = app_path('plugs/think-plugs-recorder/src/functions.php');
if (file_exists($functionsFile)) {
require_once $functionsFile;
}
}
}
```
### 方案3: 创建应用助手函数
`app/common.php` 中添加以下代码:
```php
<?php
/**
* 应用公共函数文件
*/
// 引入操作记录模板函数
if (!function_exists('recorder_get_records')) {
$recorderFunctions = app_path('plugs/think-plugs-recorder/src/functions.php');
if (file_exists($recorderFunctions)) {
require_once $recorderFunctions;
}
}
```
### 方案4: 使用类方法替代
如果函数始终无法正确注册,可以直接使用类的静态方法:
```html
<!-- 替代 recorder_get_records() -->
{assign name="records" value=":jerryyan\recorder\helper\ViewHelper::getRecords(['user_id' => $user_id])" /}
<!-- 替代 recorder_render_list() -->
{:jerryyan\recorder\helper\ViewHelper::renderList(['user_id' => $user_id], ['limit' => 10])}
<!-- 替代 recorder_render_timeline() -->
{:jerryyan\recorder\helper\ViewHelper::renderTimeline(['data_type' => 'order', 'data_id' => $order_id])}
<!-- 替代 recorder_render_item() -->
{:jerryyan\recorder\helper\ViewHelper::renderItem($record, ['show_extra' => true])}
<!-- 替代 recorder_get_stats() -->
{assign name="stats" value=":jerryyan\recorder\helper\ViewHelper::getStats()" /}
```
## 测试函数注册
运行测试脚本检查函数是否正确注册:
```bash
php plugs/think-plugs-recorder/test_functions.php
```
## 可用的模板函数
注册成功后,以下函数可在模板中直接使用:
### recorder_get_records()
获取操作记录数据:
```html
{assign name="records" value=":recorder_get_records(['user_id' => $user_id, 'limit' => 5])" /}
{volist name="records" id="record"}
<div>{$record.operation_type} - {$record.operation_desc}</div>
{/volist}
```
### recorder_render_list()
渲染记录列表:
```html
<!-- 默认样式 -->
{:recorder_render_list(['user_id' => $user_id])}
<!-- 卡片样式 -->
{:recorder_render_list(['data_type' => 'order'], ['theme' => 'card', 'limit' => 10])}
<!-- 紧凑样式 -->
{:recorder_render_list(['user_id' => $user_id], ['compact' => true, 'show_user' => false])}
```
### recorder_render_timeline()
渲染时间线:
```html
{:recorder_render_timeline(['data_type' => 'project', 'data_id' => $project_id])}
```
### recorder_render_item()
渲染单条记录:
```html
{:recorder_render_item($record, ['show_extra' => true])}
```
### recorder_get_stats()
获取统计数据:
```html
{assign name="stats" value=":recorder_get_stats(['start_time' => '2024-01-01'])" /}
<p>总操作次数: {$stats.total_count}</p>
```
## 常见问题
### Q: 提示 "Call to undefined function recorder_get_records()"
A: 函数未正确注册,请按方案1或方案2解决。
### Q: 函数存在但调用时报错
A: 可能是数据库连接问题或权限问题,检查数据库配置和表是否存在。
### Q: 在某些页面可以使用,某些页面不能使用
A: 可能是自动加载时机问题,建议使用方案2在应用启动时全局引入。
### Q: 开发环境正常,生产环境报错
A: 生产环境需要重新执行 `composer dump-autoload --optimize`
## 最佳实践
1. **推荐使用方案1**: 通过Composer自动加载是最标准的方式
2. **在应用启动时引入**: 确保所有页面都能使用函数
3. **备用方案**: 准备类方法调用作为备用方案
4. **错误处理**: 在模板中使用函数前检查函数是否存在
```html
{if function_exists('recorder_render_list')}
{:recorder_render_list(['user_id' => $user_id])}
{else}
{:jerryyan\recorder\helper\ViewHelper::renderList(['user_id' => $user_id])}
{/if}
```

View File

@@ -31,7 +31,10 @@
"autoload": {
"psr-4": {
"jerryyan\\recorder\\": "src"
}
},
"files": [
"src/functions.php"
]
},
"require": {

View File

@@ -3,13 +3,331 @@
namespace jerryyan\recorder;
use think\admin\Plugin;
use jerryyan\recorder\helper\ViewHelper;
/**
* 操作记录插件服务类
* Class Service
* @package jerryyan\recorder
*/
class Service extends Plugin
{
/**
* 插件编码
* @var string
*/
protected $appCode = 'recorder';
/**
* 插件名称
* @var string
*/
protected $appName = '操作记录';
/**
* 定义插件菜单
* @return array
*/
public static function menu(): array
{
return [];
// 暂时不提供后台菜单,仅作为服务插件使用
return [
[
'name' => '操作记录',
'subs' => [
[
'name' => '记录查询',
'icon' => 'layui-icon layui-icon-search',
'node' => 'recorder/index/index'
],
[
'name' => '记录统计',
'icon' => 'layui-icon layui-icon-chart',
'node' => 'recorder/stats/index'
]
]
]
];
}
/**
* 插件服务注册
*/
public function register()
{
// 注册事件监听器
$this->registerEventListeners();
}
/**
* 插件启动
*/
public function boot(): void
{
// 插件启动时的初始化操作
$this->initializePlugin();
// 注册模板函数
$this->registerTemplateFunctions();
}
/**
* 注册模板函数
*/
protected function registerTemplateFunctions()
{
try {
// 函数已经通过composer autoload的files自动加载
// 这里只需要确认函数是否正确加载
if (!function_exists('recorder_get_records')) {
// 如果通过autoload加载失败,手动加载
require_once __DIR__ . '/functions.php';
}
} catch (\Exception $e) {
trace("注册模板函数失败: " . $e->getMessage(), 'error');
}
}
/**
* 注册事件监听器
*/
protected function registerEventListeners()
{
// 可以在这里注册模型事件监听器,自动记录CRUD操作
// 例如:监听模型的增删改事件,自动记录操作日志
try {
// 注册全局模型事件监听
\think\facade\Event::listen('think\\model\\concern\\ModelEvent', function($event, $model) {
$this->handleModelEvent($event, $model);
});
} catch (\Exception $e) {
trace("注册事件监听器失败: " . $e->getMessage(), 'error');
}
}
/**
* 处理模型事件
* @param string $event
* @param $model
*/
protected function handleModelEvent(string $event, $model)
{
// 根据配置决定是否自动记录模型操作
$config = \jerryyan\recorder\service\RecorderService::getConfig('auto_record', []);
if (!($config['enabled'] ?? false)) {
return;
}
try {
$operationMap = [
'after_insert' => '创建',
'after_update' => '更新',
'after_delete' => '删除',
];
if (isset($operationMap[$event])) {
$operationType = $operationMap[$event];
// 检查是否排除此操作类型
$excludeOperations = $config['exclude_operations'] ?? [];
if (in_array($operationType, $excludeOperations)) {
return;
}
$tableName = $model->getTable();
$primaryKey = $model->getPk();
$primaryValue = $model->$primaryKey ?? '';
\jerryyan\recorder\service\RecorderService::autoRecord([
'operation_type' => $operationType,
'operation_desc' => "{$operationType}{$tableName}记录",
'data_type' => $tableName,
'data_id' => (string)$primaryValue,
]);
}
} catch (\Exception $e) {
trace("自动记录模型操作失败: " . $e->getMessage(), 'error');
}
}
/**
* 初始化插件
*/
protected function initializePlugin()
{
// 设置默认配置
$this->setDefaultConfig();
// 检查数据表是否存在
$this->checkDatabaseTables();
}
/**
* 设置默认配置
*/
protected function setDefaultConfig()
{
$defaultConfig = [
'enabled' => true,
'auto_record' => [
'enabled' => false,
'exclude_operations' => ['读取'],
'exclude_controllers' => [],
],
'retention_days' => 90,
'sensitive_operations' => ['删除', '导出'],
'view' => [
'default_limit' => 10,
'date_format' => 'Y-m-d H:i:s',
'theme' => 'default',
'show_user' => true,
'show_ip' => false,
'compact_mode' => false,
]
];
// 合并用户配置
$userConfig = config('recorder', []);
$finalConfig = array_merge($defaultConfig, $userConfig);
// 更新配置
config(['recorder' => $finalConfig]);
}
/**
* 检查数据表是否存在
*/
protected function checkDatabaseTables()
{
try {
$db = \think\facade\Db::connect();
// 检查操作记录表是否存在
$tables = $db->query("SHOW TABLES LIKE 'jl_recorder_log'");
if (empty($tables)) {
trace("操作记录表不存在,请运行数据库迁移脚本:php think migrate:run", 'notice');
}
} catch (\Exception $e) {
trace("检查数据表失败: " . $e->getMessage(), 'error');
}
}
/**
* 获取插件版本
* @return string
*/
public function getVersion(): string
{
return 'v1.0.0';
}
/**
* 获取插件描述
* @return string
*/
public function getDescription(): string
{
return '提供用户操作记录功能,支持手动记录和自动记录,包含完善的查询和视图展示功能';
}
/**
* 获取插件作者
* @return string
*/
public function getAuthor(): string
{
return 'Jerry Yan';
}
/**
* 插件安装
* @return bool
*/
public function install(): bool
{
try {
// 运行数据库迁移
$this->runMigrations();
// 初始化配置
$this->setDefaultConfig();
trace("操作记录插件安装成功", 'info');
return true;
} catch (\Exception $e) {
trace("操作记录插件安装失败: " . $e->getMessage(), 'error');
return false;
}
}
/**
* 插件卸载
* @return bool
*/
public function uninstall(): bool
{
try {
// 这里可以添加卸载时的清理操作
// 注意:通常不建议自动删除数据表,避免数据丢失
trace("操作记录插件卸载成功", 'info');
return true;
} catch (\Exception $e) {
trace("操作记录插件卸载失败: " . $e->getMessage(), 'error');
return false;
}
}
/**
* 运行数据库迁移
*/
protected function runMigrations()
{
// 这里可以调用Phinx迁移命令
// 或者直接执行SQL创建表结构
try {
// 示例:直接执行建表SQL
$sql = $this->getCreateTableSql();
\think\facade\Db::execute($sql);
} catch (\Exception $e) {
trace("执行数据库迁移失败: " . $e->getMessage(), 'error');
throw $e;
}
}
/**
* 获取建表SQL
* @return string
*/
protected function getCreateTableSql(): string
{
return "
CREATE TABLE IF NOT EXISTS `jl_recorder_log` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '记录ID',
`operation_type` varchar(50) NOT NULL DEFAULT '' COMMENT '操作类型',
`operation_desc` varchar(500) NOT NULL DEFAULT '' COMMENT '操作说明',
`user_id` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '操作用户ID',
`user_nickname` varchar(100) NOT NULL DEFAULT '' COMMENT '操作用户昵称',
`data_type` varchar(100) NOT NULL DEFAULT '' COMMENT '操作数据类型',
`data_id` varchar(100) NOT NULL DEFAULT '' COMMENT '操作数据ID',
`related_type` varchar(100) NOT NULL DEFAULT '' COMMENT '关联数据类型',
`related_id` varchar(100) NOT NULL DEFAULT '' COMMENT '关联数据ID',
`request_method` varchar(10) NOT NULL DEFAULT '' COMMENT '请求方法',
`request_url` varchar(500) NOT NULL DEFAULT '' COMMENT '请求URL',
`request_ip` varchar(50) NOT NULL DEFAULT '' COMMENT '请求IP',
`user_agent` varchar(500) NOT NULL DEFAULT '' COMMENT '用户代理',
`extra_data` text COMMENT '额外数据(JSON格式)',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_operation_type` (`operation_type`),
KEY `idx_data_type_id` (`data_type`, `data_id`),
KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='操作记录表';
";
}
}

133
src/functions.php Normal file
View File

@@ -0,0 +1,133 @@
<?php
/**
* think-plugs-recorder 全局模板函数
* 这些函数可以在ThinkPHP模板中直接使用
*/
use jerryyan\recorder\helper\ViewHelper;
if (!function_exists('recorder_get_records')) {
/**
* 获取操作记录数据
* @param array $conditions 查询条件
* @return array
*/
function recorder_get_records(array $conditions = []): array
{
return ViewHelper::getRecords($conditions);
}
}
if (!function_exists('recorder_render_list')) {
/**
* 渲染记录列表HTML
* @param array $conditions 查询条件
* @param array $options 显示选项
* @return string
*/
function recorder_render_list(array $conditions = [], array $options = []): string
{
return ViewHelper::renderList($conditions, $options);
}
}
if (!function_exists('recorder_render_timeline')) {
/**
* 渲染时间线HTML
* @param array $conditions 查询条件
* @param array $options 显示选项
* @return string
*/
function recorder_render_timeline(array $conditions = [], array $options = []): string
{
return ViewHelper::renderTimeline($conditions, $options);
}
}
if (!function_exists('recorder_render_item')) {
/**
* 渲染单条记录HTML
* @param array $record 记录数据
* @param array $options 显示选项
* @return string
*/
function recorder_render_item(array $record, array $options = []): string
{
return ViewHelper::renderItem($record, $options);
}
}
if (!function_exists('recorder_get_stats')) {
/**
* 获取操作统计数据
* @param array $conditions 查询条件
* @return array
*/
function recorder_get_stats(array $conditions = []): array
{
return ViewHelper::getStats($conditions);
}
}
if (!function_exists('recorder_get_data_readers')) {
/**
* 获取数据的读取用户列表
* @param string $dataType 数据类型
* @param string $dataId 数据ID
* @param array $conditions 额外条件
* @return array
*/
function recorder_get_data_readers(string $dataType, string $dataId, array $conditions = []): array
{
return ViewHelper::getDataReaders($dataType, $dataId, $conditions);
}
}
if (!function_exists('recorder_get_data_operators')) {
/**
* 获取数据的操作用户列表(排除读取)
* @param string $dataType 数据类型
* @param string $dataId 数据ID
* @param array $conditions 额外条件
* @return array
*/
function recorder_get_data_operators(string $dataType, string $dataId, array $conditions = []): array
{
return ViewHelper::getDataOperators($dataType, $dataId, $conditions);
}
}
if (!function_exists('recorder_render_data_readers')) {
/**
* 渲染数据读取用户列表
* @param string $dataType 数据类型
* @param string $dataId 数据ID
* @param array $conditions 查询条件
* @param array $options 显示选项
* @return string
*/
function recorder_render_data_readers(string $dataType, string $dataId, array $conditions = [], array $options = []): string
{
return ViewHelper::renderDataReaders($dataType, $dataId, $conditions, $options);
}
}
if (!function_exists('recorder_render_data_operators')) {
/**
* 渲染数据操作用户列表
* @param string $dataType 数据类型
* @param string $dataId 数据ID
* @param array $conditions 查询条件
* @param array $options 显示选项
* @return string
*/
function recorder_render_data_operators(string $dataType, string $dataId, array $conditions = [], array $options = []): string
{
return ViewHelper::renderDataOperators($dataType, $dataId, $conditions, $options);
}
}
// 确保函数在早期阶段注册
if (class_exists('jerryyan\recorder\helper\ViewHelper')) {
// 函数已经通过上面的定义注册了
}

831
src/helper/ViewHelper.php Normal file
View File

@@ -0,0 +1,831 @@
<?php
namespace jerryyan\recorder\helper;
use jerryyan\recorder\service\RecorderService;
use think\facade\View;
/**
* 视图辅助类
* Class ViewHelper
* @package jerryyan\recorder\helper
*/
class ViewHelper
{
/**
* 默认配置选项
* @var array
*/
protected static $defaultOptions = [
'limit' => 10,
'show_user' => true,
'show_ip' => false,
'show_extra' => false,
'date_format' => 'Y-m-d H:i:s',
'theme' => 'default',
'compact' => false,
'css_class' => '',
'template' => '',
];
/**
* 获取操作记录数据
* @param array $conditions 查询条件
* @return array
*/
public static function getRecords(array $conditions = []): array
{
try {
$result = RecorderService::query($conditions);
return $result['data'] ?? [];
} catch (\Exception $e) {
trace("获取操作记录失败: " . $e->getMessage(), 'error');
return [];
}
}
/**
* 渲染记录列表HTML
* @param array $conditions 查询条件
* @param array $options 显示选项
* @return string
*/
public static function renderList(array $conditions = [], array $options = []): string
{
$options = array_merge(static::$defaultOptions, RecorderService::getConfig('view', []), $options);
// 获取记录数据
$records = static::getRecords($conditions);
if (empty($records)) {
return static::renderEmpty($options);
}
// 处理数据格式化
$records = static::formatRecords($records, $options);
// 选择渲染模板
$template = static::getTemplate('list', $options);
return static::render($template, [
'records' => $records,
'options' => $options,
]);
}
/**
* 渲染时间线HTML
* @param array $conditions 查询条件
* @param array $options 显示选项
* @return string
*/
public static function renderTimeline(array $conditions = [], array $options = []): string
{
$options = array_merge(static::$defaultOptions, RecorderService::getConfig('view', []), $options);
$options['theme'] = 'timeline'; // 强制使用时间线主题
// 获取记录数据
$records = static::getRecords($conditions);
if (empty($records)) {
return static::renderEmpty($options);
}
// 处理数据格式化
$records = static::formatRecords($records, $options);
// 选择渲染模板
$template = static::getTemplate('timeline', $options);
return static::render($template, [
'records' => $records,
'options' => $options,
]);
}
/**
* 渲染单条记录HTML
* @param array $record 记录数据
* @param array $options 显示选项
* @return string
*/
public static function renderItem(array $record, array $options = []): string
{
$options = array_merge(static::$defaultOptions, RecorderService::getConfig('view', []), $options);
if (empty($record)) {
return '';
}
// 处理数据格式化
$record = static::formatRecord($record, $options);
// 选择渲染模板
$template = static::getTemplate('item', $options);
return static::render($template, [
'record' => $record,
'options' => $options,
]);
}
/**
* 获取操作统计数据
* @param array $conditions 查询条件
* @return array
*/
public static function getStats(array $conditions = []): array
{
try {
return RecorderService::getStats($conditions);
} catch (\Exception $e) {
trace("获取操作统计失败: " . $e->getMessage(), 'error');
return [];
}
}
/**
* 获取数据的读取用户列表
* @param string $dataType 数据类型
* @param string $dataId 数据ID
* @param array $conditions 额外条件
* @return array
*/
public static function getDataReaders(string $dataType, string $dataId, array $conditions = []): array
{
try {
return RecorderService::getDataReaders($dataType, $dataId, $conditions);
} catch (\Exception $e) {
trace("获取数据读取用户失败: " . $e->getMessage(), 'error');
return [];
}
}
/**
* 获取数据的操作用户列表(排除读取)
* @param string $dataType 数据类型
* @param string $dataId 数据ID
* @param array $conditions 额外条件
* @return array
*/
public static function getDataOperators(string $dataType, string $dataId, array $conditions = []): array
{
try {
return RecorderService::getDataOperators($dataType, $dataId, $conditions);
} catch (\Exception $e) {
trace("获取数据操作用户失败: " . $e->getMessage(), 'error');
return [];
}
}
/**
* 渲染数据读取用户列表
* @param string $dataType 数据类型
* @param string $dataId 数据ID
* @param array $conditions 查询条件
* @param array $options 显示选项
* @return string
*/
public static function renderDataReaders(string $dataType, string $dataId, array $conditions = [], array $options = []): string
{
$options = array_merge(static::$defaultOptions, RecorderService::getConfig('view', []), $options);
// 获取读取用户数据
$readers = static::getDataReaders($dataType, $dataId, $conditions);
if (empty($readers)) {
return static::renderEmpty($options, '暂无用户读取过此数据');
}
// 选择渲染模板
$template = static::getTemplate('readers', $options);
return static::render($template, [
'readers' => $readers,
'data_type' => $dataType,
'data_id' => $dataId,
'options' => $options,
]);
}
/**
* 渲染数据操作用户列表
* @param string $dataType 数据类型
* @param string $dataId 数据ID
* @param array $conditions 查询条件
* @param array $options 显示选项
* @return string
*/
public static function renderDataOperators(string $dataType, string $dataId, array $conditions = [], array $options = []): string
{
$options = array_merge(static::$defaultOptions, RecorderService::getConfig('view', []), $options);
// 获取操作用户数据
$operators = static::getDataOperators($dataType, $dataId, $conditions);
if (empty($operators)) {
return static::renderEmpty($options, '暂无用户操作过此数据');
}
// 选择渲染模板
$template = static::getTemplate('operators', $options);
return static::render($template, [
'operators' => $operators,
'data_type' => $dataType,
'data_id' => $dataId,
'options' => $options,
]);
}
/**
* 注册模板函数
*/
public static function registerTemplateFunctions(): void
{
// 注册获取记录数据函数
if (!function_exists('recorder_get_records')) {
function recorder_get_records(array $conditions = []): array {
return ViewHelper::getRecords($conditions);
}
}
// 注册渲染列表函数
if (!function_exists('recorder_render_list')) {
function recorder_render_list(array $conditions = [], array $options = []): string {
return ViewHelper::renderList($conditions, $options);
}
}
// 注册渲染时间线函数
if (!function_exists('recorder_render_timeline')) {
function recorder_render_timeline(array $conditions = [], array $options = []): string {
return ViewHelper::renderTimeline($conditions, $options);
}
}
// 注册渲染单条记录函数
if (!function_exists('recorder_render_item')) {
function recorder_render_item(array $record, array $options = []): string {
return ViewHelper::renderItem($record, $options);
}
}
// 注册获取统计数据函数
if (!function_exists('recorder_get_stats')) {
function recorder_get_stats(array $conditions = []): array {
return ViewHelper::getStats($conditions);
}
}
}
/**
* 格式化记录数组
* @param array $records
* @param array $options
* @return array
*/
protected static function formatRecords(array $records, array $options): array
{
return array_map(function($record) use ($options) {
return static::formatRecord($record, $options);
}, $records);
}
/**
* 格式化单条记录
* @param array $record
* @param array $options
* @return array
*/
protected static function formatRecord(array $record, array $options): array
{
// 格式化时间
if (!empty($record['created_at'])) {
$record['created_at_formatted'] = date($options['date_format'], strtotime($record['created_at']));
$record['created_at_relative'] = static::getRelativeTime($record['created_at']);
}
// 格式化操作类型标签
$record['operation_type_label'] = static::getOperationTypeLabel($record['operation_type'] ?? '');
// 格式化用户信息
$record['user_info'] = static::formatUserInfo($record, $options);
// 格式化数据信息
$record['data_info'] = static::formatDataInfo($record);
// 格式化关联信息
$record['related_info'] = static::formatRelatedInfo($record);
return $record;
}
/**
* 获取操作类型标签
* @param string $operationType
* @return array
*/
protected static function getOperationTypeLabel(string $operationType): array
{
$typeLabels = [
'创建' => ['class' => 'success', 'text' => '创建', 'icon' => 'plus'],
'读取' => ['class' => 'info', 'text' => '读取', 'icon' => 'eye'],
'更新' => ['class' => 'warning', 'text' => '更新', 'icon' => 'edit'],
'删除' => ['class' => 'danger', 'text' => '删除', 'icon' => 'trash'],
'导出' => ['class' => 'primary', 'text' => '导出', 'icon' => 'download'],
'登录' => ['class' => 'success', 'text' => '登录', 'icon' => 'user'],
'登出' => ['class' => 'default', 'text' => '登出', 'icon' => 'user-times'],
'审核' => ['class' => 'warning', 'text' => '审核', 'icon' => 'check'],
'发布' => ['class' => 'primary', 'text' => '发布', 'icon' => 'share'],
];
return $typeLabels[$operationType] ?? ['class' => 'default', 'text' => $operationType, 'icon' => 'circle'];
}
/**
* 格式化用户信息
* @param array $record
* @param array $options
* @return string
*/
protected static function formatUserInfo(array $record, array $options): string
{
if (!$options['show_user']) {
return '';
}
$nickname = $record['user_nickname'] ?? '';
$userId = $record['user_id'] ?? 0;
if (!empty($nickname)) {
return $nickname . " (ID:{$userId})";
}
return "用户ID:{$userId}";
}
/**
* 格式化数据信息
* @param array $record
* @return string
*/
protected static function formatDataInfo(array $record): string
{
$dataType = $record['data_type'] ?? '';
$dataId = $record['data_id'] ?? '';
if (!empty($dataType) && !empty($dataId)) {
return "{$dataType}:{$dataId}";
} elseif (!empty($dataType)) {
return $dataType;
}
return '';
}
/**
* 格式化关联信息
* @param array $record
* @return string
*/
protected static function formatRelatedInfo(array $record): string
{
$relatedType = $record['related_type'] ?? '';
$relatedId = $record['related_id'] ?? '';
if (!empty($relatedType) && !empty($relatedId)) {
return "{$relatedType}:{$relatedId}";
} elseif (!empty($relatedType)) {
return $relatedType;
}
return '';
}
/**
* 获取相对时间
* @param string $datetime
* @return string
*/
protected static function getRelativeTime(string $datetime): string
{
$time = strtotime($datetime);
$now = time();
$diff = $now - $time;
if ($diff < 60) {
return '刚刚';
} elseif ($diff < 3600) {
return intval($diff / 60) . '分钟前';
} elseif ($diff < 86400) {
return intval($diff / 3600) . '小时前';
} elseif ($diff < 604800) {
return intval($diff / 86400) . '天前';
} else {
return date('Y-m-d', $time);
}
}
/**
* 获取模板路径
* @param string $type 模板类型
* @param array $options 选项
* @return string
*/
protected static function getTemplate(string $type, array $options): string
{
// 如果指定了自定义模板,优先使用
if (!empty($options['template'])) {
return $options['template'];
}
// 根据主题和类型选择模板
$theme = $options['theme'] ?? 'default';
$templateMap = [
'list' => "recorder/components/record_list_{$theme}",
'timeline' => "recorder/components/record_timeline",
'item' => "recorder/components/record_item_{$theme}",
'readers' => "recorder/components/data_readers",
'operators' => "recorder/components/data_operators",
];
return $templateMap[$type] ?? "recorder/components/record_{$type}";
}
/**
* 渲染模板
* @param string $template 模板路径
* @param array $vars 模板变量
* @return string
*/
protected static function render(string $template, array $vars): string
{
try {
// 尝试使用ThinkPHP的视图渲染
if (class_exists('\\think\\facade\\View')) {
return View::fetch($template, $vars);
}
} catch (\Exception $e) {
// 视图渲染失败,使用内置HTML渲染
trace("视图渲染失败,使用内置渲染: " . $e->getMessage(), 'notice');
}
// 内置HTML渲染
return static::renderBuiltinHtml($template, $vars);
}
/**
* 内置HTML渲染
* @param string $template
* @param array $vars
* @return string
*/
protected static function renderBuiltinHtml(string $template, array $vars): string
{
$records = $vars['records'] ?? [];
$readers = $vars['readers'] ?? [];
$operators = $vars['operators'] ?? [];
$options = $vars['options'] ?? [];
$record = $vars['record'] ?? null;
if (strpos($template, 'readers') !== false) {
return static::renderReadersHtml($readers, $options);
} elseif (strpos($template, 'operators') !== false) {
return static::renderOperatorsHtml($operators, $options);
} elseif (strpos($template, 'timeline') !== false) {
return static::renderTimelineHtml($records, $options);
} elseif (strpos($template, 'item') !== false && $record) {
return static::renderItemHtml($record, $options);
} else {
return static::renderListHtml($records, $options);
}
}
/**
* 渲染列表HTML
* @param array $records
* @param array $options
* @return string
*/
protected static function renderListHtml(array $records, array $options): string
{
$cssClass = 'recorder-list ' . ($options['css_class'] ?? '');
$compactClass = $options['compact'] ? ' recorder-list--compact' : '';
$html = "<div class=\"{$cssClass}{$compactClass}\">";
foreach ($records as $record) {
$html .= static::renderItemHtml($record, $options);
}
$html .= '</div>';
return $html;
}
/**
* 渲染时间线HTML
* @param array $records
* @param array $options
* @return string
*/
protected static function renderTimelineHtml(array $records, array $options): string
{
$cssClass = 'recorder-timeline ' . ($options['css_class'] ?? '');
$html = "<div class=\"{$cssClass}\">";
foreach ($records as $record) {
$typeLabel = $record['operation_type_label'];
$html .= '<div class="recorder-timeline__item">';
$html .= "<div class=\"recorder-timeline__marker recorder-timeline__marker--{$typeLabel['class']}\"></div>";
$html .= '<div class="recorder-timeline__content">';
$html .= "<div class=\"recorder-timeline__header\">";
$html .= "<span class=\"recorder-timeline__type\">{$typeLabel['text']}</span>";
$html .= "<span class=\"recorder-timeline__time\">{$record['created_at_relative']}</span>";
$html .= "</div>";
$html .= "<div class=\"recorder-timeline__desc\">{$record['operation_desc']}</div>";
if ($options['show_user'] && !empty($record['user_info'])) {
$html .= "<div class=\"recorder-timeline__user\">{$record['user_info']}</div>";
}
$html .= '</div>';
$html .= '</div>';
}
$html .= '</div>';
return $html;
}
/**
* 渲染单项HTML
* @param array $record
* @param array $options
* @return string
*/
protected static function renderItemHtml(array $record, array $options): string
{
$typeLabel = $record['operation_type_label'];
$cssClass = 'recorder-item';
if ($options['theme'] === 'card') {
$cssClass .= ' recorder-item--card';
}
$html = "<div class=\"{$cssClass}\">";
$html .= "<div class=\"recorder-item__header\">";
$html .= "<span class=\"recorder-item__type recorder-item__type--{$typeLabel['class']}\">{$typeLabel['text']}</span>";
$html .= "<span class=\"recorder-item__time\">{$record['created_at_formatted']}</span>";
$html .= "</div>";
$html .= "<div class=\"recorder-item__content\">";
$html .= "<div class=\"recorder-item__desc\">{$record['operation_desc']}</div>";
if ($options['show_user'] && !empty($record['user_info'])) {
$html .= "<div class=\"recorder-item__user\">操作人:{$record['user_info']}</div>";
}
if (!empty($record['data_info'])) {
$html .= "<div class=\"recorder-item__data\">数据:{$record['data_info']}</div>";
}
if ($options['show_ip'] && !empty($record['request_ip'])) {
$html .= "<div class=\"recorder-item__ip\">IP:{$record['request_ip']}</div>";
}
$html .= "</div>";
$html .= "</div>";
return $html;
}
/**
* 渲染读取用户HTML
* @param array $readers
* @param array $options
* @return string
*/
protected static function renderReadersHtml(array $readers, array $options): string
{
$cssClass = 'recorder-readers ' . ($options['css_class'] ?? '');
$html = "<div class=\"{$cssClass}\">";
if (empty($readers)) {
$html .= '<div class="recorder-readers__empty">暂无用户读取过此数据</div>';
} else {
$html .= '<div class="recorder-readers__header">';
$html .= '<h5>读取用户 (' . count($readers) . ')</h5>';
$html .= '</div>';
$html .= '<div class="recorder-readers__list">';
foreach ($readers as $reader) {
$html .= '<div class="recorder-reader">';
$html .= '<div class="recorder-reader__avatar">';
$html .= '<i class="layui-icon layui-icon-username"></i>';
$html .= '</div>';
$html .= '<div class="recorder-reader__info">';
$html .= '<div class="recorder-reader__name">' . $reader['user_nickname'] . '</div>';
$html .= '<div class="recorder-reader__details">';
$html .= '<span class="recorder-reader__count">读取 ' . $reader['read_count'] . ' 次</span>';
if ($reader['first_read_at'] !== $reader['last_read_at']) {
$html .= '<span class="recorder-reader__time">最后: ' . $reader['last_read_at_relative'] . '</span>';
} else {
$html .= '<span class="recorder-reader__time">' . $reader['first_read_at_relative'] . '</span>';
}
$html .= '</div>';
$html .= '</div>';
$html .= '</div>';
}
$html .= '</div>';
}
$html .= '</div>';
// 添加CSS样式
$html .= '<style>
.recorder-readers {
background: #fff;
border: 1px solid #e6e6e6;
border-radius: 6px;
padding: 15px;
}
.recorder-readers__header h5 {
margin: 0 0 15px 0;
color: #333;
font-size: 14px;
font-weight: 500;
}
.recorder-readers__list {
display: flex;
flex-direction: column;
gap: 12px;
}
.recorder-reader {
display: flex;
align-items: center;
padding: 10px;
background: #fafafa;
border-radius: 4px;
border-left: 3px solid #1890ff;
}
.recorder-reader__avatar {
width: 32px;
height: 32px;
background: #1890ff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
color: #fff;
}
.recorder-reader__avatar .layui-icon {
font-size: 16px;
}
.recorder-reader__info {
flex: 1;
}
.recorder-reader__name {
font-size: 13px;
font-weight: 500;
color: #333;
margin-bottom: 4px;
}
.recorder-reader__details {
display: flex;
gap: 12px;
font-size: 12px;
color: #666;
}
.recorder-readers__empty {
text-align: center;
padding: 40px 0;
color: #999;
font-size: 13px;
}
</style>';
return $html;
}
/**
* 渲染操作用户HTML
* @param array $operators
* @param array $options
* @return string
*/
protected static function renderOperatorsHtml(array $operators, array $options): string
{
$cssClass = 'recorder-operators ' . ($options['css_class'] ?? '');
$html = "<div class=\"{$cssClass}\">";
if (empty($operators)) {
$html .= '<div class="recorder-operators__empty">暂无用户操作过此数据</div>';
} else {
$html .= '<div class="recorder-operators__header">';
$html .= '<h5>操作用户 (' . count($operators) . ')</h5>';
$html .= '</div>';
$html .= '<div class="recorder-operators__list">';
foreach ($operators as $operator) {
$html .= '<div class="recorder-operator">';
$html .= '<div class="recorder-operator__avatar">';
$html .= '<i class="layui-icon layui-icon-user"></i>';
$html .= '</div>';
$html .= '<div class="recorder-operator__info">';
$html .= '<div class="recorder-operator__name">' . $operator['user_nickname'] . '</div>';
$html .= '<div class="recorder-operator__summary">' . $operator['operation_summary'] . '</div>';
$html .= '<div class="recorder-operator__details">';
$html .= '<span class="recorder-operator__count">操作 ' . $operator['total_operations'] . ' 次</span>';
$html .= '<span class="recorder-operator__time">最后: ' . $operator['last_operation_at_relative'] . '</span>';
$html .= '</div>';
$html .= '</div>';
$html .= '</div>';
}
$html .= '</div>';
}
$html .= '</div>';
// 添加CSS样式
$html .= '<style>
.recorder-operators {
background: #fff;
border: 1px solid #e6e6e6;
border-radius: 6px;
padding: 15px;
}
.recorder-operators__header h5 {
margin: 0 0 15px 0;
color: #333;
font-size: 14px;
font-weight: 500;
}
.recorder-operators__list {
display: flex;
flex-direction: column;
gap: 12px;
}
.recorder-operator {
display: flex;
align-items: center;
padding: 12px;
background: #fafafa;
border-radius: 4px;
border-left: 3px solid #52c41a;
}
.recorder-operator__avatar {
width: 36px;
height: 36px;
background: #52c41a;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
color: #fff;
}
.recorder-operator__avatar .layui-icon {
font-size: 18px;
}
.recorder-operator__info {
flex: 1;
}
.recorder-operator__name {
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 4px;
}
.recorder-operator__summary {
font-size: 12px;
color: #1890ff;
margin-bottom: 4px;
}
.recorder-operator__details {
display: flex;
gap: 12px;
font-size: 12px;
color: #666;
}
.recorder-operators__empty {
text-align: center;
padding: 40px 0;
color: #999;
font-size: 13px;
}
</style>';
return $html;
}
/**
* 渲染空状态HTML
* @param array $options
* @param string $message
* @return string
*/
protected static function renderEmpty(array $options, string $message = '暂无操作记录'): string
{
return "<div class=\"recorder-empty\">{$message}</div>";
}
}

View File

@@ -0,0 +1,695 @@
<?php
namespace jerryyan\recorder\middleware;
use think\Request;
use think\Response;
use jerryyan\recorder\service\RecorderService;
/**
* 操作记录中间件
* Class RecorderMiddleware
* @package jerryyan\recorder\middleware
*/
class RecorderMiddleware
{
/**
* 需要排除记录的路由
* @var array
*/
protected $excludeRoutes = [
'admin/login',
'admin/logout',
'admin/captcha',
'admin/index/main',
'admin/config',
];
/**
* 需要排除记录的请求方法
* @var array
*/
protected $excludeMethods = [
'OPTIONS',
'HEAD',
];
/**
* 中间件处理
* @param Request $request
* @param \Closure $next
* @return Response
*/
public function handle(Request $request, \Closure $next): Response
{
// 获取响应
$response = $next($request);
// 在响应后记录操作
$this->recordOperation($request, $response);
return $response;
}
/**
* 记录操作
* @param Request $request
* @param Response $response
*/
protected function recordOperation(Request $request, Response $response): void
{
try {
// 检查是否启用自动记录
$config = RecorderService::getConfig('auto_record', []);
if (!($config['enabled'] ?? false)) {
return;
}
// 检查是否需要排除此请求
if ($this->shouldExclude($request, $config)) {
return;
}
// 只记录成功的请求
$statusCode = $response->getCode();
if ($statusCode >= 400) {
return;
}
// 获取操作信息
$operationInfo = $this->getOperationInfo($request, $response);
if (!empty($operationInfo)) {
RecorderService::autoRecord($operationInfo);
}
} catch (\Exception $e) {
// 中间件记录失败不应影响主要业务
trace("中间件自动记录失败: " . $e->getMessage(), 'error');
}
}
/**
* 检查是否应该排除此请求
* @param Request $request
* @param array $config
* @return bool
*/
protected function shouldExclude(Request $request, array $config): bool
{
$method = $request->method();
$route = $request->pathinfo();
$controller = $request->controller();
$action = $request->action();
// 排除特定的请求方法
if (in_array($method, $this->excludeMethods)) {
return true;
}
// 排除特定的路由
if (in_array($route, $this->excludeRoutes)) {
return true;
}
// 排除配置中指定的控制器
$excludeControllers = $config['exclude_controllers'] ?? [];
$currentController = strtolower($controller);
foreach ($excludeControllers as $excludeController) {
if (strpos($currentController, strtolower($excludeController)) !== false) {
return true;
}
}
// 排除读取操作(如果配置了排除读取)
$excludeOperations = $config['exclude_operations'] ?? [];
if (in_array('读取', $excludeOperations) && $method === 'GET') {
return true;
}
// 排除静态资源请求
if ($this->isStaticResource($request)) {
return true;
}
return false;
}
/**
* 获取操作信息
* @param Request $request
* @param Response $response
* @return array
*/
protected function getOperationInfo(Request $request, Response $response): array
{
$method = $request->method();
$controller = $request->controller();
$action = $request->action();
$route = $request->pathinfo();
// 根据请求方法和路由确定操作类型
$operationType = $this->determineOperationType($method, $route, $request);
if (empty($operationType)) {
return [];
}
// 生成操作描述
$operationDesc = $this->generateOperationDescription($operationType, $controller, $action, $request);
// 尝试获取操作的数据信息
$dataInfo = $this->extractDataInfo($request, $response);
return [
'operation_type' => $operationType,
'operation_desc' => $operationDesc,
'data_type' => $dataInfo['data_type'] ?? '',
'data_id' => $dataInfo['data_id'] ?? '',
'related_type' => $dataInfo['related_type'] ?? '',
'related_id' => $dataInfo['related_id'] ?? '',
'extra_data' => $this->getExtraData($request, $response),
];
}
/**
* 确定操作类型
* @param string $method
* @param string $route
* @param Request $request
* @return string
*/
protected function determineOperationType(string $method, string $route, Request $request): string
{
// 优先从控制器方法注释获取操作类型
$operationTypeFromComment = $this->getOperationTypeFromComment($request);
if (!empty($operationTypeFromComment)) {
return $operationTypeFromComment;
}
// 根据HTTP方法映射操作类型
$methodMap = [
'POST' => '创建',
'PUT' => '更新',
'PATCH' => '更新',
'DELETE' => '删除',
'GET' => '读取',
];
$baseType = $methodMap[$method] ?? '';
// 根据路由特征进一步判断
if (strpos($route, 'export') !== false) {
return '导出';
}
if (strpos($route, 'import') !== false) {
return '导入';
}
if (strpos($route, 'upload') !== false) {
return '上传';
}
if (strpos($route, 'download') !== false) {
return '下载';
}
if (strpos($route, 'login') !== false) {
return '登录';
}
if (strpos($route, 'logout') !== false) {
return '登出';
}
if (strpos($route, 'audit') !== false || strpos($route, 'approve') !== false) {
return '审核';
}
if (strpos($route, 'publish') !== false) {
return '发布';
}
// 检查POST请求的特定动作
if ($method === 'POST') {
$action = $request->param('action', '');
if (!empty($action)) {
$actionMap = [
'save' => '保存',
'update' => '更新',
'delete' => '删除',
'remove' => '删除',
'enable' => '启用',
'disable' => '禁用',
'sort' => '排序',
'move' => '移动',
];
if (isset($actionMap[$action])) {
return $actionMap[$action];
}
}
}
return $baseType;
}
/**
* 从控制器方法注释中获取操作类型
* @param Request $request
* @return string
*/
protected function getOperationTypeFromComment(Request $request): string
{
try {
$controller = $request->controller();
$action = $request->action();
if (empty($controller) || empty($action)) {
return '';
}
// 构造控制器类名
$controllerClass = $this->buildControllerClassName($controller);
if (!class_exists($controllerClass)) {
return '';
}
// 获取方法的反射对象
$reflection = new \ReflectionClass($controllerClass);
if (!$reflection->hasMethod($action)) {
return '';
}
$method = $reflection->getMethod($action);
$docComment = $method->getDocComment();
if (empty($docComment)) {
return '';
}
// 解析注释中的操作类型
return $this->parseOperationTypeFromDocComment($docComment);
} catch (\Exception $e) {
trace("获取方法注释失败: " . $e->getMessage(), 'error');
return '';
}
}
/**
* 构建控制器类名
* @param string $controller
* @return string
*/
protected function buildControllerClassName(string $controller): string
{
// 处理不同的控制器命名方式
// 例如: admin/user -> app\admin\controller\UserController
$parts = explode('/', $controller);
if (count($parts) >= 2) {
// 多层控制器: admin/user
$app = $parts[0];
$controllerName = ucfirst($parts[1]);
return "app\\{$app}\\controller\\{$controllerName}Controller";
} else {
// 单层控制器: User
$controllerName = ucfirst($controller);
return "app\\controller\\{$controllerName}Controller";
}
}
/**
* 从文档注释中解析操作类型
* @param string $docComment
* @return string
*/
protected function parseOperationTypeFromDocComment(string $docComment): string
{
// 支持多种注释格式
$patterns = [
// @operation 创建用户
'/@operation\s+(.+?)(?:\n|\r\n|\r|$)/i',
// @action 创建用户
'/@action\s+(.+?)(?:\n|\r\n|\r|$)/i',
// @title 创建用户
'/@title\s+(.+?)(?:\n|\r\n|\r|$)/i',
// @recorder 创建
'/@recorder\s+(.+?)(?:\n|\r\n|\r|$)/i',
// @record_type 创建
'/@record_type\s+(.+?)(?:\n|\r\n|\r|$)/i',
// @操作类型 创建
'/@操作类型\s+(.+?)(?:\n|\r\n|\r|$)/i',
];
foreach ($patterns as $pattern) {
if (preg_match($pattern, $docComment, $matches)) {
$operationType = trim($matches[1]);
// 去除可能的描述,只保留操作类型
$operationType = $this->extractOperationType($operationType);
if (!empty($operationType)) {
return $operationType;
}
}
}
// 如果没有找到特定的操作类型注解,尝试从第一行注释中提取
if (preg_match('/\/\*\*\s*\n\s*\*\s*(.+?)(?:\n|\r\n|\r)/i', $docComment, $matches)) {
$firstLine = trim($matches[1]);
$operationType = $this->extractOperationType($firstLine);
if (!empty($operationType)) {
return $operationType;
}
}
return '';
}
/**
* 从文本中提取操作类型
* @param string $text
* @return string
*/
protected function extractOperationType(string $text): string
{
// 定义常见的操作类型关键词
$operationTypes = [
'创建', '新增', '添加', '新建',
'更新', '修改', '编辑', '保存',
'删除', '移除', '清除',
'查看', '读取', '获取', '查询', '列表',
'导出', '导入', '上传', '下载',
'登录', '登出', '注销',
'审核', '审批', '通过', '拒绝',
'发布', '取消发布',
'启用', '禁用', '开启', '关闭',
'排序', '移动', '复制',
'搜索', '检索', '筛选',
'统计', '分析', '报表'
];
// 去除常见的前缀词
$text = preg_replace('/^(执行|进行|处理|操作)\s*/', '', $text);
// 查找匹配的操作类型
foreach ($operationTypes as $type) {
if (strpos($text, $type) === 0) {
return $type;
}
}
// 如果文本很短且是中文,可能就是操作类型
if (mb_strlen($text) <= 4 && preg_match('/^[\x{4e00}-\x{9fa5}]+$/u', $text)) {
return $text;
}
return '';
}
/**
* 生成操作描述
* @param string $operationType
* @param string $controller
* @param string $action
* @param Request $request
* @return string
*/
protected function generateOperationDescription(string $operationType, string $controller, string $action, Request $request): string
{
// 优先从控制器方法注释获取完整的操作描述
$descriptionFromComment = $this->getOperationDescriptionFromComment($request);
if (!empty($descriptionFromComment)) {
return $descriptionFromComment;
}
// 控制器名称映射
$controllerNames = [
'user' => '用户',
'admin' => '管理员',
'role' => '角色',
'menu' => '菜单',
'config' => '配置',
'log' => '日志',
'file' => '文件',
'upload' => '上传',
'system' => '系统',
];
$controllerName = '';
foreach ($controllerNames as $key => $name) {
if (stripos($controller, $key) !== false) {
$controllerName = $name;
break;
}
}
// 如果没有匹配到控制器名称,使用原始控制器名
if (empty($controllerName)) {
$controllerName = $controller;
}
// 动作名称映射
$actionNames = [
'index' => '列表',
'add' => '添加页面',
'edit' => '编辑页面',
'save' => '保存',
'delete' => '删除',
'remove' => '移除',
'view' => '查看',
'detail' => '详情',
];
$actionName = $actionNames[$action] ?? $action;
// 生成描述
if ($operationType === '读取' && $action === 'index') {
return "查看{$controllerName}列表";
} elseif ($operationType === '读取') {
return "查看{$controllerName}{$actionName}";
} else {
return "{$operationType}{$controllerName}";
}
}
/**
* 从控制器方法注释中获取操作描述
* @param Request $request
* @return string
*/
protected function getOperationDescriptionFromComment(Request $request): string
{
try {
$controller = $request->controller();
$action = $request->action();
if (empty($controller) || empty($action)) {
return '';
}
// 构造控制器类名
$controllerClass = $this->buildControllerClassName($controller);
if (!class_exists($controllerClass)) {
return '';
}
// 获取方法的反射对象
$reflection = new \ReflectionClass($controllerClass);
if (!$reflection->hasMethod($action)) {
return '';
}
$method = $reflection->getMethod($action);
$docComment = $method->getDocComment();
if (empty($docComment)) {
return '';
}
// 解析注释中的操作描述
return $this->parseOperationDescriptionFromDocComment($docComment);
} catch (\Exception $e) {
trace("获取方法描述失败: " . $e->getMessage(), 'error');
return '';
}
}
/**
* 从文档注释中解析操作描述
* @param string $docComment
* @return string
*/
protected function parseOperationDescriptionFromDocComment(string $docComment): string
{
// 支持多种注释格式获取操作描述
$patterns = [
// @operation 创建用户
'/@operation\s+(.+?)(?:\n|\r\n|\r|$)/i',
// @action 创建用户
'/@action\s+(.+?)(?:\n|\r\n|\r|$)/i',
// @title 创建用户
'/@title\s+(.+?)(?:\n|\r\n|\r|$)/i',
// @recorder 创建用户
'/@recorder\s+(.+?)(?:\n|\r\n|\r|$)/i',
// @desc 创建用户
'/@desc\s+(.+?)(?:\n|\r\n|\r|$)/i',
// @description 创建用户
'/@description\s+(.+?)(?:\n|\r\n|\r|$)/i',
// @操作描述 创建用户
'/@操作描述\s+(.+?)(?:\n|\r\n|\r|$)/i',
];
foreach ($patterns as $pattern) {
if (preg_match($pattern, $docComment, $matches)) {
$description = trim($matches[1]);
if (!empty($description) && mb_strlen($description) <= 100) {
return $description;
}
}
}
// 如果没有找到特定的注解,尝试从第一行注释中获取描述
if (preg_match('/\/\*\*\s*\n\s*\*\s*(.+?)(?:\n|\r\n|\r)/i', $docComment, $matches)) {
$firstLine = trim($matches[1]);
// 去除常见的无意义词汇
$firstLine = preg_replace('/^(方法|函数|接口|API|处理|执行)\s*[::]?\s*/', '', $firstLine);
if (!empty($firstLine) && mb_strlen($firstLine) <= 100) {
return $firstLine;
}
}
return '';
}
/**
* 提取数据信息
* @param Request $request
* @param Response $response
* @return array
*/
protected function extractDataInfo(Request $request, Response $response): array
{
$dataInfo = [
'data_type' => '',
'data_id' => '',
'related_type' => '',
'related_id' => '',
];
// 从控制器推断数据类型
$controller = $request->controller();
$dataInfo['data_type'] = strtolower(str_replace('Controller', '', basename($controller)));
// 尝试从请求参数获取ID
$id = $request->param('id', '');
if (empty($id)) {
$id = $request->param('ids', '');
}
if (!empty($id)) {
$dataInfo['data_id'] = is_array($id) ? implode(',', $id) : (string)$id;
}
// 尝试从响应中获取数据ID(适用于创建操作)
if (empty($dataInfo['data_id']) && $request->method() === 'POST') {
$responseData = $this->parseResponseData($response);
if (isset($responseData['id'])) {
$dataInfo['data_id'] = (string)$responseData['id'];
} elseif (isset($responseData['data']['id'])) {
$dataInfo['data_id'] = (string)$responseData['data']['id'];
}
}
// 尝试获取关联数据信息
$parentId = $request->param('parent_id', $request->param('pid', ''));
if (!empty($parentId)) {
$dataInfo['related_type'] = $dataInfo['data_type'];
$dataInfo['related_id'] = (string)$parentId;
}
return $dataInfo;
}
/**
* 获取额外数据
* @param Request $request
* @param Response $response
* @return array
*/
protected function getExtraData(Request $request, Response $response): array
{
$extra = [];
// 记录请求参数(排除敏感信息)
$params = $request->param();
$sensitiveKeys = ['password', 'pwd', 'token', 'key', 'secret'];
foreach ($params as $key => $value) {
$lowerKey = strtolower($key);
$isSensitive = false;
foreach ($sensitiveKeys as $sensitiveKey) {
if (strpos($lowerKey, $sensitiveKey) !== false) {
$isSensitive = true;
break;
}
}
if (!$isSensitive) {
// 限制参数值长度,避免存储过大的数据
if (is_string($value) && strlen($value) > 200) {
$value = substr($value, 0, 200) . '...';
}
$extra['params'][$key] = $value;
}
}
// 记录响应状态码
$extra['response_code'] = $response->getCode();
return $extra;
}
/**
* 解析响应数据
* @param Response $response
* @return array
*/
protected function parseResponseData(Response $response): array
{
try {
$content = $response->getContent();
if (is_string($content)) {
$data = json_decode($content, true);
return is_array($data) ? $data : [];
}
} catch (\Exception $e) {
// 解析失败,返回空数组
}
return [];
}
/**
* 检查是否为静态资源请求
* @param Request $request
* @return bool
*/
protected function isStaticResource(Request $request): bool
{
$uri = $request->pathinfo();
$staticExtensions = ['css', 'js', 'jpg', 'jpeg', 'png', 'gif', 'ico', 'svg', 'woff', 'woff2', 'ttf', 'eot'];
$extension = strtolower(pathinfo($uri, PATHINFO_EXTENSION));
return in_array($extension, $staticExtensions) || strpos($uri, '/static/') === 0;
}
}

263
src/model/RecorderLog.php Normal file
View File

@@ -0,0 +1,263 @@
<?php
namespace jerryyan\recorder\model;
use think\admin\Model;
/**
* 操作记录模型
* Class RecorderLog
* @package jerryyan\recorder\model
*/
class RecorderLog extends Model
{
/**
* 自动时间戳
* @var bool
*/
protected $autoWriteTimestamp = true;
/**
* 创建时间字段
* @var string
*/
protected $createTime = 'created_at';
/**
* 更新时间字段
* @var string
*/
protected $updateTime = 'updated_at';
/**
* 字段类型转换
* @var array
*/
protected $type = [
'id' => 'integer',
'user_id' => 'integer',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
/**
* JSON字段
* @var array
*/
protected $json = ['extra_data'];
/**
* 只读字段
* @var array
*/
protected $readonly = ['id', 'created_at'];
/**
* 字段验证规则
* @return array
*/
protected function getRules(): array
{
return [
'operation_type|操作类型' => 'require|max:50',
'operation_desc|操作说明' => 'require|max:500',
'user_id|用户ID' => 'require|integer|egt:0',
'user_nickname|用户昵称' => 'max:100',
'data_type|数据类型' => 'max:100',
'data_id|数据ID' => 'max:100',
'related_type|关联类型' => 'max:100',
'related_id|关联ID' => 'max:100',
'request_method|请求方法' => 'max:10',
'request_url|请求URL' => 'max:500',
'request_ip|请求IP' => 'max:50',
'user_agent|用户代理' => 'max:500',
];
}
/**
* 获取额外数据
* @param $value
* @return array
*/
public function getExtraDataAttr($value): array
{
if (is_string($value)) {
$decoded = json_decode($value, true);
return is_array($decoded) ? $decoded : [];
}
return is_array($value) ? $value : [];
}
/**
* 设置额外数据
* @param $value
* @return string
*/
public function setExtraDataAttr($value): string
{
return is_array($value) ? json_encode($value, JSON_UNESCAPED_UNICODE) : (string)$value;
}
/**
* 获取操作类型标签样式
* @param $value
* @param $data
* @return array
*/
public function getOperationTypeLabelAttr($value, $data): array
{
$typeLabels = [
'创建' => ['class' => 'success', 'text' => '创建'],
'读取' => ['class' => 'info', 'text' => '读取'],
'更新' => ['class' => 'warning', 'text' => '更新'],
'删除' => ['class' => 'danger', 'text' => '删除'],
'导出' => ['class' => 'primary', 'text' => '导出'],
'登录' => ['class' => 'success', 'text' => '登录'],
'登出' => ['class' => 'default', 'text' => '登出'],
'审核' => ['class' => 'warning', 'text' => '审核'],
'发布' => ['class' => 'primary', 'text' => '发布'],
];
$operationType = $data['operation_type'] ?? '';
return $typeLabels[$operationType] ?? ['class' => 'default', 'text' => $operationType];
}
/**
* 获取格式化创建时间
* @param $value
* @param $data
* @return string
*/
public function getCreatedAtTextAttr($value, $data): string
{
if (!empty($data['created_at'])) {
return date('Y-m-d H:i:s', strtotime($data['created_at']));
}
return '';
}
/**
* 获取用户简短信息
* @param $value
* @param $data
* @return string
*/
public function getUserInfoAttr($value, $data): string
{
$nickname = $data['user_nickname'] ?? '';
$userId = $data['user_id'] ?? 0;
if (!empty($nickname)) {
return $nickname . " (ID:{$userId})";
}
return "用户ID:{$userId}";
}
/**
* 获取数据信息
* @param $value
* @param $data
* @return string
*/
public function getDataInfoAttr($value, $data): string
{
$dataType = $data['data_type'] ?? '';
$dataId = $data['data_id'] ?? '';
if (!empty($dataType) && !empty($dataId)) {
return "{$dataType}:{$dataId}";
} elseif (!empty($dataType)) {
return $dataType;
}
return '';
}
/**
* 获取关联数据信息
* @param $value
* @param $data
* @return string
*/
public function getRelatedInfoAttr($value, $data): string
{
$relatedType = $data['related_type'] ?? '';
$relatedId = $data['related_id'] ?? '';
if (!empty($relatedType) && !empty($relatedId)) {
return "{$relatedType}:{$relatedId}";
} elseif (!empty($relatedType)) {
return $relatedType;
}
return '';
}
/**
* 作用域查询 - 按用户ID
* @param $query
* @param $userId
* @return mixed
*/
public function scopeByUser($query, $userId)
{
return $query->where('user_id', $userId);
}
/**
* 作用域查询 - 按操作类型
* @param $query
* @param $operationType
* @return mixed
*/
public function scopeByOperationType($query, $operationType)
{
if (is_array($operationType)) {
return $query->whereIn('operation_type', $operationType);
}
return $query->where('operation_type', $operationType);
}
/**
* 作用域查询 - 按数据类型和ID
* @param $query
* @param $dataType
* @param $dataId
* @return mixed
*/
public function scopeByData($query, $dataType, $dataId = null)
{
$query = $query->where('data_type', $dataType);
if (!is_null($dataId)) {
$query = $query->where('data_id', $dataId);
}
return $query;
}
/**
* 作用域查询 - 按日期范围
* @param $query
* @param $startDate
* @param $endDate
* @return mixed
*/
public function scopeByDateRange($query, $startDate, $endDate = null)
{
if (!empty($startDate)) {
$query = $query->where('created_at', '>=', $startDate);
}
if (!empty($endDate)) {
$query = $query->where('created_at', '<=', $endDate);
}
return $query;
}
/**
* 作用域查询 - 最近记录
* @param $query
* @param int $limit
* @return mixed
*/
public function scopeRecent($query, int $limit = 10)
{
return $query->order('created_at desc')->limit($limit);
}
}

View File

@@ -0,0 +1,610 @@
<?php
namespace jerryyan\recorder\service;
use think\admin\Service;
use think\facade\Request;
use think\facade\Config;
use think\exception\ValidateException;
use jerryyan\recorder\model\RecorderLog;
/**
* 操作记录服务类
* Class RecorderService
* @package jerryyan\recorder\service
*/
class RecorderService extends Service
{
/**
* 插件配置
* @var array
*/
protected static $config = [];
/**
* 初始化配置
*/
protected static function initConfig()
{
if (empty(static::$config)) {
static::$config = Config::get('recorder', [
'enabled' => true,
'auto_record' => [
'enabled' => true,
'exclude_operations' => ['读取'],
'exclude_controllers' => [],
],
'retention_days' => 90,
'sensitive_operations' => ['删除', '导出'],
'view' => [
'default_limit' => 10,
'date_format' => 'Y-m-d H:i:s',
'theme' => 'default',
'show_user' => true,
'show_ip' => false,
'compact_mode' => false,
]
]);
}
}
/**
* 记录操作
* @param array $data 操作记录数据
* @return bool
*/
public static function record(array $data): bool
{
static::initConfig();
// 检查是否启用记录功能
if (!static::$config['enabled']) {
return false;
}
try {
// 自动补充请求信息
$data = static::fillRequestInfo($data);
// 自动补充用户信息
$data = static::fillUserInfo($data);
// 验证数据
static::validateData($data);
// 创建记录
$model = new RecorderLog();
return $model->save($data) !== false;
} catch (\Exception $e) {
// 记录失败不应影响主业务,只记录到日志
trace("操作记录失败: " . $e->getMessage(), 'error');
return false;
}
}
/**
* 批量记录操作
* @param array $records 操作记录数组
* @return bool
*/
public static function batchRecord(array $records): bool
{
$success = 0;
foreach ($records as $record) {
if (static::record($record)) {
$success++;
}
}
return $success > 0;
}
/**
* 自动记录操作(填充当前请求上下文)
* @param array $data
* @return bool
*/
public static function autoRecord(array $data): bool
{
// 自动记录会补充更多上下文信息
$data = array_merge([
'request_method' => Request::method(),
'request_url' => Request::url(true),
'request_ip' => Request::ip(),
'user_agent' => Request::header('user-agent', ''),
], $data);
return static::record($data);
}
/**
* 查询操作记录
* @param array $conditions 查询条件
* @return array
*/
public static function query(array $conditions = []): array
{
$model = new RecorderLog();
$query = $model->newQuery();
// 构建查询条件
static::buildQueryConditions($query, $conditions);
// 排序和分页
$limit = $conditions['limit'] ?? 20;
$page = $conditions['page'] ?? 1;
$result = $query->order('created_at desc')
->page($page, $limit)
->select()
->toArray();
return [
'data' => $result,
'total' => $query->count(),
'page' => $page,
'limit' => $limit
];
}
/**
* 获取用户操作记录
* @param int $userId 用户ID
* @param array $conditions 额外条件
* @return array
*/
public static function getUserRecords(int $userId, array $conditions = []): array
{
$conditions['user_id'] = $userId;
return static::query($conditions);
}
/**
* 获取数据操作记录
* @param string $dataType 数据类型
* @param string $dataId 数据ID
* @param array $conditions 额外条件
* @return array
*/
public static function getDataRecords(string $dataType, string $dataId = '', array $conditions = []): array
{
$conditions['data_type'] = $dataType;
if (!empty($dataId)) {
$conditions['data_id'] = $dataId;
}
return static::query($conditions);
}
/**
* 获取操作统计
* @param array $conditions 查询条件
* @return array
*/
public static function getStats(array $conditions = []): array
{
$model = new RecorderLog();
$query = $model->newQuery();
static::buildQueryConditions($query, $conditions);
// 按操作类型统计
$typeStats = $query->field('operation_type, COUNT(*) as count')
->group('operation_type')
->select()
->toArray();
// 按用户统计
$userStats = $query->field('user_id, user_nickname, COUNT(*) as count')
->group('user_id, user_nickname')
->order('count desc')
->limit(10)
->select()
->toArray();
// 总记录数
$totalCount = $query->count();
return [
'total_count' => $totalCount,
'type_stats' => $typeStats,
'user_stats' => $userStats,
];
}
/**
* 导出操作记录
* @param array $conditions 查询条件
* @return string 文件路径
*/
public static function export(array $conditions = []): string
{
$records = static::query(array_merge($conditions, ['limit' => 10000]));
// 生成CSV文件
$filename = 'recorder_log_' . date('Y-m-d_H-i-s') . '.csv';
$filepath = runtime_path('temp') . $filename;
$file = fopen($filepath, 'w');
// 写入表头
$headers = ['ID', '操作类型', '操作说明', '用户ID', '用户昵称', '数据类型',
'数据ID', '关联类型', '关联ID', '请求方法', '请求URL',
'请求IP', '用户代理', '创建时间'];
fputcsv($file, $headers);
// 写入数据
foreach ($records['data'] as $record) {
$row = [
$record['id'],
$record['operation_type'],
$record['operation_desc'],
$record['user_id'],
$record['user_nickname'],
$record['data_type'],
$record['data_id'],
$record['related_type'],
$record['related_id'],
$record['request_method'],
$record['request_url'],
$record['request_ip'],
$record['user_agent'],
$record['created_at'],
];
fputcsv($file, $row);
}
fclose($file);
return $filepath;
}
/**
* 清理过期记录
* @param int $days 保留天数
* @return int 清理数量
*/
public static function cleanup(int $days = 90): int
{
if ($days <= 0) {
static::initConfig();
$days = static::$config['retention_days'] ?? 90;
}
$expiredDate = date('Y-m-d H:i:s', strtotime("-{$days} days"));
$model = new RecorderLog();
return $model->where('created_at', '<', $expiredDate)->delete();
}
/**
* 填充请求信息
* @param array $data
* @return array
*/
protected static function fillRequestInfo(array $data): array
{
if (empty($data['request_method'])) {
$data['request_method'] = Request::method() ?? '';
}
if (empty($data['request_url'])) {
$data['request_url'] = Request::url(true) ?? '';
}
if (empty($data['request_ip'])) {
$data['request_ip'] = Request::ip() ?? '';
}
if (empty($data['user_agent'])) {
$data['user_agent'] = Request::header('user-agent', '');
}
return $data;
}
/**
* 填充用户信息
* @param array $data
* @return array
*/
protected static function fillUserInfo(array $data): array
{
// 如果没有提供用户信息,尝试从当前会话获取
if (empty($data['user_id']) || empty($data['user_nickname'])) {
try {
// 尝试获取当前登录用户信息
$adminUser = session('user');
if (!empty($adminUser)) {
if (empty($data['user_id'])) {
$data['user_id'] = $adminUser['id'] ?? 0;
}
if (empty($data['user_nickname'])) {
$data['user_nickname'] = $adminUser['username'] ?? $adminUser['nickname'] ?? '';
}
}
} catch (\Exception $e) {
// 获取用户信息失败,使用默认值
$data['user_id'] = $data['user_id'] ?? 0;
$data['user_nickname'] = $data['user_nickname'] ?? '未知用户';
}
}
return $data;
}
/**
* 验证数据
* @param array $data
* @throws ValidateException
*/
protected static function validateData(array $data)
{
$rules = [
'operation_type' => 'require',
'operation_desc' => 'require',
];
$messages = [
'operation_type.require' => '操作类型不能为空',
'operation_desc.require' => '操作说明不能为空',
];
$validate = validate($rules, $messages);
if (!$validate->check($data)) {
throw new ValidateException($validate->getError());
}
}
/**
* 构建查询条件
* @param $query
* @param array $conditions
*/
protected static function buildQueryConditions($query, array $conditions)
{
// 用户ID条件
if (!empty($conditions['user_id'])) {
$query->byUser($conditions['user_id']);
}
// 操作类型条件
if (!empty($conditions['operation_type'])) {
$query->byOperationType($conditions['operation_type']);
}
// 数据类型和ID条件
if (!empty($conditions['data_type'])) {
$dataId = $conditions['data_id'] ?? null;
$query->byData($conditions['data_type'], $dataId);
}
// 日期范围条件
if (!empty($conditions['start_time']) || !empty($conditions['end_time'])) {
$startTime = $conditions['start_time'] ?? null;
$endTime = $conditions['end_time'] ?? null;
$query->byDateRange($startTime, $endTime);
}
// 日期范围条件 (兼容date_range参数)
if (!empty($conditions['date_range']) && is_array($conditions['date_range'])) {
$startTime = $conditions['date_range'][0] ?? null;
$endTime = $conditions['date_range'][1] ?? null;
$query->byDateRange($startTime, $endTime);
}
// IP地址条件
if (!empty($conditions['request_ip'])) {
$query->where('request_ip', $conditions['request_ip']);
}
// 关键词搜索
if (!empty($conditions['keyword'])) {
$keyword = $conditions['keyword'];
$query->where(function($q) use ($keyword) {
$q->whereLike('operation_desc', "%{$keyword}%")
->whereOr('user_nickname', 'like', "%{$keyword}%")
->whereOr('data_type', 'like', "%{$keyword}%");
});
}
}
/**
* 获取数据的读取用户列表(去重)
* @param string $dataType 数据类型
* @param string $dataId 数据ID
* @param array $conditions 额外条件
* @return array
*/
public static function getDataReaders(string $dataType, string $dataId, array $conditions = []): array
{
try {
$model = new RecorderLog();
$query = $model->newQuery();
// 基础查询条件
$query->where('data_type', $dataType)
->where('data_id', $dataId)
->where('operation_type', '读取'); // 只查询读取操作
// 额外条件
if (!empty($conditions['start_time'])) {
$query->where('created_at', '>=', $conditions['start_time']);
}
if (!empty($conditions['end_time'])) {
$query->where('created_at', '<=', $conditions['end_time']);
}
// 按用户分组,获取每个用户的首次和最后一次读取时间
$readers = $query->field([
'user_id',
'user_nickname',
'MIN(created_at) as first_read_at',
'MAX(created_at) as last_read_at',
'COUNT(*) as read_count',
'MAX(request_ip) as last_ip',
'MAX(user_agent) as last_user_agent'
])
->group('user_id, user_nickname')
->order('last_read_at desc')
->select()
->toArray();
// 格式化数据
foreach ($readers as &$reader) {
$reader['first_read_at_formatted'] = date('Y-m-d H:i:s', strtotime($reader['first_read_at']));
$reader['last_read_at_formatted'] = date('Y-m-d H:i:s', strtotime($reader['last_read_at']));
$reader['first_read_at_relative'] = static::getRelativeTime($reader['first_read_at']);
$reader['last_read_at_relative'] = static::getRelativeTime($reader['last_read_at']);
}
return $readers;
} catch (\Exception $e) {
trace("获取数据读取用户失败: " . $e->getMessage(), 'error');
return [];
}
}
/**
* 获取数据的操作用户列表(排除读取操作)
* @param string $dataType 数据类型
* @param string $dataId 数据ID
* @param array $conditions 额外条件
* @return array
*/
public static function getDataOperators(string $dataType, string $dataId, array $conditions = []): array
{
try {
$model = new RecorderLog();
$query = $model->newQuery();
// 基础查询条件
$query->where('data_type', $dataType)
->where('data_id', $dataId)
->where('operation_type', '<>', '读取'); // 排除读取操作
// 额外条件
if (!empty($conditions['start_time'])) {
$query->where('created_at', '>=', $conditions['start_time']);
}
if (!empty($conditions['end_time'])) {
$query->where('created_at', '<=', $conditions['end_time']);
}
// 按用户和操作类型分组
$operators = $query->field([
'user_id',
'user_nickname',
'operation_type',
'MIN(created_at) as first_operation_at',
'MAX(created_at) as last_operation_at',
'COUNT(*) as operation_count',
'MAX(operation_desc) as last_operation_desc',
'MAX(request_ip) as last_ip'
])
->group('user_id, user_nickname, operation_type')
->order('last_operation_at desc')
->select()
->toArray();
// 按用户聚合操作信息
$userOperations = [];
foreach ($operators as $operator) {
$userId = $operator['user_id'];
if (!isset($userOperations[$userId])) {
$userOperations[$userId] = [
'user_id' => $operator['user_id'],
'user_nickname' => $operator['user_nickname'],
'operations' => [],
'total_operations' => 0,
'first_operation_at' => $operator['first_operation_at'],
'last_operation_at' => $operator['last_operation_at'],
'last_ip' => $operator['last_ip']
];
}
// 添加操作信息
$userOperations[$userId]['operations'][] = [
'type' => $operator['operation_type'],
'count' => $operator['operation_count'],
'last_desc' => $operator['last_operation_desc'],
'first_at' => $operator['first_operation_at'],
'last_at' => $operator['last_operation_at']
];
$userOperations[$userId]['total_operations'] += $operator['operation_count'];
// 更新最早和最晚时间
if ($operator['first_operation_at'] < $userOperations[$userId]['first_operation_at']) {
$userOperations[$userId]['first_operation_at'] = $operator['first_operation_at'];
}
if ($operator['last_operation_at'] > $userOperations[$userId]['last_operation_at']) {
$userOperations[$userId]['last_operation_at'] = $operator['last_operation_at'];
}
}
// 格式化数据并转为数组
$result = array_values($userOperations);
foreach ($result as &$user) {
$user['first_operation_at_formatted'] = date('Y-m-d H:i:s', strtotime($user['first_operation_at']));
$user['last_operation_at_formatted'] = date('Y-m-d H:i:s', strtotime($user['last_operation_at']));
$user['first_operation_at_relative'] = static::getRelativeTime($user['first_operation_at']);
$user['last_operation_at_relative'] = static::getRelativeTime($user['last_operation_at']);
// 生成操作摘要
$operationTypes = array_column($user['operations'], 'type');
$user['operation_summary'] = implode('、', array_unique($operationTypes));
}
// 按最后操作时间排序
usort($result, function($a, $b) {
return strtotime($b['last_operation_at']) - strtotime($a['last_operation_at']);
});
return $result;
} catch (\Exception $e) {
trace("获取数据操作用户失败: " . $e->getMessage(), 'error');
return [];
}
}
/**
* 获取相对时间
* @param string $datetime
* @return string
*/
protected static function getRelativeTime(string $datetime): string
{
$time = strtotime($datetime);
$now = time();
$diff = $now - $time;
if ($diff < 60) {
return '刚刚';
} elseif ($diff < 3600) {
return intval($diff / 60) . '分钟前';
} elseif ($diff < 86400) {
return intval($diff / 3600) . '小时前';
} elseif ($diff < 604800) {
return intval($diff / 86400) . '天前';
} else {
return date('m-d', $time);
}
}
/**
* 获取配置
* @param string $key
* @param mixed $default
* @return mixed
*/
public static function getConfig(string $key = '', $default = null)
{
static::initConfig();
if (empty($key)) {
return static::$config;
}
return static::$config[$key] ?? $default;
}
}

View File

@@ -0,0 +1,112 @@
<?php
use Phinx\Migration\AbstractMigration;
class CreateRecorderLogsTable extends AbstractMigration
{
/**
* 创建操作记录表
*/
public function up()
{
$table = $this->table('recorder_log', [
'id' => false,
'primary_key' => ['id'],
'engine' => 'InnoDB',
'collation' => 'utf8mb4_general_ci',
'comment' => '操作记录表',
]);
$table->addColumn('id', 'biginteger', [
'identity' => true,
'signed' => false,
'comment' => '记录ID',
])
->addColumn('operation_type', 'string', [
'limit' => 50,
'default' => '',
'comment' => '操作类型',
])
->addColumn('operation_desc', 'string', [
'limit' => 500,
'default' => '',
'comment' => '操作说明',
])
->addColumn('user_id', 'biginteger', [
'signed' => false,
'default' => 0,
'comment' => '操作用户ID',
])
->addColumn('user_nickname', 'string', [
'limit' => 100,
'default' => '',
'comment' => '操作用户昵称',
])
->addColumn('data_type', 'string', [
'limit' => 100,
'default' => '',
'comment' => '操作数据类型',
])
->addColumn('data_id', 'string', [
'limit' => 100,
'default' => '',
'comment' => '操作数据ID',
])
->addColumn('related_type', 'string', [
'limit' => 100,
'default' => '',
'comment' => '关联数据类型',
])
->addColumn('related_id', 'string', [
'limit' => 100,
'default' => '',
'comment' => '关联数据ID',
])
->addColumn('request_method', 'string', [
'limit' => 10,
'default' => '',
'comment' => '请求方法',
])
->addColumn('request_url', 'string', [
'limit' => 500,
'default' => '',
'comment' => '请求URL',
])
->addColumn('request_ip', 'string', [
'limit' => 50,
'default' => '',
'comment' => '请求IP',
])
->addColumn('user_agent', 'string', [
'limit' => 500,
'default' => '',
'comment' => '用户代理',
])
->addColumn('extra_data', 'text', [
'null' => true,
'comment' => '额外数据(JSON格式)',
])
->addColumn('created_at', 'datetime', [
'default' => 'CURRENT_TIMESTAMP',
'comment' => '创建时间',
])
->addColumn('updated_at', 'datetime', [
'default' => 'CURRENT_TIMESTAMP',
'update' => 'CURRENT_TIMESTAMP',
'comment' => '更新时间',
])
->addIndex(['user_id'], ['name' => 'idx_user_id'])
->addIndex(['operation_type'], ['name' => 'idx_operation_type'])
->addIndex(['data_type', 'data_id'], ['name' => 'idx_data_type_id'])
->addIndex(['created_at'], ['name' => 'idx_created_at'])
->create();
}
/**
* 删除操作记录表
*/
public function down()
{
$this->table('recorder_log')->drop()->save();
}
}

232
test_example.php Normal file
View File

@@ -0,0 +1,232 @@
<?php
/**
* think-plugs-recorder 插件测试示例
*
* 这个文件演示了如何测试插件的各种功能
* 使用前请确保已正确安装插件并运行了数据库迁移
*/
// 引入ThinkPHP框架
require_once __DIR__ . '/../../bootstrap.php';
use jerryyan\recorder\service\RecorderService;
use jerryyan\recorder\helper\ViewHelper;
echo "=== think-plugs-recorder 插件测试 ===\n\n";
// 测试1: 基础记录功能
echo "测试1: 基础记录功能\n";
echo "--------------------\n";
try {
$result = RecorderService::record([
'operation_type' => '测试',
'operation_desc' => '测试插件基础记录功能',
'user_id' => 1,
'user_nickname' => '测试用户',
'data_type' => 'test',
'data_id' => '001'
]);
echo $result ? "✅ 基础记录成功\n" : "❌ 基础记录失败\n";
} catch (Exception $e) {
echo "❌ 基础记录异常: " . $e->getMessage() . "\n";
}
echo "\n";
// 测试2: 自动记录功能
echo "测试2: 自动记录功能\n";
echo "--------------------\n";
try {
$result = RecorderService::autoRecord([
'operation_type' => '自动测试',
'operation_desc' => '测试插件自动记录功能',
'data_type' => 'test',
'data_id' => '002',
'extra_data' => ['test' => true, 'timestamp' => time()]
]);
echo $result ? "✅ 自动记录成功\n" : "❌ 自动记录失败\n";
} catch (Exception $e) {
echo "❌ 自动记录异常: " . $e->getMessage() . "\n";
}
echo "\n";
// 测试3: 批量记录功能
echo "测试3: 批量记录功能\n";
echo "--------------------\n";
try {
$records = [
[
'operation_type' => '批量测试1',
'operation_desc' => '批量记录测试1',
'user_id' => 1,
'user_nickname' => '测试用户',
'data_type' => 'batch_test',
'data_id' => 'b001'
],
[
'operation_type' => '批量测试2',
'operation_desc' => '批量记录测试2',
'user_id' => 1,
'user_nickname' => '测试用户',
'data_type' => 'batch_test',
'data_id' => 'b002'
],
[
'operation_type' => '批量测试3',
'operation_desc' => '批量记录测试3',
'user_id' => 1,
'user_nickname' => '测试用户',
'data_type' => 'batch_test',
'data_id' => 'b003'
]
];
$result = RecorderService::batchRecord($records);
echo $result ? "✅ 批量记录成功\n" : "❌ 批量记录失败\n";
} catch (Exception $e) {
echo "❌ 批量记录异常: " . $e->getMessage() . "\n";
}
echo "\n";
// 测试4: 查询功能
echo "测试4: 查询功能\n";
echo "---------------\n";
try {
// 查询最近的记录
$recentRecords = RecorderService::query(['limit' => 5]);
echo "📊 查询到 " . count($recentRecords['data']) . " 条最近记录\n";
foreach ($recentRecords['data'] as $record) {
echo " - {$record['operation_type']}: {$record['operation_desc']} ({$record['created_at']})\n";
}
// 按用户查询
$userRecords = RecorderService::getUserRecords(1, ['limit' => 3]);
echo "📊 查询到用户1的 " . count($userRecords['data']) . " 条记录\n";
// 按数据类型查询
$dataRecords = RecorderService::getDataRecords('test');
echo "📊 查询到test类型的 " . count($dataRecords['data']) . " 条记录\n";
} catch (Exception $e) {
echo "❌ 查询异常: " . $e->getMessage() . "\n";
}
echo "\n";
// 测试5: 统计功能
echo "测试5: 统计功能\n";
echo "---------------\n";
try {
$stats = RecorderService::getStats();
echo "📈 统计信息:\n";
echo " - 总记录数: " . $stats['total_count'] . "\n";
echo " - 操作类型统计:\n";
foreach ($stats['type_stats'] as $typeStat) {
echo " * {$typeStat['operation_type']}: {$typeStat['count']}\n";
}
echo " - 用户操作统计 (Top 5):\n";
foreach (array_slice($stats['user_stats'], 0, 5) as $userStat) {
echo " * {$userStat['user_nickname']} (ID:{$userStat['user_id']}): {$userStat['count']}\n";
}
} catch (Exception $e) {
echo "❌ 统计异常: " . $e->getMessage() . "\n";
}
echo "\n";
// 测试6: 视图辅助功能
echo "测试6: 视图辅助功能\n";
echo "-------------------\n";
try {
// 测试获取记录数据
$viewRecords = ViewHelper::getRecords(['limit' => 3]);
echo "🎨 视图辅助获取到 " . count($viewRecords) . " 条记录\n";
// 测试渲染功能(输出HTML长度作为测试)
$listHtml = ViewHelper::renderList(['limit' => 2], ['theme' => 'default']);
echo "🎨 默认列表HTML长度: " . strlen($listHtml) . " 字符\n";
$cardHtml = ViewHelper::renderList(['limit' => 2], ['theme' => 'card']);
echo "🎨 卡片列表HTML长度: " . strlen($cardHtml) . " 字符\n";
$timelineHtml = ViewHelper::renderTimeline(['limit' => 2]);
echo "🎨 时间线HTML长度: " . strlen($timelineHtml) . " 字符\n";
if (!empty($viewRecords)) {
$itemHtml = ViewHelper::renderItem($viewRecords[0]);
echo "🎨 单项HTML长度: " . strlen($itemHtml) . " 字符\n";
}
} catch (Exception $e) {
echo "❌ 视图辅助异常: " . $e->getMessage() . "\n";
}
echo "\n";
// 测试7: 配置测试
echo "测试7: 配置测试\n";
echo "---------------\n";
try {
$config = RecorderService::getConfig();
echo "⚙️ 插件配置信息:\n";
echo " - 启用状态: " . ($config['enabled'] ? '已启用' : '已禁用') . "\n";
echo " - 自动记录: " . ($config['auto_record']['enabled'] ? '已启用' : '已禁用') . "\n";
echo " - 记录保留天数: " . $config['retention_days'] . "\n";
echo " - 默认显示主题: " . $config['view']['theme'] . "\n";
} catch (Exception $e) {
echo "❌ 配置测试异常: " . $e->getMessage() . "\n";
}
echo "\n";
// 测试8: 数据验证
echo "测试8: 数据验证\n";
echo "---------------\n";
// 测试必填字段验证
try {
$result = RecorderService::record([
// 缺少必填字段 operation_type
'operation_desc' => '测试数据验证'
]);
echo "❌ 数据验证失败 - 应该抛出异常但没有\n";
} catch (Exception $e) {
echo "✅ 数据验证正常 - 正确检测到必填字段缺失\n";
}
// 测试空操作描述
try {
$result = RecorderService::record([
'operation_type' => '验证测试',
// 缺少必填字段 operation_desc
]);
echo "❌ 数据验证失败 - 应该抛出异常但没有\n";
} catch (Exception $e) {
echo "✅ 数据验证正常 - 正确检测到操作描述缺失\n";
}
echo "\n";
echo "=== 测试完成 ===\n";
echo "✅ 所有测试已执行完毕\n";
echo "📝 请查看上述输出结果,确认各项功能是否正常\n";
echo "💡 如有异常,请检查数据库连接和表结构是否正确\n\n";
// 输出一些调试信息
echo "调试信息:\n";
echo "- 当前时间: " . date('Y-m-d H:i:s') . "\n";
echo "- PHP版本: " . PHP_VERSION . "\n";
echo "- 内存使用: " . round(memory_get_usage() / 1024 / 1024, 2) . " MB\n";
echo "\n";
echo "🎉 插件测试脚本执行完成!\n";
echo "📖 更多使用方法请参考 README.md 文件\n";
?>

94
test_functions.php Normal file
View File

@@ -0,0 +1,94 @@
<?php
/**
* 测试模板函数是否正确注册
*/
// 引入Composer自动加载
if (file_exists(__DIR__ . '/../../vendor/autoload.php')) {
require_once __DIR__ . '/../../vendor/autoload.php';
} elseif (file_exists(__DIR__ . '/../../../../vendor/autoload.php')) {
require_once __DIR__ . '/../../../../vendor/autoload.php';
}
echo "=== 测试模板函数注册 ===\n\n";
// 测试函数是否存在
$functions = [
'recorder_get_records',
'recorder_render_list',
'recorder_render_timeline',
'recorder_render_item',
'recorder_get_stats'
];
echo "检查函数注册状态:\n";
echo "-------------------\n";
foreach ($functions as $function) {
if (function_exists($function)) {
echo "{$function} - 已注册\n";
} else {
echo "{$function} - 未注册\n";
}
}
echo "\n";
// 如果函数未注册,尝试手动加载
if (!function_exists('recorder_get_records')) {
echo "尝试手动加载函数文件...\n";
require_once __DIR__ . '/src/functions.php';
echo "\n重新检查函数注册状态:\n";
echo "---------------------\n";
foreach ($functions as $function) {
if (function_exists($function)) {
echo "{$function} - 已注册\n";
} else {
echo "{$function} - 未注册\n";
}
}
echo "\n";
}
// 测试函数调用(如果已注册)
if (function_exists('recorder_get_records')) {
echo "测试函数调用:\n";
echo "-------------\n";
try {
// 测试获取记录(可能会失败,因为没有数据库连接)
$records = recorder_get_records(['limit' => 1]);
echo "✅ recorder_get_records() 调用成功\n";
} catch (Exception $e) {
echo "⚠️ recorder_get_records() 调用失败: " . $e->getMessage() . "\n";
}
try {
// 测试渲染空列表
$html = recorder_render_list([], ['limit' => 0]);
if (!empty($html)) {
echo "✅ recorder_render_list() 调用成功,HTML长度: " . strlen($html) . "\n";
} else {
echo "⚠️ recorder_render_list() 返回空内容\n";
}
} catch (Exception $e) {
echo "⚠️ recorder_render_list() 调用失败: " . $e->getMessage() . "\n";
}
} else {
echo "❌ 函数未正确注册,无法进行调用测试\n";
}
echo "\n=== 解决方案 ===\n";
echo "如果函数未注册,请按以下步骤操作:\n\n";
echo "1. 更新Composer自动加载:\n";
echo " composer dump-autoload\n\n";
echo "2. 在模板中使用前手动引入:\n";
echo " {:include_once(app_path('plugs/think-plugs-recorder/src/functions.php'))}\n\n";
echo "3. 或在应用启动文件中引入:\n";
echo " // app/provider.php 或 common.php\n";
echo " require_once app_path('plugs/think-plugs-recorder/src/functions.php');\n\n";
echo "=== 测试完成 ===\n";
?>

View File

@@ -0,0 +1,227 @@
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; font-size: 14px; line-height: 1.5; color: #262626;">
<div class="recorder-operators {$options.css_class|default=''}" style="background: #ffffff; border: 1px solid #e6e6e6; border-radius: 6px; padding: 15px;">
{if isset($operators) && !empty($operators)}
<div style="margin-bottom: 15px; padding-bottom: 10px; border-bottom: 1px solid #f0f0f0;">
<h5 style="margin: 0; color: #333; font-size: 14px; font-weight: 600; display: flex; align-items: center; gap: 6px;">
<i class="layui-icon layui-icon-user" style="color: #52c41a; font-size: 16px;"></i>
操作用户 ({$operators|count})
</h5>
</div>
<div style="display: flex; flex-direction: column; gap: 15px;">
{volist name="operators" id="operator"}
<div class="recorder-operator recorder-slide-in" data-user-id="{$operator.user_id}"
style="display: flex; align-items: flex-start; padding: 15px; background: #fafafa; border-radius: 6px; border-left: 3px solid #52c41a; transition: all 0.3s ease; cursor: pointer;"
onmouseover="this.style.background='#f6ffed'; this.style.borderLeftColor='#73d13d'; this.style.boxShadow='0 2px 8px rgba(82, 196, 26, 0.1)'; this.style.transform='translateX(2px)';"
onmouseout="this.style.background='#fafafa'; this.style.borderLeftColor='#52c41a'; this.style.boxShadow='none'; this.style.transform='translateX(0)';">
<div style="width: 40px; height: 40px; background: linear-gradient(135deg, #52c41a, #73d13d); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin-right: 15px; color: #ffffff; flex-shrink: 0; box-shadow: 0 2px 4px rgba(82, 196, 26, 0.2);">
<i class="layui-icon layui-icon-user" style="font-size: 18px;"></i>
</div>
<div style="flex: 1; min-width: 0;">
<div style="font-size: 14px; font-weight: 600; color: #333; margin-bottom: 4px;">{$operator.user_nickname}</div>
<div style="font-size: 12px; color: #52c41a; font-weight: 500; margin-bottom: 8px; padding: 2px 6px; background: rgba(82, 196, 26, 0.1); border-radius: 3px; display: inline-block;">{$operator.operation_summary}</div>
<div style="display: flex; flex-wrap: wrap; gap: 15px; font-size: 12px; color: #666; margin-bottom: 6px;">
<span style="display: flex; align-items: center; color: #52c41a; font-weight: 500;">
<i class="layui-icon layui-icon-edit" style="font-size: 12px; margin-right: 3px;"></i>
操作 {$operator.total_operations} 次
</span>
<span style="display: flex; align-items: center;">
<i class="layui-icon layui-icon-time" style="font-size: 12px; margin-right: 3px;"></i>
最后: {$operator.last_operation_at_relative}
</span>
</div>
<div style="color: #999; font-size: 11px; line-height: 1.4; margin-bottom: 8px;">
<small>
首次: {$operator.first_operation_at_formatted}
| 最后: {$operator.last_operation_at_formatted}
</small>
</div>
{if isset($operator.operations) && !empty($operator.operations)}
<div style="display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px;">
{volist name="operator.operations" id="op"}
<span class="operation-tag operation-tag--{$op.type}"
style="font-size: 10px; padding: 2px 6px; border-radius: 3px; font-weight: 500; transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); cursor: pointer;
{switch name="op.type"}
{case value="创建"}background: #e6f7ff; color: #1890ff;{/case}
{case value="更新"}background: #fff7e6; color: #fa8c16;{/case}
{case value="删除"}background: #fff2f0; color: #f5222d;{/case}
{case value="导出"}background: #f6ffed; color: #52c41a;{/case}
{case value="审核"}background: #f9f0ff; color: #722ed1;{/case}
{case value="发布"}background: #e6fffb; color: #13c2c2;{/case}
{default}background: #f0f0f0; color: #666;{/case}
{/switch}"
onmouseover="this.style.transform='scale(1.05)';"
onmouseout="this.style.transform='scale(1)';">
{$op.type} ({$op.count})
</span>
{/volist}
</div>
{/if}
</div>
</div>
{/volist}
</div>
{else}
<div style="text-align: center; padding: 50px 20px; color: #999;">
<i class="layui-icon layui-icon-face-cry" style="font-size: 48px; margin-bottom: 12px; color: #ddd; display: block;"></i>
<p style="margin: 0; font-size: 14px;">暂无用户操作过此数据</p>
</div>
{/if}
</div>
</div>
<style>
@keyframes recorderSlideIn {
from { opacity: 0; transform: translateX(-20px); }
to { opacity: 1; transform: translateX(0); }
}
.recorder-slide-in {
animation: recorderSlideIn 0.3s ease-in-out;
}
@media (max-width: 768px) {
.recorder-operators {
padding: 12px !important;
}
.recorder-operator {
padding: 12px !important;
flex-direction: column !important;
text-align: center !important;
}
.recorder-operator > div:first-child {
margin-right: 0 !important;
margin-bottom: 8px !important;
}
.recorder-operator > div:last-child > div:nth-child(3) {
flex-direction: column !important;
gap: 4px !important;
}
.recorder-operator > div:last-child > div:last-child {
margin-top: 6px !important;
justify-content: center !important;
}
}
</style>
<script>
(function() {
'use strict';
// 操作用户点击处理
document.addEventListener('click', function(e) {
var operator = e.target.closest('.recorder-operator');
if (operator) {
// 移除其他选中状态
document.querySelectorAll('.recorder-operator.selected').forEach(function(el) {
el.classList.remove('selected');
el.style.background = '#fafafa';
el.style.borderLeftColor = '#52c41a';
});
// 添加选中状态
operator.classList.add('selected');
operator.style.background = '#f6ffed';
operator.style.borderLeftColor = '#73d13d';
// 触发自定义事件
var event = new CustomEvent('recorderOperatorSelect', {
detail: {
userId: operator.dataset.userId,
element: operator
}
});
document.dispatchEvent(event);
}
// 操作标签点击处理
var operationTag = e.target.closest('.operation-tag');
if (operationTag) {
e.stopPropagation(); // 阻止冒泡到operator
var operationType = operationTag.textContent.split('(')[0].trim();
// 触发筛选事件
var event = new CustomEvent('recorderOperationFilter', {
detail: {
operationType: operationType,
element: operationTag
}
});
document.dispatchEvent(event);
}
});
// 为新添加的元素添加动画
function addAnimation() {
var operators = document.querySelectorAll('.recorder-operator:not(.animated)');
operators.forEach(function(operator, index) {
operator.style.animationDelay = (index * 150) + 'ms';
operator.classList.add('recorder-slide-in', 'animated');
});
}
// 初始化动画
setTimeout(addAnimation, 100);
// 响应式处理
function handleResize() {
var isMobile = window.innerWidth <= 768;
var operators = document.querySelectorAll('.recorder-operator');
operators.forEach(function(operator) {
if (isMobile) {
operator.style.padding = '12px';
operator.style.flexDirection = 'column';
operator.style.textAlign = 'center';
var avatar = operator.querySelector('div:first-child');
if (avatar) {
avatar.style.marginRight = '0';
avatar.style.marginBottom = '8px';
}
var details = operator.querySelector('div:last-child > div:nth-child(3)');
if (details) {
details.style.flexDirection = 'column';
details.style.gap = '4px';
}
var tags = operator.querySelector('div:last-child > div:last-child');
if (tags) {
tags.style.marginTop = '6px';
tags.style.justifyContent = 'center';
}
} else {
operator.style.padding = '15px';
operator.style.flexDirection = 'row';
operator.style.textAlign = 'left';
var avatar = operator.querySelector('div:first-child');
if (avatar) {
avatar.style.marginRight = '15px';
avatar.style.marginBottom = '0';
}
var details = operator.querySelector('div:last-child > div:nth-child(3)');
if (details) {
details.style.flexDirection = 'row';
details.style.gap = '15px';
}
var tags = operator.querySelector('div:last-child > div:last-child');
if (tags) {
tags.style.marginTop = '8px';
tags.style.justifyContent = 'flex-start';
}
}
});
}
window.addEventListener('resize', handleResize);
handleResize();
})();
</script>

View File

@@ -0,0 +1,180 @@
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; font-size: 14px; line-height: 1.5; color: #262626;">
<div class="recorder-readers {$options.css_class|default=''}" style="background: #ffffff; border: 1px solid #e6e6e6; border-radius: 6px; padding: 15px;">
{if isset($readers) && !empty($readers)}
<div style="margin-bottom: 15px; padding-bottom: 10px; border-bottom: 1px solid #f0f0f0;">
<h5 style="margin: 0; color: #333; font-size: 14px; font-weight: 600; display: flex; align-items: center; gap: 6px;">
<i class="layui-icon layui-icon-username" style="color: #1890ff; font-size: 16px;"></i>
读取用户 ({$readers|count})
</h5>
</div>
<div style="display: flex; flex-direction: column; gap: 12px;">
{volist name="readers" id="reader"}
<div class="recorder-reader recorder-slide-in" data-user-id="{$reader.user_id}"
style="display: flex; align-items: flex-start; padding: 12px; background: #fafafa; border-radius: 6px; border-left: 3px solid #1890ff; transition: all 0.3s ease; cursor: pointer;"
onmouseover="this.style.background='#f0f9ff'; this.style.borderLeftColor='#40a9ff'; this.style.transform='translateX(2px)';"
onmouseout="this.style.background='#fafafa'; this.style.borderLeftColor='#1890ff'; this.style.transform='translateX(0)';">
<div style="width: 36px; height: 36px; background: linear-gradient(135deg, #1890ff, #40a9ff); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin-right: 12px; color: #ffffff; flex-shrink: 0; box-shadow: 0 2px 4px rgba(24, 144, 255, 0.2);">
<i class="layui-icon layui-icon-username" style="font-size: 16px;"></i>
</div>
<div style="flex: 1; min-width: 0;">
<div style="font-size: 14px; font-weight: 600; color: #333; margin-bottom: 6px;">{$reader.user_nickname}</div>
<div style="display: flex; flex-wrap: wrap; gap: 12px; font-size: 12px; color: #666; margin-bottom: 4px;">
<span style="display: flex; align-items: center; color: #1890ff; font-weight: 500;">
<i class="layui-icon layui-icon-read" style="font-size: 12px; margin-right: 2px;"></i>
读取 {$reader.read_count} 次
</span>
{if $reader.first_read_at != $reader.last_read_at}
<span style="display: flex; align-items: center;">
<i class="layui-icon layui-icon-time" style="font-size: 12px; margin-right: 2px;"></i>
最后: {$reader.last_read_at_relative}
</span>
{else/}
<span style="display: flex; align-items: center;">
<i class="layui-icon layui-icon-time" style="font-size: 12px; margin-right: 2px;"></i>
{$reader.first_read_at_relative}
</span>
{/if}
</div>
<div style="color: #999; font-size: 11px; line-height: 1.4;">
<small>
首次: {$reader.first_read_at_formatted}
{if $reader.first_read_at != $reader.last_read_at}
| 最后: {$reader.last_read_at_formatted}
{/if}
</small>
</div>
</div>
</div>
{/volist}
</div>
{else}
<div style="text-align: center; padding: 50px 20px; color: #999;">
<i class="layui-icon layui-icon-face-cry" style="font-size: 48px; margin-bottom: 12px; color: #ddd; display: block;"></i>
<p style="margin: 0; font-size: 14px;">暂无用户读取过此数据</p>
</div>
{/if}
</div>
</div>
<style>
@keyframes recorderSlideIn {
from { opacity: 0; transform: translateX(-20px); }
to { opacity: 1; transform: translateX(0); }
}
.recorder-slide-in {
animation: recorderSlideIn 0.3s ease-in-out;
}
@media (max-width: 768px) {
.recorder-readers {
padding: 12px !important;
}
.recorder-reader {
padding: 10px !important;
flex-direction: column !important;
text-align: center !important;
}
.recorder-reader > div:first-child {
margin-right: 0 !important;
margin-bottom: 8px !important;
}
.recorder-reader > div:last-child > div:nth-child(2) {
flex-direction: column !important;
gap: 4px !important;
}
}
</style>
<script>
(function() {
'use strict';
// 读取用户点击处理
document.addEventListener('click', function(e) {
var reader = e.target.closest('.recorder-reader');
if (reader) {
// 移除其他选中状态
document.querySelectorAll('.recorder-reader.selected').forEach(function(el) {
el.classList.remove('selected');
el.style.background = '#fafafa';
el.style.borderLeftColor = '#1890ff';
});
// 添加选中状态
reader.classList.add('selected');
reader.style.background = '#e6f7ff';
reader.style.borderLeftColor = '#40a9ff';
// 触发自定义事件
var event = new CustomEvent('recorderReaderSelect', {
detail: {
userId: reader.dataset.userId,
element: reader
}
});
document.dispatchEvent(event);
}
});
// 为新添加的元素添加动画
function addAnimation() {
var readers = document.querySelectorAll('.recorder-reader:not(.animated)');
readers.forEach(function(reader, index) {
reader.style.animationDelay = (index * 100) + 'ms';
reader.classList.add('recorder-slide-in', 'animated');
});
}
// 初始化动画
setTimeout(addAnimation, 100);
// 响应式处理
function handleResize() {
var isMobile = window.innerWidth <= 768;
var readers = document.querySelectorAll('.recorder-reader');
readers.forEach(function(reader) {
if (isMobile) {
reader.style.padding = '10px';
reader.style.flexDirection = 'column';
reader.style.textAlign = 'center';
var avatar = reader.querySelector('div:first-child');
if (avatar) {
avatar.style.marginRight = '0';
avatar.style.marginBottom = '8px';
}
var details = reader.querySelector('div:last-child > div:nth-child(2)');
if (details) {
details.style.flexDirection = 'column';
details.style.gap = '4px';
}
} else {
reader.style.padding = '12px';
reader.style.flexDirection = 'row';
reader.style.textAlign = 'left';
var avatar = reader.querySelector('div:first-child');
if (avatar) {
avatar.style.marginRight = '12px';
avatar.style.marginBottom = '0';
}
var details = reader.querySelector('div:last-child > div:nth-child(2)');
if (details) {
details.style.flexDirection = 'row';
details.style.gap = '12px';
}
}
});
}
window.addEventListener('resize', handleResize);
handleResize();
})();
</script>

View File

@@ -0,0 +1,301 @@
<div class="recorder-item-single {$options.css_class|default=''}" data-id="{$record.id|default=''}">
{if isset($record) && !empty($record)}
<div class="recorder-item-single__header">
<div class="recorder-item-single__type-wrapper">
<span class="recorder-item-single__type recorder-item-single__type--{$record.operation_type_label.class}">
<i class="layui-icon layui-icon-{$record.operation_type_label.icon|default='circle'}"></i>
{$record.operation_type_label.text}
</span>
</div>
<div class="recorder-item-single__time-wrapper">
<div class="recorder-item-single__time" title="{$record.created_at_formatted|default=''}">
{$record.created_at_relative|default=''}
</div>
<div class="recorder-item-single__datetime">
{$record.created_at_formatted|default=''}
</div>
</div>
</div>
<div class="recorder-item-single__content">
<div class="recorder-item-single__desc">
{$record.operation_desc|default=''}
</div>
<div class="recorder-item-single__meta">
{if $options.show_user|default=true && !empty($record.user_info)}
<div class="recorder-item-single__meta-item">
<i class="layui-icon layui-icon-username"></i>
<span class="recorder-item-single__meta-label">操作人:</span>
<span class="recorder-item-single__meta-value">{$record.user_info}</span>
</div>
{/if}
{if !empty($record.data_info)}
<div class="recorder-item-single__meta-item">
<i class="layui-icon layui-icon-template"></i>
<span class="recorder-item-single__meta-label">操作对象:</span>
<span class="recorder-item-single__meta-value">{$record.data_info}</span>
</div>
{/if}
{if !empty($record.related_info)}
<div class="recorder-item-single__meta-item">
<i class="layui-icon layui-icon-link"></i>
<span class="recorder-item-single__meta-label">关联数据:</span>
<span class="recorder-item-single__meta-value">{$record.related_info}</span>
</div>
{/if}
{if $options.show_ip|default=false && !empty($record.request_ip)}
<div class="recorder-item-single__meta-item">
<i class="layui-icon layui-icon-location"></i>
<span class="recorder-item-single__meta-label">IP地址:</span>
<span class="recorder-item-single__meta-value">{$record.request_ip}</span>
</div>
{/if}
{if !empty($record.request_url)}
<div class="recorder-item-single__meta-item">
<i class="layui-icon layui-icon-link"></i>
<span class="recorder-item-single__meta-label">请求地址:</span>
<span class="recorder-item-single__meta-value recorder-item-single__meta-url" title="{$record.request_url}">
{$record.request_url}
</span>
</div>
{/if}
</div>
{if $options.show_extra|default=false && !empty($record.extra_data)}
<div class="recorder-item-single__extra">
<details>
<summary>
<i class="layui-icon layui-icon-more"></i>
查看详细信息
</summary>
<div class="recorder-item-single__extra-content">
<pre>{:json_encode($record.extra_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)}</pre>
</div>
</details>
</div>
{/if}
</div>
{else}
<div class="recorder-item-single__empty">
<i class="layui-icon layui-icon-face-cry"></i>
<p>记录不存在</p>
</div>
{/if}
</div>
<style>
.recorder-item-single {
background: #fff;
border: 1px solid #e6e6e6;
border-radius: 6px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.recorder-item-single__header {
padding: 12px 15px;
background: #fafafa;
border-bottom: 1px solid #f0f0f0;
display: flex;
justify-content: space-between;
align-items: center;
}
.recorder-item-single__type {
display: inline-flex;
align-items: center;
font-size: 12px;
padding: 4px 10px;
border-radius: 16px;
color: #fff;
font-weight: 500;
}
.recorder-item-single__type .layui-icon {
font-size: 12px;
margin-right: 4px;
}
.recorder-item-single__type--success { background-color: #52c41a; }
.recorder-item-single__type--info { background-color: #1890ff; }
.recorder-item-single__type--warning { background-color: #faad14; }
.recorder-item-single__type--danger { background-color: #f5222d; }
.recorder-item-single__type--primary { background-color: #722ed1; }
.recorder-item-single__type--default { background-color: #8c8c8c; }
.recorder-item-single__time-wrapper {
text-align: right;
}
.recorder-item-single__time {
font-size: 12px;
color: #1890ff;
background: #e6f7ff;
padding: 2px 6px;
border-radius: 10px;
display: inline-block;
margin-bottom: 2px;
}
.recorder-item-single__datetime {
font-size: 11px;
color: #999;
}
.recorder-item-single__content {
padding: 15px;
}
.recorder-item-single__desc {
font-size: 14px;
color: #333;
font-weight: 500;
margin-bottom: 15px;
line-height: 1.5;
}
.recorder-item-single__meta {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 15px;
}
.recorder-item-single__meta-item {
display: flex;
align-items: flex-start;
font-size: 13px;
line-height: 1.4;
}
.recorder-item-single__meta-item .layui-icon {
font-size: 12px;
margin-right: 8px;
margin-top: 2px;
color: #999;
width: 14px;
flex-shrink: 0;
}
.recorder-item-single__meta-label {
margin-right: 6px;
color: #666;
font-weight: 500;
min-width: 60px;
flex-shrink: 0;
}
.recorder-item-single__meta-value {
color: #333;
flex: 1;
}
.recorder-item-single__meta-url {
word-break: break-all;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recorder-item-single__extra {
border-top: 1px solid #f0f0f0;
padding-top: 15px;
margin-top: 15px;
}
.recorder-item-single__extra details {
font-size: 12px;
}
.recorder-item-single__extra summary {
color: #1890ff;
cursor: pointer;
outline: none;
display: flex;
align-items: center;
padding: 4px 0;
user-select: none;
}
.recorder-item-single__extra summary:hover {
color: #40a9ff;
}
.recorder-item-single__extra summary .layui-icon {
margin-right: 4px;
}
.recorder-item-single__extra-content {
margin-top: 8px;
padding: 12px;
background: #f5f5f5;
border-radius: 4px;
border-left: 4px solid #1890ff;
}
.recorder-item-single__extra-content pre {
margin: 0;
font-size: 11px;
max-height: 300px;
overflow-y: auto;
line-height: 1.4;
color: #333;
}
.recorder-item-single__empty {
padding: 60px 20px;
text-align: center;
color: #999;
}
.recorder-item-single__empty .layui-icon {
font-size: 48px;
margin-bottom: 12px;
color: #ddd;
}
.recorder-item-single__empty p {
margin: 0;
font-size: 14px;
}
/* 紧凑模式 */
.recorder-item-single.recorder-item-single--compact .recorder-item-single__content {
padding: 10px 15px;
}
.recorder-item-single.recorder-item-single--compact .recorder-item-single__meta {
margin-bottom: 10px;
}
.recorder-item-single.recorder-item-single--compact .recorder-item-single__meta-item {
font-size: 12px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.recorder-item-single__header {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.recorder-item-single__time-wrapper {
align-self: flex-end;
}
.recorder-item-single__meta-label {
min-width: 50px;
}
.recorder-item-single__meta-url {
max-width: 200px;
}
}
</style>

View File

@@ -0,0 +1,200 @@
<div class="recorder-list recorder-list--card {$options.css_class|default=''}">
{if isset($records) && !empty($records)}
<div class="recorder-cards">
{volist name="records" id="record"}
<div class="recorder-card" data-id="{$record.id}">
<div class="recorder-card__header">
<div class="recorder-card__type recorder-card__type--{$record.operation_type_label.class}">
<i class="layui-icon layui-icon-{$record.operation_type_label.icon|default='circle'}"></i>
{$record.operation_type_label.text}
</div>
<div class="recorder-card__time">
{$record.created_at_relative}
</div>
</div>
<div class="recorder-card__body">
<div class="recorder-card__desc">{$record.operation_desc}</div>
<div class="recorder-card__meta">
{if $options.show_user && !empty($record.user_info)}
<div class="recorder-card__meta-item">
<i class="layui-icon layui-icon-username"></i>
<span>{$record.user_info}</span>
</div>
{/if}
{if !empty($record.data_info)}
<div class="recorder-card__meta-item">
<i class="layui-icon layui-icon-template"></i>
<span>{$record.data_info}</span>
</div>
{/if}
{if $options.show_ip && !empty($record.request_ip)}
<div class="recorder-card__meta-item">
<i class="layui-icon layui-icon-location"></i>
<span>{$record.request_ip}</span>
</div>
{/if}
</div>
</div>
<div class="recorder-card__footer">
<time class="recorder-card__datetime" datetime="{$record.created_at}">
{$record.created_at_formatted}
</time>
{if !empty($record.related_info)}
<div class="recorder-card__related">
关联: {$record.related_info}
</div>
{/if}
</div>
</div>
{/volist}
</div>
{else}
<div class="recorder-empty recorder-empty--card">
<i class="layui-icon layui-icon-face-cry"></i>
<p>暂无操作记录</p>
</div>
{/if}
</div>
<style>
.recorder-list--card {
margin: 0;
padding: 0;
}
.recorder-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
@media (max-width: 768px) {
.recorder-cards {
grid-template-columns: 1fr;
}
}
.recorder-card {
background: #fff;
border: 1px solid #e6e6e6;
border-radius: 8px;
padding: 0;
transition: all 0.3s ease;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.recorder-card:hover {
border-color: #1890ff;
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.15);
transform: translateY(-2px);
}
.recorder-card__header {
padding: 12px 15px;
background: #fafafa;
border-bottom: 1px solid #f0f0f0;
display: flex;
justify-content: space-between;
align-items: center;
}
.recorder-card__type {
display: flex;
align-items: center;
font-size: 12px;
padding: 4px 10px;
border-radius: 16px;
color: #fff;
font-weight: 500;
}
.recorder-card__type .layui-icon {
font-size: 12px;
margin-right: 4px;
}
.recorder-card__type--success { background-color: #52c41a; }
.recorder-card__type--info { background-color: #1890ff; }
.recorder-card__type--warning { background-color: #faad14; }
.recorder-card__type--danger { background-color: #f5222d; }
.recorder-card__type--primary { background-color: #722ed1; }
.recorder-card__type--default { background-color: #8c8c8c; }
.recorder-card__time {
font-size: 12px;
color: #999;
font-weight: normal;
}
.recorder-card__body {
padding: 15px;
}
.recorder-card__desc {
font-size: 14px;
color: #333;
font-weight: 500;
margin-bottom: 12px;
line-height: 1.5;
}
.recorder-card__meta {
display: flex;
flex-direction: column;
gap: 6px;
}
.recorder-card__meta-item {
display: flex;
align-items: center;
font-size: 12px;
color: #666;
}
.recorder-card__meta-item .layui-icon {
font-size: 12px;
margin-right: 6px;
color: #999;
}
.recorder-card__footer {
padding: 10px 15px;
background: #fafafa;
border-top: 1px solid #f0f0f0;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 11px;
color: #999;
}
.recorder-card__datetime {
font-style: normal;
}
.recorder-card__related {
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recorder-empty--card {
grid-column: 1 / -1;
text-align: center;
padding: 60px 0;
color: #999;
}
.recorder-empty--card .layui-icon {
font-size: 64px;
margin-bottom: 15px;
color: #ddd;
}
.recorder-empty--card p {
font-size: 14px;
margin: 0;
}
</style>

View File

@@ -0,0 +1,206 @@
<div class="recorder-container" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; font-size: 14px; line-height: 1.5; color: #262626;">
<div class="recorder-list {$options.css_class|default=''} {$options.compact ? 'recorder-list--compact' : ''}" style="margin: 0; padding: 0; background: transparent;">
{if isset($records) && !empty($records)}
{volist name="records" id="record"}
<div class="recorder-item {$options.theme == 'card' ? 'recorder-item--card' : ''} recorder-fade-in" data-id="{$record.id}"
style="background: #ffffff; border: 1px solid #e6e6e6; border-radius: 6px; margin-bottom: 12px; padding: 16px; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); position: relative; overflow: hidden; {$options.theme == 'card' ? 'border-radius: 8px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.06); border: none;' : ''}"
onmouseover="this.style.borderColor='#1890ff'; this.style.boxShadow='0 2px 8px rgba(24, 144, 255, 0.1)'; this.style.transform='translateY(-1px)';"
onmouseout="this.style.borderColor='#e6e6e6'; this.style.boxShadow='none'; this.style.transform='translateY(0)';">
{if $options.theme == 'card'}
<div style="position: absolute; top: 0; left: 0; right: 0; height: 3px; background: linear-gradient(90deg, #1890ff, #52c41a);"></div>
{/if}
<div class="recorder-item__header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; padding-bottom: 8px; border-bottom: 1px solid #f0f0f0;">
<span class="recorder-item__type recorder-item__type--{$record.operation_type_label.class}"
style="font-size: 10px; padding: 4px 8px; border-radius: 12px; color: #ffffff; font-weight: 500; text-transform: uppercase; letter-spacing: 0.5px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
{switch name="record.operation_type_label.class"}
{case value="success"}background: linear-gradient(135deg, #52c41a, #73d13d);{/case}
{case value="info"}background: linear-gradient(135deg, #1890ff, #40a9ff);{/case}
{case value="warning"}background: linear-gradient(135deg, #faad14, #ffc53d);{/case}
{case value="danger"}background: linear-gradient(135deg, #f5222d, #ff7875);{/case}
{case value="primary"}background: linear-gradient(135deg, #722ed1, #9254de);{/case}
{default}background: linear-gradient(135deg, #8c8c8c, #a6a6a6);{/case}
{/switch}">
{$record.operation_type_label.text}
</span>
<span class="recorder-item__time" title="{$record.created_at_formatted}" style="font-size: 12px; color: #8c8c8c; font-weight: 400;">
{$record.created_at_relative}
</span>
</div>
<div class="recorder-item__content" style="font-size: 12px; line-height: 1.6;">
<div class="recorder-item__desc" style="color: #262626; font-weight: 500; margin-bottom: 4px; font-size: 14px;">{$record.operation_desc}</div>
{if $options.show_user && !empty($record.user_info)}
<div class="recorder-item__user" style="font-size: 12px; color: #8c8c8c; margin-bottom: 4px; display: flex; align-items: center; gap: 4px;">
<i class="layui-icon layui-icon-username" style="font-size: 12px; color: #1890ff;"></i> {$record.user_info}
</div>
{/if}
{if !empty($record.data_info)}
<div class="recorder-item__data" style="font-size: 12px; color: #8c8c8c; margin-bottom: 4px; display: flex; align-items: center; gap: 4px;">
<i class="layui-icon layui-icon-template" style="font-size: 12px; color: #1890ff;"></i> {$record.data_info}
</div>
{/if}
{if !empty($record.related_info)}
<div class="recorder-item__related" style="font-size: 12px; color: #8c8c8c; margin-bottom: 4px; display: flex; align-items: center; gap: 4px;">
<i class="layui-icon layui-icon-link" style="font-size: 12px; color: #1890ff;"></i> 关联:{$record.related_info}
</div>
{/if}
{if $options.show_ip && !empty($record.request_ip)}
<div class="recorder-item__ip" style="font-size: 12px; color: #8c8c8c; margin-bottom: 4px; display: flex; align-items: center; gap: 4px;">
<i class="layui-icon layui-icon-location" style="font-size: 12px; color: #1890ff;"></i> {$record.request_ip}
</div>
{/if}
{if $options.show_extra && !empty($record.extra_data)}
<div class="recorder-item__extra" style="margin-top: 8px; font-size: 12px;">
<i class="layui-icon layui-icon-more"></i>
<details style="margin-top: 4px; border: 1px solid #f0f0f0; border-radius: 4px; overflow: hidden;">
<summary style="color: #1890ff; cursor: pointer; outline: none; padding: 8px; background: #fafafa; font-weight: 500; transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);"
onmouseover="this.style.background='#f5f5f5';"
onmouseout="this.style.background='#fafafa';">额外信息</summary>
<pre style="background: #f5f5f5; padding: 12px; margin: 0; font-size: 10px; max-height: 200px; overflow-y: auto; border: none; font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;">{:json_encode($record.extra_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)}</pre>
</details>
</div>
{/if}
</div>
</div>
{/volist}
{else}
<div class="recorder-empty" style="text-align: center; padding: 20px; color: #8c8c8c; background: #fafafa; border-radius: 6px;">
<i class="layui-icon layui-icon-face-cry" style="font-size: 48px; margin-bottom: 12px; color: #d9d9d9; display: block;"></i>
<p style="margin: 0; font-size: 14px; color: #8c8c8c;">暂无操作记录</p>
</div>
{/if}
</div>
</div>
<style>
@keyframes recorderFadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.recorder-fade-in {
animation: recorderFadeIn 0.3s ease-in-out;
}
{if $options.compact}
.recorder-list--compact .recorder-item {
padding: 8px 12px !important;
margin-bottom: 6px !important;
}
.recorder-list--compact .recorder-item__header {
margin-bottom: 4px !important;
padding-bottom: 4px !important;
}
.recorder-list--compact .recorder-item__desc {
margin-bottom: 4px !important;
}
.recorder-list--compact .recorder-item__user,
.recorder-list--compact .recorder-item__data,
.recorder-list--compact .recorder-item__related {
display: none !important;
}
{/if}
@media (max-width: 768px) {
.recorder-item {
padding: 12px !important;
margin-bottom: 8px !important;
}
.recorder-item__header {
flex-direction: column !important;
align-items: flex-start !important;
gap: 4px !important;
}
.recorder-item__type {
margin-bottom: 4px !important;
}
}
</style>
<script>
(function() {
'use strict';
// 记录项点击处理
document.addEventListener('click', function(e) {
var item = e.target.closest('.recorder-item');
if (item) {
// 移除其他选中状态
document.querySelectorAll('.recorder-item.selected').forEach(function(el) {
el.classList.remove('selected');
el.style.borderColor = '#e6e6e6';
el.style.backgroundColor = '#ffffff';
});
// 添加选中状态
item.classList.add('selected');
item.style.borderColor = '#1890ff';
item.style.backgroundColor = '#f0f9ff';
// 触发自定义事件
var event = new CustomEvent('recorderItemSelect', {
detail: {
recordId: item.dataset.id,
element: item
}
});
document.dispatchEvent(event);
}
});
// 详情展开/收起处理
document.addEventListener('toggle', function(e) {
if (e.target.tagName === 'DETAILS') {
var summary = e.target.querySelector('summary');
if (e.target.open) {
summary.style.color = '#1890ff';
} else {
summary.style.color = '';
}
}
});
// 为新添加的元素添加动画
function addAnimation() {
var items = document.querySelectorAll('.recorder-item:not(.animated)');
items.forEach(function(item, index) {
item.style.animationDelay = (index * 50) + 'ms';
item.classList.add('recorder-fade-in', 'animated');
});
}
// 初始化动画
setTimeout(addAnimation, 100);
// 响应式处理
function handleResize() {
var isMobile = window.innerWidth <= 768;
var items = document.querySelectorAll('.recorder-item');
items.forEach(function(item) {
if (isMobile) {
item.style.padding = '12px';
item.style.marginBottom = '8px';
var header = item.querySelector('.recorder-item__header');
if (header) {
header.style.flexDirection = 'column';
header.style.alignItems = 'flex-start';
header.style.gap = '4px';
}
} else {
item.style.padding = '16px';
item.style.marginBottom = '12px';
var header = item.querySelector('.recorder-item__header');
if (header) {
header.style.flexDirection = 'row';
header.style.alignItems = 'center';
header.style.gap = '0';
}
}
});
}
window.addEventListener('resize', handleResize);
handleResize();
})();
</script>

View File

@@ -0,0 +1,245 @@
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; font-size: 14px; line-height: 1.5; color: #262626;">
<div class="recorder-timeline {$options.css_class|default=''} {$options.compact ? 'recorder-timeline--compact' : ''}" style="position: relative; padding: 0; margin: 0;">
{if isset($records) && !empty($records)}
<div style="position: relative; padding-left: 30px;">
<!-- 时间线主线 -->
<div style="content: ''; position: absolute; left: 15px; top: 0; bottom: 0; width: 2px; background: linear-gradient(to bottom, #e6e6e6 0%, #e6e6e6 100%); z-index: 1;"></div>
{volist name="records" id="record"}
<div class="recorder-timeline__item recorder-fade-in" style="position: relative; padding-bottom: {$options.compact ? '16px' : '24px'}; margin-bottom: {$options.compact ? '12px' : '16px'};">
<!-- 时间线标记点 -->
<div style="position: absolute; left: -22px; top: 4px; width: 16px; height: 16px; border-radius: 50%; border: 2px solid #fff; display: flex; align-items: center; justify-content: center; z-index: 2; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
{switch name="record.operation_type_label.class"}
{case value="success"}background-color: #52c41a;{/case}
{case value="info"}background-color: #1890ff;{/case}
{case value="warning"}background-color: #faad14;{/case}
{case value="danger"}background-color: #f5222d;{/case}
{case value="primary"}background-color: #722ed1;{/case}
{default}background-color: #8c8c8c;{/case}
{/switch}">
<i class="layui-icon layui-icon-{$record.operation_type_label.icon|default='circle'}" style="font-size: 10px; color: #fff;"></i>
</div>
<!-- 时间线内容 -->
<div style="background: #fff; border: 1px solid #e6e6e6; border-radius: 6px; padding: 0; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); transition: all 0.3s ease; cursor: pointer;"
onmouseover="this.style.borderColor='#d9d9d9'; this.style.boxShadow='0 2px 6px rgba(0, 0, 0, 0.1)';"
onmouseout="this.style.borderColor='#e6e6e6'; this.style.boxShadow='0 1px 3px rgba(0, 0, 0, 0.05)';">
<!-- 头部 -->
<div style="padding: 12px 15px; background: #fafafa; border-bottom: 1px solid #f0f0f0; display: flex; justify-content: space-between; align-items: center;">
<div style="display: flex; align-items: center; gap: 12px;">
<span style="font-weight: 500; font-size: 13px; color: #333;">{$record.operation_type_label.text}</span>
<span style="font-size: 12px; color: #1890ff; background: #e6f7ff; padding: 2px 6px; border-radius: 10px;">{$record.created_at_relative}</span>
</div>
<div style="font-size: 11px; color: #999;" title="{$record.created_at_formatted}">{$record.created_at_formatted}</div>
</div>
<!-- 主体内容 -->
<div style="padding: {$options.compact ? '10px 15px' : '15px'};">
<div style="font-size: 14px; color: #333; margin-bottom: 12px; line-height: 1.5;">{$record.operation_desc}</div>
{if !$options.compact}
<div style="display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px;">
{if $options.show_user && !empty($record.user_info)}
<div style="display: flex; align-items: center; font-size: 12px; color: #666;">
<i class="layui-icon layui-icon-username" style="font-size: 12px; margin-right: 6px; color: #999; width: 14px;"></i>
<span style="margin-right: 4px; font-weight: 500;">操作人:</span>
<span style="color: #333;">{$record.user_info}</span>
</div>
{/if}
{if !empty($record.data_info)}
<div style="display: flex; align-items: center; font-size: 12px; color: #666;">
<i class="layui-icon layui-icon-template" style="font-size: 12px; margin-right: 6px; color: #999; width: 14px;"></i>
<span style="margin-right: 4px; font-weight: 500;">操作对象:</span>
<span style="color: #333;">{$record.data_info}</span>
</div>
{/if}
{if !empty($record.related_info)}
<div style="display: flex; align-items: center; font-size: 12px; color: #666;">
<i class="layui-icon layui-icon-link" style="font-size: 12px; margin-right: 6px; color: #999; width: 14px;"></i>
<span style="margin-right: 4px; font-weight: 500;">关联数据:</span>
<span style="color: #333;">{$record.related_info}</span>
</div>
{/if}
{if $options.show_ip && !empty($record.request_ip)}
<div style="display: flex; align-items: center; font-size: 12px; color: #666;">
<i class="layui-icon layui-icon-location" style="font-size: 12px; margin-right: 6px; color: #999; width: 14px;"></i>
<span style="margin-right: 4px; font-weight: 500;">IP地址:</span>
<span style="color: #333;">{$record.request_ip}</span>
</div>
{/if}
</div>
{/if}
{if $options.show_extra && !empty($record.extra_data)}
<div style="border-top: 1px solid #f0f0f0; padding-top: 12px; margin-top: 12px;">
<details style="font-size: 12px;">
<summary style="color: #1890ff; cursor: pointer; outline: none; display: flex; align-items: center; padding: 4px 0;"
onmouseover="this.style.color='#40a9ff';"
onmouseout="this.style.color='#1890ff';">
<i class="layui-icon layui-icon-more" style="margin-right: 4px;"></i>
查看详细信息
</summary>
<div style="margin-top: 8px; padding: 8px; background: #f5f5f5; border-radius: 4px;">
<pre style="margin: 0; font-size: 11px; max-height: 200px; overflow-y: auto; font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;">{:json_encode($record.extra_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)}</pre>
</div>
</details>
</div>
{/if}
</div>
</div>
</div>
{/volist}
</div>
{else}
<div style="position: relative; padding-left: 30px; text-align: center;">
<div style="position: absolute; left: 7px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; border-radius: 50%; background-color: #ddd; border: 2px solid #fff; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);"></div>
<div style="padding: 40px 20px; color: #999;">
<i class="layui-icon layui-icon-face-cry" style="font-size: 48px; margin-bottom: 10px; color: #ddd; display: block;"></i>
<p style="margin: 0; font-size: 14px;">暂无操作记录</p>
</div>
</div>
{/if}
</div>
</div>
<style>
@keyframes recorderFadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.recorder-fade-in {
animation: recorderFadeIn 0.3s ease-in-out;
}
/* 最后一个时间线项目的特殊处理 */
.recorder-timeline__item:last-child {
margin-bottom: 0 !important;
padding-bottom: 0 !important;
}
/* 响应式设计 */
@media (max-width: 768px) {
.recorder-timeline {
font-size: 13px !important;
}
.recorder-timeline__item > div:nth-child(2) > div:first-child {
flex-direction: column !important;
align-items: flex-start !important;
gap: 6px !important;
}
.recorder-timeline__item > div:nth-child(2) > div:first-child > div:last-child {
align-self: flex-end !important;
}
}
</style>
<script>
(function() {
'use strict';
// 时间线项目点击处理
document.addEventListener('click', function(e) {
var timelineItem = e.target.closest('.recorder-timeline__item');
if (timelineItem) {
var content = timelineItem.querySelector('div:nth-child(2)');
if (content) {
// 移除其他选中状态
document.querySelectorAll('.recorder-timeline__item.selected').forEach(function(el) {
var itemContent = el.querySelector('div:nth-child(2)');
if (itemContent) {
el.classList.remove('selected');
itemContent.style.borderColor = '#e6e6e6';
itemContent.style.backgroundColor = '#fff';
}
});
// 添加选中状态
timelineItem.classList.add('selected');
content.style.borderColor = '#1890ff';
content.style.backgroundColor = '#f0f9ff';
// 触发自定义事件
var event = new CustomEvent('recorderTimelineSelect', {
detail: {
element: timelineItem,
content: content
}
});
document.dispatchEvent(event);
}
}
});
// 详情展开/收起处理
document.addEventListener('toggle', function(e) {
if (e.target.tagName === 'DETAILS') {
var summary = e.target.querySelector('summary');
if (e.target.open) {
summary.style.color = '#1890ff';
} else {
summary.style.color = '';
}
}
});
// 时间线标记hover提示
document.addEventListener('mouseover', function(e) {
var marker = e.target.closest('.recorder-timeline__item > div:first-child');
if (marker) {
var item = marker.closest('.recorder-timeline__item');
var typeText = item.querySelector('div:nth-child(2) span:first-child');
var timeText = item.querySelector('div:nth-child(2) span:nth-child(2)');
if (typeText && timeText) {
marker.setAttribute('title', typeText.textContent + ' - ' + timeText.textContent);
}
}
});
// 为新添加的元素添加动画
function addAnimation() {
var items = document.querySelectorAll('.recorder-timeline__item:not(.animated)');
items.forEach(function(item, index) {
item.style.animationDelay = (index * 100) + 'ms';
item.classList.add('recorder-fade-in', 'animated');
});
}
// 初始化动画
setTimeout(addAnimation, 100);
// 响应式处理
function handleResize() {
var isMobile = window.innerWidth <= 768;
var headers = document.querySelectorAll('.recorder-timeline__item > div:nth-child(2) > div:first-child');
headers.forEach(function(header) {
if (isMobile) {
header.style.flexDirection = 'column';
header.style.alignItems = 'flex-start';
header.style.gap = '6px';
var datetime = header.querySelector('div:last-child');
if (datetime) {
datetime.style.alignSelf = 'flex-end';
}
} else {
header.style.flexDirection = 'row';
header.style.alignItems = 'center';
header.style.gap = '12px';
var datetime = header.querySelector('div:last-child');
if (datetime) {
datetime.style.alignSelf = 'auto';
}
}
});
}
window.addEventListener('resize', handleResize);
handleResize();
})();
</script>