Commit 3d8d4534 authored by tao355667's avatar tao355667

feat: expand consultation admin roles

parent 19e1bb16
...@@ -13,6 +13,10 @@ let captchaObjectUrl = ""; ...@@ -13,6 +13,10 @@ let captchaObjectUrl = "";
let captchaRequest = 0; let captchaRequest = 0;
let currentUser = null; let currentUser = null;
let consultationTeachers = []; let consultationTeachers = [];
let staffAccounts = [];
let activeConsultationNote = null;
let activeConsultationUser = null;
const consultationStatusLabels = { unprocessed: "未处理", added: "已添加", not_added: "未添加" };
async function api(path, options = {}) { async function api(path, options = {}) {
const endpoint = path.startsWith("/") ? path : `/${path}`; const endpoint = path.startsWith("/") ? path : `/${path}`;
...@@ -89,135 +93,242 @@ $("#logout").addEventListener("click", async () => { await api("/session", { met ...@@ -89,135 +93,242 @@ $("#logout").addEventListener("click", async () => { await api("/session", { met
async function enterApp(user) { async function enterApp(user) {
currentUser = user; currentUser = user;
const admin = user?.role === "admin"; const admin = user?.role === "admin";
document.body.classList.toggle("teacher-session", !admin); const customerService = user?.role === "customer_service";
const operator = user?.role === "operator";
document.body.classList.toggle("teacher-session", customerService);
document.body.classList.toggle("operator-session", operator);
document.querySelectorAll(".admin-only").forEach((element) => element.classList.toggle("hidden", !admin)); document.querySelectorAll(".admin-only").forEach((element) => element.classList.toggle("hidden", !admin));
$("#account-role").textContent = admin ? "管理员账号" : "咨询老师"; document.querySelectorAll(".consultation-role").forEach((element) => element.classList.toggle("hidden", operator));
document.querySelectorAll(".content-role").forEach((element) => element.classList.toggle("hidden", customerService));
$("#account-role").textContent = admin ? "管理员账号" : operator ? "运营账号" : "客服账号";
$("#account-name").textContent = user?.name || ""; $("#account-name").textContent = user?.name || "";
$("#consultation-scope").textContent = admin ? "管理员可查看全部老师的咨询" : "这里只显示分配给你的咨询"; $("#consultation-scope").textContent = admin ? "管理员可查看全部客服的用户线索" : "这里只显示分配给你的用户线索";
await showAdminView("consultations"); await showAdminView(operator ? "articles" : "consultations");
if (admin) { if (admin || operator) {
await loadCategories(); await loadCategories();
loadArticles(); loadArticles();
} }
} }
const adminViews = { const adminViews = {
consultations: ["#consultations-view", "咨询记录", "查看和跟进官网咨询"], consultations: ["#consultations-view", "用户管理", "查看和跟进官网用户线索"],
teachers: ["#teachers-view", "咨询老师", "配置账号、二维码与轮询顺序"], teachers: ["#teachers-view", "账号管理", "管理客服和运营账号权限"],
articles: ["#list-view", "学习资讯", "管理官网文章内容"], articles: ["#list-view", "学习资讯", "管理官网文章内容"],
settings: ["#settings-view", "账号设置", "管理管理员账号的登录安全"],
}; };
async function showAdminView(name) { async function showAdminView(name) {
Object.values(adminViews).forEach(([selector]) => hide(selector)); Object.values(adminViews).forEach(([selector]) => hide(selector));
hide("#edit-view"); hide("#edit-view");
const view = adminViews[name] || adminViews.consultations; const allowedName = currentUser?.role === "operator" ? "articles" : name;
const view = adminViews[allowedName] || adminViews.consultations;
show(view[0]); show(view[0]);
$("#page-title").textContent = view[1]; $("#page-title").textContent = view[1];
$("#page-subtitle").textContent = view[2]; $("#page-subtitle").textContent = view[2];
document.querySelectorAll("[data-admin-nav]").forEach((button) => button.classList.toggle("active", button.dataset.adminNav === name)); document.querySelectorAll("[data-admin-nav]").forEach((button) => button.classList.toggle("active", button.dataset.adminNav === allowedName));
if (name === "consultations") await loadConsultations(); if (allowedName === "consultations") await loadConsultations();
if (name === "teachers") await loadTeachers(); if (allowedName === "teachers") await loadTeachers();
if (name === "articles") await loadArticles(); if (allowedName === "articles") await loadArticles();
} }
document.querySelectorAll("[data-admin-nav]").forEach((button) => button.addEventListener("click", () => { void showAdminView(button.dataset.adminNav); })); document.querySelectorAll("[data-admin-nav]").forEach((button) => button.addEventListener("click", () => { void showAdminView(button.dataset.adminNav); }));
const formatTime = (value) => new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false }).format(new Date(value)); const formatTime = (value) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "-";
const pad = (part) => String(part).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
};
function updateConsultationMetrics(records) { function updateConsultationMetrics(records) {
$("#consultation-count").textContent = records.length; $("#consultation-count").textContent = records.length;
$("#metric-total").textContent = records.length; $("#metric-total").textContent = records.length;
["new", "contacted", "completed"].forEach((status) => { [["unprocessed", "metric-unprocessed"], ["added", "metric-added"], ["not_added", "metric-not-added"]].forEach(([status, id]) => {
$(`#metric-${status}`).textContent = records.filter((item) => item.status === status).length; $(`#${id}`).textContent = records.filter((item) => item.status === status).length;
}); });
} }
function closeConsultationNoteModal() {
hide("#consultation-note-modal");
activeConsultationNote = null;
$("#note-card-message").textContent = "";
}
function openConsultationNoteModal(record, records, form) {
activeConsultationNote = { record, records, form };
$("#note-card-phone").textContent = record.phone;
$("#note-card-teacher").textContent = record.teacherName;
$("#note-card-meta").textContent = `提交于 ${formatTime(record.createdAt)}`;
$("#note-card-status").value = record.status;
$("#note-card-content").value = record.note || "";
$("#note-card-count").textContent = $("#note-card-content").value.length;
$("#note-card-message").textContent = "";
show("#consultation-note-modal");
$("#note-card-content").focus();
}
$("#close-note-modal").addEventListener("click", closeConsultationNoteModal);
$("#cancel-note-modal").addEventListener("click", closeConsultationNoteModal);
$("#note-card-content").addEventListener("input", () => { $("#note-card-count").textContent = $("#note-card-content").value.length; });
$("#consultation-note-form").addEventListener("submit", async (event) => {
event.preventDefault();
if (!activeConsultationNote) return;
const { record, records, form } = activeConsultationNote;
const submit = event.submitter;
submit.disabled = true;
$("#note-card-message").textContent = "保存中…";
try {
const result = await api(`/consultations/${record.id}`, { method: "PATCH", body: { status: $("#note-card-status").value, note: $("#note-card-content").value } });
record.status = result.consultation.status;
record.note = result.consultation.note;
if (form) {
const select = form.querySelector("select");
const textarea = form.querySelector("textarea");
if (select) select.value = record.status;
if (textarea) textarea.value = record.note;
}
updateConsultationMetrics(records);
closeConsultationNoteModal();
} catch (error) { $("#note-card-message").textContent = error.message; }
finally { submit.disabled = false; }
});
async function loadConsultations() { async function loadConsultations() {
const result = await api("/consultations").catch((error) => { alert(error.message); return { consultations: [] }; }); const result = await api("/consultations").catch((error) => { alert(error.message); return { consultations: [] }; });
const records = result.consultations || []; const records = result.consultations || [];
if (currentUser?.role === "admin" && !consultationTeachers.length) {
const teacherResult = await api("/consultation-teachers").catch(() => ({ teachers: [] }));
staffAccounts = teacherResult.teachers || [];
consultationTeachers = staffAccounts.filter((teacher) => teacher.role === "customer_service");
}
updateConsultationMetrics(records); updateConsultationMetrics(records);
const list = $("#consultation-list"); const list = $("#consultation-list");
list.innerHTML = ""; list.innerHTML = "";
if (!records.length) { if (!records.length) {
list.innerHTML = '<div class="empty">暂时没有咨询记录。官网用户提交手机号后会显示在这里。</div>'; list.innerHTML = '<div class="empty">暂时没有用户线索。官网用户提交手机号后会显示在这里。</div>';
return; return;
} }
const head = document.createElement("div"); const head = document.createElement("div");
head.className = "data-row data-head consultation-row"; head.className = "data-row data-head consultation-row";
head.innerHTML = "<span>手机号</span><span>提交时间</span><span>分配老师</span><span>跟进状态</span><span>跟进备注</span><span>操作</span>"; head.innerHTML = "<span>ID</span><span>姓名</span><span>手机号</span><span>归属地</span><span>分配客服</span><span>状态</span><span>未添加原因</span><span>备注</span><span>打电话次数</span><span>提交时间</span><span>操作</span>";
list.append(head); list.append(head);
records.forEach((record) => { records.forEach((record) => {
const row = document.createElement("article"); const row = document.createElement("article");
row.className = "data-row consultation-row"; row.className = "data-row consultation-row";
row.innerHTML = '<a class="phone-link"></a><time class="created"></time><span class="teacher-name"></span><form class="inline-follow-form"><select aria-label="咨询状态"><option value="new">待联系</option><option value="contacted">跟进中</option><option value="completed">已完成</option></select><div class="inline-note"><textarea maxlength="500" rows="2" placeholder="记录沟通结果或下次跟进时间"></textarea><button class="note-toggle" type="button">查看全部备注</button></div><div class="inline-follow-actions"><button class="primary small" type="submit">保存</button><small class="save-state"></small></div></form>'; row.innerHTML = '<strong class="sequence"></strong><span class="user-name"></span><a class="phone-link"></a><span class="location"></span><span class="teacher-name"></span><span class="consultation-status"></span><span class="unadded-reason"></span><span class="note-summary"></span><span class="call-count"></span><time class="created"></time><div class="row-actions"></div>';
row.querySelector(".sequence").textContent = record.sequence || "-";
row.querySelector(".user-name").textContent = record.name || "未填写";
const phone = row.querySelector(".phone-link"); const phone = row.querySelector(".phone-link");
phone.textContent = record.phone; phone.textContent = record.phone;
phone.href = `tel:${record.phone}`; phone.href = `tel:${record.phone}`;
row.querySelector(".location").textContent = record.location || "-";
row.querySelector(".teacher-name").textContent = record.teacherName || "未分配";
row.querySelector(".consultation-status").textContent = consultationStatusLabels[record.status] || "未处理";
row.querySelector(".unadded-reason").textContent = record.unaddedReason || "-";
row.querySelector(".note-summary").textContent = record.note || "-";
row.querySelector(".call-count").textContent = record.callCount ?? 0;
row.querySelector(".created").textContent = formatTime(record.createdAt); row.querySelector(".created").textContent = formatTime(record.createdAt);
row.querySelector(".teacher-name").textContent = record.teacherName; const actions = row.querySelector(".row-actions");
const form = row.querySelector("form"); actions.append(listAction("编辑", "ghost small", async () => { if (currentUser?.role === "admin" && !consultationTeachers.length) await loadTeachers(); openConsultationUserModal(record); }));
const textarea = form.querySelector("textarea"); if (currentUser?.role === "admin") actions.append(listAction("删除", "ghost danger small", async () => {
const noteToggle = form.querySelector(".note-toggle"); if (!confirm(`确定删除用户“${record.phone}”吗?`)) return;
form.querySelector("select").value = record.status; try { await api(`/consultations/${record.id}`, { method: "DELETE" }); await loadConsultations(); } catch (error) { alert(error.message); }
textarea.value = record.note || ""; }));
noteToggle.addEventListener("click", () => { actions.append(listAction("查看全部备注", "ghost small", () => openConsultationNoteModal(record, records, null)));
const expanded = textarea.classList.toggle("expanded");
textarea.style.height = expanded ? `${Math.min(Math.max(textarea.scrollHeight, 96), 260)}px` : "";
noteToggle.textContent = expanded ? "收起备注" : "查看全部备注";
if (expanded) textarea.focus();
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
const button = form.querySelector('button[type="submit"]');
const state = form.querySelector(".save-state");
button.disabled = true;
state.textContent = "保存中…";
try {
const result = await api(`/consultations/${record.id}`, { method: "PATCH", body: { status: form.querySelector("select").value, note: form.querySelector("textarea").value } });
record.status = result.consultation.status;
record.note = result.consultation.note;
updateConsultationMetrics(records);
state.textContent = "已保存";
setTimeout(() => { state.textContent = ""; }, 1600);
} catch (error) { state.textContent = error.message; }
finally { button.disabled = false; }
});
list.append(row); list.append(row);
}); });
} }
$("#refresh-consultations").addEventListener("click", loadConsultations); $("#refresh-consultations").addEventListener("click", loadConsultations);
function closeConsultationUserModal() {
hide("#consultation-user-modal");
$("#consultation-user-form").reset();
$("#consultation-user-error").textContent = "";
activeConsultationUser = null;
}
function openConsultationUserModal(record = null) {
if (!consultationTeachers.length && currentUser?.role === "admin") { alert("请先在账号管理中添加客服"); return; }
activeConsultationUser = record;
const teacherSelect = $("#consultation-user-teacher");
teacherSelect.innerHTML = consultationTeachers.length
? consultationTeachers.map((teacher) => `<option value="${teacher.id}">${teacher.name}${teacher.username})</option>`).join("")
: `<option value="${record?.teacherId || currentUser?.id || ""}">${record?.teacherName || currentUser?.name || "当前客服"}</option>`;
$("#consultation-user-form").reset();
$("#consultation-user-title").textContent = record ? "编辑用户" : "录入用户";
$("#consultation-user-id").value = record?.id || "";
$("#consultation-user-name").value = record?.name || "";
$("#consultation-user-phone").value = record?.phone || "";
$("#consultation-user-location").value = record?.location || "";
teacherSelect.value = record?.teacherId || consultationTeachers[0]?.id || "";
teacherSelect.disabled = currentUser?.role !== "admin";
$("#consultation-user-status").value = record?.status || "unprocessed";
$("#consultation-user-reason").value = record?.unaddedReason || "";
$("#consultation-user-calls").value = record?.callCount ?? 0;
$("#consultation-user-note").value = record?.note || "";
$("#consultation-user-error").textContent = "";
show("#consultation-user-modal");
$("#consultation-user-name").focus();
}
$("#new-consultation").addEventListener("click", async () => {
if (currentUser?.role === "admin" && !consultationTeachers.length) await loadTeachers();
openConsultationUserModal();
});
$("#close-consultation-user-modal").addEventListener("click", closeConsultationUserModal);
$("#cancel-consultation-user").addEventListener("click", closeConsultationUserModal);
$("#consultation-user-form").addEventListener("submit", async (event) => {
event.preventDefault();
const id = $("#consultation-user-id").value;
const body = {
name: $("#consultation-user-name").value, phone: $("#consultation-user-phone").value,
location: $("#consultation-user-location").value, teacherId: $("#consultation-user-teacher").value,
status: $("#consultation-user-status").value, unaddedReason: $("#consultation-user-reason").value,
callCount: Number($("#consultation-user-calls").value), note: $("#consultation-user-note").value,
};
const submit = event.submitter;
submit.disabled = true;
$("#consultation-user-error").textContent = "";
try {
await api(id ? `/consultations/${id}` : "/consultations", { method: id ? "PATCH" : "POST", body });
closeConsultationUserModal();
await loadConsultations();
} catch (error) { $("#consultation-user-error").textContent = error.message; }
finally { submit.disabled = false; }
});
async function loadTeachers() { async function loadTeachers() {
const result = await api("/consultation-teachers").catch((error) => { alert(error.message); return { teachers: [] }; }); const result = await api("/consultation-teachers").catch((error) => { alert(error.message); return { teachers: [] }; });
consultationTeachers = result.teachers || []; staffAccounts = result.teachers || [];
consultationTeachers = staffAccounts.filter((teacher) => teacher.role === "customer_service");
const list = $("#teacher-list"); const list = $("#teacher-list");
list.innerHTML = ""; list.innerHTML = "";
if (!consultationTeachers.length) { if (!staffAccounts.length) {
list.innerHTML = '<div class="empty">还没有咨询老师。添加并启用老师后,前台手机号才能按顺序分配。</div>'; list.innerHTML = '<div class="empty">还没有后台账号。添加客服或运营账号后会显示在这里。</div>';
return; return;
} }
const head = document.createElement("div"); const head = document.createElement("div");
head.className = "data-row data-head teacher-row"; head.className = "data-row data-head teacher-row";
head.innerHTML = "<span>老师</span><span>登录账号</span><span>轮询</span><span>累计分配</span><span>状态</span><span>操作</span>"; head.innerHTML = "<span>账号</span><span>登录账号</span><span>角色</span><span>累计分配</span><span>状态</span><span>操作</span>";
list.append(head); list.append(head);
consultationTeachers.forEach((teacher) => { staffAccounts.forEach((teacher) => {
const row = document.createElement("article"); const row = document.createElement("article");
row.className = "data-row teacher-row"; row.className = "data-row teacher-row";
row.innerHTML = '<div class="teacher-identity"><img alt="微信二维码" /><div><strong></strong><small></small></div></div><span class="username"></span><strong class="sort"></strong><span class="assigned"></span><span class="teacher-state"></span><div class="row-actions"></div>'; row.innerHTML = '<div class="teacher-identity"><img alt="微信二维码" /><div><strong></strong><small></small></div></div><span class="username"></span><strong class="sort"></strong><span class="assigned"></span><span class="teacher-state"></span><div class="row-actions"></div>';
row.querySelector("img").src = teacher.qrCodeUrl; row.querySelector("img").src = teacher.qrCodeUrl || "/favicon.png";
row.querySelector(".teacher-identity strong").textContent = teacher.name; row.querySelector(".teacher-identity strong").textContent = teacher.name;
row.querySelector(".teacher-identity small").textContent = teacher.title; row.querySelector(".teacher-identity small").textContent = teacher.role === "operator" ? "学习资讯运营" : teacher.title;
row.querySelector(".username").textContent = teacher.username; row.querySelector(".username").textContent = teacher.username;
row.querySelector(".sort").textContent = teacher.sort; row.querySelector(".sort").textContent = teacher.role === "operator" ? "运营" : "客服";
row.querySelector(".assigned").textContent = `${teacher.assignedCount} 次`; row.querySelector(".assigned").textContent = teacher.role === "operator" ? "-" : `${teacher.assignedCount} 次`;
const state = row.querySelector(".teacher-state"); const state = row.querySelector(".teacher-state");
state.textContent = teacher.enabled ? "启用" : "停用"; state.textContent = teacher.enabled ? "启用" : "停用";
state.className = `teacher-state ${teacher.enabled ? "enabled" : "disabled"}`; state.className = `teacher-state ${teacher.enabled ? "enabled" : "disabled"}`;
row.querySelector(".row-actions").append( row.querySelector(".row-actions").append(
listAction("编辑", "ghost small", () => openTeacherModal(teacher)), listAction("编辑", "ghost small", () => openTeacherModal(teacher)),
listAction("删除", "ghost danger small", async () => { listAction("删除", "ghost danger small", async () => {
if (!confirm(`确定删除咨询老师“${teacher.name}”吗?历史咨询仍会保留。`)) return; if (!confirm(`确定删除账号“${teacher.name}”吗?历史数据仍会保留。`)) return;
try { await api(`/consultation-teachers/${teacher.id}`, { method: "DELETE" }); await loadTeachers(); } try { await api(`/consultation-teachers/${teacher.id}`, { method: "DELETE" }); await loadTeachers(); }
catch (error) { alert(error.message); } catch (error) { alert(error.message); }
}), }),
...@@ -232,7 +343,8 @@ function openTeacherModal(teacher = null) { ...@@ -232,7 +343,8 @@ function openTeacherModal(teacher = null) {
$("#teacher-form").reset(); $("#teacher-form").reset();
$("#teacher-error").textContent = ""; $("#teacher-error").textContent = "";
$("#teacher-id").value = teacher?.id || ""; $("#teacher-id").value = teacher?.id || "";
$("#teacher-modal-title").textContent = teacher ? "编辑咨询老师" : "添加咨询老师"; $("#teacher-modal-title").textContent = teacher ? "编辑后台账号" : "添加后台账号";
$("#teacher-role").value = teacher?.role || "customer_service";
$("#teacher-name").value = teacher?.name || ""; $("#teacher-name").value = teacher?.name || "";
$("#teacher-title").value = teacher?.title || "教务老师|一对一学情沟通"; $("#teacher-title").value = teacher?.title || "教务老师|一对一学情沟通";
$("#teacher-username").value = teacher?.username || ""; $("#teacher-username").value = teacher?.username || "";
...@@ -245,11 +357,21 @@ function openTeacherModal(teacher = null) { ...@@ -245,11 +357,21 @@ function openTeacherModal(teacher = null) {
$("#teacher-qr-preview").src = teacher?.qrCodeUrl || ""; $("#teacher-qr-preview").src = teacher?.qrCodeUrl || "";
$("#teacher-qr-preview").classList.toggle("hidden", !teacher?.qrCodeUrl); $("#teacher-qr-preview").classList.toggle("hidden", !teacher?.qrCodeUrl);
$("#teacher-qr-label").textContent = teacher ? "点击更换二维码" : "点击上传二维码"; $("#teacher-qr-label").textContent = teacher ? "点击更换二维码" : "点击上传二维码";
syncStaffRoleFields();
show("#teacher-modal"); show("#teacher-modal");
$("#teacher-name").focus(); $("#teacher-name").focus();
} }
$("#new-teacher").addEventListener("click", () => openTeacherModal()); $("#new-teacher").addEventListener("click", () => openTeacherModal());
function syncStaffRoleFields() {
const customerService = $("#teacher-role").value === "customer_service";
document.querySelectorAll(".customer-service-field").forEach((field) => field.classList.toggle("hidden", !customerService));
$("#teacher-title").required = customerService;
$("#teacher-sort").required = customerService;
$("#teacher-enabled-label").textContent = customerService ? "启用并参与轮询分配" : "启用该运营账号";
$("#teacher-modal-description").textContent = customerService ? "客服负责查看并处理分配给自己的用户咨询" : "运营负责学习资讯的创建、编辑与发布";
}
$("#teacher-role").addEventListener("change", syncStaffRoleFields);
$("#close-teacher-modal").addEventListener("click", closeTeacherModal); $("#close-teacher-modal").addEventListener("click", closeTeacherModal);
$("#cancel-teacher").addEventListener("click", closeTeacherModal); $("#cancel-teacher").addEventListener("click", closeTeacherModal);
$("#teacher-qr-button").addEventListener("click", () => $("#teacher-qr-file").click()); $("#teacher-qr-button").addEventListener("click", () => $("#teacher-qr-file").click());
...@@ -272,7 +394,7 @@ $("#teacher-qr-file").addEventListener("change", async (event) => { ...@@ -272,7 +394,7 @@ $("#teacher-qr-file").addEventListener("change", async (event) => {
$("#teacher-form").addEventListener("submit", async (event) => { $("#teacher-form").addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
const id = $("#teacher-id").value; const id = $("#teacher-id").value;
const body = { name: $("#teacher-name").value, title: $("#teacher-title").value, username: $("#teacher-username").value, wechat: $("#teacher-wechat").value, sort: Number($("#teacher-sort").value), password: $("#teacher-password").value, qrCodeUrl: $("#teacher-qr-url").value, enabled: $("#teacher-enabled").checked }; const body = { role: $("#teacher-role").value, name: $("#teacher-name").value, title: $("#teacher-title").value, username: $("#teacher-username").value, wechat: $("#teacher-wechat").value, sort: Number($("#teacher-sort").value || 1), password: $("#teacher-password").value, qrCodeUrl: $("#teacher-qr-url").value, enabled: $("#teacher-enabled").checked };
const submit = event.submitter; const submit = event.submitter;
submit.disabled = true; submit.disabled = true;
$("#teacher-error").textContent = ""; $("#teacher-error").textContent = "";
......
...@@ -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() });
......
...@@ -9,10 +9,12 @@ const STORE_FILE = path.join(DATA_ROOT, "consultations", "store.json"); ...@@ -9,10 +9,12 @@ const STORE_FILE = path.join(DATA_ROOT, "consultations", "store.json");
const SCRYPT_OPTIONS = { N: 16384, r: 8, p: 1, maxmem: 32 * 1024 * 1024 }; const SCRYPT_OPTIONS = { N: 16384, r: 8, p: 1, maxmem: 32 * 1024 * 1024 };
const HASH_LENGTH = 64; const HASH_LENGTH = 64;
export type ConsultationStatus = "new" | "contacted" | "completed"; export type ConsultationStatus = "unprocessed" | "not_added" | "added";
export type StaffRole = "customer_service" | "operator";
export interface ConsultationTeacher { export interface ConsultationTeacher {
id: string; id: string;
role: StaffRole;
name: string; name: string;
username: string; username: string;
title: string; title: string;
...@@ -31,7 +33,10 @@ export interface ConsultationTeacher { ...@@ -31,7 +33,10 @@ export interface ConsultationTeacher {
export interface ConsultationRecord { export interface ConsultationRecord {
id: string; id: string;
sequence: number;
name: string;
phone: string; phone: string;
location: string;
teacherId: string; teacherId: string;
teacherName: string; teacherName: string;
teacherTitle: string; teacherTitle: string;
...@@ -40,6 +45,8 @@ export interface ConsultationRecord { ...@@ -40,6 +45,8 @@ export interface ConsultationRecord {
source: string; source: string;
page: string; page: string;
status: ConsultationStatus; status: ConsultationStatus;
unaddedReason: string;
callCount: number;
note: string; note: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
...@@ -50,6 +57,7 @@ interface ConsultationState { ...@@ -50,6 +57,7 @@ interface ConsultationState {
teachers: ConsultationTeacher[]; teachers: ConsultationTeacher[];
consultations: ConsultationRecord[]; consultations: ConsultationRecord[];
lastAssignedTeacherId: string | null; lastAssignedTeacherId: string | null;
nextConsultationNumber: number;
} }
export type PublicTeacher = Pick<ConsultationTeacher, "id" | "name" | "title" | "wechat" | "qrCodeUrl">; export type PublicTeacher = Pick<ConsultationTeacher, "id" | "name" | "title" | "wechat" | "qrCodeUrl">;
...@@ -57,7 +65,7 @@ export type TeacherListItem = Omit<ConsultationTeacher, "passwordSalt" | "passwo ...@@ -57,7 +65,7 @@ export type TeacherListItem = Omit<ConsultationTeacher, "passwordSalt" | "passwo
let mutationQueue: Promise<unknown> = Promise.resolve(); let mutationQueue: Promise<unknown> = Promise.resolve();
const emptyState = (): ConsultationState => ({ version: 1, teachers: [], consultations: [], lastAssignedTeacherId: null }); const emptyState = (): ConsultationState => ({ version: 1, teachers: [], consultations: [], lastAssignedTeacherId: null, nextConsultationNumber: 1 });
async function writeAtomic(file: string, content: string): Promise<void> { async function writeAtomic(file: string, content: string): Promise<void> {
await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 }); await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
...@@ -77,11 +85,33 @@ async function readState(): Promise<ConsultationState> { ...@@ -77,11 +85,33 @@ async function readState(): Promise<ConsultationState> {
if (parsed.version !== 1 || !Array.isArray(parsed.teachers) || !Array.isArray(parsed.consultations)) { if (parsed.version !== 1 || !Array.isArray(parsed.teachers) || !Array.isArray(parsed.consultations)) {
throw new Error("咨询数据文件格式无效"); throw new Error("咨询数据文件格式无效");
} }
const rawConsultations = parsed.consultations as Array<Partial<ConsultationRecord> & { status?: string }>;
const total = rawConsultations.length;
const consultations = rawConsultations.map((item, index) => {
const legacyStatus = String(item.status || "");
return {
...item,
sequence: Number.isInteger(item.sequence) ? Number(item.sequence) : total - index,
name: String(item.name || ""),
location: String(item.location || ""),
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,
};
}) as ConsultationRecord[];
const highestSequence = consultations.reduce((highest, item) => Math.max(highest, item.sequence), 0);
const teachers = (parsed.teachers as Array<Partial<ConsultationTeacher>>).map((teacher) => ({
...teacher,
role: teacher.role === "operator" ? "operator" : "customer_service",
})) as ConsultationTeacher[];
return { return {
version: 1, version: 1,
teachers: parsed.teachers as ConsultationTeacher[], teachers,
consultations: parsed.consultations as ConsultationRecord[], consultations,
lastAssignedTeacherId: typeof parsed.lastAssignedTeacherId === "string" ? parsed.lastAssignedTeacherId : null, lastAssignedTeacherId: typeof parsed.lastAssignedTeacherId === "string" ? parsed.lastAssignedTeacherId : null,
nextConsultationNumber: Math.max(Number(parsed.nextConsultationNumber) || 1, highestSequence + 1),
}; };
} catch (error) { } catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return emptyState(); if ((error as NodeJS.ErrnoException).code === "ENOENT") return emptyState();
...@@ -121,10 +151,32 @@ function normalizeSort(value: unknown): number { ...@@ -121,10 +151,32 @@ function normalizeSort(value: unknown): number {
function normalizeQrCodeUrl(value: unknown): string { function normalizeQrCodeUrl(value: unknown): string {
const url = String(value || "").trim(); const url = String(value || "").trim();
if (!/^\/uploads\/[a-zA-Z0-9._-]+$/.test(url)) throw new StoreError("请上传老师微信二维码"); if (!/^\/uploads\/[a-zA-Z0-9._-]+$/.test(url)) throw new StoreError("请上传客服微信二维码");
return url; return url;
} }
function normalizePhone(value: unknown): string {
const phone = String(value || "").replace(/\s+/g, "");
if (!/^1[3-9]\d{9}$/.test(phone)) throw new StoreError("请输入正确的 11 位手机号");
return phone;
}
function normalizeCallCount(value: unknown): number {
const count = Number(value ?? 0);
if (!Number.isInteger(count) || count < 0 || count > 9999) throw new StoreError("打电话次数需为 0-9999 的整数");
return count;
}
function normalizeConsultationStatus(value: unknown): ConsultationStatus {
const status = String(value || "unprocessed") as ConsultationStatus;
if (!["unprocessed", "not_added", "added"].includes(status)) throw new StoreError("用户状态无效");
return status;
}
function normalizeStaffRole(value: unknown): StaffRole {
return value === "operator" ? "operator" : "customer_service";
}
async function passwordHash(password: string, salt: Buffer): Promise<string> { async function passwordHash(password: string, salt: Buffer): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
crypto.scrypt(password, salt, HASH_LENGTH, SCRYPT_OPTIONS, (error, key) => { crypto.scrypt(password, salt, HASH_LENGTH, SCRYPT_OPTIONS, (error, key) => {
...@@ -136,7 +188,7 @@ async function passwordHash(password: string, salt: Buffer): Promise<string> { ...@@ -136,7 +188,7 @@ async function passwordHash(password: string, salt: Buffer): Promise<string> {
async function passwordFields(value: unknown): Promise<{ passwordSalt: string; passwordHash: string }> { async function passwordFields(value: unknown): Promise<{ passwordSalt: string; passwordHash: string }> {
const password = String(value || ""); const password = String(value || "");
if (password.length < 6 || password.length > 128) throw new StoreError("老师登录密码需为 6-128 个字符"); if (password.length < 6 || password.length > 128) throw new StoreError("客服登录密码需为 6-128 个字符");
const salt = crypto.randomBytes(16); const salt = crypto.randomBytes(16);
return { passwordSalt: salt.toString("base64"), passwordHash: await passwordHash(password, salt) }; return { passwordSalt: salt.toString("base64"), passwordHash: await passwordHash(password, salt) };
} }
...@@ -162,12 +214,13 @@ export async function createConsultationTeacher(input: Record<string, unknown>): ...@@ -162,12 +214,13 @@ export async function createConsultationTeacher(input: Record<string, unknown>):
const now = new Date().toISOString(); const now = new Date().toISOString();
const teacher: ConsultationTeacher = { const teacher: ConsultationTeacher = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
name: cleanText(input.name, "老师姓名", 30), role: normalizeStaffRole(input.role),
name: cleanText(input.name, "姓名", 30),
username, username,
title: cleanText(input.title, "展示称谓", 40), title: cleanText(input.title, "展示称谓", 40, false),
wechat: cleanText(input.wechat, "微信号", 60, false), wechat: cleanText(input.wechat, "微信号", 60, false),
qrCodeUrl: normalizeQrCodeUrl(input.qrCodeUrl), qrCodeUrl: "",
sort: normalizeSort(input.sort), sort: normalizeSort(input.sort || 1),
enabled: input.enabled !== false, enabled: input.enabled !== false,
assignedCount: 0, assignedCount: 0,
lastAssignedAt: null, lastAssignedAt: null,
...@@ -176,6 +229,10 @@ export async function createConsultationTeacher(input: Record<string, unknown>): ...@@ -176,6 +229,10 @@ export async function createConsultationTeacher(input: Record<string, unknown>):
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}; };
if (teacher.role === "customer_service") {
teacher.title = teacher.title || "教务老师|一对一学情沟通";
teacher.qrCodeUrl = normalizeQrCodeUrl(input.qrCodeUrl);
}
state.teachers.push(teacher); state.teachers.push(teacher);
return listItem(teacher); return listItem(teacher);
}); });
...@@ -184,15 +241,17 @@ export async function createConsultationTeacher(input: Record<string, unknown>): ...@@ -184,15 +241,17 @@ export async function createConsultationTeacher(input: Record<string, unknown>):
export async function updateConsultationTeacher(id: string, input: Record<string, unknown>): Promise<TeacherListItem> { export async function updateConsultationTeacher(id: string, input: Record<string, unknown>): Promise<TeacherListItem> {
return mutate(async (state) => { return mutate(async (state) => {
const teacher = state.teachers.find((item) => item.id === id); const teacher = state.teachers.find((item) => item.id === id);
if (!teacher) throw new StoreError("咨询老师不存在", 404); if (!teacher) throw new StoreError("客服不存在", 404);
const username = normalizeUsername(input.username); const username = normalizeUsername(input.username);
if (state.teachers.some((item) => item.id !== id && item.username === username)) throw new StoreError("该登录账号已被使用", 409); if (state.teachers.some((item) => item.id !== id && item.username === username)) throw new StoreError("该登录账号已被使用", 409);
teacher.name = cleanText(input.name, "老师姓名", 30); teacher.role = normalizeStaffRole(input.role ?? teacher.role);
teacher.name = cleanText(input.name, "姓名", 30);
teacher.username = username; teacher.username = username;
teacher.title = cleanText(input.title, "展示称谓", 40); teacher.title = cleanText(input.title, "展示称谓", 40, false);
teacher.wechat = cleanText(input.wechat, "微信号", 60, false); teacher.wechat = cleanText(input.wechat, "微信号", 60, false);
teacher.qrCodeUrl = normalizeQrCodeUrl(input.qrCodeUrl); teacher.qrCodeUrl = teacher.role === "customer_service" ? normalizeQrCodeUrl(input.qrCodeUrl) : "";
teacher.sort = normalizeSort(input.sort); teacher.sort = normalizeSort(input.sort || teacher.sort || 1);
if (teacher.role === "customer_service") teacher.title ||= "教务老师|一对一学情沟通";
teacher.enabled = input.enabled !== false; teacher.enabled = input.enabled !== false;
if (input.password) { if (input.password) {
Object.assign(teacher, await passwordFields(input.password)); Object.assign(teacher, await passwordFields(input.password));
...@@ -206,7 +265,7 @@ export async function updateConsultationTeacher(id: string, input: Record<string ...@@ -206,7 +265,7 @@ export async function updateConsultationTeacher(id: string, input: Record<string
export async function deleteConsultationTeacher(id: string): Promise<void> { export async function deleteConsultationTeacher(id: string): Promise<void> {
await mutate((state) => { await mutate((state) => {
const index = state.teachers.findIndex((teacher) => teacher.id === id); const index = state.teachers.findIndex((teacher) => teacher.id === id);
if (index < 0) throw new StoreError("咨询老师不存在", 404); if (index < 0) throw new StoreError("客服不存在", 404);
state.teachers.splice(index, 1); state.teachers.splice(index, 1);
if (state.lastAssignedTeacherId === id) state.lastAssignedTeacherId = null; if (state.lastAssignedTeacherId === id) state.lastAssignedTeacherId = null;
}); });
...@@ -232,16 +291,18 @@ export async function getTeacherSessionVersion(id: string): Promise<number | nul ...@@ -232,16 +291,18 @@ export async function getTeacherSessionVersion(id: string): Promise<number | nul
export async function assignConsultation(input: Record<string, unknown>): Promise<{ consultation: ConsultationRecord; teacher: PublicTeacher }> { export async function assignConsultation(input: Record<string, unknown>): Promise<{ consultation: ConsultationRecord; teacher: PublicTeacher }> {
return mutate((state) => { return mutate((state) => {
const phone = String(input.phone || "").replace(/\s+/g, ""); const phone = normalizePhone(input.phone);
if (!/^1[3-9]\d{9}$/.test(phone)) throw new StoreError("请输入正确的 11 位手机号"); const teachers = state.teachers.filter((teacher) => teacher.enabled && teacher.role === "customer_service").sort((a, b) => a.sort - b.sort || a.createdAt.localeCompare(b.createdAt));
const teachers = state.teachers.filter((teacher) => teacher.enabled).sort((a, b) => a.sort - b.sort || a.createdAt.localeCompare(b.createdAt)); if (!teachers.length) throw new StoreError("客服暂未配置,请稍后再试或拨打客服电话", 503);
if (!teachers.length) throw new StoreError("咨询老师暂未配置,请稍后再试或拨打客服电话", 503);
const previousIndex = teachers.findIndex((teacher) => teacher.id === state.lastAssignedTeacherId); const previousIndex = teachers.findIndex((teacher) => teacher.id === state.lastAssignedTeacherId);
const teacher = teachers[(previousIndex + 1) % teachers.length]; const teacher = teachers[(previousIndex + 1) % teachers.length];
const now = new Date().toISOString(); const now = new Date().toISOString();
const consultation: ConsultationRecord = { const consultation: ConsultationRecord = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
sequence: state.nextConsultationNumber++,
name: "",
phone, phone,
location: "",
teacherId: teacher.id, teacherId: teacher.id,
teacherName: teacher.name, teacherName: teacher.name,
teacherTitle: teacher.title, teacherTitle: teacher.title,
...@@ -249,7 +310,9 @@ export async function assignConsultation(input: Record<string, unknown>): Promis ...@@ -249,7 +310,9 @@ export async function assignConsultation(input: Record<string, unknown>): Promis
teacherQrCodeUrl: teacher.qrCodeUrl, teacherQrCodeUrl: teacher.qrCodeUrl,
source: cleanText(input.source, "来源", 80, false) || "官网学情分析", source: cleanText(input.source, "来源", 80, false) || "官网学情分析",
page: cleanText(input.page, "页面", 300, false), page: cleanText(input.page, "页面", 300, false),
status: "new", status: "unprocessed",
unaddedReason: "",
callCount: 0,
note: "", note: "",
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
...@@ -263,6 +326,36 @@ export async function assignConsultation(input: Record<string, unknown>): Promis ...@@ -263,6 +326,36 @@ export async function assignConsultation(input: Record<string, unknown>): Promis
}); });
} }
export async function createConsultation(input: Record<string, unknown>): Promise<ConsultationRecord> {
return mutate((state) => {
const teacher = state.teachers.find((item) => item.id === String(input.teacherId || "") && item.role === "customer_service");
if (!teacher) throw new StoreError("请选择分配客服");
const now = new Date().toISOString();
const consultation: ConsultationRecord = {
id: crypto.randomUUID(),
sequence: state.nextConsultationNumber++,
name: cleanText(input.name, "姓名", 30, false),
phone: normalizePhone(input.phone),
location: cleanText(input.location, "归属地", 80, false),
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),
callCount: normalizeCallCount(input.callCount),
note: cleanText(input.note, "跟进备注", 500, false),
createdAt: now,
updatedAt: now,
};
state.consultations.unshift(consultation);
return consultation;
});
}
export async function listConsultations(teacherId?: string): Promise<ConsultationRecord[]> { export async function listConsultations(teacherId?: string): Promise<ConsultationRecord[]> {
const records = (await readState()).consultations; const records = (await readState()).consultations;
return records.filter((item) => !teacherId || item.teacherId === teacherId).sort((a, b) => b.createdAt.localeCompare(a.createdAt)); return records.filter((item) => !teacherId || item.teacherId === teacherId).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
...@@ -272,11 +365,31 @@ export async function updateConsultation(id: string, input: Record<string, unkno ...@@ -272,11 +365,31 @@ export async function updateConsultation(id: string, input: Record<string, unkno
return mutate((state) => { return mutate((state) => {
const consultation = state.consultations.find((item) => item.id === id && (!teacherId || item.teacherId === teacherId)); const consultation = state.consultations.find((item) => item.id === id && (!teacherId || item.teacherId === teacherId));
if (!consultation) throw new StoreError("咨询记录不存在", 404); if (!consultation) throw new StoreError("咨询记录不存在", 404);
const status = String(input.status || consultation.status) as ConsultationStatus; consultation.status = normalizeConsultationStatus(input.status || consultation.status);
if (!["new", "contacted", "completed"].includes(status)) throw new StoreError("咨询状态无效"); consultation.phone = normalizePhone(input.phone ?? consultation.phone);
consultation.status = status;
consultation.note = cleanText(input.note, "跟进备注", 500, false); consultation.note = cleanText(input.note, "跟进备注", 500, false);
consultation.name = cleanText(input.name ?? consultation.name, "姓名", 30, false);
consultation.location = cleanText(input.location ?? consultation.location, "归属地", 80, false);
consultation.unaddedReason = cleanText(input.unaddedReason ?? consultation.unaddedReason, "未添加原因", 200, false);
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(); consultation.updatedAt = new Date().toISOString();
return consultation; return consultation;
}); });
} }
export async function deleteConsultation(id: string): Promise<void> {
await mutate((state) => {
const index = state.consultations.findIndex((item) => item.id === id);
if (index < 0) throw new StoreError("用户线索不存在", 404);
state.consultations.splice(index, 1);
});
}
...@@ -12,7 +12,7 @@ ...@@ -12,7 +12,7 @@
<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="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>
...@@ -29,48 +29,57 @@ ...@@ -29,48 +29,57 @@
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-brand"><span>启优学</span><strong>管理后台</strong></div> <div class="sidebar-brand"><span>启优学</span><strong>管理后台</strong></div>
<nav aria-label="后台功能"> <nav aria-label="后台功能">
<p>咨询服务</p> <p class="consultation-role">咨询服务</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 active consultation-role" 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> <button class="sidebar-link admin-only" data-admin-nav="teachers" type="button"><span>账号管理</span></button>
<p class="admin-only">官网内容</p> <p class="content-role">官网内容</p>
<button class="sidebar-link admin-only" 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><b id="sidebar-pending" class="hidden">0</b></button>
<p class="admin-only">系统设置</p>
<button class="sidebar-link admin-only" data-admin-nav="settings" type="button"><span>账号设置</span></button>
</nav> </nav>
<div class="sidebar-account"><small id="account-role">当前账号</small><strong id="account-name"></strong></div> <div class="sidebar-account"><small id="account-role">当前账号</small><strong id="account-name"></strong></div>
</aside> </aside>
<div class="admin-main"> <div class="admin-main">
<header class="topbar"> <header class="topbar">
<div><strong id="page-title">咨询记录</strong><span id="page-subtitle">查看和跟进官网咨询</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="/" target="_blank" rel="noopener">查看官网</a> <a class="button ghost" href="/" target="_blank" rel="noopener">查看官网</a>
<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="consultations-view" class="view consultation-view"> <main id="consultations-view" class="view consultation-view consultation-role">
<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="view-head"><div><h1>用户线索列表</h1><span id="consultation-scope">管理员可查看全部客服的用户线索</span></div><div class="view-actions"><button id="refresh-consultations" class="ghost" type="button">刷新记录</button><button id="new-consultation" class="primary admin-only" type="button">录入用户</button></div></div>
<div class="metric-grid"> <div class="metric-grid">
<article><small>待联系</small><strong id="metric-new">0</strong></article> <article><small>未处理</small><strong id="metric-unprocessed">0</strong></article>
<article><small>跟进中</small><strong id="metric-contacted">0</strong></article> <article><small>已添加</small><strong id="metric-added">0</strong></article>
<article><small>已完成</small><strong id="metric-completed">0</strong></article> <article><small>未添加</small><strong id="metric-not-added">0</strong></article>
<article><small>全部咨询</small><strong id="metric-total">0</strong></article> <article><small>全部用户</small><strong id="metric-total">0</strong></article>
</div> </div>
<div class="table-card"><div id="consultation-list" class="data-list"></div></div> <div class="table-card"><div id="consultation-list" class="data-list"></div></div>
</main> </main>
<main id="teachers-view" class="view hidden admin-only"> <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="view-head"><div><p>STAFF ACCOUNTS</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> <div class="table-card"><div id="teacher-list" class="data-list"></div></div>
</main> </main>
<main id="list-view" class="view hidden"> <main id="list-view" class="view hidden content-role">
<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>
<div id="article-list" class="article-list"></div> <div id="article-list" class="article-list"></div>
</main> </main>
<main id="settings-view" class="view hidden admin-only">
<div class="view-head"><div><h1>账号设置</h1><span>管理后台管理员账号的登录安全</span></div></div>
<section class="settings-panel">
<div><strong>登录密码</strong><p>修改管理员账号 admin 的登录密码。环境变量中的主密码仍可用于登录和恢复。</p></div>
<button id="change-password" class="ghost" type="button">修改密码</button>
</section>
</main>
<main id="edit-view" class="view hidden"> <main id="edit-view" class="view hidden">
<div class="view-head editor-titlebar"> <div class="view-head editor-titlebar">
<div><button id="back" class="back-button" type="button">← 返回文章列表</button><h1 id="edit-heading">编辑文章</h1></div> <div><button id="back" class="back-button" type="button">← 返回文章列表</button><h1 id="edit-heading">编辑文章</h1></div>
...@@ -124,25 +133,59 @@ ...@@ -124,25 +133,59 @@
<div id="teacher-modal" class="modal hidden"> <div id="teacher-modal" class="modal hidden">
<div class="modal-card teacher-card"> <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> <div class="modal-head"><div><strong id="teacher-modal-title">添加后台账号</strong><small id="teacher-modal-description">按职责分配客服或运营权限</small></div><button id="close-teacher-modal" class="ghost small" type="button">关闭</button></div>
<form id="teacher-form" class="teacher-form"> <form id="teacher-form" class="teacher-form">
<input id="teacher-id" type="hidden" /> <input id="teacher-id" type="hidden" />
<div class="form-grid"> <div class="form-grid">
<label>老师姓名<input id="teacher-name" maxlength="30" required /></label> <label>账号角色<select id="teacher-role"><option value="customer_service">客服</option><option value="operator">运营</option></select></label>
<label>展示称谓<input id="teacher-title" maxlength="40" placeholder="例如:教务主任|全科一对一" required /></label> <label>姓名<input id="teacher-name" maxlength="30" required /></label>
<label class="customer-service-field">展示称谓<input id="teacher-title" maxlength="40" placeholder="例如:教务主任|全科一对一" /></label>
<label>登录账号<input id="teacher-username" maxlength="32" autocomplete="off" 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 class="customer-service-field">微信号<input id="teacher-wechat" maxlength="60" placeholder="选填" /></label>
<label>轮询顺序<input id="teacher-sort" type="number" min="1" max="9999" value="1" required /></label> <label class="customer-service-field">轮询顺序<input id="teacher-sort" type="number" min="1" max="9999" value="1" /></label>
<label>登录密码<input id="teacher-password" type="password" minlength="6" maxlength="128" autocomplete="new-password" /><small id="teacher-password-hint">至少 6 个字符</small></label> <label>登录密码<input id="teacher-password" type="password" minlength="6" maxlength="128" autocomplete="new-password" /><small id="teacher-password-hint">至少 6 个字符</small></label>
</div> </div>
<label class="qr-field">微信二维码 <label class="qr-field customer-service-field">微信二维码
<input id="teacher-qr-file" class="hidden" type="file" accept="image/png,image/jpeg,image/webp,image/gif" /> <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" /> <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> <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>
<label class="switch-row"><input id="teacher-enabled" type="checkbox" checked /><span>启用并参与轮询分配</span></label> <label class="switch-row"><input id="teacher-enabled" type="checkbox" checked /><span id="teacher-enabled-label">启用并参与轮询分配</span></label>
<p id="teacher-error" class="error"></p> <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> <div class="password-actions"><button id="cancel-teacher" class="ghost" type="button">取消</button><button class="primary" type="submit">保存账号</button></div>
</form>
</div>
</div>
<div id="consultation-note-modal" class="modal hidden">
<div class="modal-card consultation-note-card">
<div class="modal-head"><div><strong>全部跟进备注</strong><small id="note-card-meta"></small></div><button id="close-note-modal" class="ghost small" type="button">关闭</button></div>
<form id="consultation-note-form" class="consultation-note-form">
<div class="note-card-summary"><div><small>家长手机号</small><strong id="note-card-phone"></strong></div><div><small>分配客服</small><strong id="note-card-teacher"></strong></div></div>
<label>咨询状态<select id="note-card-status"><option value="unprocessed">未处理</option><option value="added">已添加</option><option value="not_added">未添加</option></select></label>
<label>完整备注<textarea id="note-card-content" maxlength="500" rows="10" placeholder="记录沟通结果、家长需求或下次跟进时间"></textarea></label>
<div class="note-card-footer"><span><strong id="note-card-count">0</strong> / 500</span><p id="note-card-message"></p><button id="cancel-note-modal" class="ghost" type="button">取消</button><button class="primary" type="submit">保存备注</button></div>
</form>
</div>
</div>
<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>完善用户线索并分配客服</small></div><button id="close-consultation-user-modal" class="ghost small" type="button">关闭</button></div>
<form id="consultation-user-form" class="teacher-form">
<input id="consultation-user-id" type="hidden" />
<div class="form-grid">
<label>姓名<input id="consultation-user-name" maxlength="30" /></label>
<label>手机号<input id="consultation-user-phone" inputmode="numeric" maxlength="11" required /></label>
<label>归属地<input id="consultation-user-location" maxlength="80" /></label>
<label>分配客服<select id="consultation-user-teacher" required></select></label>
<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>打电话次数<input id="consultation-user-calls" type="number" min="0" max="9999" value="0" required /></label>
</div>
<label>跟进备注<textarea id="consultation-user-note" maxlength="500" rows="5"></textarea></label>
<p id="consultation-user-error" class="error"></p>
<div class="password-actions"><button id="cancel-consultation-user" class="ghost" type="button">取消</button><button class="primary" type="submit">保存用户</button></div>
</form> </form>
</div> </div>
</div> </div>
......
---
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