/* global React */ /* bixia-mailbox · UserFeatures.jsx Shared client helpers: storage, validators, drafts, likes/saves, share modal, notifications, global search, reading progress, public profile, keyboard. All state persists via localStorage. */ const { useState: uU, useEffect: uUE, useRef: uUR, useMemo: uUM } = React; /* ---------- Storage helpers ---------- */ const LS = { get(k, fallback) { try { const v = localStorage.getItem(k); return v == null ? fallback : JSON.parse(v); } catch { return fallback; } }, set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch {} }, rm(k) { try { localStorage.removeItem(k); } catch {} }, }; /* ---------- Already-taken author names (kept only as legacy export, no longer used). Real uniqueness check is server-side — see backend/schemas/all.js registerBody. Listing client-side names was misleading: it rejected names that weren't taken on the server, and accepted ones that were. ---------- */ const TAKEN_NAMES = []; /* ---------- Validators ---------- */ function validateDouyinId(id) { if (!id || !id.trim()) return '抖音 ID 不可空'; if (!/^[a-zA-Z0-9_.-]{2,30}$/.test(id)) return '抖音 ID 只可含字母、数字、下划线、点、短横'; return ''; } function validateAuthorName(name) { if (!name || !name.trim()) return '尊号不可空'; if (name.length > 12) return '尊号不过 12 字'; // Uniqueness check happens server-side — see backend/src/routes/user/auth.js. return ''; } function validatePassword(pw) { if (!pw) return '口谕不可空'; // Match backend zod schema: min 8, max 128, must contain letter + digit. if (pw.length < 8 || pw.length > 128) return '口谕 8–128 位'; if (!/[a-zA-Z]/.test(pw) || !/[0-9]/.test(pw)) return '口谕须含字母和数字'; return ''; } function passwordStrength(pw) { if (!pw) return { level: 0, label: '' }; let s = 0; if (pw.length >= 8) s++; if (pw.length >= 12) s++; if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) s++; if (/[0-9]/.test(pw)) s++; if (/[^a-zA-Z0-9]/.test(pw)) s++; const lv = s <= 1 ? 1 : s <= 3 ? 2 : 3; return { level: lv, label: ['', '弱', '中', '强'][lv] }; } /* ---------- Draft system ---------- */ const DRAFT_KEY = 'bx_drafts'; const DRAFT_AUTO_KEY = 'bx_draft_auto'; function loadDrafts() { return LS.get(DRAFT_KEY, []); } function saveDraft(draft) { const list = loadDrafts(); const now = new Date(); const stamp = now.getFullYear() + '.' + String(now.getMonth()+1).padStart(2,'0') + '.' + String(now.getDate()).padStart(2,'0') + ' ' + String(now.getHours()).padStart(2,'0') + ':' + String(now.getMinutes()).padStart(2,'0'); if (draft.id) { const i = list.findIndex(d => d.id === draft.id); if (i >= 0) list[i] = { ...list[i], ...draft, updated: stamp }; } else { list.unshift({ id: 'd' + Date.now(), ...draft, updated: stamp }); } LS.set(DRAFT_KEY, list); return list; } function deleteDraft(id) { LS.set(DRAFT_KEY, loadDrafts().filter(d => d.id !== id)); } function loadAutoDraft() { return LS.get(DRAFT_AUTO_KEY, null); } function saveAutoDraft(draft) { LS.set(DRAFT_AUTO_KEY, { ...draft, updated: Date.now() }); } function clearAutoDraft() { LS.rm(DRAFT_AUTO_KEY); } /* ---------- Daily check-in (每日觐见) ---------- */ const AUDIENCE_KEY = 'bx_audience'; // { streak, highWater, lastDate, totalDays, claimed, confessions, rescueMonth, rescueUsedThisMonth } const AUDIENCE_TIERS = [ { days: 3, name: '觐见纹 × 3', desc: '可入功勋分,爱卿榜上爬升 3 点', kind: 'merit' }, { days: 7, name: '陛下优先翻阅 × 1', desc: '下次投稿可勾选「优先批阅」,进朕的优先池', kind: 'priority' }, { days: 88, name: '陛下御赐实物 · 一份', desc: '朕将亲笔题字一帧,遣人寄至爱卿府上(需填地址)', kind: 'gift' }, ]; const RESCUE_MIN_CHARS = 30; const RESCUE_MONTHLY_QUOTA = 10; function ymd(d) { const t = d || new Date(); return t.getFullYear() + '-' + String(t.getMonth()+1).padStart(2,'0') + '-' + String(t.getDate()).padStart(2,'0'); } function ym(d) { const t = d || new Date(); return t.getFullYear() + '-' + String(t.getMonth()+1).padStart(2,'0'); } function audienceToday() { return ymd(new Date()); } function audienceThisMonth() { return ym(new Date()); } function loadAudience() { const d = LS.get(AUDIENCE_KEY, null); if (!d) return { streak: 0, highWater: 0, lastDate: '', totalDays: 0, claimed: [], confessions: [], rescueMonth: '', rescueUsedThisMonth: 0 }; return { highWater: 0, claimed: [], confessions: [], rescueMonth: '', rescueUsedThisMonth: 0, ...d }; } function getRescueRemainingThisMonth() { const d = loadAudience(); if (d.rescueMonth !== audienceThisMonth()) return RESCUE_MONTHLY_QUOTA; return Math.max(0, RESCUE_MONTHLY_QUOTA - (d.rescueUsedThisMonth || 0)); } function canCheckInToday() { return loadAudience().lastDate !== audienceToday(); } function doCheckIn() { const today = audienceToday(); const d = loadAudience(); if (d.lastDate === today) return d; const yest = new Date(); yest.setDate(yest.getDate() - 1); const isConsecutive = d.lastDate === ymd(yest); const newStreak = isConsecutive ? d.streak + 1 : 1; const next = { ...d, streak: newStreak, highWater: Math.max(d.highWater || 0, newStreak), lastDate: today, totalDays: (d.totalDays || 0) + 1, }; LS.set(AUDIENCE_KEY, next); return next; } function claimAudienceReward(days) { const d = loadAudience(); if (d.streak < days) return null; if ((d.claimed || []).includes(days)) return null; const next = { ...d, claimed: [...(d.claimed || []), days] }; LS.set(AUDIENCE_KEY, next); return next; } function nextAudienceTier(streak) { return AUDIENCE_TIERS.find(t => streak < t.days) || AUDIENCE_TIERS[AUDIENCE_TIERS.length - 1]; } // rescue: each use patches ONE missed day (streak += 1, capped at highWater) // requires user already checked in today, has a gap to recover, and monthly quota remains function canRescue() { const d = loadAudience(); const checkedToday = d.lastDate === audienceToday(); const hasGapToRecover = (d.highWater || 0) > d.streak; return checkedToday && hasGapToRecover && getRescueRemainingThisMonth() > 0; } function doRescue(confession) { const text = (confession || '').trim(); if (text.length < RESCUE_MIN_CHARS) return { ok: false, reason: `悔过需至少 ${RESCUE_MIN_CHARS} 字` }; const d = loadAudience(); if (d.lastDate !== audienceToday()) return { ok: false, reason: '请先「觐见陛下」,再补签' }; if ((d.highWater || 0) <= d.streak) return { ok: false, reason: '当前无可复原的觐见纹' }; const thisMonth = audienceThisMonth(); const usedThisMonth = d.rescueMonth === thisMonth ? (d.rescueUsedThisMonth || 0) : 0; if (usedThisMonth >= RESCUE_MONTHLY_QUOTA) return { ok: false, reason: `本月补签额度已用尽(${RESCUE_MONTHLY_QUOTA}次/月,次月重置)` }; const newStreak = Math.min(d.streak + 1, d.highWater); const next = { ...d, streak: newStreak, rescueMonth: thisMonth, rescueUsedThisMonth: usedThisMonth + 1, confessions: [...(d.confessions || []), { date: audienceToday(), text, patched: 1 }], }; LS.set(AUDIENCE_KEY, next); return { ok: true, state: next, remaining: RESCUE_MONTHLY_QUOTA - next.rescueUsedThisMonth }; } /* ---------- Likes / saves ---------- */ const LIKES_KEY = 'bx_likes'; const SAVES_KEY = 'bx_saves'; function isLiked(id) { return (LS.get(LIKES_KEY, [])).includes(id); } function isSaved(id) { return (LS.get(SAVES_KEY, [])).includes(id); } function toggleLike(id) { const set = new Set(LS.get(LIKES_KEY, [])); set.has(id) ? set.delete(id) : set.add(id); LS.set(LIKES_KEY, [...set]); return set.has(id); } function toggleSave(id) { const set = new Set(LS.get(SAVES_KEY, [])); set.has(id) ? set.delete(id) : set.add(id); LS.set(SAVES_KEY, [...set]); return set.has(id); } function getSavedIds() { return LS.get(SAVES_KEY, []); } /* ---------- Notifications ---------- */ const NOTIF_KEY = 'bx_notifs'; const NOTIF_READ_KEY = 'bx_notifs_read'; const SAMPLE_NOTIFS = [ { id: 'n1', type: 'read', title: '你的奏折被陛下翻牌了', msg: '#1287 · 「朕的爱人走了,何以自处」于 2026.04.18 朝会宣读。', time: '2 时前' }, { id: 'n2', type: 'rank', title: '封号晋升', msg: '爱卿晋封为「妃」 · 再投 58 封可晋「肱股之臣」。', time: '昨日' }, { id: 'n3', type: 'video', title: '奏折已成永恒', msg: '#1284 已剪辑成视频,流传于抖音。', time: '3 日前' }, { id: 'n4', type: 'notice', title: '朕的公告', msg: '本周六 21:00 将举办「睡前电台」特场,预读 12 封奏折。', time: '5 日前' }, ]; function getNotifs() { return LS.get(NOTIF_KEY, SAMPLE_NOTIFS); } function getNotifRead() { return new Set(LS.get(NOTIF_READ_KEY, [])); } function markAllRead() { LS.set(NOTIF_READ_KEY, getNotifs().map(n => n.id)); } function markNotifRead(id) { const r = getNotifRead(); r.add(id); LS.set(NOTIF_READ_KEY, [...r]); } /* ---------- Icon set ---------- */ const UI = { heart: , heartOn: , bookmark: , bookmarkOn: , share: , bell: , search: , close: , check: , link: , image: , qr: , }; /* ========================================================== Password strength meter (for Forms) ========================================================== */ function PwStrength({ pw }) { const s = passwordStrength(pw); if (!pw) return null; return (
{[1,2,3].map(i => ( ))}
强度 · {s.label}
); } /* ========================================================== Draft recovery prompt ========================================================== */ function DraftRecover({ draft, onRestore, onDiscard }) { if (!draft) return null; return (
📜

爱卿上次有一封未竟之奏

「{(draft.title || draft.body || '').slice(0, 40) || '(未命名)'}」 · 可继续书写或舍弃

); } /* ========================================================== Share modal ========================================================== */ function ShareModal({ open, letter, onClose }) { const [tab, setTab] = uU('image'); const canvasRef = uUR(null); // Layout helper: measures text into lines that fit maxW; returns { lines, truncated } function layoutLines(ctx, text, maxW, maxLines) { const chars = (text || '').split(''); const lines = []; let line = ''; for (const ch of chars) { if (ch === '\n') { if (line) lines.push(line); line = ''; if (lines.length >= maxLines) break; continue; } const test = line + ch; if (ctx.measureText(test).width > maxW && line) { lines.push(line); line = ch; if (lines.length >= maxLines) break; } else line = test; } if (line && lines.length < maxLines) lines.push(line); const usedChars = lines.join('').replace(/\n/g, '').length; const truncated = usedChars < chars.filter(c => c !== '\n').length; // Replace last line with ellipsis version if truncated if (truncated && lines.length > 0) { const last = lines[lines.length - 1]; let trimmed = last; while (ctx.measureText(trimmed + ' ……').width > maxW && trimmed.length > 0) { trimmed = trimmed.slice(0, -1); } lines[lines.length - 1] = trimmed + ' ……'; } return { lines, truncated }; } function drawLines(ctx, lines, x, y, lineH) { lines.forEach((l, i) => ctx.fillText(l, x, y + i * lineH)); return y + lines.length * lineH; } uUE(() => { if (!open || tab !== 'image' || !letter) return; const c = canvasRef.current; if (!c) return; const ctx = c.getContext('2d'); const W = c.width, H = c.height; // ---- 1. Background ---- const grad = ctx.createLinearGradient(0, 0, 0, H); grad.addColorStop(0, '#F7F0E0'); grad.addColorStop(1, '#ECE0C8'); ctx.fillStyle = grad; ctx.fillRect(0, 0, W, H); ctx.strokeStyle = '#8B6B3A'; ctx.lineWidth = 2; ctx.strokeRect(24, 24, W - 48, H - 48); ctx.lineWidth = 0.8; ctx.strokeRect(32, 32, W - 64, H - 64); // ---- 2. Header ---- ctx.fillStyle = '#8B6B3A'; ctx.font = '12px system-ui'; ctx.textAlign = 'center'; ctx.fillText('陛 下 信 箱 · BIXIA MAILBOX', W / 2, 68); ctx.fillText('· · ·', W / 2, 90); // ---- 3. Layout zones ---- const padX = 64; const maxW = W - padX * 2; const FOOTER_H = 120; // reserved for footer + seal const REPLY_MIN = letter.reply ? 120 : 0; const BODY_START_Y = 130; const BODY_AVAILABLE_BOTTOM = H - FOOTER_H - REPLY_MIN - 20; // ---- 4. Title: up to 2 lines ---- ctx.fillStyle = '#2A2317'; ctx.font = '600 28px "Noto Serif SC", serif'; ctx.textAlign = 'center'; const titleLayout = layoutLines(ctx, letter.title || '无题', maxW - 40, 2); let y = BODY_START_Y; titleLayout.lines.forEach((l, i) => ctx.fillText(l, W / 2, y + i * 36)); y += titleLayout.lines.length * 36 + 14; // ---- 5. Byline ---- ctx.fillStyle = '#8B6B3A'; ctx.font = '12px system-ui'; ctx.fillText((letter.anonymous ? '某位爱卿' : letter.author || '') + (letter.date ? ' · ' + letter.date : ''), W / 2, y); y += 28; // ---- 6. Body ---- ctx.fillStyle = '#40392D'; ctx.font = '16px "Noto Serif SC", serif'; ctx.textAlign = 'left'; const bodyLineH = 28; const bodyMaxLines = Math.max(3, Math.floor((BODY_AVAILABLE_BOTTOM - y) / bodyLineH)); const bodyLayout = layoutLines(ctx, letter.body || letter.excerpt || '', maxW, bodyMaxLines); y = drawLines(ctx, bodyLayout.lines, padX, y + 6, bodyLineH); // ---- 7. Reply (if exists) ---- if (letter.reply) { y += 18; ctx.fillStyle = '#8B6B3A'; ctx.font = '11px system-ui'; ctx.textAlign = 'left'; ctx.fillText('朕 曰', padX, y); ctx.strokeStyle = '#C9A25F'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(padX, y + 8); ctx.lineTo(W - padX, y + 8); ctx.stroke(); y += 26; ctx.fillStyle = '#2A2317'; ctx.font = 'italic 15px "Noto Serif SC", serif'; const replyLayout = layoutLines(ctx, letter.reply, maxW, 3); y = drawLines(ctx, replyLayout.lines, padX, y, 24); } // ---- 8. "Full version at link" banner if truncated ---- if (bodyLayout.truncated) { ctx.fillStyle = 'rgba(139, 107, 58, 0.08)'; ctx.fillRect(padX - 8, H - FOOTER_H - 44, maxW + 16, 32); ctx.fillStyle = '#8B6B3A'; ctx.font = '11px system-ui'; ctx.textAlign = 'center'; ctx.fillText('此奏较长,节选于此 · 完整奏折请访问 bixia.letters/#' + (letter.id || ''), W / 2, H - FOOTER_H - 24); } // ---- 9. Footer ---- ctx.fillStyle = '#8B6B3A'; ctx.font = '12px system-ui'; ctx.textAlign = 'center'; ctx.fillText('#' + (letter.id || '0000') + ' · 抖音 @陛下 · bixia.letters', W / 2, H - 58); // ---- 10. Seal stamp ---- ctx.fillStyle = '#A63F2E'; ctx.fillRect(W - 110, H - 120, 52, 52); ctx.fillStyle = '#F7F0E0'; ctx.font = '600 11px system-ui'; ctx.textAlign = 'center'; ctx.fillText('御', W - 84, H - 99); ctx.fillText('批', W - 84, H - 84); }, [open, tab, letter]); function download() { const c = canvasRef.current; if (!c) return; const a = document.createElement('a'); a.download = '陛下信箱-' + (letter.id || '') + '.png'; a.href = c.toDataURL('image/png'); a.click(); window.showToast && window.showToast({ type: 'success', msg: '分享图已下载' }); } function copyLink() { const url = location.origin + location.pathname + '#letter=' + letter.id; const ta = document.createElement('textarea'); ta.value = url; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); } catch {} ta.remove(); if (navigator.clipboard) navigator.clipboard.writeText(url).catch(() => {}); window.showToast && window.showToast({ type: 'success', msg: '链接已复制 · 可分享至任意处' }); } if (!open || !letter) return null; const tabs = [['image','生成分享图', UI.image], ['link','复制链接', UI.link], ['qr','微信二维码', UI.qr]]; return (
e.stopPropagation()}> SHARE · 分享此奏折

将此封奏折传于他人

{tabs.map(t => ( ))}
{tab === 'image' && (
{(letter.body || '').length > 260 && (

此奏较长(约 {(letter.body || '').length} 字),分享图将自动节选;完整内容建议用「复制链接」分享。

)}
)} {tab === 'link' && (

复制后,可粘贴至任意聊天或朋友圈。

{location.origin + location.pathname}#letter={letter.id}
)} {tab === 'qr' && (
{/* Faux QR grid */} {Array.from({ length: 441 }).map((_, i) => ( ))}
御

微信扫一扫,可分享此奏折至微信好友 / 朋友圈。

)}
); } /* ========================================================== Notification bell + center ========================================================== */ function NotifBell({ onClick, count }) { return ( ); } function NotifCenter({ open, onClose, onMarkAll, onOpenLetter }) { const [items, setItems] = uU(null); uUE(() => { if (!open) return; if (window.bxApi) { window.bxApi.notifications(false) .then(r => setItems(r.items.map(n => ({ id: n.id, type: n.type, title: n.title, msg: n.message, time: relativeTime(n.created_at), letter_id: n.letter_id, read: !!n.read_at, })))) .catch(() => setItems(null)); } else { const read = getNotifRead(); setItems(getNotifs().map(n => ({ ...n, read: read.has(n.id) }))); } }, [open]); if (!open) return null; async function onItemClick(n) { try { if (window.bxApi && !n.read) await window.bxApi.markNotifRead(n.id); else if (!window.bxApi) markNotifRead(n.id); } catch {} // Optimistically flip the local read state and notify parent to refresh badge setItems(xs => xs ? xs.map(x => x.id === n.id ? { ...x, read: true } : x) : xs); window.dispatchEvent(new CustomEvent('bx:notif_changed')); if (n.letter_id && onOpenLetter) { onOpenLetter(n.letter_id); onClose(); return; } onClose(); } const list = items || []; return ( <>

通知 · 朕的小秘书

{items === null &&
稍候 · 待朕过目……
} {items && list.length === 0 &&
暂无新消息
} {list.map(n => (
onItemClick(n)}>
{n.title}

{n.msg}

{n.time}
))}
); } // Render "N 分钟前 / N 时前 / N 日前" from an ISO timestamp function relativeTime(iso) { if (!iso) return ''; const d = new Date(iso); const diffMin = Math.max(0, Math.floor((Date.now() - d.getTime()) / 60000)); if (diffMin < 1) return '刚刚'; if (diffMin < 60) return diffMin + ' 分钟前'; if (diffMin < 1440) return Math.floor(diffMin / 60) + ' 时前'; const days = Math.floor(diffMin / 1440); if (days < 30) return days + ' 日前'; return d.getFullYear() + '.' + String(d.getMonth() + 1).padStart(2, '0') + '.' + String(d.getDate()).padStart(2, '0'); } /* ========================================================== Global search ========================================================== */ function GlobalSearch({ open, letters, onClose, onOpenLetter, onNav, onOpenProfile }) { const [q, setQ] = uU(''); const [apiHits, setApiHits] = uU(null); const refIn = uUR(null); uUE(() => { if (open) setTimeout(() => refIn.current && refIn.current.focus(), 50); }, [open]); uUE(() => { if (!open || !window.bxApi) return; const s = q.trim(); if (!s) { setApiHits(null); return; } const handle = setTimeout(() => { window.bxApi.search(s).then(r => setApiHits(r.items || [])).catch(() => setApiHits(null)); }, 180); return () => clearTimeout(handle); }, [q, open]); const matched = uUM(() => { const s = q.trim(); if (!s) return []; const hits = []; const num = parseInt(s); if (!isNaN(num) && num > 0) hits.push({ kind: '按编号查询', label: '查询 #' + num + ' 进度', go: () => { onNav('track', num); onClose(); } }); if (apiHits) { for (const h of apiHits) { if (h.kind === 'letter') { hits.push({ kind: '奏折 #' + h.id, label: h.label || h.title, go: () => { onOpenLetter(h.id); onClose(); } }); } else if (h.kind === 'user') { hits.push({ kind: '爱卿 · ' + h.rank, label: h.name, go: () => { if (onOpenProfile) onOpenProfile(h.name); else onNav('public', null); onClose(); }, }); } if (hits.length > 12) break; } return hits; } // Offline fallback to local letters for (const L of letters || []) { const hay = (L.title + (L.body || '') + (L.reply || '') + L.author + L.category).toLowerCase(); if (hay.includes(s.toLowerCase()) || String(L.id) === s) { hits.push({ kind: '奏折 #' + L.id, label: L.title + ' · ' + (L.anonymous ? '某位爱卿' : L.author), go: () => { onOpenLetter(L.id); onClose(); } }); } if (hits.length > 10) break; } return hits; }, [q, letters, apiHits]); if (!open) return null; return (
e.stopPropagation()}>
{UI.search} setQ(e.target.value)} placeholder="搜索奏折标题、内容、编号,或爱卿尊号……" /> Esc
{!q &&

输入关键字 · 标题 / 正文 / 编号 / 爱卿 皆可

} {q && matched.length === 0 &&

无结果 · 换个词试试

} {matched.map((m, i) => (
{m.label} {m.kind}
))}
); } /* ========================================================== Reading progress bar ========================================================== */ function ReadingProgress({ target }) { const [pct, setPct] = uU(0); uUE(() => { function onScroll() { const el = target && target.current; const h = document.documentElement; const max = (el ? el.scrollHeight : h.scrollHeight) - window.innerHeight; const top = window.scrollY; setPct(max > 0 ? Math.min(100, Math.max(0, (top / max) * 100)) : 0); } window.addEventListener('scroll', onScroll, { passive: true }); onScroll(); return () => window.removeEventListener('scroll', onScroll); }, [target]); return
; } /* ========================================================== Public user profile page (privacy-safe) ========================================================== */ function PublicProfilePage({ name, letters, onOpenLetter, onBack }) { const [data, setData] = uU(null); uUE(() => { if (!window.bxApi) return; window.bxApi.publicProfile(name).then(setData).catch(() => setData(null)); }, [name]); // Use API data when available; otherwise fall back to local letters array const stats = data?.stats || {}; const userLetters = data ? (data.letters || []).map(L => ({ ...L, author: data.name, anonymous: false })) : (letters || []).filter(L => L.author === name); const seal = data?.rank || (userLetters.length > 20 ? '肱股之臣' : userLetters.length > 10 ? '妃' : userLetters.length > 5 ? '贵人' : '常在'); const joined = data?.joined_at ? data.joined_at.slice(0, 10).replace(/-/g, '.') : '2026.01.08'; const submitted = stats.submitted ?? Math.max(userLetters.length, 12); const readCount = stats.read ?? Math.max(Math.floor(userLetters.length / 2), 3); const praised = stats.praised ?? 0; return (
{seal}

{name}

入宫于 {joined} · 已投 {submitted} 封 · 被翻牌 {readCount} 次{praised ? ` · 陛下赏 ${praised} 次` : ''}

{submitted}投稿数
{readCount}被宣读
{seal}当前封号

此爱卿的公开奏折

{userLetters.length === 0 &&

此爱卿尚未公开任何奏折。

} {userLetters.map(L => onOpenLetter(L.id)}/>)}
); } /* ---------- Audience (每日觐见) card ---------- */ // Normalise API shape ({streak, high_water, last_checkin_date, rescue_remaining_this_month, tiers, confessions}) // into the localStorage-style shape used inside this component. function normaliseApiAudience(api) { return { streak: api.streak || 0, highWater: api.high_water || 0, lastDate: api.last_checkin_date || '', totalDays: api.total_audience_days || 0, claimed: (api.tiers || []).filter(t => t.claimed).map(t => t.days), confessions: (api.confessions || []).map(c => ({ date: c.date, text: c.text, patched: c.patched || 1 })), rescueRemainingApi: api.rescue_remaining_this_month, }; } function AudienceCard({ compact }) { const hasApi = !!window.bxApi; const [state, setState] = uU(hasApi ? { streak: 0, highWater: 0, lastDate: '', claimed: [], confessions: [], rescueRemainingApi: RESCUE_MONTHLY_QUOTA } : loadAudience()); const [rescueOpen, setRescueOpen] = uU(false); const [confession, setConfession] = uU(''); const [loaded, setLoaded] = uU(!hasApi); // Load initial audience state from API when online uUE(() => { if (!hasApi) return; window.bxApi.audience().then(api => { setState(normaliseApiAudience(api)); setLoaded(true); }) .catch(() => { setState(loadAudience()); setLoaded(true); }); }, []); const today = audienceToday(); const checkedToday = state.lastDate === today; const tier = nextAudienceTier(state.streak); const pct = Math.min(100, Math.round((state.streak / tier.days) * 100)); const rescueRemaining = hasApi && state.rescueRemainingApi !== undefined ? state.rescueRemainingApi : getRescueRemainingThisMonth(); const hasGap = (state.highWater || 0) > state.streak; const gapSize = Math.max(0, (state.highWater || 0) - state.streak); const rescueEligible = checkedToday && hasGap && rescueRemaining > 0; async function onCheckIn() { if (hasApi) { try { const next = await window.bxApi.checkIn(); setState(s => ({ ...s, streak: next.streak, highWater: next.high_water, lastDate: next.last_checkin_date, rescueRemainingApi: next.rescue_remaining_this_month })); window.showToast && window.showToast({ type: 'success', msg: `觐见成功 · 连续 ${next.streak} 日` }); return; } catch (e) { if (e.status !== 401) window.showToast && window.showToast({ type: 'error', msg: e.message }); return; } } const next = doCheckIn(); setState(next); window.showToast && window.showToast({ type: 'success', msg: `觐见成功 · 连续 ${next.streak} 日` }); } async function onClaim(days) { if (hasApi) { try { await window.bxApi.claimTier({ tier_days: days }); const api = await window.bxApi.audience(); setState(normaliseApiAudience(api)); const t = AUDIENCE_TIERS.find(x => x.days === days); window.showToast && window.showToast({ type: 'success', msg: `领取成功 · ${t.name}` }); return; } catch (e) { if (e.status !== 401) window.showToast && window.showToast({ type: 'error', msg: e.message }); return; } } const next = claimAudienceReward(days); if (!next) { window.showToast && window.showToast({ type: 'warning', msg: '尚未达到或已领取' }); return; } setState(next); const tier = AUDIENCE_TIERS.find(t => t.days === days); window.showToast && window.showToast({ type: 'success', msg: `领取成功 · ${tier.name}` }); } async function onRescueSubmit() { if (hasApi) { try { const next = await window.bxApi.rescue(confession); setState(s => ({ ...s, streak: next.streak, highWater: next.high_water, rescueRemainingApi: next.rescue_remaining_this_month, confessions: [{ date: audienceToday(), text: confession, patched: 1 }, ...(s.confessions || [])], })); setRescueOpen(false); setConfession(''); window.showToast && window.showToast({ type: 'success', msg: `悔过已呈 · 补回一日 · 现连觐 ${next.streak} 日 · 本月补签余 ${next.rescue_remaining_this_month} 次` }); return; } catch (e) { if (e.status !== 401) window.showToast && window.showToast({ type: 'error', msg: e.message }); return; } } const r = doRescue(confession); if (!r.ok) { window.showToast && window.showToast({ type: 'error', msg: r.reason }); return; } setState(r.state); setRescueOpen(false); setConfession(''); window.showToast && window.showToast({ type: 'success', msg: `悔过已呈 · 补回一日 · 现连觐 ${r.state.streak} 日 · 本月补签余 ${r.remaining} 次` }); } return ( <>
AUDIENCE · 每日觐见

{checkedToday ? <>今日已觐见 · 连续 {state.streak} 日 : '今日尚未觐见陛下'}

{rescueEligible ? <>曾连觐 {state.highWater} 日 · 尚缺 {gapSize} 日未补 · 本月余 {rescueRemaining} / {RESCUE_MONTHLY_QUOTA} 次 · 每次 1 日 : hasGap && !checkedToday ? <>曾连觐 {state.highWater} 日,需先觐见今日,方可补签昨日 : hasGap && rescueRemaining === 0 ? <>尚缺 {gapSize} 日未补,本月额度已尽(每月 {RESCUE_MONTHLY_QUOTA} 次,次月重置) : checkedToday ? <>距下一档「{tier.name}」还差 {Math.max(0, tier.days - state.streak)} 日 : '点"觐见"以续连签,断签觐见纹将清零(已领奖励保留)'}

{rescueEligible && ( )} {!checkedToday ? ( ) : ( 今日已觐见 )}
    {AUDIENCE_TIERS.map(t => { const reached = state.streak >= t.days; const claimed = (state.claimed || []).includes(t.days); return (
  • {t.days} 日
    {t.name}
    {t.desc}
    {claimed && 已领} {!claimed && reached && } {!reached && 待解锁}
  • ); })}
{state.confessions && state.confessions.length > 0 && (
历次悔过 · {state.confessions.length} · 共补回 {state.confessions.reduce((s, c) => s + (c.patched || 1), 0)} 日
    {state.confessions.map((c, i) => (
  1. {c.date} 补回 {c.patched || 1} 日

    {c.text}

  2. ))}
)}
{rescueOpen && (
setRescueOpen(false)}>
e.stopPropagation()} style={{ maxWidth: 480 }}> RESCUE · 悔过补签

爱卿请先呈悔过

爱卿曾连续觐见 {state.highWater} 日,现尚缺 {gapSize} 日未补。每写一段悔过之言(不少于 {RESCUE_MIN_CHARS} 字),可补回 1 日觐见纹。
本月补签余 {rescueRemaining} / {RESCUE_MONTHLY_QUOTA} 次 · 次月额度按日历重置 · 断得太久恐怕补不回来。