import {ACTIONS, addCycle, completeRenewal, createId, cycleText, daysUntil, dueStatus, formatDate, getTasks, parseDate, todayString, validateActivationChange} from './core.js';
import {COUNTRIES} from './countries.js';
import {api} from './api.js';
import {CURRENCIES,COUNTRY_CURRENCY,normalizeAmount,sumMoney,balanceAfter,formatMoney} from './money.js';
const paths = {
grid:'',
sim:'',
esim:'',
calendar:'',
layers:'',
bell:'',
search:'',
chevron:'',
left:'',
down:'',
plus:'',
export:'',
more:'',
list:'',
message:'',
phone:'',
bag:'',
refresh:'',
wallet:'',
check:'',
checkCircle:'',
clock:'',
link:'',
telegram:'',
mail:'',
shield:'',
globe:'',
close:'',
edit:'',
settings:'',
archive:'',
trash:'',
menu:'',
help:'',
spark:'',
};
const icon = name => ``;
const e = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
const uid = createId;
const el = selector => document.querySelector(selector);
const NAV = [
{id:'overview',label:'总览',icon:'grid'}, {id:'cards',label:'我的卡片',icon:'sim'},
{id:'renewals',label:'续期计划',icon:'calendar'}, {id:'platforms',label:'关联平台',icon:'layers'},
{id:'settings',label:'通知设置',icon:'bell'},
];
const colors = {Telegram:'#63a9d9',WhatsApp:'#4cac88',Google:'#5e8be3',PayPal:'#5b80b7',Apple:'#7e8593',Amazon:'#da9b43',Discord:'#7987d9',WeChat:'#65b684',AlipayHK:'#609ee2',GitHub:'#697487',Microsoft:'#6d9dce',MobiMatter:'#e6935e',My3:'#5c87df',Tello:'#56a294',Airalo:'#ce7689'};
let serverUser = null, authMode = 'login', isMutating = false;
let view = 'overview', filter = 'all', layout = 'grid', query = '', showArchived = false, taskFilter = 'all';
const emptyWorkspace = () => ({version:2,cards:[],events:[],settings:{timezone:'Asia/Shanghai',time:'09:00',offsets:[7,3,1],overdue:true,telegram:false,email:false,emailAddress:'',emailVerified:false,telegramVerified:false,chatId:''},services:{telegram:{configured:false},email:{configured:false}},notifications:[]});
let data = emptyWorkspace();
let monthDate = parseDate(`${today().slice(0,7)}-01`), selectedDate = null;
let previousFocus = null, overlayCleanup = null, toastTimer, detailReturn = null, telegramBindingCode = '';
function today() {return todayString(data?.settings?.timezone || 'Asia/Shanghai');}
const user = () => serverUser;
const findCard = id => data.cards.find(card => card.id === id);
const tasks = () => getTasks(data.cards);
const nearest = card => [...card.rules].sort((a,b) => a.dueDate.localeCompare(b.dueDate))[0];
function savedMessage(message) {toast(message);}
const configured = channel => Boolean(data.services?.[channel]?.configured);
const verified = channel => Boolean(data.settings[channel === 'telegram' ? 'telegramVerified' : 'emailVerified']);
const channelReady = channel => configured(channel) && verified(channel) && data.settings[channel];
const channelStatus = channel => !configured(channel) ? '服务待配置' : !verified(channel) ? channel === 'telegram' ? '待绑定' : '待验证' : data.settings[channel] ? '已启用' : '未启用';
async function mutation(method,path,payload) {
if (isMutating) throw new Error('上一项操作正在保存,请稍候');
isMutating = true;
try {const result = await api.request(method,path,payload);api.assertCurrent(result);if (result.workspace) data = result.workspace;return result;}
finally {isMutating = false;}
}
function reportError(error,form=null) {
if (error.name === 'StaleResponse') return;
if (error.sessionGeneration !== undefined && error.sessionGeneration !== api.generation) return;
if (error.status === 401 && serverUser) {
closeOverlay(false);serverUser = null;data = emptyWorkspace();api.clear();renderAuth('登录已过期,请重新登录');return;
}
if (form?.isConnected && form.querySelector('.form-error')) formError(form,error.message);else toast(error.message,true);
}
async function refreshWorkspace() {
const result = await api.request('GET','/api/workspace');
api.assertCurrent(result);data = result.workspace;
}
function flag(country) {
const graphics = {
GB:'',
US:'',
HK:'',
JP:'',
CN:'',
DE:'',
SG:'',
};
return ``;
}
const provider = (card, mini = false) => `${e(card.mark)}`;
const typeTag = card => `${icon(card.type === 'SIM' ? 'sim' : 'esim')}${e(card.type)}`;
const statusBadge = date => {const status = dueStatus(date,today());return `${status.label}`;};
const platformBadge = registration => `${e(registration.name[0])}${e(registration.name)}`;
const moneyTags = values => values.length ? values.map(value=>`${e(formatMoney(value))}`).join('') : '未记录余额';
const cardCurrency = card => card?.balances?.[0]?.currency || COUNTRY_CURRENCY[card?.country] || 'USD';
function renderMoneySummary() {
const balances = sumMoney(data.cards.filter(card=>!card.archived).flatMap(card=>card.balances || []));
const upcoming = tasks().filter(task=>daysUntil(task.rule.dueDate,today())<=30);
const costs = sumMoney(upcoming.map(task=>task.rule.cost));
const unpriced = upcoming.filter(task=>!task.rule.cost).length;
return `${icon('wallet')}在用卡片余额
${moneyTags(balances)}
按币种分别汇总
${icon('calendar')}近 30 天待办金额
${costs.length ? moneyTags(costs) : '暂无已填写金额的待办'}
含逾期${unpriced ? ` · ${unpriced} 项未填写金额` : ''}
`;
}
function renderAuth(message = '') {
const registering = authMode === 'register';
el('#app').innerHTML = `${icon('sim')}续卡SIMKEEP每个号码,
都有它的下一次。
从第一次开通,到每一次续期。
把卡片、账号与提醒,放在自己的空间里。
开通连接留下最初的日期
安排下次续期短信、通话、重置或购买套餐
提醒,准时到达Telegram 与邮箱陪你记得
`;
}
async function bootstrap() {
const generation = api.generation;
el('#app').innerHTML = `${icon('sim')}正在打开你的卡片空间…
`;
try {
const session = await api.request('GET','/api/auth/me');
const result = await api.request('GET','/api/workspace');
api.assertCurrent(session);api.assertCurrent(result);
serverUser = session.user;data = result.workspace;
monthDate = parseDate(`${today().slice(0,7)}-01`);renderApp();
} catch (error) {
if (generation !== api.generation) return;
if (error.name === 'StaleResponse') return;
if (error.status === 401) renderAuth();
else el('#app').innerHTML = `${icon('sim')}${e(error.message)}
`;
}
}
function renderApp(keepSearch = false) {
if (!serverUser) {renderAuth();return;}
const pos = keepSearch ? el('#global-search')?.selectionStart : null;
const count = tasks().filter(task => daysUntil(task.rule.dueDate,today()) <= 7).length;
el('#app').innerHTML = `
`;
if (keepSearch) {el('#global-search').focus();if (pos !== null) el('#global-search').setSelectionRange(pos,pos);}
}
function heading(title, subtitle, actions = true) {
return `${actions ? `
` : ''}
`;
}
function footer() {return ``;}
function renderContent() {
if (view === 'settings') return renderSettings() + footer();
if (view === 'renewals') return renderRenewals() + footer();
if (view === 'platforms') return renderPlatforms() + footer();
return heading(view === 'overview' ? '卡片总览' : '我的卡片', view === 'overview' ? '管理每一条连接,让重要的号码一直在线。' : '号码、开通时间与平台记录,放在一起更清楚。') +
(view === 'overview' ? renderStats() + renderMoneySummary() + renderUpcoming() : '') +
`` + footer();
}
function renderStats() {
const active = data.cards.filter(card => !card.archived);
const soon = tasks().filter(task => daysUntil(task.rule.dueDate,today()) <= 7).length;
const registrations = active.reduce((sum,card) => sum + card.platforms.length,0);
return `
${icon('sim')}卡片总数
${active.length.toString().padStart(2,'0')}张
${icon('calendar')}近期续期
${soon.toString().padStart(2,'0')}项
${icon('layers')}平台绑定
${registrations.toString().padStart(2,'0')}条
${icon('bell')}提醒渠道
${['telegram','email'].filter(channelReady).length.toString().padStart(2,'0')}个
`;
}
function renderUpcoming() {
const soon = tasks().filter(task => daysUntil(task.rule.dueDate,today()) <= 7);
return `${icon(soon.length ? 'calendar' : 'checkCircle')}${soon.length ? `${soon.length} 项续期,记得安排` : '本周的连接都已照顾好'}
${soon.length ? '做一个小动作,让号码继续在线' : '没有临近到期的任务,安心使用'}
${soon.slice(0,3).map(({card,rule}) => ``).join('')}
`;
}
function matches(card) {
const haystack = [card.name,card.provider,card.phone,COUNTRIES[card.country],...card.platforms.flatMap(platform => [platform.name,platform.account])].join(' ').toLowerCase();
return haystack.includes(query.trim().toLowerCase());
}
function visibleCards() {return data.cards.filter(card => (showArchived ? card.archived : !card.archived) && (filter === 'all' || card.type === filter) && matches(card));}
function renderCollection() {
const cards = visibleCards();
const all = data.cards.filter(card => showArchived ? card.archived : !card.archived);
const count = type => all.filter(card => card.type === type).length;
return `
${cards.length ? layout === 'grid' ? `${cards.map(renderCard).join('')}
` : renderCardTable(cards) : empty(query ? '没有找到匹配的卡片' : showArchived ? '还没有归档的卡片' : '在这里,留下你的第一条连接', query ? '试试搜索运营商、号码尾号或已关联的平台。' : '添加 SIM 或 eSIM,设置续期周期,剩下的日期交给续卡。', !query && !showArchived)}
`;
}
function empty(title, description, add = false) {return `${icon('sim')}
${title}
${description}
${add ? `
` : ''}
`;}
function renderCard(card) {
const rule = nearest(card);
return `${provider(card)}
${e(card.provider)}
${typeTag(card)}
${flag(card.country)}${e(card.phone || '未记录号码')}
${icon('globe')}${e(COUNTRIES[card.country] || COUNTRIES.OTHER)}${icon('clock')}${formatDate(card.activatedAt)} 开通
${icon(rule ? ACTIONS[rule.action].icon : 'calendar')}
${rule ? ACTIONS[rule.action].label : '未设置续期'}${rule ? cycleText(rule) : '添加一条规则'}${rule?.cost ? ` · ${e(formatMoney(rule.cost))}` : ''}
${rule ? formatDate(rule.dueDate) : '—'}
余额${moneyTags(card.balances || [])}
${icon('link')}关联平台 ${card.platforms.length ? `(${card.platforms.length})` : ''}${card.rules.length > 1 ? `${card.rules.length} 条续期规则` : ''}
${card.platforms.length ? card.platforms.slice(0,3).map(platformBadge).join('') + (card.platforms.length > 3 ? `+${card.platforms.length-3}` : '') : '尚未关联平台'}
`;
}
function renderCardTable(cards) {
return `| 卡片名称 | 类型 / 地区 | 开通日期 | 余额 | 下次操作 / 金额 | 下次续期 | |
${cards.map(card => {const rule = nearest(card);return ` | ${typeTag(card)} ${e(COUNTRIES[card.country])} | ${formatDate(card.activatedAt)} | ${moneyTags(card.balances || [])} | ${rule ? ACTIONS[rule.action].label : '尚未设置'}${rule?.cost ? ` ${e(formatMoney(rule.cost))}` : ''} | ${rule ? `${formatDate(rule.dueDate)} ${statusBadge(rule.dueDate)}` : '—'} | |
`;}).join('')}
`;
}
function renderCalendar() {
const year = monthDate.getUTCFullYear(), month = monthDate.getUTCMonth();
const start = new Date(Date.UTC(year,month,1));
start.setUTCDate(1 - ((start.getUTCDay() + 6) % 7));
const dates = Array.from({length:42},(_,i) => {const date = new Date(start);date.setUTCDate(start.getUTCDate()+i);return date;});
const upcoming = selectedDate ? tasks().filter(task => task.rule.dueDate === selectedDate) : tasks().filter(task => task.rule.dueDate.slice(0,7) === `${year}-${String(month+1).padStart(2,'0')}`).slice(0,3);
return `${icon('calendar')}续期日历
${['一','二','三','四','五','六','日'].map(day => `${day}`).join('')}${dates.map(date => {const value = date.toISOString().slice(0,10);const n = tasks().filter(task => task.rule.dueDate === value).length;return ``;}).join('')}
今天有续期任务
${selectedDate ? `${formatDate(selectedDate,true)}的续期` : '本月接下来的安排'}
${upcoming.length ? upcoming.map(({card,rule}) => `
`).join('') : '
这段时间没有续期任务。
可以安心使用你的号码。
'}
`;
}
function renderReminderPanel() {
return `${icon('bell')}重要的续期,有人记得
提前收到提醒,按照步骤完成操作。
让每一张卡保持连接。
${icon('telegram')}Telegram Bot${e(channelStatus('telegram'))}
${icon('mail')}邮箱提醒${e(channelStatus('email'))}
`;
}
function renderRenewals() {
const all = tasks().filter(task => matches(task.card));
const shown = all.filter(task => taskFilter === 'all' || (taskFilter === 'soon' ? daysUntil(task.rule.dueDate,today()) <= 7 : daysUntil(task.rule.dueDate,today()) < 0));
return heading('续期计划','什么时候,做什么。每一次保号与续订都有清楚的安排。') + `${shown.length ? shown.map(({card,rule}) => `
${Number(rule.dueDate.slice(5,7))}月${Number(rule.dueDate.slice(8))}
${provider(card,true)}${e(card.name)} / ${e(card.provider)}
${icon(ACTIONS[rule.action].icon)}${ACTIONS[rule.action].label}${cycleText(rule)}${rule.anchor === 'scheduled' ? '固定周期' : '完成后顺延'}${rule.cost ? `${e(formatMoney(rule.cost))}` : '金额未填写'}
${statusBadge(rule.dueDate)}`).join('') : empty('这段时间没有待办','完成操作后,新的续期日期会自动出现在这里。')}
`;
}
function renderPlatforms() {
const registrations = data.cards.filter(card => !card.archived && matches(card)).flatMap(card => card.platforms.map(platform => ({card,platform}))).filter(({platform,card}) => !query || `${platform.name} ${platform.account} ${card.name} ${card.phone} ${card.provider}`.toLowerCase().includes(query.toLowerCase()));
return heading('关联平台','记住每个号码注册过什么,让账号归属有迹可循。',false) + `${registrations.length ? `` : empty('还没有匹配的平台记录','在卡片详情中关联平台,也可以点击上方按钮添加。')}`;
}
function previewMessage(task = tasks()[0]) {
if (!task) return '添加卡片和续期规则后,就可以预览提醒内容。
';
const {card,rule} = task;
const balance = (card.balances || []).find(item=>rule.cost && item.currency===rule.cost.currency);
return `${icon('bell')}续卡提醒 · ${e(card.name)}卡片${e(card.provider)} / ${e(card.type)}
号码${card.phone.startsWith('+') ? `尾号 ${e(card.phone.replace(/\s/g,'').slice(-4))}` : '数据卡 / 未填写号码'}
到期时间${formatDate(rule.dueDate)}
续期操作${ACTIONS[rule.action].label}
续期金额${e(formatMoney(rule.cost))}
${balance ? `
同币种余额${e(formatMoney(balance))}
` : ''}
${e(rule.instructions || '完成后在续卡记录操作,自动计算下一次日期。')}
`;
}
function renderSettings() {
const s = data.settings;
const zones = [['Asia/Shanghai','中国标准时间 · UTC+8'],['Asia/Hong_Kong','香港时间 · UTC+8'],['Europe/London','伦敦时间'],['America/New_York','纽约时间'],['Asia/Tokyo','东京时间 · UTC+9']];
if (!zones.some(([zone]) => zone === s.timezone)) zones.push([s.timezone,s.timezone]);
return heading('通知设置','配置自己的通知服务,再绑定接收账号,按时收到续期提醒。',false) + `
${renderNotificationLog()}`;
}
function serviceSetup(channel) {
const service = data.services[channel];
const name = channel === 'telegram' ? 'Telegram' : '邮件';
const explanation = service.source === 'account' ? '已保存本账号的配置' : service.source === 'server' ? '当前使用公共服务,可配置自己的服务' : channel === 'telegram' ? '先填写 BotFather 提供的 Bot Token' : '先填写发信服务器与 SMTP 授权码';
return `${icon(service.configured ? 'checkCircle' : 'settings')}${e(explanation)}
`;
}
function notificationConfigForm(channel) {
const service = data.services[channel], telegram = channel === 'telegram';
const secretNote = saved => saved ? '已保存,留空保留原值' : '填写后只保存在服务器,不会回显';
const body = telegram ? `${icon('telegram')}使用你在 BotFather 创建的 Bot。保存后,回到通知设置绑定 Telegram。
${field('telegram-token','Bot Token',`${secretNote(service.tokenSaved)}`)}同一个 Bot 只能用于一个账号。更换 Token 后需要重新绑定。请让续卡独占这个 Bot 的更新读取。
` : `${icon('mail')}配置发送邮件的服务。接收提醒的邮箱仍在通知设置中填写。
${field('smtp-host','SMTP 服务器',``,true)}${field('smtp-port','端口',``)}${field('smtp-mode','安全方式',``)}${field('smtp-from','发件邮箱',`填写邮件服务允许使用的发件地址。`,true)}${field('smtp-user','SMTP 用户名',`服务无需登录认证时留空。`,true)}${field('smtp-password','SMTP 密码 / 授权码',`${secretNote(service.passwordSaved)}`,true)}
`;
openOverlay(telegram ? '配置 Telegram' : '配置邮件','仅用于当前账号。Token 和密码保存后不会回显。',``,``,'config-modal');
}
function renderNotificationLog() {
const labels = {pending:'等待发送',processing:'发送中',sent:'已发送',failed:'发送失败',cancelled:'已取消'};
return `${data.notifications.length ? `| 提醒渠道 | 安排时间 | 状态 | 尝试次数 | 说明 |
${data.notifications.map(log => `| ${log.channel === 'telegram' ? 'Telegram' : '邮箱'} | ${e(new Intl.DateTimeFormat('zh-CN',{timeZone:data.settings.timezone,dateStyle:'short',timeStyle:'short'}).format(new Date(log.created_at*1000)))} | ${labels[log.status] || e(log.status)} | ${log.attempts} | ${e(log.error || (log.status === 'sent' ? '已提交到通知服务' : '—'))} |
`).join('')}
` : `${icon('bell')}还没有发送记录。续期提醒安排发送后,会出现在这里。
`}`;
}
function emailCodeForm() {
openOverlay('验证接收邮箱',`验证码已发送至 ${e(data.settings.emailAddress)}`,``,``,'small');
}
async function bindTelegram() {
const binding = await mutation('POST','/api/notifications/telegram/binding');
telegramBindingCode = binding.code;
openOverlay('绑定 Telegram','打开 Bot,点击“开始”,然后回到这里检查绑定。',``,``,'small');
}
function toast(message, error = false) {
clearTimeout(toastTimer);
el('#toast-root').innerHTML = `${icon(error ? 'help' : 'checkCircle')}${e(message)}
`;
toastTimer = setTimeout(() => {el('#toast-root').innerHTML = '';},4200);
}
function closeOverlay(restoreFocus = true) {
if (overlayCleanup) {overlayCleanup();overlayCleanup = null;}
el('#overlay-root').innerHTML = '';
document.body.classList.remove('body-overlay');
el('#app').inert = false;
if (restoreFocus && previousFocus?.isConnected) previousFocus.focus();
}
function openOverlay(title, subtitle, body, footerHtml = '', kind = '') {
closeOverlay(false);
previousFocus = document.activeElement;
el('#overlay-root').innerHTML = `${body}
${footerHtml ? `` : ''} `;
document.body.classList.add('body-overlay');
el('#app').inert = true;
const dialog = el('[role="dialog"]');
const focusables = () => Array.from(dialog.querySelectorAll('button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex="0"]'));
requestAnimationFrame(() => {(dialog.querySelector('input:not([type="hidden"]),select') || dialog.querySelector('button') || dialog).focus();});
const onKey = event => {
if (event.key === 'Escape') {event.preventDefault();closeOverlay();}
if (event.key === 'Tab') {
const elements = focusables(), first = elements[0], last = elements.at(-1);
if (!first) {event.preventDefault();dialog.focus();}
else if (event.shiftKey && (document.activeElement === first || document.activeElement === dialog)) {event.preventDefault();last.focus();}
else if (!event.shiftKey && document.activeElement === last) {event.preventDefault();first.focus();}
}
};
document.addEventListener('keydown',onKey);
overlayCleanup = () => document.removeEventListener('keydown',onKey);
}
const formError = (form, message) => {const target = form.querySelector('.form-error');target.textContent = message;target.hidden = false;target.scrollIntoView({block:'nearest'});};
const option = (value, label, selected) => ``;
const field = (id, label, input, wide = false) => `${input}
`;
const currencyOptions = selected => Object.entries(CURRENCIES).map(([code,item])=>option(code,`${code} · ${item.label}`,selected)).join('');
function amountField(id,name,label,value='') {
return field(id,label,``);
}
function costFields(prefix,cost=null,currency='USD',label='预计续期金额') {
return `${amountField(prefix+'-amount','costAmount',label,cost?.amount || '')}${field(prefix+'-currency','金额币种',``)}`;
}
function readCost(form) {
const value = form.elements.costAmount.value.trim();
if (!value) return null;
const currency = form.elements.costCurrency.value;
return {currency,amount:normalizeAmount(value,currency)};
}
function balanceRow(balance={currency:'USD',amount:''}) {
const id = 'balance-'+uid();
return `${field(id+'-currency','余额币种',``)}${amountField(id+'-amount','balanceAmount','余额金额',balance.amount)}
`;
}
function readBalances(form) {
const balances = [...form.querySelectorAll('.balance-row')].flatMap(row => {
const amount = row.querySelector('[name="balanceAmount"]').value.trim();
if (!amount) return [];
const currency = row.querySelector('[name="balanceCurrency"]').value;
return [{currency,amount:normalizeAmount(amount,currency)}];
});
if (new Set(balances.map(item=>item.currency)).size !== balances.length) throw new Error('同一张卡的每个币种只能记录一条余额');
return balances;
}
function ruleFields(rule = {}, minDate = today(), currency = 'USD') {
return `
${field('rule-action','续期操作*',``)}
${field('rule-anchor','日期计算方式',``)}
${field('rule-interval','续期周期*',``)}
${field('rule-unit','周期单位',``)}
${field('rule-due','下次到期日期*',``,true)}
${costFields('rule-cost',rule.cost,currency)}
${field('rule-instructions','操作步骤',``,true)}
`;
}
function cardForm(card = null) {
const editing = Boolean(card);
const c = card || {name:'',provider:'',phone:'',type:'eSIM',country:'HK',activatedAt:today(),notes:'',plan:''};
openOverlay(editing ? '编辑卡片' : '添加一张新卡片',editing ? '修改卡片档案;续期规则可在卡片详情中单独调整。' : '记下这条连接,再为它安排第一次续期。',``,``);
const form = el('#card-form');
if (!editing) {
form.addEventListener('input',event => {
if (!['activatedAt','interval','unit'].includes(event.target.name)) return;
try {form.elements.dueDate.min = form.elements.activatedAt.value;form.elements.dueDate.value = addCycle(form.elements.activatedAt.value,Number(form.elements.interval.value),form.elements.unit.value);} catch { /* Let form validation explain an unfinished value on submit. */ }
});
}
}
function readRule(form, existing = null) {
const f = form.elements;
const interval = Number(f.interval.value), unit = f.unit.value;
if (!Number.isInteger(interval) || interval < 1 || interval > (unit === 'months' ? 120 : 3650)) throw new Error(`周期必须为 1–${unit === 'months' ? 120 : 3650} 的整数`);
const dueDate = f.dueDate.value;
parseDate(dueDate);
return {action:f.action.value,interval,unit,dueDate,anchor:f.anchor.value,instructions:f.instructions.value.trim(),cost:readCost(form),...(existing ? {version:existing.version} : {})};
}
function ruleForm(cardId, ruleId = '') {
const card = findCard(cardId), rule = card.rules.find(item => item.id === ruleId);
detailReturn = cardId;
openOverlay(rule ? '编辑续期规则' : '添加续期规则',`${e(card.name)} · 每条规则独立计算下一次日期`, ``,``);
}
function completionForm(cardId, ruleId) {
const card = findCard(cardId), rule = card?.rules.find(item => item.id === ruleId);
if (!rule || card.archived) return;
const minDate = rule.lastCompletedAt && rule.lastCompletedAt > card.activatedAt ? rule.lastCompletedAt : card.activatedAt;
const balanceAction = rule.action === 'topup' ? 'credit' : 'deduct';
openOverlay('记录续期完成','确认已经完成操作,记录花费并自动安排下一次。',`${provider(card)}
${e(card.name)}
${ACTIONS[rule.action].label} · ${cycleText(rule)} · ${rule.anchor === 'scheduled' ? '固定周期' : '完成后顺延'}
${e(rule.instructions || '请确认续期操作成功后,再记录完成。')}
`,``,'small');
const form = el('#completion-form');
const updateDate = () => {try {el('#next-date-preview').textContent = formatDate(completeRenewal(card,ruleId,form.elements.completedAt.value,today()).card.rules.find(item => item.id === ruleId).dueDate);} catch (error) {el('#next-date-preview').textContent = error.message;}};
const updateMoney = () => {
const target = el('#balance-after-preview');
try {
target.textContent = form.elements.updateBalance.checked ? formatMoney(balanceAfter(card.balances || [],readCost(form),balanceAction)) : '只记录费用';
target.classList.remove('money-error');
} catch (error) {target.textContent = error.message;target.classList.add('money-error');}
};
form.dataset.requestId = uid();form.elements.completedAt.addEventListener('input',updateDate);form.addEventListener('input',updateMoney);form.addEventListener('change',updateMoney);updateDate();updateMoney();
}
function showDetail(cardId) {
const card = findCard(cardId);
if (!card) return;
const history = data.events.filter(item => item.cardId === cardId).sort((a,b) => b.completedAt.localeCompare(a.completedAt));
openOverlay('卡片详情','开通、平台与续期,一张卡的完整记录。',`${provider(card)}
${e(card.name)}
${e(card.provider)} ${typeTag(card)} ${card.archived ? '已归档' : ''}
- 号码
- ${e(card.phone)}
- 国家或地区
- ${flag(card.country)} ${e(COUNTRIES[card.country])}
- 开通日期
- ${formatDate(card.activatedAt)}
- 套餐说明
- ${e(card.plan || '未填写')}
- 卡片余额
- ${moneyTags(card.balances || [])}
${card.rules.length ? [...card.rules].sort((a,b) => a.dueDate.localeCompare(b.dueDate)).map(rule => `${icon(ACTIONS[rule.action].icon)}${ACTIONS[rule.action].label}${cycleText(rule)}
预计续期金额 ${e(formatMoney(rule.cost))}
${e(rule.instructions || '尚未填写操作说明')}
下次 ${formatDate(rule.dueDate)}
${rule.anchor === 'scheduled' ? '固定周期' : '按完成日顺延'}${!card.archived ? `` : ''}
`).join('') : '添加一条规则,开始记录这张卡的续期计划。
'}${card.platforms.length ? card.platforms.map(platform => ``).join('') : '记录这张卡注册的平台,下次找账号更方便。
'}${history.length ? history.map(item => `${formatDate(item.completedAt)} · ${ACTIONS[item.action]?.label || '续期操作'}下次续期 ${formatDate(item.newDueDate)}
本次金额 ${e(formatMoney(item.cost))}${item.balanceAfter ? `
${item.balanceAction === 'credit' ? '计入余额后' : '扣除后余额'} ${e(formatMoney(item.balanceAfter))}` : ''}${item.note ? `
${e(item.note)}` : ''}
`).join('') : '还没有操作记录。完成一次续期后,历史会留在这里。
'}${card.notes ? `` : ''}`,``,'drawer');
}
function platformForm(cardId = '') {
const card = findCard(cardId);
detailReturn = cardId || null;
const available = data.cards.filter(item => !item.archived);
if (!available.length && !card) {toast('请先添加一张卡片,再关联平台');return;}
openOverlay('添加平台关联',card ? `${e(card.name)} · 记住这个号码注册过什么` : '选择卡片,记录平台和账号归属。',``,``,'small');
}
function removePlatform(cardId, platformId) {
const card = findCard(cardId), platform = card?.platforms.find(item => item.id === platformId);
if (!platform) return;
const wasDetail = Boolean(el('.drawer'));
openOverlay('移除平台关联',`${e(card.name)} · ${e(platform.name)}`,`移除这条平台记录?这只会更新续卡中的记录,不会注销该平台的实际账号。
`,``,'small');
}
function showAccount() {
openOverlay('我的账号','卡片、规则与通知设置保存在你的个人空间。',`${e(user().initial)}${e(user().name)}${e(user().email)}
${icon('checkCircle')}
登录这个账号,可以在不同设备上继续管理你的卡片。导出的文件包含卡片和平台资料,请妥善保管。
`,``,'small');
}
function showHelp() {
openOverlay('认识续卡','把号码与维护它的那些小事,放在同一个地方。',`记录卡片
添加运营商、号码、卡片类型和开通日期。数据 eSIM 可以不填写号码。
安排续期
一张卡可以有多条规则,分别管理短信保号、通话、充值、重置和购买套餐。实际完成后点击“记录完成”,自动更新所选规则的下次日期。
找到平台注册记录
打开卡片详情添加平台与账号标识;使用顶部搜索可以按平台找到关联卡片。
接收自动提醒
在通知设置绑定 Telegram 或验证邮箱,开启渠道并保存提醒时间。归档卡片会停止安排提醒,历史资料仍然保留。
`,``);
}
function showReminders() {
const upcoming = tasks().filter(task => daysUntil(task.rule.dueDate,today()) <= 7);
openOverlay('近期提醒',`${upcoming.length} 项操作需要在未来 7 天内完成。`,upcoming.length ? `${upcoming.map(({card,rule}) => `
${provider(card,true)}
${e(card.name)}
${formatDate(rule.dueDate)} · ${ACTIONS[rule.action].label}
`).join('')}
开启通知渠道并完成绑定或验证后,续期提醒会按设置的时间发送。
` : empty('本周没有待完成的续期','有新的临近到期任务时,会出现在这里。'),``);
}
function exportData() {
const blob = new Blob([JSON.stringify({exportedAt:new Date().toISOString(),user:user(),...data},null,2)],{type:'application/json'});
const url = URL.createObjectURL(blob), link = document.createElement('a');
link.href = url;link.download = `simkeep-${serverUser.id}-${today()}.json`;link.click();
setTimeout(() => URL.revokeObjectURL(url),1000);toast('已导出卡片、平台与续期记录');
}
function navigate(next) {
closeOverlay(false);view = next;query = '';showArchived = false;filter = 'all';
location.hash = next;
renderApp();window.scrollTo({top:0,behavior:'instant'});
}
function showCardActions(cardId) {
const card = findCard(cardId);
openOverlay(e(card.name),'选择要进行的操作。',``,'','small');
}
document.addEventListener('click',async event => {
if (event.target.classList.contains('overlay-backdrop')) {closeOverlay();return;}
if (event.target.classList.contains('menu-scrim')) {event.target.remove();el('.sidebar').classList.remove('open');return;}
const button = event.target.closest('[data-action],[data-nav],[data-filter],[data-layout],[data-month],[data-date],[data-task-filter]');
if (!button || button.disabled) return;
if (button.dataset.nav) {event.preventDefault();navigate(button.dataset.nav);return;}
if (button.dataset.filter) {filter = button.dataset.filter;renderApp();return;}
if (button.dataset.layout) {layout = button.dataset.layout;renderApp();return;}
if (button.dataset.taskFilter) {taskFilter = button.dataset.taskFilter;renderApp();return;}
if (button.dataset.month) {monthDate.setUTCMonth(monthDate.getUTCMonth()+Number(button.dataset.month));selectedDate = null;renderApp();return;}
if (button.dataset.date) {selectedDate = selectedDate === button.dataset.date ? null : button.dataset.date;renderApp();return;}
const {action,card:cardId,rule:ruleId,platform:platformId} = button.dataset;
if (button.type !== 'submit') event.preventDefault();
const busy = ['refresh','retry-bootstrap','confirm-remove-platform','confirm-archive','archive-card','logout','bind-telegram','check-telegram','verify-email','test-email','test-telegram','confirm-unbind-telegram','confirm-clear-service-config'].includes(action);
if (busy) button.disabled = true;
try {
switch (action) {
case 'auth-login':authMode = 'login';renderAuth();break;
case 'auth-register':authMode = 'register';renderAuth();break;
case 'retry-bootstrap':await bootstrap();break;
case 'refresh':await refreshWorkspace();closeOverlay(false);renderApp();toast('资料已刷新');break;
case 'close':closeOverlay();break;
case 'add-card':cardForm();break;
case 'add-balance-row': {
const container = el('#balance-fields');
const used = [...container.querySelectorAll('[name="balanceCurrency"]')].map(input=>input.value);
const currency = Object.keys(CURRENCIES).find(code=>!used.includes(code));
if (!currency) throw new Error('所有支持的币种均已添加');
container.insertAdjacentHTML('beforeend',balanceRow({currency,amount:''}));container.lastElementChild.querySelector('select').focus();break;
}
case 'remove-balance-row':button.closest('.balance-row').remove();break;
case 'edit-card':cardForm(findCard(cardId));break;
case 'detail':showDetail(cardId);break;
case 'card-actions':showCardActions(cardId);break;
case 'complete':completionForm(cardId,ruleId);break;
case 'add-rule':ruleForm(cardId);break;
case 'edit-rule':ruleForm(cardId,ruleId);break;
case 'add-platform':platformForm(cardId);break;
case 'choose-platform':platformForm();break;
case 'remove-platform':removePlatform(cardId,platformId);break;
case 'confirm-remove-platform': {
const card = findCard(cardId);
await mutation('DELETE',`/api/cards/${cardId}/platforms/${platformId}?version=${card.version}`);
closeOverlay(false);renderApp();savedMessage('已移除平台关联');if (button.dataset.return) showDetail(button.dataset.return);break;
}
case 'archive-card': {
const card = findCard(cardId);
if (card.archived) {
await mutation('POST',`/api/cards/${cardId}/archive`,{archived:false,version:card.version});closeOverlay(false);renderApp();savedMessage('卡片已恢复,续期计划重新显示');
} else openOverlay('归档这张卡片?',e(card.name),'归档后保留卡片资料、平台关联和操作历史,停止安排续期提醒。你可以随时在“已归档”中恢复。
',``,'small');
break;
}
case 'confirm-archive':await mutation('POST',`/api/cards/${cardId}/archive`,{archived:true,version:findCard(cardId).version});closeOverlay(false);renderApp();savedMessage('卡片已归档');break;
case 'toggle-archived':showArchived = !showArchived;renderApp();break;
case 'calendar-today':monthDate = parseDate(`${today().slice(0,7)}-01`);selectedDate = null;renderApp();break;
case 'account':showAccount();break;
case 'logout': {
await mutation('POST','/api/auth/logout');closeOverlay(false);serverUser = null;data = emptyWorkspace();api.clear();view = 'overview';query = '';filter = 'all';showArchived = false;selectedDate = null;location.hash = '';renderAuth();toast('已退出登录');break;
}
case 'bind-telegram':await bindTelegram();break;
case 'configure-service':notificationConfigForm(button.dataset.channel);break;
case 'clear-service-config': {
const channel = button.dataset.channel;
openOverlay(`清除${channel === 'telegram' ? ' Telegram' : '邮件'}配置?`,'只影响当前账号。',`清除后将停止使用你保存的凭据。${channel === 'telegram' ? 'Telegram 绑定也会解除。' : '已验证的接收邮箱会保留。'}如果服务器提供公共通知服务,将恢复使用公共服务。
`,``,'small');break;
}
case 'confirm-clear-service-config':await mutation('DELETE',`/api/notifications/config/${button.dataset.channel}`);closeOverlay(false);renderApp();toast('本账号的通知服务配置已清除');break;
case 'check-telegram': {
const checked = await mutation('POST','/api/notifications/telegram/binding/check',{code:telegramBindingCode});
if (checked.bindingStatus === 'bound') {closeOverlay(false);renderApp();toast('Telegram 已绑定,请开启提醒并保存设置');}
else if (checked.bindingStatus === 'expired') toast('绑定指令已失效,请关闭后重新绑定',true);
else toast('还未收到绑定指令,请在 Bot 中点击“开始”后再检查',true);
break;
}
case 'unbind-telegram':openOverlay('解除 Telegram 绑定?','解除后将停止向这个 Telegram 账号发送提醒。','你的卡片和操作记录仍保留,可以随时重新绑定。
','','small');break;
case 'confirm-unbind-telegram':await mutation('DELETE','/api/notifications/telegram/binding');closeOverlay(false);renderApp();toast('Telegram 绑定已解除');break;
case 'verify-email': {
if (el('#notify-email').value.trim().toLowerCase() !== data.settings.emailAddress) throw new Error('请先保存新的接收邮箱,再发送验证码');
await mutation('POST','/api/notifications/email/verify');emailCodeForm();break;
}
case 'test-email':case 'test-telegram':await mutation('POST',`/api/notifications/test/${action === 'test-email' ? 'email' : 'telegram'}`);toast('测试提醒已发送,请检查接收消息');break;
case 'preview':openOverlay('续期提醒预览','这是通知内容预览。可在通知设置中发送测试提醒。',previewMessage(),'','small');break;
case 'reminders':showReminders();break;
case 'help':showHelp();break;
case 'export':exportData();break;
case 'menu':el('.sidebar').classList.add('open');el('#app').insertAdjacentHTML('beforeend','');break;
}
} catch (error) {reportError(error);}
finally {if (busy && button.isConnected) button.disabled = false;}
});
document.addEventListener('input',event => {
if (event.target.id !== 'global-search') return;
query = event.target.value;
if (view === 'settings') view = 'cards';
renderApp(true);
});
document.addEventListener('keydown',event => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k' && !el('[role="dialog"]')) {event.preventDefault();el('#global-search')?.focus();}
if (event.key === 'Escape' && el('.sidebar.open')) {el('.sidebar').classList.remove('open');el('.menu-scrim')?.remove();}
});
document.addEventListener('submit',async event => {
const form = event.target;
if (!['auth-form','card-form','rule-form','completion-form','platform-form','settings-form','email-code-form','notification-config-form'].includes(form.id)) return;
event.preventDefault();
if (form.dataset.pending === 'true') return;
form.dataset.pending = 'true';
const submits = [...form.querySelectorAll('button[type="submit"]'),...document.querySelectorAll(`button[type="submit"][form="${form.id}"]`)];
for (const button of submits) button.disabled = true;
const errorBox = form.querySelector('.form-error');if (errorBox) errorBox.hidden = true;
try {
const f = form.elements;
if (form.id === 'auth-form') {
const payload = {email:f.email.value.trim(),password:f.password.value};
if (authMode === 'register') payload.name = f.name.value.trim();
const result = await mutation('POST',`/api/auth/${authMode}`,payload);
serverUser = result.user;data = result.workspace;query = '';filter = 'all';showArchived = false;monthDate = parseDate(`${today().slice(0,7)}-01`);renderApp();toast(authMode === 'register' ? '账号已创建,添加你的第一张卡片吧' : '已登录你的个人空间');
} else if (form.id === 'card-form') {
const existing = findCard(form.dataset.card);
const activatedAt = f.activatedAt.value;
validateActivationChange(existing,data.events,activatedAt,today());
if (!f.name.value.trim() || !f.provider.value.trim()) throw new Error('请填写卡片名称和运营商');
const card = {name:f.name.value.trim(),provider:f.provider.value.trim(),type:f.type.value,country:f.country.value,phone:f.phone.value.trim(),activatedAt,plan:f.plan.value.trim(),notes:f.notes.value.trim(),balances:readBalances(form)};
if (!existing) {const rule = readRule(form);if (rule.dueDate < activatedAt) throw new Error('首次到期日期不能早于开通日期');card.rules = [rule];}
else card.version = Number(form.dataset.version);
await mutation(existing ? 'PUT' : 'POST',existing ? `/api/cards/${existing.id}` : '/api/cards',card);
closeOverlay(false);renderApp();savedMessage(existing ? '卡片资料已更新' : '卡片已添加,第一次续期已安排');
} else if (form.id === 'rule-form') {
const card = findCard(form.dataset.card), existing = card.rules.find(item => item.id === form.dataset.rule), rule = readRule(form,existing);
if (rule.dueDate < card.activatedAt) throw new Error('到期日期不能早于卡片开通日期');
if (existing?.lastCompletedAt && rule.dueDate <= existing.lastCompletedAt) throw new Error('新的到期日期必须晚于上次完成日期');
if (existing) rule.version = Number(form.dataset.version);
await mutation(existing ? 'PUT' : 'POST',existing ? `/api/rules/${existing.id}` : `/api/cards/${card.id}/rules`,rule);
renderApp();savedMessage('续期规则已保存');showDetail(card.id);
} else if (form.id === 'completion-form') {
const card = findCard(form.dataset.card);
// The local calculation is a preview; the API owns the date and the atomic history write.
completeRenewal(card,form.dataset.rule,f.completedAt.value,today());
const cost = readCost(form), balanceAction = f.updateBalance.checked ? form.dataset.balanceAction : 'none';
if (balanceAction !== 'none') balanceAfter(card.balances || [],cost,balanceAction);
await mutation('POST',`/api/rules/${form.dataset.rule}/completions`,{completedAt:f.completedAt.value,note:f.note.value.trim(),requestId:form.dataset.requestId,version:Number(form.dataset.version),cost,balanceAction,cardVersion:Number(form.dataset.cardVersion)});
const next = findCard(card.id).rules.find(rule => rule.id === form.dataset.rule).dueDate;
closeOverlay(false);renderApp();savedMessage(`已记录完成,下次续期 ${formatDate(next)}`);
} else if (form.id === 'platform-form') {
const card = findCard(form.dataset.card || f.cardId.value);
const name = f.name.value.trim();if (!name) throw new Error('请填写平台名称');
await mutation('POST',`/api/cards/${card.id}/platforms`,{name,account:f.account.value.trim(),purpose:f.purpose.value.trim(),version:card.version});
closeOverlay(false);renderApp();savedMessage('平台关联已保存');if (detailReturn) showDetail(card.id);
} else if (form.id === 'settings-form') {
const emailAddress = f.emailAddress.value.trim();
if (f.email.checked && !emailAddress) throw new Error('启用邮箱提醒时,请填写接收邮箱');
await mutation('PUT','/api/settings',{telegram:f.telegram.checked,email:f.email.checked,emailAddress,timezone:f.timezone.value,time:f.time.value,offsets:Array.from(form.querySelectorAll('input[name="offset"]:checked')).map(input => Number(input.value)),overdue:f.overdue.checked});
selectedDate = null;monthDate = parseDate(`${today().slice(0,7)}-01`);renderApp();savedMessage('通知设置已保存');
} else if (form.id === 'notification-config-form') {
const channel = form.dataset.channel;
const payload = channel === 'telegram' ? {token:f.token.value.trim()} : {host:f.host.value.trim(),port:Number(f.port.value),mode:f.mode.value,user:f.user.value.trim(),password:f.password.value,from:f.sender.value.trim()};
const feedback = form.querySelector('.config-feedback');feedback.hidden = true;
await mutation('PUT',`/api/notifications/config/${channel}`,payload);
renderApp();
const secret = channel === 'telegram' ? f.token : f.password;
secret.value = '';secret.required = false;secret.placeholder = '已保存,留空保留原值';
if (event.submitter?.value === 'check') {
feedback.textContent = '配置已保存,正在检查连接…';feedback.hidden = false;
try {
const checked = await mutation('POST',`/api/notifications/config/${channel}/check`);
feedback.textContent = channel === 'telegram' ? `配置已保存,已连接 @${checked.botUsername}。下一步绑定 Telegram。` : '配置已保存,SMTP 连接与认证通过。下一步验证接收邮箱。';
feedback.scrollIntoView({block:'nearest'});
} catch (error) {feedback.hidden = true;error.message = `配置已保存。${error.message}`;throw error;}
} else {closeOverlay(false);toast(channel === 'telegram' ? 'Telegram 配置已保存,可以绑定账号了' : '邮件配置已保存,可以验证接收邮箱了');}
} else if (form.id === 'email-code-form') {
await mutation('POST','/api/notifications/email/confirm',{code:f.code.value});closeOverlay(false);renderApp();toast('邮箱已验证,请开启提醒并保存设置');
}
} catch (error) {reportError(error,form);}
finally {form.dataset.pending = 'false';for (const button of submits) if (button.isConnected) button.disabled = false;}
});
if (NAV.some(item => `#${item.id}` === location.hash)) view = location.hash.slice(1);
window.addEventListener('hashchange',() => {const next = location.hash.slice(1);if (NAV.some(item => item.id === next) && next !== view) {view = next;closeOverlay(false);query = '';renderApp();}});
bootstrap();