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,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>