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
6a8442a9
Commit
6a8442a9
authored
Aug 14, 2026
by
tao355667
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: improve consultation management workflow
parent
a1cfaaed
Changes
7
Expand all
Hide whitespace changes
Inline
Side-by-side
Showing
7 changed files
with
236 additions
and
63 deletions
+236
-63
app.js
public/admin/app.js
+101
-32
style.css
public/admin/style.css
+26
-9
ContactDialog.astro
src/components/ContactDialog.astro
+4
-4
consultation-store.ts
src/lib/consultation-store.ts
+81
-10
index.astro
src/pages/admin/index.astro
+4
-6
global.css
src/styles/global.css
+2
-0
auth.test.ts
tests/auth.test.ts
+18
-2
No files found.
public/admin/app.js
View file @
6a8442a9
This diff is collapsed.
Click to expand it.
public/admin/style.css
View file @
6a8442a9
...
...
@@ -41,6 +41,7 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(20,115,230,.
.topbar
.ghost
{
color
:
white
;
border-color
:
rgba
(
255
,
255
,
255
,
.24
);
}
.pending-badge
{
padding
:
5px
10px
;
color
:
#cfe2ff
;
background
:
rgba
(
20
,
115
,
230
,
.12
);
border
:
1px
solid
rgba
(
20
,
115
,
230
,
.3
);
border-radius
:
999px
;
}
.view
{
width
:
min
(
1120px
,
calc
(
100%
-
40px
));
margin
:
0
auto
;
padding
:
44px
0
70px
;
}
#consultations-view
,
#teachers-view
{
width
:
calc
(
100%
-
32px
);
max-width
:
none
;
}
.view-head
{
margin-bottom
:
26px
;
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
gap
:
18px
;
}
.view-toolbar
{
min-height
:
40px
;
margin-bottom
:
18px
;
display
:
flex
;
justify-content
:
flex-end
;
}
.view-head
p
{
margin
:
0
0
5px
;
color
:
var
(
--primary-dark
);
font-size
:
11px
;
font-weight
:
700
;
letter-spacing
:
.16em
;
}
...
...
@@ -123,6 +124,7 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(20,115,230,.
.category-manager
{
width
:
min
(
680px
,
100%
);
}
.modal-card
>
.modal-head
{
padding
:
18px
20px
;
}
.modal-head
>
div
{
display
:
grid
;
gap
:
4px
;
}
.modal-head
>
.modal-head-actions
{
display
:
flex
;
align-items
:
center
;
gap
:
8px
;
flex
:
0
0
auto
;
}
.modal-head
small
{
color
:
var
(
--muted
);
font-size
:
12px
;
font-weight
:
400
;
}
.password-card
{
width
:
min
(
500px
,
100%
);
}
.modal-card
>
.password-form
{
padding
:
20px
;
display
:
grid
;
gap
:
14px
;
border
:
0
;
}
...
...
@@ -193,12 +195,12 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(20,115,230,.
.column-filter
{
min-width
:
0
;
display
:
grid
;
gap
:
4px
;
}
.column-filter
>
span
{
overflow
:
hidden
;
text-overflow
:
ellipsis
;
white-space
:
nowrap
;
}
.column-filter
select
{
min-height
:
24px
;
padding
:
2px
19px
2px
5px
;
color
:
#657587
;
background-color
:
#fff
;
border-color
:
#d9e1e9
;
border-radius
:
5px
;
font-size
:
10px
;
}
#consultation-list
{
min-width
:
11
0
0px
;
}
.consultation-row
{
grid-template-columns
:
44px
76px
108px
76
px
82px
66px
112px
minmax
(
150px
,
1
fr
)
68px
102px
86px
;
}
#consultation-list
{
min-width
:
11
9
0px
;
}
.consultation-row
{
grid-template-columns
:
44px
76px
108px
165
px
82px
66px
112px
minmax
(
150px
,
1
fr
)
68px
102px
86px
;
}
#teacher-list
{
min-width
:
1050px
;
}
.teacher-row
{
grid-template-columns
:
.
75
fr
.9
fr
.55
fr
1.35
fr
1
fr
.65
fr
.75
fr
.65
fr
86px
;
}
.teacher-row
{
grid-template-columns
:
.
8
fr
1
fr
.6
fr
1
fr
.7
fr
.8
fr
.7
fr
86px
;
}
.data-row
small
{
display
:
block
;
margin-top
:
4px
;
color
:
var
(
--muted
);
font-size
:
11px
;
}
.phone-
link
{
color
:
var
(
--text
);
font-size
:
12px
;
font-weight
:
600
;
text-decoration
:
none
;
}
.phone-
text
{
color
:
var
(
--text
);
font-size
:
12px
;
font-weight
:
600
;
}
.page
{
max-width
:
180px
;
overflow
:
hidden
;
text-overflow
:
ellipsis
;
white-space
:
nowrap
;
}
.created
,
.teacher-name
{
font-size
:
12px
;
}
.created
{
color
:
var
(
--muted
);
}
...
...
@@ -208,12 +210,18 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(20,115,230,.
.consultation-status--unprocessed
{
color
:
#1769aa
;
}
.consultation-status--added
{
color
:
#0b805b
;
}
.consultation-status--not_added
{
color
:
#bd4141
;
}
.consultation-row
>
:last-child
,
.teacher-row
>
:last-child
{
position
:
sticky
;
right
:
0
;
z-index
:
2
;
min-height
:
100%
;
align-content
:
center
;
justify-content
:
flex-end
;
background
:
#fff
;
bo
x-shadow
:
-10px
0
14px
-14px
rgba
(
19
,
48
,
79
,
.75
);
}
.consultation-row.data-head
>
:last-child
,
.teacher-row.data-head
>
:last-child
{
z-index
:
3
;
background
:
#f7f9fb
;
}
.consultation-row
>
:last-child
,
.teacher-row
>
:last-child
{
position
:
sticky
;
right
:
0
;
z-index
:
2
;
min-height
:
100%
;
align-content
:
center
;
justify-content
:
flex-end
;
background
:
#fff
;
bo
rder-left
:
1px
solid
var
(
--border
);
}
.consultation-row.data-head
>
:last-child
,
.teacher-row.data-head
>
:last-child
{
z-index
:
3
;
display
:
grid
;
place-items
:
center
start
;
background
:
#f7f9fb
;
}
.consultation-row
.row-actions
,
.teacher-row
.row-actions
{
gap
:
12px
;
flex-wrap
:
nowrap
;
}
.consultation-row
.row-actions
.text-action
,
.teacher-row
.row-actions
.text-action
{
min-height
:
auto
;
padding
:
2px
0
;
color
:
var
(
--primary-dark
);
background
:
transparent
;
border
:
0
;
border-radius
:
0
;
font-size
:
12px
;
line-height
:
1.4
;
}
.consultation-row
.row-actions
.text-action
:hover
,
.teacher-row
.row-actions
.text-action
:hover
{
color
:
var
(
--deep
);
text-decoration
:
underline
;
transform
:
none
;
}
.consultation-row
.row-actions
.text-action.danger
,
.teacher-row
.row-actions
.text-action.danger
{
color
:
var
(
--danger
);
}
.table-pagination
{
min-width
:
100%
;
padding
:
11px
14px
;
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
gap
:
18px
;
color
:
var
(
--muted
);
background
:
#fafbfd
;
border-top
:
1px
solid
var
(
--border
);
font-size
:
12px
;
}
.table-pagination__controls
{
display
:
flex
;
align-items
:
center
;
gap
:
9px
;
}
.table-pagination__controls
label
{
display
:
flex
;
align-items
:
center
;
gap
:
5px
;
white-space
:
nowrap
;
}
.table-pagination__controls
select
{
width
:
58px
;
min-height
:
32px
;
padding
:
4px
7px
;
font-size
:
12px
;
}
.table-pagination__controls
>
span
{
min-width
:
44px
;
text-align
:
center
;
white-space
:
nowrap
;
}
.table-pagination__controls
button
:disabled
{
cursor
:
default
;
opacity
:
.42
;
}
.inline-follow-form
{
display
:
contents
;
}
.inline-follow-form
select
{
padding
:
8px
10px
;
font-size
:
13px
;
}
.inline-note
{
min-width
:
0
;
display
:
grid
;
gap
:
4px
;
}
...
...
@@ -223,7 +231,7 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(20,115,230,.
.inline-follow-actions
{
display
:
grid
;
justify-items
:
start
;
gap
:
2px
;
}
.inline-follow-actions
.save-state
{
min-height
:
14px
;
margin
:
0
;
white-space
:
nowrap
;
}
.staff-name
{
overflow
:
hidden
;
text-overflow
:
ellipsis
;
white-space
:
nowrap
;
font-size
:
12px
;
}
.staff-
title
,
.staff-
wechat
,
.username
{
overflow
:
hidden
;
color
:
var
(
--muted
);
text-overflow
:
ellipsis
;
white-space
:
nowrap
;
font-size
:
12px
;
}
.staff-wechat
,
.username
{
overflow
:
hidden
;
color
:
var
(
--muted
);
text-overflow
:
ellipsis
;
white-space
:
nowrap
;
font-size
:
12px
;
}
.teacher-state
{
width
:
max-content
;
padding
:
4px
9px
;
border-radius
:
999px
;
font-size
:
12px
;
}
.teacher-state.enabled
{
color
:
#087a56
;
background
:
#dff8ee
;
}
.teacher-state.disabled
{
color
:
#7b8190
;
background
:
#edf0f4
;
}
...
...
@@ -240,10 +248,19 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(20,115,230,.
.consultation-edit-heading
{
display
:
flex
;
align-items
:
baseline
;
justify-content
:
space-between
;
gap
:
16px
;
}
.consultation-edit-heading
strong
{
color
:
#203a57
;
font-size
:
14px
;
}
.consultation-edit-heading
span
{
color
:
var
(
--muted
);
font-size
:
11px
;
}
.auto-field-note
{
grid-column
:
1
/
-1
;
margin
:
0
;
padding
:
10px
12px
;
color
:
#55718d
;
background
:
#eef6ff
;
border
:
1px
solid
#d7e8f9
;
border-radius
:
8px
;
font-size
:
12px
;
}
.consultation-note-field
{
display
:
grid
;
gap
:
7px
;
color
:
var
(
--muted
);
font-size
:
13px
;
}
.consultation-note-field
textarea
{
min-height
:
170px
;
resize
:
vertical
;
line-height
:
1.7
;
}
.consultation-note-field
small
{
justify-self
:
end
;
color
:
var
(
--muted
);
font-size
:
11px
;
}
.form-grid
{
display
:
grid
;
grid-template-columns
:
1
fr
1
fr
;
gap
:
15px
;
}
.number-stepper
{
display
:
grid
;
grid-template-columns
:
38px
1
fr
38px
;
overflow
:
hidden
;
border
:
1px
solid
var
(
--border
);
border-radius
:
9px
;
background
:
#fff
;
}
.number-stepper
:focus-within
{
outline
:
2px
solid
rgba
(
20
,
115
,
230
,
.14
);
border-color
:
var
(
--primary
);
}
.number-stepper
input
{
min-width
:
0
;
padding-inline
:
6px
;
border
:
0
;
border-inline
:
1px
solid
var
(
--border
);
border-radius
:
0
;
text-align
:
center
;
appearance
:
textfield
;
}
.number-stepper
input
:focus
{
outline
:
0
;
}
.number-stepper
input
::-webkit-inner-spin-button
,
.number-stepper
input
::-webkit-outer-spin-button
{
margin
:
0
;
appearance
:
none
;
}
.number-stepper
button
{
min-height
:
42px
;
padding
:
0
;
color
:
var
(
--primary-dark
);
background
:
#f7faff
;
border-radius
:
0
;
font-size
:
18px
;
}
.number-stepper
button
:hover:not
(
:disabled
)
{
background
:
#eaf3ff
;
}
.number-stepper
button
:disabled
{
cursor
:
default
;
color
:
#aeb8c2
;
background
:
#f4f6f8
;
}
.teacher-form
label
,
.qr-field
{
display
:
grid
;
gap
:
7px
;
color
:
var
(
--muted
);
font-size
:
13px
;
}
.teacher-form
label
small
{
font-size
:
11px
;
}
.qr-upload
{
min-height
:
132px
;
padding
:
14px
;
gap
:
16px
;
color
:
var
(
--primary-dark
);
background
:
#f5f9fd
;
border
:
1px
dashed
#9dc9f7
;
border-radius
:
13px
;
}
...
...
@@ -284,8 +301,8 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(20,115,230,.
.modal
{
padding
:
10px
;
place-items
:
start
center
;
}
.modal-card
{
max-height
:
calc
(
100
dvh
-
20px
);
}
.teacher-form
{
padding
:
17px
;
}
.consultation-row
{
grid-template-columns
:
44px
76px
108px
76
px
82px
66px
112px
minmax
(
150px
,
1
fr
)
68px
102px
86px
;
}
.teacher-row
{
grid-template-columns
:
.
75
fr
.9
fr
.55
fr
1.35
fr
1
fr
.65
fr
.75
fr
.65
fr
86px
;
}
.consultation-row
{
grid-template-columns
:
44px
76px
108px
165
px
82px
66px
112px
minmax
(
150px
,
1
fr
)
68px
102px
86px
;
}
.teacher-row
{
grid-template-columns
:
.
8
fr
1
fr
.6
fr
1
fr
.7
fr
.8
fr
.7
fr
86px
;
}
.consultation-edit-section
{
padding
:
14px
;
}
.consultation-edit-heading
{
align-items
:
flex-start
;
flex-direction
:
column
;
gap
:
3px
;
}
.settings-panel
{
align-items
:
stretch
;
flex-direction
:
column
;
}
...
...
src/components/ContactDialog.astro
View file @
6a8442a9
...
...
@@ -17,14 +17,14 @@
<dialog class="contact-dialog contact-dialog--teacher" data-teacher-dialog aria-labelledby="teacher-dialog-title">
<div class="contact-dialog__panel">
<button class="contact-dialog__close" type="button" data-teacher-dialog-close aria-label="关闭
老师
微信弹窗">×</button>
<button class="contact-dialog__close" type="button" data-teacher-dialog-close aria-label="关闭
客服
微信弹窗">×</button>
<div class="teacher-dialog__heading">
<h2 id="teacher-dialog-title">
专属咨询老师
</h2>
<p>手机号已提交,
老师稍后会与您联系。您也可以扫码添加老师
微信。</p>
<h2 id="teacher-dialog-title">
添加客服微信
</h2>
<p>手机号已提交,
客服稍后会与您联系。您也可以扫码添加客服
微信。</p>
</div>
<div class="teacher-contact">
<div class="teacher-contact__identity"><strong data-teacher-name></strong><span data-teacher-title></span></div>
<img data-teacher-qr alt="
咨询老师
微信二维码" />
<img data-teacher-qr alt="
客服
微信二维码" />
<p class="teacher-wechat" data-teacher-wechat-wrap>微信号:<strong data-teacher-wechat></strong><button type="button" data-copy-teacher-wechat>复制</button></p>
</div>
</div>
...
...
src/lib/consultation-store.ts
View file @
6a8442a9
...
...
@@ -89,11 +89,12 @@ async function readState(): Promise<ConsultationState> {
const
total
=
rawConsultations
.
length
;
const
consultations
=
rawConsultations
.
map
((
item
,
index
)
=>
{
const
legacyStatus
=
String
(
item
.
status
||
""
);
const
storedLocation
=
String
(
item
.
location
||
""
);
return
{
...
item
,
sequence
:
Number
.
isInteger
(
item
.
sequence
)
?
Number
(
item
.
sequence
)
:
total
-
index
,
name
:
String
(
item
.
name
||
""
),
location
:
String
(
item
.
location
||
""
)
,
location
:
storedLocation
,
status
:
legacyStatus
===
"added"
||
legacyStatus
===
"completed"
?
"added"
:
legacyStatus
===
"not_added"
||
legacyStatus
===
"contacted"
?
"not_added"
:
"unprocessed"
,
...
...
@@ -137,6 +138,15 @@ function cleanText(value: unknown, label: string, maxLength: number, required =
return
text
;
}
function
cleanMultilineText
(
value
:
unknown
,
label
:
string
,
maxLength
:
number
):
string
{
const
text
=
String
(
value
||
""
)
.
replace
(
/
\r\n?
/g
,
"
\n
"
)
.
replace
(
/
[
\t]
+$/gm
,
""
)
.
trim
();
if
(
text
.
length
>
maxLength
)
throw
new
StoreError
(
`
${
label
}
不能超过
${
maxLength
}
个字`
);
return
text
;
}
function
normalizeUsername
(
value
:
unknown
):
string
{
const
username
=
String
(
value
||
""
).
trim
().
toLowerCase
();
if
(
!
/^
[
a-z0-9_-
]{3,32}
$/
.
test
(
username
))
throw
new
StoreError
(
"登录账号需为 3-32 位字母、数字、下划线或短横线"
);
...
...
@@ -161,6 +171,62 @@ function normalizePhone(value: unknown): string {
return
phone
;
}
function
inferCarrierLocation
(
value
:
unknown
):
string
{
const
phone
=
String
(
value
||
""
).
replace
(
/
\s
+/g
,
""
);
if
(
!
/^1
[
3-9
]\d{9}
$/
.
test
(
phone
))
return
""
;
const
prefix
=
phone
.
slice
(
0
,
3
);
const
carrier
=
/^
(
134|135|136|137|138|139|147|148|150|151|152|157|158|159|172|178|182|183|184|187|188|195|197|198
)
$/
.
test
(
prefix
)
?
"中国移动"
:
/^
(
130|131|132|145|155|156|166|175|176|185|186|196
)
$/
.
test
(
prefix
)
?
"中国联通"
:
/^
(
133|149|153|173|174|177|180|181|189|190|191|193|199
)
$/
.
test
(
prefix
)
?
"中国电信"
:
/^
(
162|165|167|170|171
)
$/
.
test
(
prefix
)
?
"虚拟运营商"
:
"运营商未知"
;
return
`中国大陆
${
carrier
}
`
;
}
function
provinceName
(
value
:
unknown
):
string
{
const
name
=
String
(
value
||
""
).
trim
();
if
(
!
name
)
return
""
;
if
(
/
(
省|市|自治区|特别行政区
)
$/
.
test
(
name
))
return
name
;
const
autonomousRegions
:
Record
<
string
,
string
>
=
{
内蒙古
:
"内蒙古自治区"
,
广西
:
"广西壮族自治区"
,
西藏
:
"西藏自治区"
,
宁夏
:
"宁夏回族自治区"
,
新疆
:
"新疆维吾尔自治区"
};
if
(
autonomousRegions
[
name
])
return
autonomousRegions
[
name
];
if
([
"北京"
,
"上海"
,
"天津"
,
"重庆"
].
includes
(
name
))
return
`
${
name
}
市`
;
return
`
${
name
}
省`
;
}
function
cityName
(
value
:
unknown
):
string
{
const
name
=
String
(
value
||
""
).
trim
();
if
(
!
name
||
/
(
市|地区|自治州|盟
)
$/
.
test
(
name
))
return
name
;
return
`
${
name
}
市`
;
}
function
carrierName
(
value
:
unknown
):
string
{
const
name
=
String
(
value
||
""
).
trim
();
if
(
name
.
includes
(
"移动"
))
return
"中国移动"
;
if
(
name
.
includes
(
"联通"
))
return
"中国联通"
;
if
(
name
.
includes
(
"电信"
))
return
"中国电信"
;
if
(
name
.
includes
(
"广电"
))
return
"中国广电"
;
return
name
;
}
async
function
resolvePhoneLocation
(
phone
:
string
):
Promise
<
string
>
{
try
{
const
response
=
await
fetch
(
`https://cx.shouji.360.cn/phonearea.php?number=
${
encodeURIComponent
(
phone
)}
`
,
{
headers
:
{
Accept
:
"application/json"
},
signal
:
AbortSignal
.
timeout
(
2500
),
});
if
(
response
.
ok
)
{
const
result
=
await
response
.
json
()
as
{
code
?:
number
;
data
?:
{
province
?:
string
;
city
?:
string
;
sp
?:
string
}
};
const
province
=
provinceName
(
result
.
data
?.
province
);
const
city
=
cityName
(
result
.
data
?.
city
);
const
carrier
=
carrierName
(
result
.
data
?.
sp
);
if
(
result
.
code
===
0
&&
province
&&
city
&&
carrier
)
return
`
${
province
}
${
city
}
${
carrier
}
`
;
}
}
catch
{}
return
inferCarrierLocation
(
phone
);
}
function
normalizeCallCount
(
value
:
unknown
):
number
{
const
count
=
Number
(
value
??
0
);
if
(
!
Number
.
isInteger
(
count
)
||
count
<
0
||
count
>
9999
)
throw
new
StoreError
(
"打电话次数需为 0-9999 的整数"
);
...
...
@@ -290,8 +356,9 @@ export async function getTeacherSessionVersion(id: string): Promise<number | nul
}
export
async
function
assignConsultation
(
input
:
Record
<
string
,
unknown
>
):
Promise
<
{
consultation
:
ConsultationRecord
;
teacher
:
PublicTeacher
}
>
{
const
phone
=
normalizePhone
(
input
.
phone
);
const
location
=
await
resolvePhoneLocation
(
phone
);
return
mutate
((
state
)
=>
{
const
phone
=
normalizePhone
(
input
.
phone
);
const
teachers
=
state
.
teachers
.
filter
((
teacher
)
=>
teacher
.
enabled
&&
teacher
.
role
===
"customer_service"
).
sort
((
a
,
b
)
=>
a
.
sort
-
b
.
sort
||
a
.
createdAt
.
localeCompare
(
b
.
createdAt
));
if
(
!
teachers
.
length
)
throw
new
StoreError
(
"客服暂未配置,请稍后再试或拨打客服电话"
,
503
);
const
previousIndex
=
teachers
.
findIndex
((
teacher
)
=>
teacher
.
id
===
state
.
lastAssignedTeacherId
);
...
...
@@ -302,7 +369,7 @@ export async function assignConsultation(input: Record<string, unknown>): Promis
sequence
:
state
.
nextConsultationNumber
++
,
name
:
""
,
phone
,
location
:
""
,
location
,
teacherId
:
teacher
.
id
,
teacherName
:
teacher
.
name
,
teacherTitle
:
teacher
.
title
,
...
...
@@ -327,6 +394,8 @@ export async function assignConsultation(input: Record<string, unknown>): Promis
}
export
async
function
createConsultation
(
input
:
Record
<
string
,
unknown
>
):
Promise
<
ConsultationRecord
>
{
const
phone
=
normalizePhone
(
input
.
phone
);
const
location
=
await
resolvePhoneLocation
(
phone
);
return
mutate
((
state
)
=>
{
const
teacher
=
state
.
teachers
.
find
((
item
)
=>
item
.
id
===
String
(
input
.
teacherId
||
""
)
&&
item
.
role
===
"customer_service"
);
if
(
!
teacher
)
throw
new
StoreError
(
"请选择分配客服"
);
...
...
@@ -335,8 +404,8 @@ export async function createConsultation(input: Record<string, unknown>): Promis
id
:
crypto
.
randomUUID
(),
sequence
:
state
.
nextConsultationNumber
++
,
name
:
cleanText
(
input
.
name
,
"姓名"
,
30
,
false
),
phone
:
normalizePhone
(
input
.
phone
)
,
location
:
cleanText
(
input
.
location
,
"归属地"
,
80
,
false
)
,
phone
,
location
,
teacherId
:
teacher
.
id
,
teacherName
:
teacher
.
name
,
teacherTitle
:
teacher
.
title
,
...
...
@@ -347,7 +416,7 @@ export async function createConsultation(input: Record<string, unknown>): Promis
status
:
normalizeConsultationStatus
(
input
.
status
),
unaddedReason
:
cleanText
(
input
.
unaddedReason
,
"未添加原因"
,
200
,
false
),
callCount
:
normalizeCallCount
(
input
.
callCount
),
note
:
clean
Text
(
input
.
note
,
"跟进备注"
,
500
,
false
),
note
:
clean
MultilineText
(
input
.
note
,
"跟进备注"
,
500
),
createdAt
:
now
,
updatedAt
:
now
,
};
...
...
@@ -362,14 +431,16 @@ export async function listConsultations(teacherId?: string): Promise<Consultatio
}
export
async
function
updateConsultation
(
id
:
string
,
input
:
Record
<
string
,
unknown
>
,
teacherId
?:
string
):
Promise
<
ConsultationRecord
>
{
return
mutate
((
state
)
=>
{
return
mutate
(
async
(
state
)
=>
{
const
consultation
=
state
.
consultations
.
find
((
item
)
=>
item
.
id
===
id
&&
(
!
teacherId
||
item
.
teacherId
===
teacherId
));
if
(
!
consultation
)
throw
new
StoreError
(
"咨询记录不存在"
,
404
);
consultation
.
status
=
normalizeConsultationStatus
(
input
.
status
||
consultation
.
status
);
consultation
.
phone
=
normalizePhone
(
input
.
phone
??
consultation
.
phone
);
consultation
.
note
=
cleanText
(
input
.
note
,
"跟进备注"
,
500
,
false
);
const
nextPhone
=
normalizePhone
(
input
.
phone
??
consultation
.
phone
);
const
phoneChanged
=
nextPhone
!==
consultation
.
phone
;
consultation
.
phone
=
nextPhone
;
consultation
.
note
=
cleanMultilineText
(
input
.
note
,
"跟进备注"
,
500
);
consultation
.
name
=
cleanText
(
input
.
name
??
consultation
.
name
,
"姓名"
,
30
,
false
);
consultation
.
location
=
cleanText
(
input
.
location
??
consultation
.
location
,
"归属地"
,
80
,
fals
e
);
if
(
phoneChanged
||
!
consultation
.
location
)
consultation
.
location
=
await
resolvePhoneLocation
(
consultation
.
phon
e
);
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
)
{
...
...
src/pages/admin/index.astro
View file @
6a8442a9
...
...
@@ -138,7 +138,6 @@
<div
class=
"form-grid"
>
<label>
账号角色
<select
id=
"teacher-role"
><option
value=
"customer_service"
>
客服
</option><option
value=
"operator"
>
运营
</option></select></label>
<label>
姓名
<input
id=
"teacher-name"
maxlength=
"30"
required
/></label>
<label
class=
"customer-service-field"
>
展示称谓
<input
id=
"teacher-title"
maxlength=
"40"
placeholder=
"例如:教务主任|全科一对一"
/></label>
<label>
登录账号
<input
id=
"teacher-username"
maxlength=
"32"
autocomplete=
"off"
placeholder=
"字母、数字、下划线"
required
/></label>
<label
class=
"customer-service-field"
>
微信号
<input
id=
"teacher-wechat"
maxlength=
"60"
placeholder=
"选填"
/></label>
<label
class=
"customer-service-field"
>
轮询顺序
<input
id=
"teacher-sort"
type=
"number"
min=
"1"
max=
"9999"
value=
"1"
/></label>
...
...
@@ -147,7 +146,7 @@
<label
class=
"qr-field customer-service-field"
>
微信二维码
<input
id=
"teacher-qr-file"
class=
"hidden"
type=
"file"
accept=
"image/png,image/jpeg,image/webp,image/gif"
/>
<input
id=
"teacher-qr-url"
type=
"hidden"
/>
<button
id=
"teacher-qr-button"
class=
"qr-upload"
type=
"button"
><img
id=
"teacher-qr-preview"
class=
"hidden"
alt=
"客服微信二维码预览"
/><span
id=
"teacher-qr-label"
>
点击
上传二维码
</span></button>
<button
id=
"teacher-qr-button"
class=
"qr-upload"
type=
"button"
><img
id=
"teacher-qr-preview"
class=
"hidden"
alt=
"客服微信二维码预览"
/><span
id=
"teacher-qr-label"
>
点击
选择图片,或直接粘贴
</span></button>
</label>
<label
class=
"switch-row"
><input
id=
"teacher-enabled"
type=
"checkbox"
checked
/><span
id=
"teacher-enabled-label"
>
启用并参与轮询分配
</span></label>
<p
id=
"teacher-error"
class=
"error"
></p>
...
...
@@ -158,7 +157,7 @@
<div
id=
"consultation-user-modal"
class=
"modal hidden"
>
<div
class=
"modal-card consultation-user-card"
>
<div
class=
"modal-head"
><div><strong
id=
"consultation-user-title"
>
录入用户
</strong><small
id=
"consultation-user-description"
>
一次完成用户信息、咨询状态和跟进备注录入
</small></div><
button
id=
"close-consultation-user-modal"
class=
"ghost small"
type=
"button"
>
关闭
</button
></div>
<div
class=
"modal-head"
><div><strong
id=
"consultation-user-title"
>
录入用户
</strong><small
id=
"consultation-user-description"
>
一次完成用户信息、咨询状态和跟进备注录入
</small></div><
div
class=
"modal-head-actions"
><button
id=
"close-consultation-user-modal"
class=
"ghost small"
type=
"button"
>
关闭
</button><button
class=
"primary small"
type=
"submit"
form=
"consultation-user-form"
>
保存用户
</button></div
></div>
<form
id=
"consultation-user-form"
class=
"teacher-form"
>
<input
id=
"consultation-user-id"
type=
"hidden"
/>
<section
class=
"consultation-edit-section"
>
...
...
@@ -166,7 +165,7 @@
<div
class=
"form-grid"
>
<label>
姓名
<input
id=
"consultation-user-name"
maxlength=
"30"
/></label>
<label>
手机号
<input
id=
"consultation-user-phone"
inputmode=
"numeric"
maxlength=
"11"
required
/></label>
<
label>
归属地
<input
id=
"consultation-user-location"
maxlength=
"80"
/></label
>
<
p
class=
"auto-field-note"
>
归属地将根据手机号自动识别,无需手动填写
</p
>
<label>
分配客服
<select
id=
"consultation-user-teacher"
required
></select></label>
</div>
</section>
...
...
@@ -175,12 +174,11 @@
<div
class=
"form-grid"
>
<label>
状态
<select
id=
"consultation-user-status"
><option
value=
"unprocessed"
>
未处理
</option><option
value=
"added"
>
已添加
</option><option
value=
"not_added"
>
未添加
</option></select></label>
<label>
未添加原因
<input
id=
"consultation-user-reason"
maxlength=
"200"
/></label>
<label>
打电话次数
<
input
id=
"consultation-user-calls"
type=
"number"
min=
"0"
max=
"9999"
value=
"0"
required
/
></label>
<label>
打电话次数
<
div
class=
"number-stepper"
><button
id=
"decrease-consultation-calls"
type=
"button"
aria-label=
"打电话次数减一"
>
−
</button><input
id=
"consultation-user-calls"
type=
"number"
min=
"0"
max=
"9999"
value=
"0"
required
/><button
id=
"increase-consultation-calls"
type=
"button"
aria-label=
"打电话次数加一"
>
+
</button></div
></label>
</div>
<label
class=
"consultation-note-field"
>
完整跟进备注
<textarea
id=
"consultation-user-note"
maxlength=
"500"
rows=
"8"
placeholder=
"记录沟通结果、家长需求或下次跟进时间"
></textarea><small><strong
id=
"consultation-user-note-count"
>
0
</strong>
/ 500
</small></label>
</section>
<p
id=
"consultation-user-error"
class=
"error"
></p>
<div
class=
"password-actions"
><button
id=
"cancel-consultation-user"
class=
"ghost"
type=
"button"
>
取消
</button><button
class=
"primary"
type=
"submit"
>
保存用户
</button></div>
</form>
</div>
</div>
...
...
src/styles/global.css
View file @
6a8442a9
...
...
@@ -39,6 +39,8 @@
.legal-document
h2
{
margin
:
0
0
10px
;
color
:
#1f405f
;
font-size
:
18px
}
.legal-document
section
p
{
margin
:
0
;
color
:
#53697d
;
font-size
:
15px
;
line-height
:
1.95
}
@media
(
max-width
:
760px
){
.legal-page
{
padding
:
38px
14px
64px
}
.legal-document
{
padding
:
34px
24px
;
border-radius
:
14px
}
.legal-document
h1
{
font-size
:
31px
}
.legal-document
section
p
{
font-size
:
14px
}}
.teacher-contact__identity
{
display
:
none
}
.teacher-contact
img
{
width
:
min
(
300px
,
78vw
);
height
:
auto
;
max-width
:
100%
;
margin-top
:
0
;
object-fit
:
contain
}
.teacher-wechat
{
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
gap
:
6px
}
.teacher-wechat
button
{
min-height
:
28px
;
padding
:
0
8px
;
border
:
0
;
border-radius
:
5px
;
color
:
var
(
--brand-blue
);
background
:
var
(
--bg-blue-soft
);
font-size
:
12px
;
cursor
:
pointer
}
.teacher-wechat
button
:hover
{
background
:
#dcecff
}
.teacher-wechat
button
:focus-visible
{
outline
:
2px
solid
var
(
--brand-blue
);
outline-offset
:
2px
}
.brand
{
display
:
flex
;
width
:
108px
;
height
:
68px
;
align-items
:
center
;
overflow
:
visible
}
.brand
img
{
width
:
100%
;
height
:
auto
;
max-height
:
66px
;
object-fit
:
contain
}
.footer-logo
{
display
:
flex
;
width
:
148px
;
height
:
100px
;
align-items
:
center
;
overflow
:
visible
;
border-radius
:
0
}
.footer-logo
img
{
width
:
100%
;
height
:
auto
;
max-height
:
96px
;
object-fit
:
contain
}
.image-frame
{
aspect-ratio
:
1.22
}
.image-frame
img
{
height
:
100%
;
aspect-ratio
:
auto
}
.article-tabs
{
display
:
flex
;
flex-wrap
:
wrap
;
gap
:
8px
;
margin-bottom
:
32px
}
.article-tab
{
display
:
flex
;
align-items
:
center
;
gap
:
8px
;
padding
:
8px
13px
;
border
:
1px
solid
var
(
--border
);
border-radius
:
7px
;
background
:
#fff
;
color
:
var
(
--text-secondary
);
font-size
:
13px
}
.article-tab
span
{
color
:
var
(
--text-muted
);
font-size
:
11px
}
.article-tab
:hover
,
.article-tab.active
{
border-color
:
var
(
--brand-blue
);
color
:
var
(
--brand-blue
)}
.article-tab.active
{
background
:
var
(
--brand-accent-soft
)}
.article-empty
{
padding
:
60px
;
border
:
1px
dashed
var
(
--border
);
background
:
#fff
;
color
:
var
(
--text-muted
);
text-align
:
center
}
.article-pagination
{
display
:
flex
;
justify-content
:
center
;
gap
:
7px
;
margin-top
:
36px
}
.article-pagination
>
a
,
.article-pagination
>
span
{
display
:
grid
;
place-items
:
center
;
width
:
38px
;
height
:
38px
;
border
:
1px
solid
var
(
--border
);
border-radius
:
6px
;
background
:
#fff
}
.article-pagination
.active
{
border-color
:
var
(
--brand-blue
);
color
:
#fff
;
background
:
var
(
--brand-blue
)}
...
...
tests/auth.test.ts
View file @
6a8442a9
...
...
@@ -10,7 +10,7 @@ process.env.CMS_PASSWORD = "environment-master-password";
process
.
env
.
CMS_SECRET
=
"auth-test-session-secret"
;
process
.
env
.
CMS_API_KEY
=
"auth-test-api-key"
;
const
[{
handleCmsApi
},
{
createConsultationTeacher
}]
=
await
Promise
.
all
([
const
[{
handleCmsApi
},
{
createConsultation
,
createConsultation
Teacher
}]
=
await
Promise
.
all
([
import
(
"../src/lib/cms-api"
),
import
(
"../src/lib/consultation-store"
),
]);
...
...
@@ -175,7 +175,7 @@ test("an API key alone cannot change the browser login password", async () => {
});
test
(
"customer service and operator accounts are restricted to their own work areas"
,
async
()
=>
{
await
createConsultationTeacher
({
const
serviceAccount
=
await
createConsultationTeacher
({
role
:
"customer_service"
,
name
:
"测试客服"
,
username
:
"service_test"
,
...
...
@@ -185,6 +185,22 @@ test("customer service and operator accounts are restricted to their own work ar
sort
:
1
,
enabled
:
true
,
});
const
originalFetch
=
globalThis
.
fetch
;
globalThis
.
fetch
=
(
async
()
=>
new
Response
(
JSON
.
stringify
({
code
:
0
,
data
:
{
province
:
"山西"
,
city
:
"运城"
,
sp
:
"移动"
}
}),
{
status
:
200
,
headers
:
{
"Content-Type"
:
"application/json"
}
}))
as
typeof
fetch
;
try
{
const
generatedConsultation
=
await
createConsultation
({
name
:
"测试用户"
,
phone
:
"13800138000"
,
teacherId
:
serviceAccount
.
id
,
status
:
"unprocessed"
,
callCount
:
0
,
note
:
"第一行备注
\n
第二行备注"
,
});
assert
.
equal
(
generatedConsultation
.
location
,
"山西省 运城市 中国移动"
);
assert
.
equal
(
generatedConsultation
.
note
,
"第一行备注
\n
第二行备注"
);
}
finally
{
globalThis
.
fetch
=
originalFetch
;
}
await
createConsultationTeacher
({
role
:
"operator"
,
name
:
"测试运营"
,
...
...
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