Commit 6a8442a9 authored by tao355667's avatar tao355667

feat: improve consultation management workflow

parent a1cfaaed
......@@ -138,6 +138,12 @@ const formatTime = (value) => {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
};
const notePreview = (value, maxLength = 24) => {
const text = String(value || "").replace(/\s+/g, " ").trim();
if (!text) return "-";
return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text;
};
function filterableTableHead(rowClass, columns, tableRows) {
const head = document.createElement("div");
head.className = `data-row data-head ${rowClass}`;
......@@ -169,13 +175,51 @@ function filterableTableHead(rowClass, columns, tableRows) {
const operation = document.createElement("span");
operation.textContent = "操作";
head.append(operation);
const applyFilters = () => {
tableRows.forEach((item) => {
item.element.classList.toggle("hidden", filters.some((filter, index) => filter.value && item.values[index] !== filter.value));
return { head, filters };
}
function setupTablePagination(list, tableRows, filters) {
list.parentElement.querySelector(".table-pagination")?.remove();
const footer = document.createElement("div");
footer.className = "table-pagination";
let page = 1;
let pageSize = 20;
const render = (resetPage = false) => {
if (resetPage) page = 1;
const filtered = tableRows.filter((item) => filters.every((filter, index) => !filter.value || item.values[index] === filter.value));
const pageCount = Math.max(1, Math.ceil(filtered.length / pageSize));
page = Math.min(page, pageCount);
const start = (page - 1) * pageSize;
tableRows.forEach((item) => item.element.classList.toggle("hidden", !filtered.slice(start, start + pageSize).includes(item)));
footer.innerHTML = "";
const summary = document.createElement("span");
summary.textContent = `共 ${filtered.length} 条`;
const controls = document.createElement("div");
controls.className = "table-pagination__controls";
const sizeLabel = document.createElement("label");
sizeLabel.textContent = "每页";
const sizeSelect = document.createElement("select");
[20, 50, 100].forEach((size) => {
const option = document.createElement("option");
option.value = String(size);
option.textContent = String(size);
option.selected = size === pageSize;
sizeSelect.append(option);
});
sizeSelect.addEventListener("change", () => { pageSize = Number(sizeSelect.value); render(true); });
sizeLabel.append(sizeSelect, document.createTextNode("条"));
const previous = listAction("上一页", "ghost small", () => { if (page > 1) { page -= 1; render(); } });
const pageLabel = document.createElement("span");
pageLabel.textContent = `${page} / ${pageCount}`;
const next = listAction("下一页", "ghost small", () => { if (page < pageCount) { page += 1; render(); } });
previous.disabled = page <= 1;
next.disabled = page >= pageCount;
controls.append(sizeLabel, previous, pageLabel, next);
footer.append(summary, controls);
};
filters.forEach((filter) => filter.addEventListener("change", applyFilters));
return head;
filters.forEach((filter) => filter.addEventListener("change", () => render(true)));
list.parentElement.append(footer);
render();
}
function updateConsultationMetrics(records) {
......@@ -197,6 +241,7 @@ async function loadConsultations() {
updateConsultationMetrics(records);
const list = $("#consultation-list");
list.innerHTML = "";
list.parentElement.querySelector(".table-pagination")?.remove();
if (!records.length) {
list.innerHTML = '<div class="empty">暂时没有用户线索。官网用户提交手机号后会显示在这里。</div>';
return;
......@@ -205,18 +250,16 @@ async function loadConsultations() {
records.forEach((record) => {
const row = document.createElement("article");
row.className = "data-row consultation-row";
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.innerHTML = '<strong class="sequence"></strong><span class="user-name"></span><span class="phone-text"></span><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>';
const values = [
String(record.sequence || "-"), record.name || "未填写", record.phone,
record.location || "-", record.teacherName || "未分配",
consultationStatusLabels[record.status] || "未处理", record.unaddedReason || "-",
record.note || "-", String(record.callCount ?? 0), formatTime(record.createdAt),
notePreview(record.note), String(record.callCount ?? 0), formatTime(record.createdAt),
];
row.querySelector(".sequence").textContent = values[0];
row.querySelector(".user-name").textContent = values[1];
const phone = row.querySelector(".phone-link");
phone.textContent = record.phone;
phone.href = `tel:${record.phone}`;
row.querySelector(".phone-text").textContent = record.phone;
row.querySelector(".location").textContent = values[3];
row.querySelector(".teacher-name").textContent = values[4];
const status = row.querySelector(".consultation-status");
......@@ -224,6 +267,7 @@ async function loadConsultations() {
status.classList.add(`consultation-status--${record.status}`);
row.querySelector(".unadded-reason").textContent = values[6];
row.querySelector(".note-summary").textContent = values[7];
row.querySelector(".note-summary").title = record.note || "";
row.querySelector(".call-count").textContent = values[8];
row.querySelector(".created").textContent = values[9];
const actions = row.querySelector(".row-actions");
......@@ -234,8 +278,10 @@ async function loadConsultations() {
}));
tableRows.push({ element: row, values });
});
list.append(filterableTableHead("consultation-row", ["ID", "姓名", "手机号", "归属地", "分配客服", "状态", "未添加原因", "备注", "打电话次数", "提交时间"], tableRows));
const tableHead = filterableTableHead("consultation-row", ["ID", "姓名", "手机号", "归属地", "分配客服", "状态", "未添加原因", "备注", "打电话次数", "提交时间"], tableRows);
list.append(tableHead.head);
tableRows.forEach((item) => list.append(item.element));
setupTablePagination(list, tableRows, tableHead.filters);
}
$("#refresh-consultations").addEventListener("click", loadConsultations);
......@@ -260,12 +306,12 @@ function openConsultationUserModal(record = null) {
$("#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;
syncCallCountButtons();
$("#consultation-user-note").value = record?.note || "";
$("#consultation-user-note-count").textContent = $("#consultation-user-note").value.length;
$("#consultation-user-error").textContent = "";
......@@ -278,14 +324,26 @@ $("#new-consultation").addEventListener("click", async () => {
openConsultationUserModal();
});
$("#close-consultation-user-modal").addEventListener("click", closeConsultationUserModal);
$("#cancel-consultation-user").addEventListener("click", closeConsultationUserModal);
$("#consultation-user-note").addEventListener("input", () => { $("#consultation-user-note-count").textContent = $("#consultation-user-note").value.length; });
function syncCallCountButtons() {
const value = Math.min(9999, Math.max(0, Number($("#consultation-user-calls").value) || 0));
$("#decrease-consultation-calls").disabled = value <= 0;
$("#increase-consultation-calls").disabled = value >= 9999;
}
function changeCallCount(delta) {
const input = $("#consultation-user-calls");
input.value = Math.min(9999, Math.max(0, (Number(input.value) || 0) + delta));
syncCallCountButtons();
}
$("#decrease-consultation-calls").addEventListener("click", () => changeCallCount(-1));
$("#increase-consultation-calls").addEventListener("click", () => changeCallCount(1));
$("#consultation-user-calls").addEventListener("input", syncCallCountButtons);
$("#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,
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,
};
......@@ -306,6 +364,7 @@ async function loadTeachers() {
consultationTeachers = staffAccounts.filter((teacher) => teacher.role === "customer_service");
const list = $("#teacher-list");
list.innerHTML = "";
list.parentElement.querySelector(".table-pagination")?.remove();
if (!staffAccounts.length) {
list.innerHTML = '<div class="empty">还没有后台账号。添加客服或运营账号后会显示在这里。</div>';
return;
......@@ -314,10 +373,9 @@ async function loadTeachers() {
staffAccounts.forEach((teacher) => {
const row = document.createElement("article");
row.className = "data-row teacher-row";
row.innerHTML = '<strong class="staff-name"></strong><span class="username"></span><span class="staff-role"></span><span class="staff-title"></span><span class="staff-wechat"></span><span class="sort"></span><span class="assigned"></span><span class="teacher-state"></span><div class="row-actions"></div>';
row.innerHTML = '<strong class="staff-name"></strong><span class="username"></span><span class="staff-role"></span><span class="staff-wechat"></span><span class="sort"></span><span class="assigned"></span><span class="teacher-state"></span><div class="row-actions"></div>';
const values = [
teacher.name, teacher.username, teacher.role === "operator" ? "运营" : "客服",
teacher.role === "operator" ? "-" : teacher.title || "-",
teacher.role === "operator" ? "-" : teacher.wechat || "-",
teacher.role === "operator" ? "-" : String(teacher.sort),
teacher.role === "operator" ? "-" : `${teacher.assignedCount} 次`,
......@@ -326,12 +384,11 @@ async function loadTeachers() {
row.querySelector(".staff-name").textContent = values[0];
row.querySelector(".username").textContent = values[1];
row.querySelector(".staff-role").textContent = values[2];
row.querySelector(".staff-title").textContent = values[3];
row.querySelector(".staff-wechat").textContent = values[4];
row.querySelector(".sort").textContent = values[5];
row.querySelector(".assigned").textContent = values[6];
row.querySelector(".staff-wechat").textContent = values[3];
row.querySelector(".sort").textContent = values[4];
row.querySelector(".assigned").textContent = values[5];
const state = row.querySelector(".teacher-state");
state.textContent = values[7];
state.textContent = values[6];
state.className = `teacher-state ${teacher.enabled ? "enabled" : "disabled"}`;
row.querySelector(".row-actions").append(
listAction("编辑", "text-action", () => openTeacherModal(teacher)),
......@@ -343,8 +400,10 @@ async function loadTeachers() {
);
tableRows.push({ element: row, values });
});
list.append(filterableTableHead("teacher-row", ["姓名", "登录账号", "角色", "展示称谓", "微信号", "轮询顺序", "累计分配", "状态"], tableRows));
const tableHead = filterableTableHead("teacher-row", ["姓名", "登录账号", "角色", "微信号", "轮询顺序", "累计分配", "状态"], tableRows);
list.append(tableHead.head);
tableRows.forEach((item) => list.append(item.element));
setupTablePagination(list, tableRows, tableHead.filters);
}
function closeTeacherModal() { hide("#teacher-modal"); $("#teacher-form").reset(); $("#teacher-error").textContent = ""; }
......@@ -356,7 +415,6 @@ function openTeacherModal(teacher = null) {
$("#teacher-modal-title").textContent = teacher ? "编辑后台账号" : "添加后台账号";
$("#teacher-role").value = teacher?.role || "customer_service";
$("#teacher-name").value = teacher?.name || "";
$("#teacher-title").value = teacher?.title || "教务老师|一对一学情沟通";
$("#teacher-username").value = teacher?.username || "";
$("#teacher-wechat").value = teacher?.wechat || "";
$("#teacher-sort").value = teacher?.sort || Math.max(1, ...consultationTeachers.map((item) => item.sort + 1));
......@@ -366,7 +424,7 @@ function openTeacherModal(teacher = null) {
$("#teacher-qr-url").value = teacher?.qrCodeUrl || "";
$("#teacher-qr-preview").src = teacher?.qrCodeUrl || "";
$("#teacher-qr-preview").classList.toggle("hidden", !teacher?.qrCodeUrl);
$("#teacher-qr-label").textContent = teacher ? "点击更换二维码" : "点击上传二维码";
$("#teacher-qr-label").textContent = teacher ? "点击更换,或直接粘贴" : "点击选择图片,或直接粘贴";
syncStaffRoleFields();
show("#teacher-modal");
$("#teacher-name").focus();
......@@ -376,7 +434,6 @@ $("#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 ? "客服负责查看并处理分配给自己的用户咨询" : "运营负责学习资讯的创建、编辑与发布";
......@@ -385,11 +442,10 @@ $("#teacher-role").addEventListener("change", syncStaffRoleFields);
$("#close-teacher-modal").addEventListener("click", closeTeacherModal);
$("#cancel-teacher").addEventListener("click", closeTeacherModal);
$("#teacher-qr-button").addEventListener("click", () => $("#teacher-qr-file").click());
$("#teacher-qr-file").addEventListener("change", async (event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
async function uploadTeacherQr(file) {
if (!file?.type?.startsWith("image/")) return $("#teacher-error").textContent = "请选择或粘贴图片文件";
if (file.size > MAX_UPLOAD_SIZE) return $("#teacher-error").textContent = "二维码图片不能超过 20MB";
$("#teacher-error").textContent = "";
$("#teacher-qr-label").textContent = "上传中…";
try {
const dataUrl = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = reject; reader.readAsDataURL(file); });
......@@ -397,14 +453,27 @@ $("#teacher-qr-file").addEventListener("change", async (event) => {
$("#teacher-qr-url").value = result.url;
$("#teacher-qr-preview").src = result.url;
show("#teacher-qr-preview");
$("#teacher-qr-label").textContent = "点击更换二维码";
} catch (error) { $("#teacher-error").textContent = error.message; $("#teacher-qr-label").textContent = "点击上传二维码"; }
$("#teacher-qr-label").textContent = "点击更换,或直接粘贴";
} catch (error) { $("#teacher-error").textContent = error.message; $("#teacher-qr-label").textContent = "点击选择图片,或直接粘贴"; }
}
$("#teacher-qr-file").addEventListener("change", async (event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) await uploadTeacherQr(file);
});
document.addEventListener("paste", async (event) => {
if ($("#teacher-modal").classList.contains("hidden") || $("#teacher-role").value !== "customer_service") return;
const imageItem = [...(event.clipboardData?.items || [])].find((item) => item.kind === "file" && item.type.startsWith("image/"));
const file = imageItem?.getAsFile();
if (!file) return;
event.preventDefault();
await uploadTeacherQr(file);
});
$("#teacher-form").addEventListener("submit", async (event) => {
event.preventDefault();
const id = $("#teacher-id").value;
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 body = { role: $("#teacher-role").value, name: $("#teacher-name").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;
submit.disabled = true;
$("#teacher-error").textContent = "";
......
......@@ -41,6 +41,7 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(20,115,230,.
.topbar .ghost { color: white; border-color: rgba(255,255,255,.24); }
.pending-badge { padding: 5px 10px; color: #cfe2ff; background: rgba(20,115,230,.12); border: 1px solid rgba(20,115,230,.3); border-radius: 999px; }
.view { width: min(1120px, calc(100% - 40px)); margin: 0 auto; padding: 44px 0 70px; }
#consultations-view,#teachers-view { width: calc(100% - 32px); max-width: none; }
.view-head { margin-bottom: 26px; display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.view-toolbar { min-height: 40px; margin-bottom: 18px; display: flex; justify-content: flex-end; }
.view-head p { margin: 0 0 5px; color: var(--primary-dark); font-size: 11px; font-weight: 700; letter-spacing: .16em; }
......@@ -123,6 +124,7 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(20,115,230,.
.category-manager { width: min(680px, 100%); }
.modal-card > .modal-head { padding: 18px 20px; }
.modal-head > div { display: grid; gap: 4px; }
.modal-head > .modal-head-actions { display: flex; align-items: center; gap: 8px; flex: 0 0 auto; }
.modal-head small { color: var(--muted); font-size: 12px; font-weight: 400; }
.password-card { width: min(500px, 100%); }
.modal-card > .password-form { padding: 20px; display: grid; gap: 14px; border: 0; }
......@@ -193,12 +195,12 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(20,115,230,.
.column-filter { min-width: 0; display: grid; gap: 4px; }
.column-filter > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.column-filter select { min-height: 24px; padding: 2px 19px 2px 5px; color: #657587; background-color: #fff; border-color: #d9e1e9; border-radius: 5px; font-size: 10px; }
#consultation-list { min-width: 1100px; }
.consultation-row { grid-template-columns: 44px 76px 108px 76px 82px 66px 112px minmax(150px,1fr) 68px 102px 86px; }
#consultation-list { min-width: 1190px; }
.consultation-row { grid-template-columns: 44px 76px 108px 165px 82px 66px 112px minmax(150px,1fr) 68px 102px 86px; }
#teacher-list { min-width: 1050px; }
.teacher-row { grid-template-columns: .75fr .9fr .55fr 1.35fr 1fr .65fr .75fr .65fr 86px; }
.teacher-row { grid-template-columns: .8fr 1fr .6fr 1fr .7fr .8fr .7fr 86px; }
.data-row small { display: block; margin-top: 4px; color: var(--muted); font-size: 11px; }
.phone-link { color: var(--text); font-size: 12px; font-weight: 600; text-decoration: none; }
.phone-text { color: var(--text); font-size: 12px; font-weight: 600; }
.page { max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.created,.teacher-name { font-size: 12px; }
.created { color: var(--muted); }
......@@ -208,12 +210,18 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(20,115,230,.
.consultation-status--unprocessed { color: #1769aa; }
.consultation-status--added { color: #0b805b; }
.consultation-status--not_added { color: #bd4141; }
.consultation-row > :last-child,.teacher-row > :last-child { position: sticky; right: 0; z-index: 2; min-height: 100%; align-content: center; justify-content: flex-end; background: #fff; box-shadow: -10px 0 14px -14px rgba(19,48,79,.75); }
.consultation-row.data-head > :last-child,.teacher-row.data-head > :last-child { z-index: 3; background: #f7f9fb; }
.consultation-row > :last-child,.teacher-row > :last-child { position: sticky; right: 0; z-index: 2; min-height: 100%; align-content: center; justify-content: flex-end; background: #fff; border-left: 1px solid var(--border); }
.consultation-row.data-head > :last-child,.teacher-row.data-head > :last-child { z-index: 3; display: grid; place-items: center start; background: #f7f9fb; }
.consultation-row .row-actions,.teacher-row .row-actions { gap: 12px; flex-wrap: nowrap; }
.consultation-row .row-actions .text-action,.teacher-row .row-actions .text-action { min-height: auto; padding: 2px 0; color: var(--primary-dark); background: transparent; border: 0; border-radius: 0; font-size: 12px; line-height: 1.4; }
.consultation-row .row-actions .text-action:hover,.teacher-row .row-actions .text-action:hover { color: var(--deep); text-decoration: underline; transform: none; }
.consultation-row .row-actions .text-action.danger,.teacher-row .row-actions .text-action.danger { color: var(--danger); }
.table-pagination { min-width: 100%; padding: 11px 14px; display: flex; align-items: center; justify-content: space-between; gap: 18px; color: var(--muted); background: #fafbfd; border-top: 1px solid var(--border); font-size: 12px; }
.table-pagination__controls { display: flex; align-items: center; gap: 9px; }
.table-pagination__controls label { display: flex; align-items: center; gap: 5px; white-space: nowrap; }
.table-pagination__controls select { width: 58px; min-height: 32px; padding: 4px 7px; font-size: 12px; }
.table-pagination__controls > span { min-width: 44px; text-align: center; white-space: nowrap; }
.table-pagination__controls button:disabled { cursor: default; opacity: .42; }
.inline-follow-form { display: contents; }
.inline-follow-form select { padding: 8px 10px; font-size: 13px; }
.inline-note { min-width: 0; display: grid; gap: 4px; }
......@@ -223,7 +231,7 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(20,115,230,.
.inline-follow-actions { display: grid; justify-items:start; gap: 2px; }
.inline-follow-actions .save-state { min-height: 14px; margin: 0; white-space: nowrap; }
.staff-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
.staff-title,.staff-wechat,.username { overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
.staff-wechat,.username { overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
.teacher-state { width: max-content; padding: 4px 9px; border-radius: 999px; font-size: 12px; }
.teacher-state.enabled { color: #087a56; background: #dff8ee; }
.teacher-state.disabled { color: #7b8190; background: #edf0f4; }
......@@ -240,10 +248,19 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(20,115,230,.
.consultation-edit-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; }
.consultation-edit-heading strong { color: #203a57; font-size: 14px; }
.consultation-edit-heading span { color: var(--muted); font-size: 11px; }
.auto-field-note { grid-column: 1 / -1; margin: 0; padding: 10px 12px; color: #55718d; background: #eef6ff; border: 1px solid #d7e8f9; border-radius: 8px; font-size: 12px; }
.consultation-note-field { display: grid; gap: 7px; color: var(--muted); font-size: 13px; }
.consultation-note-field textarea { min-height: 170px; resize: vertical; line-height: 1.7; }
.consultation-note-field small { justify-self: end; color: var(--muted); font-size: 11px; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; }
.number-stepper { display: grid; grid-template-columns: 38px 1fr 38px; overflow: hidden; border: 1px solid var(--border); border-radius: 9px; background: #fff; }
.number-stepper:focus-within { outline: 2px solid rgba(20,115,230,.14); border-color: var(--primary); }
.number-stepper input { min-width: 0; padding-inline: 6px; border: 0; border-inline: 1px solid var(--border); border-radius: 0; text-align: center; appearance: textfield; }
.number-stepper input:focus { outline: 0; }
.number-stepper input::-webkit-inner-spin-button,.number-stepper input::-webkit-outer-spin-button { margin: 0; appearance: none; }
.number-stepper button { min-height: 42px; padding: 0; color: var(--primary-dark); background: #f7faff; border-radius: 0; font-size: 18px; }
.number-stepper button:hover:not(:disabled) { background: #eaf3ff; }
.number-stepper button:disabled { cursor: default; color: #aeb8c2; background: #f4f6f8; }
.teacher-form label, .qr-field { display: grid; gap: 7px; color: var(--muted); font-size: 13px; }
.teacher-form label small { font-size: 11px; }
.qr-upload { min-height: 132px; padding: 14px; gap: 16px; color: var(--primary-dark); background: #f5f9fd; border: 1px dashed #9dc9f7; border-radius: 13px; }
......@@ -284,8 +301,8 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(20,115,230,.
.modal { padding: 10px; place-items: start center; }
.modal-card { max-height: calc(100dvh - 20px); }
.teacher-form { padding: 17px; }
.consultation-row { grid-template-columns: 44px 76px 108px 76px 82px 66px 112px minmax(150px,1fr) 68px 102px 86px; }
.teacher-row { grid-template-columns: .75fr .9fr .55fr 1.35fr 1fr .65fr .75fr .65fr 86px; }
.consultation-row { grid-template-columns: 44px 76px 108px 165px 82px 66px 112px minmax(150px,1fr) 68px 102px 86px; }
.teacher-row { grid-template-columns: .8fr 1fr .6fr 1fr .7fr .8fr .7fr 86px; }
.consultation-edit-section { padding: 14px; }
.consultation-edit-heading { align-items: flex-start; flex-direction: column; gap: 3px; }
.settings-panel { align-items:stretch; flex-direction:column; }
......
......@@ -17,14 +17,14 @@
<dialog class="contact-dialog contact-dialog--teacher" data-teacher-dialog aria-labelledby="teacher-dialog-title">
<div class="contact-dialog__panel">
<button class="contact-dialog__close" type="button" data-teacher-dialog-close aria-label="关闭老师微信弹窗">×</button>
<button class="contact-dialog__close" type="button" data-teacher-dialog-close aria-label="关闭客服微信弹窗">×</button>
<div class="teacher-dialog__heading">
<h2 id="teacher-dialog-title">专属咨询老师</h2>
<p>手机号已提交,老师稍后会与您联系。您也可以扫码添加老师微信。</p>
<h2 id="teacher-dialog-title">添加客服微信</h2>
<p>手机号已提交,客服稍后会与您联系。您也可以扫码添加客服微信。</p>
</div>
<div class="teacher-contact">
<div class="teacher-contact__identity"><strong data-teacher-name></strong><span data-teacher-title></span></div>
<img data-teacher-qr alt="咨询老师微信二维码" />
<img data-teacher-qr alt="客服微信二维码" />
<p class="teacher-wechat" data-teacher-wechat-wrap>微信号:<strong data-teacher-wechat></strong><button type="button" data-copy-teacher-wechat>复制</button></p>
</div>
</div>
......
......@@ -89,11 +89,12 @@ async function readState(): Promise<ConsultationState> {
const total = rawConsultations.length;
const consultations = rawConsultations.map((item, index) => {
const legacyStatus = String(item.status || "");
const storedLocation = String(item.location || "");
return {
...item,
sequence: Number.isInteger(item.sequence) ? Number(item.sequence) : total - index,
name: String(item.name || ""),
location: String(item.location || ""),
location: storedLocation,
status: legacyStatus === "added" || legacyStatus === "completed"
? "added"
: legacyStatus === "not_added" || legacyStatus === "contacted" ? "not_added" : "unprocessed",
......@@ -137,6 +138,15 @@ function cleanText(value: unknown, label: string, maxLength: number, required =
return text;
}
function cleanMultilineText(value: unknown, label: string, maxLength: number): string {
const text = String(value || "")
.replace(/\r\n?/g, "\n")
.replace(/[ \t]+$/gm, "")
.trim();
if (text.length > maxLength) throw new StoreError(`${label}不能超过 ${maxLength} 个字`);
return text;
}
function normalizeUsername(value: unknown): string {
const username = String(value || "").trim().toLowerCase();
if (!/^[a-z0-9_-]{3,32}$/.test(username)) throw new StoreError("登录账号需为 3-32 位字母、数字、下划线或短横线");
......@@ -161,6 +171,62 @@ function normalizePhone(value: unknown): string {
return phone;
}
function inferCarrierLocation(value: unknown): string {
const phone = String(value || "").replace(/\s+/g, "");
if (!/^1[3-9]\d{9}$/.test(phone)) return "";
const prefix = phone.slice(0, 3);
const carrier = /^(134|135|136|137|138|139|147|148|150|151|152|157|158|159|172|178|182|183|184|187|188|195|197|198)$/.test(prefix)
? "中国移动"
: /^(130|131|132|145|155|156|166|175|176|185|186|196)$/.test(prefix)
? "中国联通"
: /^(133|149|153|173|174|177|180|181|189|190|191|193|199)$/.test(prefix)
? "中国电信"
: /^(162|165|167|170|171)$/.test(prefix) ? "虚拟运营商" : "运营商未知";
return `中国大陆 ${carrier}`;
}
function provinceName(value: unknown): string {
const name = String(value || "").trim();
if (!name) return "";
if (/(省|市|自治区|特别行政区)$/.test(name)) return name;
const autonomousRegions: Record<string, string> = { 内蒙古: "内蒙古自治区", 广西: "广西壮族自治区", 西藏: "西藏自治区", 宁夏: "宁夏回族自治区", 新疆: "新疆维吾尔自治区" };
if (autonomousRegions[name]) return autonomousRegions[name];
if (["北京", "上海", "天津", "重庆"].includes(name)) return `${name}市`;
return `${name}省`;
}
function cityName(value: unknown): string {
const name = String(value || "").trim();
if (!name || /(市|地区|自治州|盟)$/.test(name)) return name;
return `${name}市`;
}
function carrierName(value: unknown): string {
const name = String(value || "").trim();
if (name.includes("移动")) return "中国移动";
if (name.includes("联通")) return "中国联通";
if (name.includes("电信")) return "中国电信";
if (name.includes("广电")) return "中国广电";
return name;
}
async function resolvePhoneLocation(phone: string): Promise<string> {
try {
const response = await fetch(`https://cx.shouji.360.cn/phonearea.php?number=${encodeURIComponent(phone)}`, {
headers: { Accept: "application/json" },
signal: AbortSignal.timeout(2500),
});
if (response.ok) {
const result = await response.json() as { code?: number; data?: { province?: string; city?: string; sp?: string } };
const province = provinceName(result.data?.province);
const city = cityName(result.data?.city);
const carrier = carrierName(result.data?.sp);
if (result.code === 0 && province && city && carrier) return `${province} ${city} ${carrier}`;
}
} catch {}
return inferCarrierLocation(phone);
}
function normalizeCallCount(value: unknown): number {
const count = Number(value ?? 0);
if (!Number.isInteger(count) || count < 0 || count > 9999) throw new StoreError("打电话次数需为 0-9999 的整数");
......@@ -290,8 +356,9 @@ export async function getTeacherSessionVersion(id: string): Promise<number | nul
}
export async function assignConsultation(input: Record<string, unknown>): Promise<{ consultation: ConsultationRecord; teacher: PublicTeacher }> {
const phone = normalizePhone(input.phone);
const location = await resolvePhoneLocation(phone);
return mutate((state) => {
const phone = normalizePhone(input.phone);
const teachers = state.teachers.filter((teacher) => teacher.enabled && teacher.role === "customer_service").sort((a, b) => a.sort - b.sort || a.createdAt.localeCompare(b.createdAt));
if (!teachers.length) throw new StoreError("客服暂未配置,请稍后再试或拨打客服电话", 503);
const previousIndex = teachers.findIndex((teacher) => teacher.id === state.lastAssignedTeacherId);
......@@ -302,7 +369,7 @@ export async function assignConsultation(input: Record<string, unknown>): Promis
sequence: state.nextConsultationNumber++,
name: "",
phone,
location: "",
location,
teacherId: teacher.id,
teacherName: teacher.name,
teacherTitle: teacher.title,
......@@ -327,6 +394,8 @@ export async function assignConsultation(input: Record<string, unknown>): Promis
}
export async function createConsultation(input: Record<string, unknown>): Promise<ConsultationRecord> {
const phone = normalizePhone(input.phone);
const location = await resolvePhoneLocation(phone);
return mutate((state) => {
const teacher = state.teachers.find((item) => item.id === String(input.teacherId || "") && item.role === "customer_service");
if (!teacher) throw new StoreError("请选择分配客服");
......@@ -335,8 +404,8 @@ export async function createConsultation(input: Record<string, unknown>): Promis
id: crypto.randomUUID(),
sequence: state.nextConsultationNumber++,
name: cleanText(input.name, "姓名", 30, false),
phone: normalizePhone(input.phone),
location: cleanText(input.location, "归属地", 80, false),
phone,
location,
teacherId: teacher.id,
teacherName: teacher.name,
teacherTitle: teacher.title,
......@@ -347,7 +416,7 @@ export async function createConsultation(input: Record<string, unknown>): Promis
status: normalizeConsultationStatus(input.status),
unaddedReason: cleanText(input.unaddedReason, "未添加原因", 200, false),
callCount: normalizeCallCount(input.callCount),
note: cleanText(input.note, "跟进备注", 500, false),
note: cleanMultilineText(input.note, "跟进备注", 500),
createdAt: now,
updatedAt: now,
};
......@@ -362,14 +431,16 @@ export async function listConsultations(teacherId?: string): Promise<Consultatio
}
export async function updateConsultation(id: string, input: Record<string, unknown>, teacherId?: string): Promise<ConsultationRecord> {
return mutate((state) => {
return mutate(async (state) => {
const consultation = state.consultations.find((item) => item.id === id && (!teacherId || item.teacherId === teacherId));
if (!consultation) throw new StoreError("咨询记录不存在", 404);
consultation.status = normalizeConsultationStatus(input.status || consultation.status);
consultation.phone = normalizePhone(input.phone ?? consultation.phone);
consultation.note = cleanText(input.note, "跟进备注", 500, false);
const nextPhone = normalizePhone(input.phone ?? consultation.phone);
const phoneChanged = nextPhone !== consultation.phone;
consultation.phone = nextPhone;
consultation.note = cleanMultilineText(input.note, "跟进备注", 500);
consultation.name = cleanText(input.name ?? consultation.name, "姓名", 30, false);
consultation.location = cleanText(input.location ?? consultation.location, "归属地", 80, false);
if (phoneChanged || !consultation.location) consultation.location = await resolvePhoneLocation(consultation.phone);
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) {
......
......@@ -138,7 +138,6 @@
<div class="form-grid">
<label>账号角色<select id="teacher-role"><option value="customer_service">客服</option><option value="operator">运营</option></select></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 class="customer-service-field">微信号<input id="teacher-wechat" maxlength="60" placeholder="选填" /></label>
<label class="customer-service-field">轮询顺序<input id="teacher-sort" type="number" min="1" max="9999" value="1" /></label>
......@@ -147,7 +146,7 @@
<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-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 class="switch-row"><input id="teacher-enabled" type="checkbox" checked /><span id="teacher-enabled-label">启用并参与轮询分配</span></label>
<p id="teacher-error" class="error"></p>
......@@ -158,7 +157,7 @@
<div id="consultation-user-modal" class="modal hidden">
<div class="modal-card consultation-user-card">
<div class="modal-head"><div><strong id="consultation-user-title">录入用户</strong><small id="consultation-user-description">一次完成用户信息、咨询状态和跟进备注录入</small></div><button id="close-consultation-user-modal" class="ghost small" type="button">关闭</button></div>
<div class="modal-head"><div><strong id="consultation-user-title">录入用户</strong><small id="consultation-user-description">一次完成用户信息、咨询状态和跟进备注录入</small></div><div class="modal-head-actions"><button id="close-consultation-user-modal" class="ghost small" type="button">关闭</button><button class="primary small" type="submit" form="consultation-user-form">保存用户</button></div></div>
<form id="consultation-user-form" class="teacher-form">
<input id="consultation-user-id" type="hidden" />
<section class="consultation-edit-section">
......@@ -166,7 +165,7 @@
<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>
<p class="auto-field-note">归属地将根据手机号自动识别,无需手动填写</p>
<label>分配客服<select id="consultation-user-teacher" required></select></label>
</div>
</section>
......@@ -175,12 +174,11 @@
<div class="form-grid">
<label>状态<select id="consultation-user-status"><option value="unprocessed">未处理</option><option value="added">已添加</option><option value="not_added">未添加</option></select></label>
<label>未添加原因<input id="consultation-user-reason" maxlength="200" /></label>
<label>打电话次数<input id="consultation-user-calls" type="number" min="0" max="9999" value="0" required /></label>
<label>打电话次数<div class="number-stepper"><button id="decrease-consultation-calls" type="button" aria-label="打电话次数减一"></button><input id="consultation-user-calls" type="number" min="0" max="9999" value="0" required /><button id="increase-consultation-calls" type="button" aria-label="打电话次数加一"></button></div></label>
</div>
<label class="consultation-note-field">完整跟进备注<textarea id="consultation-user-note" maxlength="500" rows="8" placeholder="记录沟通结果、家长需求或下次跟进时间"></textarea><small><strong id="consultation-user-note-count">0</strong> / 500</small></label>
</section>
<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>
</div>
</div>
......
......@@ -39,6 +39,8 @@
.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-contact__identity{display:none}
.teacher-contact img{width:min(300px,78vw);height:auto;max-width:100%;margin-top:0;object-fit:contain}
.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}
.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,7 @@ process.env.CMS_PASSWORD = "environment-master-password";
process.env.CMS_SECRET = "auth-test-session-secret";
process.env.CMS_API_KEY = "auth-test-api-key";
const [{ handleCmsApi }, { createConsultationTeacher }] = await Promise.all([
const [{ handleCmsApi }, { createConsultation, createConsultationTeacher }] = await Promise.all([
import("../src/lib/cms-api"),
import("../src/lib/consultation-store"),
]);
......@@ -175,7 +175,7 @@ test("an API key alone cannot change the browser login password", async () => {
});
test("customer service and operator accounts are restricted to their own work areas", async () => {
await createConsultationTeacher({
const serviceAccount = await createConsultationTeacher({
role: "customer_service",
name: "测试客服",
username: "service_test",
......@@ -185,6 +185,22 @@ test("customer service and operator accounts are restricted to their own work ar
sort: 1,
enabled: true,
});
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => new Response(JSON.stringify({ code: 0, data: { province: "山西", city: "运城", sp: "移动" } }), { status: 200, headers: { "Content-Type": "application/json" } })) as typeof fetch;
try {
const generatedConsultation = await createConsultation({
name: "测试用户",
phone: "13800138000",
teacherId: serviceAccount.id,
status: "unprocessed",
callCount: 0,
note: "第一行备注\n第二行备注",
});
assert.equal(generatedConsultation.location, "山西省 运城市 中国移动");
assert.equal(generatedConsultation.note, "第一行备注\n第二行备注");
} finally {
globalThis.fetch = originalFetch;
}
await createConsultationTeacher({
role: "operator",
name: "测试运营",
......
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