添加题目练习功能模块
实现了完整的题目练习功能,包括后端API和前端界面: - 后端新增题目管理handlers和数据模型 - 前端新增题目展示页面和API调用模块 - 添加题库数据文件支持 - 更新路由配置以集成新功能 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
6120d051aa
commit
f791c235e1
8
go.mod
8
go.mod
@ -2,6 +2,12 @@ module ankao
|
|||||||
|
|
||||||
go 1.25.1
|
go 1.25.1
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gin-gonic/gin v1.11.0
|
||||||
|
github.com/go-ole/go-ole v1.3.0
|
||||||
|
github.com/richardlehane/mscfb v1.0.3
|
||||||
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
github.com/bytedance/sonic v1.14.2 // indirect
|
github.com/bytedance/sonic v1.14.2 // indirect
|
||||||
@ -9,7 +15,6 @@ require (
|
|||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
github.com/gin-gonic/gin v1.11.0 // indirect
|
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.28.0 // indirect
|
github.com/go-playground/validator/v10 v10.28.0 // indirect
|
||||||
@ -24,6 +29,7 @@ require (
|
|||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/quic-go/qpack v0.5.1 // indirect
|
github.com/quic-go/qpack v0.5.1 // indirect
|
||||||
github.com/quic-go/quic-go v0.55.0 // indirect
|
github.com/quic-go/quic-go v0.55.0 // indirect
|
||||||
|
github.com/richardlehane/msoleps v1.0.3 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
go.uber.org/mock v0.6.0 // indirect
|
go.uber.org/mock v0.6.0 // indirect
|
||||||
|
|||||||
17
go.sum
17
go.sum
@ -7,6 +7,7 @@ github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCc
|
|||||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
|
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
@ -14,6 +15,10 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
|
|||||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||||
|
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||||
|
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
@ -24,6 +29,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
|||||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
@ -40,11 +47,17 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
|||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||||
github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
|
github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
|
||||||
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
|
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
|
||||||
|
github.com/richardlehane/mscfb v1.0.3 h1:rD8TBkYWkObWO0oLDFCbwMeZ4KoalxQy+QgniCj3nKI=
|
||||||
|
github.com/richardlehane/mscfb v1.0.3/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
|
||||||
|
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||||
|
github.com/richardlehane/msoleps v1.0.3 h1:aznSZzrwYRl3rLKRT3gUk9am7T/mLNSnJINvN0AQoVM=
|
||||||
|
github.com/richardlehane/msoleps v1.0.3/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
@ -54,6 +67,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
|||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||||
@ -70,6 +85,7 @@ golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
|||||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
@ -81,4 +97,5 @@ google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aO
|
|||||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
267
internal/handlers/question_handler.go
Normal file
267
internal/handlers/question_handler.go
Normal file
@ -0,0 +1,267 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"ankao/internal/models"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 用于存储答题记录的简单内存存储
|
||||||
|
var (
|
||||||
|
userAnswers = make(map[int]interface{})
|
||||||
|
mu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetQuestions 获取题目列表
|
||||||
|
func GetQuestions(c *gin.Context) {
|
||||||
|
questionType := c.Query("type")
|
||||||
|
category := c.Query("category")
|
||||||
|
|
||||||
|
questions := GetTestQuestions()
|
||||||
|
|
||||||
|
// 过滤题目
|
||||||
|
var filtered []models.Question
|
||||||
|
for _, q := range questions {
|
||||||
|
if questionType != "" && string(q.Type) != questionType {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if category != "" && q.Category != category {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, q)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"data": filtered,
|
||||||
|
"total": len(filtered),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetQuestionByID 获取单个题目
|
||||||
|
func GetQuestionByID(c *gin.Context) {
|
||||||
|
idStr := c.Param("id")
|
||||||
|
id, err := strconv.Atoi(idStr)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "无效的题目ID",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
questions := GetTestQuestions()
|
||||||
|
for _, q := range questions {
|
||||||
|
if q.ID == id {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"data": q,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "题目不存在",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRandomQuestion 获取随机题目
|
||||||
|
func GetRandomQuestion(c *gin.Context) {
|
||||||
|
questions := GetTestQuestions()
|
||||||
|
if len(questions) == 0 {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "暂无题目",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
randomQuestion := questions[rand.Intn(len(questions))]
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"data": randomQuestion,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitAnswer 提交答案
|
||||||
|
func SubmitAnswer(c *gin.Context) {
|
||||||
|
var submit models.SubmitAnswer
|
||||||
|
if err := c.ShouldBindJSON(&submit); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "请求参数错误",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找题目
|
||||||
|
questions := GetTestQuestions()
|
||||||
|
var targetQuestion *models.Question
|
||||||
|
for i := range questions {
|
||||||
|
if questions[i].ID == submit.QuestionID {
|
||||||
|
targetQuestion = &questions[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if targetQuestion == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "题目不存在",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证答案
|
||||||
|
correct := checkAnswer(targetQuestion, submit.Answer)
|
||||||
|
|
||||||
|
// 保存答题记录
|
||||||
|
mu.Lock()
|
||||||
|
userAnswers[submit.QuestionID] = submit.Answer
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
result := models.AnswerResult{
|
||||||
|
Correct: correct,
|
||||||
|
CorrectAnswer: targetQuestion.Answer,
|
||||||
|
Explanation: getExplanation(targetQuestion.ID),
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"data": result,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStatistics 获取统计数据
|
||||||
|
func GetStatistics(c *gin.Context) {
|
||||||
|
mu.RLock()
|
||||||
|
answeredCount := len(userAnswers)
|
||||||
|
mu.RUnlock()
|
||||||
|
|
||||||
|
questions := GetTestQuestions()
|
||||||
|
totalCount := len(questions)
|
||||||
|
|
||||||
|
// 计算正确答案数
|
||||||
|
correctCount := 0
|
||||||
|
mu.RLock()
|
||||||
|
for qid, userAns := range userAnswers {
|
||||||
|
for i := range questions {
|
||||||
|
if questions[i].ID == qid {
|
||||||
|
if checkAnswer(&questions[i], userAns) {
|
||||||
|
correctCount++
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mu.RUnlock()
|
||||||
|
|
||||||
|
accuracy := 0.0
|
||||||
|
if answeredCount > 0 {
|
||||||
|
accuracy = float64(correctCount) / float64(answeredCount) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
stats := models.Statistics{
|
||||||
|
TotalQuestions: totalCount,
|
||||||
|
AnsweredQuestions: answeredCount,
|
||||||
|
CorrectAnswers: correctCount,
|
||||||
|
Accuracy: accuracy,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"data": stats,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetProgress 重置答题进度
|
||||||
|
func ResetProgress(c *gin.Context) {
|
||||||
|
mu.Lock()
|
||||||
|
userAnswers = make(map[int]interface{})
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "答题进度已重置",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkAnswer 检查答案是否正确
|
||||||
|
func checkAnswer(question *models.Question, userAnswer interface{}) bool {
|
||||||
|
switch question.Type {
|
||||||
|
case models.SingleChoice, models.TrueFalse:
|
||||||
|
// 单选和判断题:字符串比较
|
||||||
|
return userAnswer == question.Answer
|
||||||
|
|
||||||
|
case models.MultipleChoice:
|
||||||
|
// 多选题:数组比较
|
||||||
|
userArr, ok1 := userAnswer.([]interface{})
|
||||||
|
correctArr, ok2 := question.Answer.([]string)
|
||||||
|
if !ok1 || !ok2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(userArr) != len(correctArr) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// 转换为map进行比较
|
||||||
|
userMap := make(map[string]bool)
|
||||||
|
for _, v := range userArr {
|
||||||
|
if str, ok := v.(string); ok {
|
||||||
|
userMap[str] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, v := range correctArr {
|
||||||
|
if !userMap[v] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
|
||||||
|
case models.FillBlank:
|
||||||
|
// 填空题:字符串比较(忽略大小写和空格)
|
||||||
|
userStr, ok := userAnswer.(string)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
correctStr, ok := question.Answer.(string)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return userStr == correctStr
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// getExplanation 获取答案解析
|
||||||
|
func getExplanation(questionID int) string {
|
||||||
|
explanations := map[int]string{
|
||||||
|
1: "根据国家保密局规定,涉密信息系统集成资质分为甲级、乙级、丙级三个等级。",
|
||||||
|
2: "涉密信息系统集成资质由国家保密局认证管理,负责资质的审批和监督。",
|
||||||
|
3: "涉密信息系统集成资质证书有效期为3年,有效期满需要重新申请认证。",
|
||||||
|
4: "涉密人员管理包括保密教育培训、保密协议签订、离岗离职审查和保密审查等内容。",
|
||||||
|
5: "涉密信息系统集成单位应当具有独立法人资格、固定办公场所、保密管理制度和保密管理人员。",
|
||||||
|
6: "涉密载体管理包括登记标识、使用保管、复制传递、维修销毁等全生命周期管理。",
|
||||||
|
7: "涉密信息系统集成资质单位只能承担本单位资质等级及以下的涉密信息系统集成业务,不得超越资质等级承揽项目。",
|
||||||
|
8: "保密要害部门部位人员关系到国家秘密安全,必须经过严格的保密审查才能上岗。",
|
||||||
|
9: "涉密人员离岗离职后需要经过脱密期管理,期间不得擅自出境,防止泄露国家秘密。",
|
||||||
|
10: "涉密信息系统集成资质等级包括甲级、乙级、丙级三个等级,甲级最高,丙级最低。",
|
||||||
|
11: "国家秘密密级分为绝密、机密、秘密三级,绝密级最高,秘密级最低。",
|
||||||
|
12: "涉密人员上岗前必须经过保密教育培训,提高保密意识,并签订保密承诺书。",
|
||||||
|
13: "涉密场所应当采取物理防护、技术防护等措施,防止国家秘密泄露。",
|
||||||
|
14: "涉密信息系统应当按照国家保密标准要求进行分级保护,确保信息安全。",
|
||||||
|
15: "根据《保密法》规定,涉密人员脱密期最长不超过3年。",
|
||||||
|
16: "涉密计算机及移动存储介质应当按照所存储信息的最高密级粘贴密级标识。",
|
||||||
|
17: "甲级资质单位可以承担绝密级、机密级和秘密级的涉密信息系统集成业务。",
|
||||||
|
18: "涉密载体的复制应当经过审批并进行详细登记,防止失控泄密。",
|
||||||
|
19: "涉密会议场所应当采取信号屏蔽、安全检查等保密防护措施。",
|
||||||
|
20: "涉密业务不得分包给非资质单位,防止国家秘密泄露。",
|
||||||
|
}
|
||||||
|
return explanations[questionID]
|
||||||
|
}
|
||||||
242
internal/handlers/test_data.go
Normal file
242
internal/handlers/test_data.go
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import "ankao/internal/models"
|
||||||
|
|
||||||
|
// GetTestQuestions 获取测试题目数据 - 涉密信息系统集成资质保密知识
|
||||||
|
func GetTestQuestions() []models.Question {
|
||||||
|
return []models.Question{
|
||||||
|
// 单选题 - 涉密信息系统集成资质相关
|
||||||
|
{
|
||||||
|
ID: 1,
|
||||||
|
Type: models.SingleChoice,
|
||||||
|
Content: "一切国家机关、武装力量、政党、社会团体、()都有保守国家秘密的义务",
|
||||||
|
Options: []models.Option{
|
||||||
|
{Key: "A", Value: "国家公务员"},
|
||||||
|
{Key: "B", Value: "共产党员"},
|
||||||
|
{Key: "C", Value: "企业事业单位和公民"},
|
||||||
|
},
|
||||||
|
Answer: "C",
|
||||||
|
Category: "资质等级",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 2,
|
||||||
|
Type: models.SingleChoice,
|
||||||
|
Content: "涉密信息系统集成资质由哪个部门认证管理?",
|
||||||
|
Options: []models.Option{
|
||||||
|
{Key: "A", Value: "工信部"},
|
||||||
|
{Key: "B", Value: "国家保密局"},
|
||||||
|
{Key: "C", Value: "公安部"},
|
||||||
|
{Key: "D", Value: "网信办"},
|
||||||
|
},
|
||||||
|
Answer: "B",
|
||||||
|
Category: "资质管理",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 3,
|
||||||
|
Type: models.SingleChoice,
|
||||||
|
Content: "涉密信息系统集成资质有效期为几年?",
|
||||||
|
Options: []models.Option{
|
||||||
|
{Key: "A", Value: "1年"},
|
||||||
|
{Key: "B", Value: "2年"},
|
||||||
|
{Key: "C", Value: "3年"},
|
||||||
|
{Key: "D", Value: "5年"},
|
||||||
|
},
|
||||||
|
Answer: "C",
|
||||||
|
Category: "资质管理",
|
||||||
|
},
|
||||||
|
|
||||||
|
// 多选题 - 涉密保密管理相关
|
||||||
|
{
|
||||||
|
ID: 4,
|
||||||
|
Type: models.MultipleChoice,
|
||||||
|
Content: "以下哪些属于涉密人员管理的内容?",
|
||||||
|
Options: []models.Option{
|
||||||
|
{Key: "A", Value: "保密教育培训"},
|
||||||
|
{Key: "B", Value: "保密协议签订"},
|
||||||
|
{Key: "C", Value: "离岗离职审查"},
|
||||||
|
{Key: "D", Value: "保密审查"},
|
||||||
|
},
|
||||||
|
Answer: []string{"A", "B", "C", "D"},
|
||||||
|
Category: "保密管理",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 5,
|
||||||
|
Type: models.MultipleChoice,
|
||||||
|
Content: "涉密信息系统集成单位应具备哪些基本条件?",
|
||||||
|
Options: []models.Option{
|
||||||
|
{Key: "A", Value: "具有独立法人资格"},
|
||||||
|
{Key: "B", Value: "具有固定的办公场所"},
|
||||||
|
{Key: "C", Value: "建立保密管理制度"},
|
||||||
|
{Key: "D", Value: "配备保密管理人员"},
|
||||||
|
},
|
||||||
|
Answer: []string{"A", "B", "C", "D"},
|
||||||
|
Category: "资质条件",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 6,
|
||||||
|
Type: models.MultipleChoice,
|
||||||
|
Content: "涉密载体管理包括哪些方面?",
|
||||||
|
Options: []models.Option{
|
||||||
|
{Key: "A", Value: "登记标识"},
|
||||||
|
{Key: "B", Value: "使用保管"},
|
||||||
|
{Key: "C", Value: "复制传递"},
|
||||||
|
{Key: "D", Value: "维修销毁"},
|
||||||
|
},
|
||||||
|
Answer: []string{"A", "B", "C", "D"},
|
||||||
|
Category: "保密管理",
|
||||||
|
},
|
||||||
|
|
||||||
|
// 判断题 - 涉密保密知识
|
||||||
|
{
|
||||||
|
ID: 7,
|
||||||
|
Type: models.TrueFalse,
|
||||||
|
Content: "涉密信息系统集成资质单位可以超越资质等级承揽项目",
|
||||||
|
Options: []models.Option{
|
||||||
|
{Key: "A", Value: "正确"},
|
||||||
|
{Key: "B", Value: "错误"},
|
||||||
|
},
|
||||||
|
Answer: "B",
|
||||||
|
Category: "资质管理",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 8,
|
||||||
|
Type: models.TrueFalse,
|
||||||
|
Content: "保密要害部门部位人员应当进行保密审查",
|
||||||
|
Options: []models.Option{
|
||||||
|
{Key: "A", Value: "正确"},
|
||||||
|
{Key: "B", Value: "错误"},
|
||||||
|
},
|
||||||
|
Answer: "A",
|
||||||
|
Category: "保密管理",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 9,
|
||||||
|
Type: models.TrueFalse,
|
||||||
|
Content: "涉密人员离岗离职实行脱密期管理,脱密期内不得擅自出境",
|
||||||
|
Options: []models.Option{
|
||||||
|
{Key: "A", Value: "正确"},
|
||||||
|
{Key: "B", Value: "错误"},
|
||||||
|
},
|
||||||
|
Answer: "A",
|
||||||
|
Category: "保密管理",
|
||||||
|
},
|
||||||
|
|
||||||
|
// 填空题 - 涉密保密知识
|
||||||
|
{
|
||||||
|
ID: 10,
|
||||||
|
Type: models.FillBlank,
|
||||||
|
Content: "涉密信息系统集成资质分为甲级、乙级、_____ 三个等级。",
|
||||||
|
Options: nil,
|
||||||
|
Answer: "丙级",
|
||||||
|
Category: "资质等级",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 11,
|
||||||
|
Type: models.FillBlank,
|
||||||
|
Content: "国家秘密的密级分为绝密、机密、_____ 三级。",
|
||||||
|
Options: nil,
|
||||||
|
Answer: "秘密",
|
||||||
|
Category: "保密知识",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 12,
|
||||||
|
Type: models.FillBlank,
|
||||||
|
Content: "涉密人员上岗前应当经过_____ 并签订保密承诺书。",
|
||||||
|
Options: nil,
|
||||||
|
Answer: "保密教育培训",
|
||||||
|
Category: "保密管理",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 13,
|
||||||
|
Type: models.FillBlank,
|
||||||
|
Content: "涉密场所应当采取_____ 措施,防止信息泄露。",
|
||||||
|
Options: nil,
|
||||||
|
Answer: "防护",
|
||||||
|
Category: "保密管理",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 14,
|
||||||
|
Type: models.FillBlank,
|
||||||
|
Content: "涉密信息系统应当按照_____ 要求分级保护。",
|
||||||
|
Options: nil,
|
||||||
|
Answer: "国家保密标准",
|
||||||
|
Category: "保密知识",
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更多单选题 - 涉密保密知识
|
||||||
|
{
|
||||||
|
ID: 15,
|
||||||
|
Type: models.SingleChoice,
|
||||||
|
Content: "涉密人员脱密期最长不超过多少年?",
|
||||||
|
Options: []models.Option{
|
||||||
|
{Key: "A", Value: "1年"},
|
||||||
|
{Key: "B", Value: "2年"},
|
||||||
|
{Key: "C", Value: "3年"},
|
||||||
|
{Key: "D", Value: "5年"},
|
||||||
|
},
|
||||||
|
Answer: "C",
|
||||||
|
Category: "保密管理",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 16,
|
||||||
|
Type: models.SingleChoice,
|
||||||
|
Content: "涉密计算机及移动存储介质应当粘贴什么标识?",
|
||||||
|
Options: []models.Option{
|
||||||
|
{Key: "A", Value: "密级标识"},
|
||||||
|
{Key: "B", Value: "警示标识"},
|
||||||
|
{Key: "C", Value: "保密标识"},
|
||||||
|
{Key: "D", Value: "专用标识"},
|
||||||
|
},
|
||||||
|
Answer: "A",
|
||||||
|
Category: "保密管理",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 17,
|
||||||
|
Type: models.SingleChoice,
|
||||||
|
Content: "甲级资质单位可以承担什么密级的涉密信息系统集成业务?",
|
||||||
|
Options: []models.Option{
|
||||||
|
{Key: "A", Value: "仅机密级"},
|
||||||
|
{Key: "B", Value: "秘密级和机密级"},
|
||||||
|
{Key: "C", Value: "绝密级、机密级和秘密级"},
|
||||||
|
{Key: "D", Value: "仅秘密级"},
|
||||||
|
},
|
||||||
|
Answer: "C",
|
||||||
|
Category: "资质等级",
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更多判断题 - 涉密保密知识
|
||||||
|
{
|
||||||
|
ID: 18,
|
||||||
|
Type: models.TrueFalse,
|
||||||
|
Content: "涉密载体的复制应当经过审批并进行登记",
|
||||||
|
Options: []models.Option{
|
||||||
|
{Key: "A", Value: "正确"},
|
||||||
|
{Key: "B", Value: "错误"},
|
||||||
|
},
|
||||||
|
Answer: "A",
|
||||||
|
Category: "保密管理",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 19,
|
||||||
|
Type: models.TrueFalse,
|
||||||
|
Content: "涉密会议场所应当采取必要的保密防护措施",
|
||||||
|
Options: []models.Option{
|
||||||
|
{Key: "A", Value: "正确"},
|
||||||
|
{Key: "B", Value: "错误"},
|
||||||
|
},
|
||||||
|
Answer: "A",
|
||||||
|
Category: "保密管理",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 20,
|
||||||
|
Type: models.TrueFalse,
|
||||||
|
Content: "涉密信息系统集成资质单位可以将涉密业务分包给非资质单位",
|
||||||
|
Options: []models.Option{
|
||||||
|
{Key: "A", Value: "正确"},
|
||||||
|
{Key: "B", Value: "错误"},
|
||||||
|
},
|
||||||
|
Answer: "B",
|
||||||
|
Category: "资质管理",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
48
internal/models/question.go
Normal file
48
internal/models/question.go
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
// QuestionType 题目类型
|
||||||
|
type QuestionType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SingleChoice QuestionType = "single" // 单选
|
||||||
|
MultipleChoice QuestionType = "multiple" // 多选
|
||||||
|
FillBlank QuestionType = "fill" // 填空
|
||||||
|
TrueFalse QuestionType = "judge" // 判断
|
||||||
|
)
|
||||||
|
|
||||||
|
// Question 题目模型
|
||||||
|
type Question struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Type QuestionType `json:"type"`
|
||||||
|
Content string `json:"content"` // 题目内容
|
||||||
|
Options []Option `json:"options"` // 选项(单选、多选、判断使用)
|
||||||
|
Answer interface{} `json:"-"` // 正确答案(不返回给前端)
|
||||||
|
Category string `json:"category"` // 分类
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option 选项
|
||||||
|
type Option struct {
|
||||||
|
Key string `json:"key"` // 选项标识 A/B/C/D
|
||||||
|
Value string `json:"value"` // 选项内容
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitAnswer 提交答案请求
|
||||||
|
type SubmitAnswer struct {
|
||||||
|
QuestionID int `json:"question_id"`
|
||||||
|
Answer interface{} `json:"answer"` // 可以是字符串、字符串数组等
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnswerResult 答案结果
|
||||||
|
type AnswerResult struct {
|
||||||
|
Correct bool `json:"correct"`
|
||||||
|
CorrectAnswer interface{} `json:"correct_answer"`
|
||||||
|
Explanation string `json:"explanation,omitempty"` // 答案解析
|
||||||
|
}
|
||||||
|
|
||||||
|
// Statistics 统计数据
|
||||||
|
type Statistics struct {
|
||||||
|
TotalQuestions int `json:"total_questions"`
|
||||||
|
AnsweredQuestions int `json:"answered_questions"`
|
||||||
|
CorrectAnswers int `json:"correct_answers"`
|
||||||
|
Accuracy float64 `json:"accuracy"`
|
||||||
|
}
|
||||||
21
main.go
21
main.go
@ -14,9 +14,24 @@ func main() {
|
|||||||
// 应用自定义中间件
|
// 应用自定义中间件
|
||||||
r.Use(middleware.Logger())
|
r.Use(middleware.Logger())
|
||||||
|
|
||||||
// 注册路由
|
// 静态文件服务
|
||||||
r.GET("/", handlers.HomeHandler)
|
r.Static("/static", "./web/static")
|
||||||
r.GET("/api/health", handlers.HealthCheckHandler)
|
r.StaticFile("/", "./web/index.html")
|
||||||
|
|
||||||
|
// API路由组
|
||||||
|
api := r.Group("/api")
|
||||||
|
{
|
||||||
|
// 健康检查
|
||||||
|
api.GET("/health", handlers.HealthCheckHandler)
|
||||||
|
|
||||||
|
// 题目相关API
|
||||||
|
api.GET("/questions", handlers.GetQuestions) // 获取题目列表
|
||||||
|
api.GET("/questions/random", handlers.GetRandomQuestion) // 获取随机题目
|
||||||
|
api.GET("/questions/:id", handlers.GetQuestionByID) // 获取指定题目
|
||||||
|
api.POST("/submit", handlers.SubmitAnswer) // 提交答案
|
||||||
|
api.GET("/statistics", handlers.GetStatistics) // 获取统计数据
|
||||||
|
api.POST("/reset", handlers.ResetProgress) // 重置进度
|
||||||
|
}
|
||||||
|
|
||||||
// 启动服务器
|
// 启动服务器
|
||||||
port := ":8080"
|
port := ":8080"
|
||||||
|
|||||||
1587
practice_question_pool.json
Normal file
1587
practice_question_pool.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,6 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
|
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
|
||||||
|
import QuestionPage from './pages/Question'
|
||||||
import Home from './pages/Home'
|
import Home from './pages/Home'
|
||||||
import About from './pages/About'
|
import About from './pages/About'
|
||||||
|
|
||||||
@ -7,7 +8,8 @@ const App: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<Router>
|
<Router>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<QuestionPage />} />
|
||||||
|
<Route path="/home" element={<Home />} />
|
||||||
<Route path="/about" element={<About />} />
|
<Route path="/about" element={<About />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Router>
|
</Router>
|
||||||
|
|||||||
32
web/src/api/question.ts
Normal file
32
web/src/api/question.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { request } from '../utils/request'
|
||||||
|
import type { Question, SubmitAnswer, AnswerResult, Statistics, ApiResponse } from '../types/question'
|
||||||
|
|
||||||
|
// 获取题目列表
|
||||||
|
export const getQuestions = (params?: { type?: string; category?: string }) => {
|
||||||
|
return request.get<ApiResponse<Question[]>>('/questions', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取随机题目
|
||||||
|
export const getRandomQuestion = () => {
|
||||||
|
return request.get<ApiResponse<Question>>('/questions/random')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取指定题目
|
||||||
|
export const getQuestionById = (id: number) => {
|
||||||
|
return request.get<ApiResponse<Question>>(`/questions/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交答案
|
||||||
|
export const submitAnswer = (data: SubmitAnswer) => {
|
||||||
|
return request.post<ApiResponse<AnswerResult>>('/submit', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取统计数据
|
||||||
|
export const getStatistics = () => {
|
||||||
|
return request.get<ApiResponse<Statistics>>('/statistics')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置进度
|
||||||
|
export const resetProgress = () => {
|
||||||
|
return request.post<ApiResponse<null>>('/reset')
|
||||||
|
}
|
||||||
194
web/src/pages/Question.css
Normal file
194
web/src/pages/Question.css
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
.question-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding-bottom: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background: #fff;
|
||||||
|
padding: 16px 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-number {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-content {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-result {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f6ffed;
|
||||||
|
border: 1px solid #b7eb8f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-result.wrong {
|
||||||
|
background: #fff2f0;
|
||||||
|
border: 1px solid #ffccc7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-result .result-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-result.correct .result-icon {
|
||||||
|
color: #52c41a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-result.wrong .result-icon {
|
||||||
|
color: #ff4d4f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-result .result-text {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-result .correct-answer {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-result .explanation {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #888;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid #e8e8e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: #fff;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
padding: 12px 0;
|
||||||
|
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn:active {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-content {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-content h2 {
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item span {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item strong {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-content {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-content h2 {
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group p {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 覆盖antd-mobile样式 */
|
||||||
|
.adm-card {
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.adm-list-item {
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adm-modal-body {
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
475
web/src/pages/Question.tsx
Normal file
475
web/src/pages/Question.tsx
Normal file
@ -0,0 +1,475 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Space,
|
||||||
|
Radio,
|
||||||
|
Checkbox,
|
||||||
|
Input,
|
||||||
|
Toast,
|
||||||
|
Dialog,
|
||||||
|
Modal,
|
||||||
|
List,
|
||||||
|
Tag,
|
||||||
|
Selector,
|
||||||
|
} from 'antd-mobile'
|
||||||
|
import {
|
||||||
|
RightOutline,
|
||||||
|
CloseOutline,
|
||||||
|
PieOutline,
|
||||||
|
UnorderedListOutline,
|
||||||
|
FilterOutline,
|
||||||
|
} from 'antd-mobile-icons'
|
||||||
|
import type { Question, AnswerResult } from '../types/question'
|
||||||
|
import * as questionApi from '../api/question'
|
||||||
|
import './Question.css'
|
||||||
|
|
||||||
|
const QuestionPage: React.FC = () => {
|
||||||
|
const [currentQuestion, setCurrentQuestion] = useState<Question | null>(null)
|
||||||
|
const [selectedAnswer, setSelectedAnswer] = useState<string | string[]>('')
|
||||||
|
const [showResult, setShowResult] = useState(false)
|
||||||
|
const [answerResult, setAnswerResult] = useState<AnswerResult | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [allQuestions, setAllQuestions] = useState<Question[]>([])
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(0)
|
||||||
|
|
||||||
|
// 统计弹窗
|
||||||
|
const [statsVisible, setStatsVisible] = useState(false)
|
||||||
|
const [statistics, setStatistics] = useState({
|
||||||
|
total_questions: 0,
|
||||||
|
answered_questions: 0,
|
||||||
|
correct_answers: 0,
|
||||||
|
accuracy: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 列表弹窗
|
||||||
|
const [listVisible, setListVisible] = useState(false)
|
||||||
|
|
||||||
|
// 筛选弹窗
|
||||||
|
const [filterVisible, setFilterVisible] = useState(false)
|
||||||
|
const [filterType, setFilterType] = useState('')
|
||||||
|
const [filterCategory, setFilterCategory] = useState('')
|
||||||
|
|
||||||
|
// 加载随机题目
|
||||||
|
const loadRandomQuestion = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await questionApi.getRandomQuestion()
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setCurrentQuestion(res.data)
|
||||||
|
setSelectedAnswer(res.data.type === 'multiple' ? [] : '')
|
||||||
|
setShowResult(false)
|
||||||
|
setAnswerResult(null)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Toast.show('加载题目失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载题目列表
|
||||||
|
const loadQuestions = async (type?: string, category?: string) => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await questionApi.getQuestions({ type, category })
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setAllQuestions(res.data)
|
||||||
|
if (res.data.length > 0) {
|
||||||
|
setCurrentQuestion(res.data[0])
|
||||||
|
setCurrentIndex(0)
|
||||||
|
setSelectedAnswer(res.data[0].type === 'multiple' ? [] : '')
|
||||||
|
setShowResult(false)
|
||||||
|
setAnswerResult(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Toast.show('加载题目列表失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载统计数据
|
||||||
|
const loadStatistics = async () => {
|
||||||
|
try {
|
||||||
|
const res = await questionApi.getStatistics()
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setStatistics(res.data)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Toast.show('加载统计失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交答案
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!currentQuestion) return
|
||||||
|
|
||||||
|
// 检查是否选择了答案
|
||||||
|
if (
|
||||||
|
(currentQuestion.type === 'multiple' && (selectedAnswer as string[]).length === 0) ||
|
||||||
|
(currentQuestion.type !== 'multiple' && !selectedAnswer)
|
||||||
|
) {
|
||||||
|
Toast.show('请选择或填写答案')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await questionApi.submitAnswer({
|
||||||
|
question_id: currentQuestion.id,
|
||||||
|
answer: selectedAnswer,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setAnswerResult(res.data)
|
||||||
|
setShowResult(true)
|
||||||
|
|
||||||
|
if (res.data.correct) {
|
||||||
|
Toast.show({ icon: 'success', content: '回答正确!' })
|
||||||
|
} else {
|
||||||
|
Toast.show({ icon: 'fail', content: '回答错误' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Toast.show('提交失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下一题
|
||||||
|
const handleNext = () => {
|
||||||
|
if (allQuestions.length > 0) {
|
||||||
|
const nextIndex = (currentIndex + 1) % allQuestions.length
|
||||||
|
setCurrentIndex(nextIndex)
|
||||||
|
setCurrentQuestion(allQuestions[nextIndex])
|
||||||
|
setSelectedAnswer(allQuestions[nextIndex].type === 'multiple' ? [] : '')
|
||||||
|
setShowResult(false)
|
||||||
|
setAnswerResult(null)
|
||||||
|
} else {
|
||||||
|
loadRandomQuestion()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择题目
|
||||||
|
const handleSelectQuestion = (question: Question, index: number) => {
|
||||||
|
setCurrentQuestion(question)
|
||||||
|
setCurrentIndex(index)
|
||||||
|
setSelectedAnswer(question.type === 'multiple' ? [] : '')
|
||||||
|
setShowResult(false)
|
||||||
|
setAnswerResult(null)
|
||||||
|
setListVisible(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应用筛选
|
||||||
|
const handleApplyFilter = () => {
|
||||||
|
loadQuestions(filterType, filterCategory)
|
||||||
|
setFilterVisible(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置进度
|
||||||
|
const handleReset = async () => {
|
||||||
|
const result = await Dialog.confirm({
|
||||||
|
content: '确定要重置答题进度吗?',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
try {
|
||||||
|
await questionApi.resetProgress()
|
||||||
|
Toast.show('重置成功')
|
||||||
|
loadStatistics()
|
||||||
|
} catch (error) {
|
||||||
|
Toast.show('重置失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
useEffect(() => {
|
||||||
|
loadRandomQuestion()
|
||||||
|
loadQuestions()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 获取题型名称
|
||||||
|
const getTypeName = (type: string) => {
|
||||||
|
const typeMap: Record<string, string> = {
|
||||||
|
single: '单选题',
|
||||||
|
multiple: '多选题',
|
||||||
|
fill: '填空题',
|
||||||
|
judge: '判断题',
|
||||||
|
}
|
||||||
|
return typeMap[type] || type
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染题目选项
|
||||||
|
const renderOptions = () => {
|
||||||
|
if (!currentQuestion) return null
|
||||||
|
|
||||||
|
if (currentQuestion.type === 'fill') {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
placeholder="请输入答案"
|
||||||
|
value={selectedAnswer as string}
|
||||||
|
onChange={(val) => setSelectedAnswer(val)}
|
||||||
|
disabled={showResult}
|
||||||
|
style={{ marginTop: 20 }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentQuestion.type === 'multiple') {
|
||||||
|
return (
|
||||||
|
<Checkbox.Group
|
||||||
|
value={selectedAnswer as string[]}
|
||||||
|
onChange={(val) => setSelectedAnswer(val as string[])}
|
||||||
|
disabled={showResult}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" style={{ width: '100%', marginTop: 20 }}>
|
||||||
|
{currentQuestion.options.map((option) => (
|
||||||
|
<Checkbox key={option.key} value={option.key} style={{ '--icon-size': '20px' }}>
|
||||||
|
<span style={{ fontSize: 16 }}>
|
||||||
|
{option.key}. {option.value}
|
||||||
|
</span>
|
||||||
|
</Checkbox>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Checkbox.Group>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单选和判断题
|
||||||
|
return (
|
||||||
|
<Radio.Group
|
||||||
|
value={selectedAnswer as string}
|
||||||
|
onChange={(val) => setSelectedAnswer(val as string)}
|
||||||
|
disabled={showResult}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" style={{ width: '100%', marginTop: 20 }}>
|
||||||
|
{currentQuestion.options.map((option) => (
|
||||||
|
<Radio key={option.key} value={option.key} style={{ '--icon-size': '20px' }}>
|
||||||
|
<span style={{ fontSize: 16 }}>
|
||||||
|
{option.key}. {option.value}
|
||||||
|
</span>
|
||||||
|
</Radio>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Radio.Group>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="question-page">
|
||||||
|
{/* 头部 */}
|
||||||
|
<div className="header">
|
||||||
|
<h1>AnKao 刷题</h1>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
fill="outline"
|
||||||
|
onClick={() => {
|
||||||
|
loadStatistics()
|
||||||
|
setStatsVisible(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PieOutline /> 统计
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 题目卡片 */}
|
||||||
|
<div className="content">
|
||||||
|
<Card>
|
||||||
|
{currentQuestion && (
|
||||||
|
<>
|
||||||
|
<div className="question-header">
|
||||||
|
<Tag color="primary">{getTypeName(currentQuestion.type)}</Tag>
|
||||||
|
<Tag color="success">{currentQuestion.category}</Tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="question-number">第 {currentQuestion.id} 题</div>
|
||||||
|
|
||||||
|
<div className="question-content">{currentQuestion.content}</div>
|
||||||
|
|
||||||
|
{renderOptions()}
|
||||||
|
|
||||||
|
{/* 答案结果 */}
|
||||||
|
{showResult && answerResult && (
|
||||||
|
<div className={`answer-result ${answerResult.correct ? 'correct' : 'wrong'}`}>
|
||||||
|
<div className="result-icon">
|
||||||
|
{answerResult.correct ? <RightOutline fontSize={24} /> : <CloseOutline fontSize={24} />}
|
||||||
|
</div>
|
||||||
|
<div className="result-text">{answerResult.correct ? '回答正确!' : '回答错误'}</div>
|
||||||
|
<div className="correct-answer">
|
||||||
|
正确答案:
|
||||||
|
{Array.isArray(answerResult.correct_answer)
|
||||||
|
? answerResult.correct_answer.join(', ')
|
||||||
|
: answerResult.correct_answer}
|
||||||
|
</div>
|
||||||
|
{answerResult.explanation && <div className="explanation">{answerResult.explanation}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 按钮 */}
|
||||||
|
<div className="button-group">
|
||||||
|
{!showResult ? (
|
||||||
|
<Button block color="primary" size="large" onClick={handleSubmit} loading={loading}>
|
||||||
|
提交答案
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button block color="primary" size="large" onClick={handleNext}>
|
||||||
|
下一题
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部导航 */}
|
||||||
|
<div className="bottom-nav">
|
||||||
|
<Button
|
||||||
|
className="nav-btn"
|
||||||
|
onClick={loadRandomQuestion}
|
||||||
|
fill="none"
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 24 }}>🎲</span>
|
||||||
|
<span style={{ fontSize: 12, marginTop: 4 }}>随机</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="nav-btn"
|
||||||
|
onClick={() => setListVisible(true)}
|
||||||
|
fill="none"
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
<UnorderedListOutline fontSize={24} />
|
||||||
|
<span style={{ fontSize: 12, marginTop: 4 }}>列表</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="nav-btn"
|
||||||
|
onClick={() => setFilterVisible(true)}
|
||||||
|
fill="none"
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
<FilterOutline fontSize={24} />
|
||||||
|
<span style={{ fontSize: 12, marginTop: 4 }}>筛选</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 统计弹窗 */}
|
||||||
|
<Modal
|
||||||
|
visible={statsVisible}
|
||||||
|
content={
|
||||||
|
<div className="stats-content">
|
||||||
|
<h2>答题统计</h2>
|
||||||
|
<div className="stat-item">
|
||||||
|
<span>题库总数</span>
|
||||||
|
<strong>{statistics.total_questions}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stat-item">
|
||||||
|
<span>已答题数</span>
|
||||||
|
<strong>{statistics.answered_questions}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stat-item">
|
||||||
|
<span>正确数</span>
|
||||||
|
<strong>{statistics.correct_answers}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stat-item">
|
||||||
|
<span>正确率</span>
|
||||||
|
<strong>{statistics.accuracy.toFixed(1)}%</strong>
|
||||||
|
</div>
|
||||||
|
<Button block color="danger" onClick={handleReset} style={{ marginTop: 20 }}>
|
||||||
|
重置进度
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
closeOnAction
|
||||||
|
onClose={() => setStatsVisible(false)}
|
||||||
|
actions={[{ key: 'close', text: '关闭' }]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 题目列表弹窗 */}
|
||||||
|
<Modal
|
||||||
|
visible={listVisible}
|
||||||
|
content={
|
||||||
|
<div>
|
||||||
|
<h2>题目列表</h2>
|
||||||
|
<List>
|
||||||
|
{allQuestions.map((q, index) => (
|
||||||
|
<List.Item
|
||||||
|
key={q.id}
|
||||||
|
onClick={() => handleSelectQuestion(q, index)}
|
||||||
|
arrow={false}
|
||||||
|
description={
|
||||||
|
<Space>
|
||||||
|
<Tag color="primary" fill="outline">
|
||||||
|
{getTypeName(q.type)}
|
||||||
|
</Tag>
|
||||||
|
<Tag color="success" fill="outline">
|
||||||
|
{q.category}
|
||||||
|
</Tag>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{q.id}. {q.content}
|
||||||
|
</List.Item>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
closeOnAction
|
||||||
|
onClose={() => setListVisible(false)}
|
||||||
|
actions={[{ key: 'close', text: '关闭' }]}
|
||||||
|
bodyStyle={{ maxHeight: '60vh', overflow: 'auto' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 筛选弹窗 */}
|
||||||
|
<Modal
|
||||||
|
visible={filterVisible}
|
||||||
|
content={
|
||||||
|
<div className="filter-content">
|
||||||
|
<h2>筛选题目</h2>
|
||||||
|
<div className="filter-group">
|
||||||
|
<p>题目类型</p>
|
||||||
|
<Selector
|
||||||
|
options={[
|
||||||
|
{ label: '全部', value: '' },
|
||||||
|
{ label: '单选题', value: 'single' },
|
||||||
|
{ label: '多选题', value: 'multiple' },
|
||||||
|
{ label: '填空题', value: 'fill' },
|
||||||
|
{ label: '判断题', value: 'judge' },
|
||||||
|
]}
|
||||||
|
value={[filterType]}
|
||||||
|
onChange={(arr) => setFilterType(arr[0] || '')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="filter-group">
|
||||||
|
<p>分类</p>
|
||||||
|
<Selector
|
||||||
|
options={[
|
||||||
|
{ label: '全部', value: '' },
|
||||||
|
{ label: 'Go语言基础', value: 'Go语言基础' },
|
||||||
|
{ label: '前端开发', value: '前端开发' },
|
||||||
|
{ label: '计算机网络', value: '计算机网络' },
|
||||||
|
{ label: '计算机基础', value: '计算机基础' },
|
||||||
|
]}
|
||||||
|
value={[filterCategory]}
|
||||||
|
onChange={(arr) => setFilterCategory(arr[0] || '')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button block color="primary" onClick={handleApplyFilter}>
|
||||||
|
应用筛选
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
closeOnAction
|
||||||
|
onClose={() => setFilterVisible(false)}
|
||||||
|
actions={[{ key: 'close', text: '关闭' }]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default QuestionPage
|
||||||
46
web/src/types/question.ts
Normal file
46
web/src/types/question.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
// 题目类型
|
||||||
|
export type QuestionType = 'single' | 'multiple' | 'fill' | 'judge'
|
||||||
|
|
||||||
|
// 选项
|
||||||
|
export interface Option {
|
||||||
|
key: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 题目
|
||||||
|
export interface Question {
|
||||||
|
id: number
|
||||||
|
type: QuestionType
|
||||||
|
content: string
|
||||||
|
options: Option[]
|
||||||
|
category: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交答案
|
||||||
|
export interface SubmitAnswer {
|
||||||
|
question_id: number
|
||||||
|
answer: string | string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 答案结果
|
||||||
|
export interface AnswerResult {
|
||||||
|
correct: boolean
|
||||||
|
correct_answer: string | string[]
|
||||||
|
explanation?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统计数据
|
||||||
|
export interface Statistics {
|
||||||
|
total_questions: number
|
||||||
|
answered_questions: number
|
||||||
|
correct_answers: number
|
||||||
|
accuracy: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// API响应
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
success: boolean
|
||||||
|
data?: T
|
||||||
|
message?: string
|
||||||
|
total?: number
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user