Account / 我的账户

订阅状态与账户管理

登录后可查看到期日、可用天数、本周期用量、事件时间线,以及到期前 3/1/0 天共三次提醒。取消、降级、续费都在本页完成,所有金额与状态来自服务端。

验证码登录 / 注册

注册需依次通过:图形验证码 + 邮箱验证码 + 手机验证码。登录需:图形验证码 + 邮箱验证码。所有校验都在服务端完成。

当前模式:注册(三项校验)

先去选档位

未配置短信服务时,发送短信会明确返回“短信服务未配置”,不会伪造发送成功。测试模式仅在本机开启,验证码写入服务端日志并在页面标注。

登录后可以做什么
  • 查看到期日与剩余可用天数,到期后服务端硬拦截。
  • 查看 AI Agent 本月额度与已用条数。
  • 续费、取消(必选原因)、降级(选更低档 + 原因)。
  • 查看订阅事件时间线与到期前 3/1/0 天共三次提醒。

取消不会立即扣费;当前版本不自动扣款,续费需手动触发。

" 的尾巴、a/b、//evil.com 的第二段当成链接 const boundary = new Set(['', ' ', '\t', '\n', '\r', '(', '(', '[', '【', '{', '"', "'", '“', ':', ':', ',', ',', '。', ';', ';', '、', '`']); let out = ''; let last = 0; for (const match of raw.matchAll(re)) { const token = match[0]; const external = /^https?:\/\//i.test(token); const prev = match.index === 0 ? '' : raw[match.index - 1]; if (!external && !boundary.has(prev)) continue; const href = token.replace(/[),.;,。;]+$/, ''); out += escapeHtml(raw.slice(last, match.index)); out += `${escapeHtml(href)}`; out += escapeHtml(token.slice(href.length)); last = match.index + token.length; } out += escapeHtml(raw.slice(last)); return out; } function renderNotices(list) { const box = document.getElementById('noticeBox'); if (!box) return; if (!list.length) { box.innerHTML = `

${t('account.noNotices')}

`; return; } box.innerHTML = list.map((n) => `
${escapeHtml(n.title || t('account.notification'))}
${linkify(n.body || '')}
${n.note ? `
${t('account.sendStatusPrefix')}${escapeHtml(n.note)}
` : ''}
${fmtDate(n.createdAt)} · ${escapeHtml(n.status)}
`).join(''); } function render(me) { state.me = me; state.plans = me.plans || []; renderSub(me); renderUsage(me); renderReminders(me); renderTimeline(me); renderUser(me); renderTeam(me); buildDowngradeOptions(); const src = document.getElementById('subDetailSource'); if (src) { src.textContent = me.subscription ? (state.subDetailLoaded ? t('account.subDetailSource') : t('account.subDetailFallback')) : ''; } } function buildDowngradeOptions() { const sub = state.me?.subscription; const select = document.getElementById('downgradePlan'); if (!sub) { select.innerHTML = ``; return; } const currentRank = state.plans.find((p) => p.slug === sub.planSlug)?.entitlements?.tierRank ?? 0; const lower = state.plans.filter((p) => p.priceCentsMonth > 0 && (p.entitlements?.tierRank ?? 0) < currentRank); const seen = new Set(); const unique = lower.filter((p) => { const tier = p.tier; if (seen.has(tier)) return false; seen.add(tier); return true; }); select.innerHTML = unique.length ? unique.map((p) => ``).join('') : ``; } async function loadMe(silent = false) { const token = getUserToken(); if (!token) { showLogin(); return; } try { const [me, notices] = await Promise.all([ api('/api/me', { token }), api('/api/me/notifications', { token }).catch(() => ({ notifications: [] })), ]); // 订阅详情走 GET /api/subscriptions/:id:事件时间线与到期提醒以服务端记录为准, // 失败时保留 /api/me 快照并在页面注明,不静默假装已加载。 state.subDetailLoaded = false; if (me.subscription && me.subscription.id) { try { const detail = await api(`/api/subscriptions/${encodeURIComponent(me.subscription.id)}`, { token }); if (detail && detail.subscription) { me.subscription = detail.subscription; me.events = detail.events || me.events; me.reminders = detail.reminders || me.reminders; state.subDetailLoaded = true; } } catch { state.subDetailLoaded = false; } } render(me); renderNotices(notices.notifications || []); loadSupport(token).catch(() => {}); showAccount(); } catch (err) { if (err.status === 401) { clearUser(); showLogin(t('account.sessionExpired')); } else if (!silent) { toast(err.message, 'error'); } } } // ---------- 验证码登录 / 注册 ---------- let captcha = null; let authMode = 'register'; function showAuthError(message, code = '') { const errBox = document.getElementById('loginError'); errBox.innerHTML = `${escapeHtml(message)}${code ? `(${escapeHtml(code)})` : ''}`; errBox.classList.add('show'); toast(message, 'error'); } function setAuthMode(mode) { authMode = mode; document.getElementById('authModeText').textContent = mode === 'register' ? t('account.modeRegister') : t('account.modeLogin'); document.getElementById('switchModeBtn').textContent = mode === 'register' ? t('account.switchToLogin') : t('account.switchToRegister'); document.getElementById('phoneField').style.display = mode === 'register' ? '' : 'none'; document.getElementById('smsCodeField').style.display = mode === 'register' ? '' : 'none'; document.getElementById('loginBtn').textContent = mode === 'register' ? t('account.authRegisterBtn') : t('account.authLoginBtn'); } async function refreshCaptcha() { try { const res = await api('/api/auth/captcha', { method: 'POST', body: {} }); captcha = res; document.getElementById('captchaQuestion').textContent = res.question; document.getElementById('captchaAnswer').value = ''; } catch (err) { document.getElementById('captchaQuestion').textContent = t('account.captchaLoadFail'); } } document.getElementById('refreshCaptchaBtn').addEventListener('click', refreshCaptcha); document.getElementById('switchModeBtn').addEventListener('click', () => { setAuthMode(authMode === 'register' ? 'login' : 'register'); }); document.getElementById('sendEmailCodeBtn').addEventListener('click', async () => { const email = document.getElementById('loginEmail').value.trim(); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return showAuthError(t('account.invalidEmailHint'), 'invalid_email'); const btn = document.getElementById('sendEmailCodeBtn'); btn.disabled = true; try { const res = await api('/api/auth/email-code', { method: 'POST', body: { email, purpose: authMode === 'register' ? 'register' : 'login' } }); document.getElementById('authTestNote').textContent = res.testMode ? t('account.emailCodeTestMode') : t('account.emailCodeSent', { channel: res.delivery === 'email' ? t('account.channelEmail') : res.delivery, n: Math.round((res.expiresInSeconds || 600) / 60) }); toast(res.testMode ? t('account.codeTestModeToast') : t('account.codeSentToast'), 'ok'); } catch (err) { showAuthError(err.message, err.code); } finally { btn.disabled = false; } }); document.getElementById('sendSmsCodeBtn').addEventListener('click', async () => { const phone = document.getElementById('loginPhone').value.trim(); if (!/^1[3-9]\d{9}$/.test(phone)) return showAuthError(t('account.invalidPhoneHint'), 'invalid_phone'); const btn = document.getElementById('sendSmsCodeBtn'); btn.disabled = true; try { const res = await api('/api/auth/sms-code', { method: 'POST', body: { phone, purpose: 'register' } }); document.getElementById('authTestNote').textContent = res.testMode ? t('account.smsCodeTestMode') : t('account.smsSubmitted'); toast(res.testMode ? t('account.smsTestModeToast') : t('account.smsSubmittedToast'), 'ok'); } catch (err) { if (err.code === 'sms_not_configured') { showAuthError(t('account.smsNotConfigured'), err.code); } else { showAuthError(err.message, err.code); } } finally { btn.disabled = false; } }); document.getElementById('loginBtn').addEventListener('click', async () => { const email = document.getElementById('loginEmail').value.trim(); const name = document.getElementById('loginName').value.trim(); const phone = document.getElementById('loginPhone').value.trim(); const emailCode = document.getElementById('emailCode').value.trim(); const smsCode = document.getElementById('smsCode').value.trim(); const captchaAnswer = document.getElementById('captchaAnswer').value.trim(); const errBox = document.getElementById('loginError'); errBox.classList.remove('show'); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return showAuthError(t('account.invalidEmail'), 'invalid_email'); if (!captcha?.id) return showAuthError(t('account.captchaRequired'), 'captcha_required'); if (!emailCode) return showAuthError(t('account.emailCodeRequired'), 'code_required'); if (authMode === 'register' && !smsCode) return showAuthError(t('account.smsCodeRequired'), 'code_required'); const btn = document.getElementById('loginBtn'); btn.disabled = true; const original = btn.textContent; btn.textContent = t('account.submitting'); try { const payload = { email, name, captchaId: captcha.id, captchaAnswer, emailCode, }; const res = authMode === 'register' ? await api('/api/auth/register', { method: 'POST', body: { ...payload, phone, smsCode } }) : await api('/api/auth/login/code', { method: 'POST', body: payload }); setUser({ token: res.token, user: res.user, email, name }); mountTopbar('account'); toast(authMode === 'register' ? t('account.registerSuccess') : t('account.loginSuccess'), 'ok'); await loadMe(); } catch (err) { showAuthError(err.message, err.code); await refreshCaptcha(); } finally { btn.disabled = false; btn.textContent = original; } }); setAuthMode('register'); refreshCaptcha(); // ---------- 弹窗 ---------- function openModal(id) { document.getElementById(id).classList.remove('hidden'); } function closeModal(id) { document.getElementById(id).classList.add('hidden'); } document.querySelectorAll('.modal-backdrop').forEach((backdrop) => { backdrop.addEventListener('click', (event) => { if (event.target === backdrop || event.target.closest('[data-close-modal]')) backdrop.classList.add('hidden'); }); }); document.addEventListener('keydown', (event) => { if (event.key !== 'Escape') return; document.querySelectorAll('.modal-backdrop:not(.hidden)').forEach((el) => el.classList.add('hidden')); }); // ---------- 取消 ---------- document.getElementById('cancelBtn').addEventListener('click', async () => { const sub = state.me?.subscription; if (!sub) return; document.getElementById('cancelReason').value = ''; document.getElementById('cancelError').classList.remove('show'); await track('cancel_reason_view', { planSlug: sub.planSlug, tier: sub.tier }); openModal('cancelModal'); setTimeout(() => document.getElementById('cancelReason')?.focus(), 50); }); document.getElementById('cancelSubmit').addEventListener('click', async () => { const sub = state.me?.subscription; const reason = document.getElementById('cancelReason').value; const errBox = document.getElementById('cancelError'); if (!sub) return; const btn = document.getElementById('cancelSubmit'); btn.disabled = true; const original = btn.textContent; btn.textContent = t('account.submitting'); try { const res = await api(`/api/subscriptions/${sub.id}/cancel`, { method: 'POST', token: getUserToken(), body: { token: getUserToken(), reason: reason || 'unspecified' }, }); toast(res.note || t('account.canceledToast'), 'ok'); closeModal('cancelModal'); await loadMe(true); } catch (err) { errBox.textContent = err.message; errBox.classList.add('show'); toast(err.message, 'error'); } finally { btn.disabled = false; btn.textContent = original; } }); // ---------- 降级 ---------- document.getElementById('downgradeBtn').addEventListener('click', () => { document.getElementById('downgradeError').classList.remove('show'); openModal('downgradeModal'); }); document.getElementById('downgradeSubmit').addEventListener('click', async () => { const sub = state.me?.subscription; const planSlug = document.getElementById('downgradePlan').value; const reason = document.getElementById('downgradeReason').value; const errBox = document.getElementById('downgradeError'); if (!sub) return; if (!planSlug) { errBox.textContent = t('account.noLowerPlan'); errBox.classList.add('show'); return; } const btn = document.getElementById('downgradeSubmit'); btn.disabled = true; const original = btn.textContent; btn.textContent = t('account.submitting'); try { const res = await api(`/api/subscriptions/${sub.id}/downgrade`, { method: 'POST', token: getUserToken(), body: { token: getUserToken(), planSlug, reason }, }); toast(res.note || t('account.downgradedToast'), 'ok'); closeModal('downgradeModal'); await loadMe(true); } catch (err) { errBox.textContent = err.message; errBox.classList.add('show'); toast(err.message, 'error'); } finally { btn.disabled = false; btn.textContent = original; } }); // ---------- 续费 ---------- async function startResume(sub) { const btn = document.getElementById('resumeBtn'); if (btn) { btn.disabled = true; btn.textContent = t('account.processing'); } try { const res = await api(`/api/subscriptions/${sub.id}/resume`, { method: 'POST', token: getUserToken(), body: { token: getUserToken() }, }); toast(res.note || t('account.resumedToast'), 'ok'); await loadMe(); } catch (err) { toast(err.message, 'error'); if (btn) { btn.disabled = false; btn.textContent = t('account.resume'); } } } async function startRenew(sub) { const body = document.getElementById('payBody'); body.innerHTML = `

${t('account.creatingOrder')}

`; openModal('payModal'); try { const res = await api(`/api/subscriptions/${sub.id}/renew`, { method: 'POST', token: getUserToken(), body: { token: getUserToken() }, }); const order = res.order; if (!order?.accessToken) throw Object.assign(new Error(t('account.missingAccessToken')), { code: 'missing_token' }); const pay = await api(`/api/orders/${order.orderNo}/pay`, { method: 'POST', body: { token: order.accessToken, provider: 'wechat' }, }); if (pay.sandbox && pay.payment?.payUrl) { body.innerHTML = `
${t('account.renewOrder')}${escapeHtml(order.orderNo)}
${t('account.amount')}${money(order.amountCents)}
${t('account.mode')}${t('account.sandbox')}
${t('account.sandboxRedirect')}
${t('account.goSandbox')}`; setTimeout(() => location.assign(pay.payment.payUrl), 800); } else if (pay.payment?.payUrl) { body.innerHTML = `
${t('account.renewOrder')}${escapeHtml(order.orderNo)}
${t('account.amount')}${money(order.amountCents)}
${t('account.goPay')}`; } else { body.innerHTML = `
${t('account.renewOrder')}${escapeHtml(order.orderNo)}
${t('account.amount')}${money(order.amountCents)}
${escapeHtml(pay.payment?.qrContent || t('account.noPayLink'))}
`; } } catch (err) { body.innerHTML = `
${escapeHtml(t('account.renewFail', { msg: err.message }))}
`; toast(err.message, 'error'); } } document.getElementById('logoutBtn').addEventListener('click', () => { clearUser(); toast(t('account.loggedOutToast'), 'ok'); setTimeout(() => location.reload(), 400); }); // ---------- 退款申请 / 工单 / 发票(只展示登录用户自己的记录,服务端鉴权) ---------- const refundStatusLabel = { pending: 'account.refundPending', approved: 'account.refundApproved', refunded: 'account.refunded', rejected: 'account.rejected' }; const invoiceStatusLabel = { pending: 'account.invoicePending', issued: 'account.invoiceIssued', rejected: 'account.rejected' }; async function loadSupport(token) { const [refunds, tickets, invoices] = await Promise.all([ api('/api/me/refund-requests', { token }).catch(() => ({ requests: [] })), api('/api/me/tickets', { token }).catch(() => ({ tickets: [] })), api('/api/me/invoices', { token }).catch(() => ({ invoices: [] })), ]); const refundBox = document.getElementById('refundList'); refundBox.innerHTML = (refunds.requests || []).length ? refunds.requests.map((r) => `
${escapeHtml(r.orderNo)}${escapeHtml(refundStatusLabel[r.status] ? t(refundStatusLabel[r.status]) : r.status)}

${escapeHtml(r.reason)} · ${money(r.requestedAmountCents)}

${r.ruleNote ? `

${escapeHtml(r.ruleNote)}

` : ''}
`).join('') : `

${t('account.noRefunds')}

`; const ticketBox = document.getElementById('ticketList'); ticketBox.innerHTML = (tickets.tickets || []).length ? tickets.tickets.map((t2) => `
${escapeHtml(t2.id)}${escapeHtml(t2.status)}

${escapeHtml(t2.description)}

${t2.messages?.filter((m) => m.author !== 'user').map((m) => `

${t('account.adminPrefix')}${escapeHtml(m.body)}

`).join('') || ''}
`).join('') : `

${t('account.noTickets')}

`; const invoiceBox = document.getElementById('invoiceList'); invoiceBox.innerHTML = (invoices.invoices || []).length ? `

${escapeHtml(t('account.invoiceManualNote'))}

` + invoices.invoices.map((i) => `
${escapeHtml(i.orderNo || '—')}${escapeHtml(invoiceStatusLabel[i.status] ? t(invoiceStatusLabel[i.status]) : i.status)}

${escapeHtml(i.title)} · ${money(i.amountCents)}${i.invoiceNo ? ` · ${escapeHtml(i.invoiceNo)}` : ''}

${i.fileAvailable ? `${escapeHtml(t('account.invoiceDownload'))}` : `

${escapeHtml(t('account.invoiceFilePending'))}

`}
`).join('') : `

${t('account.noInvoices')}

`; } document.getElementById('ticketSubmitBtn').addEventListener('click', async () => { const token = getUserToken(); const msgEl = document.getElementById('ticketMsg'); msgEl.textContent = ''; try { const res = await api('/api/tickets', { method: 'POST', token, body: { orderNo: document.getElementById('ticketOrderNo').value.trim(), type: document.getElementById('ticketType').value, description: document.getElementById('ticketDesc').value.trim(), }, }); msgEl.textContent = res.duplicate ? t('account.ticketDuplicate', { id: res.ticket.id }) : t('account.ticketSubmitted', { id: res.ticket.id }); document.getElementById('ticketDesc').value = ''; await loadSupport(token); } catch (err) { msgEl.textContent = err.message; } }); document.getElementById('invoiceSubmitBtn').addEventListener('click', async () => { const token = getUserToken(); const msgEl = document.getElementById('invoiceMsg'); msgEl.textContent = ''; try { const res = await api('/api/invoices', { method: 'POST', token, body: { orderNo: document.getElementById('invoiceOrderNo').value.trim(), titleType: document.getElementById('invoiceTitleType').value, title: document.getElementById('invoiceTitle').value.trim(), taxNo: document.getElementById('invoiceTaxNo').value.trim(), email: document.getElementById('invoiceEmail').value.trim(), }, }); msgEl.textContent = res.duplicate ? t('account.invoiceDuplicate') : t('account.invoiceSubmitted', { status: invoiceStatusLabel[res.invoice.status] ? t(invoiceStatusLabel[res.invoice.status]) : res.invoice.statusLabel, }); await loadSupport(token); } catch (err) { msgEl.textContent = err.message; } }); bindTeamActions(); loadMe();