Commit 6a8442a9 authored by tao355667's avatar tao355667

feat: improve consultation management workflow

parent a1cfaaed
This diff is collapsed.
...@@ -41,6 +41,7 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(20,115,230,. ...@@ -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); } .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; } .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; } .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-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-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; } .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,. ...@@ -123,6 +124,7 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(20,115,230,.
.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; }
.modal-head > div { display: grid; gap: 4px; } .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; } .modal-head small { color: var(--muted); font-size: 12px; font-weight: 400; }
.password-card { width: min(500px, 100%); } .password-card { width: min(500px, 100%); }
.modal-card > .password-form { padding: 20px; display: grid; gap: 14px; border: 0; } .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,. ...@@ -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 { min-width: 0; display: grid; gap: 4px; }
.column-filter > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .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; } .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-list { min-width: 1190px; }
.consultation-row { grid-template-columns: 44px 76px 108px 76px 82px 66px 112px minmax(150px,1fr) 68px 102px 86px; } .consultation-row { grid-template-columns: 44px 76px 108px 165px 82px 66px 112px minmax(150px,1fr) 68px 102px 86px; }
#teacher-list { min-width: 1050px; } #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; } .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; } .page { max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.created,.teacher-name { font-size: 12px; } .created,.teacher-name { font-size: 12px; }
.created { color: var(--muted); } .created { color: var(--muted); }
...@@ -208,12 +210,18 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(20,115,230,. ...@@ -208,12 +210,18 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(20,115,230,.
.consultation-status--unprocessed { color: #1769aa; } .consultation-status--unprocessed { color: #1769aa; }
.consultation-status--added { color: #0b805b; } .consultation-status--added { color: #0b805b; }
.consultation-status--not_added { color: #bd4141; } .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 > :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; background: #f7f9fb; } .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,.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,.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: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); } .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 { 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; }
...@@ -223,7 +231,7 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(20,115,230,. ...@@ -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 { 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; }
.staff-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; } .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 { width: max-content; padding: 4px 9px; border-radius: 999px; font-size: 12px; }
.teacher-state.enabled { color: #087a56; background: #dff8ee; } .teacher-state.enabled { color: #087a56; background: #dff8ee; }
.teacher-state.disabled { color: #7b8190; background: #edf0f4; } .teacher-state.disabled { color: #7b8190; background: #edf0f4; }
...@@ -240,10 +248,19 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(20,115,230,. ...@@ -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 { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; }
.consultation-edit-heading strong { color: #203a57; font-size: 14px; } .consultation-edit-heading strong { color: #203a57; font-size: 14px; }
.consultation-edit-heading span { color: var(--muted); font-size: 11px; } .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 { 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 textarea { min-height: 170px; resize: vertical; line-height: 1.7; }
.consultation-note-field small { justify-self: end; color: var(--muted); font-size: 11px; } .consultation-note-field small { justify-self: end; color: var(--muted); font-size: 11px; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; } .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, .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; }
.qr-upload { min-height: 132px; padding: 14px; gap: 16px; color: var(--primary-dark); background: #f5f9fd; border: 1px dashed #9dc9f7; border-radius: 13px; } .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,. ...@@ -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 { padding: 10px; place-items: start center; }
.modal-card { max-height: calc(100dvh - 20px); } .modal-card { max-height: calc(100dvh - 20px); }
.teacher-form { padding: 17px; } .teacher-form { padding: 17px; }
.consultation-row { grid-template-columns: 44px 76px 108px 76px 82px 66px 112px minmax(150px,1fr) 68px 102px 86px; } .consultation-row { grid-template-columns: 44px 76px 108px 165px 82px 66px 112px minmax(150px,1fr) 68px 102px 86px; }
.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; }
.consultation-edit-section { padding: 14px; } .consultation-edit-section { padding: 14px; }
.consultation-edit-heading { align-items: flex-start; flex-direction: column; gap: 3px; } .consultation-edit-heading { align-items: flex-start; flex-direction: column; gap: 3px; }
.settings-panel { align-items:stretch; flex-direction:column; } .settings-panel { align-items:stretch; flex-direction:column; }
......
...@@ -17,14 +17,14 @@ ...@@ -17,14 +17,14 @@
<dialog class="contact-dialog contact-dialog--teacher" data-teacher-dialog aria-labelledby="teacher-dialog-title"> <dialog class="contact-dialog contact-dialog--teacher" data-teacher-dialog aria-labelledby="teacher-dialog-title">
<div class="contact-dialog__panel"> <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"> <div class="teacher-dialog__heading">
<h2 id="teacher-dialog-title">专属咨询老师</h2> <h2 id="teacher-dialog-title">添加客服微信</h2>
<p>手机号已提交,老师稍后会与您联系。您也可以扫码添加老师微信。</p> <p>手机号已提交,客服稍后会与您联系。您也可以扫码添加客服微信。</p>
</div> </div>
<div class="teacher-contact"> <div class="teacher-contact">
<div class="teacher-contact__identity"><strong data-teacher-name></strong><span data-teacher-title></span></div> <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> <p class="teacher-wechat" data-teacher-wechat-wrap>微信号:<strong data-teacher-wechat></strong><button type="button" data-copy-teacher-wechat>复制</button></p>
</div> </div>
</div> </div>
......
...@@ -89,11 +89,12 @@ async function readState(): Promise<ConsultationState> { ...@@ -89,11 +89,12 @@ async function readState(): Promise<ConsultationState> {
const total = rawConsultations.length; const total = rawConsultations.length;
const consultations = rawConsultations.map((item, index) => { const consultations = rawConsultations.map((item, index) => {
const legacyStatus = String(item.status || ""); const legacyStatus = String(item.status || "");
const storedLocation = String(item.location || "");
return { return {
...item, ...item,
sequence: Number.isInteger(item.sequence) ? Number(item.sequence) : total - index, sequence: Number.isInteger(item.sequence) ? Number(item.sequence) : total - index,
name: String(item.name || ""), name: String(item.name || ""),
location: String(item.location || ""), location: storedLocation,
status: legacyStatus === "added" || legacyStatus === "completed" status: legacyStatus === "added" || legacyStatus === "completed"
? "added" ? "added"
: legacyStatus === "not_added" || legacyStatus === "contacted" ? "not_added" : "unprocessed", : legacyStatus === "not_added" || legacyStatus === "contacted" ? "not_added" : "unprocessed",
...@@ -137,6 +138,15 @@ function cleanText(value: unknown, label: string, maxLength: number, required = ...@@ -137,6 +138,15 @@ function cleanText(value: unknown, label: string, maxLength: number, required =
return text; 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 { function normalizeUsername(value: unknown): string {
const username = String(value || "").trim().toLowerCase(); const username = String(value || "").trim().toLowerCase();
if (!/^[a-z0-9_-]{3,32}$/.test(username)) throw new StoreError("登录账号需为 3-32 位字母、数字、下划线或短横线"); if (!/^[a-z0-9_-]{3,32}$/.test(username)) throw new StoreError("登录账号需为 3-32 位字母、数字、下划线或短横线");
...@@ -161,6 +171,62 @@ function normalizePhone(value: unknown): string { ...@@ -161,6 +171,62 @@ function normalizePhone(value: unknown): string {
return phone; 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 { function normalizeCallCount(value: unknown): number {
const count = Number(value ?? 0); const count = Number(value ?? 0);
if (!Number.isInteger(count) || count < 0 || count > 9999) throw new StoreError("打电话次数需为 0-9999 的整数"); 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 ...@@ -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 }> { 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) => { 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)); 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); 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);
...@@ -302,7 +369,7 @@ export async function assignConsultation(input: Record<string, unknown>): Promis ...@@ -302,7 +369,7 @@ export async function assignConsultation(input: Record<string, unknown>): Promis
sequence: state.nextConsultationNumber++, sequence: state.nextConsultationNumber++,
name: "", name: "",
phone, phone,
location: "", location,
teacherId: teacher.id, teacherId: teacher.id,
teacherName: teacher.name, teacherName: teacher.name,
teacherTitle: teacher.title, teacherTitle: teacher.title,
...@@ -327,6 +394,8 @@ export async function assignConsultation(input: Record<string, unknown>): Promis ...@@ -327,6 +394,8 @@ export async function assignConsultation(input: Record<string, unknown>): Promis
} }
export async function createConsultation(input: Record<string, unknown>): Promise<ConsultationRecord> { export async function createConsultation(input: Record<string, unknown>): Promise<ConsultationRecord> {
const phone = normalizePhone(input.phone);
const location = await resolvePhoneLocation(phone);
return mutate((state) => { return mutate((state) => {
const teacher = state.teachers.find((item) => item.id === String(input.teacherId || "") && item.role === "customer_service"); const teacher = state.teachers.find((item) => item.id === String(input.teacherId || "") && item.role === "customer_service");
if (!teacher) throw new StoreError("请选择分配客服"); if (!teacher) throw new StoreError("请选择分配客服");
...@@ -335,8 +404,8 @@ export async function createConsultation(input: Record<string, unknown>): Promis ...@@ -335,8 +404,8 @@ export async function createConsultation(input: Record<string, unknown>): Promis
id: crypto.randomUUID(), id: crypto.randomUUID(),
sequence: state.nextConsultationNumber++, sequence: state.nextConsultationNumber++,
name: cleanText(input.name, "姓名", 30, false), name: cleanText(input.name, "姓名", 30, false),
phone: normalizePhone(input.phone), phone,
location: cleanText(input.location, "归属地", 80, false), location,
teacherId: teacher.id, teacherId: teacher.id,
teacherName: teacher.name, teacherName: teacher.name,
teacherTitle: teacher.title, teacherTitle: teacher.title,
...@@ -347,7 +416,7 @@ export async function createConsultation(input: Record<string, unknown>): Promis ...@@ -347,7 +416,7 @@ export async function createConsultation(input: Record<string, unknown>): Promis
status: normalizeConsultationStatus(input.status), status: normalizeConsultationStatus(input.status),
unaddedReason: cleanText(input.unaddedReason, "未添加原因", 200, false), unaddedReason: cleanText(input.unaddedReason, "未添加原因", 200, false),
callCount: normalizeCallCount(input.callCount), callCount: normalizeCallCount(input.callCount),
note: cleanText(input.note, "跟进备注", 500, false), note: cleanMultilineText(input.note, "跟进备注", 500),
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}; };
...@@ -362,14 +431,16 @@ export async function listConsultations(teacherId?: string): Promise<Consultatio ...@@ -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> { 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)); 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);
consultation.status = normalizeConsultationStatus(input.status || consultation.status); consultation.status = normalizeConsultationStatus(input.status || consultation.status);
consultation.phone = normalizePhone(input.phone ?? consultation.phone); const nextPhone = normalizePhone(input.phone ?? consultation.phone);
consultation.note = cleanText(input.note, "跟进备注", 500, false); const phoneChanged = nextPhone !== consultation.phone;
consultation.phone = nextPhone;
consultation.note = cleanMultilineText(input.note, "跟进备注", 500);
consultation.name = cleanText(input.name ?? consultation.name, "姓名", 30, false); 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.unaddedReason = cleanText(input.unaddedReason ?? consultation.unaddedReason, "未添加原因", 200, false);
consultation.callCount = normalizeCallCount(input.callCount ?? consultation.callCount); consultation.callCount = normalizeCallCount(input.callCount ?? consultation.callCount);
if (!teacherId && input.teacherId && String(input.teacherId) !== consultation.teacherId) { if (!teacherId && input.teacherId && String(input.teacherId) !== consultation.teacherId) {
......
...@@ -138,7 +138,6 @@ ...@@ -138,7 +138,6 @@
<div class="form-grid"> <div class="form-grid">
<label>账号角色<select id="teacher-role"><option value="customer_service">客服</option><option value="operator">运营</option></select></label> <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>姓名<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 class="customer-service-field">微信号<input id="teacher-wechat" maxlength="60" placeholder="选填" /></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> <label class="customer-service-field">轮询顺序<input id="teacher-sort" type="number" min="1" max="9999" value="1" /></label>
...@@ -147,7 +146,7 @@ ...@@ -147,7 +146,7 @@
<label class="qr-field customer-service-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 id="teacher-enabled-label">启用并参与轮询分配</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>
...@@ -158,7 +157,7 @@ ...@@ -158,7 +157,7 @@
<div id="consultation-user-modal" class="modal hidden"> <div id="consultation-user-modal" class="modal hidden">
<div class="modal-card consultation-user-card"> <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"> <form id="consultation-user-form" class="teacher-form">
<input id="consultation-user-id" type="hidden" /> <input id="consultation-user-id" type="hidden" />
<section class="consultation-edit-section"> <section class="consultation-edit-section">
...@@ -166,7 +165,7 @@ ...@@ -166,7 +165,7 @@
<div class="form-grid"> <div class="form-grid">
<label>姓名<input id="consultation-user-name" maxlength="30" /></label> <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-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> <label>分配客服<select id="consultation-user-teacher" required></select></label>
</div> </div>
</section> </section>
...@@ -175,12 +174,11 @@ ...@@ -175,12 +174,11 @@
<div class="form-grid"> <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>状态<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-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> </div>
<label class="consultation-note-field">完整跟进备注<textarea id="consultation-user-note" maxlength="500" rows="8" placeholder="记录沟通结果、家长需求或下次跟进时间"></textarea><small><strong id="consultation-user-note-count">0</strong> / 500</small></label> <label class="consultation-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> </section>
<p id="consultation-user-error" class="error"></p> <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>
......
...@@ -39,6 +39,8 @@ ...@@ -39,6 +39,8 @@
.legal-document h2{margin:0 0 10px;color:#1f405f;font-size:18px} .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} .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}} @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} .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,7 @@ process.env.CMS_PASSWORD = "environment-master-password"; ...@@ -10,7 +10,7 @@ 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 }, { createConsultationTeacher }] = await Promise.all([ const [{ handleCmsApi }, { createConsultation, createConsultationTeacher }] = await Promise.all([
import("../src/lib/cms-api"), import("../src/lib/cms-api"),
import("../src/lib/consultation-store"), import("../src/lib/consultation-store"),
]); ]);
...@@ -175,7 +175,7 @@ test("an API key alone cannot change the browser login password", async () => { ...@@ -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 () => { test("customer service and operator accounts are restricted to their own work areas", async () => {
await createConsultationTeacher({ const serviceAccount = await createConsultationTeacher({
role: "customer_service", role: "customer_service",
name: "测试客服", name: "测试客服",
username: "service_test", username: "service_test",
...@@ -185,6 +185,22 @@ test("customer service and operator accounts are restricted to their own work ar ...@@ -185,6 +185,22 @@ test("customer service and operator accounts are restricted to their own work ar
sort: 1, sort: 1,
enabled: true, 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({ await createConsultationTeacher({
role: "operator", role: "operator",
name: "测试运营", 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