`; } if (layout === 'twocol') { return `

${name}

${role}
${summary?`

Summary

${summary}

`:''} ${exps.length?`

Experience

${experienceHtml}`:''}
${skills.length?`

Skills

${skills.map(k=>`
${k}
`).join('')}
`:''}
`; } if (layout === 'header') { return `

${name}

${role}
${summary?`

Summary

${summary}

`:''} ${exps.length?`

Experience

${experienceHtml}`:''} ${skills.length?`

Skills

${skills.join(' · ')}

`:''}
`; } if (layout === 'dense') { return `

${name}

${role}
${exps.length?`

Experience

${exps.map(e=>`
${e.role||''} · ${e.company||''}
${e.duration||''}
${e.bullets&&e.bullets.length?'
    '+e.bullets.map(b=>`
  • ${b}
  • `).join('')+'
':''}
`).join('')}`:''} ${summary?`

Summary

${summary}

`:''}
${skills.length?`

Skills

${skills.map(k=>`
${k}
`).join('')}
`:''}
`; } return `

${name}

${role}
${summary?`

Summary

${summary}

`:''} ${exps.length?`

Experience

${experienceHtml}`:''} ${skills.length?`

Skills

${skills.join(' · ')}

`:''} `; } function doExport(fmt) { closeModal(); /* PDF 导出受订阅限制;TXT/DOCX 不限制 */ if (fmt === 'PDF' && !trialGuard('pdf_export')) return; const name = (_fv('sf-name') || 'resume').replace(/\s+/g,'_'); if (fmt === 'TXT') { exportFile(buildResumeText(), name + '.txt', 'text/plain'); } else if (fmt === 'DOCX') { /* Word 可直接打开 HTML,以 .doc 扩展名保存 */ exportFile(buildResumeHtml(), name + '.doc', 'application/msword'); } else if (fmt === 'PDF') { /* 使用浏览器原生打印 → 另存为 PDF,无需外部库,跨平台可靠 */ const w = window.open('', '_blank'); if (!w) { toast('Please allow popups to export PDF'); return; } w.document.write(buildResumeHtml()); w.document.close(); setTimeout(() => { w.focus(); w.print(); }, 400); /* PDF 导出 = 纯本地打印(FREEBIE 组),不扣日配额 */ toast('Print dialog opened — save as PDF'); } } function addAppModalHtml() { return modalHead(I('plus','icon sm'), 'Add Application') + `
Company
Role
`; } function checklistModalHtml() { const items = [['Passport',1],['I-20 / DS-2019',1],['Degree certificate',1],['Transcripts',1],['Resume (CV)',1],['Job offer letter',1],['Degree evaluation',0],['LCA',0]]; return modalHead(I('check','icon sm'), 'H-1B Documents') + items.map(([n,d]) => `
${d?I('check','icon xs'):''} ${n}${d?'':'missing'}
`).join('') + ``; } /* ==================== 稳定性基础设施 ==================== 1) store:localStorage 安全封装——隐私模式/配额满/JSON 损坏均降级默认值,绝不抛错 2) 全局错误兜底:window error / unhandledrejection → 控制台记录 + 限频 toast 提示,UI 不白屏不卡死 */ const store = { get(k, fb = null) { try { const v = localStorage.getItem(k); return v === null ? fb : v; } catch (e) { return fb; } }, set(k, v) { try { localStorage.setItem(k, v); return true; } catch (e) { return false; } }, getJson(k, fb = null) { try { const v = localStorage.getItem(k); return v ? JSON.parse(v) : fb; } catch (e) { return fb; } }, setJson(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); return true; } catch (e) { return false; } } }; let _errToastAt = 0; function reportGlitch(info) { try { console.error('[pip-glitch]', info); } catch (e) {} const now = Date.now(); if (now - _errToastAt > 5000) { _errToastAt = now; try { toast('A glitch was caught — Pip recovered'); } catch (e) {} } } window.addEventListener('error', e => reportGlitch(e.message || e.type)); window.addEventListener('unhandledrejection', e => { reportGlitch(e.reason); e.preventDefault(); }); /* ==================== UI/UX 优化 JavaScript 函数 ==================== */ /* 表单验证函数 */ function validateField(input) { const value = input.value.trim(); const isRequired = input.hasAttribute('data-required') || input.closest('.form-field')?.querySelector('.req'); // 清除之前的状态 input.classList.remove('error', 'success'); const errorMsg = input.parentElement.querySelector('.form-error'); const successMsg = input.parentElement.querySelector('.form-success'); if (errorMsg) errorMsg.remove(); if (successMsg) successMsg.remove(); if (isRequired && !value) { input.classList.add('error'); const error = document.createElement('div'); error.className = 'form-error pop-in'; error.textContent = 'This field is required'; input.parentElement.appendChild(error); return false; } // 邮箱验证 if (input.type === 'email' && value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { input.classList.add('error'); const error = document.createElement('div'); error.className = 'form-error pop-in'; error.textContent = 'Please enter a valid email address'; input.parentElement.appendChild(error); return false; } // 成功状态 if (value) { input.classList.add('success'); const success = document.createElement('div'); success.className = 'form-success pop-in'; success.textContent = 'Looks good!'; input.parentElement.appendChild(success); } return true; } /* 密码强度检查增强 */ function checkPasswordStrength(password) { let score = 0; if (password.length >= 8) score++; if (/[A-Z]/.test(password) && /[a-z]/.test(password)) score++; if (/\d/.test(password)) score++; if (/[^A-Za-z0-9]/.test(password)) score++; return { score: score, level: score <= 1 ? 'weak' : score <= 2 ? 'medium' : 'strong', label: score <= 1 ? 'Weak' : score <= 2 ? 'Medium' : 'Strong' }; } /* 空状态渲染 */ function renderEmptyState(icon, title, desc, actionText, actionHandler) { return `
${icon}
${title}
${desc}
${actionText ? `` : ''}
`; } /* 错误状态渲染 */ function renderErrorState(title, desc, retryText, retryHandler) { return `
${I('alert-circle')}
${title}
${desc}
${retryText ? `` : ''}
`; } /* 成功状态渲染 */ function renderSuccessState(title, desc) { return `
${I('check-circle')}
${title}
${desc}
`; } /* 加载状态渲染 */ function renderLoadingState(text = 'Loading...') { return `
${text}
`; } /* 骨架屏渲染 */ function renderSkeletonCard(lines = 4) { return `
${Array(lines).fill('
').join('')}
`; } /* 触控反馈增强 */ function addTouchFeedback(element) { if (!element) return; element.addEventListener('touchstart', () => { element.style.transform = 'scale(0.98)'; }, { passive: true }); element.addEventListener('touchend', () => { element.style.transform = ''; }, { passive: true }); } /* 批量添加触控反馈 */ function enhanceTouchFeedback() { document.querySelectorAll('.sticker, .btn, .chip, .tool-tile, .app-card').forEach(addTouchFeedback); } /* 页面加载时增强触控反馈 */ document.addEventListener('DOMContentLoaded', enhanceTouchFeedback); /* ==================== 主题切换(5套 · 全屏实时生效 · 偏好持久化) ==================== */ let currentTheme = store.get('pip-theme', 'sticker'); const THEME_NAMES = { sticker:'Sticker Quest', sunny:'Sunny Side', carbon:'Carbon Console', noir:'Noir Ledger', semester:'Semester One', pixel:'Pixel Guild' }; function setTheme(cell, name) { currentTheme = name; store.set('pip-theme', name); document.querySelectorAll('.theme-cell').forEach(d => d.classList.toggle('active', d === cell)); applyTheme(); toast('Theme: ' + (THEME_NAMES[name] || name)); } function applyTheme() { document.querySelectorAll('.phone-screen').forEach(p => { if (currentTheme === 'sticker') p.removeAttribute('data-theme'); else p.setAttribute('data-theme', currentTheme); }); const cell = document.querySelector(`.theme-cell[data-theme-name="${currentTheme}"]`); if (cell) { document.querySelectorAll('.theme-cell').forEach(d => d.classList.remove('active')); cell.classList.add('active'); } } function toggleSync(row) { const val = row.querySelector('.val'); const on = val.textContent === 'On'; val.textContent = on ? 'Off' : 'On'; toast('Cloud Sync ' + (on ? 'disabled' : 'enabled')); } /* ==================== Notification Settings ==================== */ const NOTIFICATION_DEFS = [ { id:'deadline', icon:'calendar', title:'Application deadlines', desc:'24h & 1h before a due date in your pipeline' }, { id:'interview', icon:'spark', title:'Interview prep reminders',desc:'Before scheduled interviews — questions & tips' }, { id:'quest', icon:'bell', title:'Daily quest nudges', desc:'Early morning reminder to hit your XP goals' }, { id:'salary', icon:'offers', title:'Salary & market alerts', desc:'New data for roles or locations you follow' }, { id:'newsletter', icon:'mail', title:'Product & tips digest', desc:'Monthly product news + resume writing tips' }, ]; function getNotificationPrefs() { let stored = null; try { stored = JSON.parse(localStorage.getItem('pip_notification_prefs') || 'null'); } catch(e) { stored = null; } if (stored && typeof stored === 'object') return stored; // defaults: deadline & quest on (== 2 matches the old "2 on" placeholder so layout feels familiar) const def = {}; NOTIFICATION_DEFS.forEach(d => { def[d.id] = (d.id === 'deadline' || d.id === 'quest'); }); localStorage.setItem('pip_notification_prefs', JSON.stringify(def)); return def; } function setNotificationPref(id, on) { // 直接读写 localStorage,避免依赖 getNotificationPrefs() 内部存储缓存 let p; try { p = JSON.parse(localStorage.getItem('pip_notification_prefs') || 'null') || {}; } catch(e) { p = {}; } p[id] = !!on; localStorage.setItem('pip_notification_prefs', JSON.stringify(p)); refreshNotificationsVal(); } function countEnabledNotifications() { let p = {}; try { p = JSON.parse(localStorage.getItem('pip_notification_prefs') || 'null') || {}; } catch(e) { p = {}; } return NOTIFICATION_DEFS.reduce((n, d) => n + (p[d.id] ? 1 : 0), 0); } function refreshNotificationsVal() { // Profile 屏 (id=profile 的 settings-row) const profileRow = document.querySelector('#profile .settings-row'); // 更 robust:只要是当前激活 screen 或全局所有 settings-row,凡 grow 的文字是 Notifications 都刷新 document.querySelectorAll('.settings-row').forEach(row => { const label = row.querySelector('.grow'); if (!label) return; if (label.textContent.trim() === 'Notifications') { const valEl = row.querySelector('.val'); const n = countEnabledNotifications(); if (valEl) valEl.textContent = (n === 0 ? 'Off' : n === 1 ? '1 on' : n + ' on'); } }); // 备用:直接按 id 刷 const v = document.getElementById('notif-count-val'); if (v) { const n = countEnabledNotifications(); v.textContent = (n === 0 ? 'Off' : n === 1 ? '1 on' : n + ' on'); } } function notificationsModalHtml() { const p = getNotificationPrefs(); const rows = NOTIFICATION_DEFS.map(d => `
${I(d.icon,'icon sm')}
${d.title}
${d.desc}
`).join(''); return modalHead(I('bell','icon sm'), 'Notification Settings') + `

Turn on only what matters — Pip stays quiet about the rest.

${rows}
`; } function toggleNotifSwitch(swEl) { const id = swEl.getAttribute('data-notif-id'); const isOn = swEl.classList.toggle('on'); setNotificationPref(id, isOn); // 实时刷新 modal 内的 Notifications 数量显示(若当前 modal 内有) refreshNotificationsVal(); toast(isOn ? 'Notifications on · ' + NOTIFICATION_DEFS.find(d=>d.id===id).title : 'Notifications off'); } function allNotifs(btn) { const turnOn = btn.textContent.trim().toLowerCase().includes('enable all'); // 一次性整批写入 localStorage const cur = {}; NOTIFICATION_DEFS.forEach(d => { cur[d.id] = turnOn; }); localStorage.setItem('pip_notification_prefs', JSON.stringify(cur)); document.querySelectorAll('.switch[data-notif-id]').forEach(sw => { sw.classList.toggle('on', turnOn); }); refreshNotificationsVal(); btn.textContent = turnOn ? 'Disable all' : 'Enable all'; toast(turnOn ? 'All notifications enabled' : 'All notifications disabled'); } function openNotifications() { openModal(notificationsModalHtml()); } window.addEventListener('load', () => setTimeout(refreshNotificationsVal, 20)); /* ==================== Quest Sheet · 全局悬浮面板(图4 新逻辑) ==================== 不再是独立页面:openQuest() 把面板注入当前屏 phone-screen 的可视区域, closeQuest() 仅收回面板(下拉 >90px / 点遮罩),全程无页面跳转,彻底修复"下拉跳首页"。 面板定位:overlay 用 top=scrollTop / height=clientHeight 精确覆盖 phone-screen 可视区, 打开期间锁定底层滚动;导航(go)时自动移除残留面板。 */ const QUEST_SHEET_HTML = `
${pipFace(34)}Pick your quest${aiBadge()}
Craft
${I('edit')}Editor
${I('template')}Templates
${I('star')}STAR
Boost ${aiBadge('AI')}
${I('spark')}AI Resume
${I('ats')}ATS Check
${I('mail')}Cover Letter
`; let questHost = null; // 记录面板宿主屏,关闭时恢复其滚动 function openQuest() { if (document.getElementById('quest-overlay')) return; // 已打开则忽略 const host = document.querySelector('.screen.active .phone-screen'); if (!host) return; const wrap = document.createElement('div'); wrap.className = 'quest-overlay'; wrap.id = 'quest-overlay'; wrap.style.top = host.scrollTop + 'px'; wrap.style.height = host.clientHeight + 'px'; wrap.innerHTML = QUEST_SHEET_HTML; host.appendChild(wrap); questHost = host; host.style.overflowY = 'hidden'; // 面板打开期间锁定底层滚动 bindQuestDrag(); } function closeQuest() { const sheet = document.getElementById('quest-sheet'); if (!sheet || sheet.classList.contains('closing')) return; sheet.classList.add('closing'); setTimeout(closeQuestInstant, 350); // 350ms 离场动画结束后移除面板,停留在原页 } function closeQuestInstant() { const ov = document.getElementById('quest-overlay'); if (ov) ov.remove(); if (questHost) { questHost.style.overflowY = ''; questHost = null; } } function bindQuestDrag() { const pill = document.getElementById('quest-drag'); const sheet = document.getElementById('quest-sheet'); if (!pill || !sheet) return; // 每次 openQuest 注入全新节点,必须重新绑定 let startY = 0, dy = 0; pill.addEventListener('pointerdown', e => { startY = e.clientY; dy = 0; sheet.classList.add('dragging'); sheet.style.transition = 'none'; pill.setPointerCapture(e.pointerId); }); pill.addEventListener('pointermove', e => { if (!startY) return; dy = Math.max(0, e.clientY - startY); // 仅允许向下 sheet.style.transform = `translateY(${dy}px)`; }); const end = () => { if (!startY) return; sheet.classList.remove('dragging'); sheet.style.transition = 'transform .3s cubic-bezier(.34,1.4,.64,1)'; if (dy > 90) { sheet.style.transform = ''; sheet.style.transition = ''; startY = 0; closeQuest(); } else { sheet.style.transform = 'translateY(0)'; setTimeout(() => { sheet.style.transform = ''; sheet.style.transition = ''; }, 300); startY = 0; } }; pill.addEventListener('pointerup', end); pill.addEventListener('pointercancel', end); } function filterQuest(q) { q = q.trim().toLowerCase(); const sheet = document.getElementById('quest-sheet'); if (!sheet) return; sheet.querySelectorAll('.quest-cat').forEach(cat => { const grid = cat.nextElementSibling; let visible = 0; grid.querySelectorAll('.quest-tool').forEach(t => { const hit = !q || t.textContent.toLowerCase().includes(q); t.style.display = hit ? '' : 'none'; if (hit) visible++; }); cat.style.display = visible ? '' : 'none'; grid.style.display = visible ? '' : 'none'; }); } /* ==================== Onboarding 三页分页器 · 里程碑航迹设计 ==================== 虚线墨轨迹 + Pip 沿轨迹飞行 + 三站变身卡:CRAFT 空白简历 → BOOST AI优化 → HUNT Offer */ const ONBOARD_PAGES = [ { stage:'craft', accent:'var(--honey)', pipPos:'left:8%;top:64%;transform:rotate(-6deg);', title:'CRAFT your story', sub:'Start from a blank sheet — Pip guides you section by section with questions, not forms.', btn:'Get Started' }, { stage:'boost', accent:'var(--violet)', pipPos:'left:44%;top:34%;transform:rotate(4deg);', title:'BOOST with AI', sub:'One tap rewrites weak bullets into metric-driven, ATS-ready lines in your own voice.', btn:'Next' }, { stage:'hunt', accent:'var(--leaf)', pipPos:'left:76%;top:8%;transform:rotate(-10deg);', title:'HUNT the offer', sub:'Track every application on the board, nudge recruiters on time, land the flag.', btn:'Start Free' }, ]; let onboardIdx = 0; /* 三站变身卡:同一简历卡逐站升级 */ function onboardCard(stage) { const base = 'width:118px;height:148px;background:var(--paper-light);border:var(--bw) solid var(--ink);border-radius:var(--r-sm);box-shadow:var(--shadow-md);position:relative;padding:14px 12px;'; const lines = '
'.repeat(3); if (stage === 'craft') return `
${lines}
`; if (stage === 'boost') return `
${lines.replaceAll('var(--paper-dark)','var(--violet-light)')}
${I('spark','icon sm')}
`; return `
${lines.replaceAll('var(--paper-dark)','var(--leaf-light)')}
${I('flag','icon lg')}
${I('check','icon sm')}
`; } /* 虚线墨轨迹(S 形三站路径) */ const TRAIL_SVG = ``; function renderOnboard() { const wrap = document.getElementById('onboard-wrap'); if (!wrap) return; const p = ONBOARD_PAGES[onboardIdx]; wrap.innerHTML = `
Skip
${TRAIL_SVG}
${p.stage.toUpperCase()}
${pipFace(56)}
${onboardCard(p.stage)}
${p.title}
${p.sub}
${ONBOARD_PAGES.map((_,i)=>`
`).join('')}
Already have an account? Sign in
`; } function onboardNext() { if (onboardIdx < ONBOARD_PAGES.length - 1) { onboardIdx++; renderOnboard(); } else { onboardIdx = 0; toast('Welcome aboard, Alex!'); goBack(); } } /* ==================== STAR 四步向导 ==================== */ const STAR_STEPS = [ { l:'S', t:'Situation', q:'Set the scene: team, product, constraints?', hint:'One sentence of context is enough. Recruiters skim — "2M-user fintech app, 4-person Android team" works.', ph:'Fintech app with 2M users; Android team of 4, legacy Java codebase...' }, { l:'T', t:'Task', q:'What was your specific responsibility?', hint:'Name the exact goal you owned. "Own the cold-start budget under 1.2s" beats "improve performance".', ph:'Owned the app-start performance budget after the Kotlin migration, targeting sub-1.2s cold start on mid-tier devices.' }, { l:'A', t:'Action', q:'What did YOU personally do?', hint:'Lead with verbs: shipped, built, cut, designed. Keep the team out of it — this bullet is about you.', ph:'Shipped baseline profiles, deferred init via App Startup, and rebuilt the home feed in Compose...' }, { l:'R', t:'Result', q:'What changed? Give the number.', hint:'Metrics close the loop: -38% cold start, +0.4 Play rating, 99.7% crash-free.', ph:'Cold start dropped 38% (1.9s → 1.18s), crash-free rate hit 99.7%, Play rating +0.4.' }, ]; let starIdx = 1; function renderStar() { const wrap = document.getElementById('star-wrap'); if (!wrap) return; const s = STAR_STEPS[starIdx]; wrap.innerHTML = `
${STAR_STEPS.map((x,i)=>`
${x.l}
`).join('')}
${s.l}
${s.t}
${s.q}
${I('spark','icon sm')}Tip: ${s.hint}
`; } function starStep(d) { const next = starIdx + d; if (next >= STAR_STEPS.length) { /* STAR Guide Save Story = 纯本地写入(FREEBIE 组),不扣日配额 */ if (!trialGuard('star_guide')) return; toast('STAR story saved to Experience'); goBack(); return; } if (next < 0) return; starIdx = next; renderStar(); } /* ==================== DeepSeek V4 Flash · 腾讯云函数接入层 ==================== 请求契约与 index.py main_handler 完全对齐: - Body: {feature, system_prompt, user_prompt, temperature} - Headers: X-User-Id / X-User-Tier / X-Signature / X-Timestamp / X-Nonce - 签名串: `POST${path}${ts}${nonce}${stableStringify(body)}`,HMAC-SHA256 hex 稳定性设计(用户要求"流畅稳定"): 1) 25s AbortController 超时,网络/5xx 自动重试 1 次(指数退避) 2) 未配置 endpoint(原型态)或请求失败 → 优雅降级为本地演示数据,UI 永不卡死 3) 429 配额超限单独提示,不重试 */ const AI_CONFIG = { endpoint: 'https://1253518158-jibs0w4anm.ap-shanghai.tencentscf.com/optimize-stream', signSecret: '657813627e7359e3715417f4a4c3500268c7380bc1ed22b575b76d57896cfa4a', userId: 'pip-pwa-user', tier: 'free', timeoutMs: 25000, }; /* ==================== 订阅限额系统:与 App 端设计对齐 ==================== 设计参考:UsageLimitRepository.kt + BillingRepository.kt 方案体系: - Free:全部 Pro 功能禁用(基础手动编辑/保存/模板预览始终开放) - 7-Day Trial:isPro=false,仅提供"每日 1 次"Pro 功能体验总配额(用完当日所有 Pro 功能锁死,次日 0 点自动解锁,持续 7 天) - Weekly Pro / Monthly Pro / Yearly Pro:isPro=true,全部 12 项 Pro 功能无限使用 存储契约: - pip_sub.plan: free | trial | weekly_pro | monthly_pro | yearly_pro - pip_sub.start_time: 试用/订阅开始的毫秒时间戳 - pip_sub.end_time: 到期毫秒时间戳 - pip_trial_day_key: 试用日配额日期键 YYYY-MM-DD(本地时区,跨天自动重置) - pip_trial_day_used: 当日已使用次数(12 项 Pro 功能共享) - pip_device_fp: 设备指纹(防重复试用) - pip_trial_used_on_device: 该设备是否已用过试用 */ const TRIAL_DURATION_MS = 7 * 24 * 60 * 60 * 1000; /* 试用每日总配额:所有 Pro 功能共享,每日仅 1 次;用完当日锁死全部 Pro 功能,次日 0 点自动重置 */ const TRIAL_DAILY_QUOTA = 1; /* 本地时区日期字符串 YYYY-MM-DD,用于跨天自动重置日配额 */ function _localDayKey(ts) { const d = new Date(ts || Date.now()); const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); return y + '-' + m + '-' + day; } const FEATURE_LABELS = { ai_optimize: 'AI Resume Optimize', pdf_export: 'PDF Export', cover_letter: 'Cover Letter', ats_check: 'ATS Check', chat_edit: 'AI Chat Edit', star_guide: 'STAR Guide', jd_analysis: 'JD Analysis', interview_gen: 'Interview Generator', version_history: 'Version History', cloud_sync: 'Cloud Sync', h1b_advisor: 'H-1B Advisor', github_import: 'GitHub Import' }; const PLANS = { free: { name: 'Free', price: 0, isPro: false }, trial: { name: '7-Day Trial', price: 0, isPro: false, durationMs: TRIAL_DURATION_MS }, weekly_pro: { name: 'Weekly Pro', price: 4.99, isPro: true, durationMs: 7 * 24 * 60 * 60 * 1000 }, monthly_pro: { name: 'Monthly Pro', price: 12.99, isPro: true, durationMs: 30 * 24 * 60 * 60 * 1000 }, yearly_pro: { name: 'Yearly Pro', price: 49.99, isPro: true, durationMs: 365 * 24 * 60 * 60 * 1000 } }; /* Pro 功能白名单(Weekly/Monthly/Yearly Pro 全部拥有) - 前 10 项与 App 端完全对齐 - 后 2 项为 Web 端独有功能(h1b_advisor / github_import) */ const PRO_FEATURES = [ 'ai_optimize', 'pdf_export', 'cover_letter', 'ats_check', 'chat_edit', 'star_guide', 'jd_analysis', 'interview_gen', 'version_history', 'cloud_sync', 'h1b_advisor', 'github_import' ]; /* 试用策略:分两组 - TRIAL_QUOTA_FEATURES:真正消耗 AI/服务器算力/外部 API 的(扣日配额 = 每天仅 1 次) - TRIAL_FREEBIE_FEATURES:纯本地 DOM / localStorage 操作,不花服务器成本 · Free 用户仍然不能用(白名单校验) · Trial 用户:不占日配额,可以反复用,让用户把简历打磨好再用"每天 1 次"的 AI 额度生成成品 */ const TRIAL_QUOTA_FEATURES = [ 'ai_optimize', 'chat_edit', 'cover_letter', 'h1b_advisor', 'jd_analysis', 'interview_gen', 'github_import', 'cloud_sync' ]; const TRIAL_FREEBIE_FEATURES = PRO_FEATURES.filter(f => !TRIAL_QUOTA_FEATURES.includes(f)); /* 向后兼容:TRIAL_FEATURES = 所有 Pro 功能(canUseFeature 内部会分两组判定) */ const TRIAL_FEATURES = PRO_FEATURES.slice(); /* ============== 存储层:时间戳 + 计数 ============== */ function _now() { return Date.now(); } /* 生成简易设备指纹(浏览器环境下用 userAgent + 屏幕信息 + 语言 + 时区) */ function _getDeviceFingerprint() { try { const fp = [ navigator.userAgent, screen.width + 'x' + screen.height, navigator.language, Intl.DateTimeFormat().resolvedOptions().timeZone, navigator.platform ].join('|'); /* 简单 hash(不需要加密级,去重即可) */ let h = 0; for (let i = 0; i < fp.length; i++) { h = ((h << 5) - h + fp.charCodeAt(i)) | 0; } return 'dev_' + Math.abs(h).toString(36); } catch (e) { return 'dev_unknown'; } } /* ============== 订阅状态查询 ============== */ /* 获取当前订阅状态(自动处理过期降级) 返回:{ plan, isPro, startTime, endTime, daysRemaining, expiredFrom } */ function getSubscriptionState() { const sub = store.getJson('pip_sub', null); const now = _now(); /* 无订阅数据 → 检查是否有试用标记(兼容旧字段) */ if (!sub || !sub.plan || sub.plan === 'free') { if (store.get('pip_trial_active') === '1') { const startTime = parseInt(store.get('pip_trial_start_time', '0'), 10) || (now - TRIAL_DURATION_MS + 86400000); if (now - startTime < TRIAL_DURATION_MS) { return { plan: 'trial', isPro: false, startTime: startTime, endTime: startTime + TRIAL_DURATION_MS, daysRemaining: Math.ceil((startTime + TRIAL_DURATION_MS - now) / 86400000) }; } else { store.set('pip_trial_active', '0'); } } return { plan: 'free', isPro: false, startTime: 0, endTime: 0, daysRemaining: 0 }; } /* 有订阅数据 → 检查是否过期 */ const endTime = sub.end_time || 0; if (endTime && now > endTime) { store.setJson('pip_sub', { plan: 'free', start_time: 0, end_time: 0, expired_from: sub.plan }); store.set('pip_trial_active', '0'); return { plan: 'free', isPro: false, startTime: 0, endTime: 0, daysRemaining: 0, expiredFrom: sub.plan }; } const p = PLANS[sub.plan] || PLANS.free; return { plan: sub.plan, isPro: !!p.isPro, startTime: sub.start_time || 0, endTime: endTime, daysRemaining: endTime ? Math.max(0, Math.ceil((endTime - now) / 86400000)) : 0 }; } function getUserPlan() { return getSubscriptionState().plan; } function isProUnlocked() { return getSubscriptionState().isPro; } function hasActiveTrial() { return getSubscriptionState().plan === 'trial'; } /* ============== 功能权限检查 ============== */ /* 核心判定逻辑(新试用策略 —— 分两组 + 每日配额仅 QUOTA 组): - Free:全部 Pro 功能禁用(基础手动编辑/保存/模板预览始终开放,不经过 canUseFeature) - Trial(isPro=false): · 必须先通过 PRO_FEATURES 白名单 · TRIAL_FREEBIE_FEATURES(纯本地 DOM/localStorage,无服务器成本):不占日配额,直接放行,让用户反复打磨 · TRIAL_QUOTA_FEATURES(AI 生成/云函数/OAuth 抓取,有算力/带宽成本):按本地时区 YYYY-MM-DD 维护"日配额", 当日 1 次,用完当日所有 QUOTA 组功能禁用,次日 0 点自动重置 · 7 天到期后自动降级 Free - Weekly/Monthly/Yearly Pro(isPro=true):PRO_FEATURES 全部无限使用 */ /* 读取今日试用 QUOTA 组已使用次数(跨天自动重置为 0)—— 仅 QUOTA 组扣这个池子 */ function _getTrialDayUsed() { const today = _localDayKey(); const storedDay = store.get('pip_trial_day_key', ''); if (storedDay !== today) { store.set('pip_trial_day_key', today); store.setJson('pip_trial_day_used', 0); return 0; } return store.getJson('pip_trial_day_used', 0) | 0; } function canUseFeature(feature) { const state = getSubscriptionState(); /* 付费 Pro(isPro=true):Pro 功能白名单直接放行,全部无限 */ if (state.isPro) return PRO_FEATURES.includes(feature); /* Free:全部 Pro 功能禁用 */ if (state.plan === 'free') return false; /* Trial(isPro=false): 1) Pro 功能白名单校验 2) FREEBIE 组(纯本地):直接放行,不占日配额 —— 让用户免费反复打磨,看到价值再用"每天 1 次"AI 额度生成成品 3) QUOTA 组(耗算力/服务端):按日配额判断(跨天自动重置) */ if (!PRO_FEATURES.includes(feature)) return false; if (TRIAL_FREEBIE_FEATURES.includes(feature)) return true; return _getTrialDayUsed() < TRIAL_DAILY_QUOTA; } /* 剩余使用次数: - 付费 Pro:PRO_FEATURES 返回超大整数(代表无限;避免 JSON.stringify(Infinity)=null 导致 UI 序列化展示异常),非 Pro 返回 0 - Trial: · FREEBIE 组返回超大整数(代表无限;不占配额可反复用) · QUOTA 组返回"当日剩余总配额"(8 项 QUOTA 共享);7 天到期降级后返回 0 - Free:返回 0 · 超大整数约定:999999(语义 = "无限",任何 "<= 0" 比较时都视为 false) */ const INFINITE_USES = 999999; function getRemainingUses(feature) { const state = getSubscriptionState(); if (state.isPro) return PRO_FEATURES.includes(feature) ? INFINITE_USES : 0; if (state.plan !== 'trial') return 0; if (!PRO_FEATURES.includes(feature)) return 0; if (TRIAL_FREEBIE_FEATURES.includes(feature)) return INFINITE_USES; return Math.max(0, TRIAL_DAILY_QUOTA - _getTrialDayUsed()); } /* 记录使用次数(仅 Trial 需要计数;付费 Pro 不计数;Free 到不了这里) - 重要:只有 TRIAL_QUOTA_FEATURES 才真正 +1 扣日配额 - TRIAL_FREEBIE_FEATURES:空操作(不扣配额,可反复用) - 调用方必须先通过 canUseFeature 校验,这里不再二次检查,保证"一次成功调用只记 1 次" */ function recordFeatureUse(feature) { const state = getSubscriptionState(); if (state.plan !== 'trial') return; /* FREEBIE 组永不扣日配额(纯本地反复打磨不花钱,不在试用稀缺池里) */ if (!TRIAL_QUOTA_FEATURES.includes(feature)) return; const today = _localDayKey(); const storedDay = store.get('pip_trial_day_key', ''); let used; if (storedDay !== today) { store.set('pip_trial_day_key', today); used = 0; } else { used = store.getJson('pip_trial_day_used', 0) | 0; } store.setJson('pip_trial_day_used', used + 1); } /* ============== 设备级试用防重复 ============== */ function hasUsedTrialOnDevice() { const fp = _getDeviceFingerprint(); const storedFp = store.get('pip_device_fp', ''); if (storedFp && storedFp !== fp) { /* 指纹变化(如屏幕/UA/时区变动): 覆盖指纹 + 重置 trial_used_on_device,允许新环境再次试用 */ store.set('pip_device_fp', fp); store.set('pip_trial_used_on_device', '0'); return false; } if (!storedFp) store.set('pip_device_fp', fp); return store.get('pip_trial_used_on_device', '0') === '1'; } function markTrialUsedOnDevice() { const fp = _getDeviceFingerprint(); store.set('pip_device_fp', fp); store.set('pip_trial_used_on_device', '1'); } /* ============== 激活订阅 / 开始试用 ============== */ /* 激活指定方案(时间戳模式,与 App 端对齐) */ function activatePlan(planId) { const p = PLANS[planId]; if (!p) return false; const now = _now(); const endTime = p.durationMs ? now + p.durationMs : 0; /* 如果是 trial,同时设置设备防重复标记 */ if (planId === 'trial') { /* 试用开始:重置日配额(清空当日记录,保证首日有 TRIAL_DAILY_QUOTA 额度) */ store.set('pip_trial_day_key', _localDayKey(now)); store.setJson('pip_trial_day_used', 0); markTrialUsedOnDevice(); /* 兼容旧字段 */ store.set('pip_trial_active', '1'); store.set('pip_trial_start_time', String(now)); } store.setJson('pip_sub', { plan: planId, start_time: now, end_time: endTime }); return true; } /* 开始试用(带设备级防重复检查) */ function startTrial() { if (hasUsedTrialOnDevice()) { toast('You have already used your free trial on this device.'); go('paywall'); return false; } return activatePlan('trial'); } /* ============== 统一提示 & guard API ============== */ /* 限额达到 / 功能不可用时的统一提示 */ function showLimitReached(feature) { const state = getSubscriptionState(); const label = FEATURE_LABELS[feature] || feature.replace(/_/g, ' '); if (state.plan === 'free') { toast('🔒 ' + label + ' requires Pro. Start a 7-day trial · polish locally for free · 1 AI action/day.'); go('paywall'); return; } if (state.plan === 'trial') { const rem = getRemainingUses(feature); if (rem <= 0 && TRIAL_QUOTA_FEATURES.includes(feature)) { toast('⏸️ Today\'s AI action used up. Come back tomorrow · export/ATS/STAR still unlimited · or upgrade for infinite AI.'); } else if (rem <= 0) { toast('🔒 ' + label + ' is a Pro feature. Upgrade to unlock.'); } else { toast('🔒 ' + label + ' is a Pro feature. Upgrade now.'); } go('paywall'); return; } toast('🔒 ' + label + ' is not available on your plan.'); go('paywall'); } /* 兼容旧 API:trialGuard → canUseFeature;trialConsume → recordFeatureUse */ function trialGuard(feature) { if (canUseFeature(feature)) return true; showLimitReached(feature); return false; } function trialConsume(feature) { recordFeatureUse(feature); } function isTrialActive() { return getSubscriptionState().plan === 'trial'; } /* 订阅过期主动检测:在应用启动时调用 */ function checkSubscriptionExpiry() { const state = getSubscriptionState(); if (state.plan !== 'free' || !state.expiredFrom) return; const notified = store.get('pip_expiry_notified', ''); if (notified === state.expiredFrom) return; const planName = PLANS[state.expiredFrom] ? PLANS[state.expiredFrom].name : state.expiredFrom; toast('Your ' + planName + ' subscription has expired. Renew to keep full access.'); store.set('pip_expiry_notified', state.expiredFrom); } /* 获取订阅状态的摘要信息(用于 Profile/Paywall 页面展示) */ function getSubscriptionInfo() { const state = getSubscriptionState(); const p = PLANS[state.plan] || PLANS.free; return { plan: state.plan, planName: p.name, price: p.price, isActive: state.plan !== 'free', isPro: state.isPro, startDate: state.startTime ? new Date(state.startTime).toISOString().slice(0, 10) : null, endDate: state.endTime ? new Date(state.endTime).toISOString().slice(0, 10) : null, expiredFrom: state.expiredFrom || null, daysRemaining: state.daysRemaining }; } function stableStringify(v) { if (v === null || typeof v !== 'object') return JSON.stringify(v); if (Array.isArray(v)) return '[' + v.map(stableStringify).join(',') + ']'; return '{' + Object.keys(v).sort().map(k => JSON.stringify(k) + ':' + stableStringify(v[k])).join(',') + '}'; } async function hmacSha256Hex(secret, msg) { const enc = new TextEncoder(); const key = await crypto.subtle.importKey('raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); const sig = await crypto.subtle.sign('HMAC', key, enc.encode(msg)); return [...new Uint8Array(sig)].map(b => b.toString(16).padStart(2, '0')).join(''); } async function callDeepSeek(feature, systemPrompt, userPrompt, temperature = 0.7) { if (!AI_CONFIG.endpoint) return { ok: false, demo: true }; const body = { feature, system_prompt: systemPrompt, user_prompt: userPrompt, temperature }; const path = (() => { try { return new URL(AI_CONFIG.endpoint).pathname || '/'; } catch { return '/'; } })(); for (let attempt = 0; attempt < 2; attempt++) { try { const headers = { 'Content-Type': 'application/json', 'X-User-Id': AI_CONFIG.userId, 'X-User-Tier': getUserPlan(), }; if (AI_CONFIG.signSecret) { const ts = Math.floor(Date.now() / 1000).toString(); const nonce = (crypto.randomUUID ? crypto.randomUUID() : String(Date.now()) + Math.random().toString(36).slice(2)); headers['X-Timestamp'] = ts; headers['X-Nonce'] = nonce; headers['X-Signature'] = await hmacSha256Hex(AI_CONFIG.signSecret, `POST${path}${ts}${nonce}${stableStringify(body)}`); } const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), AI_CONFIG.timeoutMs); const resp = await fetch(AI_CONFIG.endpoint, { method: 'POST', headers, body: JSON.stringify(body), signal: ctrl.signal }); clearTimeout(timer); if (resp.status === 429) return { ok: false, error: 'limit', msg: 'Daily AI limit reached — try again tomorrow' }; if (!resp.ok) throw new Error('HTTP ' + resp.status); const ct = resp.headers.get('Content-Type') || ''; if (ct.includes('text/event-stream')) return parseSSE(await resp.text(), feature); const data = await resp.json(); if (data && typeof data.reply === 'string' && data.reply.trim()) return { ok: true, reply: data.reply.trim(), cached: !!data.cached }; throw new Error('empty reply'); } catch (e) { if (attempt === 1) return { ok: false, error: String(e && e.name === 'AbortError' ? 'timeout' : e) }; await new Promise(r => setTimeout(r, 800 * (attempt + 1))); } } return { ok: false, error: 'unreachable' }; } function parseSSE(text, feature) { const events = []; const lines = text.split('\n'); let eventType = ''; for (const line of lines) { if (line.startsWith('event:')) { eventType = line.slice(6).trim(); } else if (line.startsWith('data:')) { const raw = line.slice(5).trim(); if (raw === '[DONE]') break; // SSE 协议:data 字段是 JSON 字符串(带外层引号),先 unescape 再 parse // 兼容两种格式:带引号(标准 SSE)或裸 JSON let inner = raw; if (inner.startsWith('"') && inner.endsWith('"')) { try { inner = JSON.parse(inner); } catch (_) { /* fall through */ } } try { const payload = JSON.parse(inner); events.push({ type: eventType || 'message', data: payload }); } catch (_) { events.push({ type: eventType || 'message', data: inner }); } } } const complete = events.find(e => e && e.type === 'complete'); if (complete && complete.data) { const d = complete.data; const replyParts = []; if (d.targetTitle) replyParts.push(d.targetTitle); if (d.summary) replyParts.push(d.summary); if (Array.isArray(d.experiences)) { for (const exp of d.experiences) { if (exp.role && exp.company) replyParts.push(exp.role + ' at ' + exp.company); if (Array.isArray(exp.bullets)) replyParts.push(exp.bullets.join('. ')); } } if (Array.isArray(d.skills) && d.skills.length) replyParts.push(d.skills.join(', ')); return { ok: true, reply: replyParts.join('\n\n'), cached: false, structured: d }; } const tokens = events.filter(e => e && e.type === 'token').map(e => e.data).join(''); if (tokens) return { ok: true, reply: tokens, cached: false }; throw new Error('SSE parse failed: no complete event'); } /* ==================== Studio:技能标签 / 生成 / 聊天 ==================== */ /* 图3:+ Add 不再自动生成技能,弹出用户自填窗口 */ function addSkill(addBtn) { window._skillAddBtn = addBtn; openModal( modalHead(I('plus','icon sm'), 'Add Skill') + `
Type any skill yourself — nothing is auto-filled.
Skill name *
` ); setTimeout(() => { const i = document.getElementById('skill-input'); if (i) i.focus(); }, 60); } function confirmAddSkill() { const inp = document.getElementById('skill-input'); const v = inp ? inp.value.trim() : ''; if (!v) { toast('Enter a skill name'); return; } const addBtn = window._skillAddBtn; if (addBtn && addBtn.parentElement) { const tag = document.createElement('span'); tag.className = 'skill-tag pop-in'; tag.appendChild(document.createTextNode(v + ' ')); // textContent 防注入 const x = document.createElement('span'); x.className = 'x'; x.textContent = '×'; x.onclick = function () { this.parentElement.remove(); }; tag.appendChild(x); addBtn.parentElement.insertBefore(tag, addBtn); } closeModal(); toast('Skill added: ' + v); } /* 简历生成 → DeepSeek V4 Flash(feature: ai_optimize),失败降级本地流程 分阶段进度:按钮文案按 Analyzing → Drafting → Polishing 轮播,等待期状态透明; 原型态(无 endpoint)补 1.4s 最小节奏,避免"瞬跳"造成的廉价感 */ async function generateResume(btn) { if (!trialGuard('ai_optimize')) { go('paywall'); return; } if (btn.dataset.busy) return; btn.dataset.busy = '1'; btn.classList.add('busy'); const old = btn.innerHTML; const stages = ['Analyzing profile…', 'Drafting bullets…', 'Polishing for ATS…']; let si = 0; btn.textContent = stages[0]; const stageTimer = setInterval(() => { si = (si + 1) % stages.length; btn.textContent = stages[si]; }, 750); const v = id => { const el = document.getElementById(id); return el ? el.value.trim() : ''; }; const skills = [...document.querySelectorAll('#skill-row .skill-tag')] .map(t => t.childNodes[0] ? t.childNodes[0].textContent.trim() : '') .filter(s => s && s !== '+ Add').join(', '); editorMode(null, 'chat'); const loadingArea = getChatArea(); if (loadingArea) { loadingArea.innerHTML = '
' + pipFace(22,'') + ' ' + aiBadge() + '
Analyzing your profile and generating a tailored resume...
'; } const minPace = AI_CONFIG.endpoint ? 0 : 1400; const [res] = await Promise.all([ callDeepSeek('ai_optimize', 'You are an expert ATS resume optimizer. Rewrite the resume to maximize keyword match while staying truthful. Return ONLY valid JSON with this exact structure: {"targetTitle": string, "summary": string (2-4 sentences), "experiences": [{"role": string, "company": string, "duration": string, "bullets": [string]}], "skills": [string]}.', `Name: ${v('sf-name')}\nTarget role: ${v('sf-role')}\nSkills: ${skills}\nExperience: ${v('sf-exp')}\n\nReturn ONLY valid JSON, no markdown fences, no prose.`), new Promise(r => setTimeout(r, minPace)) ]); clearInterval(stageTimer); btn.innerHTML = old; btn.classList.remove('busy'); delete btn.dataset.busy; if (res.ok) { trialConsume('ai_optimize'); window._lastAiResume = res.reply; if (res.structured) { window._lastAiStructured = res.structured; store.setJson('pip-ai-structured', res.structured); } toast(res.cached ? 'Resume generated (cached)' : 'Resume generated by DeepSeek'); const area = getChatArea(); if (area) { area.innerHTML = `
${pipFace(22,'')} ${aiBadge()}
Done! ✨ I've generated your resume. This chat is only for resume edits — tell me what to polish below:
• "Make it more concise"
• "Add more quantifiable metrics"
• "Tailor it for a senior role"
• "Fix the summary section"
`; } } else if (res.error === 'limit') { toast(res.msg); return; } else { toast(res.demo ? 'Demo mode — resume generated locally' : 'Network issue — local draft saved'); const area = getChatArea(); if (area) { area.innerHTML = '
' + pipFace(22,'') + ' ' + aiBadge() + '
Hmm, the AI service seems to be having issues. You can still edit manually in the Form tab, or try again.
'; } } } /* 打字机逐字渲染:AI 回复到达后按 ~12ms/字流入,感知延迟大幅降低;textNode 逐字追加防注入 */ function typewriter(el, text, speed = 12, done) { el.textContent = ''; const span = document.createElement('span'); const cur = document.createElement('span'); cur.className = 'tw-cursor'; el.appendChild(span); el.appendChild(cur); const chars = [...text]; const area = getChatArea(); let i = 0; (function tick() { if (i >= chars.length) { cur.remove(); if (done) done(); return; } const ch = chars[i++]; if (ch === '\n') span.appendChild(document.createElement('br')); else span.appendChild(document.createTextNode(ch)); if (area) area.scrollTop = area.scrollHeight; setTimeout(tick, ch === '\n' ? speed * 6 : speed); })(); } /* AI Chat → 基于当前简历进行修改,返回更新后的JSON */ const CHAT_EDIT_SYS = `You are Pip, a friendly resume assistant in Pip Paper Plane app. This chat is ONLY for polishing, editing, tailoring, or improving the user's resume. Scope you MUST stay within (answer / act ONLY on these): • Rewrite, shorten, or expand resume bullets, summary, skills, target title • Tailor resume to a pasted job description • Add quantifiable metrics, impact, strong verbs • Fix formatting, wording, typos, ATS-unfriendly phrases • Respond to resume-specific feedback ("make it more concise", "add leadership", etc.) • Generate or refine cover letters IF explicitly tied to a resume job If user asks ANYTHING outside resume/CV polish (general advice, interview tips unrelated to resume rewrite, career coaching, life questions, math, code, jokes, homework, personal questions, weather, news, translations NOT for resume content, H-1B strategy NOT touching resume text, pipeline/tracker questions, product feedback on this app): 1. DO NOT answer the off-topic question 2. Reply in 1-2 friendly sentences: politely explain this chat is reserved for resume edits only, and gently guide them to paste or describe what part of their resume they want improved. Suggest examples: "Try asking me to rewrite the summary, make bullets more quantifiable, or tailor the resume for a job description." Given the current resume data (JSON) and the user's request, return ONLY valid JSON with the updated resume in this exact structure: {"targetTitle": string, "summary": string, "experiences": [{"role": string, "company": string, "duration": string, "bullets": [string]}], "skills": [string]}. Also include a "message" field with a friendly 1-sentence response to the user. Return ONLY valid JSON, no markdown fences, no extra text.`; const CHAT_SYS = `You are Pip, a resume coach in the Pip Paper Plane mobile app. This conversation is ONLY about collecting resume content — specifically work-achievement details for the user's resume. Rules: • Ask exactly ONE sharp follow-up question to extract a QUANTIFIABLE METRIC (team size, %, $ amount, time saved, scale, deadline compression, user base size, launch scope, etc.) • Keep the question under 50 words, plain text, no lists, no markdown. • NEVER answer anything outside resume-content collection. If the user shifts topic, gently refocus: "This chat is just for polishing your resume — tell me about a line you want rewritten and I'll make it shine."`; let chatBusy = false; function syncStructuredToForm(s) { if (!s) return; const nameEl = document.getElementById('sf-name'); if (nameEl && s.targetTitle) { const parts = s.targetTitle.split(' '); if (parts.length >= 2) nameEl.value = parts.slice(0, 2).join(' '); else nameEl.value = s.targetTitle; } const roleEl = document.getElementById('sf-role'); if (roleEl && s.targetTitle) roleEl.value = s.targetTitle; const expEl = document.getElementById('sf-exp'); if (expEl && s.experiences && s.experiences.length) { const exp = s.experiences[0]; const lines = []; if (exp.role) lines.push(exp.role + (exp.company ? ' at ' + exp.company : '')); if (exp.duration) lines.push(exp.duration); if (exp.bullets) lines.push(...exp.bullets.slice(0, 3)); expEl.value = lines.join('\n'); } if (s.skills && s.skills.length) { const row = document.getElementById('skill-row'); if (row) { row.querySelectorAll('.skill-tag').forEach(t => { if (!t.textContent.includes('+ Add')) t.remove(); }); s.skills.slice(0, 8).forEach(sk => { const span = document.createElement('span'); span.className = 'skill-tag'; span.innerHTML = sk + ' ×'; row.insertBefore(span, row.lastElementChild); }); } } if (typeof updateAvatar === 'function') updateAvatar(); } function extractJson(text) { if (!text) return null; const trimmed = text.trim(); try { return JSON.parse(trimmed); } catch (_) {} const first = trimmed.indexOf("{"); const last = trimmed.lastIndexOf("}"); if (first >= 0 && last > first) { try { return JSON.parse(trimmed.slice(first, last + 1)); } catch (_) {} } const fm = trimmed.match(/```(?:json)?\s*\n([\s\S]*?)\n```/i); if (fm) { try { return JSON.parse(fm[1].trim()); } catch (_) {} } return null; } function getChatArea() { const screen = document.querySelector(".screen.active"); if (!screen) return document.getElementById("chat-area"); return screen.querySelector("#chat-area") || document.getElementById("chat-area"); } function getChatInput() { const screen = document.querySelector(".screen.active"); if (!screen) return document.getElementById("chat-input"); return screen.querySelector("#chat-input") || document.getElementById("chat-input"); } async function sendChat(input) { const text = input.value.trim(); if (!text || chatBusy) return; if (!trialGuard('chat_edit')) { go('paywall'); return; } // 温和引导:若输入不太像简历美化请求,给提示但不阻塞发送 const resumeKeywords = /resume|cv|bullets?|summary|experience|skill(s)?|cover|letter|job\s*desc|jd|tailor|metric|impact|concise|shorter|longer|fix|rewrite|edit|polish|improve|add\s*(more)?|remove|delete|expand|detail|typo|ats|keyword|format|heading|section|google|meta|amazon|senior|junior|intern|role|title|company|bullet\s*point|achievement|accomplishment|project/i; if (!resumeKeywords.test(text)) { toast('💡 Quick note · this chat is reserved for resume polish only', 2600); } const area = getChatArea(); area.insertAdjacentHTML('beforeend', `
${text.replace(/`); input.value = ''; const tid = 'typing-' + Date.now(); area.insertAdjacentHTML('beforeend', `
`); area.scrollTop = area.scrollHeight; chatBusy = true; let replyText = ''; const hasResume = window._lastAiStructured && window._lastAiStructured.experiences; if (hasResume) { const currentJson = JSON.stringify(window._lastAiStructured); const res = await callDeepSeek('chat_edit', CHAT_EDIT_SYS, `Current resume JSON:\n${currentJson}\n\nUser request: ${text}\n\nReturn updated resume as JSON with a "message" field.`); let structured = null; if (res.ok && res.structured) { structured = res.structured; } else if (res.ok && res.reply) { structured = extractJson(res.reply); } if (structured && structured.experiences) { trialConsume('chat_edit'); window._lastAiStructured = structured; syncStructuredToForm(structured); store.setJson('pip-ai-structured', structured); replyText = structured.message || 'Got it — I updated your resume. Check the Preview tab to see the changes!'; } else if (res.ok) { replyText = res.reply; } else { replyText = 'Got it! I\'ve noted your feedback. Want me to regenerate the resume with those changes?'; } } else { const res = await callDeepSeek('chat_edit', CHAT_SYS, text); replyText = res.ok ? res.reply : 'Great detail! What was the team size or business impact?'; } chatBusy = false; const t = document.getElementById(tid); if (t) { typewriter(t, replyText, 12); } area.scrollTop = area.scrollHeight; } function quickReply(chip) { const input = getChatInput(); input.value = chip.textContent.trim(); sendChat(input); } /* ==================== Editor:模块选择器 / 模板选择 ==================== */ /* Add Module 菜单:展示全部可选模块,支持单选/多选,底部确认一次添加 */ const MODULE_MENU = [ { n:'Projects', i:'code', d:'Side projects with links & metrics', c:'var(--sky-light)', ic:'var(--sky)' }, { n:'Education', i:'grad', d:'Degrees, GPA, honors', c:'var(--violet-light)', ic:'var(--violet-dark)' }, { n:'Certifications', i:'award', d:'AWS, Google, PMP...', c:'var(--honey-light)', ic:'var(--honey-dark)' }, { n:'Languages', i:'lang', d:'Spoken languages & levels', c:'var(--leaf-light)', ic:'var(--leaf-dark)' }, { n:'Open Source', i:'github', d:'Repos, stars, contributions', c:'var(--paper-dark)', ic:'var(--ink)' }, { n:'Publications', i:'book', d:'Papers, talks, blog posts', c:'var(--coral-light)', ic:'var(--coral)' }, { n:'Volunteering', i:'users', d:'Community & mentoring', c:'var(--leaf-light)', ic:'var(--leaf-dark)' }, { n:'Custom Section', i:'folder', d:'Anything else, your way', c:'var(--paper-dark)', ic:'var(--ink-lighter)' }, ]; function toggleMod(head) { const body = head.nextElementSibling; body.style.display = body.style.display === 'none' ? 'block' : 'none'; } function openModulePicker() { openModal( modalHead(I('plus','icon sm'), 'Add Modules') + `
Tap to select — pick one or many.
` + `
` + MODULE_MENU.map((m, i) => `
${I('check','icon xs')} ${I(m.i,'icon sm')}
${m.n}
${m.d}
`).join('') + `
` ); } function updModPickBtn() { const n = document.querySelectorAll('#mod-pick-list .mod-pick-row.sel').length; const btn = document.getElementById('mod-pick-btn'); if (!btn) return; btn.style.opacity = n ? '' : '.45'; btn.innerHTML = `${I('check','icon sm')} Add Selected${n ? ' (' + n + ')' : ''}`; } function confirmModulePicker() { const sel = [...document.querySelectorAll('#mod-pick-list .mod-pick-row.sel')]; if (!sel.length) { toast('Pick at least one module'); return; } const zone = document.getElementById('add-module-zone'); sel.forEach((row, k) => { const name = row.dataset.mod; zone.insertAdjacentHTML('beforebegin', `
${I('drag','icon sm')}

${name}

0%${I('chev','icon sm')}
${name}
`); }); closeModal(); toast(sel.length + (sel.length > 1 ? ' modules added' : ' module added')); } /* 6个简历模板定义:各自有独特的样式变量和布局结构 */ const TEMPLATES = [ { id:'classic', name:'Classic Ink', tag:'ATS-safe · single column', vars:{bg:'#faf7f2',ink:'#1a1a2e',accent:'#1a1a2e',font:`Georgia,'Times New Roman',serif`, layout:'single'}, desc:'Traditional serif — hiring managers love it' }, { id:'modern', name:'Modern Sans', tag:'Clean · tech friendly', vars:{bg:'#ffffff',ink:'#1f2937',accent:'#3b82f6',font:'system-ui,sans-serif', layout:'single'}, desc:'Minimal sans-serif for tech roles' }, { id:'exec', name:'Executive Bold', tag:'Senior · authoritative', vars:{bg:'#0f172a',ink:'#f8fafc',accent:'#f59e0b',font:`'Georgia',serif`, layout:'header'}, desc:'Dark band header for experienced hires' }, { id:'creative', name:'Creative Side', tag:'Two-column · accent bar', vars:{bg:'#f5f3ff',ink:'#1e1b4b',accent:'#7c3aed',font:'system-ui,sans-serif', layout:'sidebar'}, desc:'Violet sidebar for design/marketing roles' }, { id:'fresh', name:'Fresh Grid', tag:'Two-column · modern', vars:{bg:'#f0fdf4',ink:'#14532d',accent:'#16a34a',font:'system-ui,sans-serif', layout:'twocol'}, desc:'Mint two-column for early-career' }, { id:'compact', name:'Compact Pro', tag:'One page · dense', vars:{bg:'#fff7ed',ink:'#431407',accent:'#ea580c',font:`'Courier New',monospace`, layout:'dense'}, desc:'Dense layout for 10+ years experience' }, ]; let selectedTpl = store.get('pip-tpl', 'classic'); window._lastAiStructured = store.getJson('pip-ai-structured', null); ['GitHub','LinkedIn'].forEach(function(p) { if (store.get('pip-connected-' + p) === 'true') { connectedAccounts[p] = true; document.querySelectorAll('.settings-row').forEach(function(r) { if (r.textContent.indexOf(p) >= 0) { r.classList.add('connected'); var v = r.querySelector('.val'); if (v) { v.textContent = 'Linked'; v.style.color = 'var(--leaf-dark)'; } } }); document.querySelectorAll('.connect-btn').forEach(function(b) { if (b.textContent.trim().includes(p) && !b.classList.contains('connected')) { b.classList.add('connected'); b.classList.remove('gh','li'); b.innerHTML = b.innerHTML.replace(/.*Sign in.*/, '').replace(/.*Connect.*/, '') + '' + p + ' Connected'; b.onclick = function() { toast(p + ' already connected'); }; } }); } }); /* 生成模板预览的缩略 HTML(用真实数据渲染,不是骨架线) */ function tplPreviewHtml(tpl) { const v = tpl.vars; const name = _fv('sf-name') || 'Alex Johnson'; const role = _fv('sf-role') || 'Senior Android Developer'; const s = window._lastAiStructured || {}; const sk = (s.skills && s.skills.slice(0,4)) || ['Kotlin','Compose','Coroutines','Room']; const lineColor = v.accent; const initial = name.trim().charAt(0).toUpperCase(); const avatarImg = typeof userAvatar !== 'undefined' && userAvatar ? userAvatar : ''; const avatarHtml = (size=24) => avatarImg ? `
` : `
${initial}
`; if (v.layout === 'sidebar') { return `
${avatarHtml(28)}
SKILLS
${sk.slice(0,4).map(k=>`
${k}
`).join('')}
${name.split(' ')[0]} ${name.split(' ')[1]||''}
${role}
EXPERIENCE
Android Engineer · Finly
2021 - Present
Led Kotlin migration, 38% faster cold start
`; } if (v.layout === 'twocol') { return `
${avatarHtml(32)}
${name.split(' ')[0]} ${name.split(' ')[1]||''}
${role}
EXPERIENCE
Senior Android Eng
Finly · 2021-Present
Led Kotlin migration, cut cold start 38%
SKILLS
${sk.map(k=>`
${k}
`).join('')}
`; } if (v.layout === 'header') { return `
${avatarHtml(36)}
${name.toUpperCase()}
${role}
Experience
Senior Android Engineer · Finly
2021 - Present
Led Kotlin migration for 2M-user fintech app, reduced cold start by 38%
Skills
${sk.join(' · ')}
`; } if (v.layout === 'dense') { return `
${avatarHtml(22)}
${name.split(' ')[0]} ${name.split(' ')[1]||''}
${role}
EXPERIENCE
Senior Android Eng · Finly
2021-Present
Kotlin migration · 38% perf gain
Mobile Dev · TechCo
2018-2021
SKILLS
${sk.map(k=>`
${k}
`).join('')}
EDUCATION
B.S. CS · State Univ
`; } return `
${avatarHtml(30)}
${name.split(' ')[0]} ${name.split(' ')[1]||''}
${role}
Experience
Senior Android Engineer
Finly · 2021 - Present
Led Kotlin migration for 2M-user fintech app, cut cold start 38% via baseline profiles & benchmarking.
Skills
${sk.join(' · ')}
`; } /* 页面加载时渲染模板卡片 */ document.addEventListener('DOMContentLoaded', () => { /* 启动时检测订阅是否刚过期,提示用户 */ try { checkSubscriptionExpiry(); } catch (e) {} const grid = document.getElementById('tpl-grid'); if (!grid) return; TEMPLATES.forEach((t, i) => { const isSel = t.id === selectedTpl; const card = document.createElement('div'); card.className = 'tpl-card sticker pop-in' + (i > 0 ? ' d' + (i < 3 ? i : 2) : ''); card.style.cssText = 'cursor:pointer;border:2px solid var(--ink);border-radius:var(--r-sm);overflow:hidden;box-shadow:var(--shadow-sm);'; card.innerHTML = `
${tplPreviewHtml(t)} ${isSel ? `
${I('check','icon xs')}
` : ''}

${t.name}

${t.tag}

`; card.onclick = () => selectTpl(card, t.id, t.name); grid.appendChild(card); }); const btn = document.getElementById('tpl-use-btn'); if (btn) { const t = TEMPLATES.find(x => x.id === selectedTpl); if (t) btn.innerHTML = `${I('check','icon sm')} Use ${t.name}`; } }); function selectTpl(card, id, name) { selectedTpl = id; store.set('pip-tpl', id); document.querySelectorAll('.tpl-selected-badge').forEach(b => b.remove()); const badge = document.createElement('div'); badge.className = 'tpl-selected-badge'; badge.style.cssText = 'position:absolute;top:6px;right:6px;width:22px;height:22px;background:var(--leaf);border:2px solid var(--ink);border-radius:50%;display:flex;align-items:center;justify-content:center;'; badge.innerHTML = I('check','icon xs'); card.querySelector('.tpl-preview').appendChild(badge); document.getElementById('tpl-use-btn').innerHTML = `${I('check','icon sm')} Use ${name}`; toast('Template: ' + name); } function useTpl() { const t = TEMPLATES.find(x => x.id === selectedTpl); if (t) toast('Template applied: ' + t.name); goBack(); } /* ==================== ATS / Cover Letter ==================== */ function fixKw(el) { const hit = document.getElementById('kw-hit-row'); el.className = 'kw hit pop-in'; el.removeAttribute('onclick'); el.style.cursor = 'default'; hit.appendChild(el); toast('Added to Skills · match ~91%'); } function applyAtsFix(btn) { if (!trialGuard('ats_check')) return; const miss = document.querySelectorAll('#kw-miss-row .kw.miss'); if (!miss.length) { toast('All keywords matched'); return; } miss.forEach(el => fixKw(el)); /* ATS Apply Fix = 纯本地把关键词加入 Skills 数组(FREEBIE 组),不扣日配额 */ setTimeout(() => go('editor'), 700); } const TONE_TEXT = { pro: 'Dear Hiring Team at Google,

As an Android engineer who cut cold-start time by 38% for a 2M-user fintech app, I was excited to see your opening for the Android Platform team.

At Finly, I led the Kotlin migration of our core banking module, shipping baseline profiles and a Compose-first UI layer that lifted our crash-free rate to 99.7%...', friendly: 'Hi Google team!

I build Android apps that feel instant — at Finly I cut cold start 38% for 2M users, and I’d love to bring that same craft to the Android Platform team.

I led our Kotlin migration end-to-end: baseline profiles, Compose-first UI, and a crash-free rate of 99.7%...', bold: 'Your Android team ships to billions. So do I.

I cut cold-start time 38% on a 2M-user fintech app and kept crash-free at 99.7% — while leading the Kotlin migration that made it possible.

Give me 20 minutes and I’ll show you exactly how I’d do it for Google...', }; let curTone = 'pro'; function setTone(chip, tone) { chip.parentElement.querySelectorAll('.tone-chip').forEach(c => c.classList.remove('active')); chip.classList.add('active'); curTone = tone; document.getElementById('cl-paper').innerHTML = TONE_TEXT[tone] + '

Generating remaining paragraphs...'; } /* Regenerate:分阶段反馈(Drafting 按钮态 + 信纸打字机重渲),等待过程全程可视 */ function regenLetter(btn) { if (!trialGuard('cover_letter')) { go('paywall'); return; } if (btn.dataset.busy) return; btn.dataset.busy = '1'; btn.classList.add('busy'); const old = btn.innerHTML; btn.textContent = 'Drafting…'; const paper = document.getElementById('cl-paper'); paper.style.opacity = '.45'; setTimeout(() => { paper.style.opacity = ''; const plain = TONE_TEXT[curTone].replace(/
/g, '\n') + '\n\n'; typewriter(paper, plain, 6, () => { paper.insertAdjacentHTML('beforeend', 'Generating remaining paragraphs...'); }); btn.innerHTML = old; btn.classList.remove('busy'); delete btn.dataset.busy; trialConsume('cover_letter'); toast('New draft generated'); }, 650); } /* ==================== Tracker 看板过滤 / 新增申请 ==================== */ function filterKan(chip, mode) { const semantic = { all:['','',''], active:['','var(--sky)',''], offers:['','var(--leaf-dark)',''] }; chip.parentElement.querySelectorAll('.kan-filter').forEach(c => { c.classList.remove('active'); const s = semantic[c.dataset.filter] || ['','','']; c.style.background = s[0]; c.style.color = s[1]; c.style.borderColor = s[2]; }); chip.classList.add('active'); chip.style.background = 'var(--honey)'; chip.style.color = 'var(--ink)'; chip.style.borderColor = 'var(--ink)'; document.querySelectorAll('#kanban .kan-col').forEach(col => { const st = col.dataset.stage; const show = mode === 'all' || (mode === 'active' && st !== 'offer') || (mode === 'offers' && st === 'offer'); col.style.display = show ? '' : 'none'; }); } function addApplication() { const company = (document.getElementById('na-company').value || 'New Co').trim(); const role = (document.getElementById('na-role').value || 'Android').trim(); const col = document.querySelector('#kanban .kan-col[data-stage="applied"]'); col.insertAdjacentHTML('beforeend', `
${company.replace(/

just now

`); const countEl = col.querySelector('.kan-col-head span'); countEl.textContent = parseInt(countEl.textContent) + 1; closeModal(); toast(company + ' added to Applied'); } /* ==================== Paywall ==================== */ function planSel(card) { document.querySelectorAll('.plan-card, .plan-mini').forEach(c => c.classList.remove('selected')); card.classList.add('selected'); } /* Paywall 方案键映射(供 activatePlan/subscribe 等使用) */ function _paywallPlanKey() { var s = document.querySelector('.plan-card.selected .plan-name'); var t = s ? s.textContent.trim() : 'Yearly Pro'; var m = { 'Weekly Pro':'weekly_pro', 'Monthly Pro':'monthly_pro', 'Yearly Pro':'yearly_pro' }; return m[t] || 'yearly_pro'; } /* Editor三Tab切换 */ function editorMode(btn, mode) { const activeScreen = document.querySelector('.screen.active'); if (!activeScreen) return; let screen = activeScreen.querySelector('.phone-screen') || activeScreen; const switchBtns = activeScreen.querySelectorAll('.mode-switch button'); switchBtns.forEach(b => b.classList.remove('active')); if (btn) btn.classList.add('active'); else { const idx = mode === 'edit' ? 0 : mode === 'chat' ? 1 : 2; if (switchBtns[idx]) switchBtns[idx].classList.add('active'); } const editEl = document.getElementById('editor-edit'); const chatEl = document.getElementById('editor-chat'); const previewEl = document.getElementById('editor-preview'); if (editEl) { editEl.style.setProperty('display', mode === 'edit' ? 'block' : 'none', 'important'); } if (chatEl) { chatEl.style.setProperty('display', mode === 'chat' ? 'flex' : 'none', 'important'); } if (previewEl) { previewEl.style.setProperty('display', mode === 'preview' ? 'block' : 'none', 'important'); } const dock = activeScreen.querySelector('.dock'); const spacer = activeScreen.querySelector('.dock-spacer'); const chatBar = document.getElementById('editor-chat-input'); const isChat = mode === 'chat'; if (screen && screen.classList) { if (isChat) screen.classList.add('chat-mode'); else screen.classList.remove('chat-mode'); } if (dock) { if (isChat) dock.classList.add('hidden'); else dock.classList.remove('hidden'); } if (spacer) spacer.style.display = isChat ? 'none' : ''; if (chatBar) { if (isChat) chatBar.classList.add('visible'); else chatBar.classList.remove('visible'); } if (mode === 'preview') renderPreviewCard(); if (mode === 'chat') { const chatArea = document.getElementById('chat-area'); if (chatArea && chatArea.children.length <= 1) { const hasResume = window._lastAiStructured && window._lastAiStructured.experiences; const msg = hasResume ? `Done! ✨ Your resume is ready. This chat is only for resume edits — tell me what to polish:
✍️ Rewrite my summary 📊 Add metrics & impact 🎯 Tailor for a JD ✂️ Make it more concise
` : `Hi! 👋 This chat is only for editing & polishing your resume. Paste a job description to tailor for it, or describe what part needs work. Try one of these:
✍️ Rewrite my summary 📊 Add metrics & impact 🎯 Tailor for a JD ✂️ Make it more concise
`; chatArea.innerHTML = '
' + pipFace(22,'') + ' ' + aiBadge() + '
' + msg + '
'; } const chatInput = document.getElementById('chat-input'); if (chatInput) setTimeout(() => chatInput.focus(), 100); } store.set('pip-editor-mode', mode); } /* 预览卡片渲染 */ function renderPreviewCard() { const card = document.querySelector('.screen.active #preview-tpl-card'); if (!card) return; const tpl = TEMPLATES.find(t => t.id === selectedTpl) || TEMPLATES[0]; card.innerHTML = `
${tplPreviewHtml(tpl)}
`; } /* 头像功能 */ let userAvatar = store.get('pip-avatar', ''); function updateAvatar() { const name = _fv('sf-name') || 'A'; const initial = name.trim().charAt(0).toUpperCase(); const avatars = document.querySelectorAll('#editor-avatar, .avatar-inner'); avatars.forEach(a => { if (userAvatar) { a.style.backgroundImage = `url(${userAvatar})`; a.style.backgroundSize = 'cover'; a.textContent = ''; } else { a.style.backgroundImage = ''; a.textContent = initial; } }); } function changeAvatar() { const input = document.createElement('input'); input.type = 'file'; input.accept = 'image/*'; input.onchange = e => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = ev => { userAvatar = ev.target.result; store.set('pip-avatar', userAvatar); updateAvatar(); toast('Avatar updated'); }; reader.readAsDataURL(file); }; input.click(); } /* ==================== GitHub / LinkedIn OAuth 连接(图2) ==================== 流程:点击 → 弹窗说明 → 跳转官方授权页(新窗口登录并授权)→ 授权回调后 云端用 code 换 token 拉取资料 → DeepSeek V4 Flash 解析 → 自动填入简历字段。 原型限制:无真实 client_id / 回调地址,window.open 打开真实授权页演示跳转, 回调与资料拉取以演示数据模拟;AI_CONFIG.endpoint 配置后解析步骤走真实云函数。 */ const OAUTH_CONFIG = { GitHub: { clientId: 'Ov23liROqT5qInbXIbDI', authUrl: 'https://github.com/login/oauth/authorize', scope: 'read:user repo', redirectUri: 'techresume://oauth/github', webRedirectUri: 'https://pip-paper-plane.pages.dev/' }, LinkedIn: { clientId: 'YOUR_LINKEDIN_CLIENT_ID', authUrl: 'https://www.linkedin.com/oauth/v2/authorization', scope: 'openid profile email', redirectUri: '', webRedirectUri: 'https://pip-paper-plane.pages.dev/' }, }; const connectedAccounts = { GitHub: false, LinkedIn: false }; function getRedirectUri(provider) { const c = OAUTH_CONFIG[provider]; if (location.protocol === 'https:') { // 优先使用配置的 webRedirectUri;否则回退到 origin(不含查询参数,避免 cb/test 等干扰) return c.webRedirectUri || (location.origin + location.pathname); } return c.redirectUri || (location.origin + location.pathname); } function oauthUrl(provider) { const c = OAUTH_CONFIG[provider]; const state = Math.random().toString(36).slice(2) + Date.now().toString(36); sessionStorage.setItem('oauth-state-' + provider, state); // 回调时校验 state 防 CSRF const redirect = encodeURIComponent(getRedirectUri(provider)); if (provider === 'GitHub') return `${c.authUrl}?client_id=${c.clientId}&redirect_uri=${redirect}&scope=${encodeURIComponent(c.scope)}&state=${state}`; return `${c.authUrl}?response_type=code&client_id=${c.clientId}&redirect_uri=${redirect}&scope=${encodeURIComponent(c.scope)}&state=${state}`; } function connectAccount(btn, provider) { if (connectedAccounts[provider]) { toast(provider + ' already connected'); return; } openModal( modalHead(I(provider.toLowerCase(),'icon sm'), 'Connect ' + provider) + `
${aiBadge('DeepSeek V4 · Import')}

You'll be redirected to ${provider}'s official page to sign in and authorize.
After authorization, DeepSeek parses your ${provider === 'GitHub' ? 'repos, stars & languages' : 'headline, roles & endorsements'} and auto-fills your resume.
` ); } function startOAuth(provider) { const cfg = OAUTH_CONFIG[provider]; if (!cfg || cfg.clientId.startsWith('YOUR_')) { toast(provider + ' OAuth not configured — add client_id in settings'); return; } if (location.protocol === 'file:' || (location.protocol === 'http:' && !location.hostname.includes('localhost'))) { toast('OAuth requires HTTPS — deploy to a server first'); return; } const popup = window.open(oauthUrl(provider), '_blank', 'width=640,height=720'); if (!popup) { toast('Popup blocked — allow popups for this site'); return; } /* 不再立即假成功:等待用户真实完成授权 */ const card = document.querySelector('.modal-card'); if (card) { card.innerHTML = modalHead(I(provider.toLowerCase(),'icon sm'), 'Authorize ' + provider) + `
${pipFace(46)}
Complete authorization in the popup
After you finish signing in, click below to import.
`; } const timer = setInterval(() => { if (popup.closed) clearInterval(timer); }, 500); } async function confirmOAuth(provider) { /* GitHub/LinkedIn 导入受订阅限制 */ const featureKey = provider === 'GitHub' ? 'github_import' : (provider === 'LinkedIn' ? 'github_import' : null); if (featureKey && !canUseFeature(featureKey)) { showLimitReached(featureKey); return; } const params = new URLSearchParams(location.search); const code = params.get('code'); const state = params.get('state'); const savedState = sessionStorage.getItem('oauth-state-' + provider); if (code && state && savedState && state !== savedState) { toast('Security check failed — please try again'); return; } if (code && (provider === 'GitHub' || provider === 'LinkedIn')) { runImport(provider, code); } else { runImport(provider, null); } /* 注意:扣费点从这里移除 —— 移到 finishImport() 成功 toast 之后再扣,失败/导入中途退出不扣日配额 */ } async function runImport(provider, code) { const card = document.querySelector('.modal-card'); if (!card) return; card.innerHTML = modalHead(I(provider.toLowerCase(),'icon sm'), 'Importing...') + `
${pipFace(46)}
Connecting to ${provider}...
Authorization received…
`; let userData = null; if ((provider === 'GitHub' || provider === 'LinkedIn') && code) { const fill = document.getElementById('imp-fill'); const step = document.getElementById('imp-step'); if (fill) fill.style.width = '30%'; if (step) step.textContent = 'Exchanging authorization code...'; try { const featureKey = provider === 'GitHub' ? 'github_oauth' : 'linkedin_oauth'; const oauthBody = { feature: featureKey, code: code, redirect_uri: getRedirectUri(provider) }; const oauthPath = (() => { try { return new URL(AI_CONFIG.endpoint).pathname.replace('/optimize-stream','') || '/'; } catch { return '/'; } })(); const oauthHeaders = { 'Content-Type': 'application/json', 'X-User-Id': AI_CONFIG.userId, 'X-User-Tier': getUserPlan() }; if (AI_CONFIG.signSecret) { const ts = Math.floor(Date.now() / 1000).toString(); const nonce = (crypto.randomUUID ? crypto.randomUUID() : String(Date.now()) + Math.random().toString(36).slice(2)); oauthHeaders['X-Timestamp'] = ts; oauthHeaders['X-Nonce'] = nonce; oauthHeaders['X-Signature'] = await hmacSha256Hex(AI_CONFIG.signSecret, `POST${oauthPath}${ts}${nonce}${stableStringify(oauthBody)}`); } const res = await fetch(AI_CONFIG.endpoint.replace('/optimize-stream', ''), { method: 'POST', headers: oauthHeaders, body: JSON.stringify(oauthBody) }); const result = await res.json(); if (result.ok) { userData = result; if (fill) fill.style.width = '80%'; const displayName = (result.user && (result.user.name || result.user.login)) || provider; if (step) step.textContent = `Fetched ${displayName}'s profile...`; } else { if (step) step.textContent = result.error || 'Import failed'; if (fill) fill.style.width = '100%'; } } catch (e) { if (step) step.textContent = 'Network error: ' + e.message; } } else { // 演示导入:播放步骤动画 const steps = ['Authorization received…','Fetching profile…','Parsing ' + (provider==='GitHub'?'top repos…':'experience…'),'Extracting metrics…','Writing bullets…']; for (let i = 1; i < steps.length; i++) { await new Promise(r => setTimeout(r, 500)); const fill = document.getElementById('imp-fill'); const step = document.getElementById('imp-step'); if (!fill || !step) return; fill.style.width = (12 + i * 20) + '%'; step.textContent = steps[i]; } } finishImport(provider, userData); } function finishImport(provider, data) { connectedAccounts[provider] = true; store.set('pip-connected-' + provider, 'true'); closeModal(); document.querySelectorAll('.connect-btn').forEach(b => { if (b.textContent.trim().includes(provider) && !b.classList.contains('connected')) { b.classList.add('connected'); b.classList.remove('gh','li'); b.innerHTML = `${I('check')} ${provider} Connected`; b.onclick = () => toast(provider + ' already connected'); } }); const valEl = document.getElementById(provider === 'GitHub' ? 'gh-val' : 'li-val'); if (valEl) { valEl.textContent = 'Linked'; valEl.style.color = 'var(--leaf-dark)'; } /* 自动填入简历字段 */ const setVal = (id, val, append) => { const el = document.getElementById(id); if (!el || !val) return; el.value = append && el.value.trim() ? el.value.trim() + '\n' + val : val; el.style.boxShadow = '0 0 0 3px var(--leaf)'; setTimeout(() => { el.style.boxShadow = ''; }, 1200); }; const addSkillTag = s => { const row = document.getElementById('skill-row'); if (!row) return; const addBtn = row.lastElementChild; const tag = document.createElement('span'); tag.className = 'skill-tag pop-in'; tag.appendChild(document.createTextNode(s + ' ')); const x = document.createElement('span'); x.className = 'x'; x.textContent = '×'; x.onclick = function () { this.parentElement.remove(); }; tag.appendChild(x); row.insertBefore(tag, addBtn); }; if (provider === 'GitHub') { if (data && data.ok) { // 真实 GitHub 数据 const user = data.user; const repos = data.repos || []; setVal('sf-name', user.name || user.login); if (user.bio) setVal('sf-exp', user.bio, false); // 提取编程语言作为技能 const languages = [...new Set(repos.map(r => r.language).filter(Boolean))].slice(0, 5); languages.forEach(addSkillTag); // 生成仓库描述 if (repos.length > 0) { const repoText = repos.slice(0, 3).map(r => `${r.name} (${r.stargazers_count}★): ${r.description || r.language || 'Project'}` ).join('\n'); setVal('sf-exp', repoText, true); } toast(`GitHub imported — ${user.login} (${user.public_repos} repos, ${user.followers} followers)`); trialConsume('github_import'); } else { // 回退到演示数据 ['Retrofit','KMP','Coroutines Flow'].forEach(addSkillTag); setVal('sf-exp', 'Maintained 3 open-source Android libraries (1.2k★ total): retrofit-coroutines adapter & KMP networking layer.', true); toast('GitHub imported — demo data'); trialConsume('github_import'); } } else if (provider === 'LinkedIn') { if (data && data.ok) { // 真实 LinkedIn 数据 const user = data.user; const positions = data.positions || []; setVal('sf-name', user.name || user.given_name + ' ' + user.family_name); if (user.email) { setVal('sf-email', user.email); } // 生成工作经历描述 if (positions.length > 0) { const expText = positions.slice(0, 3).map(p => { const title = p.title || {}; const company = p.company || {}; const titleName = (title.name && title.name['en_US']) || ''; const companyName = (company.name && company.name['en_US']) || ''; return titleName + (companyName ? ' at ' + companyName : ''); }).filter(Boolean).join('\n'); if (expText) setVal('sf-exp', expText, false); } toast(`LinkedIn imported — ${user.name || user.given_name}`); trialConsume('github_import'); } else { // 回退到演示数据 setVal('sf-name', 'Alex Johnson'); setVal('sf-role', 'Senior Android Developer'); setVal('sf-exp', 'Senior Android Engineer with 6.5 yrs in fintech; led 5-person team shipping to 2M+ users; endorsed for Kotlin & System Design.', true); toast('LinkedIn imported — demo data'); trialConsume('github_import'); } } } /* ==================== 认证:登录 / 注册 ==================== */ function togglePwd(eye, id) { const input = document.getElementById(id); const show = input.type === 'password'; input.type = show ? 'text' : 'password'; eye.innerHTML = I(show ? 'eyeoff' : 'eye', 'icon sm'); } function pwdStrength(input) { const v = input.value; const meter = document.querySelector('#pwd-meter .progress-fill'); if (!meter) return; let score = 0; if (v.length >= 8) score++; if (/[A-Z]/.test(v) && /[a-z]/.test(v)) score++; if (/\d/.test(v) || /[^A-Za-z0-9]/.test(v)) score++; meter.style.width = (score / 3 * 100) + '%'; meter.style.background = score <= 1 ? 'var(--coral)' : score === 2 ? 'var(--honey)' : 'var(--leaf)'; } function doLogin(btn) { const email = document.getElementById('li-email').value.trim(); if (!email || !email.includes('@')) { toast('Enter a valid email'); return; } if (btn.dataset.busy) return; btn.dataset.busy = '1'; const old = btn.innerHTML; btn.innerHTML = 'Signing in...'; btn.style.opacity = '.75'; setTimeout(() => { delete btn.dataset.busy; btn.innerHTML = old; btn.style.opacity = ''; toast('Welcome back, Alex!'); goBack(); }, 800); } function doSignup(btn) { const name = document.getElementById('su-name').value.trim(); const email = document.getElementById('su-email').value.trim(); if (!name) { toast('Enter your name'); return; } if (!email || !email.includes('@')) { toast('Enter a valid email'); return; } if (btn.dataset.busy) return; btn.dataset.busy = '1'; const old = btn.innerHTML; btn.innerHTML = 'Creating account...'; btn.style.opacity = '.75'; setTimeout(() => { delete btn.dataset.busy; btn.innerHTML = old; btn.style.opacity = ''; toast('Account created — welcome!'); go('onboarding'); }, 900); } /* 登录/注册页 OAuth:原型演示真实流程。 GitHub/LinkedIn:新窗口打开官方授权页(与 connectAccount 一致); 全流程步骤可视化:跳转授权页 → 用户同意 → code 回调 Firebase handler → 换取 token → 资料写入 Firestore → 进入主界面。 生产环境由 Firebase Auth startActivityForSignInWithProvider 自动完成。 */ function oauthLogin(provider) { const iconHtml = provider === 'Google' ? G_LOGO : I(provider.toLowerCase(), 'icon sm'); openModal( modalHead(iconHtml, 'Sign in with ' + provider) + `
You'll be redirected to ${provider}'s official sign-in page.
${pipFace(40)}
Waiting for authorization…
Complete sign-in in the popup, then return here.
` ); if (provider === 'GitHub' || provider === 'LinkedIn') { const cfg = OAUTH_CONFIG[provider]; if (!cfg || cfg.clientId.startsWith('YOUR_')) { document.getElementById('oauth-wait').innerHTML = `
${provider} OAuth not configured — add client_id in settings
`; return; } if (location.protocol === 'file:' || (location.protocol === 'http:' && !location.hostname.includes('localhost'))) { document.getElementById('oauth-wait').innerHTML = `
OAuth requires HTTPS — deploy to a server first
`; return; } const popup = window.open(oauthUrl(provider), '_blank', 'width=640,height=720'); if (!popup) { document.getElementById('oauth-wait').innerHTML = `
Popup blocked — allow popups for this site
`; return; } const timer = setInterval(() => { if (popup.closed) { clearInterval(timer); const wait = document.getElementById('oauth-wait'); const actions = document.getElementById('oauth-actions'); if (wait && actions) { wait.style.display = 'none'; actions.style.display = 'flex'; } } }, 500); } } function confirmOAuthLogin(provider) { connectedAccounts[provider] = true; closeModal(); toast('Signed in with ' + provider + ' · profile auto-imported'); goBack(); } /* ==================== BOARD 本地功能:Daily Quests + Resume History ==================== 无服务器架构:全部状态 localStorage 持久化,刷新/重开不丢失 */ const DAILY_QUESTS = [ { id:'skills', txt:'Add 3 new skills to your profile', xp:10, page:'studio' }, { id:'ats', txt:'Run an ATS check on latest resume', xp:15, page:'ats' }, { id:'cover', txt:'Draft one cover letter', xp:20, page:'coverletter' }, ]; const dqKey = () => 'pip_dq_' + new Date().toISOString().slice(0, 10); function dqDone() { return store.getJson(dqKey(), []); } function renderDailyQuests() { const list = document.getElementById('dq-list'); if (!list) return; const done = dqDone(); list.innerHTML = DAILY_QUESTS.map(q => `
${I('check','icon xs')} ${q.txt} +${q.xp} XP
`).join(''); const pg = document.getElementById('dq-progress'); if (pg) pg.textContent = done.length + '/' + DAILY_QUESTS.length; } function toggleQuest(id) { const done = dqDone(); const q = DAILY_QUESTS.find(x => x.id === id); const i = done.indexOf(id); if (i >= 0) { done.splice(i, 1); } else { done.push(id); toast('+' + q.xp + ' XP — quest done'); } store.setJson(dqKey(), done); renderDailyQuests(); if (done.length === DAILY_QUESTS.length) setTimeout(() => toast('All quests done — streak +1'), 400); } function getHistory() { return store.getJson('pip_resume_history', null); } function seedHistory() { return [ { v:5, note:'Added fintech metrics', t:'2m ago', cur:true }, { v:4, note:'Reordered projects section', t:'Yesterday' }, { v:3, note:'DeepSeek rewrite · summary', t:'Mon' }, ]; } function renderHistory() { const list = document.getElementById('rh-list'); if (!list) return; const h = getHistory() || seedHistory(); list.innerHTML = h.map(r => `
v${r.v}
${r.note}
${r.t}
${r.cur ? 'Current' : `Restore`}
`).join(''); } function snapshotNow() { const h = getHistory() || seedHistory(); const nv = Math.max(...h.map(r => r.v)) + 1; h.forEach(r => delete r.cur); h.unshift({ v:nv, note:'Manual snapshot', t:'just now', cur:true }); store.setJson('pip_resume_history', h.slice(0, 6)); renderHistory(); toast('v' + nv + ' snapshot saved locally'); } function restoreVer(v) { const h = getHistory() || seedHistory(); h.forEach(r => { delete r.cur; if (r.v === v) r.cur = true; }); h.sort((a, b) => (b.cur ? 1 : 0) - (a.cur ? 1 : 0)); store.setJson('pip_resume_history', h); renderHistory(); toast('Restored v' + v + ' as current'); } function hydrateBoard() { renderDailyQuests(); renderHistory(); } /* 初始化:应用持久化主题 + 预渲染分页内容 */ applyTheme(); renderOnboard(); hydrateBoard(); renderStar(); bindQuestDrag(); refreshNotificationsVal(); // 首屏确保 Notifications 数量显示与 localStorage 一致 initFromUrl(); /* URL ?screen= 直达 + OAuth 回调,优先级最高 */ /* 首次访问且无 URL 参数时,自动进入 onboarding;标记后不再重复展示 */ const _params = new URLSearchParams(location.search); if (!_params.get('screen') && !_params.get('code')) { if (!store.get('pip_seen_onboard')) { store.set('pip_seen_onboard', '1'); go('onboarding', false); } } /* PWA Service Worker Registration */ if ('serviceWorker' in navigator) { navigator.serviceWorker.register('sw.js').then(function(reg) { console.log('[PWA] Service Worker registered:', reg.scope); }).catch(function(err) { console.log('[PWA] Service Worker registration failed:', err); }); } /* Web Notification Permission */ function requestNotifyPermission() { if ('Notification' in window && Notification.permission === 'default') { Notification.requestPermission(); } } /* Toast triggers system notification */ const _origToast = toast; toast = function(msg) { _origToast(msg); if ('Notification' in window && Notification.permission === 'granted') { try { new Notification('Pip', { body: msg, icon: 'icon.svg' }); } catch(e) {} } }; /* Native file export: PDF/DOCX/TXT */ async function exportFile(content, filename, mimeType) { if (window.showSaveFilePicker) { try { const handle = await window.showSaveFilePicker({ suggestedName: filename, types: [{ description: 'Document', accept: { [mimeType]: ['.' + filename.split('.').pop()] } }] }); const writable = await handle.createWritable(); await writable.write(content); await writable.close(); toast('Saved: ' + filename); return; } catch (e) { /* User cancelled, fallback below */ } } const blob = new Blob([content], { type: mimeType }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); toast('Downloaded: ' + filename); } /* PWA Install Prompt */ let deferredPrompt; window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); deferredPrompt = e; console.log('[PWA] Install prompt ready'); }); function promptInstall() { if (deferredPrompt) { deferredPrompt.prompt(); deferredPrompt.userChoice.then((choice) => { if (choice.outcome === 'accepted') toast('Pip installed'); deferredPrompt = null; }); } else { toast('Install from browser menu: More -> Install app'); } }