chore: initial import
This commit is contained in:
commit
b7e5d1964f
0
.eslintrc.cjs
Normal file
0
.eslintrc.cjs
Normal file
25
.eslintrc.json
Normal file
25
.eslintrc.json
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"root": true,
|
||||||
|
"env": {
|
||||||
|
"browser": true,
|
||||||
|
"es2020": true,
|
||||||
|
"node": true
|
||||||
|
},
|
||||||
|
"extends": [
|
||||||
|
"eslint:recommended",
|
||||||
|
"plugin:vue/vue3-recommended",
|
||||||
|
"plugin:@typescript-eslint/recommended"
|
||||||
|
],
|
||||||
|
"parser": "vue-eslint-parser",
|
||||||
|
"parserOptions": {
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"ecmaVersion": 2020,
|
||||||
|
"sourceType": "module"
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"vue/multi-word-component-names": "off",
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
33
.gitignore
vendored
Normal file
33
.gitignore
vendored
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
.pnpm-store
|
||||||
|
api/data
|
||||||
|
*.db
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
.trae
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
.vite
|
||||||
5
.npmrc
Normal file
5
.npmrc
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
registry=https://registry.npmjs.org/
|
||||||
|
store-dir=.pnpm-store
|
||||||
|
package-import-method=copy
|
||||||
|
node-linker=hoisted
|
||||||
|
|
||||||
5
.vercelignore
Normal file
5
.vercelignore
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
.pnpm-store
|
||||||
|
node_modules
|
||||||
|
api
|
||||||
|
dist
|
||||||
|
**/*.db
|
||||||
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
69
README.md
Normal file
69
README.md
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
# MySQL 查询练习网站
|
||||||
|
|
||||||
|
一个面向新手/一般/进阶的 MySQL 查询练习网站:
|
||||||
|
- 登录注册
|
||||||
|
- 首次引导(填姓名/选模块/经验/选择模拟库或导入库)
|
||||||
|
- 题库分级、在线运行 SQL、判题与进度保存
|
||||||
|
- 支持模拟数据库(内置电商库/人事库)与导入自定义数据库(上传初始化 SQL)
|
||||||
|
|
||||||
|
## 运行方式(本地开发)
|
||||||
|
|
||||||
|
### 1) 启动 MySQL(Docker)
|
||||||
|
在项目根目录执行:
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
默认连接信息:
|
||||||
|
- Host: `127.0.0.1`
|
||||||
|
- Port: `3306`
|
||||||
|
- User: `root`
|
||||||
|
- Password: `root`
|
||||||
|
|
||||||
|
### 2) 启动后端(Go + Gin)
|
||||||
|
打开一个终端:
|
||||||
|
```bash
|
||||||
|
cd api
|
||||||
|
go run .
|
||||||
|
```
|
||||||
|
|
||||||
|
后端默认端口:`http://localhost:8080`
|
||||||
|
|
||||||
|
可选环境变量:
|
||||||
|
- `PORT`(默认 `8080`)
|
||||||
|
- `JWT_SECRET`(默认 `dev-secret`)
|
||||||
|
- `SQLITE_PATH`(默认 `./data/app.db`)
|
||||||
|
- `MYSQL_HOST` `MYSQL_PORT` `MYSQL_USER` `MYSQL_PASSWORD`
|
||||||
|
|
||||||
|
### 3) 启动前端(Vite + Vue3)
|
||||||
|
打开另一个终端:
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
前端会通过 Vite 代理把 `/api` 转发到 `http://localhost:8080`。
|
||||||
|
|
||||||
|
## 部署说明(生产)
|
||||||
|
|
||||||
|
当前仓库支持:
|
||||||
|
- 前端部署到 Vercel(静态站点)
|
||||||
|
- 后端+MySQL 用 Docker Compose 部署到一台服务器(或任意支持 Docker 的环境)
|
||||||
|
|
||||||
|
### 1) 后端+MySQL(Docker Compose)
|
||||||
|
在服务器上:
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
默认会把后端暴露到 `http://<你的服务器>:8080`。
|
||||||
|
|
||||||
|
### 2) 前端(Vercel)
|
||||||
|
- 通过 Vercel 部署本仓库根目录(Vite 构建输出为 `dist`,配置见 `vercel.json`)
|
||||||
|
- 在 Vercel 项目环境变量中设置:
|
||||||
|
- `VITE_API_BASE_URL` = `http://<你的服务器>:8080`
|
||||||
|
|
||||||
|
## 常用命令
|
||||||
|
- `pnpm run check`:TypeScript 类型检查
|
||||||
|
- `pnpm run lint`:ESLint 检查
|
||||||
|
- `cd api && go test ./...`:后端单元测试
|
||||||
23
api/Dockerfile
Normal file
23
api/Dockerfile
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
FROM golang:1.22-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/api .
|
||||||
|
|
||||||
|
|
||||||
|
FROM alpine:3.20
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
RUN adduser -D -u 10001 app
|
||||||
|
USER app
|
||||||
|
|
||||||
|
COPY --from=build /out/api /app/api
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
ENV PORT=8080
|
||||||
|
|
||||||
|
CMD ["/app/api"]
|
||||||
|
|
||||||
52
api/go.mod
Normal file
52
api/go.mod
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
module query-database/api
|
||||||
|
|
||||||
|
go 1.23.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gin-contrib/cors v1.7.6
|
||||||
|
github.com/gin-gonic/gin v1.10.1
|
||||||
|
github.com/go-sql-driver/mysql v1.9.0
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
golang.org/x/crypto v0.39.0
|
||||||
|
modernc.org/sqlite v1.33.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
|
github.com/bytedance/sonic v1.13.3 // indirect
|
||||||
|
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||||
|
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
||||||
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/go-playground/validator/v10 v10.26.0 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||||
|
github.com/kr/text v0.2.0 // indirect
|
||||||
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||||
|
golang.org/x/arch v0.18.0 // indirect
|
||||||
|
golang.org/x/net v0.41.0 // indirect
|
||||||
|
golang.org/x/sys v0.33.0 // indirect
|
||||||
|
golang.org/x/text v0.26.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.6 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
||||||
|
modernc.org/libc v1.55.3 // indirect
|
||||||
|
modernc.org/mathutil v1.6.0 // indirect
|
||||||
|
modernc.org/memory v1.8.0 // indirect
|
||||||
|
modernc.org/strutil v1.2.0 // indirect
|
||||||
|
modernc.org/token v1.1.0 // indirect
|
||||||
|
)
|
||||||
142
api/go.sum
Normal file
142
api/go.sum
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
|
github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0=
|
||||||
|
github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
|
||||||
|
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||||
|
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
|
||||||
|
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||||
|
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
||||||
|
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||||
|
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
|
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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||||
|
github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
|
||||||
|
github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
|
||||||
|
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-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||||
|
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||||
|
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/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/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
|
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
|
||||||
|
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||||
|
github.com/go-sql-driver/mysql v1.9.0 h1:Y0zIbQXhQKmQgTp44Y1dp3wTXcn804QoTptLZT1vtvo=
|
||||||
|
github.com/go-sql-driver/mysql v1.9.0/go.mod h1:pDetrLJeA3oMujJuvXc8RJoasr589B6A9fwzD3QMrqw=
|
||||||
|
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/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
|
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/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||||
|
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
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/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
|
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||||
|
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||||
|
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||||
|
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||||
|
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.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
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/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||||
|
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
|
golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc=
|
||||||
|
golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||||
|
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||||
|
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||||
|
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||||
|
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||||
|
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||||
|
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||||
|
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||||
|
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||||
|
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||||
|
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||||
|
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
|
||||||
|
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
|
||||||
|
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||||
|
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
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=
|
||||||
|
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||||
|
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||||
|
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||||
|
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
|
||||||
|
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||||
|
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||||
|
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
||||||
|
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||||
|
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
|
||||||
|
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
||||||
|
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||||
|
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||||
|
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||||
|
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||||
|
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||||
|
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||||
|
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||||
|
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||||
|
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||||
|
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||||
|
modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM=
|
||||||
|
modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
|
||||||
|
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||||
|
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
|
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||||
87
api/internal/auth/auth.go
Normal file
87
api/internal/auth/auth.go
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"database/sql"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Auth struct {
|
||||||
|
secret []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(secret string) *Auth {
|
||||||
|
return &Auth{secret: []byte(secret)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Auth) Issue(userID string) (string, error) {
|
||||||
|
t := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||||
|
"sub": userID,
|
||||||
|
"iat": time.Now().Unix(),
|
||||||
|
"exp": time.Now().Add(7 * 24 * time.Hour).Unix(),
|
||||||
|
})
|
||||||
|
return t.SignedString(a.secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Auth) parseToken(token string) (string, error) {
|
||||||
|
parsed, err := jwt.Parse(token, func(t *jwt.Token) (any, error) {
|
||||||
|
if t.Method.Alg() != jwt.SigningMethodHS256.Alg() {
|
||||||
|
return nil, jwt.ErrSignatureInvalid
|
||||||
|
}
|
||||||
|
return a.secret, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !parsed.Valid {
|
||||||
|
return "", jwt.ErrTokenInvalidClaims
|
||||||
|
}
|
||||||
|
claims, ok := parsed.Claims.(jwt.MapClaims)
|
||||||
|
if !ok {
|
||||||
|
return "", jwt.ErrTokenInvalidClaims
|
||||||
|
}
|
||||||
|
sub, ok := claims["sub"].(string)
|
||||||
|
if !ok || sub == "" {
|
||||||
|
return "", jwt.ErrTokenInvalidClaims
|
||||||
|
}
|
||||||
|
return sub, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Auth) RequireAuth(sqlite *sql.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
hdr := c.GetHeader("Authorization")
|
||||||
|
if hdr == "" || !strings.HasPrefix(hdr, "Bearer ") {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"message": "未登录"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token := strings.TrimSpace(strings.TrimPrefix(hdr, "Bearer "))
|
||||||
|
userID, err := a.parseToken(token)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"message": "Token 无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var exists int
|
||||||
|
if err := sqlite.QueryRow(`SELECT COUNT(1) FROM users WHERE id = ?`, userID).Scan(&exists); err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"message": "服务异常"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if subtle.ConstantTimeEq(int32(exists), 1) != 1 {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"message": "账号不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Set("userID", userID)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func UserID(c *gin.Context) string {
|
||||||
|
v, _ := c.Get("userID")
|
||||||
|
s, _ := v.(string)
|
||||||
|
return s
|
||||||
|
}
|
||||||
52
api/internal/config/config.go
Normal file
52
api/internal/config/config.go
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Port string
|
||||||
|
JWTSecret string
|
||||||
|
SQLitePath string
|
||||||
|
|
||||||
|
MySQLHost string
|
||||||
|
MySQLPort string
|
||||||
|
MySQLUser string
|
||||||
|
MySQLPassword string
|
||||||
|
|
||||||
|
CORSOrigins string
|
||||||
|
CORSAllowAll string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() Config {
|
||||||
|
cfg := Config{
|
||||||
|
Port: os.Getenv("PORT"),
|
||||||
|
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||||
|
SQLitePath: os.Getenv("SQLITE_PATH"),
|
||||||
|
|
||||||
|
MySQLHost: os.Getenv("MYSQL_HOST"),
|
||||||
|
MySQLPort: os.Getenv("MYSQL_PORT"),
|
||||||
|
MySQLUser: os.Getenv("MYSQL_USER"),
|
||||||
|
MySQLPassword: os.Getenv("MYSQL_PASSWORD"),
|
||||||
|
|
||||||
|
CORSOrigins: os.Getenv("CORS_ORIGINS"),
|
||||||
|
CORSAllowAll: os.Getenv("CORS_ALLOW_ALL"),
|
||||||
|
}
|
||||||
|
if cfg.JWTSecret == "" {
|
||||||
|
cfg.JWTSecret = "dev-secret"
|
||||||
|
}
|
||||||
|
if cfg.SQLitePath == "" {
|
||||||
|
cfg.SQLitePath = "./data/app.db"
|
||||||
|
}
|
||||||
|
if cfg.MySQLHost == "" {
|
||||||
|
cfg.MySQLHost = "127.0.0.1"
|
||||||
|
}
|
||||||
|
if cfg.MySQLPort == "" {
|
||||||
|
cfg.MySQLPort = "3306"
|
||||||
|
}
|
||||||
|
if cfg.MySQLUser == "" {
|
||||||
|
cfg.MySQLUser = "root"
|
||||||
|
}
|
||||||
|
if cfg.MySQLPassword == "" {
|
||||||
|
cfg.MySQLPassword = "root"
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
25
api/internal/db/mysql.go
Normal file
25
api/internal/db/mysql.go
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
|
|
||||||
|
"query-database/api/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func OpenMySQL(cfg config.Config) (*sql.DB, error) {
|
||||||
|
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/?parseTime=true&multiStatements=true&charset=utf8mb4", cfg.MySQLUser, cfg.MySQLPassword, cfg.MySQLHost, cfg.MySQLPort)
|
||||||
|
db, err := sql.Open("mysql", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
db.SetMaxOpenConns(10)
|
||||||
|
db.SetConnMaxLifetime(5 * time.Minute)
|
||||||
|
if err := db.Ping(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
173
api/internal/db/sqlite.go
Normal file
173
api/internal/db/sqlite.go
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func OpenSQLite(path string) (*sql.DB, error) {
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
if dir != "." {
|
||||||
|
_ = os.MkdirAll(dir, 0o755)
|
||||||
|
}
|
||||||
|
db, err := sql.Open("sqlite", path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
db.SetMaxOpenConns(1)
|
||||||
|
db.SetConnMaxLifetime(10 * time.Minute)
|
||||||
|
if err := db.Ping(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func MigrateSQLite(db *sql.DB) error {
|
||||||
|
stmts := []string{
|
||||||
|
`CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
module_key TEXT NOT NULL DEFAULT 'shop',
|
||||||
|
experience_level TEXT NOT NULL DEFAULT 'beginner',
|
||||||
|
onboarding_completed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS user_databases (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
schema_name TEXT NOT NULL,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_user_databases_user_id ON user_databases(user_id);`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS exercises (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
level TEXT NOT NULL,
|
||||||
|
prompt TEXT NOT NULL,
|
||||||
|
answer_sql TEXT NOT NULL,
|
||||||
|
database_key TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_exercises_level ON exercises(level);`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS progress (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
exercise_id TEXT NOT NULL,
|
||||||
|
draft_sql TEXT NOT NULL DEFAULT '',
|
||||||
|
is_solved INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);`,
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS uniq_progress_user_exercise ON progress(user_id, exercise_id);`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range stmts {
|
||||||
|
if _, err := db.Exec(s); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SeedSQLite(db *sql.DB) error {
|
||||||
|
var cnt int
|
||||||
|
if err := db.QueryRow(`SELECT COUNT(1) FROM exercises`).Scan(&cnt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cnt > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
seed := []struct {
|
||||||
|
Title string
|
||||||
|
Level string
|
||||||
|
Prompt string
|
||||||
|
AnswerSQL string
|
||||||
|
DatabaseKey string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Title: "新手 1:查询所有商品名称与价格",
|
||||||
|
Level: "beginner",
|
||||||
|
Prompt: "在电商库中查询 products 表,返回 name 与 price 两列。",
|
||||||
|
AnswerSQL: "SELECT name, price FROM products ORDER BY id;",
|
||||||
|
DatabaseKey: "shop",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Title: "新手 2:筛选价格大于 100 的商品",
|
||||||
|
Level: "beginner",
|
||||||
|
Prompt: "在电商库中查询 price > 100 的商品,返回 id, name, price,并按 price 从高到低排序。",
|
||||||
|
AnswerSQL: "SELECT id, name, price FROM products WHERE price > 100 ORDER BY price DESC, id ASC;",
|
||||||
|
DatabaseKey: "shop",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Title: "一般 1:统计每个用户的订单数",
|
||||||
|
Level: "normal",
|
||||||
|
Prompt: "在电商库中统计每个用户的订单数量,返回 user_id 与 order_count,并按 order_count 从高到低排序。",
|
||||||
|
AnswerSQL: "SELECT user_id, COUNT(*) AS order_count FROM orders GROUP BY user_id ORDER BY order_count DESC, user_id ASC;",
|
||||||
|
DatabaseKey: "shop",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Title: "一般 2:查询每个订单的总金额",
|
||||||
|
Level: "normal",
|
||||||
|
Prompt: "在电商库中,计算每个订单的总金额(sum(quantity * unit_price)),返回 order_id 与 total_amount,按 order_id 排序。",
|
||||||
|
AnswerSQL: "SELECT order_id, SUM(quantity * unit_price) AS total_amount FROM order_items GROUP BY order_id ORDER BY order_id ASC;",
|
||||||
|
DatabaseKey: "shop",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Title: "进阶 1:找出下单金额最高的用户",
|
||||||
|
Level: "advanced",
|
||||||
|
Prompt: "在电商库中,计算每个用户的下单总金额,找出总金额最高的用户,返回 user_id 与 total_amount。",
|
||||||
|
AnswerSQL: "SELECT o.user_id, SUM(oi.quantity * oi.unit_price) AS total_amount FROM orders o JOIN order_items oi ON oi.order_id = o.id GROUP BY o.user_id ORDER BY total_amount DESC, o.user_id ASC LIMIT 1;",
|
||||||
|
DatabaseKey: "shop",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Title: "新手 3:查询所有员工姓名与部门",
|
||||||
|
Level: "beginner",
|
||||||
|
Prompt: "在人事库中查询 employees 与 departments,返回 employee_name 与 department_name,并按 employee_id 排序。",
|
||||||
|
AnswerSQL: "SELECT e.name AS employee_name, d.name AS department_name FROM employees e JOIN departments d ON d.id = e.department_id ORDER BY e.id ASC;",
|
||||||
|
DatabaseKey: "hr",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Title: "一般 3:统计每个部门员工数",
|
||||||
|
Level: "normal",
|
||||||
|
Prompt: "在人事库中统计每个部门的员工数,返回 department_name 与 employee_count,按 employee_count 从高到低排序。",
|
||||||
|
AnswerSQL: "SELECT d.name AS department_name, COUNT(e.id) AS employee_count FROM departments d LEFT JOIN employees e ON e.department_id = d.id GROUP BY d.id ORDER BY employee_count DESC, d.id ASC;",
|
||||||
|
DatabaseKey: "hr",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Title: "进阶 2:找出每个部门工资最高的员工",
|
||||||
|
Level: "advanced",
|
||||||
|
Prompt: "在人事库中,找出每个部门工资最高的员工,返回 department_name, employee_name, salary。",
|
||||||
|
AnswerSQL: "SELECT d.name AS department_name, e.name AS employee_name, e.salary FROM departments d JOIN employees e ON e.department_id = d.id WHERE e.salary = (SELECT MAX(salary) FROM employees e2 WHERE e2.department_id = d.id) ORDER BY d.id ASC, e.id ASC;",
|
||||||
|
DatabaseKey: "hr",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range seed {
|
||||||
|
id := uuid.NewString()
|
||||||
|
if _, err := db.Exec(
|
||||||
|
`INSERT INTO exercises (id, title, level, prompt, answer_sql, database_key, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
id,
|
||||||
|
s.Title,
|
||||||
|
s.Level,
|
||||||
|
s.Prompt,
|
||||||
|
s.AnswerSQL,
|
||||||
|
s.DatabaseKey,
|
||||||
|
now,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
92
api/internal/handlers/auth.go
Normal file
92
api/internal/handlers/auth.go
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type authReq struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) Register(c *gin.Context) {
|
||||||
|
var req authReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"message": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
email := strings.TrimSpace(strings.ToLower(req.Email))
|
||||||
|
if email == "" || len(req.Password) < 6 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"message": "邮箱或密码不合法"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"message": "服务异常"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := uuid.NewString()
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
_, err = h.sqlite.Exec(
|
||||||
|
`INSERT INTO users (id, email, password_hash, name, module_key, experience_level, onboarding_completed, created_at)
|
||||||
|
VALUES (?, ?, ?, '', 'shop', 'beginner', 0, ?)`,
|
||||||
|
id,
|
||||||
|
email,
|
||||||
|
string(hash),
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"message": "该邮箱已注册"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token, err := h.auth.Issue(id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"message": "服务异常"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"token": token})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) Login(c *gin.Context) {
|
||||||
|
var req authReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"message": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
email := strings.TrimSpace(strings.ToLower(req.Email))
|
||||||
|
if email == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"message": "邮箱或密码不合法"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var id string
|
||||||
|
var hash string
|
||||||
|
err := h.sqlite.QueryRow(`SELECT id, password_hash FROM users WHERE email = ?`, email).Scan(&id, &hash)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"message": "账号或密码错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"message": "服务异常"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)) != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"message": "账号或密码错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token, err := h.auth.Issue(id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"message": "服务异常"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"token": token})
|
||||||
|
}
|
||||||
296
api/internal/handlers/databases.go
Normal file
296
api/internal/handlers/databases.go
Normal file
@ -0,0 +1,296 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"query-database/api/internal/auth"
|
||||||
|
"query-database/api/internal/mockdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type userDatabaseItem struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
IsActive bool `json:"isActive"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) ListMockDatabases(c *gin.Context) {
|
||||||
|
ms := mockdata.List()
|
||||||
|
out := make([]gin.H, 0, len(ms))
|
||||||
|
for _, m := range ms {
|
||||||
|
out = append(out, gin.H{"key": m.Key, "name": m.Name, "description": m.Description})
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) ListUserDatabases(c *gin.Context) {
|
||||||
|
userID := auth.UserID(c)
|
||||||
|
rows, err := h.sqlite.Query(
|
||||||
|
`SELECT id, name, source, is_active FROM user_databases WHERE user_id = ? ORDER BY created_at DESC`,
|
||||||
|
userID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"message": "服务异常"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := make([]userDatabaseItem, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var id, name, source string
|
||||||
|
var active int
|
||||||
|
if err := rows.Scan(&id, &name, &source, &active); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"message": "服务异常"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out = append(out, userDatabaseItem{ID: id, Name: name, Source: source, IsActive: active == 1})
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
type activateReq struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) ActivateUserDatabase(c *gin.Context) {
|
||||||
|
userID := auth.UserID(c)
|
||||||
|
var req activateReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"message": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.ID) == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"message": "id 不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tx, err := h.sqlite.Begin()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"message": "服务异常"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
if _, err := tx.Exec(`UPDATE user_databases SET is_active = 0 WHERE user_id = ?`, userID); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"message": "服务异常"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := tx.Exec(`UPDATE user_databases SET is_active = 1 WHERE id = ? AND user_id = ?`, req.ID, userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"message": "服务异常"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"message": "数据库不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"message": "服务异常"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
type activateMockReq struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) ActivateMockDatabase(c *gin.Context) {
|
||||||
|
userID := auth.UserID(c)
|
||||||
|
var req activateMockReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"message": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := strings.TrimSpace(req.Key)
|
||||||
|
if key != "shop" && key != "hr" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"message": "key 不合法"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.activateMockForUser(userID, key, time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"message": "设置失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) activateMockForUser(userID string, key string, now string) error {
|
||||||
|
ms := mockdata.List()
|
||||||
|
var picked *mockdata.MockDatabase
|
||||||
|
for i := range ms {
|
||||||
|
if ms[i].Key == key {
|
||||||
|
picked = &ms[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if picked == nil {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
tx, err := h.sqlite.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
if _, err := tx.Exec(`UPDATE user_databases SET is_active = 0 WHERE user_id = ?`, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var existingID string
|
||||||
|
err = tx.QueryRow(
|
||||||
|
`SELECT id FROM user_databases WHERE user_id = ? AND source = 'mock' AND schema_name = ? LIMIT 1`,
|
||||||
|
userID,
|
||||||
|
picked.SchemaName,
|
||||||
|
).Scan(&existingID)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
newID := uuid.NewString()
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`INSERT INTO user_databases (id, user_id, name, source, schema_name, is_active, created_at) VALUES (?, ?, ?, 'mock', ?, 1, ?)`,
|
||||||
|
newID,
|
||||||
|
userID,
|
||||||
|
picked.Name,
|
||||||
|
picked.SchemaName,
|
||||||
|
now,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if _, err := tx.Exec(`UPDATE user_databases SET is_active = 1 WHERE id = ?`, existingID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) ImportDatabase(c *gin.Context) {
|
||||||
|
userID := auth.UserID(c)
|
||||||
|
name := strings.TrimSpace(c.PostForm("name"))
|
||||||
|
if name == "" {
|
||||||
|
name = "我的数据库"
|
||||||
|
}
|
||||||
|
file, hdr, err := c.Request.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"message": "请选择文件"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
if strings.ToLower(filepath.Ext(hdr.Filename)) != ".sql" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"message": "仅支持 .sql 文件"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
if _, err := io.CopyN(buf, file, 2<<20); err != nil && err != io.EOF {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"message": "读取失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sqlText := buf.String()
|
||||||
|
if strings.TrimSpace(sqlText) == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"message": "SQL 为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
schema := makeSchemaName(userID)
|
||||||
|
if err := createSchemaAndInit(h.mysql, schema, sqlText); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"message": "导入失败:" + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
tx, err := h.sqlite.Begin()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"message": "服务异常"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
if _, err := tx.Exec(`UPDATE user_databases SET is_active = 0 WHERE user_id = ?`, userID); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"message": "服务异常"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := uuid.NewString()
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`INSERT INTO user_databases (id, user_id, name, source, schema_name, is_active, created_at)
|
||||||
|
VALUES (?, ?, ?, 'imported', ?, 1, ?)`,
|
||||||
|
id,
|
||||||
|
userID,
|
||||||
|
name,
|
||||||
|
schema,
|
||||||
|
now,
|
||||||
|
); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"message": "保存失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"message": "服务异常"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
var reSchemaSan = regexp.MustCompile(`[^a-z0-9_]+`)
|
||||||
|
|
||||||
|
func makeSchemaName(userID string) string {
|
||||||
|
base := strings.ToLower(userID)
|
||||||
|
base = strings.ReplaceAll(base, "-", "_")
|
||||||
|
base = reSchemaSan.ReplaceAllString(base, "")
|
||||||
|
if len(base) > 12 {
|
||||||
|
base = base[:12]
|
||||||
|
}
|
||||||
|
return "udb_" + base + "_" + strings.ReplaceAll(uuid.NewString(), "-", "")[:8]
|
||||||
|
}
|
||||||
|
|
||||||
|
func quoteIdent(s string) string {
|
||||||
|
return "`" + strings.ReplaceAll(s, "`", "``") + "`"
|
||||||
|
}
|
||||||
|
|
||||||
|
func createSchemaAndInit(mysql *sql.DB, schema string, initSQL string) error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
conn, err := mysql.Conn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
if _, err := conn.ExecContext(ctx, "CREATE DATABASE IF NOT EXISTS "+quoteIdent(schema)+" CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := conn.ExecContext(ctx, "USE "+quoteIdent(schema)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stmts := splitStatements(initSQL)
|
||||||
|
for _, s := range stmts {
|
||||||
|
if _, err := conn.ExecContext(ctx, s); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitStatements(sqlText string) []string {
|
||||||
|
parts := strings.Split(sqlText, ";")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
s := strings.TrimSpace(p)
|
||||||
|
if s == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
99
api/internal/handlers/exercises.go
Normal file
99
api/internal/handlers/exercises.go
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"query-database/api/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type exerciseSummary struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Level string `json:"level"`
|
||||||
|
Database string `json:"databaseKey"`
|
||||||
|
IsSolved bool `json:"isSolved"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) ListExercises(c *gin.Context) {
|
||||||
|
userID := auth.UserID(c)
|
||||||
|
level := strings.TrimSpace(c.Query("level"))
|
||||||
|
if level == "" {
|
||||||
|
level = "beginner"
|
||||||
|
}
|
||||||
|
if level != "beginner" && level != "normal" && level != "advanced" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"message": "level 不合法"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
moduleKey := strings.TrimSpace(c.Query("moduleKey"))
|
||||||
|
if moduleKey == "" {
|
||||||
|
_ = h.sqlite.QueryRow(`SELECT module_key FROM users WHERE id = ?`, userID).Scan(&moduleKey)
|
||||||
|
}
|
||||||
|
if moduleKey != "shop" && moduleKey != "hr" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"message": "moduleKey 不合法"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := h.sqlite.Query(
|
||||||
|
`SELECT e.id, e.title, e.level, e.database_key,
|
||||||
|
COALESCE(p.is_solved, 0) AS is_solved
|
||||||
|
FROM exercises e
|
||||||
|
LEFT JOIN progress p ON p.exercise_id = e.id AND p.user_id = ?
|
||||||
|
WHERE e.level = ? AND e.database_key = ?
|
||||||
|
ORDER BY e.created_at ASC`,
|
||||||
|
userID,
|
||||||
|
level,
|
||||||
|
moduleKey,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"message": "服务异常"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := make([]exerciseSummary, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var id, title, lvl, dbKey string
|
||||||
|
var solved int
|
||||||
|
if err := rows.Scan(&id, &title, &lvl, &dbKey, &solved); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"message": "服务异常"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out = append(out, exerciseSummary{ID: id, Title: title, Level: lvl, Database: dbKey, IsSolved: solved == 1})
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
type exerciseDetail struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Level string `json:"level"`
|
||||||
|
Prompt string `json:"prompt"`
|
||||||
|
Database string `json:"databaseKey"`
|
||||||
|
IsSolved bool `json:"isSolved"`
|
||||||
|
DraftSQL string `json:"draftSql"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) GetExercise(c *gin.Context) {
|
||||||
|
userID := auth.UserID(c)
|
||||||
|
id := c.Param("id")
|
||||||
|
var title, level, prompt, dbKey string
|
||||||
|
if err := h.sqlite.QueryRow(`SELECT title, level, prompt, database_key FROM exercises WHERE id = ?`, id).Scan(&title, &level, &prompt, &dbKey); err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"message": "题目不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var draft string
|
||||||
|
var solved int
|
||||||
|
_ = h.sqlite.QueryRow(
|
||||||
|
`SELECT draft_sql, is_solved FROM progress WHERE user_id = ? AND exercise_id = ?`,
|
||||||
|
userID,
|
||||||
|
id,
|
||||||
|
).Scan(&draft, &solved)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, exerciseDetail{
|
||||||
|
ID: id, Title: title, Level: level, Prompt: prompt, Database: dbKey, IsSolved: solved == 1, DraftSQL: draft,
|
||||||
|
})
|
||||||
|
}
|
||||||
26
api/internal/handlers/handlers.go
Normal file
26
api/internal/handlers/handlers.go
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
"query-database/api/internal/auth"
|
||||||
|
"query-database/api/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Deps struct {
|
||||||
|
Cfg config.Config
|
||||||
|
Auth *auth.Auth
|
||||||
|
SQLite *sql.DB
|
||||||
|
MySQL *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
type Handlers struct {
|
||||||
|
cfg config.Config
|
||||||
|
auth *auth.Auth
|
||||||
|
sqlite *sql.DB
|
||||||
|
mysql *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(d Deps) *Handlers {
|
||||||
|
return &Handlers{cfg: d.Cfg, auth: d.Auth, sqlite: d.SQLite, mysql: d.MySQL}
|
||||||
|
}
|
||||||
81
api/internal/handlers/me.go
Normal file
81
api/internal/handlers/me.go
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"query-database/api/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handlers) Me(c *gin.Context) {
|
||||||
|
userID := auth.UserID(c)
|
||||||
|
var email, name, moduleKey, exp string
|
||||||
|
var onboarding int
|
||||||
|
if err := h.sqlite.QueryRow(
|
||||||
|
`SELECT email, name, module_key, experience_level, onboarding_completed FROM users WHERE id = ?`,
|
||||||
|
userID,
|
||||||
|
).Scan(&email, &name, &moduleKey, &exp, &onboarding); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"message": "服务异常"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
active, err := h.getActiveDatabase(userID)
|
||||||
|
if err != nil {
|
||||||
|
if onboarding == 1 {
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
_ = h.activateMockForUser(userID, moduleKey, now)
|
||||||
|
active, _ = h.getActiveDatabase(userID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var activeDBID any = nil
|
||||||
|
var activeDB any = nil
|
||||||
|
if active != nil {
|
||||||
|
activeDBID = active.ID
|
||||||
|
activeDB = gin.H{
|
||||||
|
"id": active.ID,
|
||||||
|
"name": active.Name,
|
||||||
|
"source": active.Source,
|
||||||
|
"schemaName": active.SchemaName,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"id": userID,
|
||||||
|
"email": email,
|
||||||
|
"name": name,
|
||||||
|
"moduleKey": moduleKey,
|
||||||
|
"experienceLevel": exp,
|
||||||
|
"onboardingCompleted": onboarding == 1,
|
||||||
|
"activeDatabaseId": activeDBID,
|
||||||
|
"activeDatabase": activeDB,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type activeDatabase struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Source string
|
||||||
|
SchemaName string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) getActiveDatabase(userID string) (*activeDatabase, error) {
|
||||||
|
row := h.sqlite.QueryRow(
|
||||||
|
`SELECT id, name, source, schema_name
|
||||||
|
FROM user_databases
|
||||||
|
WHERE user_id = ? AND is_active = 1
|
||||||
|
LIMIT 1`,
|
||||||
|
userID,
|
||||||
|
)
|
||||||
|
var id, name, source, schema string
|
||||||
|
if err := row.Scan(&id, &name, &source, &schema); err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &activeDatabase{ID: id, Name: name, Source: source, SchemaName: schema}, nil
|
||||||
|
}
|
||||||
19
api/internal/handlers/module.go
Normal file
19
api/internal/handlers/module.go
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import "query-database/api/internal/mockdata"
|
||||||
|
|
||||||
|
type moduleDatabase struct {
|
||||||
|
Key string
|
||||||
|
Name string
|
||||||
|
SchemaName string
|
||||||
|
}
|
||||||
|
|
||||||
|
func moduleDatabaseByKey(key string) (*moduleDatabase, bool) {
|
||||||
|
ms := mockdata.List()
|
||||||
|
for i := range ms {
|
||||||
|
if ms[i].Key == key {
|
||||||
|
return &moduleDatabase{Key: ms[i].Key, Name: ms[i].Name, SchemaName: ms[i].SchemaName}, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
94
api/internal/handlers/module_switch.go
Normal file
94
api/internal/handlers/module_switch.go
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"query-database/api/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type switchModuleReq struct {
|
||||||
|
ModuleKey string `json:"moduleKey"`
|
||||||
|
ActivateRecommendedMock *bool `json:"activateRecommendedMock"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) SwitchModule(c *gin.Context) {
|
||||||
|
userID := auth.UserID(c)
|
||||||
|
var req switchModuleReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"message": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newKey := strings.TrimSpace(req.ModuleKey)
|
||||||
|
if newKey != "shop" && newKey != "hr" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"message": "模块不合法"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var prevKey string
|
||||||
|
if err := h.sqlite.QueryRow(`SELECT module_key FROM users WHERE id = ?`, userID).Scan(&prevKey); err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"message": "未登录"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"message": "服务异常"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
before, _ := h.getActiveDatabase(userID)
|
||||||
|
|
||||||
|
if _, err := h.sqlite.Exec(`UPDATE users SET module_key = ? WHERE id = ?`, newKey, userID); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"message": "服务异常"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldActivate := false
|
||||||
|
if req.ActivateRecommendedMock != nil {
|
||||||
|
shouldActivate = *req.ActivateRecommendedMock
|
||||||
|
} else {
|
||||||
|
if before == nil || before.Source == "mock" {
|
||||||
|
shouldActivate = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
didActivate := false
|
||||||
|
if shouldActivate {
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
if err := h.activateMockForUser(userID, newKey, now); err != nil {
|
||||||
|
_, _ = h.sqlite.Exec(`UPDATE users SET module_key = ? WHERE id = ?`, prevKey, userID)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"message": "设置数据库失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
didActivate = true
|
||||||
|
}
|
||||||
|
|
||||||
|
after, _ := h.getActiveDatabase(userID)
|
||||||
|
md, _ := moduleDatabaseByKey(newKey)
|
||||||
|
|
||||||
|
var beforeDB any = nil
|
||||||
|
if before != nil {
|
||||||
|
beforeDB = gin.H{"id": before.ID, "name": before.Name, "source": before.Source, "schemaName": before.SchemaName}
|
||||||
|
}
|
||||||
|
var afterDB any = nil
|
||||||
|
if after != nil {
|
||||||
|
afterDB = gin.H{"id": after.ID, "name": after.Name, "source": after.Source, "schemaName": after.SchemaName}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := gin.H{
|
||||||
|
"previousModuleKey": prevKey,
|
||||||
|
"moduleKey": newKey,
|
||||||
|
"didActivateRecommendedMock": didActivate,
|
||||||
|
"activeDatabaseBefore": beforeDB,
|
||||||
|
"activeDatabaseAfter": afterDB,
|
||||||
|
}
|
||||||
|
if md != nil {
|
||||||
|
out["recommendedMock"] = gin.H{"key": md.Key, "name": md.Name, "schemaName": md.SchemaName}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
74
api/internal/handlers/onboarding.go
Normal file
74
api/internal/handlers/onboarding.go
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"query-database/api/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type onboardingReq struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
ModuleKey string `json:"moduleKey"`
|
||||||
|
ExperienceLevel string `json:"experienceLevel"`
|
||||||
|
DBMode string `json:"dbMode"`
|
||||||
|
MockDBKey *string `json:"mockDbKey"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) CompleteOnboarding(c *gin.Context) {
|
||||||
|
userID := auth.UserID(c)
|
||||||
|
var req onboardingReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"message": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(req.Name)
|
||||||
|
if name == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"message": "请填写姓名"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.ModuleKey != "shop" && req.ModuleKey != "hr" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"message": "模块不合法"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.ExperienceLevel != "beginner" && req.ExperienceLevel != "normal" && req.ExperienceLevel != "advanced" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"message": "经验不合法"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.DBMode != "mock" && req.DBMode != "import" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"message": "数据库模式不合法"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := h.sqlite.Exec(
|
||||||
|
`UPDATE users SET name = ?, module_key = ?, experience_level = ?, onboarding_completed = 1 WHERE id = ?`,
|
||||||
|
name,
|
||||||
|
req.ModuleKey,
|
||||||
|
req.ExperienceLevel,
|
||||||
|
userID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"message": "服务异常"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.DBMode == "mock" {
|
||||||
|
key := req.ModuleKey
|
||||||
|
if req.MockDBKey != nil {
|
||||||
|
key = *req.MockDBKey
|
||||||
|
}
|
||||||
|
if key != "shop" && key != "hr" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"message": "模拟库不合法"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.activateMockForUser(userID, key, time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"message": "设置数据库失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
58
api/internal/handlers/progress.go
Normal file
58
api/internal/handlers/progress.go
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"query-database/api/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type upsertProgressReq struct {
|
||||||
|
ExerciseID string `json:"exerciseId"`
|
||||||
|
DraftSQL string `json:"draftSql"`
|
||||||
|
IsSolved bool `json:"isSolved"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) UpsertProgress(c *gin.Context) {
|
||||||
|
userID := auth.UserID(c)
|
||||||
|
var req upsertProgressReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"message": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.ExerciseID) == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"message": "exerciseId 不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
solved := 0
|
||||||
|
if req.IsSolved {
|
||||||
|
solved = 1
|
||||||
|
}
|
||||||
|
_, err := h.sqlite.Exec(
|
||||||
|
`INSERT INTO progress (id, user_id, exercise_id, draft_sql, is_solved, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(user_id, exercise_id) DO UPDATE SET
|
||||||
|
draft_sql = excluded.draft_sql,
|
||||||
|
is_solved = CASE WHEN progress.is_solved = 1 THEN 1 ELSE excluded.is_solved END,
|
||||||
|
updated_at = excluded.updated_at`,
|
||||||
|
uuidOrDeterministic(userID, req.ExerciseID),
|
||||||
|
userID,
|
||||||
|
req.ExerciseID,
|
||||||
|
req.DraftSQL,
|
||||||
|
solved,
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"message": "保存失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func uuidOrDeterministic(userID string, exerciseID string) string {
|
||||||
|
return userID + ":" + exerciseID
|
||||||
|
}
|
||||||
191
api/internal/handlers/sql.go
Normal file
191
api/internal/handlers/sql.go
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"query-database/api/internal/auth"
|
||||||
|
"query-database/api/internal/judge"
|
||||||
|
)
|
||||||
|
|
||||||
|
type executeReq struct {
|
||||||
|
ExerciseID string `json:"exerciseId"`
|
||||||
|
SQL string `json:"sql"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type executeResp struct {
|
||||||
|
Ok bool `json:"ok"`
|
||||||
|
DurationMs int64 `json:"durationMs"`
|
||||||
|
Columns []string `json:"columns"`
|
||||||
|
Rows []map[string]any `json:"rows"`
|
||||||
|
Verdict string `json:"verdict"`
|
||||||
|
Hint string `json:"hint"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) ExecuteSQL(c *gin.Context) {
|
||||||
|
userID := auth.UserID(c)
|
||||||
|
var req executeReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"message": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sqlText := strings.TrimSpace(req.SQL)
|
||||||
|
if req.ExerciseID == "" || sqlText == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"message": "exerciseId 或 sql 不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := validateUserQuery(sqlText); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
active, err := h.getActiveDatabase(userID)
|
||||||
|
if err != nil || active == nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"message": "请先在数据库管理页激活一个数据库"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var answerSQL string
|
||||||
|
var dbKey string
|
||||||
|
if err := h.sqlite.QueryRow(`SELECT answer_sql, database_key FROM exercises WHERE id = ?`, req.ExerciseID).Scan(&answerSQL, &dbKey); err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"message": "题目不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mod, ok := moduleDatabaseByKey(dbKey)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"message": "题目数据库配置错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if active.Source != "mock" || active.SchemaName != mod.SchemaName {
|
||||||
|
c.JSON(http.StatusOK, executeResp{
|
||||||
|
Ok: false,
|
||||||
|
DurationMs: 0,
|
||||||
|
Columns: []string{},
|
||||||
|
Rows: []map[string]any{},
|
||||||
|
Verdict: "fail",
|
||||||
|
Hint: "当前激活数据库与本题不匹配,请切换到:" + mod.Name,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
conn, err := h.mysql.Conn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"message": "MySQL 连接失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
if _, err := conn.ExecContext(ctx, "USE "+quoteIdent(active.SchemaName)); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"message": "数据库不可用"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
started := time.Now()
|
||||||
|
userRes, err := runQuery(ctx, conn, sqlText)
|
||||||
|
dur := time.Since(started).Milliseconds()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, executeResp{Ok: false, DurationMs: dur, Verdict: "fail", Hint: "SQL 执行失败:" + err.Error(), Columns: []string{}, Rows: []map[string]any{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ansRes, err := runQuery(ctx, conn, answerSQL)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"message": "标准答案执行失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
v := judge.Compare(ansRes, userRes)
|
||||||
|
verdict := "fail"
|
||||||
|
if v.Pass {
|
||||||
|
verdict = "pass"
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, executeResp{
|
||||||
|
Ok: true,
|
||||||
|
DurationMs: dur,
|
||||||
|
Columns: userRes.Columns,
|
||||||
|
Rows: toRowMaps(userRes),
|
||||||
|
Verdict: verdict,
|
||||||
|
Hint: v.Hint,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateUserQuery(sqlText string) error {
|
||||||
|
parts := splitStatements(sqlText)
|
||||||
|
if len(parts) != 1 {
|
||||||
|
return errString("一次只能执行一条语句")
|
||||||
|
}
|
||||||
|
s := strings.ToLower(strings.TrimSpace(parts[0]))
|
||||||
|
if strings.HasPrefix(s, "select ") || s == "select" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(s, "with ") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(s, "show ") || strings.HasPrefix(s, "describe ") || strings.HasPrefix(s, "explain ") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errString("仅允许 SELECT/WITH/SHOW/DESCRIBE/EXPLAIN")
|
||||||
|
}
|
||||||
|
|
||||||
|
type errString string
|
||||||
|
|
||||||
|
func (e errString) Error() string { return string(e) }
|
||||||
|
|
||||||
|
func runQuery(ctx context.Context, conn *sql.Conn, sqlText string) (judge.QueryResult, error) {
|
||||||
|
rows, err := conn.QueryContext(ctx, sqlText)
|
||||||
|
if err != nil {
|
||||||
|
return judge.QueryResult{}, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
cols, err := rows.Columns()
|
||||||
|
if err != nil {
|
||||||
|
return judge.QueryResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := judge.QueryResult{Columns: cols, Rows: make([][]any, 0)}
|
||||||
|
for rows.Next() {
|
||||||
|
values := make([]any, len(cols))
|
||||||
|
ptrs := make([]any, len(cols))
|
||||||
|
for i := range values {
|
||||||
|
ptrs[i] = &values[i]
|
||||||
|
}
|
||||||
|
if err := rows.Scan(ptrs...); err != nil {
|
||||||
|
return judge.QueryResult{}, err
|
||||||
|
}
|
||||||
|
for i := range values {
|
||||||
|
if b, ok := values[i].([]byte); ok {
|
||||||
|
values[i] = string(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.Rows = append(out.Rows, values)
|
||||||
|
if len(out.Rows) >= 200 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toRowMaps(r judge.QueryResult) []map[string]any {
|
||||||
|
out := make([]map[string]any, 0, len(r.Rows))
|
||||||
|
for _, row := range r.Rows {
|
||||||
|
m := make(map[string]any, len(r.Columns))
|
||||||
|
for i, c := range r.Columns {
|
||||||
|
if i < len(row) {
|
||||||
|
m[c] = row[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
9
api/internal/handlers/types.go
Normal file
9
api/internal/handlers/types.go
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func normalizeEmail(s string) string {
|
||||||
|
return strings.TrimSpace(strings.ToLower(s))
|
||||||
|
}
|
||||||
38
api/internal/judge/judge.go
Normal file
38
api/internal/judge/judge.go
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
package judge
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
type QueryResult struct {
|
||||||
|
Columns []string
|
||||||
|
Rows [][]any
|
||||||
|
}
|
||||||
|
|
||||||
|
type Verdict struct {
|
||||||
|
Pass bool
|
||||||
|
Hint string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Compare(expected QueryResult, actual QueryResult) Verdict {
|
||||||
|
if len(expected.Columns) != len(actual.Columns) {
|
||||||
|
return Verdict{Pass: false, Hint: "列数量不一致"}
|
||||||
|
}
|
||||||
|
for i := range expected.Columns {
|
||||||
|
if expected.Columns[i] != actual.Columns[i] {
|
||||||
|
return Verdict{Pass: false, Hint: "列顺序或列名不一致"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(expected.Rows) != len(actual.Rows) {
|
||||||
|
return Verdict{Pass: false, Hint: "返回行数不一致"}
|
||||||
|
}
|
||||||
|
for r := range expected.Rows {
|
||||||
|
if len(expected.Rows[r]) != len(actual.Rows[r]) {
|
||||||
|
return Verdict{Pass: false, Hint: "返回列数不一致"}
|
||||||
|
}
|
||||||
|
for c := range expected.Rows[r] {
|
||||||
|
if fmt.Sprint(expected.Rows[r][c]) != fmt.Sprint(actual.Rows[r][c]) {
|
||||||
|
return Verdict{Pass: false, Hint: "结果内容不一致"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Verdict{Pass: true, Hint: "结果正确"}
|
||||||
|
}
|
||||||
30
api/internal/judge/judge_test.go
Normal file
30
api/internal/judge/judge_test.go
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
package judge
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestCompare_Pass(t *testing.T) {
|
||||||
|
exp := QueryResult{Columns: []string{"id"}, Rows: [][]any{{1}, {2}}}
|
||||||
|
act := QueryResult{Columns: []string{"id"}, Rows: [][]any{{1}, {2}}}
|
||||||
|
v := Compare(exp, act)
|
||||||
|
if !v.Pass {
|
||||||
|
t.Fatalf("expected pass")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompare_ColumnsMismatch(t *testing.T) {
|
||||||
|
exp := QueryResult{Columns: []string{"id"}, Rows: [][]any{{1}}}
|
||||||
|
act := QueryResult{Columns: []string{"ID"}, Rows: [][]any{{1}}}
|
||||||
|
v := Compare(exp, act)
|
||||||
|
if v.Pass {
|
||||||
|
t.Fatalf("expected fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompare_RowCountMismatch(t *testing.T) {
|
||||||
|
exp := QueryResult{Columns: []string{"id"}, Rows: [][]any{{1}, {2}}}
|
||||||
|
act := QueryResult{Columns: []string{"id"}, Rows: [][]any{{1}}}
|
||||||
|
v := Compare(exp, act)
|
||||||
|
if v.Pass {
|
||||||
|
t.Fatalf("expected fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
127
api/internal/mockdata/mockdata.go
Normal file
127
api/internal/mockdata/mockdata.go
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
package mockdata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MockDatabase struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
SchemaName string
|
||||||
|
InitSQL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func List() []MockDatabase {
|
||||||
|
return []MockDatabase{
|
||||||
|
{
|
||||||
|
Key: "shop",
|
||||||
|
Name: "电商库",
|
||||||
|
Description: "商品/订单/用户",
|
||||||
|
SchemaName: "mock_shop",
|
||||||
|
InitSQL: `CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INT PRIMARY KEY,
|
||||||
|
name VARCHAR(64) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS products (
|
||||||
|
id INT PRIMARY KEY,
|
||||||
|
name VARCHAR(128) NOT NULL,
|
||||||
|
price DECIMAL(10,2) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS orders (
|
||||||
|
id INT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS order_items (
|
||||||
|
id INT PRIMARY KEY,
|
||||||
|
order_id INT NOT NULL,
|
||||||
|
product_id INT NOT NULL,
|
||||||
|
quantity INT NOT NULL,
|
||||||
|
unit_price DECIMAL(10,2) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
DELETE FROM users;
|
||||||
|
DELETE FROM products;
|
||||||
|
DELETE FROM orders;
|
||||||
|
DELETE FROM order_items;
|
||||||
|
|
||||||
|
INSERT INTO users (id, name) VALUES
|
||||||
|
(1, 'Alice'),
|
||||||
|
(2, 'Bob'),
|
||||||
|
(3, 'Carol');
|
||||||
|
|
||||||
|
INSERT INTO products (id, name, price) VALUES
|
||||||
|
(1, 'Keyboard', 199.00),
|
||||||
|
(2, 'Mouse', 49.00),
|
||||||
|
(3, 'Monitor', 899.00),
|
||||||
|
(4, 'USB-C Cable', 19.00),
|
||||||
|
(5, 'Laptop Stand', 129.00);
|
||||||
|
|
||||||
|
INSERT INTO orders (id, user_id, created_at) VALUES
|
||||||
|
(1, 1, '2025-01-01 10:00:00'),
|
||||||
|
(2, 1, '2025-01-05 12:00:00'),
|
||||||
|
(3, 2, '2025-02-10 09:30:00');
|
||||||
|
|
||||||
|
INSERT INTO order_items (id, order_id, product_id, quantity, unit_price) VALUES
|
||||||
|
(1, 1, 1, 1, 199.00),
|
||||||
|
(2, 1, 2, 2, 49.00),
|
||||||
|
(3, 2, 3, 1, 899.00),
|
||||||
|
(4, 2, 4, 3, 19.00),
|
||||||
|
(5, 3, 2, 1, 49.00),
|
||||||
|
(6, 3, 5, 1, 129.00);
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "hr",
|
||||||
|
Name: "人事库",
|
||||||
|
Description: "部门/员工/薪资",
|
||||||
|
SchemaName: "mock_hr",
|
||||||
|
InitSQL: `CREATE TABLE IF NOT EXISTS departments (
|
||||||
|
id INT PRIMARY KEY,
|
||||||
|
name VARCHAR(64) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS employees (
|
||||||
|
id INT PRIMARY KEY,
|
||||||
|
department_id INT NOT NULL,
|
||||||
|
name VARCHAR(64) NOT NULL,
|
||||||
|
salary INT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
DELETE FROM employees;
|
||||||
|
DELETE FROM departments;
|
||||||
|
|
||||||
|
INSERT INTO departments (id, name) VALUES
|
||||||
|
(1, 'Engineering'),
|
||||||
|
(2, 'Sales'),
|
||||||
|
(3, 'HR');
|
||||||
|
|
||||||
|
INSERT INTO employees (id, department_id, name, salary) VALUES
|
||||||
|
(1, 1, 'Eve', 30000),
|
||||||
|
(2, 1, 'Mallory', 42000),
|
||||||
|
(3, 2, 'Trent', 28000),
|
||||||
|
(4, 2, 'Peggy', 35000),
|
||||||
|
(5, 3, 'Victor', 26000);
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func EnsureMockSchemas(mysql *sql.DB) error {
|
||||||
|
for _, m := range List() {
|
||||||
|
if _, err := mysql.Exec("CREATE DATABASE IF NOT EXISTS " + m.SchemaName + " CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := mysql.Exec("USE " + m.SchemaName); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := mysql.Exec(m.InitSQL); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
116
api/main.go
Normal file
116
api/main.go
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-contrib/cors"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"query-database/api/internal/auth"
|
||||||
|
"query-database/api/internal/config"
|
||||||
|
"query-database/api/internal/db"
|
||||||
|
"query-database/api/internal/handlers"
|
||||||
|
"query-database/api/internal/mockdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg := config.Load()
|
||||||
|
|
||||||
|
sqliteDB, err := db.OpenSQLite(cfg.SQLitePath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.MigrateSQLite(sqliteDB); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.SeedSQLite(sqliteDB); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mysqlDB, err := db.OpenMySQL(cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := mockdata.EnsureMockSchemas(mysqlDB); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
a := auth.New(cfg.JWTSecret)
|
||||||
|
h := handlers.New(handlers.Deps{
|
||||||
|
Cfg: cfg,
|
||||||
|
Auth: a,
|
||||||
|
SQLite: sqliteDB,
|
||||||
|
MySQL: mysqlDB,
|
||||||
|
})
|
||||||
|
|
||||||
|
r := gin.New()
|
||||||
|
r.Use(gin.Recovery())
|
||||||
|
r.Use(gin.LoggerWithFormatter(func(p gin.LogFormatterParams) string {
|
||||||
|
return ""
|
||||||
|
}))
|
||||||
|
|
||||||
|
allowOrigins := []string{"http://localhost:5173"}
|
||||||
|
if strings.TrimSpace(cfg.CORSAllowAll) == "1" {
|
||||||
|
allowOrigins = []string{"*"}
|
||||||
|
} else if strings.TrimSpace(cfg.CORSOrigins) != "" {
|
||||||
|
parts := strings.Split(cfg.CORSOrigins, ",")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
v := strings.TrimSpace(p)
|
||||||
|
if v != "" {
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) > 0 {
|
||||||
|
allowOrigins = out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Use(cors.New(cors.Config{
|
||||||
|
AllowOrigins: allowOrigins,
|
||||||
|
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||||
|
AllowHeaders: []string{"Authorization", "Content-Type"},
|
||||||
|
AllowCredentials: false,
|
||||||
|
MaxAge: 12 * time.Hour,
|
||||||
|
}))
|
||||||
|
|
||||||
|
r.GET("/health", func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
})
|
||||||
|
|
||||||
|
api := r.Group("/api")
|
||||||
|
api.POST("/auth/register", h.Register)
|
||||||
|
api.POST("/auth/login", h.Login)
|
||||||
|
|
||||||
|
api.GET("/mock-databases", a.RequireAuth(sqliteDB), h.ListMockDatabases)
|
||||||
|
|
||||||
|
authed := api.Group("")
|
||||||
|
authed.Use(a.RequireAuth(sqliteDB))
|
||||||
|
authed.GET("/me", h.Me)
|
||||||
|
authed.POST("/onboarding/complete", h.CompleteOnboarding)
|
||||||
|
authed.POST("/module/switch", h.SwitchModule)
|
||||||
|
|
||||||
|
authed.GET("/exercises", h.ListExercises)
|
||||||
|
authed.GET("/exercises/:id", h.GetExercise)
|
||||||
|
|
||||||
|
authed.POST("/sql/execute", h.ExecuteSQL)
|
||||||
|
authed.POST("/progress/upsert", h.UpsertProgress)
|
||||||
|
|
||||||
|
authed.GET("/user-databases", h.ListUserDatabases)
|
||||||
|
authed.POST("/user-databases/activate", h.ActivateUserDatabase)
|
||||||
|
authed.POST("/user-databases/activate-mock", h.ActivateMockDatabase)
|
||||||
|
authed.POST("/user-databases/import", h.ImportDatabase)
|
||||||
|
|
||||||
|
port := cfg.Port
|
||||||
|
if port == "" {
|
||||||
|
port = "8080"
|
||||||
|
}
|
||||||
|
if err := r.Run(":" + port); err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
35
docker-compose.prod.yml
Normal file
35
docker-compose.prod.yml
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
services:
|
||||||
|
mysql:
|
||||||
|
image: docker.m.daocloud.io/library/mysql:8.4
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: root
|
||||||
|
ports:
|
||||||
|
- "3306:3306"
|
||||||
|
command:
|
||||||
|
- "--character-set-server=utf8mb4"
|
||||||
|
- "--collation-server=utf8mb4_general_ci"
|
||||||
|
volumes:
|
||||||
|
- mysql_data:/var/lib/mysql
|
||||||
|
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: ./api
|
||||||
|
environment:
|
||||||
|
PORT: "8080"
|
||||||
|
MYSQL_HOST: mysql
|
||||||
|
MYSQL_PORT: "3306"
|
||||||
|
MYSQL_USER: root
|
||||||
|
MYSQL_PASSWORD: root
|
||||||
|
SQLITE_PATH: /app/data/app.db
|
||||||
|
CORS_ALLOW_ALL: "1"
|
||||||
|
depends_on:
|
||||||
|
- mysql
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- api_data:/app/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mysql_data:
|
||||||
|
api_data:
|
||||||
|
|
||||||
16
docker-compose.yml
Normal file
16
docker-compose.yml
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
services:
|
||||||
|
mysql:
|
||||||
|
image: docker.m.daocloud.io/library/mysql:8.4
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: root
|
||||||
|
ports:
|
||||||
|
- "3306:3306"
|
||||||
|
command:
|
||||||
|
- "--character-set-server=utf8mb4"
|
||||||
|
- "--collation-server=utf8mb4_general_ci"
|
||||||
|
volumes:
|
||||||
|
- mysql_data:/var/lib/mysql
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mysql_data:
|
||||||
|
|
||||||
122
docs/PAGE-MySQL查询练习网站-页面设计.md
Normal file
122
docs/PAGE-MySQL查询练习网站-页面设计.md
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
# MySQL 查询练习网站|页面设计说明(Desktop-first)
|
||||||
|
|
||||||
|
## 1. Global Styles(全局规范)
|
||||||
|
- 设计基调:学习型工具,信息密度中高,强调“编辑器可读性”和“结果对比清晰”。
|
||||||
|
- Design tokens(建议)
|
||||||
|
- Background: #0B1220(深色)/ #FFFFFF(浅色,可选主题开关,默认深色)
|
||||||
|
- Primary: #3B82F6;Success: #22C55E;Warning: #F59E0B;Danger: #EF4444
|
||||||
|
- Text: 主文 #E5E7EB;次文 #9CA3AF;反白 #111827
|
||||||
|
- Font:系统字体 + 等宽(编辑器/SQL 区:ui-monospace)
|
||||||
|
- 圆角:8px;卡片阴影:轻量;分割线:1px #1F2937
|
||||||
|
- 交互状态
|
||||||
|
- Button:默认/hover 提亮 6–10%;disabled 降低不透明度并禁用点击
|
||||||
|
- Link:hover 下划线;当前路由高亮
|
||||||
|
- 响应式(桌面优先)
|
||||||
|
- ≥1200px:练习页三栏;
|
||||||
|
- 768–1199px:左右栏折叠为抽屉;
|
||||||
|
- <768px:编辑器与结果上下堆叠(仍可用)。
|
||||||
|
|
||||||
|
## 2. Page: 登录/注册页
|
||||||
|
### Layout
|
||||||
|
- 居中单卡片(max-width 420px),背景使用柔和渐变或插画空白区。
|
||||||
|
|
||||||
|
### Meta Information
|
||||||
|
- title:登录|MySQL 查询练习
|
||||||
|
- description:登录后开始 MySQL 分级练习。
|
||||||
|
|
||||||
|
### Page Structure
|
||||||
|
1. 顶部:Logo + 产品一句话
|
||||||
|
2. 表单区:邮箱、密码、主按钮(登录/注册切换 Tab)
|
||||||
|
3. 辅助区:错误提示(表单下方),次按钮(忘记密码/返回首页)
|
||||||
|
|
||||||
|
### Sections & Components
|
||||||
|
- AuthTabs:登录/注册切换(同一页完成)
|
||||||
|
- FormField:输入框带校验(必填、邮箱格式)
|
||||||
|
- FeedbackBanner:展示认证失败原因(如密码错误)
|
||||||
|
|
||||||
|
## 3. Page: 首页(题目与引导)
|
||||||
|
### Layout
|
||||||
|
- 顶部导航 + 内容双列:左侧筛选,右侧题目列表(CSS Grid:280px + 1fr)。
|
||||||
|
|
||||||
|
### Meta Information
|
||||||
|
- title:题目练习|MySQL 查询练习
|
||||||
|
- description:按新手/一般/进阶选择题目进行练习。
|
||||||
|
|
||||||
|
### Page Structure
|
||||||
|
1. TopNav(全局):Logo、导航(题目/数据库管理)、用户菜单(退出/重新引导)
|
||||||
|
2. LeftPanel:分级切换、新手引导入口、状态筛选、搜索框
|
||||||
|
3. MainList:题目卡片列表(默认按模块过滤) + 分页/加载更多(任选其一)
|
||||||
|
4. ContinueCard:继续上次练习(若存在)
|
||||||
|
|
||||||
|
### 强关联展示
|
||||||
|
- TopNav 右侧展示“当前数据库”胶囊(名称 + 来源),帮助新手始终知道自己在对哪个库练习。
|
||||||
|
- 题目列表仅展示你选择模块对应的题库。
|
||||||
|
|
||||||
|
### Sections & Components
|
||||||
|
- LevelSegment:三段式切换(新手/一般/进阶)
|
||||||
|
- SearchInput:关键词筛选(标题/题干关键字)
|
||||||
|
- StatusFilter:未做/已做
|
||||||
|
- ExerciseCard:标题、难度徽标、完成状态、进入按钮
|
||||||
|
- OnboardingTrigger:按钮“新手引导”,点击后弹出引导
|
||||||
|
|
||||||
|
### 新手引导(Overlay/Modal,不单独页面)
|
||||||
|
- Step 1:提示去“数据库管理”选择模拟库
|
||||||
|
- Step 2:回到首页,选择一题进入练习
|
||||||
|
- Step 3:练习页说明“编辑-运行-看结果-判题”
|
||||||
|
- 支持:跳过/上一步/下一步/完成;完成后记录状态,避免重复打扰
|
||||||
|
|
||||||
|
## 4. Page: 练习页(SQL 编辑与判题)
|
||||||
|
### Layout
|
||||||
|
- 桌面三栏布局(CSS Grid:320px | 1fr | 420px)
|
||||||
|
- 左:题目与表结构入口
|
||||||
|
- 中:编辑器与运行
|
||||||
|
- 右:结果/判题/提示
|
||||||
|
|
||||||
|
### Meta Information
|
||||||
|
- title:做题|{题目标题}|MySQL 查询练习
|
||||||
|
- description:在线编写并运行 MySQL 查询,实时查看结果与判题。
|
||||||
|
|
||||||
|
### Page Structure
|
||||||
|
1. TopNav:返回题目列表、当前题目标题、当前激活数据库标识
|
||||||
|
2. Left(QuestionPanel)
|
||||||
|
- 题目描述(支持代码块/要点)
|
||||||
|
- 目标输出说明(字段/排序/过滤)
|
||||||
|
- 表结构入口(按钮打开 Drawer/Modal 展示表与字段,只读)
|
||||||
|
3. Center(EditorPanel)
|
||||||
|
- Monaco Editor(SQL 高亮)
|
||||||
|
- 操作区:运行、重置、保存草稿(可自动保存,仅展示状态)
|
||||||
|
- 运行状态:loading、耗时、错误(语法/权限/超时)
|
||||||
|
4. Right(ResultPanel)
|
||||||
|
- 查询结果表格(表头固定、横向滚动)
|
||||||
|
- 判题结论:通过/未通过
|
||||||
|
- 最小提示:例如缺字段、行数不一致、排序不一致(不泄露完整答案)
|
||||||
|
|
||||||
|
### Interaction & States
|
||||||
|
- Run:触发执行后禁用按钮直到返回;错误时高亮并展示可读信息
|
||||||
|
- Pass:展示绿色状态并可“下一题”
|
||||||
|
- Fail:展示差异点与建议方向(简短)
|
||||||
|
|
||||||
|
### 防误操作(库不匹配)
|
||||||
|
- 若当前激活数据库与题目要求的数据库不一致:
|
||||||
|
- 结果区展示醒目提示
|
||||||
|
- 提供“一键切换到推荐模拟库”
|
||||||
|
|
||||||
|
## 5. Page: 数据库管理页
|
||||||
|
### Layout
|
||||||
|
- 上下结构:顶部说明 + 两个卡片区(模拟库、导入库),右侧可显示“当前激活库”概览。
|
||||||
|
|
||||||
|
### Meta Information
|
||||||
|
- title:数据库管理|MySQL 查询练习
|
||||||
|
- description:选择模拟数据库或导入你的自定义数据库用于练习。
|
||||||
|
|
||||||
|
### Page Structure
|
||||||
|
1. Header:说明“练习将基于当前激活数据库运行”
|
||||||
|
2. MockDBSection:模拟库列表卡片(名称、简介、表数量、激活按钮)
|
||||||
|
3. ImportSection:上传初始化 SQL 文件(建表+数据)
|
||||||
|
4. ActiveDBSummary:当前激活库信息(名称、来源、导入时间、查看表结构)
|
||||||
|
|
||||||
|
### Sections & Components
|
||||||
|
- MockDBCard:点击设为激活,成功后 toast 提示
|
||||||
|
- UploadInitSql:拖拽上传 + 进度条 + 成功/失败提示(失败给出原因摘要)
|
||||||
|
- TablePreviewDrawer:只读展示表/字段列表(避免在此页执行 SQL,保持职责清晰)
|
||||||
|
|
||||||
51
docs/PRD-MySQL查询练习网站.md
Normal file
51
docs/PRD-MySQL查询练习网站.md
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
## 1. Product Overview
|
||||||
|
面向初学者到进阶用户的 MySQL 查询练习网站,提供分级题目、可运行的在线 SQL 练习环境与学习引导。
|
||||||
|
支持使用内置模拟数据库或导入你的自定义数据库,在真实数据结构上练习查询。
|
||||||
|
|
||||||
|
新增:通过“模块(电商/人事)”把题库与推荐数据库强绑定,降低新手决策成本。
|
||||||
|
|
||||||
|
## 2. Core Features
|
||||||
|
|
||||||
|
### 2.1 Feature Module
|
||||||
|
本产品由以下核心页面构成:
|
||||||
|
1. **登录/注册页**:账号登录注册、找回入口、登录后跳转。
|
||||||
|
2. **首页(题目与引导)**:新手引导入口/首次引导、模块与题目分级筛选、继续上次练习、当前数据库提示。
|
||||||
|
3. **练习页(SQL 编辑与判题)**:题目描述、SQL 编辑器、运行与结果展示、答案校验与提示、进度保存。
|
||||||
|
4. **数据库管理页**:选择模拟数据库、导入自定义数据库(上传初始化脚本/数据)、查看当前激活数据库。
|
||||||
|
|
||||||
|
### 2.2 Page Details
|
||||||
|
| Page Name | Module Name | Feature description |
|
||||||
|
|-----------|-------------|---------------------|
|
||||||
|
| 登录/注册页 | 登录注册 | 支持邮箱/密码注册与登录;登录成功后进入首页;展示基础校验与错误提示。 |
|
||||||
|
| 首页(题目与引导) | 新手引导 | 首次登录自动触发分步引导:如何选库、如何选题、如何运行 SQL;支持“跳过/下次再看/重新查看”。 |
|
||||||
|
| 首页(题目与引导) | 题目分级与列表 | 按**新手/一般/进阶**分级展示;支持按关键词搜索与按状态(未做/已做)筛选;点击进入练习页。 |
|
||||||
|
| 首页(题目与引导) | 模块-题库强关联 | 题目默认只展示你选择的模块;切换模块(可选能力)会同步影响题库与推荐模拟库。 |
|
||||||
|
| 首页(题目与引导) | 继续练习 | 展示你最近一次练习的题目与当前激活数据库,支持一键继续。 |
|
||||||
|
| 练习页(SQL 编辑与判题) | 题目内容 | 展示题目描述、目标输出说明(字段/排序/过滤要求)、关联的数据库/表信息入口。 |
|
||||||
|
| 练习页(SQL 编辑与判题) | SQL 编辑与运行 | 提供 SQL 编辑器;运行当前 SQL;展示运行耗时与错误信息;支持重置为上次保存草稿。 |
|
||||||
|
| 练习页(SQL 编辑与判题) | 结果与校验 | 展示查询结果表格;将结果与标准答案进行对比判定(通过/未通过);未通过时展示最小必要提示(如缺字段/行数不一致)。 |
|
||||||
|
| 练习页(SQL 编辑与判题) | 进度保存 | 自动保存你的 SQL 草稿与通过状态;返回首页后可按状态筛选。 |
|
||||||
|
| 数据库管理页 | 模拟数据库选择 | 列出内置模拟数据库(名称、简介、表数量);选择后设为当前激活数据库并影响做题环境。 |
|
||||||
|
| 数据库管理页 | 导入自定义数据库 | 上传你的初始化 SQL(建表+插入数据);显示导入进度与成功/失败原因;导入成功后可设为激活数据库。 |
|
||||||
|
| 数据库管理页 | 当前库概览 | 展示当前激活数据库的基本信息(名称、导入时间、表列表入口/只读预览)。 |
|
||||||
|
|
||||||
|
### 2.3 Module Rules(模块规则)
|
||||||
|
- 模块:`电商(shop)`、`人事(hr)`。
|
||||||
|
- 题目:每道题绑定一个 `databaseKey`(例如 `shop` 题只能在电商模拟库中判题)。
|
||||||
|
- 推荐库:当你完成首次引导且未激活任何数据库时,系统会自动激活与你模块匹配的模拟库(电商→电商库;人事→人事库)。
|
||||||
|
- 防误操作:当你在练习页运行 SQL 时,如果当前激活库与本题不匹配,系统提示“一键切换到推荐库”。
|
||||||
|
|
||||||
|
## 3. Core Process
|
||||||
|
- 账号流程:你在登录/注册页完成注册或登录后进入首页;首次登录会弹出新手引导,帮助你完成“选数据库 → 选题 → 运行 SQL → 查看结果”的最短路径。
|
||||||
|
- 练习流程:你在首页按新手/一般/进阶选择题目进入练习页;在编辑器输入 SQL 并运行;系统展示结果并进行校验;通过后记录进度,未通过给出必要提示;你可随时返回首页继续下一题。
|
||||||
|
- 数据库流程:你在数据库管理页选择一个模拟数据库作为当前练习环境,或上传初始化 SQL 导入自定义数据库;导入成功后可将其设为激活数据库,之后做题运行都基于该数据库。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A["登录/注册页"] --> B["首页(题目与引导)"]
|
||||||
|
B --> C["练习页(SQL 编辑与判题)"]
|
||||||
|
B --> D["数据库管理页"]
|
||||||
|
D --> B
|
||||||
|
C --> B
|
||||||
|
```
|
||||||
|
|
||||||
244
docs/TECH-MySQL查询练习网站-技术架构.md
Normal file
244
docs/TECH-MySQL查询练习网站-技术架构.md
Normal file
@ -0,0 +1,244 @@
|
|||||||
|
## 1.Architecture design
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
U["User Browser"] --> F["Vue Frontend Application"]
|
||||||
|
F --> S["Backend API Service"]
|
||||||
|
S --> M["MySQL 8 Database"]
|
||||||
|
S --> P["SQLite (Metadata)"]
|
||||||
|
|
||||||
|
subgraph "Frontend Layer"
|
||||||
|
F
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Backend Layer"
|
||||||
|
S
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Data Layer"
|
||||||
|
M
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Service Layer (Provided by Supabase)"
|
||||||
|
P
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2.Technology Description
|
||||||
|
- Frontend: Vue3 + Vite + TailwindCSS
|
||||||
|
- Backend: Go + Gin
|
||||||
|
- Auth: JWT(后端签发与校验)
|
||||||
|
- Metadata: SQLite(用户、题目、进度、用户数据库列表等)
|
||||||
|
- SQL Execution Engine: MySQL 8(用于模拟库与用户导入库的执行环境)
|
||||||
|
|
||||||
|
## 3.Route definitions
|
||||||
|
| Route | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| /login | 登录/注册页 |
|
||||||
|
| / | 首页:题目分级列表、新手引导、继续练习 |
|
||||||
|
| /practice/:id | 练习页:SQL 编辑、运行、结果与校验 |
|
||||||
|
| /databases | 数据库管理:选择模拟库、导入自定义库、激活当前库 |
|
||||||
|
|
||||||
|
## 4.API definitions (If it includes backend services)
|
||||||
|
### 4.1 Core Types (TypeScript)
|
||||||
|
```ts
|
||||||
|
export type Level = 'beginner' | 'normal' | 'advanced'
|
||||||
|
|
||||||
|
export type ModuleKey = 'shop' | 'hr'
|
||||||
|
|
||||||
|
export type ActiveDatabase = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
source: 'mock' | 'imported'
|
||||||
|
schemaName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExerciseSummary = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
level: Level
|
||||||
|
isSolved: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExecuteSqlRequest = {
|
||||||
|
exerciseId: string
|
||||||
|
sql: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExecuteSqlResponse = {
|
||||||
|
ok: boolean
|
||||||
|
durationMs: number
|
||||||
|
columns?: string[]
|
||||||
|
rows?: Array<Record<string, unknown>>
|
||||||
|
errorMessage?: string
|
||||||
|
verdict?: 'pass' | 'fail'
|
||||||
|
hint?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ImportDatabaseRequest = {
|
||||||
|
name: string
|
||||||
|
initSqlFileUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserDatabase = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
source: 'mock' | 'imported'
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Core API
|
||||||
|
- 获取题目列表
|
||||||
|
```
|
||||||
|
GET /api/exercises?level=beginner|normal|advanced&query=...
|
||||||
|
```
|
||||||
|
|
||||||
|
- 获取题目详情(含描述与校验配置)
|
||||||
|
```
|
||||||
|
GET /api/exercises/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
- 执行 SQL + 返回结果 + 判题
|
||||||
|
```
|
||||||
|
POST /api/sql/execute
|
||||||
|
```
|
||||||
|
Request: ExecuteSqlRequest
|
||||||
|
|
||||||
|
- 保存/更新进度(草稿 SQL、是否通过)
|
||||||
|
```
|
||||||
|
POST /api/progress/upsert
|
||||||
|
```
|
||||||
|
|
||||||
|
- 获取/切换当前激活数据库
|
||||||
|
```
|
||||||
|
GET /api/user-databases
|
||||||
|
POST /api/user-databases/activate
|
||||||
|
```
|
||||||
|
|
||||||
|
- 导入自定义数据库(先上传文件到 Storage,再触发导入)
|
||||||
|
```
|
||||||
|
POST /api/user-databases/import
|
||||||
|
```
|
||||||
|
Request: ImportDatabaseRequest
|
||||||
|
|
||||||
|
- 新手引导状态(是否已完成/已跳过)
|
||||||
|
```
|
||||||
|
GET /api/onboarding
|
||||||
|
POST /api/onboarding/complete
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5.Server architecture diagram (If it includes backend services)
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A["Client / Frontend"] --> B["API Controller"]
|
||||||
|
B --> C["Practice Service (Execute + Judge)"]
|
||||||
|
B --> D["Exercise Service"]
|
||||||
|
B --> E["Database Import Service"]
|
||||||
|
C --> F["MySQL Repository"]
|
||||||
|
D --> G["SQLite Repository (Metadata)"]
|
||||||
|
E --> F
|
||||||
|
|
||||||
|
subgraph "Server"
|
||||||
|
B
|
||||||
|
C
|
||||||
|
D
|
||||||
|
E
|
||||||
|
F
|
||||||
|
G
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6.Data model(if applicable)
|
||||||
|
### 6.1 Data model definition
|
||||||
|
(SQLite 用于题目/进度/用户库元数据;MySQL 用于实际运行 SQL 的数据面。)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
PROFILE {
|
||||||
|
uuid id
|
||||||
|
text email
|
||||||
|
timestamptz created_at
|
||||||
|
}
|
||||||
|
EXERCISE {
|
||||||
|
uuid id
|
||||||
|
text title
|
||||||
|
text level
|
||||||
|
text prompt
|
||||||
|
text answer_sql
|
||||||
|
}
|
||||||
|
USER_PROGRESS {
|
||||||
|
uuid id
|
||||||
|
uuid user_id
|
||||||
|
uuid exercise_id
|
||||||
|
text draft_sql
|
||||||
|
bool is_solved
|
||||||
|
timestamptz updated_at
|
||||||
|
}
|
||||||
|
USER_DATABASE {
|
||||||
|
uuid id
|
||||||
|
uuid user_id
|
||||||
|
text name
|
||||||
|
text source
|
||||||
|
text mysql_schema_name
|
||||||
|
bool is_active
|
||||||
|
timestamptz created_at
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Data Definition Language
|
||||||
|
Profile Table (profiles)
|
||||||
|
```
|
||||||
|
CREATE TABLE profiles (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
email TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
GRANT SELECT ON profiles TO anon;
|
||||||
|
GRANT ALL PRIVILEGES ON profiles TO authenticated;
|
||||||
|
```
|
||||||
|
|
||||||
|
Exercise Table (exercises)
|
||||||
|
```
|
||||||
|
CREATE TABLE exercises (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
level TEXT NOT NULL CHECK (level IN ('beginner','normal','advanced')),
|
||||||
|
prompt TEXT NOT NULL,
|
||||||
|
answer_sql TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
GRANT SELECT ON exercises TO anon;
|
||||||
|
GRANT ALL PRIVILEGES ON exercises TO authenticated;
|
||||||
|
```
|
||||||
|
|
||||||
|
User Progress Table (user_progress)
|
||||||
|
```
|
||||||
|
CREATE TABLE user_progress (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL,
|
||||||
|
exercise_id UUID NOT NULL,
|
||||||
|
draft_sql TEXT,
|
||||||
|
is_solved BOOLEAN DEFAULT FALSE,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_user_progress_user_id ON user_progress(user_id);
|
||||||
|
CREATE INDEX idx_user_progress_exercise_id ON user_progress(exercise_id);
|
||||||
|
GRANT SELECT ON user_progress TO anon;
|
||||||
|
GRANT ALL PRIVILEGES ON user_progress TO authenticated;
|
||||||
|
```
|
||||||
|
|
||||||
|
User Database Table (user_databases)
|
||||||
|
```
|
||||||
|
CREATE TABLE user_databases (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
source TEXT NOT NULL CHECK (source IN ('mock','imported')),
|
||||||
|
mysql_schema_name TEXT NOT NULL,
|
||||||
|
is_active BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_user_databases_user_id ON user_databases(user_id);
|
||||||
|
GRANT SELECT ON user_databases TO anon
|
||||||
|
```
|
||||||
|
|
||||||
33
eslint.config.js
Normal file
33
eslint.config.js
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import vuePlugin from 'eslint-plugin-vue'
|
||||||
|
import tsPlugin from '@typescript-eslint/eslint-plugin'
|
||||||
|
import tsParser from '@typescript-eslint/parser'
|
||||||
|
import vueParser from 'vue-eslint-parser'
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
ignores: ['dist/**', 'node_modules/**', 'api/**', 'data/**'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,vue}'],
|
||||||
|
languageOptions: {
|
||||||
|
parser: vueParser,
|
||||||
|
parserOptions: {
|
||||||
|
parser: tsParser,
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
vue: vuePlugin,
|
||||||
|
'@typescript-eslint': tsPlugin,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...(vuePlugin.configs['vue3-recommended']?.rules ?? {}),
|
||||||
|
...(tsPlugin.configs.recommended?.rules ?? {}),
|
||||||
|
'vue/multi-word-component-names': 'off',
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
24
index.html
Normal file
24
index.html
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>My Trae Project</title>
|
||||||
|
<script type="module">
|
||||||
|
if (import.meta.hot?.on) {
|
||||||
|
import.meta.hot.on('vite:error', (error) => {
|
||||||
|
if (error.err) {
|
||||||
|
console.error(
|
||||||
|
[error.err.message, error.err.frame].filter(Boolean).join('\n'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
43
package.json
Normal file
43
package.json
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"name": "query-database",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc -b && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"check": "vue-tsc -b",
|
||||||
|
"lint": "pnpm exec eslint .",
|
||||||
|
"lint:fix": "pnpm exec eslint . --fix"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-vue-next": "^0.511.0",
|
||||||
|
"tailwind-merge": "^3.3.0",
|
||||||
|
"vue": "^3.4.15",
|
||||||
|
"vue-router": "^4.2.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.15.30",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^7.0.1",
|
||||||
|
"@typescript-eslint/parser": "^7.0.1",
|
||||||
|
"@vitejs/plugin-vue": "^5.0.3",
|
||||||
|
"@vue/runtime-dom": "^3.4.15",
|
||||||
|
"@vue/tsconfig": "^0.7.0",
|
||||||
|
"autoprefixer": "^10.4.17",
|
||||||
|
"eslint": "^8.56.0",
|
||||||
|
"eslint-plugin-vue": "^9.20.1",
|
||||||
|
"vue-eslint-parser": "^9.4.3",
|
||||||
|
"postcss": "^8.4.35",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"typescript": "~5.3.3",
|
||||||
|
"unplugin-vue-dev-locator": "^1.0.0",
|
||||||
|
"vite": "^5.0.12",
|
||||||
|
"vite-plugin-trae-solo-badge": "^1.0.0",
|
||||||
|
"vue-tsc": "^1.8.27"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": ["esbuild"]
|
||||||
|
}
|
||||||
|
}
|
||||||
3018
pnpm-lock.yaml
generated
Normal file
3018
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
10
postcss.config.js
Normal file
10
postcss.config.js
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
/** WARNING: DON'T EDIT THIS FILE */
|
||||||
|
/** WARNING: DON'T EDIT THIS FILE */
|
||||||
|
/** WARNING: DON'T EDIT THIS FILE */
|
||||||
|
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
4
public/favicon.svg
Normal file
4
public/favicon.svg
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="32" height="32" fill="#0A0B0D"/>
|
||||||
|
<path d="M26.6677 23.7149H8.38057V20.6496H5.33301V8.38159H26.6677V23.7149ZM8.38057 20.6496H23.6201V11.4482H8.38057V20.6496ZM16.0011 16.0021L13.8461 18.1705L11.6913 16.0021L13.8461 13.8337L16.0011 16.0021ZM22.0963 16.0008L19.9414 18.1691L17.7865 16.0008L19.9414 13.8324L22.0963 16.0008Z" fill="#32F08C"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 453 B |
3
src/App.vue
Normal file
3
src/App.vue
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
1
src/assets/vue.svg
Normal file
1
src/assets/vue.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 496 B |
3
src/components/Empty.vue
Normal file
3
src/components/Empty.vue
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<div>empty</div>
|
||||||
|
</template>
|
||||||
167
src/components/ModuleSwitchDialog.vue
Normal file
167
src/components/ModuleSwitchDialog.vue
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { getToken, refreshMe, useAuthState } from '@/composables/useAuth'
|
||||||
|
import type { ModuleKey } from '@/lib/domain'
|
||||||
|
import { MODULES, moduleName, recommendedDatabaseNameForModule } from '@/lib/domain'
|
||||||
|
import type { HttpError } from '@/lib/http'
|
||||||
|
import { http } from '@/lib/http'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{ open: boolean }>()
|
||||||
|
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||||
|
|
||||||
|
const auth = useAuthState()
|
||||||
|
const nextModuleKey = ref<ModuleKey | null>(null)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
const currentModuleKey = computed(() => {
|
||||||
|
const k = auth.me?.moduleKey
|
||||||
|
return k === 'shop' || k === 'hr' ? (k as ModuleKey) : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeDbLabel = computed(() => {
|
||||||
|
const adb = auth.me?.activeDatabase
|
||||||
|
if (!adb) return '未选择数据库'
|
||||||
|
return `${adb.name}${adb.source === 'imported' ? '(导入)' : '(模拟)'}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const recommendedMockKey = computed(() => {
|
||||||
|
const k = nextModuleKey.value ?? currentModuleKey.value
|
||||||
|
return k
|
||||||
|
})
|
||||||
|
|
||||||
|
const recommendedDbName = computed(() => recommendedDatabaseNameForModule(recommendedMockKey.value))
|
||||||
|
|
||||||
|
const isImportedActiveDb = computed(() => {
|
||||||
|
const adb = auth.me?.activeDatabase
|
||||||
|
return !!adb && adb.source === 'imported'
|
||||||
|
})
|
||||||
|
|
||||||
|
const shouldAutoActivateIfDefault = computed(() => {
|
||||||
|
const adb = auth.me?.activeDatabase
|
||||||
|
if (!adb) return true
|
||||||
|
return adb.source === 'mock'
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
(v) => {
|
||||||
|
if (!v) return
|
||||||
|
error.value = null
|
||||||
|
nextModuleKey.value = currentModuleKey.value
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async function submit(activateRecommendedMock: boolean) {
|
||||||
|
error.value = null
|
||||||
|
const token = getToken()
|
||||||
|
const mk = nextModuleKey.value
|
||||||
|
if (!token || !mk) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await http('/api/module/switch', {
|
||||||
|
method: 'POST',
|
||||||
|
token,
|
||||||
|
body: { moduleKey: mk, activateRecommendedMock },
|
||||||
|
})
|
||||||
|
await refreshMe()
|
||||||
|
emit('close')
|
||||||
|
} catch (e) {
|
||||||
|
const he = e as HttpError
|
||||||
|
error.value = he.message || '切换失败'
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const strategyTitle = computed(() => {
|
||||||
|
const mk = nextModuleKey.value
|
||||||
|
return mk ? `切换到「${moduleName(mk)}」会发生什么` : '切换模块'
|
||||||
|
})
|
||||||
|
|
||||||
|
const recommendation = computed(() => {
|
||||||
|
if (isImportedActiveDb.value) {
|
||||||
|
return `你当前使用的是导入库。为确保判题一致,推荐切换到 ${recommendedDbName.value}(模拟库)。`
|
||||||
|
}
|
||||||
|
return `推荐使用 ${recommendedDbName.value}(模拟库)练习该模块题目。`
|
||||||
|
})
|
||||||
|
|
||||||
|
const defaultActivateRecommendedMock = computed(() => {
|
||||||
|
return shouldAutoActivateIfDefault.value
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="open" class="fixed inset-0 z-50">
|
||||||
|
<div class="absolute inset-0 bg-black/60" @click="emit('close')" />
|
||||||
|
<div class="absolute left-1/2 top-1/2 w-[92vw] max-w-xl -translate-x-1/2 -translate-y-1/2">
|
||||||
|
<div class="rounded-2xl border border-zinc-800 bg-zinc-950 p-5 shadow-2xl">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div class="text-base font-semibold text-zinc-100">切换模块</div>
|
||||||
|
<div class="mt-1 text-sm text-zinc-400">
|
||||||
|
当前模块:{{ moduleName(currentModuleKey) }}|当前库:{{ activeDbLabel }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="rounded-lg px-3 py-2 text-sm text-zinc-300 hover:bg-zinc-900" @click="emit('close')">关闭</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
v-for="m in MODULES"
|
||||||
|
:key="m.key"
|
||||||
|
class="rounded-xl border px-4 py-3 text-left"
|
||||||
|
:class="
|
||||||
|
nextModuleKey === m.key
|
||||||
|
? 'border-blue-600 bg-blue-600/10 text-zinc-100'
|
||||||
|
: 'border-zinc-800 bg-zinc-950 text-zinc-200 hover:bg-zinc-900'
|
||||||
|
"
|
||||||
|
@click="nextModuleKey = m.key"
|
||||||
|
>
|
||||||
|
<div class="text-sm font-medium">{{ m.name }}</div>
|
||||||
|
<div class="mt-1 text-xs text-zinc-400">推荐:{{ recommendedDatabaseNameForModule(m.key) }}</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 rounded-2xl border border-zinc-800 bg-zinc-950 p-4">
|
||||||
|
<div class="text-sm font-medium text-zinc-100">{{ strategyTitle }}</div>
|
||||||
|
<div class="mt-2 text-sm text-zinc-300">
|
||||||
|
<div>题目列表将切换为新模块题目。</div>
|
||||||
|
<div>已做进度与草稿不会丢失,切回原模块仍可继续。</div>
|
||||||
|
<div>
|
||||||
|
数据库策略:
|
||||||
|
<span v-if="isImportedActiveDb">导入库默认保留,但新模块题目可能无法判题。</span>
|
||||||
|
<span v-else>模拟库会自动对齐到推荐模拟库,避免判题不一致。</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-xs text-zinc-400">{{ recommendation }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="mt-4 rounded-lg border border-red-900 bg-red-950 px-3 py-2 text-sm text-red-200">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 flex flex-wrap justify-end gap-2">
|
||||||
|
<button
|
||||||
|
class="rounded-lg bg-zinc-900 px-3 py-2 text-sm text-zinc-200 hover:bg-zinc-800 disabled:opacity-60"
|
||||||
|
:disabled="saving || !nextModuleKey || nextModuleKey === currentModuleKey"
|
||||||
|
@click="submit(false)"
|
||||||
|
>
|
||||||
|
仅切换模块
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded-lg bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-500 disabled:opacity-60"
|
||||||
|
:disabled="saving || !nextModuleKey || nextModuleKey === currentModuleKey"
|
||||||
|
@click="submit(true)"
|
||||||
|
>
|
||||||
|
切换模块并切换到推荐模拟库
|
||||||
|
</button>
|
||||||
|
<div v-if="defaultActivateRecommendedMock" class="w-full text-right text-xs text-zinc-500">
|
||||||
|
新手默认建议:切换模块时同步切换到推荐模拟库
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
93
src/components/TopNav.vue
Normal file
93
src/components/TopNav.vue
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ModuleSwitchDialog from '@/components/ModuleSwitchDialog.vue'
|
||||||
|
import { logout, useAuthState } from '@/composables/useAuth'
|
||||||
|
import { useTheme } from '@/composables/useTheme'
|
||||||
|
import { moduleName } from '@/lib/domain'
|
||||||
|
import { Database, GraduationCap, LayoutGrid, LogOut, SunMoon } from 'lucide-vue-next'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const auth = useAuthState()
|
||||||
|
const { toggleTheme } = useTheme()
|
||||||
|
|
||||||
|
const showModuleDialog = ref(false)
|
||||||
|
|
||||||
|
async function onLogout() {
|
||||||
|
await logout()
|
||||||
|
router.replace({ name: 'login' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header class="sticky top-0 z-20 border-b border-zinc-800 bg-zinc-950/80 backdrop-blur">
|
||||||
|
<div class="mx-auto flex h-14 max-w-6xl items-center justify-between px-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-600 text-sm font-semibold">
|
||||||
|
SQL
|
||||||
|
</div>
|
||||||
|
<div class="hidden text-sm font-medium text-zinc-100 sm:block">MySQL 查询练习</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm text-zinc-200 hover:bg-zinc-900"
|
||||||
|
:class="route.name === 'home' ? 'bg-zinc-900' : ''"
|
||||||
|
@click="router.push({ name: 'home' })"
|
||||||
|
>
|
||||||
|
<GraduationCap class="h-4 w-4" />
|
||||||
|
题目
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm text-zinc-200 hover:bg-zinc-900"
|
||||||
|
:class="route.name === 'databases' ? 'bg-zinc-900' : ''"
|
||||||
|
@click="router.push({ name: 'databases' })"
|
||||||
|
>
|
||||||
|
<Database class="h-4 w-4" />
|
||||||
|
数据库
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
class="hidden items-center gap-2 rounded-full border border-zinc-800 bg-zinc-950 px-3 py-1 text-xs text-zinc-300 hover:bg-zinc-900 sm:flex"
|
||||||
|
@click="showModuleDialog = true"
|
||||||
|
>
|
||||||
|
<LayoutGrid class="h-3.5 w-3.5" />
|
||||||
|
<span class="max-w-[120px] truncate">{{ moduleName(auth.me?.moduleKey) }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="hidden items-center gap-2 rounded-full border border-zinc-800 bg-zinc-950 px-3 py-1 text-xs text-zinc-300 sm:flex"
|
||||||
|
>
|
||||||
|
<Database class="h-3.5 w-3.5" />
|
||||||
|
<span class="max-w-[180px] truncate">
|
||||||
|
{{ auth.me?.activeDatabase?.name ? auth.me.activeDatabase.name : '未选择数据库' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm text-zinc-200 hover:bg-zinc-900"
|
||||||
|
@click="toggleTheme"
|
||||||
|
>
|
||||||
|
<SunMoon class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="hidden text-sm text-zinc-300 sm:block">
|
||||||
|
{{ auth.me?.name ? auth.me.name : auth.me?.email }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm text-zinc-200 hover:bg-zinc-900"
|
||||||
|
@click="onLogout"
|
||||||
|
>
|
||||||
|
<LogOut class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ModuleSwitchDialog :open="showModuleDialog" @close="showModuleDialog = false" />
|
||||||
|
</template>
|
||||||
|
|
||||||
85
src/composables/useAuth.ts
Normal file
85
src/composables/useAuth.ts
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import { http } from '@/lib/http'
|
||||||
|
import { computed, reactive } from 'vue'
|
||||||
|
|
||||||
|
export type Me = {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
moduleKey: string
|
||||||
|
experienceLevel: 'beginner' | 'normal' | 'advanced'
|
||||||
|
onboardingCompleted: boolean
|
||||||
|
activeDatabaseId: string | null
|
||||||
|
activeDatabase: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
source: 'mock' | 'imported'
|
||||||
|
schemaName: string
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthState = {
|
||||||
|
token: string | null
|
||||||
|
me: Me | null
|
||||||
|
meLoading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = reactive<AuthState>({
|
||||||
|
token: localStorage.getItem('token'),
|
||||||
|
me: null,
|
||||||
|
meLoading: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
export function useAuthState() {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getToken() {
|
||||||
|
return state.token
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setToken(token: string | null) {
|
||||||
|
state.token = token
|
||||||
|
if (token) localStorage.setItem('token', token)
|
||||||
|
else localStorage.removeItem('token')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshMe() {
|
||||||
|
const token = state.token
|
||||||
|
if (!token) {
|
||||||
|
state.me = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state.meLoading = true
|
||||||
|
try {
|
||||||
|
state.me = await http<Me>('/api/me', { token })
|
||||||
|
} finally {
|
||||||
|
state.meLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout() {
|
||||||
|
setToken(null)
|
||||||
|
state.me = null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(email: string, password: string) {
|
||||||
|
const res = await http<{ token: string }>('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { email, password },
|
||||||
|
})
|
||||||
|
setToken(res.token)
|
||||||
|
await refreshMe()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function register(email: string, password: string) {
|
||||||
|
const res = await http<{ token: string }>('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { email, password },
|
||||||
|
})
|
||||||
|
setToken(res.token)
|
||||||
|
await refreshMe()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isAuthed = computed(() => !!state.token)
|
||||||
|
|
||||||
40
src/composables/useTheme.ts
Normal file
40
src/composables/useTheme.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { ref, watchEffect, onMounted, computed } from 'vue'
|
||||||
|
|
||||||
|
type Theme = 'light' | 'dark'
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const theme = ref<Theme>('light')
|
||||||
|
|
||||||
|
const getPreferredTheme = (): Theme => {
|
||||||
|
const saved = localStorage.getItem('theme') as Theme | null
|
||||||
|
if (saved === 'light' || saved === 'dark') return saved
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
? 'dark'
|
||||||
|
: 'light'
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyTheme = (t: Theme) => {
|
||||||
|
document.documentElement.classList.remove('light', 'dark')
|
||||||
|
document.documentElement.classList.add(t)
|
||||||
|
localStorage.setItem('theme', t)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
theme.value = theme.value === 'light' ? 'dark' : 'light'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
theme.value = getPreferredTheme()
|
||||||
|
applyTheme(theme.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
applyTheme(theme.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
theme,
|
||||||
|
toggleTheme,
|
||||||
|
isDark: computed(() => theme.value === 'dark'),
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/lib/domain.ts
Normal file
18
src/lib/domain.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
export type ModuleKey = 'shop' | 'hr'
|
||||||
|
|
||||||
|
export const MODULES: Array<{ key: ModuleKey; name: string; recommendedMockKey: ModuleKey }> = [
|
||||||
|
{ key: 'shop', name: '电商', recommendedMockKey: 'shop' },
|
||||||
|
{ key: 'hr', name: '人事', recommendedMockKey: 'hr' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function moduleName(key: string | null | undefined) {
|
||||||
|
const m = MODULES.find((x) => x.key === key)
|
||||||
|
return m ? m.name : '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recommendedDatabaseNameForModule(key: string | null | undefined) {
|
||||||
|
if (key === 'shop') return '电商库'
|
||||||
|
if (key === 'hr') return '人事库'
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
|
||||||
68
src/lib/http.ts
Normal file
68
src/lib/http.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
export type HttpError = {
|
||||||
|
status: number
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBaseUrl(input: string | undefined) {
|
||||||
|
const v = (input || '').trim()
|
||||||
|
if (!v) return ''
|
||||||
|
return v.endsWith('/') ? v.slice(0, -1) : v
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_BASE_URL = normalizeBaseUrl((import.meta as any).env?.VITE_API_BASE_URL as string | undefined)
|
||||||
|
|
||||||
|
function resolveUrl(url: string) {
|
||||||
|
if (!API_BASE_URL) return url
|
||||||
|
if (url.startsWith('http://') || url.startsWith('https://')) return url
|
||||||
|
if (!url.startsWith('/')) return url
|
||||||
|
return `${API_BASE_URL}${url}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readErrorMessage(res: Response) {
|
||||||
|
try {
|
||||||
|
const data = (await res.json()) as unknown
|
||||||
|
if (data && typeof data === 'object' && 'message' in data && typeof (data as any).message === 'string') {
|
||||||
|
return (data as any).message as string
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
return `${res.status} ${res.statusText}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function http<T>(
|
||||||
|
url: string,
|
||||||
|
opts: {
|
||||||
|
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||||
|
token?: string | null
|
||||||
|
body?: unknown
|
||||||
|
formData?: FormData
|
||||||
|
} = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const finalUrl = resolveUrl(url)
|
||||||
|
const headers: Record<string, string> = {}
|
||||||
|
if (opts.token) headers.Authorization = `Bearer ${opts.token}`
|
||||||
|
|
||||||
|
let body: BodyInit | undefined
|
||||||
|
if (opts.formData) {
|
||||||
|
body = opts.formData
|
||||||
|
} else if (opts.body !== undefined) {
|
||||||
|
headers['Content-Type'] = 'application/json'
|
||||||
|
body = JSON.stringify(opts.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(finalUrl, {
|
||||||
|
method: opts.method ?? 'GET',
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const message = await readErrorMessage(res)
|
||||||
|
const err: HttpError = { status: res.status, message }
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 204) return undefined as T
|
||||||
|
return (await res.json()) as T
|
||||||
|
}
|
||||||
|
|
||||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
13
src/main.ts
Normal file
13
src/main.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import './style.css'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
|
||||||
|
// 创建Vue应用实例
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
// 使用路由
|
||||||
|
app.use(router)
|
||||||
|
|
||||||
|
// 挂载应用
|
||||||
|
app.mount('#app')
|
||||||
177
src/pages/DatabasesPage.vue
Normal file
177
src/pages/DatabasesPage.vue
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import TopNav from '@/components/TopNav.vue'
|
||||||
|
import { getToken, refreshMe, useAuthState } from '@/composables/useAuth'
|
||||||
|
import type { HttpError } from '@/lib/http'
|
||||||
|
import { http } from '@/lib/http'
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
type MockDb = { key: string; name: string; description: string }
|
||||||
|
type UserDb = { id: string; name: string; source: 'mock' | 'imported'; isActive: boolean }
|
||||||
|
|
||||||
|
const auth = useAuthState()
|
||||||
|
const mocks = ref<MockDb[]>([])
|
||||||
|
const userDbs = ref<UserDb[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const importName = ref('')
|
||||||
|
const importFile = ref<File | null>(null)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const token = getToken()
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
mocks.value = await http<MockDb[]>('/api/mock-databases', { token })
|
||||||
|
userDbs.value = await http<UserDb[]>('/api/user-databases', { token })
|
||||||
|
} catch (e) {
|
||||||
|
const he = e as HttpError
|
||||||
|
error.value = he.message || '加载失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function activate(id: string) {
|
||||||
|
const token = getToken()
|
||||||
|
await http('/api/user-databases/activate', { method: 'POST', token, body: { id } })
|
||||||
|
await refreshMe()
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function activateMock(key: string) {
|
||||||
|
const token = getToken()
|
||||||
|
await http('/api/user-databases/activate-mock', { method: 'POST', token, body: { key } })
|
||||||
|
await refreshMe()
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doImport() {
|
||||||
|
const token = getToken()
|
||||||
|
if (!importFile.value) {
|
||||||
|
error.value = '请选择一个 .sql 文件'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('name', importName.value.trim() || '我的数据库')
|
||||||
|
fd.append('file', importFile.value)
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
await http('/api/user-databases/import', { method: 'POST', token, formData: fd })
|
||||||
|
importName.value = ''
|
||||||
|
importFile.value = null
|
||||||
|
await refreshMe()
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
const he = e as HttpError
|
||||||
|
error.value = he.message || '导入失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-full">
|
||||||
|
<TopNav />
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-6xl px-4 py-6">
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="text-lg font-semibold">数据库管理</div>
|
||||||
|
<div class="mt-1 text-sm text-zinc-400">练习运行将基于你当前激活的数据库。</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="mb-4 rounded-lg border border-red-900 bg-red-950 px-3 py-2 text-sm text-red-200">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||||
|
<div class="rounded-2xl border border-zinc-800 bg-zinc-950 p-5 lg:col-span-2">
|
||||||
|
<div class="text-sm font-medium">模拟数据库</div>
|
||||||
|
<div class="mt-1 text-xs text-zinc-400">新手推荐,开箱即用。</div>
|
||||||
|
|
||||||
|
<div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<div v-for="m in mocks" :key="m.key" class="rounded-xl border border-zinc-800 p-4">
|
||||||
|
<div class="text-sm font-medium">{{ m.name }}</div>
|
||||||
|
<div class="mt-1 text-xs text-zinc-400">{{ m.description }}</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<button
|
||||||
|
class="rounded-lg bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-500 disabled:opacity-60"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="activateMock(m.key)"
|
||||||
|
>
|
||||||
|
设为当前
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 border-t border-zinc-800 pt-6">
|
||||||
|
<div class="text-sm font-medium">导入自定义数据库</div>
|
||||||
|
<div class="mt-1 text-xs text-zinc-400">上传初始化 SQL(建表 + 插入数据)。</div>
|
||||||
|
<div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||||
|
<input
|
||||||
|
v-model="importName"
|
||||||
|
class="rounded-lg border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm outline-none focus:border-blue-600"
|
||||||
|
placeholder="数据库名称(可选)"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".sql"
|
||||||
|
class="rounded-lg border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm"
|
||||||
|
@change="(e: Event) => (importFile = ((e.target as HTMLInputElement).files?.[0] ?? null))"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="rounded-lg bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-500 disabled:opacity-60"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="doImport"
|
||||||
|
>
|
||||||
|
{{ loading ? '导入中…' : '上传并导入' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-zinc-800 bg-zinc-950 p-5">
|
||||||
|
<div class="text-sm font-medium">我的数据库</div>
|
||||||
|
<div class="mt-1 text-xs text-zinc-400">选择一个作为当前练习环境。</div>
|
||||||
|
|
||||||
|
<div class="mt-4 space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="d in userDbs"
|
||||||
|
:key="d.id"
|
||||||
|
class="flex items-center justify-between rounded-xl border border-zinc-800 px-3 py-3"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium">{{ d.name }}</div>
|
||||||
|
<div class="mt-1 text-xs text-zinc-400">{{ d.source === 'mock' ? '模拟库' : '导入库' }}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="rounded-lg px-3 py-2 text-sm"
|
||||||
|
:class="d.isActive ? 'bg-green-700 text-white' : 'bg-zinc-900 text-zinc-200 hover:bg-zinc-800'"
|
||||||
|
:disabled="loading || d.isActive"
|
||||||
|
@click="activate(d.id)"
|
||||||
|
>
|
||||||
|
{{ d.isActive ? '当前' : '激活' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 rounded-xl border border-zinc-800 bg-zinc-950 p-4">
|
||||||
|
<div class="text-xs text-zinc-400">当前激活数据库</div>
|
||||||
|
<div class="mt-1 text-sm font-medium">
|
||||||
|
{{ auth.me?.activeDatabase?.name ? auth.me.activeDatabase.name : '未选择' }}
|
||||||
|
</div>
|
||||||
|
<div v-if="auth.me?.activeDatabase" class="mt-1 text-xs text-zinc-400">
|
||||||
|
{{ auth.me.activeDatabase.source === 'imported' ? '导入库' : '模拟库' }}|{{ auth.me.activeDatabase.schemaName }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
224
src/pages/HomePage.vue
Normal file
224
src/pages/HomePage.vue
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import TopNav from '@/components/TopNav.vue'
|
||||||
|
import { getToken, refreshMe, useAuthState } from '@/composables/useAuth'
|
||||||
|
import { moduleName } from '@/lib/domain'
|
||||||
|
import type { HttpError } from '@/lib/http'
|
||||||
|
import { http } from '@/lib/http'
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
type Level = 'beginner' | 'normal' | 'advanced'
|
||||||
|
type ExerciseSummary = { id: string; title: string; level: Level; isSolved: boolean; databaseKey: string }
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const auth = useAuthState()
|
||||||
|
|
||||||
|
const level = ref<Level>('beginner')
|
||||||
|
const query = ref('')
|
||||||
|
const status = ref<'all' | 'unsolved' | 'solved'>('all')
|
||||||
|
const items = ref<ExerciseSummary[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const showExerciseDbMismatch = computed(() => {
|
||||||
|
const adb = auth.me?.activeDatabase
|
||||||
|
if (!adb) return false
|
||||||
|
return adb.source !== 'mock'
|
||||||
|
})
|
||||||
|
|
||||||
|
async function activateRecommendedMock() {
|
||||||
|
const token = getToken()
|
||||||
|
const key = auth.me?.moduleKey
|
||||||
|
if (!token || (key !== 'shop' && key !== 'hr')) return
|
||||||
|
await http('/api/user-databases/activate-mock', { method: 'POST', token, body: { key } })
|
||||||
|
await refreshMe()
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeDbLabel = computed(() => {
|
||||||
|
const adb = auth.me?.activeDatabase
|
||||||
|
if (!adb) return '未选择数据库'
|
||||||
|
return `${adb.name}${adb.source === 'imported' ? '(导入)' : '(模拟)'}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const filtered = computed(() => {
|
||||||
|
const q = query.value.trim().toLowerCase()
|
||||||
|
return items.value.filter((it) => {
|
||||||
|
if (status.value === 'solved' && !it.isSolved) return false
|
||||||
|
if (status.value === 'unsolved' && it.isSolved) return false
|
||||||
|
if (!q) return true
|
||||||
|
return it.title.toLowerCase().includes(q)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const token = getToken()
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
items.value = await http<ExerciseSummary[]>(`/api/exercises?level=${level.value}`, { token })
|
||||||
|
} catch (e) {
|
||||||
|
const he = e as HttpError
|
||||||
|
error.value = he.message || '加载失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(level, load)
|
||||||
|
watch(
|
||||||
|
() => auth.me?.moduleKey,
|
||||||
|
() => load(),
|
||||||
|
)
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-full">
|
||||||
|
<TopNav />
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-6xl px-4 py-6">
|
||||||
|
<div class="mb-4 flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div class="text-lg font-semibold">题目练习</div>
|
||||||
|
<div class="mt-1 text-sm text-zinc-400">
|
||||||
|
当前模块:{{ moduleName(auth.me?.moduleKey) }}|经验:{{ auth.me?.experienceLevel || '-' }}|当前库:{{ activeDbLabel }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="rounded-lg bg-zinc-900 px-3 py-2 text-sm text-zinc-200 hover:bg-zinc-800"
|
||||||
|
@click="router.push({ name: 'onboarding' })"
|
||||||
|
>
|
||||||
|
重新查看引导
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="mb-4 rounded-lg border border-red-900 bg-red-950 px-3 py-2 text-sm text-red-200">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 lg:grid-cols-[280px_1fr]">
|
||||||
|
<div class="rounded-2xl border border-zinc-800 bg-zinc-950 p-5">
|
||||||
|
<div class="text-xs text-zinc-400">难度</div>
|
||||||
|
<div class="mt-2 grid grid-cols-3 gap-2">
|
||||||
|
<button
|
||||||
|
class="rounded-lg px-3 py-2 text-sm"
|
||||||
|
:class="level === 'beginner' ? 'bg-blue-600 text-white' : 'bg-zinc-900 text-zinc-200 hover:bg-zinc-800'"
|
||||||
|
@click="level = 'beginner'"
|
||||||
|
>
|
||||||
|
新手
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded-lg px-3 py-2 text-sm"
|
||||||
|
:class="level === 'normal' ? 'bg-blue-600 text-white' : 'bg-zinc-900 text-zinc-200 hover:bg-zinc-800'"
|
||||||
|
@click="level = 'normal'"
|
||||||
|
>
|
||||||
|
一般
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded-lg px-3 py-2 text-sm"
|
||||||
|
:class="level === 'advanced' ? 'bg-blue-600 text-white' : 'bg-zinc-900 text-zinc-200 hover:bg-zinc-800'"
|
||||||
|
@click="level = 'advanced'"
|
||||||
|
>
|
||||||
|
进阶
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 text-xs text-zinc-400">筛选</div>
|
||||||
|
<div class="mt-2 grid grid-cols-3 gap-2">
|
||||||
|
<button
|
||||||
|
class="rounded-lg px-3 py-2 text-sm"
|
||||||
|
:class="status === 'all' ? 'bg-zinc-800 text-white' : 'bg-zinc-900 text-zinc-200 hover:bg-zinc-800'"
|
||||||
|
@click="status = 'all'"
|
||||||
|
>
|
||||||
|
全部
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded-lg px-3 py-2 text-sm"
|
||||||
|
:class="status === 'unsolved' ? 'bg-zinc-800 text-white' : 'bg-zinc-900 text-zinc-200 hover:bg-zinc-800'"
|
||||||
|
@click="status = 'unsolved'"
|
||||||
|
>
|
||||||
|
未做
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded-lg px-3 py-2 text-sm"
|
||||||
|
:class="status === 'solved' ? 'bg-zinc-800 text-white' : 'bg-zinc-900 text-zinc-200 hover:bg-zinc-800'"
|
||||||
|
@click="status = 'solved'"
|
||||||
|
>
|
||||||
|
已做
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 text-xs text-zinc-400">搜索</div>
|
||||||
|
<input
|
||||||
|
v-model="query"
|
||||||
|
class="mt-2 w-full rounded-lg border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm outline-none focus:border-blue-600"
|
||||||
|
placeholder="按标题搜索"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="mt-6 rounded-xl border border-zinc-800 bg-zinc-950 p-4">
|
||||||
|
<div class="text-xs text-zinc-400">开始前检查</div>
|
||||||
|
<div class="mt-1 text-sm text-zinc-200">
|
||||||
|
{{
|
||||||
|
!auth.me?.activeDatabase
|
||||||
|
? '未选择数据库,先去数据库页激活一个'
|
||||||
|
: showExerciseDbMismatch
|
||||||
|
? '当前为导入库:做题判题需要匹配的模拟库'
|
||||||
|
: '当前数据库已就绪'
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-if="!auth.me?.activeDatabase"
|
||||||
|
class="rounded-lg bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-500"
|
||||||
|
@click="router.push({ name: 'databases' })"
|
||||||
|
>
|
||||||
|
去选择数据库
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="showExerciseDbMismatch"
|
||||||
|
class="rounded-lg bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-500"
|
||||||
|
@click="activateRecommendedMock"
|
||||||
|
>
|
||||||
|
一键切换到推荐模拟库
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="showExerciseDbMismatch"
|
||||||
|
class="rounded-lg bg-zinc-900 px-3 py-2 text-sm text-zinc-200 hover:bg-zinc-800"
|
||||||
|
@click="router.push({ name: 'databases' })"
|
||||||
|
>
|
||||||
|
去数据库页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-zinc-800 bg-zinc-950 p-5">
|
||||||
|
<div class="mb-3 flex items-center justify-between">
|
||||||
|
<div class="text-sm font-medium">题目列表</div>
|
||||||
|
<div class="text-xs text-zinc-400">{{ loading ? '加载中…' : filtered.length + ' 题' }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-3">
|
||||||
|
<button
|
||||||
|
v-for="it in filtered"
|
||||||
|
:key="it.id"
|
||||||
|
class="flex items-center justify-between rounded-xl border border-zinc-800 px-4 py-3 text-left hover:bg-zinc-900"
|
||||||
|
@click="router.push({ name: 'practice', params: { id: it.id } })"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium text-zinc-100">{{ it.title }}</div>
|
||||||
|
<div class="mt-1 text-xs text-zinc-400">{{ it.level }}</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="rounded-full px-2 py-1 text-xs"
|
||||||
|
:class="it.isSolved ? 'bg-green-950/50 text-green-200' : 'bg-zinc-900 text-zinc-300'"
|
||||||
|
>
|
||||||
|
{{ it.isSolved ? '已通过' : '未完成' }}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
95
src/pages/LoginPage.vue
Normal file
95
src/pages/LoginPage.vue
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { login, register } from '@/composables/useAuth'
|
||||||
|
import type { HttpError } from '@/lib/http'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const mode = ref<'login' | 'register'>('login')
|
||||||
|
const email = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
error.value = null
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
if (mode.value === 'login') await login(email.value.trim(), password.value)
|
||||||
|
else await register(email.value.trim(), password.value)
|
||||||
|
router.replace({ name: 'home' })
|
||||||
|
} catch (e) {
|
||||||
|
const he = e as HttpError
|
||||||
|
error.value = he.message || '登录失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex min-h-full items-center justify-center px-4 py-10">
|
||||||
|
<div class="w-full max-w-md rounded-2xl border border-zinc-800 bg-zinc-950 p-6 shadow">
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="text-lg font-semibold">MySQL 查询练习</div>
|
||||||
|
<div class="mt-1 text-sm text-zinc-400">登录后开始分级练习与在线运行 SQL</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-5 grid grid-cols-2 rounded-lg bg-zinc-900 p-1">
|
||||||
|
<button
|
||||||
|
class="rounded-md px-3 py-2 text-sm"
|
||||||
|
:class="mode === 'login' ? 'bg-zinc-950 text-zinc-100' : 'text-zinc-300'"
|
||||||
|
@click="mode = 'login'"
|
||||||
|
>
|
||||||
|
登录
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded-md px-3 py-2 text-sm"
|
||||||
|
:class="mode === 'register' ? 'bg-zinc-950 text-zinc-100' : 'text-zinc-300'"
|
||||||
|
@click="mode = 'register'"
|
||||||
|
>
|
||||||
|
注册
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="space-y-4" @submit.prevent="onSubmit">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="text-xs text-zinc-400">邮箱</div>
|
||||||
|
<input
|
||||||
|
v-model="email"
|
||||||
|
type="email"
|
||||||
|
class="w-full rounded-lg border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm outline-none focus:border-blue-600"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
autocomplete="email"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="text-xs text-zinc-400">密码</div>
|
||||||
|
<input
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
class="w-full rounded-lg border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm outline-none focus:border-blue-600"
|
||||||
|
placeholder="至少 6 位"
|
||||||
|
autocomplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="rounded-lg border border-red-900 bg-red-950 px-3 py-2 text-sm text-red-200">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="inline-flex w-full items-center justify-center rounded-lg bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-500 disabled:opacity-60"
|
||||||
|
:disabled="loading"
|
||||||
|
>
|
||||||
|
{{ loading ? '处理中…' : mode === 'login' ? '登录' : '注册并登录' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
238
src/pages/OnboardingPage.vue
Normal file
238
src/pages/OnboardingPage.vue
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { getToken, refreshMe, useAuthState } from '@/composables/useAuth'
|
||||||
|
import type { HttpError } from '@/lib/http'
|
||||||
|
import { http } from '@/lib/http'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const auth = useAuthState()
|
||||||
|
|
||||||
|
const step = ref(1)
|
||||||
|
const name = ref('')
|
||||||
|
const moduleKey = ref<'shop' | 'hr'>('shop')
|
||||||
|
const experienceLevel = ref<'beginner' | 'normal' | 'advanced'>('beginner')
|
||||||
|
const dbMode = ref<'mock' | 'import'>('mock')
|
||||||
|
const mockDbKey = ref<'shop' | 'hr'>('shop')
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const canNext = computed(() => {
|
||||||
|
if (step.value === 1) return name.value.trim().length >= 1
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => moduleKey.value,
|
||||||
|
(v) => {
|
||||||
|
if (dbMode.value === 'mock') mockDbKey.value = v
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => dbMode.value,
|
||||||
|
(v) => {
|
||||||
|
if (v === 'mock') mockDbKey.value = moduleKey.value
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async function next() {
|
||||||
|
if (!canNext.value) return
|
||||||
|
step.value = Math.min(4, step.value + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function prev() {
|
||||||
|
step.value = Math.max(1, step.value - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function complete() {
|
||||||
|
error.value = null
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const token = getToken()
|
||||||
|
await http('/api/onboarding/complete', {
|
||||||
|
method: 'POST',
|
||||||
|
token,
|
||||||
|
body: {
|
||||||
|
name: name.value.trim(),
|
||||||
|
moduleKey: moduleKey.value,
|
||||||
|
experienceLevel: experienceLevel.value,
|
||||||
|
dbMode: dbMode.value,
|
||||||
|
mockDbKey: dbMode.value === 'mock' ? mockDbKey.value : null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await refreshMe()
|
||||||
|
if (dbMode.value === 'import') router.replace({ name: 'databases' })
|
||||||
|
else router.replace({ name: 'home' })
|
||||||
|
} catch (e) {
|
||||||
|
const he = e as HttpError
|
||||||
|
error.value = he.message || '提交失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-full">
|
||||||
|
<div class="mx-auto max-w-3xl px-4 py-10">
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="text-lg font-semibold">首次引导</div>
|
||||||
|
<div class="mt-1 text-sm text-zinc-400">用 1 分钟完成设置,就可以开始练习。</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-zinc-800 bg-zinc-950 p-6">
|
||||||
|
<div class="mb-5 flex items-center justify-between">
|
||||||
|
<div class="text-sm text-zinc-400">第 {{ step }} / 4 步</div>
|
||||||
|
<div class="text-sm text-zinc-300">{{ auth.me?.email }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="step === 1" class="space-y-3">
|
||||||
|
<div class="text-sm font-medium">先告诉我你的名字</div>
|
||||||
|
<input
|
||||||
|
v-model="name"
|
||||||
|
class="w-full rounded-lg border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm outline-none focus:border-blue-600"
|
||||||
|
placeholder="例如:小明"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="step === 2" class="space-y-3">
|
||||||
|
<div class="text-sm font-medium">你感兴趣的模块</div>
|
||||||
|
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
|
<button
|
||||||
|
class="rounded-xl border px-4 py-3 text-left"
|
||||||
|
:class="moduleKey === 'shop' ? 'border-blue-600 bg-blue-950/30' : 'border-zinc-800 hover:bg-zinc-900'"
|
||||||
|
@click="moduleKey = 'shop'"
|
||||||
|
>
|
||||||
|
<div class="text-sm font-medium">电商</div>
|
||||||
|
<div class="mt-1 text-xs text-zinc-400">商品/订单/用户</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded-xl border px-4 py-3 text-left"
|
||||||
|
:class="moduleKey === 'hr' ? 'border-blue-600 bg-blue-950/30' : 'border-zinc-800 hover:bg-zinc-900'"
|
||||||
|
@click="moduleKey = 'hr'"
|
||||||
|
>
|
||||||
|
<div class="text-sm font-medium">人事</div>
|
||||||
|
<div class="mt-1 text-xs text-zinc-400">员工/部门/薪资</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="step === 3" class="space-y-3">
|
||||||
|
<div class="text-sm font-medium">你的经验水平</div>
|
||||||
|
<div class="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||||
|
<button
|
||||||
|
class="rounded-xl border px-4 py-3 text-left"
|
||||||
|
:class="experienceLevel === 'beginner' ? 'border-blue-600 bg-blue-950/30' : 'border-zinc-800 hover:bg-zinc-900'"
|
||||||
|
@click="experienceLevel = 'beginner'"
|
||||||
|
>
|
||||||
|
<div class="text-sm font-medium">新手</div>
|
||||||
|
<div class="mt-1 text-xs text-zinc-400">从 SELECT 开始</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded-xl border px-4 py-3 text-left"
|
||||||
|
:class="experienceLevel === 'normal' ? 'border-blue-600 bg-blue-950/30' : 'border-zinc-800 hover:bg-zinc-900'"
|
||||||
|
@click="experienceLevel = 'normal'"
|
||||||
|
>
|
||||||
|
<div class="text-sm font-medium">一般</div>
|
||||||
|
<div class="mt-1 text-xs text-zinc-400">JOIN/聚合</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded-xl border px-4 py-3 text-left"
|
||||||
|
:class="experienceLevel === 'advanced' ? 'border-blue-600 bg-blue-950/30' : 'border-zinc-800 hover:bg-zinc-900'"
|
||||||
|
@click="experienceLevel = 'advanced'"
|
||||||
|
>
|
||||||
|
<div class="text-sm font-medium">进阶</div>
|
||||||
|
<div class="mt-1 text-xs text-zinc-400">窗口/CTE</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-3">
|
||||||
|
<div class="text-sm font-medium">选择数据库来源</div>
|
||||||
|
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
|
<button
|
||||||
|
class="rounded-xl border px-4 py-3 text-left"
|
||||||
|
:class="dbMode === 'mock' ? 'border-blue-600 bg-blue-950/30' : 'border-zinc-800 hover:bg-zinc-900'"
|
||||||
|
@click="dbMode = 'mock'"
|
||||||
|
>
|
||||||
|
<div class="text-sm font-medium">使用模拟数据库</div>
|
||||||
|
<div class="mt-1 text-xs text-zinc-400">开箱即用,适合练习</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded-xl border px-4 py-3 text-left"
|
||||||
|
:class="dbMode === 'import' ? 'border-blue-600 bg-blue-950/30' : 'border-zinc-800 hover:bg-zinc-900'"
|
||||||
|
@click="dbMode = 'import'"
|
||||||
|
>
|
||||||
|
<div class="text-sm font-medium">导入我的数据库</div>
|
||||||
|
<div class="mt-1 text-xs text-zinc-400">上传初始化 SQL</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="dbMode === 'mock'" class="rounded-xl border border-zinc-800 bg-zinc-950 p-4">
|
||||||
|
<div class="text-xs text-zinc-400">选择一个模拟库</div>
|
||||||
|
<div class="mt-1 text-xs text-zinc-500">推荐:与模块一致的模拟库(更容易通过判题)</div>
|
||||||
|
<div class="mt-2 grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
|
<button
|
||||||
|
class="rounded-lg border px-3 py-2 text-left"
|
||||||
|
:class="mockDbKey === 'shop' ? 'border-blue-600 bg-blue-950/30' : 'border-zinc-800 hover:bg-zinc-900'"
|
||||||
|
@click="mockDbKey = 'shop'"
|
||||||
|
>
|
||||||
|
<div class="text-sm font-medium">电商库</div>
|
||||||
|
<div class="mt-1 text-xs text-zinc-400">商品/订单</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded-lg border px-3 py-2 text-left"
|
||||||
|
:class="mockDbKey === 'hr' ? 'border-blue-600 bg-blue-950/30' : 'border-zinc-800 hover:bg-zinc-900'"
|
||||||
|
@click="mockDbKey = 'hr'"
|
||||||
|
>
|
||||||
|
<div class="text-sm font-medium">人事库</div>
|
||||||
|
<div class="mt-1 text-xs text-zinc-400">员工/部门</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="rounded-xl border border-zinc-800 bg-zinc-950 p-4 text-sm text-zinc-300">
|
||||||
|
你可以先导入自己的库做自由练习;若要做题并获得判题结果,建议之后切换到模块对应的模拟库。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="mt-5 rounded-lg border border-red-900 bg-red-950 px-3 py-2 text-sm text-red-200">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
class="rounded-lg px-3 py-2 text-sm text-zinc-300 hover:bg-zinc-900 disabled:opacity-50"
|
||||||
|
:disabled="step === 1 || loading"
|
||||||
|
@click="prev"
|
||||||
|
>
|
||||||
|
上一步
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
v-if="step < 4"
|
||||||
|
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500 disabled:opacity-60"
|
||||||
|
:disabled="!canNext || loading"
|
||||||
|
@click="next"
|
||||||
|
>
|
||||||
|
下一步
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500 disabled:opacity-60"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="complete"
|
||||||
|
>
|
||||||
|
{{ loading ? '提交中…' : '完成并进入系统' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
232
src/pages/PracticePage.vue
Normal file
232
src/pages/PracticePage.vue
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import TopNav from '@/components/TopNav.vue'
|
||||||
|
import { getToken, refreshMe, useAuthState } from '@/composables/useAuth'
|
||||||
|
import { moduleName, recommendedDatabaseNameForModule } from '@/lib/domain'
|
||||||
|
import type { HttpError } from '@/lib/http'
|
||||||
|
import { http } from '@/lib/http'
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
type ExerciseDetail = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
level: 'beginner' | 'normal' | 'advanced'
|
||||||
|
prompt: string
|
||||||
|
databaseKey: string
|
||||||
|
isSolved: boolean
|
||||||
|
draftSql: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExecuteResp = {
|
||||||
|
ok: boolean
|
||||||
|
durationMs: number
|
||||||
|
columns: string[]
|
||||||
|
rows: Array<Record<string, unknown>>
|
||||||
|
verdict: 'pass' | 'fail'
|
||||||
|
hint: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const id = computed(() => String(route.params.id || ''))
|
||||||
|
|
||||||
|
const auth = useAuthState()
|
||||||
|
|
||||||
|
const ex = ref<ExerciseDetail | null>(null)
|
||||||
|
const sql = ref('')
|
||||||
|
const running = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const result = ref<ExecuteResp | null>(null)
|
||||||
|
|
||||||
|
const expectedDbName = computed(() => recommendedDatabaseNameForModule(ex.value?.databaseKey))
|
||||||
|
const currentDbLabel = computed(() => {
|
||||||
|
const adb = auth.me?.activeDatabase
|
||||||
|
if (!adb) return '未选择'
|
||||||
|
return `${adb.name}${adb.source === 'imported' ? '(导入)' : '(模拟)'}`
|
||||||
|
})
|
||||||
|
const isDbMismatch = computed(() => {
|
||||||
|
const adb = auth.me?.activeDatabase
|
||||||
|
const key = ex.value?.databaseKey
|
||||||
|
if (!adb || !key) return false
|
||||||
|
if (adb.source !== 'mock') return true
|
||||||
|
if (key === 'shop') return adb.schemaName !== 'mock_shop'
|
||||||
|
if (key === 'hr') return adb.schemaName !== 'mock_hr'
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
const isModuleMismatch = computed(() => {
|
||||||
|
const mk = auth.me?.moduleKey
|
||||||
|
const ek = ex.value?.databaseKey
|
||||||
|
if (!mk || !ek) return false
|
||||||
|
if ((mk !== 'shop' && mk !== 'hr') || (ek !== 'shop' && ek !== 'hr')) return false
|
||||||
|
return mk !== ek
|
||||||
|
})
|
||||||
|
|
||||||
|
async function activateExpectedMock() {
|
||||||
|
const token = getToken()
|
||||||
|
const key = ex.value?.databaseKey
|
||||||
|
if (!token || (key !== 'shop' && key !== 'hr')) return
|
||||||
|
await http('/api/user-databases/activate-mock', { method: 'POST', token, body: { key } })
|
||||||
|
await refreshMe()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function switchToExerciseModule() {
|
||||||
|
const token = getToken()
|
||||||
|
const key = ex.value?.databaseKey
|
||||||
|
if (!token || (key !== 'shop' && key !== 'hr')) return
|
||||||
|
await http('/api/module/switch', { method: 'POST', token, body: { moduleKey: key, activateRecommendedMock: true } })
|
||||||
|
await refreshMe()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const token = getToken()
|
||||||
|
ex.value = await http<ExerciseDetail>(`/api/exercises/${encodeURIComponent(id.value)}`, { token })
|
||||||
|
sql.value = ex.value.draftSql || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
error.value = null
|
||||||
|
result.value = null
|
||||||
|
running.value = true
|
||||||
|
try {
|
||||||
|
const token = getToken()
|
||||||
|
const resp = await http<ExecuteResp>('/api/sql/execute', {
|
||||||
|
method: 'POST',
|
||||||
|
token,
|
||||||
|
body: { exerciseId: id.value, sql: sql.value },
|
||||||
|
})
|
||||||
|
result.value = resp
|
||||||
|
await http('/api/progress/upsert', {
|
||||||
|
method: 'POST',
|
||||||
|
token,
|
||||||
|
body: { exerciseId: id.value, draftSql: sql.value, isSolved: resp.ok && resp.verdict === 'pass' },
|
||||||
|
})
|
||||||
|
if (!resp.ok) return
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
const he = e as HttpError
|
||||||
|
error.value = he.message || '运行失败'
|
||||||
|
} finally {
|
||||||
|
running.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => id.value,
|
||||||
|
() => {
|
||||||
|
ex.value = null
|
||||||
|
sql.value = ''
|
||||||
|
result.value = null
|
||||||
|
error.value = null
|
||||||
|
load()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-full">
|
||||||
|
<TopNav />
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-6xl px-4 py-6">
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<button class="rounded-lg px-3 py-2 text-sm text-zinc-300 hover:bg-zinc-900" @click="router.push({ name: 'home' })">
|
||||||
|
返回题目
|
||||||
|
</button>
|
||||||
|
<div class="text-sm text-zinc-400">练习页</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 xl:grid-cols-[320px_1fr_420px]">
|
||||||
|
<div class="rounded-2xl border border-zinc-800 bg-zinc-950 p-5">
|
||||||
|
<div class="text-sm font-medium">{{ ex?.title || '加载中…' }}</div>
|
||||||
|
<div class="mt-1 text-xs text-zinc-400">难度:{{ ex?.level }}</div>
|
||||||
|
<div class="mt-1 text-xs text-zinc-400">题目库:{{ expectedDbName }}|当前库:{{ currentDbLabel }}</div>
|
||||||
|
<div
|
||||||
|
v-if="isModuleMismatch"
|
||||||
|
class="mt-3 rounded-xl border border-zinc-800 bg-zinc-950 px-4 py-3 text-sm text-zinc-200"
|
||||||
|
>
|
||||||
|
你当前模块为 {{ moduleName(auth.me?.moduleKey) }},本题属于 {{ moduleName(ex?.databaseKey) }}。
|
||||||
|
<button class="ml-2 rounded-lg bg-zinc-900 px-3 py-2 text-xs text-zinc-200 hover:bg-zinc-800" @click="switchToExerciseModule">
|
||||||
|
切到本题模块
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="isDbMismatch"
|
||||||
|
class="mt-3 rounded-xl border border-amber-900 bg-amber-950/30 px-4 py-3 text-sm text-amber-100"
|
||||||
|
>
|
||||||
|
当前激活数据库与本题不匹配,建议切换到 {{ expectedDbName }}。
|
||||||
|
<button class="ml-2 rounded-lg bg-blue-600 px-3 py-2 text-xs font-medium text-white hover:bg-blue-500" @click="activateExpectedMock">
|
||||||
|
一键切换
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 whitespace-pre-wrap text-sm text-zinc-200">{{ ex?.prompt }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-zinc-800 bg-zinc-950 p-5">
|
||||||
|
<div class="mb-3 flex items-center justify-between">
|
||||||
|
<div class="text-sm font-medium">SQL 编辑</div>
|
||||||
|
<button
|
||||||
|
class="rounded-lg bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-500 disabled:opacity-60"
|
||||||
|
:disabled="running"
|
||||||
|
@click="run"
|
||||||
|
>
|
||||||
|
{{ running ? '运行中…' : '运行 SQL' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
v-model="sql"
|
||||||
|
class="h-[420px] w-full resize-none rounded-xl border border-zinc-800 bg-zinc-950 px-3 py-3 font-mono text-sm text-zinc-100 outline-none focus:border-blue-600"
|
||||||
|
placeholder="输入 SELECT ..."
|
||||||
|
/>
|
||||||
|
<div v-if="error" class="mt-3 rounded-lg border border-red-900 bg-red-950 px-3 py-2 text-sm text-red-200">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-zinc-800 bg-zinc-950 p-5">
|
||||||
|
<div class="text-sm font-medium">结果与判题</div>
|
||||||
|
|
||||||
|
<div v-if="result" class="mt-3">
|
||||||
|
<div
|
||||||
|
class="rounded-xl border px-4 py-3"
|
||||||
|
:class="result.verdict === 'pass' ? 'border-green-900 bg-green-950/30' : 'border-amber-900 bg-amber-950/30'"
|
||||||
|
>
|
||||||
|
<div class="text-sm font-medium">
|
||||||
|
{{ result.verdict === 'pass' ? '通过' : '未通过' }}
|
||||||
|
<span class="ml-2 text-xs text-zinc-400">{{ result.durationMs }}ms</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-xs text-zinc-300">{{ result.hint }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 overflow-hidden rounded-xl border border-zinc-800">
|
||||||
|
<div class="max-h-[420px] overflow-auto">
|
||||||
|
<table class="min-w-full text-left text-sm">
|
||||||
|
<thead class="sticky top-0 bg-zinc-950">
|
||||||
|
<tr class="border-b border-zinc-800">
|
||||||
|
<th v-for="c in result.columns" :key="c" class="px-3 py-2 text-xs font-medium text-zinc-300">
|
||||||
|
{{ c }}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(r, idx) in result.rows" :key="idx" class="border-b border-zinc-900">
|
||||||
|
<td v-for="c in result.columns" :key="c" class="px-3 py-2 text-zinc-200">
|
||||||
|
{{ r[c] }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="mt-3 rounded-xl border border-zinc-800 bg-zinc-950 p-4 text-sm text-zinc-400">
|
||||||
|
运行 SQL 后在这里查看结果与判题。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
64
src/router/index.ts
Normal file
64
src/router/index.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import { getToken, refreshMe, useAuthState } from '@/composables/useAuth'
|
||||||
|
import DatabasesPage from '@/pages/DatabasesPage.vue'
|
||||||
|
import HomePage from '@/pages/HomePage.vue'
|
||||||
|
import LoginPage from '@/pages/LoginPage.vue'
|
||||||
|
import OnboardingPage from '@/pages/OnboardingPage.vue'
|
||||||
|
import PracticePage from '@/pages/PracticePage.vue'
|
||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'login',
|
||||||
|
component: LoginPage,
|
||||||
|
meta: { public: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/onboarding',
|
||||||
|
name: 'onboarding',
|
||||||
|
component: OnboardingPage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'home',
|
||||||
|
component: HomePage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/practice/:id',
|
||||||
|
name: 'practice',
|
||||||
|
component: PracticePage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/databases',
|
||||||
|
name: 'databases',
|
||||||
|
component: DatabasesPage,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// 创建路由实例
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes,
|
||||||
|
})
|
||||||
|
|
||||||
|
router.beforeEach(async (to) => {
|
||||||
|
if (to.meta.public) return true
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) return { name: 'login' }
|
||||||
|
|
||||||
|
const auth = useAuthState()
|
||||||
|
if (!auth.me && !auth.meLoading) {
|
||||||
|
await refreshMe()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!auth.me) return { name: 'login' }
|
||||||
|
if (!auth.me.onboardingCompleted && to.name !== 'onboarding') {
|
||||||
|
return { name: 'onboarding' }
|
||||||
|
}
|
||||||
|
if (auth.me.onboardingCompleted && to.name === 'onboarding') {
|
||||||
|
return { name: 'home' }
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
26
src/style.css
Normal file
26
src/style.css
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
font-family:
|
||||||
|
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica,
|
||||||
|
Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-zinc-950 text-zinc-100;
|
||||||
|
}
|
||||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
13
tailwind.config.js
Normal file
13
tailwind.config.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
|
||||||
|
export default {
|
||||||
|
darkMode: "class",
|
||||||
|
content: ["./index.html", "./src/**/*.{js,ts,vue}"],
|
||||||
|
theme: {
|
||||||
|
container: {
|
||||||
|
center: true,
|
||||||
|
},
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
22
tsconfig.json
Normal file
22
tsconfig.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"strict": false,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": false,
|
||||||
|
"baseUrl": "./",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts",
|
||||||
|
"src/**/*.vue",
|
||||||
|
"api"
|
||||||
|
]
|
||||||
|
}
|
||||||
7
vercel.json
Normal file
7
vercel.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"framework": "vite",
|
||||||
|
"installCommand": "pnpm install",
|
||||||
|
"buildCommand": "pnpm run build",
|
||||||
|
"outputDirectory": "dist"
|
||||||
|
}
|
||||||
|
|
||||||
38
vite.config.ts
Normal file
38
vite.config.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import path from 'path'
|
||||||
|
import Inspector from 'unplugin-vue-dev-locator/vite'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import traeBadgePlugin from 'vite-plugin-trae-solo-badge'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
build: {
|
||||||
|
sourcemap: 'hidden',
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
Inspector(),
|
||||||
|
traeBadgePlugin({
|
||||||
|
variant: 'dark',
|
||||||
|
position: 'bottom-right',
|
||||||
|
prodOnly: true,
|
||||||
|
clickable: true,
|
||||||
|
clickUrl: 'https://www.trae.ai/solo?showJoin=1',
|
||||||
|
autoTheme: true,
|
||||||
|
autoThemeTarget: '#app',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'), // ✅ 定义 @ = src
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user