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

每个号码,
都有它的下一次。

从第一次开通,到每一次续期。
把卡片、账号与提醒,放在自己的空间里。

每一条连接,都有迹可循

${registering ? '开启你的卡片空间' : '回来照顾你的连接'}

${registering ? '每个人都有独立的卡片、平台与续期记录。' : '登录后,接着管理你的 SIM 与 eSIM。'}

${registering ? field('auth-name','你的名字',``) : ''}${field('auth-email','邮箱',``)}${field('auth-password','密码',``)}
续卡 SIMKEEP · SIM 与 eSIM 管理
`; } 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 = `
${renderContent()}
`; if (keepSearch) {el('#global-search').focus();if (pos !== null) el('#global-search').setSelectionRange(pos,pos);} } function heading(title, subtitle, actions = true) { return `

${title}

${subtitle}

${actions ? `
` : ''}
`; } function footer() {return `
续卡 SIMKEEP · 每一条连接,都有迹可循${icon('shield')}资料已保存到你的账号
`;} 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() : '') + `
${renderCollection()}
` + 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')}张

${active.filter(card => card.type === 'eSIM').length} eSIM / ${active.filter(card => card.type === 'SIM').length} 实体 SIM

${icon('calendar')}近期续期
${soon.toString().padStart(2,'0')}项

未来 7 天内需要照顾的连接

${icon('layers')}平台绑定
${registrations.toString().padStart(2,'0')}条

重要账号与号码一一对应

${icon('bell')}提醒渠道
${['telegram','email'].filter(channelReady).length.toString().padStart(2,'0')}个

Telegram 和邮箱自动提醒

`; } 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 `

${showArchived ? '已归档卡片' : '我的卡片'}${cards.length}

${[['all','全部',all.length],['eSIM','eSIM',count('eSIM')],['SIM','SIM',count('SIM')]].map(([value,label,n]) => ``).join('')}
${cards.length ? layout === 'grid' ? `
${cards.map(renderCard).join('')}
` : renderCardTable(cards) : empty(query ? '没有找到匹配的卡片' : showArchived ? '还没有归档的卡片' : '在这里,留下你的第一条连接', query ? '试试搜索运营商、号码尾号或已关联的平台。' : '添加 SIM 或 eSIM,设置续期周期,剩下的日期交给续卡。', !query && !showArchived)}
${query ? `搜索“${e(query)}” · ` : ''}共 ${cards.length} 张${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 ``;}).join('')}
卡片名称类型 / 地区开通日期余额下次操作 / 金额下次续期
${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)}` : '—'}
`; } 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')}续期日历

${year} 年 ${month+1} 月
${['一','二','三','四','五','六','日'].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'))}
邮箱提醒${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}

${[['all','全部计划'],['soon','近 7 天'],['overdue','已逾期']].map(([key,label]) => ``).join('')}
${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}

${registrations.length ? `
${registrations.map(({card,platform}) => ``).join('')}
平台关联卡片账号标识用途
${e(platform.name[0])}${e(platform.name)}${e(platform.account || '未填写')}${e(platform.purpose || '未填写')}
` : 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) + `

${icon('telegram')}通知渠道

Bot Token 和 SMTP 按账号单独保存。可以同时启用两种提醒。

${icon('telegram')}
Telegram Bot

在 Bot 中点击开始,绑定你的个人账号

${serviceSetup('telegram')}
${e(channelStatus('telegram'))}${s.telegramVerified ? `` : ''}
邮箱提醒

验证接收邮箱,确保提醒送到你手中

${serviceSetup('email')}
修改邮箱后先保存设置,再发送验证码。
${s.emailVerified ? '邮箱已验证' : '邮箱待验证'}${s.emailVerified ? `` : ''}

${icon('clock')}提醒时间

按你所在的时区安排提醒。到期当天总会提醒一次。


提前几天提醒
${[...new Set([14,7,3,1,...s.offsets])].sort((a,b) => b-a).map(offset => ``).join('')}${icon('check')}到期当天
${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 和密码保存后不会回显。',`
${body}

“保存并检查”会测试连接与认证,不发送消息。

${service.source === 'account' ? `` : ''}
`,``,'config-modal'); } function renderNotificationLog() { const labels = {pending:'等待发送',processing:'发送中',sent:'已发送',failed:'发送失败',cancelled:'已取消'}; return `

发送记录${data.notifications.length}

${data.notifications.length ? `
${data.notifications.map(log => ``).join('')}
提醒渠道安排时间状态尝试次数说明
${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' ? '已提交到通知服务' : '—'))}
` : `
${icon('bell')}还没有发送记录。续期提醒安排发送后,会出现在这里。
`}
`; } function emailCodeForm() { openOverlay('验证接收邮箱',`验证码已发送至 ${e(data.settings.emailAddress)}`,`
${field('email-code','6 位验证码',``)}
`,``,'small'); } async function bindTelegram() { const binding = await mutation('POST','/api/notifications/telegram/binding'); telegramBindingCode = binding.code; openOverlay('绑定 Telegram','打开 Bot,点击“开始”,然后回到这里检查绑定。',`
${icon('telegram')}打开 Telegram Bot ${icon('link')}

如果没有自动发送,手动向 Bot 发送:

绑定指令 10 分钟内有效,仅绑定你的私人聊天。

`,``,'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 = `
`; 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 ? '修改卡片档案;续期规则可在卡片详情中单独调整。' : '记下这条连接,再为它安排第一次续期。',`
${field('card-name','卡片名称*',``,true)} ${field('card-provider','运营商*',``)} ${field('card-type','卡片类型',``)} ${field('card-phone','手机号码',``)} ${field('card-country','国家或地区',``)} ${field('card-activation','开通日期*',``)} ${field('card-plan','套餐说明',``)}

卡片余额

${(c.balances?.length ? c.balances : [{currency:cardCurrency(c),amount:''}]).map(balanceRow).join('')}

同一张卡可记录多个币种。留空表示未记录,填 0 表示余额为零。

${editing ? '' : `

第一次续期之后可添加更多规则

${ruleFields({},today(),cardCurrency(c))}`}
${field('card-notes','备注',``)}
`,``); 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)} · 每条规则独立计算下一次日期`, `
${ruleFields(rule,card.activatedAt,cardCurrency(card))}
按完成日期顺延适合保号;固定周期适合套餐账单。修改当前到期日期会建立新的周期基准。
`,``); } 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 || '请确认续期操作成功后,再记录完成。')}
${field('completed-date','实际完成日期*',``,true)}${costFields('completed-cost',rule.cost,cardCurrency(card),'本次实际金额')}${field('completed-note','操作备注',``,true)}

${balanceAction === 'credit' ? '未勾选时只记录费用,余额保持当前记录。' : '默认扣除本次金额。取消勾选可只记录费用。'}

${balanceAction === 'credit' ? '入账后余额' : '扣除后余额'}只记录费用
完成后,下次续期日期—
`,``,'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.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.length ? card.platforms.map(platform => `
${e(platform.name[0])}
${e(platform.name)}

${e([platform.account,platform.purpose].filter(Boolean).join(' · ') || '未填写账号标识')}

`).join('') : '

记录这张卡注册的平台,下次找账号更方便。

'}

续期历史 ${history.length}

${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 ? `

备注

${e(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)} · 记住这个号码注册过什么` : '选择卡片,记录平台和账号归属。',`
${!card ? field('platform-card','关联卡片',``,true) : ''}${field('platform-name','平台名称*',`${Object.keys(colors).map(name => ``,true)}${field('platform-account','账号标识',``,true)}${field('platform-purpose','用途',``,true)}
`,``,'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('我的账号','卡片、规则与通知设置保存在你的个人空间。',``,``,'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();