/* 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 ? (
) : (
今日已觐见
)}
{state.confessions && state.confessions.length > 0 && (
历次悔过 · {state.confessions.length} · 共补回 {state.confessions.reduce((s, c) => s + (c.patched || 1), 0)} 日
{state.confessions.map((c, i) => (
-
{c.date}
补回 {c.patched || 1} 日
{c.text}
))}
)}
{rescueOpen && (
setRescueOpen(false)}>
e.stopPropagation()} style={{ maxWidth: 480 }}>
RESCUE · 悔过补签
爱卿请先呈悔过
爱卿曾连续觐见 {state.highWater} 日,现尚缺 {gapSize} 日未补。每写一段悔过之言(不少于 {RESCUE_MIN_CHARS} 字),可补回 1 日觐见纹。
本月补签余 {rescueRemaining} / {RESCUE_MONTHLY_QUOTA} 次 · 次月额度按日历重置 · 断得太久恐怕补不回来。
)}
>
);
}
Object.assign(window, {
LS, TAKEN_NAMES,
validateDouyinId, validateAuthorName, validatePassword, passwordStrength,
loadDrafts, saveDraft, deleteDraft, loadAutoDraft, saveAutoDraft, clearAutoDraft,
isLiked, isSaved, toggleLike, toggleSave, getSavedIds,
getNotifs, getNotifRead, markAllRead, markNotifRead,
loadAudience, doCheckIn, canCheckInToday, claimAudienceReward, canRescue, doRescue, getRescueRemainingThisMonth, AUDIENCE_TIERS, RESCUE_MIN_CHARS, RESCUE_MONTHLY_QUOTA, AudienceCard,
UI, PwStrength, DraftRecover, ShareModal, NotifBell, NotifCenter, GlobalSearch, ReadingProgress, PublicProfilePage,
});