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
Expand all
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
This diff is collapsed.
Click to expand it.
public/admin/style.css
View file @
3d8d4534
...
...
@@ -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
:hover
{
background
:
#fff0f0
;
}
#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-card
{
width
:
min
(
780px
,
100%
);
max-height
:
82vh
;
background
:
white
;
border-radius
:
16px
;
overflow
:
hidden
;
}
.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
:
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
);
}
.category-manager
{
width
:
min
(
680px
,
100%
);
}
.modal-card
>
.modal-head
{
padding
:
18px
20px
;
}
...
...
@@ -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
small
{
color
:
var
(
--muted
);
font-size
:
12px
;
}
.metric-grid
strong
{
color
:
#12365f
;
font-size
:
20px
;
}
.table-card
{
overflow
:
hidden
;
background
:
white
;
border
:
1px
solid
var
(
--border
);
border-radius
:
10px
;
}
.data-list
{
min-width
:
850px
;
}
.data-row
{
display
:
grid
;
align-items
:
center
;
gap
:
16px
;
padding
:
14px
18px
;
border-bottom
:
1px
solid
var
(
--border
);
}
.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
;
}
.table-card
::-webkit-scrollbar
{
height
:
9px
;
}
.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-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
;
}
.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
;
}
.created
,
.teacher-name
{
font-size
:
1
3
px
;
}
.created
,
.teacher-name
{
font-size
:
1
2
px
;
}
.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
select
{
padding
:
8px
10px
;
font-size
:
13px
;
}
.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
.expanded
{
overflow-y
:
auto
;
resize
:
vertical
;
}
.inline-note
textarea
{
height
:
54px
;
min-height
:
54px
;
padding
:
7px
10px
;
overflow
:
hidden
;
resize
:
none
;
font-size
:
13px
;
line-height
:
1.5
;
}
.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
;
}
.inline-follow-actions
{
display
:
grid
;
justify-items
:
start
;
gap
:
2px
;
}
.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
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
;
}
...
...
@@ -209,7 +229,13 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(20,115,230,.
.teacher-state.disabled
{
color
:
#7b8190
;
background
:
#edf0f4
;
}
.row-actions
.danger
{
color
:
var
(
--danger
);
}
.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
;
}
.teacher-form
label
,
.qr-field
{
display
:
grid
;
gap
:
7px
;
color
:
var
(
--muted
);
font-size
:
13px
;
}
.teacher-form
label
small
{
font-size
:
11px
;
}
...
...
@@ -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
input
{
width
:
17px
;
height
:
17px
;
}
.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
)
{
.topbar
,
.view-head
,
.article-row
{
align-items
:
flex-start
;
flex-direction
:
column
;
}
.top-actions
{
width
:
100%
;
flex-wrap
:
wrap
;
}
...
...
@@ -235,14 +266,20 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(20,115,230,.
.admin-shell
{
padding
:
0
0
72px
;
}
.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
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
b
{
margin-left
:
6px
;
}
.topbar
{
position
:
static
;
}
.metric-grid
{
padding
:
0
;
overflow-x
:
auto
;
}
.metric-grid
article
{
min-width
:
120px
;
padding
:
13px
16px
;
}
.metric-grid
article
:first-child
{
padding-left
:
16px
;
}
.table-card
{
overflow-x
:
auto
;
}
.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 @@
<form class="consultation-form" data-consultation-form>
<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>
<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>
<button class="button consultation-submit" type="submit">立即领取</button>
</form>
...
...
src/lib/cms-api.ts
View file @
3d8d4534
...
...
@@ -25,7 +25,9 @@ import { convertWordToMarkdown, decodeWordDataUrl } from "./word-import";
import
{
currentSessionVersion
,
savePassword
,
verifyPassword
}
from
"./cms-auth"
;
import
{
assignConsultation
,
createConsultation
,
createConsultationTeacher
,
deleteConsultation
,
deleteConsultationTeacher
,
getTeacherSessionVersion
,
listConsultations
,
...
...
@@ -44,7 +46,9 @@ const MAX_LOGIN_FAILURES = 5;
const
LOGIN_FAILURE_WINDOW
=
10
*
60
*
1000
;
const
LOGIN_BLOCK_DURATION
=
10
*
60
*
1000
;
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
=
{
status
:
"idle"
|
"building"
|
"success"
|
"error"
;
ok
:
boolean
|
null
;
...
...
@@ -97,7 +101,7 @@ async function teacherSession(request: Request): Promise<AuthContext | null> {
const
currentVersion
=
await
getTeacherSessionVersion
(
id
);
if
(
currentVersion
!==
version
)
return
null
;
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
>
{
...
...
@@ -282,7 +286,7 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA
headers
.
append
(
"Set-Cookie"
,
cookieHeader
(
request
,
"cms_session"
,
""
,
0
));
const
token
=
`
${
teacher
.
id
}
.
${
teacher
.
sessionVersion
}
.
${
teacherSessionMac
(
teacher
.
id
,
teacher
.
sessionVersion
)}
`
;
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_session"
,
await
sessionToken
(),
86400
));
...
...
@@ -315,13 +319,13 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA
if
(
!
context
)
throw
new
StoreError
(
"请先登录后台"
,
401
);
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
===
"POST"
)
return
json
({
teacher
:
await
createConsultationTeacher
(
await
bodyOf
(
request
))
},
201
);
}
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
===
"DELETE"
)
{
await
deleteConsultationTeacher
(
parts
[
1
]);
...
...
@@ -329,15 +333,26 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA
}
}
if
(
route
===
"consultations"
&&
method
===
"GET"
)
{
return
json
({
consultations
:
await
listConsultations
(
context
.
role
===
"teacher"
?
context
.
id
:
undefined
)
});
if
(
route
===
"consultations"
)
{
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"
)
{
return
json
({
consultation
:
await
updateConsultation
(
parts
[
1
],
await
bodyOf
(
request
),
context
.
role
===
"teacher"
?
context
.
id
:
undefined
)
});
if
(
parts
[
0
]
===
"consultations"
&&
parts
[
1
]
&&
parts
.
length
===
2
)
{
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
(
method
===
"GET"
)
return
json
({
categories
:
await
getCategoryNames
(),
stats
:
await
getCategoryStats
()
});
...
...
src/lib/consultation-store.ts
View file @
3d8d4534
This diff is collapsed.
Click to expand it.
src/pages/admin/index.astro
View file @
3d8d4534
This diff is collapsed.
Click to expand it.
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 @@
@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
}
@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
}
.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 @
3d8d4534
...
...
@@ -10,7 +10,10 @@ 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
}
=
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
;
after
(
async
()
=>
{
...
...
@@ -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
>
{
const
response
=
await
handleCmsApi
(
new
Request
(
"http://localhost/api/cms/session"
,
{
headers
:
{
Cookie
:
cookie
},
...
...
@@ -146,3 +173,49 @@ test("an API key alone cannot change the browser login password", async () => {
}),
"account/password"
);
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