Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Sign in / Register
Toggle navigation
Q
qiyouxue
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
xuchentao
qiyouxue
Commits
3d8d4534
Commit
3d8d4534
authored
Aug 13, 2026
by
tao355667
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: expand consultation admin roles
parent
19e1bb16
Changes
10
Show whitespace changes
Inline
Side-by-side
Showing
10 changed files
with
602 additions
and
133 deletions
+602
-133
app.js
public/admin/app.js
+180
-58
style.css
public/admin/style.css
+51
-14
ContactDialog.astro
src/components/ContactDialog.astro
+1
-1
cms-api.ts
src/lib/cms-api.ts
+25
-10
consultation-store.ts
src/lib/consultation-store.ts
+137
-24
index.astro
src/pages/admin/index.astro
+68
-25
privacy-agreement.html.astro
src/pages/privacy-agreement.html.astro
+24
-0
service-agreement.html.astro
src/pages/service-agreement.html.astro
+23
-0
global.css
src/styles/global.css
+19
-0
auth.test.ts
tests/auth.test.ts
+74
-1
No files found.
public/admin/app.js
View file @
3d8d4534
...
@@ -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
===
n
ame
));
document
.
querySelectorAll
(
"[data-admin-nav]"
).
forEach
((
button
)
=>
button
.
classList
.
toggle
(
"active"
,
button
.
dataset
.
adminNav
===
allowedN
ame
));
if
(
n
ame
===
"consultations"
)
await
loadConsultations
();
if
(
allowedN
ame
===
"consultations"
)
await
loadConsultations
();
if
(
n
ame
===
"teachers"
)
await
loadTeachers
();
if
(
allowedN
ame
===
"teachers"
)
await
loadTeachers
();
if
(
n
ame
===
"articles"
)
await
loadArticles
();
if
(
allowedN
ame
===
"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
(
!
consultationTeacher
s
.
length
)
{
if (!
staffAccount
s.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);
consultationTeacher
s
.
forEach
((
teacher
)
=>
{
staffAccount
s.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 = "";
...
...
public/admin/style.css
View file @
3d8d4534
...
@@ -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
(
100
dvh
-
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.05
fr
.8
fr
.75
fr
.8
fr
2.2
fr
.55
fr
;
}
.consultation-row
{
grid-template-columns
:
44px
76px
108px
76px
82px
66px
112px
150px
68px
102px
214px
;
}
.teacher-row
{
grid-template-columns
:
1.45
fr
.85
fr
.45
fr
.65
fr
.55
fr
.9
fr
;
}
.teacher-row
{
grid-template-columns
:
1.45
fr
.85
fr
.45
fr
.65
fr
.55
fr
.9
fr
;
}
.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
:
1
5
px
;
font-weight
:
600
;
text-decoration
:
none
;
}
.phone-link
{
color
:
var
(
--text
);
font-size
:
1
2
px
;
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
:
1
3
px
;
}
.created
,
.teacher-name
{
font-size
:
1
2
px
;
}
.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
:
1
fr
1
fr
;
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
1
fr
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
:
1
fr
1
fr
;
gap
:
15px
;
}
.form-grid
{
display
:
grid
;
grid-template-columns
:
1
fr
1
fr
;
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
:
1
fr
;
}
.form-grid
{
grid-template-columns
:
1
fr
;
}
.consultation-row
{
grid-template-columns
:
150px
125px
120px
130px
360px
90px
;
}
.modal
{
padding
:
10px
;
place-items
:
start
center
;
}
.modal-card
{
max-height
:
calc
(
100
dvh
-
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
:
1
fr
;
}
.note-card-footer
{
grid-template-columns
:
1
fr
1
fr
;
}
.note-card-footer
>
p
{
grid-column
:
1
/
-1
;
grid-row
:
2
;
}
.settings-panel
{
align-items
:
stretch
;
flex-direction
:
column
;
}
}
}
src/components/ContactDialog.astro
View file @
3d8d4534
...
@@ -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>
...
...
src/lib/cms-api.ts
View file @
3d8d4534
...
@@ -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
()
});
...
...
src/lib/consultation-store.ts
View file @
3d8d4534
...
@@ -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
);
});
}
src/pages/admin/index.astro
View file @
3d8d4534
...
@@ -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-contact
ed"
>
0
</strong></article>
<article><small>
已添加
</small><strong
id=
"metric-add
ed"
>
0
</strong></article>
<article><small>
已完成
</small><strong
id=
"metric-complet
ed"
>
0
</strong></article>
<article><small>
未添加
</small><strong
id=
"metric-not-add
ed"
>
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>
...
...
src/pages/privacy-agreement.html.astro
0 → 100644
View file @
3d8d4534
---
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>
src/pages/service-agreement.html.astro
0 → 100644
View file @
3d8d4534
---
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>
src/styles/global.css
View file @
3d8d4534
...
@@ -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
:
1
fr
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
:
1
fr
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
)}
...
...
tests/auth.test.ts
View file @
3d8d4534
...
@@ -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
);
});
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment