Files
rnpay/wallets_config.html
T
2026-09-15 15:49:47 +08:00

414 lines
16 KiB
HTML
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.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>钱包配置</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f4f6f8;color:#222;min-height:100vh;padding:20px 16px 40px}
.wrap{max-width:920px;margin:0 auto}
h1{font-size:20px;margin-bottom:4px}
.sub{font-size:12px;color:#666;margin-bottom:16px}
.toolbar{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:16px}
.toolbar input,.toolbar select{padding:8px 10px;border:1px solid #ddd;border-radius:8px;font-size:13px;background:#fff}
.toolbar input{min-width:160px}
.btn{padding:8px 14px;border:none;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;background:#2563eb;color:#fff}
.btn:hover{opacity:.92}
.btn.secondary{background:#fff;color:#333;border:1px solid #ddd}
.btn.danger{background:#dc2626}
.btn.sm{padding:5px 10px;font-size:12px}
.card{background:#fff;border-radius:12px;padding:16px;margin-bottom:12px;box-shadow:0 2px 8px rgba(0,0,0,.06)}
.card-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:12px}
.wallet-name{font-size:16px;font-weight:700}
.wallet-actions{display:flex;gap:6px;flex-wrap:wrap}
.mode-block{border:1px solid #eee;border-radius:10px;padding:12px;margin-bottom:8px;background:#fafafa}
.mode-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px}
.mode-tag{font-size:12px;font-weight:700;padding:3px 8px;border-radius:6px;background:#dbeafe;color:#1d4ed8;text-transform:uppercase}
.mode-tag.otp{background:#fef3c7;color:#b45309}
.toggles{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.toggle{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:#fff;border:1px solid #e5e7eb;border-radius:8px}
.toggle label{font-size:13px;font-weight:600;color:#444}
.switch{position:relative;width:44px;height:24px;flex-shrink:0}
.switch input{opacity:0;width:0;height:0}
.slider{position:absolute;inset:0;background:#cbd5e1;border-radius:999px;cursor:pointer;transition:.2s}
.slider:before{content:'';position:absolute;width:18px;height:18px;left:3px;top:3px;background:#fff;border-radius:50%;transition:.2s}
.switch input:checked+.slider{background:#22c55e}
.switch input:checked+.slider:before{transform:translateX(20px)}
.switch input:disabled+.slider{background:#e5e7eb;cursor:not-allowed;opacity:.75}
.switch input:disabled:checked+.slider{background:#86efac;opacity:.55}
.wallet-switches{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:14px;padding:12px;background:#f8fafc;border:1px solid #e5e7eb;border-radius:10px}
.wallet-switches .toggle{background:#fff}
.wallet-switches .toggle.disabled{opacity:.55}
.wallet-switches .toggle.disabled label{color:#94a3b8}
.card.off{opacity:.72}
.card.off .wallet-name{color:#64748b}
.empty{text-align:center;padding:40px 16px;color:#888;font-size:14px;border:1px dashed #ddd;border-radius:12px;background:#fff}
.add-mode{display:flex;gap:8px;margin-top:8px}
.add-mode select{flex:1;padding:8px;border:1px solid #ddd;border-radius:8px}
.whitelist{margin-top:12px;padding-top:12px;border-top:1px dashed #e5e7eb}
.whitelist-title{font-size:12px;font-weight:700;color:#555;margin-bottom:8px}
.whitelist-hint{font-size:11px;color:#888;margin-bottom:8px}
.whitelist-tags{display:flex;flex-wrap:wrap;gap:6px;min-height:28px;margin-bottom:8px}
.tag{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;background:#eef2ff;color:#3730a3;border-radius:999px;font-size:12px;font-weight:600}
.tag button{border:none;background:transparent;color:#6366f1;cursor:pointer;font-size:14px;line-height:1;padding:0}
.tag.empty-tag{background:#f3f4f6;color:#888;font-weight:500}
.whitelist-add{display:flex;gap:8px}
.whitelist-add input{flex:1;padding:8px 10px;border:1px solid #ddd;border-radius:8px;font-size:13px}
.json-box{margin-top:16px}
.json-box textarea{width:100%;min-height:160px;padding:12px;border:1px solid #ddd;border-radius:10px;font-family:ui-monospace,monospace;font-size:12px;line-height:1.5;resize:vertical}
.toast{position:fixed;top:16px;right:16px;padding:10px 14px;border-radius:8px;color:#fff;font-size:13px;z-index:99;opacity:0;transform:translateY(-8px);transition:.25s;pointer-events:none}
.toast.show{opacity:1;transform:none}
.toast.ok{background:#16a34a}.toast.err{background:#dc2626}
</style>
</head>
<body>
<div class="wrap">
<h1>钱包配置</h1>
<p class="sub">钱包总开关 · 代收/代付调度 · app/otp · payin/payout · userId 白名单</p>
<div class="toolbar">
<input id="newWalletName" placeholder="新钱包名,如 paytm">
<button class="btn" onclick="addWallet()">添加钱包</button>
<button class="btn secondary" onclick="importJson()">导入 JSON</button>
<button class="btn secondary" onclick="exportJson()">导出 JSON</button>
<button class="btn secondary" onclick="copyJson()">复制 JSON</button>
<input type="file" id="fileInput" accept=".json,application/json" hidden>
</div>
<div id="walletList"></div>
<div id="emptyHint" class="empty" style="display:none">暂无钱包,上方添加</div>
<div class="card json-box">
<div class="card-head"><strong>JSON 预览</strong></div>
<textarea id="jsonPreview" readonly></textarea>
</div>
</div>
<div id="toast" class="toast"></div>
<script>
const DEFAULT_CONFIG = {
"paytm": { "app": { "payin": true, "payout": true }, "otp": { "payin": true, "payout": false } },
"phonepe": { "app": { "payin": true, "payout": true }, "otp": { "payin": true, "payout": false } },
"mobikwik": { "app": { "payin": true, "payout": true }, "otp": { "payin": true, "payout": true } },
"freecharge": { "otp": { "payin": true, "payout": true } },
"amazonpay": { "app": { "payin": true, "payout": true } },
"bhartepe business": { "app": { "payin": true, "payout": false } },
"googlepay business": { "app": { "payin": true, "payout": false } },
"paytm business": { "app": { "payin": true, "payout": false }, "otp": { "payin": true, "payout": false } },
"phonepe business": { "app": { "payin": true, "payout": false } }
};
let config = structuredClone(DEFAULT_CONFIG);
const MODES = ['app', 'otp'];
const FLAGS = ['payin', 'payout'];
function toast(msg, type = 'ok') {
const el = document.getElementById('toast');
el.textContent = msg;
el.className = 'toast show ' + type;
clearTimeout(el._t);
el._t = setTimeout(() => el.classList.remove('show'), 2200);
}
function normalizeUserIds(list) {
if (!Array.isArray(list)) return [];
const seen = new Set();
const out = [];
for (const item of list) {
const id = String(item).trim();
if (!/^\d+$/.test(id) || seen.has(id)) continue;
seen.add(id);
out.push(Number(id));
}
return out.sort((a, b) => a - b);
}
function hasPayinCapability(w) {
return MODES.some(m => w[m]?.payin);
}
function hasPayoutCapability(w) {
return MODES.some(m => w[m]?.payout);
}
function ensureWalletMeta(w) {
if (w.enabled === undefined) w.enabled = true;
if (w.payinDispatch === undefined) w.payinDispatch = true;
if (w.payoutDispatch === undefined) w.payoutDispatch = false;
if (!hasPayoutCapability(w)) w.payoutDispatch = false;
if (!hasPayinCapability(w)) w.payinDispatch = false;
}
function normalizeWallet(w) {
const out = {};
out.enabled = w.enabled !== false;
out.payinDispatch = !!w.payinDispatch;
out.payoutDispatch = !!w.payoutDispatch;
const whitelist = normalizeUserIds(w.userIdWhitelist);
if (whitelist.length) out.userIdWhitelist = whitelist;
for (const mode of MODES) {
if (!w[mode]) continue;
out[mode] = { payin: !!w[mode].payin, payout: !!w[mode].payout };
}
if (!hasPayoutCapability(out)) out.payoutDispatch = false;
if (!hasPayinCapability(out)) out.payinDispatch = false;
return out;
}
function normalizeConfig(raw) {
const out = {};
for (const [name, wallet] of Object.entries(raw || {})) {
const n = normalizeWallet(wallet);
const hasModes = MODES.some(m => n[m]);
if (hasModes || n.userIdWhitelist?.length) out[name] = n;
}
return out;
}
function walletModes(w) {
return MODES.filter(m => w[m]);
}
function render() {
const list = document.getElementById('walletList');
const names = Object.keys(config);
document.getElementById('emptyHint').style.display = names.length ? 'none' : 'block';
list.innerHTML = names.map(name => {
const w = config[name];
ensureWalletMeta(w);
const modes = walletModes(w);
const canPayin = hasPayinCapability(w);
const canPayout = hasPayoutCapability(w);
const masterHtml = `
<div class="wallet-switches">
<div class="toggle">
<label>钱包可用</label>
<label class="switch">
<input type="checkbox" ${w.enabled ? 'checked' : ''} onchange="setWalletMeta('${esc(name)}','enabled',this.checked)">
<span class="slider"></span>
</label>
</div>
<div class="toggle ${canPayin ? '' : 'disabled'}">
<label>代收调度</label>
<label class="switch">
<input type="checkbox" ${w.payinDispatch ? 'checked' : ''} ${canPayin ? '' : 'disabled'}
onchange="setWalletMeta('${esc(name)}','payinDispatch',this.checked)">
<span class="slider"></span>
</label>
</div>
<div class="toggle ${canPayout ? '' : 'disabled'}">
<label>代付调度</label>
<label class="switch">
<input type="checkbox" ${w.payoutDispatch ? 'checked' : ''} ${canPayout ? '' : 'disabled'}
onchange="setWalletMeta('${esc(name)}','payoutDispatch',this.checked)">
<span class="slider"></span>
</label>
</div>
</div>`;
const whitelist = w.userIdWhitelist || [];
const whitelistHtml = `
<div class="whitelist">
<div class="whitelist-title">userId 白名单</div>
<div class="whitelist-hint">留空表示不限制;有值时仅 listed userId 可用该钱包</div>
<div class="whitelist-tags">
${whitelist.length ? whitelist.map(uid => `
<span class="tag">${esc(uid)}<button type="button" onclick="removeUserId('${esc(name)}','${esc(uid)}')" title="移除">×</button></span>
`).join('') : '<span class="tag empty-tag">未限制</span>'}
</div>
<div class="whitelist-add">
<input id="uid_${css(name)}" placeholder="userId,如 10000" inputmode="numeric" pattern="\\d*"
onkeydown="if(event.key==='Enter'){event.preventDefault();addUserId('${esc(name)}')}">
<button class="btn sm secondary" type="button" onclick="addUserId('${esc(name)}')">添加</button>
</div>
</div>`;
const modeHtml = modes.map(mode => `
<div class="mode-block">
<div class="mode-head">
<span class="mode-tag ${mode}">${mode}</span>
<button class="btn sm danger" onclick="removeMode('${esc(name)}','${mode}')">删除模式</button>
</div>
<div class="toggles">
${FLAGS.map(flag => `
<div class="toggle">
<label>${flag}</label>
<label class="switch">
<input type="checkbox" ${w[mode][flag] ? 'checked' : ''}
onchange="setFlag('${esc(name)}','${mode}','${flag}',this.checked)">
<span class="slider"></span>
</label>
</div>`).join('')}
</div>
</div>`).join('');
const missing = MODES.filter(m => !w[m]);
const addModeHtml = missing.length ? `
<div class="add-mode">
<select id="addMode_${css(name)}">${missing.map(m => `<option value="${m}">${m}</option>`).join('')}</select>
<button class="btn sm secondary" onclick="addMode('${esc(name)}')">添加模式</button>
</div>` : '';
return `<div class="card ${w.enabled ? '' : 'off'}" data-wallet="${esc(name)}">
<div class="card-head">
<div class="wallet-name">${esc(name)}</div>
<div class="wallet-actions">
<button class="btn sm secondary" onclick="renameWallet('${esc(name)}')">重命名</button>
<button class="btn sm danger" onclick="removeWallet('${esc(name)}')">删除钱包</button>
</div>
</div>
${masterHtml}
${modeHtml || '<div style="color:#888;font-size:13px;margin-bottom:8px">暂无模式</div>'}
${addModeHtml}
${whitelistHtml}
</div>`;
}).join('');
document.getElementById('jsonPreview').value = JSON.stringify(exportConfig(), null, 4);
}
function exportConfig() {
const out = {};
for (const [name, wallet] of Object.entries(config)) {
out[name] = normalizeWallet(wallet);
}
return out;
}
function esc(s) { return String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'"); }
function css(s) { return String(s).replace(/[^a-zA-Z0-9_-]/g, '_'); }
function addWallet() {
const input = document.getElementById('newWalletName');
const name = input.value.trim();
if (!name) return toast('请输入钱包名', 'err');
if (config[name]) return toast('钱包已存在', 'err');
config[name] = { enabled: true, payinDispatch: true, payoutDispatch: false, app: { payin: true, payout: false }, userIdWhitelist: [] };
input.value = '';
render();
toast('已添加 ' + name);
}
function removeWallet(name) {
if (!confirm('删除钱包「' + name + '」?')) return;
delete config[name];
render();
}
function renameWallet(name) {
const next = prompt('新名称', name);
if (!next || next.trim() === name) return;
const n = next.trim();
if (config[n]) return toast('名称已存在', 'err');
config[n] = config[name];
delete config[name];
render();
}
function addMode(name) {
const sel = document.getElementById('addMode_' + css(name));
const mode = sel.value;
if (config[name][mode]) return toast('模式已存在', 'err');
config[name][mode] = { payin: true, payout: false };
render();
toast('已添加 ' + mode);
}
function removeMode(name, mode) {
if (!confirm('删除「' + name + '」的 ' + mode + ' 模式?')) return;
delete config[name][mode];
if (!walletModes(config[name]).length && !(config[name].userIdWhitelist || []).length) delete config[name];
render();
}
function ensureWhitelist(name) {
if (!config[name].userIdWhitelist) config[name].userIdWhitelist = [];
}
function addUserId(name) {
const input = document.getElementById('uid_' + css(name));
const id = (input?.value || '').trim();
if (!/^\d+$/.test(id)) return toast('userId 必须是数字', 'err');
ensureWhitelist(name);
const numId = Number(id);
if (config[name].userIdWhitelist.includes(numId)) return toast('userId 已在白名单', 'err');
config[name].userIdWhitelist.push(numId);
config[name].userIdWhitelist = normalizeUserIds(config[name].userIdWhitelist);
if (input) input.value = '';
render();
toast('已添加 userId ' + id);
}
function removeUserId(name, uid) {
ensureWhitelist(name);
config[name].userIdWhitelist = config[name].userIdWhitelist.filter(x => x !== Number(uid));
if (!config[name].userIdWhitelist.length) delete config[name].userIdWhitelist;
render();
}
function setWalletMeta(name, key, val) {
if (!config[name]) return;
config[name][key] = val;
ensureWalletMeta(config[name]);
render();
}
function setFlag(name, mode, flag, val) {
if (!config[name]?.[mode]) return;
config[name][mode][flag] = val;
ensureWalletMeta(config[name]);
render();
}
function exportJson() {
const blob = new Blob([JSON.stringify(exportConfig(), null, 4)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'wallets_config.json';
a.click();
URL.revokeObjectURL(a.href);
toast('已导出');
}
function copyJson() {
navigator.clipboard.writeText(JSON.stringify(exportConfig(), null, 4)).then(() => toast('已复制'));
}
function importJson() {
document.getElementById('fileInput').click();
}
document.getElementById('fileInput').addEventListener('change', e => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
config = normalizeConfig(JSON.parse(reader.result));
Object.values(config).forEach(ensureWalletMeta);
render();
toast('导入成功');
} catch {
toast('JSON 格式错误', 'err');
}
e.target.value = '';
};
reader.readAsText(file);
});
async function tryLoadLocal() {
try {
const res = await fetch('wallets_config.json?_=' + Date.now());
if (res.ok) {
config = normalizeConfig(await res.json());
Object.values(config).forEach(ensureWalletMeta);
}
} catch (_) {}
Object.values(config).forEach(ensureWalletMeta);
render();
}
tryLoadLocal();
</script>
</body>
</html>