/* global React, Button, SealIcon, SensitiveNotice,
loadDrafts, saveDraft, deleteDraft, loadAutoDraft, saveAutoDraft, clearAutoDraft, DraftRecover */
const { useState: useStateForm, useEffect: useEffForm } = React;
function LoginModal({ open, onClose, onSubmit, onRules }) {
const [busy, setBusy] = useStateForm(null); // 'douyin' | 'wechat' | null
const [authModes, setAuthModes] = useStateForm({ password: false, oauth: ['douyin', 'wechat'] });
// legacy password fallback (only used when backend explicitly enables it for dev/staging)
const [legacyOpen, setLegacyOpen] = useStateForm(false);
const [legacyId, setLegacyId] = useStateForm('');
const [legacyPw, setLegacyPw] = useStateForm('');
const [legacyErr, setLegacyErr] = useStateForm('');
const [legacyBusy, setLegacyBusy] = useStateForm(false);
// Probe backend capabilities once when modal opens
useEffForm(() => {
if (!open || !window.bxApi) return;
fetch((window.BX_API_BASE || '') + '/api/user/auth_modes', { credentials: 'include' })
.then(r => r.json()).then(setAuthModes).catch(() => {});
setBusy(null); setLegacyOpen(false); setLegacyErr('');
}, [open]);
async function startOAuth(provider) {
if (!window.bxApi) {
window.showToast && window.showToast({ type: 'warning', msg: '第三方登录需在云端环境使用' });
return;
}
setBusy(provider);
try {
const r = await window.bxApi.oauthStart(provider, 'login');
// Full page redirect to the provider's authorize URL. The provider page
// displays the QR code (desktop) or deep-links into its app (mobile).
// After grant, provider redirects to /api/user/oauth/:provider/callback,
// which sets a session cookie and 302's back to /mailbox/.
window.location.href = r.authorize_url;
} catch (e) {
setBusy(null);
if (e.error === 'provider_not_configured') {
window.showToast && window.showToast({ type: 'warning', msg: '此授权尚未配置,请联系陛下管理员' });
} else {
window.showToast && window.showToast({ type: 'error', msg: (e && e.message) || '授权请求失败' });
}
}
}
async function legacyLogin() {
if (legacyBusy) return;
if (!legacyId.trim() || !legacyPw) { setLegacyErr('两栏皆不可空'); return; }
setLegacyBusy(true); setLegacyErr('');
try {
const u = await window.bxApi.login({ douyin_id: legacyId.trim(), password: legacyPw });
onSubmit(u);
} catch (e) {
setLegacyErr((e && e.message) || '登录失败');
} finally {
setLegacyBusy(false);
}
}
if (!open) return null;
const oauthList = (authModes.oauth || []);
const douyinAvail = oauthList.includes('douyin');
const wechatAvail = oauthList.includes('wechat');
return (
e.stopPropagation()}>
登入朝堂
本站只支持抖音扫码登录 · 朕通过抖音认你身份,无需另设口谕。
电脑访问 → 点下面按钮跳到抖音二维码页 → 用抖音 App 扫码
手机访问 → 点下面按钮自动跳到抖音 App 完成授权
{(douyinAvail || !window.bxApi) ? (
) : (
抖音登录尚在配置中。请联系陛下管理员或稍后再试。
)}
{wechatAvail && (
)}
{/* Legacy password form — only when backend explicitly enables PASSWORD_AUTH. */}
{authModes.password && (
{!legacyOpen ? (
) : (
)}
)}
);
}
function ComposeForm({ onSubmit, draftToEdit }) {
const [title, setTitle] = useStateForm(draftToEdit?.title || '');
const [body, setBody] = useStateForm(draftToEdit?.body || '');
const [cat, setCat] = useStateForm(draftToEdit?.cat || '情感困扰');
const [named, setNamed] = useStateForm(draftToEdit?.named || 'real');
const [clip, setClip] = useStateForm(draftToEdit?.clip || 'yes');
const [errs, setErrs] = useStateForm({});
const [recovered, setRecovered] = useStateForm(null);
const [draftId, setDraftId] = useStateForm(draftToEdit?.id || null);
const [images, setImages] = useStateForm(draftToEdit?.images || []); // [{id, url, name, size}]
const [submitting, setSubmitting] = useStateForm(false); // double-click guard
const [savingDraft, setSavingDraft] = useStateForm(false);
const [sens, setSens] = useStateForm({ blocked: [], warnings: [] }); // 违规词检测结果
const fileInputRef = React.useRef(null);
const checkTimer = React.useRef(null);
const imagesRef = React.useRef(images);
imagesRef.current = images; // 始终指向最新 images,供卸载时清理用
const MAX_IMAGES = 6;
const MAX_IMAGE_MB = 5;
// 组件卸载时(用户中途离开/不提交)兜底释放所有 blob: 预览 URL,避免内存泄漏。
useEffForm(() => () => {
imagesRef.current.forEach(img => {
if (img.url && img.url.startsWith('blob:')) URL.revokeObjectURL(img.url);
});
}, []);
function handleImageFiles(files) {
const incoming = Array.from(files || []);
if (incoming.length === 0) return;
const room = MAX_IMAGES - images.length;
if (room <= 0) {
window.showToast && window.showToast({ type: 'warning', msg: `至多 ${MAX_IMAGES} 张图,不可再添` });
return;
}
const picked = incoming.slice(0, room);
const added = [];
picked.forEach(f => {
if (!f.type.startsWith('image/')) {
window.showToast && window.showToast({ type: 'error', msg: `「${f.name}」非图片,朕不收` });
return;
}
if (f.size > MAX_IMAGE_MB * 1024 * 1024) {
window.showToast && window.showToast({ type: 'error', msg: `「${f.name}」超过 ${MAX_IMAGE_MB}MB` });
return;
}
added.push({ id: Date.now() + Math.random(), url: URL.createObjectURL(f), name: f.name, size: f.size, file: f });
});
if (added.length > 0) setImages(x => [...x, ...added]);
if (incoming.length > room) window.showToast && window.showToast({ type: 'warning', msg: `只收了前 ${room} 张,余者请删一些再上` });
}
function removeImage(id) {
setImages(x => {
const gone = x.find(i => i.id === id);
if (gone && gone.url && gone.url.startsWith('blob:')) URL.revokeObjectURL(gone.url);
return x.filter(i => i.id !== id);
});
}
function onDrop(e) { e.preventDefault(); handleImageFiles(e.dataTransfer.files); }
function onDragOver(e) { e.preventDefault(); }
// Draft recovery on mount — only if not already editing a draft
useEffForm(() => {
if (draftToEdit) return;
const auto = loadAutoDraft();
if (auto && (auto.title || auto.body) && (auto.body || '').length > 10) {
setRecovered(auto);
}
}, []);
// Auto-save every 30s
useEffForm(() => {
const t = setInterval(() => {
if (title.trim() || body.trim()) saveAutoDraft({ title, body, cat, named, clip });
}, 30000);
return () => clearInterval(t);
}, [title, body, cat, named, clip]);
// 违规词实时检测:停手 400ms 后查一次(防抖)。命中 block 词则禁止呈递。
useEffForm(() => {
if (!window.bxApi) return; // 离线 mock 模式不查
const text = (title + '\n' + body).trim();
if (!text) { setSens({ blocked: [], warnings: [] }); return; }
clearTimeout(checkTimer.current);
checkTimer.current = setTimeout(() => {
window.bxApi.checkContent(text)
.then(r => setSens({ blocked: r.blocked || [], warnings: r.warnings || [] }))
.catch(() => {}); // 网络/401 静默,不打扰打字
}, 400);
return () => clearTimeout(checkTimer.current);
}, [title, body]);
function restore() { setTitle(recovered.title || ''); setBody(recovered.body || ''); setCat(recovered.cat || '情感困扰'); setRecovered(null); }
function discardRecovered() { clearAutoDraft(); setRecovered(null); }
function validate() {
const e = {};
if (!title.trim()) e.title = '题目不可空';
else if (title.length > 30) e.title = '题目不过 30 字';
if (!body.trim()) e.body = '正文不可空';
else if (body.trim().length < 20) e.body = '正文至少 20 字 · 朕需略知全貌';
setErrs(e);
return Object.keys(e).length === 0;
}
async function doSubmit() {
if (submitting) return; // double-click guard
if (!validate()) { window.showToast && window.showToast({ type: 'error', msg: '奏折有漏,请爱卿检查' }); return; }
if (sens.blocked.length) { // 违规词未改,不放行
window.showToast && window.showToast({ type: 'error', msg: '奏折含违规词,请先修改后再呈' });
return;
}
setSubmitting(true);
try {
if (window.bxApi) {
try {
const res = await window.bxApi.submitLetter({
title, body, category: cat,
anonymous: named === 'anon',
allow_clip: clip === 'yes',
use_priority_credit: false,
});
// Upload images sequentially after we have a letter id
for (const img of images) {
if (img.file) {
try { await window.bxApi.uploadImage(res.id, img.file); }
catch (e) { window.showToast && window.showToast({ type: 'warning', msg: `图片 ${img.name} 上传失败: ${e.message}` }); }
}
}
// Delete the draft we just submitted — from the correct store
if (draftId) {
if (typeof draftId === 'number' && window.bxApi) {
try { await window.bxApi.deleteDraft(draftId); } catch {}
} else {
deleteDraft(draftId);
}
}
// Free blob: URL previews to avoid memory leak.
for (const img of images) { if (img.url && img.url.startsWith('blob:')) URL.revokeObjectURL(img.url); }
clearAutoDraft();
onSubmit({ id: res.id });
return;
} catch (err) {
// 后端违规词闸(绕过前端直发也会被拦)。把命中词回填,标红正文。
if (err && err.error === 'sensitive_content') {
if (err.details) setSens({ blocked: err.details.blocked || [], warnings: err.details.warnings || [] });
if (err.fields) setErrs(err.fields);
window.showToast && window.showToast({ type: 'error', msg: '奏折含违规词,请修改后再呈' });
return;
}
if (err && err.fields) { setErrs(err.fields); return; }
window.showToast && window.showToast({ type: 'error', msg: (err && err.message) || '呈递失败,请稍后再试' });
return;
}
}
// offline fallback
if (draftId) deleteDraft(draftId);
clearAutoDraft();
onSubmit({ title, body, cat, images });
} finally {
setSubmitting(false);
}
}
async function doSaveDraft() {
if (savingDraft) return;
if (!title.trim() && !body.trim() && images.length === 0) { window.showToast && window.showToast({ type: 'info', msg: '尚无内容可存' }); return; }
setSavingDraft(true);
try {
// Try server draft first (persists across devices). Fall back to localStorage.
if (window.bxApi) {
try {
const payload = {
title, body, category: cat,
anonymous: named === 'anon',
allow_clip: clip === 'yes',
};
const r = typeof draftId === 'number'
? await window.bxApi.updateDraft(draftId, payload)
: await window.bxApi.createDraft(payload);
setDraftId(r.id);
clearAutoDraft();
window.showToast && window.showToast({ type: 'success', msg: '奏折已入草稿匣' });
return;
} catch (e) {
if (e.status === 401) { window.showToast && window.showToast({ type: 'warning', msg: '请先入朝方可存草稿' }); return; }
// else fall through to local
}
}
const draft = { id: draftId, title, body, cat, named, clip, images };
const list = saveDraft(draft);
const saved = list[0];
setDraftId(saved.id);
clearAutoDraft();
window.showToast && window.showToast({ type: 'success', msg: '奏折已入草稿匣(本地)' });
} finally {
setSavingDraft(false);
}
}
return (
COMPOSE · 上奏
呈递奏折
朕听着呢,爱卿请讲
{recovered &&
}
);
}
function SubmitSuccess({ id, onAnother, onMine, onHome }) {
// 仪式感:全屏古风背景 + 卷轴展开 + 印章落下 + 粒子飘起 + 编号特写
// 进场顺序:卷轴展开 (0-700ms) → 印章降落 (500-1200ms) → 文字逐字 (1000-2000ms)
// → 粒子飘起 (1200ms+ 持续) → 按钮淡入 (2200ms)
// 配音: 给一个