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
330212cd
Commit
330212cd
authored
Aug 13, 2026
by
tao355667
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: expand homepage and fix admin captcha
parent
63264608
Changes
7
Expand all
Show whitespace changes
Inline
Side-by-side
Showing
7 changed files
with
83 additions
and
24 deletions
+83
-24
app.js
public/admin/app.js
+23
-4
style.css
public/admin/style.css
+2
-2
site.ts
src/data/site.ts
+9
-5
cms-api.ts
src/lib/cms-api.ts
+6
-4
index.astro
src/pages/index.astro
+6
-6
global.css
src/styles/global.css
+2
-2
auth.test.ts
tests/auth.test.ts
+35
-1
No files found.
public/admin/app.js
View file @
330212cd
...
@@ -8,6 +8,9 @@ let editingStatus = "new-unsaved";
...
@@ -8,6 +8,9 @@ let editingStatus = "new-unsaved";
let
dirty
=
false
;
let
dirty
=
false
;
let
previewTimer
=
null
;
let
previewTimer
=
null
;
let
previewRequest
=
0
;
let
previewRequest
=
0
;
let
captchaToken
=
""
;
let
captchaObjectUrl
=
""
;
let
captchaRequest
=
0
;
async
function
api
(
path
,
options
=
{})
{
async
function
api
(
path
,
options
=
{})
{
const
endpoint
=
path
.
startsWith
(
"/"
)
?
path
:
`/
${
path
}
`
;
const
endpoint
=
path
.
startsWith
(
"/"
)
?
path
:
`/
${
path
}
`
;
...
@@ -24,10 +27,26 @@ async function api(path, options = {}) {
...
@@ -24,10 +27,26 @@ async function api(path, options = {}) {
const
show
=
(
selector
)
=>
$
(
selector
).
classList
.
remove
(
"hidden"
);
const
show
=
(
selector
)
=>
$
(
selector
).
classList
.
remove
(
"hidden"
);
const
hide
=
(
selector
)
=>
$
(
selector
).
classList
.
add
(
"hidden"
);
const
hide
=
(
selector
)
=>
$
(
selector
).
classList
.
add
(
"hidden"
);
const
refreshCaptcha
=
()
=>
{
async
function
refreshCaptcha
()
{
$
(
"#captcha-image"
).
src
=
`
${
API_BASE
}
/captcha?t=
${
Date
.
now
()}
`
;
const
request
=
++
captchaRequest
;
captchaToken
=
""
;
$
(
"#captcha"
).
value
=
""
;
$
(
"#captcha"
).
value
=
""
;
};
$
(
"#captcha-image"
).
removeAttribute
(
"src"
);
try
{
const
response
=
await
fetch
(
`
${
API_BASE
}
/captcha?t=
${
Date
.
now
()}
`
,
{
cache
:
"no-store"
});
if
(
!
response
.
ok
)
throw
new
Error
(
`验证码加载失败(
${
response
.
status
}
)`
);
const
token
=
response
.
headers
.
get
(
"X-Captcha-Token"
)
||
""
;
const
blob
=
await
response
.
blob
();
if
(
request
!==
captchaRequest
)
return
;
if
(
!
token
)
throw
new
Error
(
"验证码令牌缺失"
);
if
(
captchaObjectUrl
)
URL
.
revokeObjectURL
(
captchaObjectUrl
);
captchaObjectUrl
=
URL
.
createObjectURL
(
blob
);
captchaToken
=
token
;
$
(
"#captcha-image"
).
src
=
captchaObjectUrl
;
}
catch
(
error
)
{
if
(
request
===
captchaRequest
)
$
(
"#login-error"
).
textContent
=
error
.
message
;
}
}
function
showLogin
()
{
function
showLogin
()
{
show
(
"#login"
);
show
(
"#login"
);
...
@@ -52,7 +71,7 @@ $("#login-form").addEventListener("submit", async (event) => {
...
@@ -52,7 +71,7 @@ $("#login-form").addEventListener("submit", async (event) => {
event
.
preventDefault
();
event
.
preventDefault
();
$
(
"#login-error"
).
textContent
=
""
;
$
(
"#login-error"
).
textContent
=
""
;
try
{
try
{
await
api
(
"/session"
,
{
method
:
"POST"
,
body
:
{
password
:
$
(
"#password"
).
value
,
captcha
:
$
(
"#captcha"
).
value
}
});
await
api
(
"/session"
,
{
method
:
"POST"
,
body
:
{
password
:
$
(
"#password"
).
value
,
captcha
:
$
(
"#captcha"
).
value
,
captchaToken
}
});
$
(
"#password"
).
value
=
""
;
$
(
"#password"
).
value
=
""
;
$
(
"#captcha"
).
value
=
""
;
$
(
"#captcha"
).
value
=
""
;
hide
(
"#login"
);
show
(
"#app"
);
hide
(
"#login"
);
show
(
"#app"
);
...
...
public/admin/style.css
View file @
330212cd
...
@@ -35,8 +35,8 @@ input, textarea, select { width: 100%; padding: 11px 13px; color: var(--text); b
...
@@ -35,8 +35,8 @@ input, textarea, select { width: 100%; padding: 11px 13px; color: var(--text); b
input
:focus
,
textarea
:focus
,
select
:focus
{
outline
:
2px
solid
rgba
(
20
,
115
,
230
,
.14
);
border-color
:
var
(
--primary
);
}
input
:focus
,
textarea
:focus
,
select
:focus
{
outline
:
2px
solid
rgba
(
20
,
115
,
230
,
.14
);
border-color
:
var
(
--primary
);
}
.topbar
{
min-height
:
68px
;
padding
:
12px
28px
;
position
:
sticky
;
top
:
0
;
z-index
:
10
;
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
gap
:
22px
;
color
:
white
;
background
:
var
(
--primary
);
}
.topbar
{
min-height
:
68px
;
padding
:
12px
28px
;
position
:
sticky
;
top
:
0
;
z-index
:
10
;
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
gap
:
22px
;
color
:
white
;
background
:
var
(
--primary
);
}
.topbar
>
div
:first-child
{
display
:
flex
;
align-items
:
center
;
gap
:
13px
;
}
.topbar
>
div
:first-child
{
display
:
flex
;
align-items
:
center
;
gap
:
13px
;
}
.topbar
strong
{
color
:
var
(
--primary
)
;
font-size
:
19px
;
}
.topbar
strong
{
color
:
white
;
font-size
:
19px
;
}
.topbar
span
{
font-size
:
13px
;
opacity
:
.76
;
}
.topbar
span
{
color
:
white
;
font-size
:
13px
;
opacity
:
.76
;
}
.top-actions
{
display
:
flex
;
align-items
:
center
;
gap
:
9px
;
}
.top-actions
{
display
:
flex
;
align-items
:
center
;
gap
:
9px
;
}
.topbar
.ghost
{
color
:
white
;
border-color
:
rgba
(
255
,
255
,
255
,
.24
);
}
.topbar
.ghost
{
color
:
white
;
border-color
:
rgba
(
255
,
255
,
255
,
.24
);
}
.pending-badge
{
padding
:
5px
10px
;
color
:
#cfe2ff
;
background
:
rgba
(
20
,
115
,
230
,
.12
);
border
:
1px
solid
rgba
(
20
,
115
,
230
,
.3
);
border-radius
:
999px
;
}
.pending-badge
{
padding
:
5px
10px
;
color
:
#cfe2ff
;
background
:
rgba
(
20
,
115
,
230
,
.12
);
border
:
1px
solid
rgba
(
20
,
115
,
230
,
.3
);
border-radius
:
999px
;
}
...
...
src/data/site.ts
View file @
330212cd
export
const
site
=
{
export
const
site
=
{
seo
:
{
seo
:
{
title
:
"启优学一对一|小学初中高中在线个性化辅导"
,
title
:
"启优学一对一|小学初中高中在线个性化辅导"
,
description
:
"启优学一对一面向小学、初中和高中学生
,通过学情诊断、教师匹配、在线一对一授课、课后反馈与阶段复盘,制定有针对性的学习方案
。"
,
description
:
"启优学一对一面向小学、初中和高中学生
提供在线个性化辅导,通过学情诊断、教师匹配、专属学习方案、课堂反馈和阶段复盘,帮助学生针对性巩固基础、改善学习方法
。"
,
keywords
:
[
"启优学"
,
"启优学一对一"
,
"在线一对一辅导"
,
"小学一对一"
,
"初中一对一"
,
"高中一对一"
,
"个性化辅导"
],
keywords
:
[
"启优学"
,
"启优学一对一"
,
"在线一对一辅导"
,
"小学一对一"
,
"初中一对一"
,
"高中一对一"
,
"个性化辅导"
],
imageAlt
:
"启优学在线一对一课堂"
,
imageAlt
:
"启优学在线一对一课堂"
,
},
},
...
@@ -22,6 +22,7 @@ export const site = {
...
@@ -22,6 +22,7 @@ export const site = {
{
value
:
"85%+"
,
label
:
"来自 985 / 211 或重点师范院校"
},
{
value
:
"85%+"
,
label
:
"来自 985 / 211 或重点师范院校"
},
{
value
:
"20万+"
,
label
:
"累计服务学员"
},
{
value
:
"20万+"
,
label
:
"累计服务学员"
},
],
],
scenarios
:
[
"基础夯实"
,
"同步校内"
,
"偏科补弱"
,
"阶段复习"
,
"升学冲刺"
,
"拔高培优"
],
},
},
services
:
[
services
:
[
{
{
...
@@ -77,14 +78,17 @@ export const site = {
...
@@ -77,14 +78,17 @@ export const site = {
vision
:
"启发引导,因材施教;不只关注一次结果,更陪伴学生建立可以持续使用的学习能力。"
,
vision
:
"启发引导,因材施教;不只关注一次结果,更陪伴学生建立可以持续使用的学习能力。"
,
},
},
workflow
:
[
workflow
:
[
{
number
:
"01"
,
title
:
"沟通"
,
desc
:
"了解年级、教材、进度、近期试卷与当前目标。"
},
{
number
:
"01"
,
title
:
"需求沟通"
,
desc
:
"了解年级、地区、教材、校内进度、近期试卷与阶段目标。"
},
{
number
:
"02"
,
title
:
"诊断"
,
desc
:
"区分知识缺口、方法问题和学习习惯问题。"
},
{
number
:
"02"
,
title
:
"三维学情诊断"
,
desc
:
"结合试卷、错题与学习进度,区分知识缺口、方法和习惯问题。"
},
{
number
:
"03"
,
title
:
"匹配"
,
desc
:
"综合学段、学科、基础与沟通特点匹配教师。"
},
{
number
:
"03"
,
title
:
"教师匹配"
,
desc
:
"综合学段、学科、教材版本、当前基础与沟通特点匹配固定教师。"
},
{
number
:
"04"
,
title
:
"复盘"
,
desc
:
"根据课堂与练习反馈持续调整后续安排。"
},
{
number
:
"04"
,
title
:
"互动授课"
,
desc
:
"通过提问、作答、即时反馈与难度调整,检查学生是否真正理解。"
},
{
number
:
"05"
,
title
:
"课后反馈"
,
desc
:
"记录本节内容、掌握情况、仍需巩固的问题与下一次安排。"
},
{
number
:
"06"
,
title
:
"阶段复盘"
,
desc
:
"根据课堂、练习与阶段表现持续调整后续学习方案。"
},
],
],
faq
:
[
faq
:
[
{
q
:
"启优学一对一是什么?"
,
a
:
"启优学一对一是面向小学、初中、高中学生的在线一对一辅导品牌,主要提供小初高校内学科个性化辅导服务。课程以学情诊断为起点,根据年级、学科基础、教材版本、薄弱点和学习目标匹配教师。"
},
{
q
:
"启优学一对一是什么?"
,
a
:
"启优学一对一是面向小学、初中、高中学生的在线一对一辅导品牌,主要提供小初高校内学科个性化辅导服务。课程以学情诊断为起点,根据年级、学科基础、教材版本、薄弱点和学习目标匹配教师。"
},
{
q
:
"主要提供哪些课程?"
,
a
:
"覆盖基础夯实、同步校内、偏科补弱、阶段复习、升学冲刺和拔高培优等学习场景。课程根据学生实际学情制定,不是统一模板课。"
},
{
q
:
"主要提供哪些课程?"
,
a
:
"覆盖基础夯实、同步校内、偏科补弱、阶段复习、升学冲刺和拔高培优等学习场景。课程根据学生实际学情制定,不是统一模板课。"
},
{
q
:
"适合哪些学生?"
,
a
:
"适合存在单科短板、偏科拖分、基础断层、学习习惯不稳定、校内进度跟不上、考试复习效率较低,或需要升学冲刺与拔高培优的小初高学生。正式上课前会先沟通学情,再判断是否适合一对一辅导。"
},
{
q
:
"覆盖哪些年级和科目?"
,
a
:
"覆盖小学1-6年级、初中1-3年级和高中1-3年级,支持语文、数学、英语、物理、化学、生物、地理、道德与法治等常见校内学科。具体开课情况以咨询确认为准。"
},
{
q
:
"覆盖哪些年级和科目?"
,
a
:
"覆盖小学1-6年级、初中1-3年级和高中1-3年级,支持语文、数学、英语、物理、化学、生物、地理、道德与法治等常见校内学科。具体开课情况以咨询确认为准。"
},
{
q
:
"可以只辅导一科吗?"
,
a
:
"可以,尤其适合单科拖分、偏科明显或某一学科基础断层的学生。平台会先判断具体知识漏洞,再匹配对应学科教师,不强制多科打包。"
},
{
q
:
"可以只辅导一科吗?"
,
a
:
"可以,尤其适合单科拖分、偏科明显或某一学科基础断层的学生。平台会先判断具体知识漏洞,再匹配对应学科教师,不强制多科打包。"
},
{
q
:
"如何匹配教师?"
,
a
:
"根据学生年级、学科、教材版本、当前基础、学习目标和沟通特点进行匹配。匹配成功后通常安排固定教师长期授课;如适配度明显不合适,可联系课程服务人员协助评估调整。"
},
{
q
:
"如何匹配教师?"
,
a
:
"根据学生年级、学科、教材版本、当前基础、学习目标和沟通特点进行匹配。匹配成功后通常安排固定教师长期授课;如适配度明显不合适,可联系课程服务人员协助评估调整。"
},
...
...
src/lib/cms-api.ts
View file @
330212cd
...
@@ -117,8 +117,8 @@ function captchaMac(code: unknown, expiresAt: number): string {
...
@@ -117,8 +117,8 @@ function captchaMac(code: unknown, expiresAt: number): string {
.
digest
(
"hex"
);
.
digest
(
"hex"
);
}
}
function
captchaValid
(
request
:
Request
,
answer
:
unknown
):
boolean
{
function
captchaValid
(
request
:
Request
,
answer
:
unknown
,
token
?:
unknown
):
boolean
{
const
value
=
cookies
(
request
).
cms_captcha
;
const
value
=
String
(
token
||
cookies
(
request
).
cms_captcha
||
""
)
;
if
(
!
value
||
!
answer
)
return
false
;
if
(
!
value
||
!
answer
)
return
false
;
const
separator
=
value
.
lastIndexOf
(
"."
);
const
separator
=
value
.
lastIndexOf
(
"."
);
const
expiresAt
=
Number
(
value
.
slice
(
0
,
separator
));
const
expiresAt
=
Number
(
value
.
slice
(
0
,
separator
));
...
@@ -208,11 +208,13 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA
...
@@ -208,11 +208,13 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA
if
(
route
===
"captcha"
&&
method
===
"GET"
)
{
if
(
route
===
"captcha"
&&
method
===
"GET"
)
{
const
code
=
randomCaptcha
();
const
code
=
randomCaptcha
();
const
expiresAt
=
Date
.
now
()
+
CAPTCHA_TTL
;
const
expiresAt
=
Date
.
now
()
+
CAPTCHA_TTL
;
const
token
=
`
${
expiresAt
}
.
${
captchaMac
(
code
,
expiresAt
)}
`
;
return
new
Response
(
captchaSvg
(
code
),
{
return
new
Response
(
captchaSvg
(
code
),
{
headers
:
{
headers
:
{
"Cache-Control"
:
"no-store"
,
"Cache-Control"
:
"no-store"
,
"Content-Type"
:
"image/svg+xml; charset=utf-8"
,
"Content-Type"
:
"image/svg+xml; charset=utf-8"
,
"Set-Cookie"
:
cookieHeader
(
request
,
"cms_captcha"
,
`
${
expiresAt
}
.
${
captchaMac
(
code
,
expiresAt
)}
`
,
CAPTCHA_TTL
/
1000
),
"Set-Cookie"
:
cookieHeader
(
request
,
"cms_captcha"
,
token
,
CAPTCHA_TTL
/
1000
),
"X-Captcha-Token"
:
token
,
},
},
});
});
}
}
...
@@ -224,7 +226,7 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA
...
@@ -224,7 +226,7 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA
if
(
blockedFor
)
return
json
({
error
:
`尝试过于频繁,请
${
Math
.
ceil
(
blockedFor
/
60
)}
分钟后再试`
},
429
);
if
(
blockedFor
)
return
json
({
error
:
`尝试过于频繁,请
${
Math
.
ceil
(
blockedFor
/
60
)}
分钟后再试`
},
429
);
const
body
=
await
bodyOf
(
request
);
const
body
=
await
bodyOf
(
request
);
const
clearCaptcha
=
cookieHeader
(
request
,
"cms_captcha"
,
""
,
0
);
const
clearCaptcha
=
cookieHeader
(
request
,
"cms_captcha"
,
""
,
0
);
if
(
!
captchaValid
(
request
,
body
.
captcha
))
{
if
(
!
captchaValid
(
request
,
body
.
captcha
,
body
.
captchaToken
))
{
return
json
({
error
:
"验证码错误或已过期"
},
400
,
{
"Set-Cookie"
:
clearCaptcha
});
return
json
({
error
:
"验证码错误或已过期"
},
400
,
{
"Set-Cookie"
:
clearCaptcha
});
}
}
if
(
!
await
verifyPassword
(
body
.
password
))
{
if
(
!
await
verifyPassword
(
body
.
password
))
{
...
...
src/pages/index.astro
View file @
330212cd
This diff is collapsed.
Click to expand it.
src/styles/global.css
View file @
330212cd
This diff is collapsed.
Click to expand it.
tests/auth.test.ts
View file @
330212cd
...
@@ -41,7 +41,7 @@ async function login(password: string): Promise<{ response: Response; cookie?: s
...
@@ -41,7 +41,7 @@ async function login(password: string): Promise<{ response: Response; cookie?: s
"Content-Type"
:
"application/json"
,
"Content-Type"
:
"application/json"
,
Cookie
:
cookieFrom
(
captchaResponse
,
"cms_captcha"
),
Cookie
:
cookieFrom
(
captchaResponse
,
"cms_captcha"
),
},
},
body
:
JSON
.
stringify
({
password
,
captcha
:
code
}),
body
:
JSON
.
stringify
({
password
,
captcha
:
code
,
captchaToken
:
captchaResponse
.
headers
.
get
(
"X-Captcha-Token"
)
}),
}),
"session"
,
`login-
${
requestNumber
}
`
);
}),
"session"
,
`login-
${
requestNumber
}
`
);
return
{
return
{
response
,
response
,
...
@@ -64,6 +64,40 @@ async function changePassword(cookie: string, currentPassword: string, newPasswo
...
@@ -64,6 +64,40 @@ async function changePassword(cookie: string, currentPassword: string, newPasswo
}),
"account/password"
);
}),
"account/password"
);
}
}
test
(
"the displayed captcha remains valid when a later image request replaces the captcha cookie"
,
async
()
=>
{
const
displayedCaptcha
=
await
handleCmsApi
(
new
Request
(
"http://localhost/api/cms/captcha"
),
"captcha"
,
"captcha-race-displayed"
,
);
const
displayedCode
=
[...(
await
displayedCaptcha
.
text
()).
matchAll
(
/<text
\b[^
>
]
*>
([^
<
])
<
\/
text>/g
)]
.
map
((
match
)
=>
match
[
1
])
.
join
(
""
);
const
displayedToken
=
displayedCaptcha
.
headers
.
get
(
"X-Captcha-Token"
);
assert
.
equal
(
displayedCode
.
length
,
4
);
assert
.
ok
(
displayedToken
);
const
laterCaptcha
=
await
handleCmsApi
(
new
Request
(
"http://localhost/api/cms/captcha"
),
"captcha"
,
"captcha-race-later"
,
);
const
response
=
await
handleCmsApi
(
new
Request
(
"http://localhost/api/cms/session"
,
{
method
:
"POST"
,
headers
:
{
"Content-Type"
:
"application/json"
,
Cookie
:
cookieFrom
(
laterCaptcha
,
"cms_captcha"
),
},
body
:
JSON
.
stringify
({
password
:
"environment-master-password"
,
captcha
:
displayedCode
,
captchaToken
:
displayedToken
,
}),
}),
"session"
,
"captcha-race-login"
);
assert
.
equal
(
response
.
status
,
200
);
});
test
(
"users can change the persisted password while the .env password remains valid"
,
async
()
=>
{
test
(
"users can change the persisted password while the .env password remains valid"
,
async
()
=>
{
const
initialLogin
=
await
login
(
"environment-master-password"
);
const
initialLogin
=
await
login
(
"environment-master-password"
);
assert
.
equal
(
initialLogin
.
response
.
status
,
200
);
assert
.
equal
(
initialLogin
.
response
.
status
,
200
);
...
...
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