/* App 外殼 + 登入 — app.jsx */
const { useState: useAppState, useEffect: useAppEffect, useMemo: useAppMemo } = React;
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"theme": "深藍主控台",
"accent": "#2f6fed",
"fontScale": 100,
"radius": "圓潤"
}/*EDITMODE-END*/;
const ROLE_LABELS = { teacher: '老師', assistant: '小老師', student: '學生' };
/* 角色 → 可用的導覽分頁 */
const NAV = {
teacher: [
{ key: 'class', label: '全班成績', icon: 'users' },
{ key: 'input', label: '輸入成績', icon: 'pencil' },
{ key: 'import', label: '匯入成績', icon: 'upload' }
],
assistant: [
{ key: 'mine', label: '我的成績', icon: 'chart' },
{ key: 'input', label: '輸入成績', icon: 'pencil' },
{ key: 'analysis', label: '學習分析', icon: 'spark' }
],
student: [
{ key: 'mine', label: '我的成績', icon: 'chart' },
{ key: 'analysis', label: '學習分析', icon: 'spark' }
]
};
/* ── 全域載入畫面 ── */
function LoadingScreen({ message }) {
return (
);
}
/* ── 登入畫面 ── */
function LoginScreen({ onLogin }) {
const [email, setEmail] = useAppState('');
const [password, setPassword] = useAppState('');
const [loading, setLoading] = useAppState(false);
const [error, setError] = useAppState(null);
const [forgot, setForgot] = useAppState(false);
const [forgotEmail, setForgotEmail] = useAppState('');
const [resetSent, setResetSent] = useAppState(false);
const [resetLoading, setResetLoading] = useAppState(false);
async function handleLogin(e) {
if (e) e.preventDefault();
if (!email || !password) return;
setLoading(true);
setError(null);
try {
await onLogin(email, password);
} catch (err) {
setError('帳號或密碼錯誤,請再試一次。');
setLoading(false);
}
}
async function handleReset() {
if (!forgotEmail) return;
setResetLoading(true);
try {
await API.resetPassword(forgotEmail);
setResetSent(true);
} catch {
setResetSent(true); /* 不揭露帳號是否存在 */
} finally {
setResetLoading(false);
}
}
return (
系統由老師設定帳號
{/* 忘記密碼 Modal */}
{forgot && (
setForgot(false)}>
e.stopPropagation()}>
重設密碼
{resetSent ? (
若此 Email 已註冊,重設連結將寄送至信箱,請查收。
) : (
<>
輸入你的帳號 Email,系統將寄送密碼重設連結。
setForgotEmail(e.target.value)} />
>
)}
)}
);
}
/* ── 主 App ── */
function App() {
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
const [appReady, setAppReady] = useAppState(false); /* 初始 session 檢查完畢 */
const [user, setUser] = useAppState(null);
const [tab, setTab] = useAppState('mine');
const [grades, setGrades] = useAppState([]);
const [types, setTypes] = useAppState(window.APPDATA.assessmentTypes.slice());
const [loadErr, setLoadErr] = useAppState(null);
/* 套用主題 */
useAppEffect(() => {
const root = document.documentElement;
root.dataset.theme = t.theme === '淨白學院' ? 'light' : 'dark';
root.dataset.radius = t.radius === '方正' ? 'sharp' : 'round';
root.style.setProperty('--accent', t.accent);
root.style.setProperty('--font-scale', t.fontScale / 100);
}, [t.theme, t.accent, t.fontScale, t.radius]);
/* 初次載入:檢查是否有現有 session */
useAppEffect(() => {
API.getSession().then(function (session) {
if (session) {
return loadUserData(session.user);
}
setAppReady(true);
}).catch(function (err) {
console.error('Session 檢查失敗', err);
setAppReady(true);
});
/* 監聽 auth 狀態變化(登入 / 登出) */
var sub = API.onAuthChange(function (event, session) {
if (event === 'SIGNED_OUT') {
setUser(null);
setGrades([]);
window.APPDATA.students = [];
setAppReady(true);
}
});
return function () { if (sub && sub.data) sub.data.subscription.unsubscribe(); };
}, []);
async function loadUserData(authUser) {
setLoadErr(null);
try {
const profile = await API.getProfile(authUser.id);
const [students, fetchedGrades] = await Promise.all([
API.getStudents(),
API.getGrades(profile.role, profile.student_id)
]);
/* 更新全域 APPDATA.students 供 screens 使用 */
window.APPDATA.students = students;
setUser({
id: authUser.id,
email: authUser.email,
role: profile.role,
roleLabel: profile.role_label || ROLE_LABELS[profile.role] || profile.role,
name: profile.name,
studentId: profile.student_id
});
setGrades(fetchedGrades);
setTab(NAV[profile.role][0].key);
} catch (err) {
console.error('載入資料失敗', err);
setLoadErr('載入資料失敗:' + (err.message || '請稍後再試'));
await API.signOut();
} finally {
setAppReady(true);
}
}
async function login(email, password) {
setAppReady(false);
const data = await API.signIn(email, password); /* 若失敗會 throw */
await loadUserData(data.user);
}
async function logout() {
await API.signOut();
setUser(null);
setGrades([]);
window.APPDATA.students = [];
setAppReady(true);
}
/* ── 成績操作 ── */
async function addGrade(g) {
const dbGrade = {
id: 'g' + Date.now() + '_' + Math.floor(Math.random() * 9999),
student_id: g.studentId,
type: g.type,
title: g.title,
date: g.date,
score: g.score,
created_by: user.id
};
const saved = await API.addGrade(dbGrade);
setGrades(prev => [...prev, Object.assign({}, saved, { addedThisSession: true })]);
}
async function deleteGrade(id) {
await API.deleteGrade(id);
setGrades(prev => prev.filter(g => g.id !== id));
}
function addType(t2) {
setTypes(prev => prev.includes(t2) ? prev : [...prev, t2]);
}
async function commitImport(rows) {
const students = window.APPDATA.students;
const dbRows = rows.map(function (r, i) {
const st = students.find(s => s.seat === r.seat);
if (!st) return null;
return {
id: 'imp' + Date.now() + '_' + i,
student_id: st.id,
type: r.type,
title: r.title,
date: r.date,
score: r.score,
created_by: user.id
};
}).filter(Boolean);
if (!dbRows.length) return;
const saved = await API.addGrades(dbRows);
setGrades(prev => [
...prev,
...saved.map(g => Object.assign({}, g, { addedThisSession: true }))
]);
}
/* ── Tweaks 面板 ── */
const panel = (
setTweak('theme', v)} />
setTweak('accent', v)} />
setTweak('radius', v)} />
setTweak('fontScale', v)} />
);
/* 初始 session 還在確認中 */
if (!appReady) return <>{panel}>;
/* 未登入 */
if (!user) {
return (
<>
{loadErr &&
{loadErr}
}
{panel}
>
);
}
const nav = NAV[user.role];
const sid = user.studentId;
return (
{/* 頂部列 */}
√
數學科成績查詢系統
{user.name}
{user.roleLabel}
{/* 行動版導覽 */}
{tab === 'mine' && }
{tab === 'analysis' && }
{tab === 'input' && }
{tab === 'class' && }
{tab === 'import' && }
{panel}
);
}
ReactDOM.createRoot(document.getElementById('root')).render();