chatapp-h5/h5/app-invite/dev-test.html
2026-04-24 12:42:12 +08:00

800 lines
24 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" />
<title>邀请活动本地接口测试</title>
<style>
:root {
color-scheme: light;
--bg: #f5f7f4;
--panel: #ffffff;
--line: #d8dfd2;
--text: #1f2b1d;
--muted: #64705f;
--brand: #176d36;
--brand-soft: #e7f4ea;
--danger: #a62b21;
--danger-soft: #fff0ee;
--code: #102414;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family:
ui-sans-serif, -apple-system, BlinkMacSystemFont, "PingFang SC",
"Helvetica Neue", Arial, sans-serif;
}
button,
input,
textarea {
font: inherit;
}
button {
border: 0;
border-radius: 8px;
padding: 10px 14px;
background: var(--brand);
color: #fff;
cursor: pointer;
font-weight: 700;
}
button.secondary {
background: var(--brand-soft);
color: var(--brand);
}
button.danger {
background: var(--danger-soft);
color: var(--danger);
}
button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
input,
textarea {
width: 100%;
border: 1px solid var(--line);
border-radius: 8px;
padding: 10px 12px;
color: var(--text);
background: #fff;
}
textarea {
min-height: 76px;
resize: vertical;
}
label {
display: grid;
gap: 6px;
color: var(--muted);
font-size: 13px;
}
pre {
margin: 0;
padding: 14px;
border-radius: 8px;
overflow: auto;
background: var(--code);
color: #dff5dd;
font-size: 12px;
line-height: 1.45;
}
.page {
max-width: 1180px;
margin: 0 auto;
padding: 24px;
}
.topbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
margin-bottom: 18px;
}
.title {
margin: 0;
font-size: 26px;
line-height: 1.15;
}
.subtitle {
margin: 8px 0 0;
color: var(--muted);
}
.status {
min-width: 210px;
padding: 10px 12px;
border-radius: 8px;
background: var(--panel);
border: 1px solid var(--line);
color: var(--muted);
font-size: 13px;
}
.layout {
display: grid;
grid-template-columns: 360px minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.panel {
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
padding: 16px;
}
.panel h2 {
margin: 0 0 12px;
font-size: 17px;
}
.grid {
display: grid;
gap: 12px;
}
.row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.metric {
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px;
background: #fbfcfa;
}
.metric strong {
display: block;
margin-top: 4px;
font-size: 20px;
}
.task-list,
.rank-list {
display: grid;
gap: 10px;
}
.task,
.rank {
display: grid;
gap: 8px;
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px;
background: #fbfcfa;
}
.task-head,
.rank {
grid-template-columns: 1fr auto;
align-items: center;
}
.task-head {
display: grid;
gap: 8px;
}
.task-meta,
.small {
color: var(--muted);
font-size: 12px;
}
.avatar {
width: 34px;
height: 34px;
border-radius: 999px;
object-fit: cover;
background: var(--line);
}
.rank-user {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.rank-name {
font-weight: 700;
}
.logs {
display: grid;
gap: 10px;
}
.log-entry {
display: grid;
gap: 6px;
}
.log-title {
display: flex;
justify-content: space-between;
gap: 10px;
color: var(--muted);
font-size: 12px;
}
@media (max-width: 900px) {
.layout,
.metric-grid {
grid-template-columns: 1fr;
}
.topbar {
display: grid;
}
}
</style>
</head>
<body>
<main class="page">
<div class="topbar">
<div>
<h1 class="title">邀请活动本地接口测试</h1>
<p class="subtitle">
默认通过本页同源的 <code>/api</code> 代理到本地 Go 服务,避免浏览器 CORS 限制。
</p>
</div>
<div class="status" id="statusText">等待操作</div>
</div>
<div class="layout">
<aside class="panel">
<h2>测试参数</h2>
<div class="grid">
<label>
API Base
<input id="apiBaseInput" value="/api" />
</label>
<label>
邀请人 token
<textarea id="inviterTokenInput" placeholder="用于 /app/h5/invite-campaign/home、rank、claim"></textarea>
</label>
<label>
被邀请人 token
<textarea id="inviteeTokenInput" placeholder="用于 bind-code"></textarea>
</label>
<div class="row">
<label>
邀请码
<input id="inviteCodeInput" placeholder="查首页后自动填入" />
</label>
<label>
排行 limit
<input id="rankLimitInput" type="number" value="50" />
</label>
</div>
<div class="row">
<label>
被邀请人 userId
<input id="inviteeUserIdInput" type="number" placeholder="充值回调用" />
</label>
<label>
sysOrigin
<input id="sysOriginInput" placeholder="查首页后自动填入" />
</label>
</div>
<div class="row">
<label>
充值金币
<input id="rechargeCoinsInput" type="number" value="100000" />
</label>
<label>
订单号
<input id="orderIdInput" />
</label>
</div>
<div class="actions">
<button type="button" id="saveButton" class="secondary">保存参数</button>
<button type="button" id="resetButton" class="danger">清空日志</button>
</div>
</div>
</aside>
<section class="grid">
<div class="panel">
<h2>流程操作</h2>
<div class="actions">
<button type="button" data-action="health">1. 健康检查</button>
<button type="button" data-action="home">2. 邀请人首页</button>
<button type="button" data-action="rank">3. 排行榜</button>
<button type="button" data-action="landing">4. 落地页</button>
<button type="button" data-action="bind">5. 被邀请人绑码</button>
<button type="button" data-action="recharge">6. 模拟充值回调</button>
<button type="button" data-action="refresh">7. 刷新首页+榜单</button>
</div>
<p class="small">
推荐顺序:填邀请人 token -> 查首页 -> 填被邀请人 token 和 userId -> 绑码 -> 充值回调 -> 刷新 -> 点任务 Claim。
</p>
</div>
<div class="panel">
<h2>首页结果</h2>
<div class="metric-grid" id="metrics"></div>
<div class="grid" style="margin-top: 14px">
<label>
邀请链接
<input id="inviteLinkOutput" readonly />
</label>
<div class="small" id="countdownText">月度倒计时未加载</div>
</div>
</div>
<div class="panel">
<h2>任务领取</h2>
<div class="task-list" id="taskList"></div>
</div>
<div class="panel">
<h2>排行榜</h2>
<div class="rank-list" id="rankList"></div>
</div>
<div class="panel">
<h2>接口日志</h2>
<div class="logs" id="logs"></div>
</div>
</section>
</div>
</main>
<script>
const storageKey = "appInviteDevTest";
const state = {
home: null,
rank: null,
landing: null,
countdownTimer: 0,
};
const els = {
statusText: document.getElementById("statusText"),
apiBaseInput: document.getElementById("apiBaseInput"),
inviterTokenInput: document.getElementById("inviterTokenInput"),
inviteeTokenInput: document.getElementById("inviteeTokenInput"),
inviteCodeInput: document.getElementById("inviteCodeInput"),
rankLimitInput: document.getElementById("rankLimitInput"),
inviteeUserIdInput: document.getElementById("inviteeUserIdInput"),
sysOriginInput: document.getElementById("sysOriginInput"),
rechargeCoinsInput: document.getElementById("rechargeCoinsInput"),
orderIdInput: document.getElementById("orderIdInput"),
inviteLinkOutput: document.getElementById("inviteLinkOutput"),
countdownText: document.getElementById("countdownText"),
metrics: document.getElementById("metrics"),
taskList: document.getElementById("taskList"),
rankList: document.getElementById("rankList"),
logs: document.getElementById("logs"),
};
function loadSavedParams() {
const saved = JSON.parse(localStorage.getItem(storageKey) || "{}");
Object.entries(saved).forEach(([key, value]) => {
if (els[key]) {
els[key].value = value;
}
});
if (!els.orderIdInput.value) {
els.orderIdInput.value = nextOrderId();
}
}
function saveParams() {
const payload = {};
[
"apiBaseInput",
"inviterTokenInput",
"inviteeTokenInput",
"inviteCodeInput",
"rankLimitInput",
"inviteeUserIdInput",
"sysOriginInput",
"rechargeCoinsInput",
].forEach((key) => {
payload[key] = els[key].value;
});
localStorage.setItem(storageKey, JSON.stringify(payload));
setStatus("参数已保存");
}
function nextOrderId() {
return "invite-test-" + Date.now();
}
function normalizeBase(base) {
const value = String(base || "").trim() || "/api";
return value.endsWith("/") ? value.slice(0, -1) : value;
}
function authHeader(token) {
const value = String(token || "").trim();
if (!value) {
return "";
}
return /^bearer\s+/i.test(value) ? value : "Bearer " + value;
}
function setStatus(text) {
els.statusText.textContent = text;
}
function formatJSON(value) {
return JSON.stringify(value, null, 2);
}
function unwrap(payload) {
return payload && (payload.body || payload.data || payload);
}
function addLog(title, payload, ok = true) {
const entry = document.createElement("div");
entry.className = "log-entry";
entry.innerHTML = `
<div class="log-title">
<strong>${escapeHtml(title)}</strong>
<span>${ok ? "OK" : "ERROR"} · ${new Date().toLocaleTimeString()}</span>
</div>
<pre>${escapeHtml(formatJSON(payload))}</pre>
`;
els.logs.prepend(entry);
}
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, (match) => {
return {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
}[match];
});
}
async function request(path, options = {}) {
const base = normalizeBase(els.apiBaseInput.value);
const response = await fetch(base + path, {
method: options.method || "GET",
headers: {
Accept: "application/json",
...(options.headers || {}),
},
body: options.body,
});
const contentType = response.headers.get("content-type") || "";
const payload = contentType.includes("application/json")
? await response.json()
: await response.text();
if (!response.ok || payload?.status === false) {
const message = payload?.errorMsg || payload?.message || response.statusText;
throw Object.assign(new Error(message), { payload, status: response.status });
}
return payload;
}
async function runAction(action) {
setStatus("请求中:" + action);
saveParams();
try {
if (action === "health") {
const payload = await request("/health");
addLog("health", payload);
}
if (action === "home") {
await fetchHome();
}
if (action === "rank") {
await fetchRank();
}
if (action === "landing") {
await fetchLanding();
}
if (action === "bind") {
await bindCode();
}
if (action === "recharge") {
await rechargeSuccess();
}
if (action === "refresh") {
await fetchHome();
await fetchRank();
}
setStatus("完成:" + action);
} catch (error) {
addLog(action, error.payload || { message: error.message, status: error.status }, false);
setStatus("失败:" + error.message);
}
}
async function fetchHome() {
const token = authHeader(els.inviterTokenInput.value);
if (!token) {
throw new Error("请先填写邀请人 token");
}
const payload = await request("/app/h5/invite-campaign/home", {
headers: { Authorization: token },
});
state.home = unwrap(payload);
addLog("home", payload);
applyHome(state.home);
}
async function fetchRank() {
const token = authHeader(els.inviterTokenInput.value);
if (!token) {
throw new Error("请先填写邀请人 token");
}
const limit = Number(els.rankLimitInput.value || 50);
const payload = await request("/app/h5/invite-campaign/rank?limit=" + encodeURIComponent(limit), {
headers: { Authorization: token },
});
state.rank = unwrap(payload);
addLog("rank", payload);
renderRank(state.rank);
}
async function fetchLanding() {
const code = els.inviteCodeInput.value.trim();
if (!code) {
throw new Error("请先填写邀请码,或先查邀请人首页自动填入");
}
const payload = await request("/public/h5/invite-campaign/landing?code=" + encodeURIComponent(code));
state.landing = unwrap(payload);
addLog("landing", payload);
}
async function bindCode() {
const token = authHeader(els.inviteeTokenInput.value);
const code = els.inviteCodeInput.value.trim();
if (!token) {
throw new Error("请先填写被邀请人 token");
}
if (!code) {
throw new Error("请先填写邀请码");
}
const payload = await request("/app/h5/invite-campaign/bind-code", {
method: "POST",
headers: {
Authorization: token,
"Content-Type": "application/json",
},
body: JSON.stringify({ inviteCode: code }),
});
addLog("bind-code", payload);
}
async function rechargeSuccess() {
const sysOrigin = els.sysOriginInput.value.trim();
const userId = Number(els.inviteeUserIdInput.value || 0);
const rechargeCoins = Number(els.rechargeCoinsInput.value || 0);
const orderId = els.orderIdInput.value.trim() || nextOrderId();
if (!sysOrigin) {
throw new Error("请先填写 sysOrigin或先查首页自动填入");
}
if (!userId) {
throw new Error("请填写被邀请人 userId");
}
if (!rechargeCoins) {
throw new Error("请填写充值金币数量");
}
els.orderIdInput.value = orderId;
const payload = await request("/internal/invite-campaign/recharge-success", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
orderId,
userId,
rechargeCoins,
sysOrigin,
payTime: Date.now(),
}),
});
addLog("recharge-success", payload);
els.orderIdInput.value = nextOrderId();
}
async function claimTask(ruleId) {
const token = authHeader(els.inviterTokenInput.value);
if (!token) {
throw new Error("请先填写邀请人 token");
}
const payload = await request("/app/h5/invite-campaign/tasks/claim", {
method: "POST",
headers: {
Authorization: token,
"Content-Type": "application/json",
},
body: JSON.stringify({ ruleId }),
});
addLog("claim rule " + ruleId, payload);
await fetchHome();
}
function applyHome(home) {
if (!home) {
return;
}
els.inviteCodeInput.value = home.inviteCode || els.inviteCodeInput.value;
els.sysOriginInput.value = home.sysOrigin || els.sysOriginInput.value;
els.inviteLinkOutput.value = home.inviteLink || "";
renderMetrics(home);
renderTasks(home);
startCountdown(home);
}
function renderMetrics(home) {
const stats = home.stats || {};
const monthly = home.monthlyProgress || {};
const values = [
["累计邀请", stats.totalInvitedUsers],
["累计有效", stats.totalValidUsers],
["累计充值", stats.totalRechargeCoins],
["累计奖励", stats.rewardGoldCoins],
["本月邀请", monthly.inviteCount],
["本月有效", monthly.validUserCount],
["本月充值", monthly.totalRechargeCoins],
["本月奖励", monthly.rewardGoldCoins],
];
els.metrics.innerHTML = values
.map(([label, value]) => `
<div class="metric">
<span>${escapeHtml(label)}</span>
<strong>${escapeHtml(formatNumber(value))}</strong>
</div>
`)
.join("");
}
function renderTasks(home) {
const groups = [
["邀请人数任务", home.inviterValidCountTasks || []],
["总充值任务", home.inviterTotalRechargeTasks || []],
];
els.taskList.innerHTML = groups
.map(([title, tasks]) => `
<div class="task">
<strong>${escapeHtml(title)}</strong>
${tasks.map(renderTask).join("") || '<span class="small">暂无任务</span>'}
</div>
`)
.join("");
}
function renderTask(task) {
const canClaim = task.achieved && !task.claimed;
return `
<div class="task-head">
<div>
<strong>Rule #${escapeHtml(task.ruleId)}</strong>
<div class="task-meta">
进度 ${escapeHtml(formatNumber(task.progress))} / ${escapeHtml(formatNumber(task.threshold))}
· 奖励 ${escapeHtml(formatNumber(task.goldAmount))} 金币
· ${task.claimed ? "已领取" : task.achieved ? "可领取" : "未达标"}
</div>
</div>
<button type="button" ${canClaim ? "" : "disabled"} data-claim="${escapeHtml(task.ruleId)}">
${task.claimed ? "Claimed" : "Claim"}
</button>
</div>
`;
}
function renderRank(rank) {
const items = rank?.items || [];
els.rankList.innerHTML = items.length
? items.map((item) => `
<div class="rank">
<div class="rank-user">
<strong>#${escapeHtml(item.rank)}</strong>
<img class="avatar" src="${escapeHtml(item.avatar || "")}" alt="" />
<div>
<div class="rank-name">${escapeHtml(item.nickname || "User " + item.userId)}</div>
<div class="small">userId: ${escapeHtml(item.userId)}</div>
</div>
</div>
<div class="small">
邀请 ${escapeHtml(formatNumber(item.inviteCount))} 人 · 奖励 ${escapeHtml(formatNumber(item.rewardGoldCoins))}
</div>
</div>
`).join("")
: '<span class="small">暂无排行榜数据</span>';
}
function startCountdown(home) {
window.clearInterval(state.countdownTimer);
const endAt = Number(home.periodEndAtMs || 0);
const serverTime = Number(home.serverTimeMs || Date.now());
const offset = serverTime - Date.now();
const tick = () => {
const diff = Math.max(0, endAt - (Date.now() + offset));
const seconds = Math.floor(diff / 1000);
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
els.countdownText.textContent =
"当前月 " + (home.monthKey || "--") + ",距离重置:" +
days + "天 " + pad(hours) + ":" + pad(minutes) + ":" + pad(secs) +
"periodEndAtMs=" + endAt;
};
tick();
state.countdownTimer = window.setInterval(tick, 1000);
}
function pad(value) {
return String(value).padStart(2, "0");
}
function formatNumber(value) {
const number = Number(value || 0);
return Number.isFinite(number) ? number.toLocaleString("en-US") : "--";
}
document.addEventListener("click", (event) => {
const actionButton = event.target.closest("[data-action]");
if (actionButton) {
runAction(actionButton.dataset.action);
return;
}
const claimButton = event.target.closest("[data-claim]");
if (claimButton) {
claimTask(Number(claimButton.dataset.claim)).catch((error) => {
addLog("claim", error.payload || { message: error.message }, false);
setStatus("领取失败:" + error.message);
});
}
});
document.getElementById("saveButton").addEventListener("click", saveParams);
document.getElementById("resetButton").addEventListener("click", () => {
els.logs.innerHTML = "";
setStatus("日志已清空");
});
loadSavedParams();
renderMetrics({ stats: {}, monthlyProgress: {} });
</script>
</body>
</html>