Commit 330212cd authored by tao355667's avatar tao355667

feat: expand homepage and fix admin captcha

parent 63264608
......@@ -8,6 +8,9 @@ let editingStatus = "new-unsaved";
let dirty = false;
let previewTimer = null;
let previewRequest = 0;
let captchaToken = "";
let captchaObjectUrl = "";
let captchaRequest = 0;
async function api(path, options = {}) {
const endpoint = path.startsWith("/") ? path : `/${path}`;
......@@ -24,10 +27,26 @@ async function api(path, options = {}) {
const show = (selector) => $(selector).classList.remove("hidden");
const hide = (selector) => $(selector).classList.add("hidden");
const refreshCaptcha = () => {
$("#captcha-image").src = `${API_BASE}/captcha?t=${Date.now()}`;
async function refreshCaptcha() {
const request = ++captchaRequest;
captchaToken = "";
$("#captcha").value = "";
};
$("#captcha-image").removeAttribute("src");
try {
const response = await fetch(`${API_BASE}/captcha?t=${Date.now()}`, { cache: "no-store" });
if (!response.ok) throw new Error(`验证码加载失败(${response.status})`);
const token = response.headers.get("X-Captcha-Token") || "";
const blob = await response.blob();
if (request !== captchaRequest) return;
if (!token) throw new Error("验证码令牌缺失");
if (captchaObjectUrl) URL.revokeObjectURL(captchaObjectUrl);
captchaObjectUrl = URL.createObjectURL(blob);
captchaToken = token;
$("#captcha-image").src = captchaObjectUrl;
} catch (error) {
if (request === captchaRequest) $("#login-error").textContent = error.message;
}
}
function showLogin() {
show("#login");
......@@ -52,7 +71,7 @@ $("#login-form").addEventListener("submit", async (event) => {
event.preventDefault();
$("#login-error").textContent = "";
try {
await api("/session", { method: "POST", body: { password: $("#password").value, captcha: $("#captcha").value } });
await api("/session", { method: "POST", body: { password: $("#password").value, captcha: $("#captcha").value, captchaToken } });
$("#password").value = "";
$("#captcha").value = "";
hide("#login"); show("#app");
......
......@@ -35,8 +35,8 @@ input, textarea, select { width: 100%; padding: 11px 13px; color: var(--text); b
input:focus, textarea:focus, select:focus { outline: 2px solid rgba(20,115,230,.14); border-color: var(--primary); }
.topbar { min-height: 68px; padding: 12px 28px; position: sticky; top: 0; z-index: 10; display: flex; align-items: center; justify-content: space-between; gap: 22px; color: white; background: var(--primary); }
.topbar > div:first-child { display: flex; align-items: center; gap: 13px; }
.topbar strong { color: var(--primary); font-size: 19px; }
.topbar span { font-size: 13px; opacity: .76; }
.topbar strong { color: white; font-size: 19px; }
.topbar span { color: white; font-size: 13px; opacity: .76; }
.top-actions { display: flex; align-items: center; gap: 9px; }
.topbar .ghost { color: white; border-color: rgba(255,255,255,.24); }
.pending-badge { padding: 5px 10px; color: #cfe2ff; background: rgba(20,115,230,.12); border: 1px solid rgba(20,115,230,.3); border-radius: 999px; }
......
export const site = {
seo: {
title: "启优学一对一|小学初中高中在线个性化辅导",
description: "启优学一对一面向小学、初中和高中学生,通过学情诊断、教师匹配、在线一对一授课、课后反馈与阶段复盘,制定有针对性的学习方案。",
description: "启优学一对一面向小学、初中和高中学生提供在线个性化辅导,通过学情诊断、教师匹配、专属学习方案、课堂反馈和阶段复盘,帮助学生针对性巩固基础、改善学习方法。",
keywords: ["启优学", "启优学一对一", "在线一对一辅导", "小学一对一", "初中一对一", "高中一对一", "个性化辅导"],
imageAlt: "启优学在线一对一课堂",
},
......@@ -22,6 +22,7 @@ export const site = {
{ value: "85%+", label: "来自 985 / 211 或重点师范院校" },
{ value: "20万+", label: "累计服务学员" },
],
scenarios: ["基础夯实", "同步校内", "偏科补弱", "阶段复习", "升学冲刺", "拔高培优"],
},
services: [
{
......@@ -77,14 +78,17 @@ export const site = {
vision: "启发引导,因材施教;不只关注一次结果,更陪伴学生建立可以持续使用的学习能力。",
},
workflow: [
{ number: "01", title: "沟通", desc: "了解年级、教材、进度、近期试卷与当前目标。" },
{ number: "02", title: "诊断", desc: "区分知识缺口、方法问题和学习习惯问题。" },
{ number: "03", title: "匹配", desc: "综合学段、学科、基础与沟通特点匹配教师。" },
{ number: "04", title: "复盘", desc: "根据课堂与练习反馈持续调整后续安排。" },
{ number: "01", title: "需求沟通", desc: "了解年级、地区、教材、校内进度、近期试卷与阶段目标。" },
{ number: "02", title: "三维学情诊断", desc: "结合试卷、错题与学习进度,区分知识缺口、方法和习惯问题。" },
{ number: "03", title: "教师匹配", desc: "综合学段、学科、教材版本、当前基础与沟通特点匹配固定教师。" },
{ number: "04", title: "互动授课", desc: "通过提问、作答、即时反馈与难度调整,检查学生是否真正理解。" },
{ number: "05", title: "课后反馈", desc: "记录本节内容、掌握情况、仍需巩固的问题与下一次安排。" },
{ number: "06", title: "阶段复盘", desc: "根据课堂、练习与阶段表现持续调整后续学习方案。" },
],
faq: [
{ q: "启优学一对一是什么?", a: "启优学一对一是面向小学、初中、高中学生的在线一对一辅导品牌,主要提供小初高校内学科个性化辅导服务。课程以学情诊断为起点,根据年级、学科基础、教材版本、薄弱点和学习目标匹配教师。" },
{ q: "主要提供哪些课程?", a: "覆盖基础夯实、同步校内、偏科补弱、阶段复习、升学冲刺和拔高培优等学习场景。课程根据学生实际学情制定,不是统一模板课。" },
{ q: "适合哪些学生?", a: "适合存在单科短板、偏科拖分、基础断层、学习习惯不稳定、校内进度跟不上、考试复习效率较低,或需要升学冲刺与拔高培优的小初高学生。正式上课前会先沟通学情,再判断是否适合一对一辅导。" },
{ q: "覆盖哪些年级和科目?", a: "覆盖小学1-6年级、初中1-3年级和高中1-3年级,支持语文、数学、英语、物理、化学、生物、地理、道德与法治等常见校内学科。具体开课情况以咨询确认为准。" },
{ q: "可以只辅导一科吗?", a: "可以,尤其适合单科拖分、偏科明显或某一学科基础断层的学生。平台会先判断具体知识漏洞,再匹配对应学科教师,不强制多科打包。" },
{ q: "如何匹配教师?", a: "根据学生年级、学科、教材版本、当前基础、学习目标和沟通特点进行匹配。匹配成功后通常安排固定教师长期授课;如适配度明显不合适,可联系课程服务人员协助评估调整。" },
......
......@@ -117,8 +117,8 @@ function captchaMac(code: unknown, expiresAt: number): string {
.digest("hex");
}
function captchaValid(request: Request, answer: unknown): boolean {
const value = cookies(request).cms_captcha;
function captchaValid(request: Request, answer: unknown, token?: unknown): boolean {
const value = String(token || cookies(request).cms_captcha || "");
if (!value || !answer) return false;
const separator = value.lastIndexOf(".");
const expiresAt = Number(value.slice(0, separator));
......@@ -208,11 +208,13 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA
if (route === "captcha" && method === "GET") {
const code = randomCaptcha();
const expiresAt = Date.now() + CAPTCHA_TTL;
const token = `${expiresAt}.${captchaMac(code, expiresAt)}`;
return new Response(captchaSvg(code), {
headers: {
"Cache-Control": "no-store",
"Content-Type": "image/svg+xml; charset=utf-8",
"Set-Cookie": cookieHeader(request, "cms_captcha", `${expiresAt}.${captchaMac(code, expiresAt)}`, CAPTCHA_TTL / 1000),
"Set-Cookie": cookieHeader(request, "cms_captcha", token, CAPTCHA_TTL / 1000),
"X-Captcha-Token": token,
},
});
}
......@@ -224,7 +226,7 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA
if (blockedFor) return json({ error: `尝试过于频繁,请 ${Math.ceil(blockedFor / 60)} 分钟后再试` }, 429);
const body = await bodyOf(request);
const clearCaptcha = cookieHeader(request, "cms_captcha", "", 0);
if (!captchaValid(request, body.captcha)) {
if (!captchaValid(request, body.captcha, body.captchaToken)) {
return json({ error: "验证码错误或已过期" }, 400, { "Set-Cookie": clearCaptcha });
}
if (!await verifyPassword(body.password)) {
......
This diff is collapsed.
This diff is collapsed.
......@@ -41,7 +41,7 @@ async function login(password: string): Promise<{ response: Response; cookie?: s
"Content-Type": "application/json",
Cookie: cookieFrom(captchaResponse, "cms_captcha"),
},
body: JSON.stringify({ password, captcha: code }),
body: JSON.stringify({ password, captcha: code, captchaToken: captchaResponse.headers.get("X-Captcha-Token") }),
}), "session", `login-${requestNumber}`);
return {
response,
......@@ -64,6 +64,40 @@ async function changePassword(cookie: string, currentPassword: string, newPasswo
}), "account/password");
}
test("the displayed captcha remains valid when a later image request replaces the captcha cookie", async () => {
const displayedCaptcha = await handleCmsApi(
new Request("http://localhost/api/cms/captcha"),
"captcha",
"captcha-race-displayed",
);
const displayedCode = [...(await displayedCaptcha.text()).matchAll(/<text\b[^>]*>([^<])<\/text>/g)]
.map((match) => match[1])
.join("");
const displayedToken = displayedCaptcha.headers.get("X-Captcha-Token");
assert.equal(displayedCode.length, 4);
assert.ok(displayedToken);
const laterCaptcha = await handleCmsApi(
new Request("http://localhost/api/cms/captcha"),
"captcha",
"captcha-race-later",
);
const response = await handleCmsApi(new Request("http://localhost/api/cms/session", {
method: "POST",
headers: {
"Content-Type": "application/json",
Cookie: cookieFrom(laterCaptcha, "cms_captcha"),
},
body: JSON.stringify({
password: "environment-master-password",
captcha: displayedCode,
captchaToken: displayedToken,
}),
}), "session", "captcha-race-login");
assert.equal(response.status, 200);
});
test("users can change the persisted password while the .env password remains valid", async () => {
const initialLogin = await login("environment-master-password");
assert.equal(initialLogin.response.status, 200);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment