800 lines
24 KiB
HTML
800 lines
24 KiB
HTML
<!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 {
|
||
"&": "&",
|
||
"<": "<",
|
||
">": ">",
|
||
'"': """,
|
||
"'": "'",
|
||
}[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>
|