Files
pangolin/pay/internal/httpapi/testpage.go
T
wangjia e421f03790 fix(pay): 测试页对齐官网暖色风格 + 修地址逐字换行布局
- 布局 bug:收款地址原挤在 flex 行里(与复制键争宽)→ word-break 逐字换行。
  改为独立整行 .addr 块(全宽自然换行),复制键下移全宽。
- 风格:改用官网 light 暖色 token(sand/clay,主色 #B96A3D、sage 绿
  #5B8C5A、JetBrains Mono),内联对齐 design/prototype/tokens.css。
- 页脚补一句「测什么」:收款订单闭环 POST /order + GET /order/{id} 轮询到 paid。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 16:00:25 +08:00

255 lines
11 KiB
Go
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.
package httpapi
import "net/http"
// testPage serves a self-contained, same-origin manual test page at GET /_test.
// Same origin as the API → no CORS, and pangolin-pay sets no restrictive CSP,
// so its inline JS/CSS run freely. This is a throwaway harness for the
// money-critical Phase E loop (create order → pay exact amount → watch paid),
// NOT the production checkout (that lives on the storefront). It only creates
// orders and polls status — no secrets, no keys.
//
// Styling mirrors the marketing site's warm sand/clay palette (design tokens in
// design/prototype/tokens.css, light theme default, brand = clay-500 #B96A3D).
// Values are inlined here because pangolin-pay is a separate origin and cannot
// @import the site's stylesheet; keep them in sync if the brand palette changes.
func (h *Handler) testPage(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write([]byte(testPageHTML))
}
const testPageHTML = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<meta name="theme-color" content="#B96A3D">
<title>pangolin-pay · 收款测试</title>
<style>
:root{
--bg:#FAF8F4; --surface:#FFFFFF; --surface-2:#F2EEE7;
--fg1:#1F1C18; --fg2:#6B6253; --fg3:#8C8270;
--accent:#B96A3D; --accent-hover:#9E5630; --accent-subtle:#FAF3ED; --accent-border:#E6C7AC;
--border:#E6DFD3; --border-strong:#D2C8B8;
--success:#5B8C5A; --success-subtle:#E9F0E6;
--warning:#D69A3C; --warning-subtle:#F8EED6;
--danger:#C0533B; --danger-subtle:#F6E1DA;
--radius-lg:14px; --radius-md:10px; --radius-sm:8px;
--font-display:'Sora','Noto Sans SC',system-ui,sans-serif;
--font-sans:'Manrope','Noto Sans SC',system-ui,sans-serif;
--font-mono:'JetBrains Mono',ui-monospace,'SFMono-Regular',Menlo,monospace;
--shadow-md:0 4px 14px rgba(45,30,20,.08);
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg1);
font-family:var(--font-sans);font-size:15px;line-height:1.55;
-webkit-font-smoothing:antialiased;
display:flex;justify-content:center;padding:40px 16px;}
.wrap{width:100%;max-width:480px}
h1{font-family:var(--font-display);font-size:22px;margin:0 0 5px;font-weight:700;letter-spacing:-.02em}
h1 .dot{color:var(--accent)}
.sub{color:var(--fg2);font-size:13px;margin:0 0 24px}
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);
padding:22px;margin-bottom:16px;box-shadow:var(--shadow-md)}
label{display:block;font-size:12px;color:var(--fg2);margin:14px 0 6px;font-weight:500}
label:first-child{margin-top:0}
input{width:100%;background:var(--bg);border:1px solid var(--border-strong);border-radius:var(--radius-md);
color:var(--fg1);padding:11px 13px;font-size:15px;outline:none;font-family:inherit;transition:border-color .15s}
input:focus{border-color:var(--accent)}
button{width:100%;margin-top:20px;background:var(--accent);color:#fff;border:0;border-radius:var(--radius-md);
padding:13px;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit;transition:background .15s}
button:hover:not(:disabled){background:var(--accent-hover)}
button:disabled{opacity:.5;cursor:not-allowed}
button.ghost{background:transparent;border:1px solid var(--border-strong);color:var(--fg1);font-weight:500;margin-top:12px}
button.ghost:hover:not(:disabled){background:var(--surface-2)}
button.copy{margin-top:10px;background:var(--accent-subtle);border:1px solid var(--accent-border);
color:var(--accent-hover);font-weight:600;font-size:13px;padding:9px}
button.copy:hover:not(:disabled){background:#F5E7D8}
.kv{display:flex;justify-content:space-between;align-items:center;gap:12px;
padding:12px 0;border-bottom:1px solid var(--border)}
.kv .k{color:var(--fg2);font-size:13px;white-space:nowrap;flex:none}
.kv .v{text-align:right;font-family:var(--font-mono);font-size:13px;word-break:break-all}
.amtbox{text-align:center;padding:20px 0 6px}
.amtbox .cap{color:var(--fg2);font-size:12px;margin-bottom:8px}
.amtbox .cap b{color:var(--warning)}
.amt{font-family:var(--font-mono);font-size:30px;font-weight:700;color:var(--success);letter-spacing:-.01em}
.amt small{font-size:14px;color:var(--fg3);font-weight:400;margin-left:5px;font-family:var(--font-sans)}
.addrblock{margin:6px 0 2px}
.addrblock .k{color:var(--fg2);font-size:12px;margin-bottom:6px}
.addr{font-family:var(--font-mono);font-size:14px;background:var(--surface-2);border:1px solid var(--border);
border-radius:var(--radius-md);padding:12px 14px;word-break:break-all;line-height:1.5;color:var(--fg1)}
.badge{display:inline-block;padding:4px 12px;border-radius:999px;font-size:12px;font-weight:600;font-family:var(--font-mono)}
.b-pending{background:var(--warning-subtle);color:#9A6B1F}
.b-paid{background:var(--success-subtle);color:#3E6B3D}
.b-expired{background:var(--danger-subtle);color:#8F3826}
.note{font-size:12px;color:var(--fg2);margin-top:16px;line-height:1.65}
.note b{color:var(--fg1)}
.err{color:var(--danger);font-size:13px;margin-top:12px;min-height:18px}
.hidden{display:none}
.warnbox{background:var(--warning-subtle);border:1px solid #EBD7A6;border-radius:var(--radius-md);
padding:12px 14px;font-size:12.5px;color:#7A5514;margin-top:16px;line-height:1.65}
.warnbox b{color:#5E4110}
code{background:var(--surface-2);border:1px solid var(--border);padding:1px 5px;border-radius:5px;font-size:12px;font-family:var(--font-mono)}
</style>
</head>
<body>
<div class="wrap">
<h1>pangolin-pay <span class="dot">·</span> 收款测试</h1>
<p class="sub">USDT-TRC20 · 单地址 + 唯一金额 · 手动测试页(非生产收银台)</p>
<div class="card" id="form-card">
<label>用户标识 user_ref</label>
<input id="user_ref" autocomplete="off">
<label>商品 SKU</label>
<input id="sku" value="pro-year" autocomplete="off">
<label>价格(USDT</label>
<input id="amount" value="0.1" inputmode="decimal" autocomplete="off">
<button id="create">创建订单</button>
<div class="err" id="form-err"></div>
</div>
<div class="card hidden" id="order-card">
<div class="kv">
<span class="k">订单号</span>
<span class="v" id="o-no"></span>
</div>
<div class="kv">
<span class="k">状态</span>
<span class="v"><span class="badge" id="o-status"></span></span>
</div>
<div class="amtbox">
<div class="cap">请支付<b>精确金额</b>(多付少付都不匹配)</div>
<div class="amt" id="o-amount"></div>
</div>
<button class="copy" id="copy-amt">复制金额</button>
<div class="addrblock">
<div class="k">收款地址</div>
<div class="addr" id="o-addr"></div>
</div>
<button class="copy" id="copy-addr">复制地址</button>
<div class="kv" style="margin-top:6px">
<span class="k">TxID</span>
<span class="v" id="o-tx">—</span>
</div>
<div class="kv">
<span class="k">过期倒计时</span>
<span class="v" id="o-ttl"></span>
</div>
<div class="warnbox">
发往此地址、且金额<b>正好等于</b>上面数字的 USDT-TRC20 转账,会在 watcher 下一轮轮询后自动标记
<code>paid</code>。金额不符 → 进 orphan 需人工对账。<br>
⚠️ 需 <code>TRONGRID_API_KEY</code> 已配置,否则 watcher 被限流、侦测很慢。
</div>
<button class="ghost" id="reset">新建另一单</button>
<div class="err" id="order-err"></div>
</div>
<p class="note">
测什么:<b>收款订单闭环</b> —— <code>POST /order</code> 建单拿地址+唯一金额、<code>GET /order/{id}</code>
轮询到 <code>paid</code>。地址模型:所有订单收到<b>同一地址</b>,靠<b>唯一金额</b>(基准价 + 微尾数
≤0.01U)区分。本页无私钥、无密钥。
</p>
</div>
<script>
(function(){
var $ = function(id){ return document.getElementById(id); };
var poll = null, ttlTimer = null, curNo = null, expiresAt = 0;
function rnd(){ return 'test-' + Math.random().toString(36).slice(2, 8); }
$('user_ref').value = rnd();
function fmtUSDT(micro){ return (micro/1e6).toFixed(6).replace(/0+$/,'').replace(/\.$/,''); }
function setStatus(s){
var el = $('o-status');
el.textContent = s;
el.className = 'badge b-' + s;
}
function copy(text, btn){
navigator.clipboard.writeText(text).then(function(){
var old = btn.textContent; btn.textContent = '已复制 ✓';
setTimeout(function(){ btn.textContent = old; }, 1200);
});
}
function tickTTL(){
if(!expiresAt){ return; }
var left = Math.max(0, Math.floor((expiresAt - Date.now())/1000));
var m = Math.floor(left/60), s = left%60;
$('o-ttl').textContent = left>0 ? (m+':'+(s<10?'0':'')+s) : '已过期';
}
function showOrder(o){
curNo = o.order_no;
expiresAt = new Date(o.expires_at).getTime();
$('o-no').textContent = o.order_no;
$('o-amount').innerHTML = fmtUSDT(o.expect_amount) + '<small>USDT</small>';
$('o-addr').textContent = o.address;
$('o-tx').textContent = o.tx_id || '—';
setStatus(o.status);
$('copy-amt').onclick = function(){ copy(fmtUSDT(o.expect_amount), this); };
$('copy-addr').onclick = function(){ copy(o.address, this); };
$('form-card').classList.add('hidden');
$('order-card').classList.remove('hidden');
tickTTL();
ttlTimer = setInterval(tickTTL, 1000);
startPoll();
}
function startPoll(){
stopPoll();
poll = setInterval(function(){
fetch('/order/' + encodeURIComponent(curNo)).then(function(r){ return r.json(); }).then(function(o){
if(o.error){ return; }
setStatus(o.status);
$('o-tx').textContent = o.tx_id || '—';
if(o.status === 'paid' || o.status === 'expired'){ stopPoll(); }
}).catch(function(){});
}, 5000);
}
function stopPoll(){ if(poll){ clearInterval(poll); poll=null; } }
$('create').onclick = function(){
$('form-err').textContent = '';
var usdt = parseFloat($('amount').value);
if(!(usdt > 0)){ $('form-err').textContent = '价格必须 > 0'; return; }
var body = {
user_ref: $('user_ref').value.trim() || rnd(),
sku: $('sku').value.trim() || 'sku',
amount: Math.round(usdt * 1e6)
};
this.disabled = true; this.textContent = '创建中…';
var btn = this;
fetch('/order', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)})
.then(function(r){ return r.json().then(function(j){ return {code:r.status, j:j}; }); })
.then(function(res){
btn.disabled = false; btn.textContent = '创建订单';
if(res.code === 201){ showOrder(res.j); }
else if(res.code === 409){ $('form-err').textContent = '该 user_ref 已有活跃订单(换一个或等它过期)'; }
else { $('form-err').textContent = '错误:' + (res.j.error || res.code); }
})
.catch(function(e){
btn.disabled = false; btn.textContent = '创建订单';
$('form-err').textContent = '网络错误:' + e.message;
});
};
$('reset').onclick = function(){
stopPoll(); if(ttlTimer){ clearInterval(ttlTimer); ttlTimer=null; }
$('order-card').classList.add('hidden');
$('form-card').classList.remove('hidden');
$('user_ref').value = rnd();
};
})();
</script>
</body>
</html>`