Commit 19e1bb16 authored by tao355667's avatar tao355667

feat: add consultation assignment workflow

parent 780a2844
...@@ -11,6 +11,8 @@ let previewRequest = 0; ...@@ -11,6 +11,8 @@ let previewRequest = 0;
let captchaToken = ""; let captchaToken = "";
let captchaObjectUrl = ""; let captchaObjectUrl = "";
let captchaRequest = 0; let captchaRequest = 0;
let currentUser = null;
let consultationTeachers = [];
async function api(path, options = {}) { async function api(path, options = {}) {
const endpoint = path.startsWith("/") ? path : `/${path}`; const endpoint = path.startsWith("/") ? path : `/${path}`;
...@@ -52,15 +54,15 @@ function showLogin() { ...@@ -52,15 +54,15 @@ function showLogin() {
show("#login"); show("#login");
hide("#app"); hide("#app");
refreshCaptcha(); refreshCaptcha();
$("#password").focus(); $("#username").value ||= "admin";
$("#username").focus();
} }
async function boot() { async function boot() {
const session = await api("/session").catch(() => ({ authed: false })); const session = await api("/session").catch(() => ({ authed: false }));
if (session.authed) { if (session.authed) {
hide("#login"); show("#app"); hide("#login"); show("#app");
await loadCategories(); await enterApp(session.user);
loadArticles();
} }
else showLogin(); else showLogin();
} }
...@@ -71,12 +73,11 @@ $("#login-form").addEventListener("submit", async (event) => { ...@@ -71,12 +73,11 @@ $("#login-form").addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
$("#login-error").textContent = ""; $("#login-error").textContent = "";
try { try {
await api("/session", { method: "POST", body: { password: $("#password").value, captcha: $("#captcha").value, captchaToken } }); const result = await api("/session", { method: "POST", body: { username: $("#username").value, password: $("#password").value, captcha: $("#captcha").value, captchaToken } });
$("#password").value = ""; $("#password").value = "";
$("#captcha").value = ""; $("#captcha").value = "";
hide("#login"); show("#app"); hide("#login"); show("#app");
await loadCategories(); await enterApp(result.user);
loadArticles();
} catch (error) { } catch (error) {
$("#login-error").textContent = error.message; $("#login-error").textContent = error.message;
refreshCaptcha(); refreshCaptcha();
...@@ -85,6 +86,204 @@ $("#login-form").addEventListener("submit", async (event) => { ...@@ -85,6 +86,204 @@ $("#login-form").addEventListener("submit", async (event) => {
$("#logout").addEventListener("click", async () => { await api("/session", { method: "DELETE" }); location.reload(); }); $("#logout").addEventListener("click", async () => { await api("/session", { method: "DELETE" }); location.reload(); });
async function enterApp(user) {
currentUser = user;
const admin = user?.role === "admin";
document.body.classList.toggle("teacher-session", !admin);
document.querySelectorAll(".admin-only").forEach((element) => element.classList.toggle("hidden", !admin));
$("#account-role").textContent = admin ? "管理员账号" : "咨询老师";
$("#account-name").textContent = user?.name || "";
$("#consultation-scope").textContent = admin ? "管理员可查看全部老师的咨询" : "这里只显示分配给你的咨询";
await showAdminView("consultations");
if (admin) {
await loadCategories();
loadArticles();
}
}
const adminViews = {
consultations: ["#consultations-view", "咨询记录", "查看和跟进官网咨询"],
teachers: ["#teachers-view", "咨询老师", "配置账号、二维码与轮询顺序"],
articles: ["#list-view", "学习资讯", "管理官网文章内容"],
};
async function showAdminView(name) {
Object.values(adminViews).forEach(([selector]) => hide(selector));
hide("#edit-view");
const view = adminViews[name] || adminViews.consultations;
show(view[0]);
$("#page-title").textContent = view[1];
$("#page-subtitle").textContent = view[2];
document.querySelectorAll("[data-admin-nav]").forEach((button) => button.classList.toggle("active", button.dataset.adminNav === name));
if (name === "consultations") await loadConsultations();
if (name === "teachers") await loadTeachers();
if (name === "articles") await loadArticles();
}
document.querySelectorAll("[data-admin-nav]").forEach((button) => button.addEventListener("click", () => { void showAdminView(button.dataset.adminNav); }));
const formatTime = (value) => new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false }).format(new Date(value));
function updateConsultationMetrics(records) {
$("#consultation-count").textContent = records.length;
$("#metric-total").textContent = records.length;
["new", "contacted", "completed"].forEach((status) => {
$(`#metric-${status}`).textContent = records.filter((item) => item.status === status).length;
});
}
async function loadConsultations() {
const result = await api("/consultations").catch((error) => { alert(error.message); return { consultations: [] }; });
const records = result.consultations || [];
updateConsultationMetrics(records);
const list = $("#consultation-list");
list.innerHTML = "";
if (!records.length) {
list.innerHTML = '<div class="empty">暂时没有咨询记录。官网用户提交手机号后会显示在这里。</div>';
return;
}
const head = document.createElement("div");
head.className = "data-row data-head consultation-row";
head.innerHTML = "<span>手机号</span><span>提交时间</span><span>分配老师</span><span>跟进状态</span><span>跟进备注</span><span>操作</span>";
list.append(head);
records.forEach((record) => {
const row = document.createElement("article");
row.className = "data-row consultation-row";
row.innerHTML = '<a class="phone-link"></a><time class="created"></time><span class="teacher-name"></span><form class="inline-follow-form"><select aria-label="咨询状态"><option value="new">待联系</option><option value="contacted">跟进中</option><option value="completed">已完成</option></select><div class="inline-note"><textarea maxlength="500" rows="2" placeholder="记录沟通结果或下次跟进时间"></textarea><button class="note-toggle" type="button">查看全部备注</button></div><div class="inline-follow-actions"><button class="primary small" type="submit">保存</button><small class="save-state"></small></div></form>';
const phone = row.querySelector(".phone-link");
phone.textContent = record.phone;
phone.href = `tel:${record.phone}`;
row.querySelector(".created").textContent = formatTime(record.createdAt);
row.querySelector(".teacher-name").textContent = record.teacherName;
const form = row.querySelector("form");
const textarea = form.querySelector("textarea");
const noteToggle = form.querySelector(".note-toggle");
form.querySelector("select").value = record.status;
textarea.value = record.note || "";
noteToggle.addEventListener("click", () => {
const expanded = textarea.classList.toggle("expanded");
textarea.style.height = expanded ? `${Math.min(Math.max(textarea.scrollHeight, 96), 260)}px` : "";
noteToggle.textContent = expanded ? "收起备注" : "查看全部备注";
if (expanded) textarea.focus();
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
const button = form.querySelector('button[type="submit"]');
const state = form.querySelector(".save-state");
button.disabled = true;
state.textContent = "保存中…";
try {
const result = await api(`/consultations/${record.id}`, { method: "PATCH", body: { status: form.querySelector("select").value, note: form.querySelector("textarea").value } });
record.status = result.consultation.status;
record.note = result.consultation.note;
updateConsultationMetrics(records);
state.textContent = "已保存";
setTimeout(() => { state.textContent = ""; }, 1600);
} catch (error) { state.textContent = error.message; }
finally { button.disabled = false; }
});
list.append(row);
});
}
$("#refresh-consultations").addEventListener("click", loadConsultations);
async function loadTeachers() {
const result = await api("/consultation-teachers").catch((error) => { alert(error.message); return { teachers: [] }; });
consultationTeachers = result.teachers || [];
const list = $("#teacher-list");
list.innerHTML = "";
if (!consultationTeachers.length) {
list.innerHTML = '<div class="empty">还没有咨询老师。添加并启用老师后,前台手机号才能按顺序分配。</div>';
return;
}
const head = document.createElement("div");
head.className = "data-row data-head teacher-row";
head.innerHTML = "<span>老师</span><span>登录账号</span><span>轮询</span><span>累计分配</span><span>状态</span><span>操作</span>";
list.append(head);
consultationTeachers.forEach((teacher) => {
const row = document.createElement("article");
row.className = "data-row teacher-row";
row.innerHTML = '<div class="teacher-identity"><img alt="微信二维码" /><div><strong></strong><small></small></div></div><span class="username"></span><strong class="sort"></strong><span class="assigned"></span><span class="teacher-state"></span><div class="row-actions"></div>';
row.querySelector("img").src = teacher.qrCodeUrl;
row.querySelector(".teacher-identity strong").textContent = teacher.name;
row.querySelector(".teacher-identity small").textContent = teacher.title;
row.querySelector(".username").textContent = teacher.username;
row.querySelector(".sort").textContent = teacher.sort;
row.querySelector(".assigned").textContent = `${teacher.assignedCount} 次`;
const state = row.querySelector(".teacher-state");
state.textContent = teacher.enabled ? "启用" : "停用";
state.className = `teacher-state ${teacher.enabled ? "enabled" : "disabled"}`;
row.querySelector(".row-actions").append(
listAction("编辑", "ghost small", () => openTeacherModal(teacher)),
listAction("删除", "ghost danger small", async () => {
if (!confirm(`确定删除咨询老师“${teacher.name}”吗?历史咨询仍会保留。`)) return;
try { await api(`/consultation-teachers/${teacher.id}`, { method: "DELETE" }); await loadTeachers(); }
catch (error) { alert(error.message); }
}),
);
list.append(row);
});
}
function closeTeacherModal() { hide("#teacher-modal"); $("#teacher-form").reset(); $("#teacher-error").textContent = ""; }
function openTeacherModal(teacher = null) {
$("#teacher-form").reset();
$("#teacher-error").textContent = "";
$("#teacher-id").value = teacher?.id || "";
$("#teacher-modal-title").textContent = teacher ? "编辑咨询老师" : "添加咨询老师";
$("#teacher-name").value = teacher?.name || "";
$("#teacher-title").value = teacher?.title || "教务老师|一对一学情沟通";
$("#teacher-username").value = teacher?.username || "";
$("#teacher-wechat").value = teacher?.wechat || "";
$("#teacher-sort").value = teacher?.sort || Math.max(1, ...consultationTeachers.map((item) => item.sort + 1));
$("#teacher-enabled").checked = teacher?.enabled ?? true;
$("#teacher-password").required = !teacher;
$("#teacher-password-hint").textContent = teacher ? "留空则不修改密码" : "至少 6 个字符";
$("#teacher-qr-url").value = teacher?.qrCodeUrl || "";
$("#teacher-qr-preview").src = teacher?.qrCodeUrl || "";
$("#teacher-qr-preview").classList.toggle("hidden", !teacher?.qrCodeUrl);
$("#teacher-qr-label").textContent = teacher ? "点击更换二维码" : "点击上传二维码";
show("#teacher-modal");
$("#teacher-name").focus();
}
$("#new-teacher").addEventListener("click", () => openTeacherModal());
$("#close-teacher-modal").addEventListener("click", closeTeacherModal);
$("#cancel-teacher").addEventListener("click", closeTeacherModal);
$("#teacher-qr-button").addEventListener("click", () => $("#teacher-qr-file").click());
$("#teacher-qr-file").addEventListener("change", async (event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
if (file.size > MAX_UPLOAD_SIZE) return $("#teacher-error").textContent = "二维码图片不能超过 20MB";
$("#teacher-qr-label").textContent = "上传中…";
try {
const dataUrl = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = reject; reader.readAsDataURL(file); });
const result = await api("/uploads", { method: "POST", body: { dataUrl } });
$("#teacher-qr-url").value = result.url;
$("#teacher-qr-preview").src = result.url;
show("#teacher-qr-preview");
$("#teacher-qr-label").textContent = "点击更换二维码";
} catch (error) { $("#teacher-error").textContent = error.message; $("#teacher-qr-label").textContent = "点击上传二维码"; }
});
$("#teacher-form").addEventListener("submit", async (event) => {
event.preventDefault();
const id = $("#teacher-id").value;
const body = { name: $("#teacher-name").value, title: $("#teacher-title").value, username: $("#teacher-username").value, wechat: $("#teacher-wechat").value, sort: Number($("#teacher-sort").value), password: $("#teacher-password").value, qrCodeUrl: $("#teacher-qr-url").value, enabled: $("#teacher-enabled").checked };
const submit = event.submitter;
submit.disabled = true;
$("#teacher-error").textContent = "";
try {
await api(id ? `/consultation-teachers/${id}` : "/consultation-teachers", { method: id ? "PUT" : "POST", body });
closeTeacherModal();
await loadTeachers();
} catch (error) { $("#teacher-error").textContent = error.message; }
finally { submit.disabled = false; }
});
function closePasswordModal() { function closePasswordModal() {
hide("#password-modal"); hide("#password-modal");
$("#password-form").reset(); $("#password-form").reset();
...@@ -145,6 +344,8 @@ async function loadArticles() { ...@@ -145,6 +344,8 @@ async function loadArticles() {
const pending = articles.filter((item) => item.publishStatus !== "live").length; const pending = articles.filter((item) => item.publishStatus !== "live").length;
$("#pending-badge").textContent = `${pending} 篇草稿待发布`; $("#pending-badge").textContent = `${pending} 篇草稿待发布`;
$("#pending-badge").classList.toggle("hidden", pending === 0); $("#pending-badge").classList.toggle("hidden", pending === 0);
$("#sidebar-pending").textContent = pending;
$("#sidebar-pending").classList.toggle("hidden", pending === 0);
if (!articles.length) { list.innerHTML = '<div class="empty">还没有文章,点击“新建文章”开始。</div>'; return; } if (!articles.length) { list.innerHTML = '<div class="empty">还没有文章,点击“新建文章”开始。</div>'; return; }
for (const article of articles) { for (const article of articles) {
const card = document.createElement("article"); const card = document.createElement("article");
......
...@@ -154,6 +154,70 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(20,115,230,. ...@@ -154,6 +154,70 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(20,115,230,.
.replacement-options button:hover { border-color: var(--primary); background: #eaf3ff; } .replacement-options button:hover { border-color: var(--primary); background: #eaf3ff; }
.build-card { width: min(780px, 100%); } .build-card { width: min(780px, 100%); }
#build-log { max-height: 62vh; margin: 0; padding: 18px; overflow: auto; color: var(--muted); background: #f5f9fd; white-space: pre-wrap; font-size: 12px; } #build-log { max-height: 62vh; margin: 0; padding: 18px; overflow: auto; color: var(--muted); background: #f5f9fd; white-space: pre-wrap; font-size: 12px; }
.admin-shell { min-height: 100vh; padding-left: 238px; }
.sidebar { width: 238px; position: fixed; inset: 0 auto 0 0; z-index: 20; display: flex; flex-direction: column; color: #dbeaff; background: #102f55; box-shadow: 8px 0 28px rgba(10,40,75,.12); }
.sidebar-brand { height: 86px; padding: 20px 24px; display: grid; align-content: center; border-bottom: 1px solid rgba(255,255,255,.1); }
.sidebar-brand span { color: #78b8ff; font-size: 11px; font-weight: 800; letter-spacing: .18em; }
.sidebar-brand strong { margin-top: 3px; color: white; font-size: 20px; }
.sidebar nav { padding: 20px 13px; display: grid; gap: 5px; }
.sidebar nav > p { margin: 17px 11px 5px; color: rgba(219,234,255,.48); font-size: 10px; font-weight: 800; letter-spacing: .14em; }
.sidebar nav > p:first-child { margin-top: 0; }
.sidebar-link { width: 100%; min-height: 44px; padding: 0 13px; justify-content: space-between; color: #dbeaff; background: transparent; border-radius: 10px; }
.sidebar-link:hover, .sidebar-link.active { color: white; background: rgba(92,166,255,.2); }
.sidebar-link b { min-width: 24px; padding: 3px 7px; color: #0f3865; background: #86c2ff; border-radius: 999px; font-size: 11px; }
.sidebar-account { margin-top: auto; padding: 19px 24px 22px; display: grid; gap: 4px; border-top: 1px solid rgba(255,255,255,.1); }
.sidebar-account small { color: rgba(219,234,255,.55); }
.sidebar-account strong { color: white; }
.admin-main { min-width: 0; }
.topbar { color: var(--text); background: rgba(255,255,255,.96); border-bottom: 1px solid var(--border); backdrop-filter: blur(16px); }
.topbar strong { color: var(--text); }
.topbar span { color: var(--muted); }
.topbar .ghost { color: var(--primary-dark); border-color: var(--border); }
.pending-badge { color: #145da8; background: #eaf3ff; border-color: #c7def8; }
.view-head > div > span { display: block; margin-top: 7px; color: var(--muted); font-size: 13px; }
.metric-grid { margin-bottom: 16px; padding: 0 18px; display: flex; align-items: center; background: white; border: 1px solid var(--border); border-radius: 10px; }
.metric-grid article { min-width: 150px; padding: 15px 22px; display: flex; align-items: baseline; gap: 10px; border-right: 1px solid var(--border); }
.metric-grid article:first-child { padding-left: 0; }
.metric-grid article:last-child { border-right: 0; }
.metric-grid small { color: var(--muted); font-size: 12px; }
.metric-grid strong { color: #12365f; font-size: 20px; }
.table-card { overflow: hidden; background: white; border: 1px solid var(--border); border-radius: 10px; }
.data-list { min-width: 850px; }
.data-row { display: grid; align-items: center; gap: 16px; padding: 14px 18px; border-bottom: 1px solid var(--border); }
.data-row:last-child { border-bottom: 0; }
.data-head { min-height: 44px; padding-block: 11px; color: var(--muted); background: #f7f9fb; font-size: 12px; font-weight: 600; }
.consultation-row { grid-template-columns: 1.05fr .8fr .75fr .8fr 2.2fr .55fr; }
.teacher-row { grid-template-columns: 1.45fr .85fr .45fr .65fr .55fr .9fr; }
.data-row small { display: block; margin-top: 4px; color: var(--muted); font-size: 11px; }
.phone-link { color: var(--text); font-size: 15px; font-weight: 600; text-decoration: none; }
.page { max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.created,.teacher-name { font-size: 13px; }
.created { color: var(--muted); }
.inline-follow-form { display: contents; }
.inline-follow-form select { padding: 8px 10px; font-size: 13px; }
.inline-note { min-width: 0; display: grid; gap: 4px; }
.inline-note textarea { height: 54px; min-height: 54px; padding: 7px 10px; overflow: hidden; resize: none; font-size: 13px; line-height: 1.5; transition: height .18s ease; }
.inline-note textarea.expanded { overflow-y: auto; resize: vertical; }
.note-toggle { width: max-content; min-height: 22px; padding: 0; color: var(--primary-dark); background: transparent; border-radius: 0; font-size: 11px; }
.note-toggle:hover { text-decoration: underline; }
.inline-follow-actions { display: grid; justify-items:start; gap: 2px; }
.inline-follow-actions .save-state { min-height: 14px; margin: 0; white-space: nowrap; }
.teacher-identity { display: flex; align-items: center; gap: 12px; }
.teacher-identity img { width: 48px; height: 48px; object-fit: cover; border: 1px solid var(--border); border-radius: 8px; }
.teacher-state { width: max-content; padding: 4px 9px; border-radius: 999px; font-size: 12px; }
.teacher-state.enabled { color: #087a56; background: #dff8ee; }
.teacher-state.disabled { color: #7b8190; background: #edf0f4; }
.row-actions .danger { color: var(--danger); }
.teacher-card { width: min(760px, 100%); }
.teacher-form { padding: 20px; display: grid; gap: 18px; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; }
.teacher-form label, .qr-field { display: grid; gap: 7px; color: var(--muted); font-size: 13px; }
.teacher-form label small { font-size: 11px; }
.qr-upload { min-height: 132px; padding: 14px; gap: 16px; color: var(--primary-dark); background: #f5f9fd; border: 1px dashed #9dc9f7; border-radius: 13px; }
.qr-upload img { width: 102px; height: 102px; object-fit: contain; background: white; border-radius: 7px; }
.switch-row { display: flex !important; align-items: center; gap: 9px !important; color: var(--text) !important; }
.switch-row input { width: 17px; height: 17px; }
.teacher-session #pending-badge { display: none !important; }
@media (max-width: 760px) { @media (max-width: 760px) {
.topbar, .view-head, .article-row { align-items: flex-start; flex-direction: column; } .topbar, .view-head, .article-row { align-items: flex-start; flex-direction: column; }
.top-actions { width: 100%; flex-wrap: wrap; } .top-actions { width: 100%; flex-wrap: wrap; }
...@@ -168,4 +232,17 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(20,115,230,. ...@@ -168,4 +232,17 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(20,115,230,.
#message { width: 100%; margin: 4px 0 0; } #message { width: 100%; margin: 4px 0 0; }
.login-card { padding: 30px 24px; } .login-card { padding: 30px 24px; }
.manager-create, .category-row-main, .category-rename-form { align-items: stretch; flex-direction: column; } .manager-create, .category-row-main, .category-rename-form { align-items: stretch; flex-direction: column; }
.admin-shell { padding: 0 0 72px; }
.sidebar { width: auto; height: 64px; inset: auto 10px 8px; flex-direction: row; border-radius: 16px; }
.sidebar-brand, .sidebar-account, .sidebar nav > p { display: none; }
.sidebar nav { width: 100%; padding: 7px; display: flex; gap: 5px; }
.sidebar-link { min-height: 50px; flex: 1; padding: 0 8px; justify-content: center; }
.sidebar-link b { margin-left: 6px; }
.topbar { position: static; }
.metric-grid { padding: 0; overflow-x: auto; }
.metric-grid article { min-width: 120px; padding: 13px 16px; }
.metric-grid article:first-child { padding-left: 16px; }
.table-card { overflow-x: auto; }
.form-grid { grid-template-columns: 1fr; }
.consultation-row { grid-template-columns: 150px 125px 120px 130px 360px 90px; }
} }
...@@ -3,4 +3,4 @@ import { site } from "../data/site"; ...@@ -3,4 +3,4 @@ import { site } from "../data/site";
interface Props { title?: string; text?: string; } interface Props { title?: string; text?: string; }
const { title = "先了解学情,再决定怎么学", text = "准备学生年级、意向学科、近期试卷或错题与阶段目标,领取一对一专属学情分析。" } = Astro.props; const { title = "先了解学情,再决定怎么学", text = "准备学生年级、意向学科、近期试卷或错题与阶段目标,领取一对一专属学情分析。" } = Astro.props;
--- ---
<section class="cta-band"><div class="container cta-band__inner" data-reveal><div><h2>{title}</h2><p>{text}</p></div><div class="cta-band__actions"><a class="button button--white" href="/contact/">预约学情分析</a><a class="text-link text-link--light" href={`tel:${site.contact.phone}`}>电话 {site.contact.phoneDisplay}</a></div></div></section> <section class="cta-band"><div class="container cta-band__inner" data-reveal><div><h2>{title}</h2><p>{text}</p></div><div class="cta-band__actions"><a class="button button--white" href="/contact/" data-open-contact>预约学情分析</a><a class="text-link text-link--light" href={`tel:${site.contact.phone}`}>电话 {site.contact.phoneDisplay}</a></div></div></section>
--- <dialog class="contact-dialog contact-dialog--form" data-contact-dialog aria-labelledby="contact-dialog-title">
import { site } from "../data/site"; <div class="contact-dialog__panel">
--- <button class="contact-dialog__close" type="button" data-contact-dialog-close aria-label="关闭领取弹窗">×</button>
<dialog class="contact-dialog" data-contact-dialog aria-labelledby="contact-dialog-title"><div class="contact-dialog__panel"><button class="contact-dialog__close" type="button" data-contact-dialog-close aria-label="关闭联系方式弹窗">×</button><div class="contact-dialog__heading"><h2 id="contact-dialog-title">从一次学情沟通开始</h2><p>准备学生年级、地区、意向学科、当前情况与阶段目标,我们会协助梳理下一步。</p></div><div class="contact-dialog__grid"><article><h3>官方电话</h3><a class="contact-dialog__primary" href={`tel:${site.contact.phone}`}>{site.contact.phoneDisplay}</a><p>周一至周日 · 7×24 小时</p></article><article><h3>微信小程序</h3><strong>启优学一对一</strong><p>搜索小程序名称提交咨询</p></article><article><h3>联系邮箱</h3><a class="contact-dialog__primary contact-dialog__email" href={`mailto:${site.contact.email}`}>{site.contact.email}</a><p>服务反馈与商务合作</p></article><article><h3>官方网站</h3><a class="contact-dialog__primary contact-dialog__website" href={`https://${site.contact.website}/`}>{site.contact.website}</a><p>启优学官方信息窗口</p></article></div></div></dialog> <div class="contact-dialog__heading">
<h2 id="contact-dialog-title">免费领取学情分析</h2>
<p>留下手机号,教务老师会尽快与您沟通孩子的年级、学科和当前学习情况。</p>
</div>
<form class="consultation-form" data-consultation-form>
<label for="consultation-phone">家长手机号</label>
<div class="consultation-phone-row"><span>+86</span><input id="consultation-phone" name="phone" type="tel" inputmode="numeric" autocomplete="tel" maxlength="11" placeholder="请输入 11 位手机号" required /></div>
<label class="consultation-consent"><input name="consent" type="checkbox" required /><span>我同意启优学使用该手机号联系我并提供本次咨询服务</span></label>
<p class="consultation-error" data-consultation-error aria-live="polite"></p>
<button class="button consultation-submit" type="submit">立即领取</button>
</form>
</div>
</dialog>
<dialog class="contact-dialog contact-dialog--teacher" data-teacher-dialog aria-labelledby="teacher-dialog-title">
<div class="contact-dialog__panel">
<button class="contact-dialog__close" type="button" data-teacher-dialog-close aria-label="关闭老师微信弹窗">×</button>
<div class="teacher-dialog__heading">
<h2 id="teacher-dialog-title">专属咨询老师</h2>
<p>手机号已提交,老师稍后会与您联系。您也可以扫码添加老师微信。</p>
</div>
<div class="teacher-contact">
<div class="teacher-contact__identity"><strong data-teacher-name></strong><span data-teacher-title></span></div>
<img data-teacher-qr alt="咨询老师微信二维码" />
<p class="teacher-wechat" data-teacher-wechat-wrap>微信号:<strong data-teacher-wechat></strong><button type="button" data-copy-teacher-wechat>复制</button></p>
</div>
</div>
</dialog>
...@@ -11,7 +11,7 @@ const nav = site.nav.map((item) => item.href === "/articles/" ? { ...item, child ...@@ -11,7 +11,7 @@ const nav = site.nav.map((item) => item.href === "/articles/" ? { ...item, child
<div class="container site-header__inner"> <div class="container site-header__inner">
<a class="brand" href="/" aria-label="启优学首页"><img src={logo.src} width="1214" height="910" alt="启优学一对一" /></a> <a class="brand" href="/" aria-label="启优学首页"><img src={logo.src} width="1214" height="910" alt="启优学一对一" /></a>
<nav class="desktop-nav" aria-label="主导航"><ul>{nav.map((item) => <li class="nav-item"><a class="nav-link" href={item.href} aria-current={isActive(item.href) ? "page" : undefined}>{item.label}</a><div class="nav-dropdown"><ul>{item.children.map((child) => <li><a href={child.href}>{child.label}</a></li>)}</ul></div></li>)}</ul></nav> <nav class="desktop-nav" aria-label="主导航"><ul>{nav.map((item) => <li class="nav-item"><a class="nav-link" href={item.href} aria-current={isActive(item.href) ? "page" : undefined}>{item.label}</a><div class="nav-dropdown"><ul>{item.children.map((child) => <li><a href={child.href}>{child.label}</a></li>)}</ul></div></li>)}</ul></nav>
<a class="button button--small header-cta" href="/contact/">免费领取学情分析</a> <a class="button button--small header-cta" href="/contact/" data-open-contact>免费领取学情分析</a>
<details class="mobile-nav"><summary aria-label="打开导航菜单"><span></span><span></span><span></span></summary><nav data-mobile-nav aria-label="移动端导航">{nav.map((item) => <div class="mobile-nav__group"><a class="mobile-nav__primary" href={item.href} aria-current={isActive(item.href) ? "page" : undefined}>{item.label}</a><div class="mobile-subnav">{item.children.map((child) => <a href={child.href}>{child.label}</a>)}</div></div>)}<a class="button" href="/contact/">免费领取学情分析</a></nav></details> <details class="mobile-nav"><summary aria-label="打开导航菜单"><span></span><span></span><span></span></summary><nav data-mobile-nav aria-label="移动端导航">{nav.map((item) => <div class="mobile-nav__group"><a class="mobile-nav__primary" href={item.href} aria-current={isActive(item.href) ? "page" : undefined}>{item.label}</a><div class="mobile-subnav">{item.children.map((child) => <a href={child.href}>{child.label}</a>)}</div></div>)}<a class="button" href="/contact/" data-open-contact>免费领取学情分析</a></nav></details>
</div> </div>
</header> </header>
...@@ -18,5 +18,5 @@ const webPageLd = { "@context": "https://schema.org", "@type": "WebPage", "@id": ...@@ -18,5 +18,5 @@ const webPageLd = { "@context": "https://schema.org", "@type": "WebPage", "@id":
const pageLd = Array.isArray(jsonLd) ? jsonLd : [jsonLd]; const serializedLd = JSON.stringify([organizationLd, websiteLd, webPageLd, ...pageLd.filter((item) => Object.keys(item).length > 0)]).replace(/</g, "\\u003c"); const pageLd = Array.isArray(jsonLd) ? jsonLd : [jsonLd]; const serializedLd = JSON.stringify([organizationLd, websiteLd, webPageLd, ...pageLd.filter((item) => Object.keys(item).length > 0)]).replace(/</g, "\\u003c");
--- ---
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><meta name="theme-color" content="#1473E6" /><meta name="color-scheme" content="light" /><title>{metaTitle}</title><meta name="description" content={metaDescription} /><meta name="keywords" content={metaKeywords} /><meta name="robots" content={noindex ? "noindex,nofollow" : "index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1"} /><link rel="canonical" href={canonical} /><link rel="icon" href="/favicon.png" type="image/png" sizes="512x512" /><link rel="apple-touch-icon" href="/favicon.png" /><link rel="manifest" href="/site.webmanifest" /><link rel="alternate" hreflang="zh-CN" href={canonical} /><link rel="alternate" hreflang="x-default" href={canonical} /><meta property="og:type" content={ogType} /><meta property="og:title" content={metaTitle} /><meta property="og:description" content={metaDescription} /><meta property="og:url" content={canonical} /><meta property="og:image" content={ogImage} /><meta property="og:image:alt" content={imageAlt} /><meta property="og:site_name" content={site.brand.name} /><meta property="og:locale" content="zh_CN" />{publishedTime && <meta property="article:published_time" content={publishedTime} />}{modifiedTime && <meta property="article:modified_time" content={modifiedTime} />}{articleSection && <meta property="article:section" content={articleSection} />}{articleAuthor && <meta property="article:author" content={articleAuthor} />}<meta name="twitter:card" content="summary_large_image" /><meta name="twitter:title" content={metaTitle} /><meta name="twitter:description" content={metaDescription} /><meta name="twitter:image" content={ogImage} /><script type="application/ld+json" set:html={serializedLd} is:inline></script></head><body><a class="skip-link" href="#main-content">跳到主要内容</a><slot /><ContactDialog /><script is:inline> <!doctype html><html lang="zh-CN"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><meta name="theme-color" content="#1473E6" /><meta name="color-scheme" content="light" /><title>{metaTitle}</title><meta name="description" content={metaDescription} /><meta name="keywords" content={metaKeywords} /><meta name="robots" content={noindex ? "noindex,nofollow" : "index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1"} /><link rel="canonical" href={canonical} /><link rel="icon" href="/favicon.png" type="image/png" sizes="512x512" /><link rel="apple-touch-icon" href="/favicon.png" /><link rel="manifest" href="/site.webmanifest" /><link rel="alternate" hreflang="zh-CN" href={canonical} /><link rel="alternate" hreflang="x-default" href={canonical} /><meta property="og:type" content={ogType} /><meta property="og:title" content={metaTitle} /><meta property="og:description" content={metaDescription} /><meta property="og:url" content={canonical} /><meta property="og:image" content={ogImage} /><meta property="og:image:alt" content={imageAlt} /><meta property="og:site_name" content={site.brand.name} /><meta property="og:locale" content="zh_CN" />{publishedTime && <meta property="article:published_time" content={publishedTime} />}{modifiedTime && <meta property="article:modified_time" content={modifiedTime} />}{articleSection && <meta property="article:section" content={articleSection} />}{articleAuthor && <meta property="article:author" content={articleAuthor} />}<meta name="twitter:card" content="summary_large_image" /><meta name="twitter:title" content={metaTitle} /><meta name="twitter:description" content={metaDescription} /><meta name="twitter:image" content={ogImage} /><script type="application/ld+json" set:html={serializedLd} is:inline></script></head><body><a class="skip-link" href="#main-content">跳到主要内容</a><slot /><ContactDialog /><script is:inline>
const header=document.querySelector('[data-header]');const syncHeader=()=>header?.classList.toggle('is-scrolled',scrollY>12);addEventListener('scroll',syncHeader,{passive:true});syncHeader();const observer='IntersectionObserver'in window?new IntersectionObserver(entries=>entries.forEach(entry=>{if(entry.isIntersecting){entry.target.classList.add('is-visible');observer.unobserve(entry.target)}}),{threshold:.08}):null;document.querySelectorAll('[data-reveal]').forEach(el=>observer?observer.observe(el):el.classList.add('is-visible'));document.querySelectorAll('[data-mobile-nav] a').forEach(link=>link.addEventListener('click',()=>link.closest('details')?.removeAttribute('open')));const dialog=document.querySelector('[data-contact-dialog]');const mobile=()=>matchMedia('(max-width:760px),(hover:none) and (pointer:coarse)').matches;document.addEventListener('click',event=>{const trigger=event.target.closest('[data-open-contact]');const phone=event.target.closest('a[href^="tel:"]');if(!dialog||(!trigger&&(!phone||mobile())))return;event.preventDefault();dialog.showModal()});dialog?.querySelector('[data-contact-dialog-close]')?.addEventListener('click',()=>dialog.close());dialog?.addEventListener('click',event=>{if(event.target===dialog)dialog.close()}); const header=document.querySelector('[data-header]');const syncHeader=()=>header?.classList.toggle('is-scrolled',scrollY>12);addEventListener('scroll',syncHeader,{passive:true});syncHeader();const observer='IntersectionObserver'in window?new IntersectionObserver(entries=>entries.forEach(entry=>{if(entry.isIntersecting){entry.target.classList.add('is-visible');observer.unobserve(entry.target)}}),{threshold:.08}):null;document.querySelectorAll('[data-reveal]').forEach(el=>observer?observer.observe(el):el.classList.add('is-visible'));document.querySelectorAll('[data-mobile-nav] a').forEach(link=>link.addEventListener('click',()=>link.closest('details')?.removeAttribute('open')));const dialog=document.querySelector('[data-contact-dialog]');const teacherDialog=document.querySelector('[data-teacher-dialog]');const form=dialog?.querySelector('[data-consultation-form]');const copyWechat=teacherDialog?.querySelector('[data-copy-teacher-wechat]');const resetConsultation=()=>{form?.reset();const error=dialog?.querySelector('[data-consultation-error]');if(error)error.textContent=''};document.addEventListener('click',event=>{const trigger=event.target.closest('[data-open-contact]');if(!dialog||!trigger)return;event.preventDefault();resetConsultation();dialog.showModal();dialog.querySelector('#consultation-phone')?.focus()});dialog?.querySelector('[data-contact-dialog-close]')?.addEventListener('click',()=>dialog.close());teacherDialog?.querySelector('[data-teacher-dialog-close]')?.addEventListener('click',()=>teacherDialog.close());[dialog,teacherDialog].forEach(item=>item?.addEventListener('click',event=>{if(event.target===item)item.close()}));copyWechat?.addEventListener('click',async()=>{const value=teacherDialog.querySelector('[data-teacher-wechat]').textContent.trim();if(!value)return;try{await navigator.clipboard.writeText(value);copyWechat.textContent='已复制';setTimeout(()=>{copyWechat.textContent='复制'},1600)}catch{copyWechat.textContent='复制失败';setTimeout(()=>{copyWechat.textContent='复制'},1600)}});form?.addEventListener('submit',async event=>{event.preventDefault();const phone=String(new FormData(form).get('phone')||'').replace(/\s+/g,'');const error=dialog.querySelector('[data-consultation-error]');const submit=form.querySelector('button[type="submit"]');error.textContent='';if(!/^1[3-9]\d{9}$/.test(phone)){error.textContent='请输入正确的 11 位手机号';return}submit.disabled=true;submit.textContent='正在领取…';try{const response=await fetch('/api/cms/consultations/claim',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({phone,source:'免费领取学情分析',page:location.href})});const result=await response.json();if(!response.ok)throw new Error(result.error||'提交失败,请稍后重试');teacherDialog.querySelector('[data-teacher-name]').textContent=result.teacher.name;teacherDialog.querySelector('[data-teacher-title]').textContent=result.teacher.title;teacherDialog.querySelector('[data-teacher-qr]').src=result.teacher.qrCodeUrl;teacherDialog.querySelector('[data-teacher-wechat]').textContent=result.teacher.wechat||'';teacherDialog.querySelector('[data-teacher-wechat-wrap]').classList.toggle('hidden',!result.teacher.wechat);copyWechat.textContent='复制';dialog.close();teacherDialog.showModal()}catch(requestError){error.textContent=requestError.message}finally{submit.disabled=false;submit.textContent='立即领取'}});
</script></body></html> </script></body></html>
...@@ -23,6 +23,17 @@ import { ...@@ -23,6 +23,17 @@ import {
} from "./article-store"; } from "./article-store";
import { convertWordToMarkdown, decodeWordDataUrl } from "./word-import"; import { convertWordToMarkdown, decodeWordDataUrl } from "./word-import";
import { currentSessionVersion, savePassword, verifyPassword } from "./cms-auth"; import { currentSessionVersion, savePassword, verifyPassword } from "./cms-auth";
import {
assignConsultation,
createConsultationTeacher,
deleteConsultationTeacher,
getTeacherSessionVersion,
listConsultations,
listConsultationTeachers,
updateConsultation,
updateConsultationTeacher,
verifyTeacherCredentials,
} from "./consultation-store";
import { buildSiteAtomic, tryAcquireSiteBuildLock, withSiteBuildLock } from "../../scripts/site-build.mjs"; import { buildSiteAtomic, tryAcquireSiteBuildLock, withSiteBuildLock } from "../../scripts/site-build.mjs";
const SECRET = process.env.CMS_SECRET || crypto.randomBytes(32).toString("hex"); const SECRET = process.env.CMS_SECRET || crypto.randomBytes(32).toString("hex");
...@@ -33,6 +44,7 @@ const MAX_LOGIN_FAILURES = 5; ...@@ -33,6 +44,7 @@ const MAX_LOGIN_FAILURES = 5;
const LOGIN_FAILURE_WINDOW = 10 * 60 * 1000; const LOGIN_FAILURE_WINDOW = 10 * 60 * 1000;
const LOGIN_BLOCK_DURATION = 10 * 60 * 1000; const LOGIN_BLOCK_DURATION = 10 * 60 * 1000;
const loginAttempts = new Map<string, { count: number; first: number; blockedUntil: number }>(); const loginAttempts = new Map<string, { count: number; first: number; blockedUntil: number }>();
type AuthContext = { role: "admin"; id: "admin"; name: "管理员" } | { role: "teacher"; id: string; name: string };
type BuildState = { type BuildState = {
status: "idle" | "building" | "success" | "error"; status: "idle" | "building" | "success" | "error";
ok: boolean | null; ok: boolean | null;
...@@ -73,7 +85,25 @@ const sessionAuthed = async (request: Request): Promise<boolean> => { ...@@ -73,7 +85,25 @@ const sessionAuthed = async (request: Request): Promise<boolean> => {
return Boolean(token && safeEqual(token, await sessionToken())); return Boolean(token && safeEqual(token, await sessionToken()));
}; };
const authed = async (request: Request): Promise<boolean> => await sessionAuthed(request) || apiKeyValid(request); function teacherSessionMac(id: string, version: number): string {
return crypto.createHmac("sha256", SECRET).update(`qiyouxue-teacher-v1:${id}:${version}`).digest("hex");
}
async function teacherSession(request: Request): Promise<AuthContext | null> {
const token = cookies(request).cms_teacher_session || "";
const [id, versionValue, mac] = token.split(".");
const version = Number(versionValue);
if (!id || !Number.isInteger(version) || !mac || !safeEqual(mac, teacherSessionMac(id, version))) return null;
const currentVersion = await getTeacherSessionVersion(id);
if (currentVersion !== version) return null;
const teacher = (await listConsultationTeachers()).find((item) => item.id === id && item.enabled);
return teacher ? { role: "teacher", id: teacher.id, name: teacher.name } : null;
}
async function authContext(request: Request): Promise<AuthContext | null> {
if (await sessionAuthed(request) || apiKeyValid(request)) return { role: "admin", id: "admin", name: "管理员" };
return teacherSession(request);
}
async function bodyOf(request: Request): Promise<Record<string, unknown>> { async function bodyOf(request: Request): Promise<Record<string, unknown>> {
try { return await request.json(); } catch { return {}; } try { return await request.json(); } catch { return {}; }
...@@ -219,8 +249,17 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA ...@@ -219,8 +249,17 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA
}); });
} }
if (route === "consultations/claim" && method === "POST") {
const body = await bodyOf(request);
const result = await assignConsultation({ ...body, page: body.page || request.headers.get("referer") || "" });
return json({ ok: true, teacher: result.teacher }, 201);
}
if (route === "session") { if (route === "session") {
if (method === "GET") return json({ authed: await authed(request) }); if (method === "GET") {
const context = await authContext(request);
return json({ authed: Boolean(context), user: context });
}
if (method === "POST") { if (method === "POST") {
const blockedFor = loginBlockedFor(clientAddress); const blockedFor = loginBlockedFor(clientAddress);
if (blockedFor) return json({ error: `尝试过于频繁,请 ${Math.ceil(blockedFor / 60)} 分钟后再试` }, 429); if (blockedFor) return json({ error: `尝试过于频繁,请 ${Math.ceil(blockedFor / 60)} 分钟后再试` }, 429);
...@@ -229,17 +268,32 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA ...@@ -229,17 +268,32 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA
if (!captchaValid(request, body.captcha, body.captchaToken)) { if (!captchaValid(request, body.captcha, body.captchaToken)) {
return json({ error: "验证码错误或已过期" }, 400, { "Set-Cookie": clearCaptcha }); return json({ error: "验证码错误或已过期" }, 400, { "Set-Cookie": clearCaptcha });
} }
if (!await verifyPassword(body.password)) { const username = String(body.username || "").trim().toLowerCase();
const teacher = username && username !== "admin" ? await verifyTeacherCredentials(username, body.password) : null;
const adminValid = !teacher && (!username || username === "admin") && await verifyPassword(body.password);
if (!teacher && !adminValid) {
loginFailed(clientAddress); loginFailed(clientAddress);
return json({ error: "管理密码错误" }, 401, { "Set-Cookie": clearCaptcha }); return json({ error: "登录账号或密码错误" }, 401, { "Set-Cookie": clearCaptcha });
} }
loginAttempts.delete(clientAddress); loginAttempts.delete(clientAddress);
const headers = new Headers(); const headers = new Headers();
headers.append("Set-Cookie", clearCaptcha); headers.append("Set-Cookie", clearCaptcha);
if (teacher) {
headers.append("Set-Cookie", cookieHeader(request, "cms_session", "", 0));
const token = `${teacher.id}.${teacher.sessionVersion}.${teacherSessionMac(teacher.id, teacher.sessionVersion)}`;
headers.append("Set-Cookie", cookieHeader(request, "cms_teacher_session", token, 86400));
return json({ ok: true, user: { role: "teacher", id: teacher.id, name: teacher.name } }, 200, headers);
}
headers.append("Set-Cookie", cookieHeader(request, "cms_teacher_session", "", 0));
headers.append("Set-Cookie", cookieHeader(request, "cms_session", await sessionToken(), 86400)); headers.append("Set-Cookie", cookieHeader(request, "cms_session", await sessionToken(), 86400));
return json({ ok: true, user: { role: "admin", id: "admin", name: "管理员" } }, 200, headers);
}
if (method === "DELETE") {
const headers = new Headers();
headers.append("Set-Cookie", cookieHeader(request, "cms_session", "", 0));
headers.append("Set-Cookie", cookieHeader(request, "cms_teacher_session", "", 0));
return json({ ok: true }, 200, headers); return json({ ok: true }, 200, headers);
} }
if (method === "DELETE") return json({ ok: true }, 200, { "Set-Cookie": cookieHeader(request, "cms_session", "", 0) });
} }
if (route === "account/password" && method === "PUT") { if (route === "account/password" && method === "PUT") {
...@@ -257,7 +311,33 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA ...@@ -257,7 +311,33 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA
}); });
} }
if (!await authed(request)) throw new StoreError("请先登录文章后台", 401); const context = await authContext(request);
if (!context) throw new StoreError("请先登录后台", 401);
if (route === "consultation-teachers") {
if (context.role !== "admin") throw new StoreError("无权管理咨询老师", 403);
if (method === "GET") return json({ teachers: await listConsultationTeachers() });
if (method === "POST") return json({ teacher: await createConsultationTeacher(await bodyOf(request)) }, 201);
}
if (parts[0] === "consultation-teachers" && parts[1] && parts.length === 2) {
if (context.role !== "admin") throw new StoreError("无权管理咨询老师", 403);
if (method === "PUT") return json({ teacher: await updateConsultationTeacher(parts[1], await bodyOf(request)) });
if (method === "DELETE") {
await deleteConsultationTeacher(parts[1]);
return json({ ok: true });
}
}
if (route === "consultations" && method === "GET") {
return json({ consultations: await listConsultations(context.role === "teacher" ? context.id : undefined) });
}
if (parts[0] === "consultations" && parts[1] && parts.length === 2 && method === "PATCH") {
return json({ consultation: await updateConsultation(parts[1], await bodyOf(request), context.role === "teacher" ? context.id : undefined) });
}
if (context.role !== "admin") throw new StoreError("无权访问内容管理", 403);
if (route === "categories") { if (route === "categories") {
if (method === "GET") return json({ categories: await getCategoryNames(), stats: await getCategoryStats() }); if (method === "GET") return json({ categories: await getCategoryNames(), stats: await getCategoryStats() });
......
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { StoreError } from "./article-store";
const ROOT = process.cwd();
const DATA_ROOT = process.env.CMS_DATA_DIR ? path.resolve(process.env.CMS_DATA_DIR) : path.join(ROOT, ".runtime");
const STORE_FILE = path.join(DATA_ROOT, "consultations", "store.json");
const SCRYPT_OPTIONS = { N: 16384, r: 8, p: 1, maxmem: 32 * 1024 * 1024 };
const HASH_LENGTH = 64;
export type ConsultationStatus = "new" | "contacted" | "completed";
export interface ConsultationTeacher {
id: string;
name: string;
username: string;
title: string;
wechat: string;
qrCodeUrl: string;
sort: number;
enabled: boolean;
assignedCount: number;
lastAssignedAt: string | null;
passwordSalt: string;
passwordHash: string;
sessionVersion: number;
createdAt: string;
updatedAt: string;
}
export interface ConsultationRecord {
id: string;
phone: string;
teacherId: string;
teacherName: string;
teacherTitle: string;
teacherWechat: string;
teacherQrCodeUrl: string;
source: string;
page: string;
status: ConsultationStatus;
note: string;
createdAt: string;
updatedAt: string;
}
interface ConsultationState {
version: 1;
teachers: ConsultationTeacher[];
consultations: ConsultationRecord[];
lastAssignedTeacherId: string | null;
}
export type PublicTeacher = Pick<ConsultationTeacher, "id" | "name" | "title" | "wechat" | "qrCodeUrl">;
export type TeacherListItem = Omit<ConsultationTeacher, "passwordSalt" | "passwordHash">;
let mutationQueue: Promise<unknown> = Promise.resolve();
const emptyState = (): ConsultationState => ({ version: 1, teachers: [], consultations: [], lastAssignedTeacherId: null });
async function writeAtomic(file: string, content: string): Promise<void> {
await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
const temporary = `${file}.${process.pid}.${crypto.randomBytes(5).toString("hex")}.tmp`;
try {
await fs.writeFile(temporary, content, { mode: 0o600, flag: "wx" });
await fs.rename(temporary, file);
} catch (error) {
await fs.rm(temporary, { force: true }).catch(() => {});
throw error;
}
}
async function readState(): Promise<ConsultationState> {
try {
const parsed = JSON.parse(await fs.readFile(STORE_FILE, "utf8")) as Partial<ConsultationState>;
if (parsed.version !== 1 || !Array.isArray(parsed.teachers) || !Array.isArray(parsed.consultations)) {
throw new Error("咨询数据文件格式无效");
}
return {
version: 1,
teachers: parsed.teachers as ConsultationTeacher[],
consultations: parsed.consultations as ConsultationRecord[],
lastAssignedTeacherId: typeof parsed.lastAssignedTeacherId === "string" ? parsed.lastAssignedTeacherId : null,
};
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return emptyState();
throw error;
}
}
async function mutate<T>(operation: (state: ConsultationState) => Promise<T> | T): Promise<T> {
const pending = mutationQueue.then(async () => {
const state = await readState();
const result = await operation(state);
await writeAtomic(STORE_FILE, `${JSON.stringify(state, null, 2)}\n`);
return result;
});
mutationQueue = pending.then(() => undefined, () => undefined);
return pending;
}
function cleanText(value: unknown, label: string, maxLength: number, required = true): string {
const text = String(value || "").replace(/\s+/g, " ").trim();
if (required && !text) throw new StoreError(`请填写${label}`);
if (text.length > maxLength) throw new StoreError(`${label}不能超过 ${maxLength} 个字`);
return text;
}
function normalizeUsername(value: unknown): string {
const username = String(value || "").trim().toLowerCase();
if (!/^[a-z0-9_-]{3,32}$/.test(username)) throw new StoreError("登录账号需为 3-32 位字母、数字、下划线或短横线");
return username;
}
function normalizeSort(value: unknown): number {
const sort = Number(value);
if (!Number.isInteger(sort) || sort < 1 || sort > 9999) throw new StoreError("轮询顺序需为 1-9999 的整数");
return sort;
}
function normalizeQrCodeUrl(value: unknown): string {
const url = String(value || "").trim();
if (!/^\/uploads\/[a-zA-Z0-9._-]+$/.test(url)) throw new StoreError("请上传老师微信二维码");
return url;
}
async function passwordHash(password: string, salt: Buffer): Promise<string> {
return new Promise((resolve, reject) => {
crypto.scrypt(password, salt, HASH_LENGTH, SCRYPT_OPTIONS, (error, key) => {
if (error) reject(error);
else resolve(key.toString("base64"));
});
});
}
async function passwordFields(value: unknown): Promise<{ passwordSalt: string; passwordHash: string }> {
const password = String(value || "");
if (password.length < 6 || password.length > 128) throw new StoreError("老师登录密码需为 6-128 个字符");
const salt = crypto.randomBytes(16);
return { passwordSalt: salt.toString("base64"), passwordHash: await passwordHash(password, salt) };
}
function publicTeacher(teacher: ConsultationTeacher): PublicTeacher {
return { id: teacher.id, name: teacher.name, title: teacher.title, wechat: teacher.wechat, qrCodeUrl: teacher.qrCodeUrl };
}
function listItem(teacher: ConsultationTeacher): TeacherListItem {
const { passwordSalt: _passwordSalt, passwordHash: _passwordHash, ...item } = teacher;
return item;
}
export async function listConsultationTeachers(): Promise<TeacherListItem[]> {
const state = await readState();
return state.teachers.slice().sort((a, b) => a.sort - b.sort || a.createdAt.localeCompare(b.createdAt)).map(listItem);
}
export async function createConsultationTeacher(input: Record<string, unknown>): Promise<TeacherListItem> {
return mutate(async (state) => {
const username = normalizeUsername(input.username);
if (state.teachers.some((teacher) => teacher.username === username)) throw new StoreError("该登录账号已被使用", 409);
const now = new Date().toISOString();
const teacher: ConsultationTeacher = {
id: crypto.randomUUID(),
name: cleanText(input.name, "老师姓名", 30),
username,
title: cleanText(input.title, "展示称谓", 40),
wechat: cleanText(input.wechat, "微信号", 60, false),
qrCodeUrl: normalizeQrCodeUrl(input.qrCodeUrl),
sort: normalizeSort(input.sort),
enabled: input.enabled !== false,
assignedCount: 0,
lastAssignedAt: null,
...(await passwordFields(input.password)),
sessionVersion: 1,
createdAt: now,
updatedAt: now,
};
state.teachers.push(teacher);
return listItem(teacher);
});
}
export async function updateConsultationTeacher(id: string, input: Record<string, unknown>): Promise<TeacherListItem> {
return mutate(async (state) => {
const teacher = state.teachers.find((item) => item.id === id);
if (!teacher) throw new StoreError("咨询老师不存在", 404);
const username = normalizeUsername(input.username);
if (state.teachers.some((item) => item.id !== id && item.username === username)) throw new StoreError("该登录账号已被使用", 409);
teacher.name = cleanText(input.name, "老师姓名", 30);
teacher.username = username;
teacher.title = cleanText(input.title, "展示称谓", 40);
teacher.wechat = cleanText(input.wechat, "微信号", 60, false);
teacher.qrCodeUrl = normalizeQrCodeUrl(input.qrCodeUrl);
teacher.sort = normalizeSort(input.sort);
teacher.enabled = input.enabled !== false;
if (input.password) {
Object.assign(teacher, await passwordFields(input.password));
teacher.sessionVersion += 1;
}
teacher.updatedAt = new Date().toISOString();
return listItem(teacher);
});
}
export async function deleteConsultationTeacher(id: string): Promise<void> {
await mutate((state) => {
const index = state.teachers.findIndex((teacher) => teacher.id === id);
if (index < 0) throw new StoreError("咨询老师不存在", 404);
state.teachers.splice(index, 1);
if (state.lastAssignedTeacherId === id) state.lastAssignedTeacherId = null;
});
}
export async function verifyTeacherCredentials(usernameValue: unknown, passwordValue: unknown): Promise<ConsultationTeacher | null> {
const username = String(usernameValue || "").trim().toLowerCase();
const password = String(passwordValue || "");
if (!username || !password) return null;
const state = await readState();
const teacher = state.teachers.find((item) => item.username === username && item.enabled);
if (!teacher) return null;
const actual = await passwordHash(password, Buffer.from(teacher.passwordSalt, "base64"));
const left = Buffer.from(actual);
const right = Buffer.from(teacher.passwordHash);
return left.length === right.length && crypto.timingSafeEqual(left, right) ? teacher : null;
}
export async function getTeacherSessionVersion(id: string): Promise<number | null> {
const teacher = (await readState()).teachers.find((item) => item.id === id && item.enabled);
return teacher?.sessionVersion ?? null;
}
export async function assignConsultation(input: Record<string, unknown>): Promise<{ consultation: ConsultationRecord; teacher: PublicTeacher }> {
return mutate((state) => {
const phone = String(input.phone || "").replace(/\s+/g, "");
if (!/^1[3-9]\d{9}$/.test(phone)) throw new StoreError("请输入正确的 11 位手机号");
const teachers = state.teachers.filter((teacher) => teacher.enabled).sort((a, b) => a.sort - b.sort || a.createdAt.localeCompare(b.createdAt));
if (!teachers.length) throw new StoreError("咨询老师暂未配置,请稍后再试或拨打客服电话", 503);
const previousIndex = teachers.findIndex((teacher) => teacher.id === state.lastAssignedTeacherId);
const teacher = teachers[(previousIndex + 1) % teachers.length];
const now = new Date().toISOString();
const consultation: ConsultationRecord = {
id: crypto.randomUUID(),
phone,
teacherId: teacher.id,
teacherName: teacher.name,
teacherTitle: teacher.title,
teacherWechat: teacher.wechat,
teacherQrCodeUrl: teacher.qrCodeUrl,
source: cleanText(input.source, "来源", 80, false) || "官网学情分析",
page: cleanText(input.page, "页面", 300, false),
status: "new",
note: "",
createdAt: now,
updatedAt: now,
};
state.consultations.unshift(consultation);
state.lastAssignedTeacherId = teacher.id;
teacher.assignedCount += 1;
teacher.lastAssignedAt = now;
teacher.updatedAt = now;
return { consultation, teacher: publicTeacher(teacher) };
});
}
export async function listConsultations(teacherId?: string): Promise<ConsultationRecord[]> {
const records = (await readState()).consultations;
return records.filter((item) => !teacherId || item.teacherId === teacherId).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
export async function updateConsultation(id: string, input: Record<string, unknown>, teacherId?: string): Promise<ConsultationRecord> {
return mutate((state) => {
const consultation = state.consultations.find((item) => item.id === id && (!teacherId || item.teacherId === teacherId));
if (!consultation) throw new StoreError("咨询记录不存在", 404);
const status = String(input.status || consultation.status) as ConsultationStatus;
if (!["new", "contacted", "completed"].includes(status)) throw new StoreError("咨询状态无效");
consultation.status = status;
consultation.note = cleanText(input.note, "跟进备注", 500, false);
consultation.updatedAt = new Date().toISOString();
return consultation;
});
}
...@@ -11,8 +11,9 @@ ...@@ -11,8 +11,9 @@
<section id="login" class="login hidden"> <section id="login" class="login hidden">
<form id="login-form" class="login-card"> <form id="login-form" class="login-card">
<div class="brand-mark">启优学</div> <div class="brand-mark">启优学</div>
<h1>学习资讯后台</h1> <h1>启优学管理后台</h1>
<p>输入管理密码和图片验证码,管理官网文章内容。</p> <p>管理员和咨询老师均可使用自己的账号登录。</p>
<label>登录账号<input id="username" autocomplete="username" placeholder="管理员填写 admin" required /></label>
<label>管理密码<input id="password" type="password" autocomplete="current-password" required /></label> <label>管理密码<input id="password" type="password" autocomplete="current-password" required /></label>
<label>图片验证码</label> <label>图片验证码</label>
<div class="captcha-row"> <div class="captcha-row">
...@@ -24,18 +25,46 @@ ...@@ -24,18 +25,46 @@
</form> </form>
</section> </section>
<div id="app" class="hidden"> <div id="app" class="admin-shell hidden">
<aside class="sidebar">
<div class="sidebar-brand"><span>启优学</span><strong>管理后台</strong></div>
<nav aria-label="后台功能">
<p>咨询服务</p>
<button class="sidebar-link active" data-admin-nav="consultations" type="button"><span>咨询记录</span><b id="consultation-count">0</b></button>
<button class="sidebar-link admin-only" data-admin-nav="teachers" type="button"><span>咨询老师</span></button>
<p class="admin-only">官网内容</p>
<button class="sidebar-link admin-only" data-admin-nav="articles" type="button"><span>学习资讯</span><b id="sidebar-pending" class="hidden">0</b></button>
</nav>
<div class="sidebar-account"><small id="account-role">当前账号</small><strong id="account-name"></strong></div>
</aside>
<div class="admin-main">
<header class="topbar"> <header class="topbar">
<div><strong>启优学</strong><span>学习资讯后台</span></div> <div><strong id="page-title">咨询记录</strong><span id="page-subtitle">查看和跟进官网咨询</span></div>
<div class="top-actions"> <div class="top-actions">
<span id="pending-badge" class="pending-badge hidden"></span> <span id="pending-badge" class="pending-badge hidden"></span>
<a class="button ghost" href="/articles/" target="_blank" rel="noopener">查看前台</a> <a class="button ghost" href="/" target="_blank" rel="noopener">查看官网</a>
<button id="change-password" class="ghost" type="button">修改密码</button> <button id="change-password" class="ghost admin-only" type="button">修改管理员密码</button>
<button id="logout" class="ghost">退出</button> <button id="logout" class="ghost">退出</button>
</div> </div>
</header> </header>
<main id="list-view" class="view"> <main id="consultations-view" class="view consultation-view">
<div class="view-head"><div><h1>咨询记录</h1><span id="consultation-scope">管理员可查看全部老师的咨询</span></div><button id="refresh-consultations" class="ghost" type="button">刷新</button></div>
<div class="metric-grid">
<article><small>待联系</small><strong id="metric-new">0</strong></article>
<article><small>跟进中</small><strong id="metric-contacted">0</strong></article>
<article><small>已完成</small><strong id="metric-completed">0</strong></article>
<article><small>全部咨询</small><strong id="metric-total">0</strong></article>
</div>
<div class="table-card"><div id="consultation-list" class="data-list"></div></div>
</main>
<main id="teachers-view" class="view hidden admin-only">
<div class="view-head"><div><p>ROUND ROBIN</p><h1>咨询老师</h1><span>启用的老师按顺序轮询分配,排序数字越小越靠前。</span></div><button id="new-teacher" class="primary" type="button">+ 添加老师</button></div>
<div class="table-card"><div id="teacher-list" class="data-list"></div></div>
</main>
<main id="list-view" class="view hidden">
<div class="view-head"><div><p>CONTENT</p><h1>学习资讯</h1></div><div class="view-actions"><button id="manage-categories" class="ghost">管理分类</button><button id="import-article" class="ghost">导入文件</button><button id="new-article" class="primary">+ 新建文章</button></div></div> <div class="view-head"><div><p>CONTENT</p><h1>学习资讯</h1></div><div class="view-actions"><button id="manage-categories" class="ghost">管理分类</button><button id="import-article" class="ghost">导入文件</button><button id="new-article" class="primary">+ 新建文章</button></div></div>
<input id="article-file" class="hidden" type="file" accept=".md,.docx,text/markdown,application/vnd.openxmlformats-officedocument.wordprocessingml.document" /> <input id="article-file" class="hidden" type="file" accept=".md,.docx,text/markdown,application/vnd.openxmlformats-officedocument.wordprocessingml.document" />
<p id="import-status" class="import-status" aria-live="polite"></p> <p id="import-status" class="import-status" aria-live="polite"></p>
...@@ -91,6 +120,32 @@ ...@@ -91,6 +120,32 @@
</form> </form>
</main> </main>
</div> </div>
</div>
<div id="teacher-modal" class="modal hidden">
<div class="modal-card teacher-card">
<div class="modal-head"><div><strong id="teacher-modal-title">添加咨询老师</strong><small>账号用于老师登录后台查看自己的咨询</small></div><button id="close-teacher-modal" class="ghost small" type="button">关闭</button></div>
<form id="teacher-form" class="teacher-form">
<input id="teacher-id" type="hidden" />
<div class="form-grid">
<label>老师姓名<input id="teacher-name" maxlength="30" required /></label>
<label>展示称谓<input id="teacher-title" maxlength="40" placeholder="例如:教务主任|全科一对一" required /></label>
<label>登录账号<input id="teacher-username" maxlength="32" autocomplete="off" placeholder="字母、数字、下划线" required /></label>
<label>微信号<input id="teacher-wechat" maxlength="60" placeholder="选填" /></label>
<label>轮询顺序<input id="teacher-sort" type="number" min="1" max="9999" value="1" required /></label>
<label>登录密码<input id="teacher-password" type="password" minlength="6" maxlength="128" autocomplete="new-password" /><small id="teacher-password-hint">至少 6 个字符</small></label>
</div>
<label class="qr-field">微信二维码
<input id="teacher-qr-file" class="hidden" type="file" accept="image/png,image/jpeg,image/webp,image/gif" />
<input id="teacher-qr-url" type="hidden" />
<button id="teacher-qr-button" class="qr-upload" type="button"><img id="teacher-qr-preview" class="hidden" alt="老师微信二维码预览" /><span id="teacher-qr-label">点击上传二维码</span></button>
</label>
<label class="switch-row"><input id="teacher-enabled" type="checkbox" checked /><span>启用并参与轮询分配</span></label>
<p id="teacher-error" class="error"></p>
<div class="password-actions"><button id="cancel-teacher" class="ghost" type="button">取消</button><button class="primary" type="submit">保存老师</button></div>
</form>
</div>
</div>
<div id="category-modal" class="modal hidden"> <div id="category-modal" class="modal hidden">
<div class="modal-card category-manager"> <div class="modal-card category-manager">
......
...@@ -21,7 +21,7 @@ const subjects = [ ...@@ -21,7 +21,7 @@ const subjects = [
const jsonLd = [{ "@context": "https://schema.org", "@type": "Service", name: "启优学在线一对一个性化辅导", provider: { "@id": new URL("/#organization", Astro.site).href }, areaServed: { "@type": "Country", name: "中国" }, audience: { "@type": "EducationalAudience", educationalRole: "student" }, description: site.seo.description }, { "@context": "https://schema.org", "@type": "FAQPage", mainEntity: site.faq.slice(0, 5).map((item) => ({ "@type": "Question", name: item.q, acceptedAnswer: { "@type": "Answer", text: item.a } })) }]; const jsonLd = [{ "@context": "https://schema.org", "@type": "Service", name: "启优学在线一对一个性化辅导", provider: { "@id": new URL("/#organization", Astro.site).href }, areaServed: { "@type": "Country", name: "中国" }, audience: { "@type": "EducationalAudience", educationalRole: "student" }, description: site.seo.description }, { "@context": "https://schema.org", "@type": "FAQPage", mainEntity: site.faq.slice(0, 5).map((item) => ({ "@type": "Question", name: item.q, acceptedAnswer: { "@type": "Answer", text: item.a } })) }];
--- ---
<Base title={site.seo.title} jsonLd={jsonLd}><Header /><main id="main-content"> <Base title={site.seo.title} jsonLd={jsonLd}><Header /><main id="main-content">
<section class="home-hero"><div class="container home-hero__grid"><div class="home-hero__copy" data-reveal><div class="hero-label"><strong>启优学一对一在线辅导</strong></div><h1><span>优质师资直达,个性化辅导看得见</span><em>打破地域壁垒,让优质教育触手可及</em></h1><p class="hero-subhead"><span>{site.home.subhead}</span><span>覆盖小学 1-6 年级、初中 1-3 年级、高中 1-3 年级</span></p><div class="hero__actions"><a class="button" href="/contact/">免费领取学情分析</a><a class="button button--outline" href="#workflow">了解学习流程</a></div><div class="hero__proof"><span>固定教师</span><span>互动课堂</span><span>课程回放</span><span>阶段复盘</span></div></div><div class="home-hero__visual" data-reveal><div class="image-frame"><Image src={heroImage} widths={[580, 760, 980]} sizes="(max-width: 760px) 100vw, 52vw" alt="启优学老师与学生在线一对一互动学习" format="webp" loading="eager" fetchpriority="high" /></div></div></div></section> <section class="home-hero"><div class="container home-hero__grid"><div class="home-hero__copy" data-reveal><div class="hero-label"><strong>启优学一对一在线辅导</strong></div><h1><span>优质师资直达,个性化辅导看得见</span><em>打破地域壁垒,让优质教育触手可及</em></h1><p class="hero-subhead"><span>{site.home.subhead}</span><span>覆盖小学 1-6 年级、初中 1-3 年级、高中 1-3 年级</span></p><div class="hero__actions"><a class="button" href="/contact/" data-open-contact>免费领取学情分析</a><a class="button button--outline" href="#workflow">了解学习流程</a></div><div class="hero__proof"><span>固定教师</span><span>互动课堂</span><span>课程回放</span><span>阶段复盘</span></div></div><div class="home-hero__visual" data-reveal><div class="image-frame"><Image src={heroImage} widths={[580, 760, 980]} sizes="(max-width: 760px) 100vw, 52vw" alt="启优学老师与学生在线一对一互动学习" format="webp" loading="eager" fetchpriority="high" /></div></div></div></section>
<section class="stat-band"><div class="container stat-grid">{site.home.stats.map((stat) => <div data-reveal><strong>{stat.value}</strong><p>{stat.label}</p></div>)}</div><p class="data-note">师资数据来源于启优学一对一教务系统内部统计,截至 2026 年 7 月;相关数据会随师资及业务情况动态更新。</p></section> <section class="stat-band"><div class="container stat-grid">{site.home.stats.map((stat) => <div data-reveal><strong>{stat.value}</strong><p>{stat.label}</p></div>)}</div><p class="data-note">师资数据来源于启优学一对一教务系统内部统计,截至 2026 年 7 月;相关数据会随师资及业务情况动态更新。</p></section>
......
...@@ -16,6 +16,11 @@ ...@@ -16,6 +16,11 @@
@media(max-width:760px){.container{width:min(calc(100% - 32px),var(--container))}.section-pad{padding:72px 0}.section-intro-row{display:block;margin-bottom:34px}.section-intro-row>.text-link{display:inline-block;margin-top:18px}.section-heading h2{font-size:36px}.home-hero{padding-top:38px}.home-hero__grid,.learning-map,.teacher-layout,.feedback-layout,.faq-preview,.faq-layout,.service-hero__inner,.service-cap-layout,.service-feature-layout,.about-intro-layout,.principle-layout,.delivery-layout,.contact-hero__grid,.visit-grid{grid-template-columns:1fr;gap:40px}.home-hero h1{font-size:34px;line-height:1.32}.home-hero h1 span,.home-hero h1 em{white-space:normal}.home-hero__copy>p{font-size:16px}.home-hero__visual{margin-top:10px;margin-left:0}.stage-ruler{gap:10px;font-size:11px}.subject-overview__head{margin-bottom:42px;text-align:left}.subject-overview__head h2{font-size:38px}.subject-overview__lead{font-size:17px}.subject-overview__grid{grid-template-columns:1fr}.subject-overview__grid article{min-height:auto;padding:24px}.stat-grid{grid-template-columns:1fr 1fr;row-gap:28px}.stat-grid>div:nth-child(2){border:0}.stat-grid strong{font-size:34px}.need-grid,.service-cap-grid,.stage-grid,.advantage-grid,.delivery-grid,.principle-grid,.article-grid,.article-list-grid,.contact-grid,.contact-dialog__grid{grid-template-columns:1fr}.need-card{min-height:260px}.need-card h3{margin-top:0}.need-scope{display:block}.need-scope>div{margin:14px 0}.need-scope p{margin:0}.learning-map__copy,.faq-preview>div:first-child,.faq-layout aside{position:static}.teacher-layout h2{font-size:40px}.feedback-layout .feedback-board{order:2}.faq-list details>div{padding-left:0}.page-hero{min-height:auto;padding:68px 0}.page-hero h1{font-size:43px}.anchor-nav{top:76px;overflow:auto}.anchor-nav .container{width:max-content;grid-template-columns:repeat(4,150px)}.service-detail__content{grid-template-columns:1fr;gap:25px;padding:28px}.service-hero{padding:62px 0}.process-grid,.method-flow{grid-template-columns:1fr 1fr}.process-grid article,.method-flow article{border-bottom:1px solid rgba(255,255,255,.24)}.related-services .container{display:block}.related-services a{border-bottom:1px solid var(--border)}.principle-layout h2,.visit-grid h2{font-size:38px}.contact-hours{display:none}.contact-grid article{min-height:auto}.contact-big-link{font-size:25px}.article-layout,.article-detail-layout{grid-template-columns:1fr}.article-sidebar{display:none}.article-detail-layout{padding:48px 16px}.cta-band__inner,.cta-band__actions{align-items:flex-start;flex-direction:column}.footer-grid{grid-template-columns:1fr 1fr}.footer-brand{grid-column:1/3}.footer-contact{grid-column:1/3}.footer-bottom{flex-direction:column}.contact-dialog__panel{padding:26px 20px}.contact-dialog__heading h2{font-size:29px}} @media(max-width:760px){.container{width:min(calc(100% - 32px),var(--container))}.section-pad{padding:72px 0}.section-intro-row{display:block;margin-bottom:34px}.section-intro-row>.text-link{display:inline-block;margin-top:18px}.section-heading h2{font-size:36px}.home-hero{padding-top:38px}.home-hero__grid,.learning-map,.teacher-layout,.feedback-layout,.faq-preview,.faq-layout,.service-hero__inner,.service-cap-layout,.service-feature-layout,.about-intro-layout,.principle-layout,.delivery-layout,.contact-hero__grid,.visit-grid{grid-template-columns:1fr;gap:40px}.home-hero h1{font-size:34px;line-height:1.32}.home-hero h1 span,.home-hero h1 em{white-space:normal}.home-hero__copy>p{font-size:16px}.home-hero__visual{margin-top:10px;margin-left:0}.stage-ruler{gap:10px;font-size:11px}.subject-overview__head{margin-bottom:42px;text-align:left}.subject-overview__head h2{font-size:38px}.subject-overview__lead{font-size:17px}.subject-overview__grid{grid-template-columns:1fr}.subject-overview__grid article{min-height:auto;padding:24px}.stat-grid{grid-template-columns:1fr 1fr;row-gap:28px}.stat-grid>div:nth-child(2){border:0}.stat-grid strong{font-size:34px}.need-grid,.service-cap-grid,.stage-grid,.advantage-grid,.delivery-grid,.principle-grid,.article-grid,.article-list-grid,.contact-grid,.contact-dialog__grid{grid-template-columns:1fr}.need-card{min-height:260px}.need-card h3{margin-top:0}.need-scope{display:block}.need-scope>div{margin:14px 0}.need-scope p{margin:0}.learning-map__copy,.faq-preview>div:first-child,.faq-layout aside{position:static}.teacher-layout h2{font-size:40px}.feedback-layout .feedback-board{order:2}.faq-list details>div{padding-left:0}.page-hero{min-height:auto;padding:68px 0}.page-hero h1{font-size:43px}.anchor-nav{top:76px;overflow:auto}.anchor-nav .container{width:max-content;grid-template-columns:repeat(4,150px)}.service-detail__content{grid-template-columns:1fr;gap:25px;padding:28px}.service-hero{padding:62px 0}.process-grid,.method-flow{grid-template-columns:1fr 1fr}.process-grid article,.method-flow article{border-bottom:1px solid rgba(255,255,255,.24)}.related-services .container{display:block}.related-services a{border-bottom:1px solid var(--border)}.principle-layout h2,.visit-grid h2{font-size:38px}.contact-hours{display:none}.contact-grid article{min-height:auto}.contact-big-link{font-size:25px}.article-layout,.article-detail-layout{grid-template-columns:1fr}.article-sidebar{display:none}.article-detail-layout{padding:48px 16px}.cta-band__inner,.cta-band__actions{align-items:flex-start;flex-direction:column}.footer-grid{grid-template-columns:1fr 1fr}.footer-brand{grid-column:1/3}.footer-contact{grid-column:1/3}.footer-bottom{flex-direction:column}.contact-dialog__panel{padding:26px 20px}.contact-dialog__heading h2{font-size:29px}}
@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}*,*::before,*::after{animation-duration:.01ms!important;transition-duration:.01ms!important}[data-reveal]{opacity:1;transform:none}} @media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}*,*::before,*::after{animation-duration:.01ms!important;transition-duration:.01ms!important}[data-reveal]{opacity:1;transform:none}}
@media print{.site-header,.site-footer,.cta-band,.mobile-nav{display:none}[data-reveal]{opacity:1;transform:none}} @media print{.site-header,.site-footer,.cta-band,.mobile-nav{display:none}[data-reveal]{opacity:1;transform:none}}
.contact-dialog{width:min(520px,calc(100% - 28px));border-radius:24px}.contact-dialog__panel{padding:34px 38px 28px;border:0;border-radius:24px;box-shadow:0 28px 80px rgba(18,58,94,.22)}.contact-dialog__close{top:16px;right:16px;color:#526171;background:#f1f4f7;font-size:25px}.consultation-mark{width:52px;height:52px;margin:2px auto 20px;display:grid;place-items:center;border-radius:17px;color:#fff;background:linear-gradient(145deg,#42d99a,#16b77c);box-shadow:0 0 0 9px #e8faf2;transform:rotate(-7deg)}.consultation-mark span{font-size:12px;font-weight:800;transform:rotate(7deg)}.contact-dialog__heading{max-width:100%;text-align:center}.contact-dialog__heading small{color:#20b67b;font-size:10px;font-weight:800;letter-spacing:.18em}.contact-dialog__heading h2{margin:9px 0;color:#1c2e43;font-size:30px;letter-spacing:-.04em}.contact-dialog__heading p{margin:0;color:#6f7d8a;font-size:14px;line-height:1.7}.consultation-form{margin-top:26px}.consultation-form>label:first-child{display:block;margin-bottom:8px;color:#30455a;font-size:13px;font-weight:700}.consultation-phone-row{display:flex;align-items:center;border:1px solid #dbe4ec;border-radius:12px;background:#fbfdff}.consultation-phone-row:focus-within{border-color:#38bd8a;box-shadow:0 0 0 3px rgba(56,189,138,.12)}.consultation-phone-row span{padding:0 15px;color:#7f8c99;font-size:14px;border-right:1px solid #e5ebf0}.consultation-phone-row input{min-width:0;padding:14px;border:0;background:transparent;outline:0;font-size:16px}.consultation-consent{display:flex;gap:7px;margin-top:13px;color:#8a96a1;font-size:11px;line-height:1.5}.consultation-consent input{width:14px;height:14px;flex:0 0 auto;margin-top:1px;accent-color:#1fbb80}.consultation-error{min-height:18px;margin:10px 0 0;color:#d05b5b;font-size:12px}.consultation-submit{width:100%;margin-top:4px;justify-content:center;border:0;border-radius:12px;color:#fff;background:#22bf82;box-shadow:0 8px 18px rgba(34,191,130,.22)}.consultation-trust{display:flex;justify-content:center;gap:13px;margin-top:18px;color:#a0aab4;font-size:10px}.consultation-trust span+span{padding-left:13px;border-left:1px solid #e1e7eb}.consultation-success{text-align:center}.success-check{width:60px;height:60px;margin:5px auto 18px;display:grid;place-items:center;border-radius:50%;color:#fff;background:#2dcc88;box-shadow:0 0 0 10px #e8faf2;font-size:38px;font-weight:700}.consultation-success h2{margin:0 0 10px;color:#1c2e43;font-size:32px}.consultation-success>p{margin:0;color:#536879;font-size:15px}.assigned-teacher{margin:28px auto 0;padding:18px 18px 15px;display:grid;grid-template-columns:1fr 130px;gap:4px 18px;align-items:center;text-align:left;border:1px solid #e3edf2;border-radius:15px;background:#fbfefd}.assigned-teacher__name{display:grid;gap:5px}.assigned-teacher__name span{color:#7d8b97;font-size:11px}.assigned-teacher__name strong{color:#263b4f;font-size:18px}.assigned-teacher__name small{color:#5f7b72;font-size:12px}.assigned-teacher img{grid-column:2;grid-row:1 / span 3;width:130px;height:130px;object-fit:contain;border-radius:8px;background:#fff}.assigned-teacher p{grid-column:1;margin:8px 0 0;color:#607484;font-size:12px}.assigned-teacher>small{grid-column:1 / -1;margin-top:12px;color:#9aa7b1;font-size:10px;text-align:center}.assigned-teacher p strong{color:#2e9f78}
@media(max-width:760px){.contact-dialog__panel{padding:30px 20px 24px}.contact-dialog__heading h2{font-size:29px}.consultation-trust{gap:8px;font-size:9px}.consultation-trust span+span{padding-left:8px}.assigned-teacher{grid-template-columns:1fr 112px;gap:4px 10px;padding:15px}.assigned-teacher img{width:112px;height:112px}}
.contact-dialog--form{width:min(480px,calc(100% - 28px))}.contact-dialog--teacher{width:min(440px,calc(100% - 28px))}.contact-dialog--form .contact-dialog__panel,.contact-dialog--teacher .contact-dialog__panel{padding:36px 36px 32px;border-radius:16px}.contact-dialog--form .contact-dialog__heading{text-align:left}.contact-dialog--form .contact-dialog__heading h2{margin:0 42px 10px 0;color:#1e3248;font-size:28px;letter-spacing:0}.contact-dialog--form .contact-dialog__heading p{max-width:390px;color:#66798a;line-height:1.75}.contact-dialog--form .consultation-form{margin-top:25px}.contact-dialog--form .consultation-submit{margin-top:2px;border-radius:9px;background:var(--brand-blue);box-shadow:none}.contact-dialog--form .consultation-submit:hover{background:var(--brand-blue-dark)}.contact-dialog--form .consultation-phone-row{border-radius:9px}.teacher-dialog__heading{padding-right:34px}.teacher-dialog__heading h2{margin:0 0 9px;color:#1e3248;font-size:27px;letter-spacing:0}.teacher-dialog__heading p{margin:0;color:#66798a;font-size:14px;line-height:1.7}.teacher-contact{margin-top:24px;padding-top:22px;display:grid;justify-items:center;border-top:1px solid #e2eaf0;text-align:center}.teacher-contact__identity{display:grid;gap:6px}.teacher-contact__identity strong{color:#1e3248;font-size:20px}.teacher-contact__identity span{color:#718393;font-size:13px}.teacher-contact img{width:min(230px,72vw);height:min(230px,72vw);margin-top:18px;object-fit:contain;background:#fff}.teacher-contact>p{margin:16px 0 0;color:#607485;font-size:13px}.teacher-contact>p strong{color:#1e5d9e}.contact-dialog--form .contact-dialog__close,.contact-dialog--teacher .contact-dialog__close{top:14px;right:14px;border-radius:50%;color:#5d6b78;background:#f1f4f7}.consultation-mark,.consultation-trust,.consultation-success,.assigned-teacher{display:none!important}
@media(max-width:760px){.contact-dialog--form .contact-dialog__panel,.contact-dialog--teacher .contact-dialog__panel{padding:30px 22px 26px}.contact-dialog--form .contact-dialog__heading h2,.teacher-dialog__heading h2{font-size:25px}.teacher-contact img{width:min(210px,68vw);height:min(210px,68vw)}}
.teacher-wechat{display:flex;align-items:center;justify-content:center;gap:6px}.teacher-wechat button{min-height:28px;padding:0 8px;border:0;border-radius:5px;color:var(--brand-blue);background:var(--bg-blue-soft);font-size:12px;cursor:pointer}.teacher-wechat button:hover{background:#dcecff}.teacher-wechat button:focus-visible{outline:2px solid var(--brand-blue);outline-offset:2px}
.brand{display:flex;width:108px;height:68px;align-items:center;overflow:visible}.brand img{width:100%;height:auto;max-height:66px;object-fit:contain}.footer-logo{display:flex;width:148px;height:100px;align-items:center;overflow:visible;border-radius:0}.footer-logo img{width:100%;height:auto;max-height:96px;object-fit:contain} .brand{display:flex;width:108px;height:68px;align-items:center;overflow:visible}.brand img{width:100%;height:auto;max-height:66px;object-fit:contain}.footer-logo{display:flex;width:148px;height:100px;align-items:center;overflow:visible;border-radius:0}.footer-logo img{width:100%;height:auto;max-height:96px;object-fit:contain}
.image-frame{aspect-ratio:1.22}.image-frame img{height:100%;aspect-ratio:auto}.article-tabs{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:32px}.article-tab{display:flex;align-items:center;gap:8px;padding:8px 13px;border:1px solid var(--border);border-radius:7px;background:#fff;color:var(--text-secondary);font-size:13px}.article-tab span{color:var(--text-muted);font-size:11px}.article-tab:hover,.article-tab.active{border-color:var(--brand-blue);color:var(--brand-blue)}.article-tab.active{background:var(--brand-accent-soft)}.article-empty{padding:60px;border:1px dashed var(--border);background:#fff;color:var(--text-muted);text-align:center}.article-pagination{display:flex;justify-content:center;gap:7px;margin-top:36px}.article-pagination>a,.article-pagination>span{display:grid;place-items:center;width:38px;height:38px;border:1px solid var(--border);border-radius:6px;background:#fff}.article-pagination .active{border-color:var(--brand-blue);color:#fff;background:var(--brand-blue)} .image-frame{aspect-ratio:1.22}.image-frame img{height:100%;aspect-ratio:auto}.article-tabs{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:32px}.article-tab{display:flex;align-items:center;gap:8px;padding:8px 13px;border:1px solid var(--border);border-radius:7px;background:#fff;color:var(--text-secondary);font-size:13px}.article-tab span{color:var(--text-muted);font-size:11px}.article-tab:hover,.article-tab.active{border-color:var(--brand-blue);color:var(--brand-blue)}.article-tab.active{background:var(--brand-accent-soft)}.article-empty{padding:60px;border:1px dashed var(--border);background:#fff;color:var(--text-muted);text-align:center}.article-pagination{display:flex;justify-content:center;gap:7px;margin-top:36px}.article-pagination>a,.article-pagination>span{display:grid;place-items:center;width:38px;height:38px;border:1px solid var(--border);border-radius:6px;background:#fff}.article-pagination .active{border-color:var(--brand-blue);color:#fff;background:var(--brand-blue)}
.home-hero h1,.page-hero h1,.teacher-layout h2{font-weight:600;letter-spacing:-.025em}.stage-ruler{font-weight:600}.stat-grid strong{font-weight:600}.nav-link{font-weight:500}.mobile-nav__primary{font-weight:600}.need-card>strong,.footer-phone{font-weight:600} .home-hero h1,.page-hero h1,.teacher-layout h2{font-weight:600;letter-spacing:-.025em}.stage-ruler{font-weight:600}.stat-grid strong{font-weight:600}.nav-link{font-weight:500}.mobile-nav__primary{font-weight:600}.need-card>strong,.footer-phone{font-weight:600}
......
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