// AdminScreens.jsx — 登入、帳號管理、歷史紀錄 const { useState: useStateA, useEffect: useEffectA, useMemo: useMemoA } = React; // ────────── 登入畫面 ────────── function LoginScreen({ theme, onLoggedIn }) { const [u, setU] = useStateA(''); const [p, setP] = useStateA(''); const [err, setErr] = useStateA(''); const [busy, setBusy] = useStateA(false); const submit = async () => { if (!u || !p) { setErr('請輸入帳號與密碼'); return; } setBusy(true); setErr(''); try { const session = await api.login(u.trim(), p); onLoggedIn(session); } catch (e) { setErr(e.message || '登入失敗'); } finally { setBusy(false); } }; return (
CELEBREAD · 2026

登入

帳號
setU(e.target.value)} autoCapitalize="none" autoCorrect="off" style={{ width: '100%', padding: '16px 18px', fontSize: 20 * theme.scale, background: theme.surface, color: theme.ink, border: 'none', borderRadius: theme.cardRadius, fontFamily: 'inherit', outline: `2px solid transparent`, transition: 'outline .15s', }} onFocus={e => e.target.style.outline = `2px solid ${theme.accent}`} onBlur={e => e.target.style.outline = `2px solid transparent`} />
密碼
setP(e.target.value)} type="password" onKeyDown={e => e.key === 'Enter' && submit()} style={{ width: '100%', padding: '16px 18px', fontSize: 20 * theme.scale, background: theme.surface, color: theme.ink, border: 'none', borderRadius: theme.cardRadius, fontFamily: 'inherit', outline: `2px solid transparent`, }} onFocus={e => e.target.style.outline = `2px solid ${theme.accent}`} onBlur={e => e.target.style.outline = `2px solid transparent`} />
{err &&
{err}
} {busy ? '登入中…' : '登入'}
); } // ────────── 帳號管理 ────────── function UsersScreen({ theme, session, onBack }) { const [users, setUsers] = useStateA([]); const [loading, setLoading] = useStateA(true); const [showAdd, setShowAdd] = useStateA(false); const [changePwUser, setChangePwUser] = useStateA(null); const [err, setErr] = useStateA(''); const refresh = async () => { try { const list = await api.listUsers(); setUsers(list); } catch (e) { setErr(e.message); } finally { setLoading(false); } }; useEffectA(() => { refresh(); }, []); const remove = async (u) => { if (u.username === session.username) { alert('不能刪除自己'); return; } if (!confirm(`確定刪除「${u.display_name}」(${u.username})?`)) return; try { await api.deleteUser(u.id); refresh(); } catch (e) { alert(e.message); } }; return ( <> } trailing={ setShowAdd(true)}>} />
{loading &&
讀取中…
} {err &&
{err}
}
{users.map((u, idx) => (
{(u.display_name || u.username)[0]}
{u.display_name} {u.role === 'admin' && ADMIN}
@{u.username}
{u.username !== session.username && ( )}
))}
{showAdd && setShowAdd(false)} onAdded={() => { setShowAdd(false); refresh(); }}/>} {changePwUser && setChangePwUser(null)} onDone={() => { setChangePwUser(null); }}/>} ); } function AddUserModal({ theme, onClose, onAdded }) { const [username, setUsername] = useStateA(''); const [displayName, setDisplayName] = useStateA(''); const [password, setPassword] = useStateA(''); const [role, setRole] = useStateA('baker'); const [busy, setBusy] = useStateA(false); const [err, setErr] = useStateA(''); const submit = async () => { if (!username || !displayName || !password) { setErr('請填完所有欄位'); return; } setBusy(true); setErr(''); try { await api.createUser({ username: username.trim(), password, display_name: displayName.trim(), role }); onAdded(); } catch (e) { setErr(e.message); } finally { setBusy(false); } }; const inputStyle = { width: '100%', padding: '14px 16px', fontSize: 17 * theme.scale, background: theme.surfaceAlt, color: theme.ink, border: 'none', borderRadius: theme.cardRadius - 4, fontFamily: 'inherit', marginBottom: 12, boxSizing: 'border-box', }; return (

新增帳號

setUsername(e.target.value)} autoCapitalize="none" placeholder="例如 wendy" style={inputStyle}/> setDisplayName(e.target.value)} placeholder="例如 阿強師傅" style={inputStyle}/> setPassword(e.target.value)} type="text" style={inputStyle}/>
{[['baker','一般師傅'],['admin','管理員']].map(([k,l]) => ( ))}
{err &&
{err}
}
); } function ChangePasswordModal({ theme, user, onClose, onDone }) { const [password, setPassword] = useStateA(''); const [busy, setBusy] = useStateA(false); const [err, setErr] = useStateA(''); const submit = async () => { if (!password) { setErr('請輸入新密碼'); return; } setBusy(true); setErr(''); try { await api.updatePassword(user.id, password); onDone(); } catch (e) { setErr(e.message); } finally { setBusy(false); } }; const inputStyle = { width: '100%', padding: '14px 16px', fontSize: 17 * theme.scale, background: theme.surfaceAlt, color: theme.ink, border: 'none', borderRadius: theme.cardRadius - 4, fontFamily: 'inherit', marginBottom: 12, boxSizing: 'border-box', }; return (

修改密碼

{user.display_name} @{user.username}
setPassword(e.target.value)} type="text" placeholder="輸入新密碼" style={inputStyle} onKeyDown={e => e.key === 'Enter' && submit()} /> {err &&
{err}
}
); } // ────────── 歷史紀錄 ────────── function HistoryScreen({ theme, session, onBack }) { const [logs, setLogs] = useStateA([]); const [loading, setLoading] = useStateA(true); const [err, setErr] = useStateA(''); const [openId, setOpenId] = useStateA(null); const [openDates, setOpenDates] = useStateA({}); const toggleDate = (date) => setOpenDates(prev => ({ ...prev, [date]: !prev[date] })); const refresh = async () => { try { setLogs(await api.listLogs({ limit: 500 })); } catch (e) { setErr(e.message); } finally { setLoading(false); } }; useEffectA(() => { refresh(); }, []); const grouped = useMemoA(() => { const g = {}; for (const l of logs) { const d = new Date(l.created_at); const k = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; (g[k] = g[k] || []).push(l); } return g; }, [logs]); const exportCSV = () => { const rows = [['時間','操作者','麵包','顆數','總粉量(g)','材料明細']]; for (const l of logs) { const items = (l.recipe_payload?.sections || []).flatMap(s => s.items.map(i => `${i.name}:${i.precise ? Math.round(i.amount*10)/10 : Math.round(i.amount)}g`) ).join(' | '); rows.push([ new Date(l.created_at).toLocaleString('zh-TW'), `${l.display_name || ''}(@${l.username})`, (l.recipe_names || []).join('+'), l.total_pieces, Math.round(l.total_flour || 0), items, ]); } const csv = '\ufeff' + rows.map(r => r.map(c => `"${String(c).replace(/"/g,'""')}"`).join(',')).join('\n'); const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' })); a.download = `celebread-history-${new Date().toISOString().slice(0,10)}.csv`; a.click(); }; const removeLog = async (id) => { if (!confirm('刪除這筆紀錄?')) return; try { await api.deleteLog(id); refresh(); } catch (e) { alert(e.message); } }; return ( <> } trailing={ } />
{loading &&
讀取中…
} {err &&
{err}
} {!loading && !logs.length &&
還沒有任何紀錄
} {Object.entries(grouped).map(([date, dayLogs]) => { const dateOpen = !!openDates[date]; const totalPieces = dayLogs.reduce((s, l) => s + (l.total_pieces || 0), 0); const totalFlour = dayLogs.reduce((s, l) => s + (l.total_flour || 0), 0); return (
{dateOpen &&
{dayLogs.map((l, idx) => { const open = openId === l.id; const time = new Date(l.created_at).toLocaleTimeString('zh-TW', { hour: '2-digit', minute: '2-digit' }); return (
{open && (
{(l.recipe_payload?.sections || []).map(s => (
{s.title}
{s.items.map(i => (
{i.name} {i.precise ? (Math.round(i.amount*10)/10).toFixed(1) : Math.round(i.amount)}{i.unit || 'g'}
))}
))} {session.role === 'admin' && ( )}
)}
); })}
}
); })}
); } // ────────── 抽屜選單 ────────── function MenuDrawer({ theme, session, onClose, onLogout, onUsers, onHistory }) { return (
e.stopPropagation()} style={{ width: '78%', maxWidth: 320, background: theme.surface, display: 'flex', flexDirection: 'column', padding: '40px 0 24px', }}>
已登入
{session.display_name}
@{session.username} · {session.role === 'admin' ? '管理員' : '師傅'}
{session.role === 'admin' && ( <> 📋 生產紀錄 👥 帳號管理 )}
); } function MenuItem({ theme, onClick, children }) { return ( ); } function Label({ theme, children }) { return
{children}
; } function IconBtn({ theme, onClick, children }) { return ; } function IconMenu({ color }) { return ; } Object.assign(window, { LoginScreen, UsersScreen, HistoryScreen, MenuDrawer, IconBtn, IconMenu, Label, });