Account / 我的账户
订阅状态与账户管理
登录后可查看到期日、可用天数、本周期用量、事件时间线,以及到期前 3/1/0 天共三次提醒。取消、降级、续费都在本页完成,所有金额与状态来自服务端。
验证码登录 / 注册
注册需依次通过:图形验证码 + 邮箱验证码 + 手机验证码。登录需:图形验证码 + 邮箱验证码。所有校验都在服务端完成。
当前模式:注册(三项校验)
未配置短信服务时,发送短信会明确返回“短信服务未配置”,不会伪造发送成功。测试模式仅在本机开启,验证码写入服务端日志并在页面标注。
登录后可以做什么
- 查看到期日与剩余可用天数,到期后服务端硬拦截。
- 查看 AI Agent 本月额度与已用条数。
- 续费、取消(必选原因)、降级(选更低档 + 原因)。
- 查看订阅事件时间线与到期前 3/1/0 天共三次提醒。
取消不会立即扣费;当前版本不自动扣款,续费需手动触发。
可用天数与用量
—
到期提醒(到期前 3 / 1 / 0 天,共三次)
账户信息
操作
取消需要选择原因,原因仅用于留存分析;降级立即生效并同步续费金额。
还没有生效中的订阅
当前账户可查看历史事件,但内容与 Agent 额度按免费档执行。可选择入门、主力或团队档开通。
取消订阅
试用期取消立即失效;生效中订阅取消后仍可用到本周期结束,到期硬失效。
取消后不再产生续费;当前版本无自动扣款,不会在到期日划扣。
降级订阅
降级立即生效,本周期内按低档权益执行,续费金额同步为低档价格。只能选择低于当前档位的付费档。
" 的尾巴、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();