Commit 3d8d4534 authored by tao355667's avatar tao355667

feat: expand consultation admin roles

parent 19e1bb16
This diff is collapsed.
...@@ -116,8 +116,8 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(20,115,230,. ...@@ -116,8 +116,8 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(20,115,230,.
.menu-action.danger { color: var(--danger); } .menu-action.danger { color: var(--danger); }
.menu-action.danger:hover { background: #fff0f0; } .menu-action.danger:hover { background: #fff0f0; }
#message { margin-left: 8px; color: var(--muted); font-size: 13px; } #message { margin-left: 8px; color: var(--muted); font-size: 13px; }
.modal { position: fixed; inset: 0; z-index: 30; padding: 20px; display: grid; place-items: center; background: rgba(0,0,0,.6); } .modal { position: fixed; inset: 0; z-index: 30; padding: 20px; display: grid; place-items: center; overflow-y: auto; overscroll-behavior: contain; background: rgba(0,0,0,.6); }
.modal-card { width: min(780px, 100%); max-height: 82vh; background: white; border-radius: 16px; overflow: hidden; } .modal-card { width: min(780px, 100%); max-height: calc(100dvh - 40px); display: flex; flex-direction: column; background: white; border-radius: 16px; overflow: hidden; }
.modal-card > div { padding: 15px 18px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--border); } .modal-card > div { padding: 15px 18px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--border); }
.category-manager { width: min(680px, 100%); } .category-manager { width: min(680px, 100%); }
.modal-card > .modal-head { padding: 18px 20px; } .modal-card > .modal-head { padding: 18px 20px; }
...@@ -181,27 +181,47 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(20,115,230,. ...@@ -181,27 +181,47 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(20,115,230,.
.metric-grid article:last-child { border-right: 0; } .metric-grid article:last-child { border-right: 0; }
.metric-grid small { color: var(--muted); font-size: 12px; } .metric-grid small { color: var(--muted); font-size: 12px; }
.metric-grid strong { color: #12365f; font-size: 20px; } .metric-grid strong { color: #12365f; font-size: 20px; }
.table-card { overflow: hidden; background: white; border: 1px solid var(--border); border-radius: 10px; } .table-card { overflow-x: auto; overflow-y: hidden; background: white; border: 1px solid var(--border); border-radius: 10px; scrollbar-color: #b8c7d8 #f2f5f8; scrollbar-width: thin; }
.data-list { min-width: 850px; } .table-card::-webkit-scrollbar { height: 9px; }
.data-row { display: grid; align-items: center; gap: 16px; padding: 14px 18px; border-bottom: 1px solid var(--border); } .table-card::-webkit-scrollbar-track { background: #f2f5f8; }
.table-card::-webkit-scrollbar-thumb { background: #b8c7d8; border: 2px solid #f2f5f8; border-radius: 999px; }
.data-list { min-width: 1320px; }
.data-row { display: grid; align-items: center; gap: 10px; padding: 11px 14px; border-bottom: 1px solid var(--border); font-size: 12px; }
.data-row:last-child { border-bottom: 0; } .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; } .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; } .consultation-row { grid-template-columns: 44px 76px 108px 76px 82px 66px 112px 150px 68px 102px 214px; }
.teacher-row { grid-template-columns: 1.45fr .85fr .45fr .65fr .55fr .9fr; } .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; } .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; } .phone-link { color: var(--text); font-size: 12px; font-weight: 600; text-decoration: none; }
.page { max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .page { max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.created,.teacher-name { font-size: 13px; } .created,.teacher-name { font-size: 12px; }
.created { color: var(--muted); } .created { color: var(--muted); }
.sequence,.call-count { color: var(--muted); font-size: 12px; }
.user-name,.location,.unadded-reason,.note-summary,.consultation-status { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
.consultation-row > :last-child { position: sticky; right: 0; z-index: 2; min-height: 100%; align-content: center; background: #fff; box-shadow: -10px 0 14px -14px rgba(19,48,79,.75); }
.consultation-row.data-head > :last-child { z-index: 3; background: #f7f9fb; }
.consultation-row .row-actions { gap: 5px; flex-wrap: nowrap; }
.consultation-row .row-actions button { min-height: 29px; padding-inline: 9px; font-size: 11px; }
.inline-follow-form { display: contents; } .inline-follow-form { display: contents; }
.inline-follow-form select { padding: 8px 10px; font-size: 13px; } .inline-follow-form select { padding: 8px 10px; font-size: 13px; }
.inline-note { min-width: 0; display: grid; gap: 4px; } .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 { height: 54px; min-height: 54px; padding: 7px 10px; overflow: hidden; resize: none; font-size: 13px; line-height: 1.5; }
.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 { 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; } .note-toggle:hover { text-decoration: underline; }
.inline-follow-actions { display: grid; justify-items:start; gap: 2px; } .inline-follow-actions { display: grid; justify-items:start; gap: 2px; }
.inline-follow-actions .save-state { min-height: 14px; margin: 0; white-space: nowrap; } .inline-follow-actions .save-state { min-height: 14px; margin: 0; white-space: nowrap; }
.consultation-note-card { width: min(680px, 100%); }
.consultation-note-form { padding: 22px; display: grid; gap: 18px; }
.note-card-summary { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.note-card-summary > div { padding: 14px 16px; display: grid; gap: 5px; background: #f5f9fd; border-radius: 9px; }
.note-card-summary small { color: var(--muted); font-size: 11px; }
.note-card-summary strong { font-size: 15px; }
.consultation-note-form > label { display: grid; gap: 7px; color: var(--muted); font-size: 13px; }
.consultation-note-form select { max-width: 180px; }
.consultation-note-form textarea { min-height: 230px; resize: vertical; line-height: 1.7; }
.note-card-footer { display: grid; grid-template-columns: auto 1fr auto auto; gap: 9px; align-items:center; }
.note-card-footer > span { color: var(--muted); font-size: 11px; }
.note-card-footer > p { margin: 0; color: var(--danger); font-size: 12px; }
.teacher-identity { display: flex; align-items: center; gap: 12px; } .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-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 { width: max-content; padding: 4px 9px; border-radius: 999px; font-size: 12px; }
...@@ -209,7 +229,13 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(20,115,230,. ...@@ -209,7 +229,13 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(20,115,230,.
.teacher-state.disabled { color: #7b8190; background: #edf0f4; } .teacher-state.disabled { color: #7b8190; background: #edf0f4; }
.row-actions .danger { color: var(--danger); } .row-actions .danger { color: var(--danger); }
.teacher-card { width: min(760px, 100%); } .teacher-card { width: min(760px, 100%); }
.teacher-form { padding: 20px; display: grid; gap: 18px; } .consultation-user-card { width: min(760px, 100%); }
.teacher-card > .modal-head,.consultation-user-card > .modal-head { flex: 0 0 auto; }
.teacher-form { min-height: 0; padding: 20px; display: grid; gap: 18px; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; }
.teacher-form::-webkit-scrollbar { width: 8px; }
.teacher-form::-webkit-scrollbar-track { background: #f3f6f9; }
.teacher-form::-webkit-scrollbar-thumb { background: #bdc9d6; border: 2px solid #f3f6f9; border-radius: 999px; }
.teacher-form > .password-actions { margin: 0 -4px -4px; padding: 12px 4px 4px; position: sticky; bottom: 0; z-index: 2; background: linear-gradient(180deg,rgba(255,255,255,0),#fff 24%); }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; } .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, .qr-field { display: grid; gap: 7px; color: var(--muted); font-size: 13px; }
.teacher-form label small { font-size: 11px; } .teacher-form label small { font-size: 11px; }
...@@ -218,6 +244,11 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(20,115,230,. ...@@ -218,6 +244,11 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(20,115,230,.
.switch-row { display: flex !important; align-items: center; gap: 9px !important; color: var(--text) !important; } .switch-row { display: flex !important; align-items: center; gap: 9px !important; color: var(--text) !important; }
.switch-row input { width: 17px; height: 17px; } .switch-row input { width: 17px; height: 17px; }
.teacher-session #pending-badge { display: none !important; } .teacher-session #pending-badge { display: none !important; }
.settings-panel { padding: 22px 24px; display: flex; align-items:center; justify-content:space-between; gap: 30px; background:#fff; border:1px solid var(--border); border-radius:10px; }
.settings-panel > div { max-width:720px; }
.settings-panel strong { display:block; margin-bottom:7px; font-size:16px; }
.settings-panel p { margin:0; color:var(--muted); font-size:13px; line-height:1.7; }
.settings-panel button { flex:0 0 auto; }
@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; }
...@@ -235,14 +266,20 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(20,115,230,. ...@@ -235,14 +266,20 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(20,115,230,.
.admin-shell { padding: 0 0 72px; } .admin-shell { padding: 0 0 72px; }
.sidebar { width: auto; height: 64px; inset: auto 10px 8px; flex-direction: row; border-radius: 16px; } .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-brand, .sidebar-account, .sidebar nav > p { display: none; }
.sidebar nav { width: 100%; padding: 7px; display: flex; gap: 5px; } .sidebar nav { width: 100%; padding: 7px; display: flex; gap: 5px; overflow-x:auto; }
.sidebar-link { min-height: 50px; flex: 1; padding: 0 8px; justify-content: center; } .sidebar-link { min-height: 50px; flex: 1; padding: 0 8px; justify-content: center; }
.sidebar-link b { margin-left: 6px; } .sidebar-link b { margin-left: 6px; }
.topbar { position: static; } .topbar { position: static; }
.metric-grid { padding: 0; overflow-x: auto; } .metric-grid { padding: 0; overflow-x: auto; }
.metric-grid article { min-width: 120px; padding: 13px 16px; } .metric-grid article { min-width: 120px; padding: 13px 16px; }
.metric-grid article:first-child { padding-left: 16px; } .metric-grid article:first-child { padding-left: 16px; }
.table-card { overflow-x: auto; }
.form-grid { grid-template-columns: 1fr; } .form-grid { grid-template-columns: 1fr; }
.consultation-row { grid-template-columns: 150px 125px 120px 130px 360px 90px; } .modal { padding: 10px; place-items: start center; }
.modal-card { max-height: calc(100dvh - 20px); }
.teacher-form { padding: 17px; }
.consultation-row { grid-template-columns: 44px 76px 108px 76px 82px 66px 112px 150px 68px 102px 214px; }
.note-card-summary { grid-template-columns: 1fr; }
.note-card-footer { grid-template-columns: 1fr 1fr; }
.note-card-footer > p { grid-column: 1 / -1; grid-row: 2; }
.settings-panel { align-items:stretch; flex-direction:column; }
} }
...@@ -8,7 +8,7 @@ ...@@ -8,7 +8,7 @@
<form class="consultation-form" data-consultation-form> <form class="consultation-form" data-consultation-form>
<label for="consultation-phone">家长手机号</label> <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> <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> <label class="consultation-consent"><input name="consent" type="checkbox" required /><span>提交即表示同意<a href="/service-agreement.html" target="_blank">《用户协议》</a>与<a href="/privacy-agreement.html" target="_blank">《隐私政策》</a></span></label>
<p class="consultation-error" data-consultation-error aria-live="polite"></p> <p class="consultation-error" data-consultation-error aria-live="polite"></p>
<button class="button consultation-submit" type="submit">立即领取</button> <button class="button consultation-submit" type="submit">立即领取</button>
</form> </form>
......
...@@ -25,7 +25,9 @@ import { convertWordToMarkdown, decodeWordDataUrl } from "./word-import"; ...@@ -25,7 +25,9 @@ import { convertWordToMarkdown, decodeWordDataUrl } from "./word-import";
import { currentSessionVersion, savePassword, verifyPassword } from "./cms-auth"; import { currentSessionVersion, savePassword, verifyPassword } from "./cms-auth";
import { import {
assignConsultation, assignConsultation,
createConsultation,
createConsultationTeacher, createConsultationTeacher,
deleteConsultation,
deleteConsultationTeacher, deleteConsultationTeacher,
getTeacherSessionVersion, getTeacherSessionVersion,
listConsultations, listConsultations,
...@@ -44,7 +46,9 @@ const MAX_LOGIN_FAILURES = 5; ...@@ -44,7 +46,9 @@ 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 AuthContext =
| { role: "admin"; id: "admin"; name: "管理员" }
| { role: "customer_service" | "operator"; id: string; name: string };
type BuildState = { type BuildState = {
status: "idle" | "building" | "success" | "error"; status: "idle" | "building" | "success" | "error";
ok: boolean | null; ok: boolean | null;
...@@ -97,7 +101,7 @@ async function teacherSession(request: Request): Promise<AuthContext | null> { ...@@ -97,7 +101,7 @@ async function teacherSession(request: Request): Promise<AuthContext | null> {
const currentVersion = await getTeacherSessionVersion(id); const currentVersion = await getTeacherSessionVersion(id);
if (currentVersion !== version) return null; if (currentVersion !== version) return null;
const teacher = (await listConsultationTeachers()).find((item) => item.id === id && item.enabled); const teacher = (await listConsultationTeachers()).find((item) => item.id === id && item.enabled);
return teacher ? { role: "teacher", id: teacher.id, name: teacher.name } : null; return teacher ? { role: teacher.role, id: teacher.id, name: teacher.name } : null;
} }
async function authContext(request: Request): Promise<AuthContext | null> { async function authContext(request: Request): Promise<AuthContext | null> {
...@@ -282,7 +286,7 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA ...@@ -282,7 +286,7 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA
headers.append("Set-Cookie", cookieHeader(request, "cms_session", "", 0)); headers.append("Set-Cookie", cookieHeader(request, "cms_session", "", 0));
const token = `${teacher.id}.${teacher.sessionVersion}.${teacherSessionMac(teacher.id, teacher.sessionVersion)}`; const token = `${teacher.id}.${teacher.sessionVersion}.${teacherSessionMac(teacher.id, teacher.sessionVersion)}`;
headers.append("Set-Cookie", cookieHeader(request, "cms_teacher_session", token, 86400)); 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); return json({ ok: true, user: { role: teacher.role, 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_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));
...@@ -315,13 +319,13 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA ...@@ -315,13 +319,13 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA
if (!context) throw new StoreError("请先登录后台", 401); if (!context) throw new StoreError("请先登录后台", 401);
if (route === "consultation-teachers") { if (route === "consultation-teachers") {
if (context.role !== "admin") throw new StoreError("无权管理咨询老师", 403); if (context.role !== "admin") throw new StoreError("无权管理后台账号", 403);
if (method === "GET") return json({ teachers: await listConsultationTeachers() }); if (method === "GET") return json({ teachers: await listConsultationTeachers() });
if (method === "POST") return json({ teacher: await createConsultationTeacher(await bodyOf(request)) }, 201); if (method === "POST") return json({ teacher: await createConsultationTeacher(await bodyOf(request)) }, 201);
} }
if (parts[0] === "consultation-teachers" && parts[1] && parts.length === 2) { if (parts[0] === "consultation-teachers" && parts[1] && parts.length === 2) {
if (context.role !== "admin") throw new StoreError("无权管理咨询老师", 403); if (context.role !== "admin") throw new StoreError("无权管理后台账号", 403);
if (method === "PUT") return json({ teacher: await updateConsultationTeacher(parts[1], await bodyOf(request)) }); if (method === "PUT") return json({ teacher: await updateConsultationTeacher(parts[1], await bodyOf(request)) });
if (method === "DELETE") { if (method === "DELETE") {
await deleteConsultationTeacher(parts[1]); await deleteConsultationTeacher(parts[1]);
...@@ -329,15 +333,26 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA ...@@ -329,15 +333,26 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA
} }
} }
if (route === "consultations" && method === "GET") { if (route === "consultations") {
return json({ consultations: await listConsultations(context.role === "teacher" ? context.id : undefined) }); if (context.role === "operator") throw new StoreError("运营账号无权访问用户咨询", 403);
if (method === "GET") return json({ consultations: await listConsultations(context.role === "customer_service" ? context.id : undefined) });
if (method === "POST") {
if (context.role !== "admin") throw new StoreError("无权录入用户", 403);
return json({ consultation: await createConsultation(await bodyOf(request)) }, 201);
}
} }
if (parts[0] === "consultations" && parts[1] && parts.length === 2 && method === "PATCH") { if (parts[0] === "consultations" && parts[1] && parts.length === 2) {
return json({ consultation: await updateConsultation(parts[1], await bodyOf(request), context.role === "teacher" ? context.id : undefined) }); if (context.role === "operator") throw new StoreError("运营账号无权处理用户咨询", 403);
if (method === "PATCH") return json({ consultation: await updateConsultation(parts[1], await bodyOf(request), context.role === "customer_service" ? context.id : undefined) });
if (method === "DELETE") {
if (context.role !== "admin") throw new StoreError("无权删除用户", 403);
await deleteConsultation(parts[1]);
return json({ ok: true });
}
} }
if (context.role !== "admin") throw new StoreError("无权访问内容管理", 403); if (context.role !== "admin" && context.role !== "operator") 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.
This diff is collapsed.
---
import Base from "../layouts/Base.astro";
import Header from "../components/Header.astro";
import Footer from "../components/Footer.astro";
const sections = [
["一、适用范围", "本政策适用于用户访问启优学一对一官网、提交课程咨询信息以及与客服进行后续沟通的场景。具体课程服务另有约定的,以双方确认的服务文件为准。"],
["二、我们收集的信息", "当用户主动咨询时,官网会收集用户提交的手机号码,以及用户自愿提供的学生年级、意向学科、学习需求等信息。服务器还可能为保障安全记录必要的访问时间、浏览器类型和网络日志。"],
["三、信息使用目的", "相关信息用于响应课程咨询、匹配客服、了解学习需求、安排后续沟通、改进网站服务以及防范恶意提交。未经用户授权,我们不会将咨询信息用于与上述目的无关的用途。"],
["四、信息保存与保护", "我们会在实现服务目的所需的期限内保存必要信息,并采取合理的访问控制与安全措施。法律法规另有保存要求的,按照相关规定执行。"],
["五、用户的选择与权利", "用户可拒绝提交非必要信息,也可联系客服申请查询、更正或删除其咨询资料。为保障安全,处理相关请求前可能需要核验申请人的身份。"],
["六、未成年人信息", "未成年人应在监护人指导下使用官网咨询服务。建议由监护人提交联系方式,且不要在公开区域填写身份证号、住址、学校班级等非必要敏感信息。"],
["七、联系我们", "如对本页面有疑问,可拨打客服热线 400 838 3090,服务时间为周一至周日 7x24小时。"],
];
---
<Base title="隐私政策|启优学一对一" description="启优学一对一官网隐私政策。">
<Header />
<main id="main-content" class="legal-page">
<article class="legal-document">
<header><p>PRIVACY POLICY</p><h1>隐私政策</h1><time datetime="2026-07-22">更新日期:2026年7月22日</time></header>
{sections.map(([title, body]) => <section><h2>{title}</h2><p>{body}</p></section>)}
</article>
</main>
<Footer />
</Base>
---
import Base from "../layouts/Base.astro";
import Header from "../components/Header.astro";
import Footer from "../components/Footer.astro";
const sections = [
["一、协议范围", "本协议适用于用户访问启优学一对一官网、浏览公开信息和使用课程咨询表单。提交咨询不代表课程合同已经成立,具体服务以双方后续确认的课程方案及相关约定为准。"],
["二、咨询信息提交", "用户应提供本人或经合法授权使用的联系方式,并保证所填信息真实、准确。请勿利用表单实施批量提交、干扰系统运行、冒用他人身份或其他违法违规行为。"],
["三、课程信息说明", "官网展示的课程介绍用于帮助用户了解服务方向。实际开课学科、教师资源、上课时间、费用、调整和退款规则,以咨询后双方明确确认的方案及服务文件为准。"],
["四、知识产权", "官网中的品牌标识、页面设计、文字、图片及其他内容受相关法律保护。未经授权,不得以商业目的复制、修改、抓取或传播,但法律允许的合理引用除外。"],
["五、责任边界", "我们会尽合理努力保障官网信息和服务的稳定性,但不对网络故障、不可抗力或第三方原因造成的临时中断承担超出法律规定范围的责任。"],
["六、联系我们", "如对本页面有疑问,可拨打客服热线 400 838 3090,服务时间为周一至周日 7x24小时。"],
];
---
<Base title="用户服务协议|启优学一对一" description="启优学一对一官网用户服务协议。">
<Header />
<main id="main-content" class="legal-page">
<article class="legal-document">
<header><p>LEGAL TERMS</p><h1>用户服务协议</h1><time datetime="2026-07-22">更新日期:2026年7月22日</time></header>
{sections.map(([title, body]) => <section><h2>{title}</h2><p>{body}</p></section>)}
</article>
</main>
<Footer />
</Base>
...@@ -20,6 +20,25 @@ ...@@ -20,6 +20,25 @@
@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}} @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} .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)}} @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)}}
/* 学情咨询使用官网主题蓝,协议链接保持清晰可读。 */
.consultation-phone-row:focus-within{border-color:var(--brand-blue);box-shadow:0 0 0 3px rgba(20,115,230,.12)}
.consultation-consent input{accent-color:var(--brand-blue)}
.consultation-consent a{color:var(--brand-blue);font-weight:600;text-decoration:none}
.consultation-consent a:hover{text-decoration:underline}
.contact-dialog--form .consultation-submit{background:var(--brand-blue);box-shadow:0 8px 18px rgba(20,115,230,.2)}
.contact-dialog--form .consultation-submit:hover{background:var(--brand-blue-dark)}
.teacher-contact>p strong{color:var(--brand-blue)}
.legal-page{min-height:70vh;padding:76px 20px 96px;background:linear-gradient(180deg,#f2f8ff 0,#fff 260px)}
.legal-document{width:min(820px,100%);margin:0 auto;padding:54px 64px;background:#fff;border:1px solid #dce9f7;border-radius:18px;box-shadow:0 22px 60px rgba(20,86,156,.09)}
.legal-document header{margin-bottom:38px;padding-bottom:28px;border-bottom:1px solid #dce6ef}
.legal-document header>p{margin:0 0 10px;color:var(--brand-blue);font-size:11px;font-weight:800;letter-spacing:.16em}
.legal-document h1{margin:0 0 13px;color:#173654;font-size:38px;letter-spacing:-.03em}
.legal-document time{color:#758697;font-size:13px}
.legal-document section+section{margin-top:28px}
.legal-document h2{margin:0 0 10px;color:#1f405f;font-size:18px}
.legal-document section p{margin:0;color:#53697d;font-size:15px;line-height:1.95}
@media(max-width:760px){.legal-page{padding:38px 14px 64px}.legal-document{padding:34px 24px;border-radius:14px}.legal-document h1{font-size:31px}.legal-document section p{font-size:14px}}
.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} .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)}
......
...@@ -10,7 +10,10 @@ process.env.CMS_PASSWORD = "environment-master-password"; ...@@ -10,7 +10,10 @@ process.env.CMS_PASSWORD = "environment-master-password";
process.env.CMS_SECRET = "auth-test-session-secret"; process.env.CMS_SECRET = "auth-test-session-secret";
process.env.CMS_API_KEY = "auth-test-api-key"; process.env.CMS_API_KEY = "auth-test-api-key";
const { handleCmsApi } = await import("../src/lib/cms-api"); const [{ handleCmsApi }, { createConsultationTeacher }] = await Promise.all([
import("../src/lib/cms-api"),
import("../src/lib/consultation-store"),
]);
let requestNumber = 0; let requestNumber = 0;
after(async () => { after(async () => {
...@@ -49,6 +52,30 @@ async function login(password: string): Promise<{ response: Response; cookie?: s ...@@ -49,6 +52,30 @@ async function login(password: string): Promise<{ response: Response; cookie?: s
}; };
} }
async function staffLogin(username: string, password: string): Promise<{ response: Response; cookie?: string }> {
requestNumber += 1;
const captchaResponse = await handleCmsApi(
new Request("http://localhost/api/cms/captcha"),
"captcha",
`staff-captcha-${requestNumber}`,
);
const code = [...(await captchaResponse.text()).matchAll(/<text\b[^>]*>([^<])<\/text>/g)]
.map((match) => match[1])
.join("");
const response = await handleCmsApi(new Request("http://localhost/api/cms/session", {
method: "POST",
headers: {
"Content-Type": "application/json",
Cookie: cookieFrom(captchaResponse, "cms_captcha"),
},
body: JSON.stringify({ username, password, captcha: code, captchaToken: captchaResponse.headers.get("X-Captcha-Token") }),
}), "session", `staff-login-${requestNumber}`);
return {
response,
cookie: response.ok ? cookieFrom(response, "cms_teacher_session") : undefined,
};
}
async function sessionStatus(cookie: string): Promise<boolean> { async function sessionStatus(cookie: string): Promise<boolean> {
const response = await handleCmsApi(new Request("http://localhost/api/cms/session", { const response = await handleCmsApi(new Request("http://localhost/api/cms/session", {
headers: { Cookie: cookie }, headers: { Cookie: cookie },
...@@ -146,3 +173,49 @@ test("an API key alone cannot change the browser login password", async () => { ...@@ -146,3 +173,49 @@ test("an API key alone cannot change the browser login password", async () => {
}), "account/password"); }), "account/password");
assert.equal(response.status, 401); assert.equal(response.status, 401);
}); });
test("customer service and operator accounts are restricted to their own work areas", async () => {
await createConsultationTeacher({
role: "customer_service",
name: "测试客服",
username: "service_test",
password: "service-password",
title: "学情客服",
qrCodeUrl: "/uploads/service-test.png",
sort: 1,
enabled: true,
});
await createConsultationTeacher({
role: "operator",
name: "测试运营",
username: "operator_test",
password: "operator-password",
enabled: true,
});
const serviceLogin = await staffLogin("service_test", "service-password");
assert.equal(serviceLogin.response.status, 200);
assert.equal((await serviceLogin.response.json()).user.role, "customer_service");
assert.ok(serviceLogin.cookie);
const serviceConsultations = await handleCmsApi(new Request("http://localhost/api/cms/consultations", {
headers: { Cookie: serviceLogin.cookie },
}), "consultations");
assert.equal(serviceConsultations.status, 200);
const serviceArticles = await handleCmsApi(new Request("http://localhost/api/cms/articles", {
headers: { Cookie: serviceLogin.cookie },
}), "articles");
assert.equal(serviceArticles.status, 403);
const operatorLogin = await staffLogin("operator_test", "operator-password");
assert.equal(operatorLogin.response.status, 200);
assert.equal((await operatorLogin.response.json()).user.role, "operator");
assert.ok(operatorLogin.cookie);
const operatorArticles = await handleCmsApi(new Request("http://localhost/api/cms/articles", {
headers: { Cookie: operatorLogin.cookie },
}), "articles");
assert.equal(operatorArticles.status, 200);
const operatorConsultations = await handleCmsApi(new Request("http://localhost/api/cms/consultations", {
headers: { Cookie: operatorLogin.cookie },
}), "consultations");
assert.equal(operatorConsultations.status, 403);
});
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