Commit 19e1bb16 authored by tao355667's avatar tao355667

feat: add consultation assignment workflow

parent 780a2844
This diff is collapsed.
...@@ -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() });
......
This diff is collapsed.
...@@ -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>
...@@ -90,6 +119,32 @@ ...@@ -90,6 +119,32 @@
</div> </div>
</form> </form>
</main> </main>
</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>
<div id="category-modal" class="modal hidden"> <div id="category-modal" class="modal hidden">
......
...@@ -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>
......
This diff is collapsed.
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