Commit 5ce54ef5 authored by tao355667's avatar tao355667

feat: refine admin workflow and content pages

parent e9d9cb8e
This diff is collapsed.
......@@ -165,7 +165,6 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(20,115,230,.
.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; }
......@@ -189,27 +188,31 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(20,115,230,.
.data-list { min-width: 0; }
.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:not(.data-head) { transition: background-color .16s ease; }
.data-row:not(.data-head):hover,.data-row:not(.data-head):focus-within { background: #f1f7ff; }
.data-head { min-height: 61px; padding-block: 8px; color: var(--muted); background: #f7f9fb; font-size: 12px; font-weight: 600; }
.column-filter { min-width: 0; display: grid; gap: 4px; }
.column-filter > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.column-filter select { min-height: 24px; padding: 2px 19px 2px 5px; color: #657587; background-color: #fff; border-color: #d9e1e9; border-radius: 5px; font-size: 10px; }
#consultation-list { min-width: 1190px; }
.consultation-row { grid-template-columns: 44px 76px 108px 165px 82px 66px 112px minmax(150px,1fr) 68px 102px 86px; }
#consultation-list { min-width: 1120px; }
.consultation-row { grid-template-columns: minmax(44px,.5fr) minmax(76px,.75fr) minmax(108px,1fr) minmax(165px,1.4fr) minmax(82px,.8fr) minmax(66px,.7fr) 20em minmax(68px,.7fr) minmax(102px,1fr) 86px; }
#teacher-list { min-width: 1050px; }
.teacher-row { grid-template-columns: .8fr 1fr .6fr 1fr .7fr .8fr .7fr 86px; }
.teacher-row { grid-template-columns: .8fr 1fr .6fr 1fr .65fr .7fr .8fr .7fr 86px; }
.data-row small { display: block; margin-top: 4px; color: var(--muted); font-size: 11px; }
.phone-text { color: var(--text); font-size: 12px; font-weight: 600; }
.page { max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.created,.teacher-name { font-size: 12px; }
.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; }
.user-name,.location,.consultation-status { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
.unadded-reason { min-width: 0; white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; line-height: 1.55; }
.consultation-status { font-weight: 700; }
.consultation-status--unprocessed { color: #1769aa; }
.consultation-status--added { color: #0b805b; }
.consultation-status--not_added { color: #bd4141; }
.consultation-row > :last-child,.teacher-row > :last-child { position: sticky; right: 0; z-index: 2; min-height: 100%; align-content: center; justify-content: flex-end; background: #fff; border-left: 1px solid var(--border); }
.consultation-row.data-head > :last-child,.teacher-row.data-head > :last-child { z-index: 3; display: grid; place-items: center start; background: #f7f9fb; }
.consultation-row > :last-child,.teacher-row > :last-child { position: sticky; right: 0; z-index: 2; min-height: 100%; align-content: center; justify-content: flex-start; background: #fff; border-left: 1px solid var(--border); }
.consultation-row:not(.data-head):hover > :last-child,.consultation-row:not(.data-head):focus-within > :last-child,.teacher-row:not(.data-head):hover > :last-child,.teacher-row:not(.data-head):focus-within > :last-child { background: #f1f7ff; }
.consultation-row.data-head > :last-child,.teacher-row.data-head > :last-child { z-index: 3; display: grid; place-items: center start; text-align: left; background: #f7f9fb; }
.consultation-row .row-actions,.teacher-row .row-actions { gap: 12px; flex-wrap: nowrap; }
.consultation-row .row-actions .text-action,.teacher-row .row-actions .text-action { min-height: auto; padding: 2px 0; color: var(--primary-dark); background: transparent; border: 0; border-radius: 0; font-size: 12px; line-height: 1.4; }
.consultation-row .row-actions .text-action:hover,.teacher-row .row-actions .text-action:hover { color: var(--deep); text-decoration: underline; transform: none; }
......@@ -235,22 +238,27 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(20,115,230,.
.teacher-state.disabled { color: #7b8190; background: #edf0f4; }
.row-actions .danger { color: var(--danger); }
.teacher-card { width: min(760px, 100%); }
.consultation-user-card { width: min(760px, 100%); }
.consultation-user-card { width: min(720px, 100%); }
.teacher-card > .modal-head,.consultation-user-card > .modal-head { flex: 0 0 auto; }
.teacher-card > .teacher-modal-head { align-items: flex-start; justify-content: flex-start; flex-direction: column; gap: 13px; }
.teacher-modal-head > .teacher-modal-heading { display: grid; gap: 4px; }
.consultation-user-card > .modal-head { padding: 14px 18px; }
.teacher-card > .teacher-modal-head { align-items: flex-start; justify-content: space-between; flex-direction: row; gap: 20px; }
.teacher-modal-head > .teacher-modal-heading { min-width: 0; flex: 1 1 auto; display: grid; gap: 4px; }
.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; }
.consultation-edit-section { padding: 17px; display: grid; gap: 15px; border: 1px solid var(--border); border-radius: 12px; background: #fbfcfe; }
.consultation-user-form { padding: 14px 16px; gap: 12px; }
.consultation-edit-section { padding: 13px 14px; display: grid; gap: 11px; border: 1px solid var(--border); border-radius: 10px; background: #fbfcfe; }
.consultation-edit-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; }
.consultation-edit-heading strong { color: #203a57; font-size: 14px; }
.consultation-edit-heading strong { color: #203a57; font-size: 13px; }
.consultation-edit-heading span { color: var(--muted); font-size: 11px; }
.auto-field-note { grid-column: 1 / -1; margin: 0; padding: 10px 12px; color: #55718d; background: #eef6ff; border: 1px solid #d7e8f9; border-radius: 8px; font-size: 12px; }
.consultation-note-field { display: grid; gap: 7px; color: var(--muted); font-size: 13px; }
.consultation-note-field textarea { min-height: 170px; resize: vertical; line-height: 1.7; }
.consultation-note-field small { justify-self: end; color: var(--muted); font-size: 11px; }
.consultation-user-grid { grid-template-columns: minmax(0,.8fr) minmax(0,1fr) minmax(0,1.15fr); gap: 12px; align-items: start; }
.consultation-handling-grid { gap: 12px; }
.field-hint { color: #73869a; font-size: 10px !important; font-weight: 400; }
.consultation-reason-field { width: 100%; display: grid; gap: 6px; color: var(--muted); font-size: 13px; }
.consultation-reason-field textarea { min-height: 96px; resize: vertical; white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.65; }
.consultation-reason-field small { justify-self: end; color: var(--muted); font-size: 11px; }
#consultation-user-error { min-height: 0; margin: 0; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; }
.number-stepper { display: grid; grid-template-columns: 38px 1fr 38px; overflow: hidden; border: 1px solid var(--border); border-radius: 9px; background: #fff; }
.number-stepper:focus-within { outline: 2px solid rgba(20,115,230,.14); border-color: var(--primary); }
......@@ -291,7 +299,6 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(20,115,230,.
.sidebar-brand, .sidebar-account, .sidebar nav > p { display: none; }
.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 b { margin-left: 6px; }
.topbar { position: static; }
.metric-grid { padding: 0; overflow-x: auto; }
.metric-grid article { min-width: 120px; padding: 13px 16px; }
......@@ -300,9 +307,10 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(20,115,230,.
.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 165px 82px 66px 112px minmax(150px,1fr) 68px 102px 86px; }
.teacher-row { grid-template-columns: .8fr 1fr .6fr 1fr .7fr .8fr .7fr 86px; }
.consultation-edit-section { padding: 14px; }
.consultation-user-form { padding: 12px; gap: 10px; }
.consultation-row { grid-template-columns: minmax(44px,.5fr) minmax(76px,.75fr) minmax(108px,1fr) minmax(165px,1.4fr) minmax(82px,.8fr) minmax(66px,.7fr) 20em minmax(68px,.7fr) minmax(102px,1fr) 86px; }
.teacher-row { grid-template-columns: .8fr 1fr .6fr 1fr .65fr .7fr .8fr .7fr 86px; }
.consultation-edit-section { padding: 12px; }
.consultation-edit-heading { align-items: flex-start; flex-direction: column; gap: 3px; }
.settings-panel { align-items:stretch; flex-direction:column; }
}
---
import { site } from "../data/site";
interface Props { title?: string; text?: string; }
const { title = "先了解学情,再决定怎么学", text = "准备学生年级、意向学科、近期试卷或错题与阶段目标,领取一对一专属学情分析。" } = Astro.props;
interface Props { title?: string; text?: string; tone?: "default" | "soft"; }
const { title = "先了解学情,再决定怎么学", text = "准备学生年级、意向学科、近期试卷或错题与阶段目标,领取一对一专属学情分析。", tone = "default" } = 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/" data-open-contact>预约学情分析</a><a class="text-link text-link--light" href={`tel:${site.contact.phone}`}>电话 {site.contact.phoneDisplay}</a></div></div></section>
<section class:list={["cta-band", { "cta-band--soft": tone === "soft" }]}><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>
......@@ -38,10 +38,6 @@ export interface ConsultationRecord {
phone: string;
location: string;
teacherId: string;
teacherName: string;
teacherTitle: string;
teacherWechat: string;
teacherQrCodeUrl: string;
source: string;
page: string;
status: ConsultationStatus;
......@@ -62,6 +58,12 @@ interface ConsultationState {
export type PublicTeacher = Pick<ConsultationTeacher, "id" | "name" | "title" | "wechat" | "qrCodeUrl">;
export type TeacherListItem = Omit<ConsultationTeacher, "passwordSalt" | "passwordHash">;
export type ConsultationListItem = ConsultationRecord & {
teacherName: string;
teacherTitle: string;
teacherWechat: string;
teacherQrCodeUrl: string;
};
let mutationQueue: Promise<unknown> = Promise.resolve();
......@@ -91,15 +93,22 @@ async function readState(): Promise<ConsultationState> {
const legacyStatus = String(item.status || "");
const storedLocation = String(item.location || "");
return {
...item,
id: String(item.id || ""),
sequence: Number.isInteger(item.sequence) ? Number(item.sequence) : total - index,
name: String(item.name || ""),
phone: String(item.phone || ""),
location: storedLocation,
teacherId: String(item.teacherId || ""),
source: String(item.source || ""),
page: String(item.page || ""),
status: legacyStatus === "added" || legacyStatus === "completed"
? "added"
: legacyStatus === "not_added" || legacyStatus === "contacted" ? "not_added" : "unprocessed",
unaddedReason: String(item.unaddedReason || ""),
callCount: Number.isInteger(item.callCount) && Number(item.callCount) >= 0 ? Number(item.callCount) : 0,
note: String(item.note || ""),
createdAt: String(item.createdAt || ""),
updatedAt: String(item.updatedAt || item.createdAt || ""),
};
}) as ConsultationRecord[];
const highestSequence = consultations.reduce((highest, item) => Math.max(highest, item.sequence), 0);
......@@ -263,6 +272,16 @@ function publicTeacher(teacher: ConsultationTeacher): PublicTeacher {
return { id: teacher.id, name: teacher.name, title: teacher.title, wechat: teacher.wechat, qrCodeUrl: teacher.qrCodeUrl };
}
function consultationListItem(consultation: ConsultationRecord, teacher?: ConsultationTeacher): ConsultationListItem {
return {
...consultation,
teacherName: teacher?.name || (consultation.teacherId ? "已删除客服" : ""),
teacherTitle: teacher?.title || "",
teacherWechat: teacher?.wechat || "",
teacherQrCodeUrl: teacher?.qrCodeUrl || "",
};
}
function listItem(teacher: ConsultationTeacher): TeacherListItem {
const { passwordSalt: _passwordSalt, passwordHash: _passwordHash, ...item } = teacher;
return item;
......@@ -310,7 +329,11 @@ export async function updateConsultationTeacher(id: string, input: Record<string
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.role = normalizeStaffRole(input.role ?? teacher.role);
const nextRole = normalizeStaffRole(input.role ?? teacher.role);
if (teacher.role === "customer_service" && nextRole !== teacher.role && state.consultations.some((item) => item.teacherId === id)) {
throw new StoreError("该客服已有分配记录,请先重新分配用户后再修改角色", 409);
}
teacher.role = nextRole;
teacher.name = cleanText(input.name, "姓名", 30);
teacher.username = username;
teacher.title = cleanText(input.title, "展示称谓", 40, false);
......@@ -332,6 +355,9 @@ 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);
if (state.consultations.some((item) => item.teacherId === id)) {
throw new StoreError("该客服已有分配记录,请停用账号或先重新分配用户", 409);
}
state.teachers.splice(index, 1);
if (state.lastAssignedTeacherId === id) state.lastAssignedTeacherId = null;
});
......@@ -371,10 +397,6 @@ export async function assignConsultation(input: Record<string, unknown>): Promis
phone,
location,
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: "unprocessed",
......@@ -393,7 +415,7 @@ export async function assignConsultation(input: Record<string, unknown>): Promis
});
}
export async function createConsultation(input: Record<string, unknown>): Promise<ConsultationRecord> {
export async function createConsultation(input: Record<string, unknown>): Promise<ConsultationListItem> {
const phone = normalizePhone(input.phone);
const location = await resolvePhoneLocation(phone);
return mutate((state) => {
......@@ -407,30 +429,30 @@ export async function createConsultation(input: Record<string, unknown>): Promis
phone,
location,
teacherId: teacher.id,
teacherName: teacher.name,
teacherTitle: teacher.title,
teacherWechat: teacher.wechat,
teacherQrCodeUrl: teacher.qrCodeUrl,
source: cleanText(input.source, "来源", 80, false) || "后台录入",
page: "",
status: normalizeConsultationStatus(input.status),
unaddedReason: cleanText(input.unaddedReason, "未添加原因", 200, false),
unaddedReason: cleanMultilineText(input.unaddedReason, "未添加原因", 200),
callCount: normalizeCallCount(input.callCount),
note: cleanMultilineText(input.note, "跟进备注", 500),
createdAt: now,
updatedAt: now,
};
state.consultations.unshift(consultation);
return consultation;
return consultationListItem(consultation, 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 listConsultations(teacherId?: string): Promise<ConsultationListItem[]> {
const state = await readState();
const teachers = new Map(state.teachers.map((teacher) => [teacher.id, teacher]));
return state.consultations
.filter((item) => !teacherId || item.teacherId === teacherId)
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
.map((item) => consultationListItem(item, teachers.get(item.teacherId)));
}
export async function updateConsultation(id: string, input: Record<string, unknown>, teacherId?: string): Promise<ConsultationRecord> {
export async function updateConsultation(id: string, input: Record<string, unknown>, teacherId?: string): Promise<ConsultationListItem> {
return mutate(async (state) => {
const consultation = state.consultations.find((item) => item.id === id && (!teacherId || item.teacherId === teacherId));
if (!consultation) throw new StoreError("咨询记录不存在", 404);
......@@ -441,19 +463,15 @@ export async function updateConsultation(id: string, input: Record<string, unkno
consultation.note = cleanMultilineText(input.note, "跟进备注", 500);
consultation.name = cleanText(input.name ?? consultation.name, "姓名", 30, false);
if (phoneChanged || !consultation.location) consultation.location = await resolvePhoneLocation(consultation.phone);
consultation.unaddedReason = cleanText(input.unaddedReason ?? consultation.unaddedReason, "未添加原因", 200, false);
consultation.unaddedReason = cleanMultilineText(input.unaddedReason ?? consultation.unaddedReason, "未添加原因", 200);
consultation.callCount = normalizeCallCount(input.callCount ?? consultation.callCount);
if (!teacherId && input.teacherId && String(input.teacherId) !== consultation.teacherId) {
const teacher = state.teachers.find((item) => item.id === String(input.teacherId) && item.role === "customer_service");
if (!teacher) throw new StoreError("请选择分配客服");
consultation.teacherId = teacher.id;
consultation.teacherName = teacher.name;
consultation.teacherTitle = teacher.title;
consultation.teacherWechat = teacher.wechat;
consultation.teacherQrCodeUrl = teacher.qrCodeUrl;
}
consultation.updatedAt = new Date().toISOString();
return consultation;
return consultationListItem(consultation, state.teachers.find((item) => item.id === consultation.teacherId));
});
}
......
......@@ -4,8 +4,6 @@ import Header from "../../components/Header.astro";
import Footer from "../../components/Footer.astro";
import CTA from "../../components/CTA.astro";
import SectionHeading from "../../components/SectionHeading.astro";
import brandImage from "../../../pic/主页大图素材-1.png";
import { Image } from "astro:assets";
import { site } from "../../data/site";
const title = `关于我们|${site.brand.name}`;
......@@ -33,15 +31,11 @@ const serviceFacts = [
<section id="position" class="section-pad section-white">
<div class="container about-brand-layout">
<figure class="about-brand-visual" data-reveal>
<Image src={brandImage} widths={[640, 900, 1200]} sizes="(max-width: 760px) 100vw, 46vw" alt="启优学老师与学生通过在线课堂开展一对一个性化辅导" format="webp" />
<figcaption>在线一对一互动课堂场景示意</figcaption>
</figure>
<div class="about-brand-copy">
<div class="about-brand-copy" data-reveal>
<SectionHeading title="我们是什么" description={site.about.position} />
<div class="story-column">
{site.about.story.map((paragraph) => <p data-reveal>{paragraph}</p>)}
</div>
</div>
<div class="story-column">
{site.about.story.map((paragraph) => <p data-reveal>{paragraph}</p>)}
</div>
</div>
......
......@@ -30,10 +30,10 @@
<div class="sidebar-brand"><span>启优学</span><strong>管理后台</strong></div>
<nav aria-label="后台功能">
<p class="consultation-role">咨询服务</p>
<button class="sidebar-link active consultation-role" data-admin-nav="consultations" type="button"><span>用户管理</span><b id="consultation-count">0</b></button>
<button class="sidebar-link active consultation-role" data-admin-nav="consultations" type="button"><span>用户管理</span></button>
<button class="sidebar-link admin-only" data-admin-nav="teachers" type="button"><span>账号管理</span></button>
<p class="content-role">官网内容</p>
<button class="sidebar-link content-role" data-admin-nav="articles" type="button"><span>学习资讯</span><b id="sidebar-pending" class="hidden">0</b></button>
<button class="sidebar-link content-role" data-admin-nav="articles" type="button"><span>学习资讯</span></button>
<p class="admin-only">系统设置</p>
<button class="sidebar-link admin-only" data-admin-nav="settings" type="button"><span>账号设置</span></button>
</nav>
......@@ -159,26 +159,24 @@
<div id="consultation-user-modal" class="modal hidden">
<div class="modal-card consultation-user-card">
<div class="modal-head"><div><strong id="consultation-user-title">录入用户</strong><small id="consultation-user-description">一次完成用户信息、咨询状态和跟进备注录入</small></div><div class="modal-head-actions"><button id="close-consultation-user-modal" class="ghost small" type="button">关闭</button><button class="primary small" type="submit" form="consultation-user-form">保存用户</button></div></div>
<form id="consultation-user-form" class="teacher-form">
<div class="modal-head"><div><strong id="consultation-user-title">录入用户</strong><small id="consultation-user-description">一次完成用户信息、咨询状态和未添加原因录入</small></div><div class="modal-head-actions"><button id="close-consultation-user-modal" class="ghost small" type="button">关闭</button><button class="primary small" type="submit" form="consultation-user-form">保存用户</button></div></div>
<form id="consultation-user-form" class="teacher-form consultation-user-form">
<input id="consultation-user-id" type="hidden" />
<section class="consultation-edit-section">
<div class="consultation-edit-heading"><strong>用户信息</strong><span>联系方式与归属信息</span></div>
<div class="form-grid">
<div class="form-grid consultation-user-grid">
<label>姓名<input id="consultation-user-name" maxlength="30" /></label>
<label>手机号<input id="consultation-user-phone" inputmode="numeric" maxlength="11" required /></label>
<p class="auto-field-note">归属地将根据手机号自动识别,无需手动填写</p>
<label>手机号<input id="consultation-user-phone" inputmode="numeric" maxlength="11" required /><small class="field-hint">归属地自动识别</small></label>
<label>分配客服<select id="consultation-user-teacher" required></select></label>
</div>
</section>
<section class="consultation-edit-section">
<div class="consultation-edit-heading"><strong>咨询处理</strong><span>状态与本次联系结果</span></div>
<div class="form-grid">
<div class="form-grid consultation-handling-grid">
<label>状态<select id="consultation-user-status"><option value="unprocessed">未处理</option><option value="added">已添加</option><option value="not_added">未添加</option></select></label>
<label>未添加原因<input id="consultation-user-reason" maxlength="200" /></label>
<label>打电话次数<div class="number-stepper"><button id="decrease-consultation-calls" type="button" aria-label="打电话次数减一"></button><input id="consultation-user-calls" type="number" min="0" max="9999" value="0" required /><button id="increase-consultation-calls" type="button" aria-label="打电话次数加一"></button></div></label>
</div>
<label class="consultation-note-field">完整跟进备注<textarea id="consultation-user-note" maxlength="500" rows="8" placeholder="记录沟通结果、家长需求或下次跟进时间"></textarea><small><strong id="consultation-user-note-count">0</strong> / 500</small></label>
<label class="consultation-reason-field">未添加原因<textarea id="consultation-user-reason" maxlength="200" rows="4" placeholder="填写未添加原因"></textarea><small><strong id="consultation-user-reason-count">0</strong> / 200</small></label>
</section>
<p id="consultation-user-error" class="error"></p>
</form>
......
---
import Base from "../../layouts/Base.astro"; import Header from "../../components/Header.astro"; import Footer from "../../components/Footer.astro"; import { site } from "../../data/site"; const title=`联系我们|${site.brand.name}`; const description=`联系启优学一对一,咨询课程安排、教师匹配和学习方案。客服电话 ${site.contact.phoneDisplay}。`;
---
<Base {title} {description}><Header /><main id="main-content"><section class="contact-hero"><div class="container contact-hero__grid"><div data-reveal><div class="breadcrumb"><a href="/">首页</a> / 联系我们</div><h1>{site.contact.heading}</h1><p>{site.contact.intro}</p><div class="hero__actions"><a class="button" href={`tel:${site.contact.phone}`}>电话咨询</a><a class="button button--ghost" href={`mailto:${site.contact.email}`}>发送邮件</a></div></div><div class="contact-hours" data-reveal><span>7×24</span><strong>服务时间</strong><p>周一至周日</p></div></div></section><section class="section-pad section-blue"><div class="container contact-grid"><article data-reveal><h2>客服电话</h2><a class="contact-big-link" href={`tel:${site.contact.phone}`}>{site.contact.phoneDisplay}</a><p>课程咨询与服务反馈</p></article><article data-reveal><h2>微信小程序</h2><strong class="contact-big-link">启优学一对一</strong><p>搜索小程序名称提交预约</p></article><article data-reveal><h2>联系邮箱</h2><a class="contact-big-link contact-big-link--small" href={`mailto:${site.contact.email}`}>{site.contact.email}</a><p>商务合作与服务反馈</p></article><article data-reveal><h2>官方网站</h2><a class="contact-big-link contact-big-link--small" href={`https://${site.contact.website}`}>{site.contact.website}</a><p>官方课程与服务信息</p></article></div></section><section class="section-pad section-white"><div class="container visit-grid"><div><h2>准备这些信息<br />让沟通更聚焦</h2><p>无需制作复杂材料,近期试卷或错题和最关心的问题就足够开始。</p></div><ol>{[["学生年级与地区","帮助确认学段、教材与当前可匹配资源"],["意向学科与教材进度","了解目前正在学习的内容与具体章节"],["近期试卷或错题","定位知识缺口、方法与学习习惯问题"],["当前情况与阶段目标","确定近期任务与适合的检查方式"]].map(([name,text])=><li><div><strong>{name}</strong><p>{text}</p></div></li>)}</ol></div></section><section class="contact-bottom"><div class="container"><h2>先看清问题<br />再规划每一步</h2><a class="button button--white" href={`tel:${site.contact.phone}`}>立即联系 {site.contact.phoneDisplay}</a></div></section></main><Footer /></Base>
<Base {title} {description}><Header /><main id="main-content"><section class="contact-hero"><div class="container contact-hero__grid"><div data-reveal><div class="breadcrumb"><a href="/">首页</a> / 联系我们</div><h1>{site.contact.heading}</h1><p>{site.contact.intro}</p><div class="hero__actions"><a class="button" href={`tel:${site.contact.phone}`}>电话咨询</a><a class="button button--ghost" href={`mailto:${site.contact.email}`}>发送邮件</a></div></div><div class="contact-hours" data-reveal><span>7×24</span><strong>服务时间</strong><p>周一至周日</p></div></div></section><section class="section-pad section-blue"><div class="container contact-grid"><article data-reveal><h2>客服电话</h2><a class="contact-big-link" href={`tel:${site.contact.phone}`}>{site.contact.phoneDisplay}</a><p>课程咨询与服务反馈</p></article><article data-reveal><h2>微信小程序</h2><strong class="contact-big-link">启优学一对一</strong><p>搜索小程序名称提交预约</p></article><article data-reveal><h2>联系邮箱</h2><a class="contact-big-link contact-big-link--small" href={`mailto:${site.contact.email}`}>{site.contact.email}</a><p>商务合作与服务反馈</p></article><article data-reveal><h2>官方网站</h2><a class="contact-big-link contact-big-link--small" href={`https://${site.contact.website}`}>{site.contact.website}</a><p>官方课程与服务信息</p></article></div></section><section class="section-pad section-white"><div class="container visit-grid"><div><h2>准备这些信息<br />让沟通更聚焦</h2><p>无需制作复杂材料,近期试卷或错题和最关心的问题就足够开始。</p></div><ol>{[["学生年级与地区","帮助确认学段、教材与当前可匹配资源"],["意向学科与教材进度","了解目前正在学习的内容与具体章节"],["近期试卷或错题","定位知识缺口、方法与学习习惯问题"],["当前情况与阶段目标","确定近期任务与适合的检查方式"]].map(([name,text])=><li><div><strong>{name}</strong><p>{text}</p></div></li>)}</ol></div></section></main><Footer /></Base>
......@@ -72,8 +72,8 @@ const jsonLd = {
<div class="container">
<a href="#overview"><span>01</span>课程说明</a>
<a href="#stages"><span>02</span>覆盖学段</a>
<a href="#needs"><span>03</span>学习需求</a>
<a href="#process"><span>04</span>服务流程</a>
<a href="#process"><span>03</span>服务流程</a>
<a href="#needs"><span>04</span>学习需求</a>
</div>
</nav>
......@@ -107,6 +107,23 @@ const jsonLd = {
</div>
</section>
<section id="process" class="section-pad subject-process-section">
<div class="container">
<div class="section-heading section-heading--center section-heading--light" data-reveal>
<h2>一对一学习服务如何开展</h2>
<p>从了解学习情况到阶段复盘,具体环节会根据学生需求和实际课程方案调整。</p>
</div>
<div class="subject-process-path">
{process.map((step) => (
<article data-reveal>
<span>{step.number}</span>
<div><h3>{step.title}</h3><p>{step.desc}</p></div>
</article>
))}
</div>
</div>
</section>
<section id="needs" class="section-pad section-white subject-needs-section">
<div class="container">
<div class="subject-needs-head">
......@@ -125,24 +142,7 @@ const jsonLd = {
</div>
</section>
<section id="process" class="section-pad subject-process-section">
<div class="container">
<div class="section-heading section-heading--center section-heading--light" data-reveal>
<h2>一对一学习服务如何开展</h2>
<p>从了解学习情况到阶段复盘,具体环节会根据学生需求和实际课程方案调整。</p>
</div>
<div class="subject-process-path">
{process.map((step) => (
<article data-reveal>
<span>{step.number}</span>
<div><h3>{step.title}</h3><p>{step.desc}</p></div>
</article>
))}
</div>
</div>
</section>
<CTA title="先了解孩子目前最需要解决的问题" text="准备年级、教材进度、近期试卷或错题和阶段目标,从一次学情沟通开始。" />
<CTA tone="soft" title="先了解孩子目前最需要解决的问题" text="准备年级、教材进度、近期试卷或错题和阶段目标,从一次学情沟通开始。" />
</main>
<Footer />
</Base>
......@@ -62,6 +62,13 @@
.teacher-profile__intro h2>span{display:inline-block;white-space:nowrap}
@media(min-width:761px){.teacher-profile__intro{grid-template-columns:1.08fr .92fr;gap:78px}}
/* 服务流程后的咨询区使用浅蓝底,和深蓝流程区形成清晰分层。 */
.cta-band--soft{color:var(--text-primary);border-top:1px solid #d7e8f9;background:var(--bg-blue-soft)}
.cta-band--soft p{color:var(--text-secondary)}
.cta-band--soft .button--white{border-color:var(--brand-blue);color:#fff;background:var(--brand-blue)}
.cta-band--soft .button--white:hover{border-color:var(--primary-hover);color:#fff;background:var(--primary-hover)}
.cta-band--soft .text-link--light{color:var(--brand-blue)}
/* 学科辅导单页:四个连续信息模块,减少跨页查找。 */
.subject-service-hero{position:relative;overflow:hidden;background:linear-gradient(125deg,#f1f7ff 0%,#fff 76%)}
.subject-service-hero::after{position:absolute;right:-8vw;bottom:-220px;width:520px;height:520px;border:78px solid rgba(20,115,230,.055);border-radius:50%;content:""}
......@@ -73,7 +80,7 @@
.subject-service-overview__copy aside{margin-top:32px;padding:20px 22px;border-left:3px solid var(--brand-blue);color:#53697d;background:#f3f8ff;font-size:13px;line-height:1.8}
.subject-stage-section .section-heading{max-width:830px}
.subject-stage-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-top:48px}
.subject-stage-grid article{min-height:340px;padding:30px;border:1px solid #cfe2f6;border-top:4px solid var(--brand-blue);border-radius:14px;background:#fff;box-shadow:var(--shadow-card)}
.subject-stage-grid article{min-height:340px;padding:34px 30px 30px;border:1px solid #cfe2f6;border-radius:0;background:linear-gradient(to bottom,var(--brand-blue) 0 4px,#fff 4px 100%);box-shadow:var(--shadow-card)}
.subject-stage-card__meta{display:flex;min-height:48px;align-items:flex-start;justify-content:space-between;gap:16px;padding-bottom:18px;border-bottom:1px solid var(--divider)}
.subject-stage-card__meta span{color:var(--brand-blue);font-size:12px;font-weight:700}
.subject-stage-card__meta small{color:var(--text-muted);font-size:11px;text-align:right}
......@@ -102,9 +109,6 @@
/* 关于页:用真实服务信息和课堂场景完成品牌介绍。 */
.about-hero{background:linear-gradient(125deg,#f1f7ff 0%,#fff 76%)}
.about-brand-layout{display:grid;grid-template-columns:.92fr 1.08fr;gap:76px;align-items:start}
.about-brand-visual{position:sticky;top:112px;margin:0;overflow:hidden;border:1px solid var(--border);border-radius:18px;background:#edf6ff;box-shadow:var(--shadow-card)}
.about-brand-visual img{width:100%;aspect-ratio:1.16;object-fit:cover}
.about-brand-visual figcaption{padding:12px 17px;color:var(--text-muted);background:#fff;font-size:11px}
.about-brand-copy .section-heading{margin-bottom:32px}
.about-brand-copy .section-heading>p{font-size:16px;line-height:1.9}
.about-brand-copy .story-column p:last-child{border-bottom:0}
......@@ -124,6 +128,8 @@
.contact-hero .hero__actions>.button:first-child{border-color:#fff;color:#0b5eba;background:#fff;box-shadow:0 8px 22px rgba(6,52,105,.18)}
.contact-hero .hero__actions>.button:first-child:hover{border-color:#fff;color:#084f9c;background:#edf6ff}
.contact-hero .hero__actions>.button:focus-visible{outline:3px solid rgba(255,255,255,.85);outline-offset:3px}
.contact-hero .breadcrumb,.contact-hero .breadcrumb a{color:rgba(255,255,255,.86)}
.contact-hero .breadcrumb a:hover{color:#fff}
@media(max-width:1080px){
.subject-stage-grid article{min-height:365px}
......@@ -158,7 +164,6 @@
.subject-process-path article>span{margin:0;box-shadow:0 0 0 6px #1473e6}
.subject-process-path h3{font-size:19px}
.about-brand-layout{grid-template-columns:1fr;gap:38px}
.about-brand-visual{position:static}
.about-brand-copy .section-heading{margin-bottom:26px}
.about-facts{grid-template-columns:1fr;margin-top:46px}
.about-facts article,.about-facts article:nth-child(2){min-height:auto;border-right:0;border-bottom:1px solid var(--border)}
......
......@@ -10,7 +10,13 @@ process.env.CMS_PASSWORD = "environment-master-password";
process.env.CMS_SECRET = "auth-test-session-secret";
process.env.CMS_API_KEY = "auth-test-api-key";
const [{ handleCmsApi }, { createConsultation, createConsultationTeacher }] = await Promise.all([
const [{ handleCmsApi }, {
createConsultation,
createConsultationTeacher,
deleteConsultationTeacher,
listConsultations,
updateConsultationTeacher,
}] = await Promise.all([
import("../src/lib/cms-api"),
import("../src/lib/consultation-store"),
]);
......@@ -193,10 +199,12 @@ test("customer service and operator accounts are restricted to their own work ar
phone: "13800138000",
teacherId: serviceAccount.id,
status: "unprocessed",
unaddedReason: "第一行原因\n第二行原因",
callCount: 0,
note: "第一行备注\n第二行备注",
});
assert.equal(generatedConsultation.location, "山西省 运城市 中国移动");
assert.equal(generatedConsultation.unaddedReason, "第一行原因\n第二行原因");
assert.equal(generatedConsultation.note, "第一行备注\n第二行备注");
} finally {
globalThis.fetch = originalFetch;
......@@ -235,3 +243,55 @@ test("customer service and operator accounts are restricted to their own work ar
}), "consultations");
assert.equal(operatorConsultations.status, 403);
});
test("consultations resolve mutable teacher details through teacherId", async () => {
const teacher = await createConsultationTeacher({
role: "customer_service",
name: "老师1",
username: "relation_test",
password: "relation-password",
title: "学情老师",
wechat: "teacher_one",
qrCodeUrl: "/uploads/relation-test.png",
sort: 2,
enabled: true,
});
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => new Response(JSON.stringify({ code: 0, data: { province: "河北", city: "石家庄", sp: "联通" } }), { status: 200, headers: { "Content-Type": "application/json" } })) as typeof fetch;
let consultation: Awaited<ReturnType<typeof createConsultation>>;
try {
consultation = await createConsultation({
name: "关联测试用户",
phone: "13900139000",
teacherId: teacher.id,
status: "unprocessed",
callCount: 0,
note: "",
});
} finally {
globalThis.fetch = originalFetch;
}
await updateConsultationTeacher(teacher.id, {
role: "customer_service",
name: "老师2",
username: "relation_test",
title: "资深学情老师",
wechat: "teacher_two",
qrCodeUrl: "/uploads/relation-test-new.png",
sort: 2,
enabled: true,
});
const listed = (await listConsultations()).find((item) => item.id === consultation.id);
assert.equal(listed?.teacherName, "老师2");
assert.equal(listed?.teacherTitle, "资深学情老师");
assert.equal(listed?.teacherWechat, "teacher_two");
assert.equal(listed?.teacherQrCodeUrl, "/uploads/relation-test-new.png");
const stored = JSON.parse(await fs.readFile(path.join(dataDirectory, "consultations", "store.json"), "utf8"));
const storedConsultation = stored.consultations.find((item: { id: string }) => item.id === consultation.id);
assert.equal(Object.hasOwn(storedConsultation, "teacherName"), false);
assert.equal(Object.hasOwn(storedConsultation, "teacherWechat"), false);
await assert.rejects(() => deleteConsultationTeacher(teacher.id), /已有分配记录/);
});
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