chore: initial import

This commit is contained in:
query-database 2026-03-25 15:46:20 +08:00
commit b7e5d1964f
63 changed files with 7278 additions and 0 deletions

0
.eslintrc.cjs Normal file
View File

25
.eslintrc.json Normal file
View 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
View 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
View 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
View File

@ -0,0 +1,5 @@
.pnpm-store
node_modules
api
dist
**/*.db

3
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

69
README.md Normal file
View File

@ -0,0 +1,69 @@
# MySQL 查询练习网站
一个面向新手/一般/进阶的 MySQL 查询练习网站:
- 登录注册
- 首次引导(填姓名/选模块/经验/选择模拟库或导入库)
- 题库分级、在线运行 SQL、判题与进度保存
- 支持模拟数据库(内置电商库/人事库)与导入自定义数据库(上传初始化 SQL
## 运行方式(本地开发)
### 1) 启动 MySQLDocker
在项目根目录执行:
```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) 后端+MySQLDocker 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
View 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
View 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
View 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
View 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
}

View 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
View 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
View 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
}

View 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})
}

View 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
}

View 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,
})
}

View 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}
}

View 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
}

View 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
}

View 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)
}

View 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)
}

View 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
}

View 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
}

View File

@ -0,0 +1,9 @@
package handlers
import (
"strings"
)
func normalizeEmail(s string) string {
return strings.TrimSpace(strings.ToLower(s))
}

View 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: "结果正确"}
}

View 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")
}
}

View 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
View 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
View 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
View 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:

View File

@ -0,0 +1,122 @@
# MySQL 查询练习网站页面设计说明Desktop-first
## 1. Global Styles全局规范
- 设计基调:学习型工具,信息密度中高,强调“编辑器可读性”和“结果对比清晰”。
- Design tokens建议
- Background: #0B1220(深色)/ #FFFFFF(浅色,可选主题开关,默认深色)
- Primary: #3B82F6Success: #22C55EWarning: #F59E0BDanger: #EF4444
- Text: 主文 #E5E7EB;次文 #9CA3AF;反白 #111827
- Font系统字体 + 等宽(编辑器/SQL 区ui-monospace
- 圆角8px卡片阴影轻量分割线1px #1F2937
- 交互状态
- Button默认/hover 提亮 610%disabled 降低不透明度并禁用点击
- Linkhover 下划线;当前路由高亮
- 响应式(桌面优先)
- ≥1200px练习页三栏
- 7681199px左右栏折叠为抽屉
- <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 Grid280px + 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 Grid320px | 1fr | 420px
- 左:题目与表结构入口
- 中:编辑器与运行
- 右:结果/判题/提示
### Meta Information
- title做题{题目标题}MySQL 查询练习
- description在线编写并运行 MySQL 查询,实时查看结果与判题。
### Page Structure
1. TopNav返回题目列表、当前题目标题、当前激活数据库标识
2. LeftQuestionPanel
- 题目描述(支持代码块/要点)
- 目标输出说明(字段/排序/过滤)
- 表结构入口(按钮打开 Drawer/Modal 展示表与字段,只读)
3. CenterEditorPanel
- Monaco EditorSQL 高亮)
- 操作区:运行、重置、保存草稿(可自动保存,仅展示状态)
- 运行状态loading、耗时、错误语法/权限/超时)
4. RightResultPanel
- 查询结果表格(表头固定、横向滚动)
- 判题结论:通过/未通过
- 最小提示:例如缺字段、行数不一致、排序不一致(不泄露完整答案)
### 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保持职责清晰

View 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
```

View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

10
postcss.config.js Normal file
View 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
View 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
View File

@ -0,0 +1,3 @@
<template>
<router-view />
</template>

1
src/assets/vue.svg Normal file
View 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
View File

@ -0,0 +1,3 @@
<template>
<div>empty</div>
</template>

View 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
View 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>

View 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)

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>

View 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
View 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
View 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
View 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
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

13
tailwind.config.js Normal file
View 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
View 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
View File

@ -0,0 +1,7 @@
{
"framework": "vite",
"installCommand": "pnpm install",
"buildCommand": "pnpm run build",
"outputDirectory": "dist"
}

38
vite.config.ts Normal file
View 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
},
},
})