重置项目
This commit is contained in:
@@ -1,45 +1 @@
|
|||||||
# Language Learning Tool
|
# Language Learning Tool
|
||||||
|
|
||||||
一个从英语背单词入门的语言学习工具。
|
|
||||||
|
|
||||||
## 目录结构
|
|
||||||
|
|
||||||
```
|
|
||||||
language/
|
|
||||||
├── backend/ Go + PostgreSQL 后台服务
|
|
||||||
├── frontend/
|
|
||||||
│ └── admin/ 管理后台(Refine + Ant Design)
|
|
||||||
├── client/ 未来客户端(App / Desktop)
|
|
||||||
└── README.md
|
|
||||||
```
|
|
||||||
|
|
||||||
## 当前阶段
|
|
||||||
|
|
||||||
- 类别管理:如「农场动物」「颜色」
|
|
||||||
- 单词维护:英文单词、中文释义、音标、例句等
|
|
||||||
- 管理后台:分类与单词的增删改查
|
|
||||||
|
|
||||||
## 技术栈
|
|
||||||
|
|
||||||
- 后台:Go 1.25.8 + PostgreSQL 18.3 + Docker,详见 [backend/README.md](backend/README.md)
|
|
||||||
- 管理后台:Vite + React 19 + Refine + Ant Design 5
|
|
||||||
|
|
||||||
## 开发
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 启动后端(热重载,API 在 :8080)
|
|
||||||
cd backend && make dev
|
|
||||||
|
|
||||||
# 启动管理后台(:5173,/api 自动代理到 :8080)
|
|
||||||
cd frontend/admin && npm install && npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
## Docker 部署
|
|
||||||
|
|
||||||
根目录一条命令拉起整套(PostgreSQL + 迁移 + API + 管理后台):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
浏览器访问 http://localhost 即可使用管理后台。
|
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
root = "."
|
|
||||||
tmp_dir = "tmp"
|
|
||||||
|
|
||||||
[build]
|
|
||||||
cmd = "go build -o ./tmp/main ./cmd/server"
|
|
||||||
bin = "./tmp/main"
|
|
||||||
full_bin = "./tmp/main"
|
|
||||||
include_ext = ["go", "mod", "sum"]
|
|
||||||
exclude_dir = ["tmp", "vendor", "migrations"]
|
|
||||||
delay = 1000
|
|
||||||
stop_on_error = true
|
|
||||||
|
|
||||||
[misc]
|
|
||||||
clean_on_exit = true
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
APP_ENV=development
|
|
||||||
HTTP_ADDR=:8080
|
|
||||||
DATABASE_URL=postgres://language:language123@localhost:5432/language?sslmode=disable
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
.env
|
|
||||||
*.log
|
|
||||||
tmp/
|
|
||||||
.DS_Store
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
.PHONY: dev up down build logs migrate migrate-down sqlc test clean
|
|
||||||
|
|
||||||
dev:
|
|
||||||
docker compose up --build
|
|
||||||
|
|
||||||
up:
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
down:
|
|
||||||
docker compose down
|
|
||||||
|
|
||||||
build:
|
|
||||||
docker compose build
|
|
||||||
|
|
||||||
logs:
|
|
||||||
docker compose logs -f api db
|
|
||||||
|
|
||||||
migrate:
|
|
||||||
migrate -path migrations -database "$(DATABASE_URL)" up
|
|
||||||
|
|
||||||
migrate-down:
|
|
||||||
migrate -path migrations -database "$(DATABASE_URL)" down 1
|
|
||||||
|
|
||||||
sqlc:
|
|
||||||
sqlc generate
|
|
||||||
|
|
||||||
test:
|
|
||||||
go test ./...
|
|
||||||
|
|
||||||
clean:
|
|
||||||
docker compose down -v
|
|
||||||
rm -rf tmp
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
# Language Backend
|
|
||||||
|
|
||||||
Go + PostgreSQL 实现的语言学习后台服务。
|
|
||||||
|
|
||||||
## 快速开始
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
|
|
||||||
# 启动开发环境(热重载)
|
|
||||||
make dev
|
|
||||||
|
|
||||||
# 数据库迁移
|
|
||||||
make migrate
|
|
||||||
|
|
||||||
# 重新生成 sqlc 代码
|
|
||||||
make sqlc
|
|
||||||
|
|
||||||
# 运行测试
|
|
||||||
make test
|
|
||||||
```
|
|
||||||
|
|
||||||
## 开发端口
|
|
||||||
|
|
||||||
- API: http://localhost:8080
|
|
||||||
- PostgreSQL: localhost:5432
|
|
||||||
|
|
||||||
## API 文档
|
|
||||||
|
|
||||||
接口以 `/api/v1` 为前缀。
|
|
||||||
|
|
||||||
### Categories
|
|
||||||
|
|
||||||
- `GET /api/v1/categories`
|
|
||||||
- `POST /api/v1/categories`
|
|
||||||
- `GET /api/v1/categories/:id`
|
|
||||||
- `PUT /api/v1/categories/:id`
|
|
||||||
- `DELETE /api/v1/categories/:id`(分类下仍有单词时返回 409,code 10005)
|
|
||||||
|
|
||||||
### Words
|
|
||||||
|
|
||||||
- `GET /api/v1/words`
|
|
||||||
- `POST /api/v1/words`
|
|
||||||
- `GET /api/v1/words/:id`
|
|
||||||
- `PUT /api/v1/words/:id`
|
|
||||||
- `DELETE /api/v1/words/:id`
|
|
||||||
|
|
||||||
## 环境变量
|
|
||||||
|
|
||||||
复制 `.env.example` 为 `.env` 并按需修改。
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"log/slog"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"syscall"
|
|
||||||
|
|
||||||
"github.com/fish/language/backend/internal/app"
|
|
||||||
"github.com/fish/language/backend/internal/config"
|
|
||||||
"github.com/fish/language/backend/internal/repository"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
cfg, err := config.Load()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to load config", "error", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
pool, err := repository.NewPool(ctx, cfg.DatabaseURL)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to connect to database", "error", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
defer pool.Close()
|
|
||||||
|
|
||||||
application := app.New(cfg, pool)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
if err := application.Run(); err != nil {
|
|
||||||
slog.Error("server error", "error", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
slog.Info("server started", "addr", cfg.HTTPAddr)
|
|
||||||
|
|
||||||
quit := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
|
||||||
<-quit
|
|
||||||
|
|
||||||
slog.Info("shutting down server")
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
services:
|
|
||||||
db:
|
|
||||||
image: postgres:18.3-alpine3.23
|
|
||||||
container_name: language-db
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: language
|
|
||||||
POSTGRES_PASSWORD: language123
|
|
||||||
POSTGRES_DB: language
|
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
volumes:
|
|
||||||
- postgres_data:/var/lib/postgresql
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U language -d language"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
|
|
||||||
api:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: docker/Dockerfile
|
|
||||||
target: dev
|
|
||||||
container_name: language-api
|
|
||||||
environment:
|
|
||||||
APP_ENV: development
|
|
||||||
DATABASE_URL: postgres://language:language123@db:5432/language?sslmode=disable
|
|
||||||
HTTP_ADDR: :8080
|
|
||||||
ports:
|
|
||||||
- "8080:8080"
|
|
||||||
volumes:
|
|
||||||
- .:/app
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
command: ["air", "-c", ".air.toml"]
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
postgres_data:
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
FROM golang:1.25.8-alpine3.23 AS base
|
|
||||||
WORKDIR /app
|
|
||||||
RUN apk add --no-cache git make
|
|
||||||
|
|
||||||
FROM base AS dev
|
|
||||||
RUN go install github.com/air-verse/air@latest
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
CMD ["air", "-c", ".air.toml"]
|
|
||||||
|
|
||||||
FROM base AS builder
|
|
||||||
COPY . .
|
|
||||||
RUN go build -o /bin/server ./cmd/server
|
|
||||||
|
|
||||||
FROM alpine:3.23 AS prod
|
|
||||||
RUN apk add --no-cache ca-certificates
|
|
||||||
COPY --from=builder /bin/server /bin/server
|
|
||||||
EXPOSE 8080
|
|
||||||
CMD ["/bin/server"]
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
module github.com/fish/language/backend
|
|
||||||
|
|
||||||
go 1.25
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/go-chi/chi/v5 v5.2.0
|
|
||||||
github.com/jackc/pgx/v5 v5.7.2
|
|
||||||
github.com/kelseyhightower/envconfig v1.4.0
|
|
||||||
)
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
|
||||||
golang.org/x/crypto v0.31.0 // indirect
|
|
||||||
golang.org/x/sync v0.10.0 // indirect
|
|
||||||
golang.org/x/text v0.21.0 // indirect
|
|
||||||
)
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
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/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0=
|
|
||||||
github.com/go-chi/chi/v5 v5.2.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
|
||||||
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
|
|
||||||
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
|
||||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
|
||||||
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
|
|
||||||
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
|
|
||||||
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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
|
||||||
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.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
|
||||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
|
||||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
|
||||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
|
||||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
|
||||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
|
||||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
package app
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/fish/language/backend/internal/config"
|
|
||||||
"github.com/fish/language/backend/internal/handlers"
|
|
||||||
"github.com/fish/language/backend/internal/repository/queries"
|
|
||||||
"github.com/fish/language/backend/internal/services"
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
// App wires all dependencies and exposes the HTTP server.
|
|
||||||
type App struct {
|
|
||||||
cfg *config.Config
|
|
||||||
router *chi.Mux
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
// New creates and initializes a new App.
|
|
||||||
func New(cfg *config.Config, pool *pgxpool.Pool) *App {
|
|
||||||
app := &App{
|
|
||||||
cfg: cfg,
|
|
||||||
pool: pool,
|
|
||||||
}
|
|
||||||
app.setupRouter()
|
|
||||||
return app
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) setupRouter() {
|
|
||||||
r := chi.NewRouter()
|
|
||||||
r.Use(middleware.Logger)
|
|
||||||
r.Use(middleware.Recoverer)
|
|
||||||
r.Use(middleware.RequestID)
|
|
||||||
r.Use(middleware.RealIP)
|
|
||||||
r.Use(middleware.SetHeader("Content-Type", "application/json"))
|
|
||||||
|
|
||||||
q := queries.New(a.pool)
|
|
||||||
categorySvc := services.NewCategoryService(q)
|
|
||||||
wordSvc := services.NewWordService(q)
|
|
||||||
|
|
||||||
categoryHandler := handlers.NewCategoryHandler(categorySvc)
|
|
||||||
wordHandler := handlers.NewWordHandler(wordSvc)
|
|
||||||
|
|
||||||
r.Route("/api/v1", func(api chi.Router) {
|
|
||||||
categoryHandler.Register(api)
|
|
||||||
wordHandler.Register(api)
|
|
||||||
})
|
|
||||||
|
|
||||||
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
|
||||||
})
|
|
||||||
|
|
||||||
a.router = r
|
|
||||||
}
|
|
||||||
|
|
||||||
// Router returns the underlying chi router for testing.
|
|
||||||
func (a *App) Router() *chi.Mux {
|
|
||||||
return a.router
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run starts the HTTP server.
|
|
||||||
func (a *App) Run() error {
|
|
||||||
return http.ListenAndServe(a.cfg.HTTPAddr, a.router)
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/kelseyhightower/envconfig"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Config holds all application configuration loaded from environment variables.
|
|
||||||
type Config struct {
|
|
||||||
AppEnv string `envconfig:"APP_ENV" default:"development"`
|
|
||||||
HTTPAddr string `envconfig:"HTTP_ADDR" default:":8080"`
|
|
||||||
DatabaseURL string `envconfig:"DATABASE_URL" required:"true"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load parses environment variables into Config.
|
|
||||||
func Load() (*Config, error) {
|
|
||||||
var cfg Config
|
|
||||||
if err := envconfig.Process("", &cfg); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to load config: %w", err)
|
|
||||||
}
|
|
||||||
return &cfg, nil
|
|
||||||
}
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/fish/language/backend/internal/models"
|
|
||||||
"github.com/fish/language/backend/internal/services"
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CategoryHandler handles HTTP requests for categories.
|
|
||||||
type CategoryHandler struct {
|
|
||||||
svc *services.CategoryService
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewCategoryHandler creates a new CategoryHandler.
|
|
||||||
func NewCategoryHandler(svc *services.CategoryService) *CategoryHandler {
|
|
||||||
return &CategoryHandler{svc: svc}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register mounts category routes.
|
|
||||||
func (h *CategoryHandler) Register(r chi.Router) {
|
|
||||||
r.Get("/categories", h.List)
|
|
||||||
r.Post("/categories", h.Create)
|
|
||||||
r.Get("/categories/{id}", h.Get)
|
|
||||||
r.Put("/categories/{id}", h.Update)
|
|
||||||
r.Delete("/categories/{id}", h.Delete)
|
|
||||||
}
|
|
||||||
|
|
||||||
// List returns all categories.
|
|
||||||
func (h *CategoryHandler) List(w http.ResponseWriter, r *http.Request) {
|
|
||||||
items, err := h.svc.List(r.Context())
|
|
||||||
if err != nil {
|
|
||||||
status, code, msg := MapError(err)
|
|
||||||
RespondError(w, status, code, msg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
RespondSuccess(w, items)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get returns a category by id.
|
|
||||||
func (h *CategoryHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id, err := parseID(r)
|
|
||||||
if err != nil {
|
|
||||||
RespondError(w, http.StatusBadRequest, 10002, "invalid id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
item, err := h.svc.Get(r.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
status, code, msg := MapError(err)
|
|
||||||
RespondError(w, status, code, msg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
RespondSuccess(w, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create creates a new category.
|
|
||||||
func (h *CategoryHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req models.CreateCategoryRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
RespondError(w, http.StatusBadRequest, 10003, "invalid request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.Name == "" {
|
|
||||||
RespondError(w, http.StatusBadRequest, 10004, "name is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
item, err := h.svc.Create(r.Context(), req)
|
|
||||||
if err != nil {
|
|
||||||
status, code, msg := MapError(err)
|
|
||||||
RespondError(w, status, code, msg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
RespondJSON(w, http.StatusCreated, modelsAPIResponse{Code: 0, Message: "created", Data: item})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update updates a category.
|
|
||||||
func (h *CategoryHandler) Update(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id, err := parseID(r)
|
|
||||||
if err != nil {
|
|
||||||
RespondError(w, http.StatusBadRequest, 10002, "invalid id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req models.UpdateCategoryRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
RespondError(w, http.StatusBadRequest, 10003, "invalid request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.Name == "" {
|
|
||||||
RespondError(w, http.StatusBadRequest, 10004, "name is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
item, err := h.svc.Update(r.Context(), id, req)
|
|
||||||
if err != nil {
|
|
||||||
status, code, msg := MapError(err)
|
|
||||||
RespondError(w, status, code, msg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
RespondSuccess(w, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete removes a category.
|
|
||||||
func (h *CategoryHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id, err := parseID(r)
|
|
||||||
if err != nil {
|
|
||||||
RespondError(w, http.StatusBadRequest, 10002, "invalid id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := h.svc.Delete(r.Context(), id); err != nil {
|
|
||||||
status, code, msg := MapError(err)
|
|
||||||
RespondError(w, status, code, msg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
RespondSuccess(w, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseID(r *http.Request) (int32, error) {
|
|
||||||
idStr := chi.URLParam(r, "id")
|
|
||||||
id64, err := strconv.ParseInt(idStr, 10, 32)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return int32(id64), nil
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/fish/language/backend/internal/services"
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RespondJSON writes a JSON response with the given status code.
|
|
||||||
func RespondJSON(w http.ResponseWriter, status int, data any) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(status)
|
|
||||||
_ = json.NewEncoder(w).Encode(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RespondSuccess returns the common success envelope.
|
|
||||||
func RespondSuccess(w http.ResponseWriter, data any) {
|
|
||||||
RespondJSON(w, http.StatusOK, modelsAPIResponse{Code: 0, Message: "ok", Data: data})
|
|
||||||
}
|
|
||||||
|
|
||||||
// RespondError returns the common error envelope.
|
|
||||||
func RespondError(w http.ResponseWriter, status int, code int, message string) {
|
|
||||||
RespondJSON(w, status, modelsAPIResponse{Code: code, Message: message, Data: nil})
|
|
||||||
}
|
|
||||||
|
|
||||||
// MapError maps repository/ service errors to HTTP status and code.
|
|
||||||
func MapError(err error) (status int, code int, message string) {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return http.StatusNotFound, 10001, "resource not found"
|
|
||||||
}
|
|
||||||
if errors.Is(err, services.ErrCategoryNotEmpty) {
|
|
||||||
return http.StatusConflict, 10005, "category has words and cannot be deleted"
|
|
||||||
}
|
|
||||||
return http.StatusInternalServerError, 10000, "internal server error"
|
|
||||||
}
|
|
||||||
|
|
||||||
type modelsAPIResponse struct {
|
|
||||||
Code int `json:"code"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
Data any `json:"data"`
|
|
||||||
}
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/fish/language/backend/internal/models"
|
|
||||||
"github.com/fish/language/backend/internal/services"
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
// WordHandler handles HTTP requests for words.
|
|
||||||
type WordHandler struct {
|
|
||||||
svc *services.WordService
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewWordHandler creates a new WordHandler.
|
|
||||||
func NewWordHandler(svc *services.WordService) *WordHandler {
|
|
||||||
return &WordHandler{svc: svc}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register mounts word routes.
|
|
||||||
func (h *WordHandler) Register(r chi.Router) {
|
|
||||||
r.Get("/words", h.List)
|
|
||||||
r.Post("/words", h.Create)
|
|
||||||
r.Get("/words/{id}", h.Get)
|
|
||||||
r.Put("/words/{id}", h.Update)
|
|
||||||
r.Delete("/words/{id}", h.Delete)
|
|
||||||
}
|
|
||||||
|
|
||||||
// List returns a paginated list of words.
|
|
||||||
func (h *WordHandler) List(w http.ResponseWriter, r *http.Request) {
|
|
||||||
req := models.ListWordsRequest{}
|
|
||||||
if v := r.URL.Query().Get("category_id"); v != "" {
|
|
||||||
if id64, err := strconv.ParseInt(v, 10, 32); err == nil {
|
|
||||||
id := int32(id64)
|
|
||||||
req.CategoryID = &id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
req.Keyword = r.URL.Query().Get("keyword")
|
|
||||||
if v := r.URL.Query().Get("page"); v != "" {
|
|
||||||
if page, err := strconv.ParseInt(v, 10, 32); err == nil {
|
|
||||||
req.Page = int32(page)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if v := r.URL.Query().Get("page_size"); v != "" {
|
|
||||||
if ps, err := strconv.ParseInt(v, 10, 32); err == nil {
|
|
||||||
req.PageSize = int32(ps)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := h.svc.List(r.Context(), req)
|
|
||||||
if err != nil {
|
|
||||||
status, code, msg := MapError(err)
|
|
||||||
RespondError(w, status, code, msg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
RespondSuccess(w, resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get returns a word by id.
|
|
||||||
func (h *WordHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id, err := parseID(r)
|
|
||||||
if err != nil {
|
|
||||||
RespondError(w, http.StatusBadRequest, 10002, "invalid id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
item, err := h.svc.Get(r.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
status, code, msg := MapError(err)
|
|
||||||
RespondError(w, status, code, msg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
RespondSuccess(w, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create creates a new word.
|
|
||||||
func (h *WordHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req models.CreateWordRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
RespondError(w, http.StatusBadRequest, 10003, "invalid request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.Word == "" || req.Translation == "" || req.CategoryID == 0 {
|
|
||||||
RespondError(w, http.StatusBadRequest, 10004, "word, translation and category_id are required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
item, err := h.svc.Create(r.Context(), req)
|
|
||||||
if err != nil {
|
|
||||||
status, code, msg := MapError(err)
|
|
||||||
RespondError(w, status, code, msg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
RespondJSON(w, http.StatusCreated, modelsAPIResponse{Code: 0, Message: "created", Data: item})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update updates a word.
|
|
||||||
func (h *WordHandler) Update(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id, err := parseID(r)
|
|
||||||
if err != nil {
|
|
||||||
RespondError(w, http.StatusBadRequest, 10002, "invalid id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req models.UpdateWordRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
RespondError(w, http.StatusBadRequest, 10003, "invalid request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.Word == "" || req.Translation == "" || req.CategoryID == 0 {
|
|
||||||
RespondError(w, http.StatusBadRequest, 10004, "word, translation and category_id are required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
item, err := h.svc.Update(r.Context(), id, req)
|
|
||||||
if err != nil {
|
|
||||||
status, code, msg := MapError(err)
|
|
||||||
RespondError(w, status, code, msg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
RespondSuccess(w, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete removes a word.
|
|
||||||
func (h *WordHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id, err := parseID(r)
|
|
||||||
if err != nil {
|
|
||||||
RespondError(w, http.StatusBadRequest, 10002, "invalid id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := h.svc.Delete(r.Context(), id); err != nil {
|
|
||||||
status, code, msg := MapError(err)
|
|
||||||
RespondError(w, status, code, msg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
RespondSuccess(w, nil)
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
package models
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// Category represents a word category, such as "农场动物" or "颜色".
|
|
||||||
type Category struct {
|
|
||||||
ID int32 `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
SortOrder int32 `json:"sort_order"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateCategoryRequest is the payload for creating a category.
|
|
||||||
type CreateCategoryRequest struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
SortOrder int32 `json:"sort_order"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateCategoryRequest is the payload for updating a category.
|
|
||||||
type UpdateCategoryRequest struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
SortOrder int32 `json:"sort_order"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Word represents a vocabulary word.
|
|
||||||
type Word struct {
|
|
||||||
ID int32 `json:"id"`
|
|
||||||
CategoryID int32 `json:"category_id"`
|
|
||||||
Word string `json:"word"`
|
|
||||||
Translation string `json:"translation"`
|
|
||||||
Phonetic *string `json:"phonetic,omitempty"`
|
|
||||||
ExampleSentence *string `json:"example_sentence,omitempty"`
|
|
||||||
AudioURL *string `json:"audio_url,omitempty"`
|
|
||||||
ImageURL *string `json:"image_url,omitempty"`
|
|
||||||
SortOrder int32 `json:"sort_order"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateWordRequest is the payload for creating a word.
|
|
||||||
type CreateWordRequest struct {
|
|
||||||
CategoryID int32 `json:"category_id"`
|
|
||||||
Word string `json:"word"`
|
|
||||||
Translation string `json:"translation"`
|
|
||||||
Phonetic *string `json:"phonetic,omitempty"`
|
|
||||||
ExampleSentence *string `json:"example_sentence,omitempty"`
|
|
||||||
AudioURL *string `json:"audio_url,omitempty"`
|
|
||||||
ImageURL *string `json:"image_url,omitempty"`
|
|
||||||
SortOrder int32 `json:"sort_order"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateWordRequest is the payload for updating a word.
|
|
||||||
type UpdateWordRequest struct {
|
|
||||||
CategoryID int32 `json:"category_id"`
|
|
||||||
Word string `json:"word"`
|
|
||||||
Translation string `json:"translation"`
|
|
||||||
Phonetic *string `json:"phonetic,omitempty"`
|
|
||||||
ExampleSentence *string `json:"example_sentence,omitempty"`
|
|
||||||
AudioURL *string `json:"audio_url,omitempty"`
|
|
||||||
ImageURL *string `json:"image_url,omitempty"`
|
|
||||||
SortOrder int32 `json:"sort_order"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListWordsRequest holds query parameters for listing words.
|
|
||||||
type ListWordsRequest struct {
|
|
||||||
CategoryID *int32 `json:"category_id,omitempty"`
|
|
||||||
Keyword string `json:"keyword,omitempty"`
|
|
||||||
Page int32 `json:"page,omitempty"`
|
|
||||||
PageSize int32 `json:"page_size,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListWordsResponse is the paginated response for word listing.
|
|
||||||
type ListWordsResponse struct {
|
|
||||||
Words []Word `json:"words"`
|
|
||||||
Total int64 `json:"total"`
|
|
||||||
Page int32 `json:"page"`
|
|
||||||
PageSize int32 `json:"page_size"`
|
|
||||||
TotalPages int32 `json:"total_pages"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// APIResponse is the common JSON envelope for all API responses.
|
|
||||||
type APIResponse struct {
|
|
||||||
Code int `json:"code"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
Data any `json:"data"`
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
package repository
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
// NewPool creates a new pgx connection pool.
|
|
||||||
func NewPool(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
|
|
||||||
pool, err := pgxpool.New(ctx, databaseURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create connection pool: %w", err)
|
|
||||||
}
|
|
||||||
if err := pool.Ping(ctx); err != nil {
|
|
||||||
pool.Close()
|
|
||||||
return nil, fmt.Errorf("failed to ping database: %w", err)
|
|
||||||
}
|
|
||||||
return pool, nil
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
|
||||||
// versions:
|
|
||||||
// sqlc v1.31.1
|
|
||||||
// source: category.sql
|
|
||||||
|
|
||||||
package queries
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
)
|
|
||||||
|
|
||||||
const createCategory = `-- name: CreateCategory :one
|
|
||||||
INSERT INTO categories (name, sort_order)
|
|
||||||
VALUES ($1, $2)
|
|
||||||
RETURNING id, name, sort_order, created_at, updated_at
|
|
||||||
`
|
|
||||||
|
|
||||||
type CreateCategoryParams struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
SortOrder int32 `json:"sort_order"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) CreateCategory(ctx context.Context, arg CreateCategoryParams) (Category, error) {
|
|
||||||
row := q.db.QueryRow(ctx, createCategory, arg.Name, arg.SortOrder)
|
|
||||||
var i Category
|
|
||||||
err := row.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.Name,
|
|
||||||
&i.SortOrder,
|
|
||||||
&i.CreatedAt,
|
|
||||||
&i.UpdatedAt,
|
|
||||||
)
|
|
||||||
return i, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteCategory = `-- name: DeleteCategory :exec
|
|
||||||
DELETE FROM categories WHERE id = $1
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) DeleteCategory(ctx context.Context, id int32) error {
|
|
||||||
_, err := q.db.Exec(ctx, deleteCategory, id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
const getCategory = `-- name: GetCategory :one
|
|
||||||
SELECT id, name, sort_order, created_at, updated_at
|
|
||||||
FROM categories
|
|
||||||
WHERE id = $1
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) GetCategory(ctx context.Context, id int32) (Category, error) {
|
|
||||||
row := q.db.QueryRow(ctx, getCategory, id)
|
|
||||||
var i Category
|
|
||||||
err := row.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.Name,
|
|
||||||
&i.SortOrder,
|
|
||||||
&i.CreatedAt,
|
|
||||||
&i.UpdatedAt,
|
|
||||||
)
|
|
||||||
return i, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const listCategories = `-- name: ListCategories :many
|
|
||||||
SELECT id, name, sort_order, created_at, updated_at
|
|
||||||
FROM categories
|
|
||||||
ORDER BY sort_order ASC, id ASC
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) ListCategories(ctx context.Context) ([]Category, error) {
|
|
||||||
rows, err := q.db.Query(ctx, listCategories)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
items := []Category{}
|
|
||||||
for rows.Next() {
|
|
||||||
var i Category
|
|
||||||
if err := rows.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.Name,
|
|
||||||
&i.SortOrder,
|
|
||||||
&i.CreatedAt,
|
|
||||||
&i.UpdatedAt,
|
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
items = append(items, i)
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateCategory = `-- name: UpdateCategory :one
|
|
||||||
UPDATE categories
|
|
||||||
SET name = $2,
|
|
||||||
sort_order = $3,
|
|
||||||
updated_at = NOW()
|
|
||||||
WHERE id = $1
|
|
||||||
RETURNING id, name, sort_order, created_at, updated_at
|
|
||||||
`
|
|
||||||
|
|
||||||
type UpdateCategoryParams struct {
|
|
||||||
ID int32 `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
SortOrder int32 `json:"sort_order"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) UpdateCategory(ctx context.Context, arg UpdateCategoryParams) (Category, error) {
|
|
||||||
row := q.db.QueryRow(ctx, updateCategory, arg.ID, arg.Name, arg.SortOrder)
|
|
||||||
var i Category
|
|
||||||
err := row.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.Name,
|
|
||||||
&i.SortOrder,
|
|
||||||
&i.CreatedAt,
|
|
||||||
&i.UpdatedAt,
|
|
||||||
)
|
|
||||||
return i, err
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
|
||||||
// versions:
|
|
||||||
// sqlc v1.31.1
|
|
||||||
|
|
||||||
package queries
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgconn"
|
|
||||||
)
|
|
||||||
|
|
||||||
type DBTX interface {
|
|
||||||
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
|
|
||||||
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
|
|
||||||
QueryRow(context.Context, string, ...interface{}) pgx.Row
|
|
||||||
}
|
|
||||||
|
|
||||||
func New(db DBTX) *Queries {
|
|
||||||
return &Queries{db: db}
|
|
||||||
}
|
|
||||||
|
|
||||||
type Queries struct {
|
|
||||||
db DBTX
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
|
|
||||||
return &Queries{
|
|
||||||
db: tx,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
|
||||||
// versions:
|
|
||||||
// sqlc v1.31.1
|
|
||||||
|
|
||||||
package queries
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Category struct {
|
|
||||||
ID int32 `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
SortOrder int32 `json:"sort_order"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Word struct {
|
|
||||||
ID int32 `json:"id"`
|
|
||||||
CategoryID int32 `json:"category_id"`
|
|
||||||
Word string `json:"word"`
|
|
||||||
Translation string `json:"translation"`
|
|
||||||
Phonetic *string `json:"phonetic"`
|
|
||||||
ExampleSentence *string `json:"example_sentence"`
|
|
||||||
AudioUrl *string `json:"audio_url"`
|
|
||||||
ImageUrl *string `json:"image_url"`
|
|
||||||
SortOrder int32 `json:"sort_order"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
|
||||||
// versions:
|
|
||||||
// sqlc v1.31.1
|
|
||||||
|
|
||||||
package queries
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Querier interface {
|
|
||||||
CountWords(ctx context.Context, arg CountWordsParams) (int64, error)
|
|
||||||
CreateCategory(ctx context.Context, arg CreateCategoryParams) (Category, error)
|
|
||||||
CreateWord(ctx context.Context, arg CreateWordParams) (Word, error)
|
|
||||||
DeleteCategory(ctx context.Context, id int32) error
|
|
||||||
DeleteWord(ctx context.Context, id int32) error
|
|
||||||
GetCategory(ctx context.Context, id int32) (Category, error)
|
|
||||||
GetWord(ctx context.Context, id int32) (Word, error)
|
|
||||||
ListCategories(ctx context.Context) ([]Category, error)
|
|
||||||
ListWords(ctx context.Context, arg ListWordsParams) ([]Word, error)
|
|
||||||
UpdateCategory(ctx context.Context, arg UpdateCategoryParams) (Category, error)
|
|
||||||
UpdateWord(ctx context.Context, arg UpdateWordParams) (Word, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ Querier = (*Queries)(nil)
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
|
||||||
// versions:
|
|
||||||
// sqlc v1.31.1
|
|
||||||
// source: word.sql
|
|
||||||
|
|
||||||
package queries
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
)
|
|
||||||
|
|
||||||
const countWords = `-- name: CountWords :one
|
|
||||||
SELECT COUNT(*) AS total
|
|
||||||
FROM words w
|
|
||||||
WHERE
|
|
||||||
($1::int = 0 OR w.category_id = $1)
|
|
||||||
AND ($2::text = '' OR w.word ILIKE '%' || $2 || '%' OR w.translation ILIKE '%' || $2 || '%')
|
|
||||||
`
|
|
||||||
|
|
||||||
type CountWordsParams struct {
|
|
||||||
CategoryID int32 `json:"category_id"`
|
|
||||||
Keyword string `json:"keyword"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) CountWords(ctx context.Context, arg CountWordsParams) (int64, error) {
|
|
||||||
row := q.db.QueryRow(ctx, countWords, arg.CategoryID, arg.Keyword)
|
|
||||||
var total int64
|
|
||||||
err := row.Scan(&total)
|
|
||||||
return total, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const createWord = `-- name: CreateWord :one
|
|
||||||
INSERT INTO words (category_id, word, translation, phonetic, example_sentence, audio_url, image_url, sort_order)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
||||||
RETURNING id, category_id, word, translation, phonetic,
|
|
||||||
example_sentence, audio_url, image_url, sort_order,
|
|
||||||
created_at, updated_at
|
|
||||||
`
|
|
||||||
|
|
||||||
type CreateWordParams struct {
|
|
||||||
CategoryID int32 `json:"category_id"`
|
|
||||||
Word string `json:"word"`
|
|
||||||
Translation string `json:"translation"`
|
|
||||||
Phonetic *string `json:"phonetic"`
|
|
||||||
ExampleSentence *string `json:"example_sentence"`
|
|
||||||
AudioUrl *string `json:"audio_url"`
|
|
||||||
ImageUrl *string `json:"image_url"`
|
|
||||||
SortOrder int32 `json:"sort_order"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) CreateWord(ctx context.Context, arg CreateWordParams) (Word, error) {
|
|
||||||
row := q.db.QueryRow(ctx, createWord,
|
|
||||||
arg.CategoryID,
|
|
||||||
arg.Word,
|
|
||||||
arg.Translation,
|
|
||||||
arg.Phonetic,
|
|
||||||
arg.ExampleSentence,
|
|
||||||
arg.AudioUrl,
|
|
||||||
arg.ImageUrl,
|
|
||||||
arg.SortOrder,
|
|
||||||
)
|
|
||||||
var i Word
|
|
||||||
err := row.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.CategoryID,
|
|
||||||
&i.Word,
|
|
||||||
&i.Translation,
|
|
||||||
&i.Phonetic,
|
|
||||||
&i.ExampleSentence,
|
|
||||||
&i.AudioUrl,
|
|
||||||
&i.ImageUrl,
|
|
||||||
&i.SortOrder,
|
|
||||||
&i.CreatedAt,
|
|
||||||
&i.UpdatedAt,
|
|
||||||
)
|
|
||||||
return i, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteWord = `-- name: DeleteWord :exec
|
|
||||||
DELETE FROM words WHERE id = $1
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) DeleteWord(ctx context.Context, id int32) error {
|
|
||||||
_, err := q.db.Exec(ctx, deleteWord, id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
const getWord = `-- name: GetWord :one
|
|
||||||
SELECT id, category_id, word, translation, phonetic,
|
|
||||||
example_sentence, audio_url, image_url, sort_order,
|
|
||||||
created_at, updated_at
|
|
||||||
FROM words
|
|
||||||
WHERE id = $1
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) GetWord(ctx context.Context, id int32) (Word, error) {
|
|
||||||
row := q.db.QueryRow(ctx, getWord, id)
|
|
||||||
var i Word
|
|
||||||
err := row.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.CategoryID,
|
|
||||||
&i.Word,
|
|
||||||
&i.Translation,
|
|
||||||
&i.Phonetic,
|
|
||||||
&i.ExampleSentence,
|
|
||||||
&i.AudioUrl,
|
|
||||||
&i.ImageUrl,
|
|
||||||
&i.SortOrder,
|
|
||||||
&i.CreatedAt,
|
|
||||||
&i.UpdatedAt,
|
|
||||||
)
|
|
||||||
return i, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const listWords = `-- name: ListWords :many
|
|
||||||
SELECT w.id, w.category_id, w.word, w.translation, w.phonetic,
|
|
||||||
w.example_sentence, w.audio_url, w.image_url, w.sort_order,
|
|
||||||
w.created_at, w.updated_at
|
|
||||||
FROM words w
|
|
||||||
WHERE
|
|
||||||
($3::int = 0 OR w.category_id = $3)
|
|
||||||
AND ($4::text = '' OR w.word ILIKE '%' || $4 || '%' OR w.translation ILIKE '%' || $4 || '%')
|
|
||||||
ORDER BY w.sort_order ASC, w.id ASC
|
|
||||||
LIMIT $1 OFFSET $2
|
|
||||||
`
|
|
||||||
|
|
||||||
type ListWordsParams struct {
|
|
||||||
Limit int32 `json:"limit"`
|
|
||||||
Offset int32 `json:"offset"`
|
|
||||||
CategoryID int32 `json:"category_id"`
|
|
||||||
Keyword string `json:"keyword"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) ListWords(ctx context.Context, arg ListWordsParams) ([]Word, error) {
|
|
||||||
rows, err := q.db.Query(ctx, listWords,
|
|
||||||
arg.Limit,
|
|
||||||
arg.Offset,
|
|
||||||
arg.CategoryID,
|
|
||||||
arg.Keyword,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
items := []Word{}
|
|
||||||
for rows.Next() {
|
|
||||||
var i Word
|
|
||||||
if err := rows.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.CategoryID,
|
|
||||||
&i.Word,
|
|
||||||
&i.Translation,
|
|
||||||
&i.Phonetic,
|
|
||||||
&i.ExampleSentence,
|
|
||||||
&i.AudioUrl,
|
|
||||||
&i.ImageUrl,
|
|
||||||
&i.SortOrder,
|
|
||||||
&i.CreatedAt,
|
|
||||||
&i.UpdatedAt,
|
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
items = append(items, i)
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateWord = `-- name: UpdateWord :one
|
|
||||||
UPDATE words
|
|
||||||
SET category_id = $2,
|
|
||||||
word = $3,
|
|
||||||
translation = $4,
|
|
||||||
phonetic = $5,
|
|
||||||
example_sentence = $6,
|
|
||||||
audio_url = $7,
|
|
||||||
image_url = $8,
|
|
||||||
sort_order = $9,
|
|
||||||
updated_at = NOW()
|
|
||||||
WHERE id = $1
|
|
||||||
RETURNING id, category_id, word, translation, phonetic,
|
|
||||||
example_sentence, audio_url, image_url, sort_order,
|
|
||||||
created_at, updated_at
|
|
||||||
`
|
|
||||||
|
|
||||||
type UpdateWordParams struct {
|
|
||||||
ID int32 `json:"id"`
|
|
||||||
CategoryID int32 `json:"category_id"`
|
|
||||||
Word string `json:"word"`
|
|
||||||
Translation string `json:"translation"`
|
|
||||||
Phonetic *string `json:"phonetic"`
|
|
||||||
ExampleSentence *string `json:"example_sentence"`
|
|
||||||
AudioUrl *string `json:"audio_url"`
|
|
||||||
ImageUrl *string `json:"image_url"`
|
|
||||||
SortOrder int32 `json:"sort_order"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) UpdateWord(ctx context.Context, arg UpdateWordParams) (Word, error) {
|
|
||||||
row := q.db.QueryRow(ctx, updateWord,
|
|
||||||
arg.ID,
|
|
||||||
arg.CategoryID,
|
|
||||||
arg.Word,
|
|
||||||
arg.Translation,
|
|
||||||
arg.Phonetic,
|
|
||||||
arg.ExampleSentence,
|
|
||||||
arg.AudioUrl,
|
|
||||||
arg.ImageUrl,
|
|
||||||
arg.SortOrder,
|
|
||||||
)
|
|
||||||
var i Word
|
|
||||||
err := row.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.CategoryID,
|
|
||||||
&i.Word,
|
|
||||||
&i.Translation,
|
|
||||||
&i.Phonetic,
|
|
||||||
&i.ExampleSentence,
|
|
||||||
&i.AudioUrl,
|
|
||||||
&i.ImageUrl,
|
|
||||||
&i.SortOrder,
|
|
||||||
&i.CreatedAt,
|
|
||||||
&i.UpdatedAt,
|
|
||||||
)
|
|
||||||
return i, err
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
|
|
||||||
"github.com/fish/language/backend/internal/models"
|
|
||||||
"github.com/fish/language/backend/internal/repository/queries"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ErrCategoryNotEmpty is returned when deleting a category that still has words.
|
|
||||||
var ErrCategoryNotEmpty = errors.New("category has words")
|
|
||||||
|
|
||||||
// CategoryService handles business logic for categories.
|
|
||||||
type CategoryService struct {
|
|
||||||
q *queries.Queries
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewCategoryService creates a new CategoryService.
|
|
||||||
func NewCategoryService(q *queries.Queries) *CategoryService {
|
|
||||||
return &CategoryService{q: q}
|
|
||||||
}
|
|
||||||
|
|
||||||
// List returns all categories ordered by sort_order.
|
|
||||||
func (s *CategoryService) List(ctx context.Context) ([]models.Category, error) {
|
|
||||||
rows, err := s.q.ListCategories(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out := make([]models.Category, len(rows))
|
|
||||||
for i, r := range rows {
|
|
||||||
out[i] = toCategory(r)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get returns a single category by id.
|
|
||||||
func (s *CategoryService) Get(ctx context.Context, id int32) (models.Category, error) {
|
|
||||||
row, err := s.q.GetCategory(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return models.Category{}, err
|
|
||||||
}
|
|
||||||
return toCategory(row), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create creates a new category.
|
|
||||||
func (s *CategoryService) Create(ctx context.Context, req models.CreateCategoryRequest) (models.Category, error) {
|
|
||||||
row, err := s.q.CreateCategory(ctx, queries.CreateCategoryParams{
|
|
||||||
Name: req.Name,
|
|
||||||
SortOrder: req.SortOrder,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return models.Category{}, err
|
|
||||||
}
|
|
||||||
return toCategory(row), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update updates an existing category.
|
|
||||||
func (s *CategoryService) Update(ctx context.Context, id int32, req models.UpdateCategoryRequest) (models.Category, error) {
|
|
||||||
row, err := s.q.UpdateCategory(ctx, queries.UpdateCategoryParams{
|
|
||||||
ID: id,
|
|
||||||
Name: req.Name,
|
|
||||||
SortOrder: req.SortOrder,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return models.Category{}, err
|
|
||||||
}
|
|
||||||
return toCategory(row), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete removes a category by id. It refuses to delete a category that still has words.
|
|
||||||
func (s *CategoryService) Delete(ctx context.Context, id int32) error {
|
|
||||||
count, err := s.q.CountWords(ctx, queries.CountWordsParams{CategoryID: id})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if count > 0 {
|
|
||||||
return ErrCategoryNotEmpty
|
|
||||||
}
|
|
||||||
return s.q.DeleteCategory(ctx, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func toCategory(c queries.Category) models.Category {
|
|
||||||
return models.Category{
|
|
||||||
ID: c.ID,
|
|
||||||
Name: c.Name,
|
|
||||||
SortOrder: c.SortOrder,
|
|
||||||
CreatedAt: c.CreatedAt,
|
|
||||||
UpdatedAt: c.UpdatedAt,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"math"
|
|
||||||
|
|
||||||
"github.com/fish/language/backend/internal/models"
|
|
||||||
"github.com/fish/language/backend/internal/repository/queries"
|
|
||||||
)
|
|
||||||
|
|
||||||
const defaultPageSize = 20
|
|
||||||
const maxPageSize = 100
|
|
||||||
|
|
||||||
// WordService handles business logic for words.
|
|
||||||
type WordService struct {
|
|
||||||
q *queries.Queries
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewWordService creates a new WordService.
|
|
||||||
func NewWordService(q *queries.Queries) *WordService {
|
|
||||||
return &WordService{q: q}
|
|
||||||
}
|
|
||||||
|
|
||||||
// List returns a paginated list of words with optional filters.
|
|
||||||
func (s *WordService) List(ctx context.Context, req models.ListWordsRequest) (models.ListWordsResponse, error) {
|
|
||||||
page := req.Page
|
|
||||||
if page < 1 {
|
|
||||||
page = 1
|
|
||||||
}
|
|
||||||
pageSize := req.PageSize
|
|
||||||
if pageSize < 1 {
|
|
||||||
pageSize = defaultPageSize
|
|
||||||
}
|
|
||||||
if pageSize > maxPageSize {
|
|
||||||
pageSize = maxPageSize
|
|
||||||
}
|
|
||||||
offset := (page - 1) * pageSize
|
|
||||||
|
|
||||||
listParams := queries.ListWordsParams{
|
|
||||||
Limit: pageSize,
|
|
||||||
Offset: offset,
|
|
||||||
CategoryID: 0,
|
|
||||||
Keyword: "",
|
|
||||||
}
|
|
||||||
countParams := queries.CountWordsParams{
|
|
||||||
CategoryID: 0,
|
|
||||||
Keyword: "",
|
|
||||||
}
|
|
||||||
if req.CategoryID != nil && *req.CategoryID > 0 {
|
|
||||||
listParams.CategoryID = *req.CategoryID
|
|
||||||
countParams.CategoryID = *req.CategoryID
|
|
||||||
}
|
|
||||||
if req.Keyword != "" {
|
|
||||||
listParams.Keyword = req.Keyword
|
|
||||||
countParams.Keyword = req.Keyword
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := s.q.ListWords(ctx, listParams)
|
|
||||||
if err != nil {
|
|
||||||
return models.ListWordsResponse{}, err
|
|
||||||
}
|
|
||||||
total, err := s.q.CountWords(ctx, countParams)
|
|
||||||
if err != nil {
|
|
||||||
return models.ListWordsResponse{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
words := make([]models.Word, len(rows))
|
|
||||||
for i, r := range rows {
|
|
||||||
words[i] = toWord(r)
|
|
||||||
}
|
|
||||||
|
|
||||||
totalPages := int32(math.Ceil(float64(total) / float64(pageSize)))
|
|
||||||
return models.ListWordsResponse{
|
|
||||||
Words: words,
|
|
||||||
Total: total,
|
|
||||||
Page: page,
|
|
||||||
PageSize: pageSize,
|
|
||||||
TotalPages: totalPages,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get returns a single word by id.
|
|
||||||
func (s *WordService) Get(ctx context.Context, id int32) (models.Word, error) {
|
|
||||||
row, err := s.q.GetWord(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return models.Word{}, err
|
|
||||||
}
|
|
||||||
return toWord(row), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create creates a new word.
|
|
||||||
func (s *WordService) Create(ctx context.Context, req models.CreateWordRequest) (models.Word, error) {
|
|
||||||
row, err := s.q.CreateWord(ctx, queries.CreateWordParams{
|
|
||||||
CategoryID: req.CategoryID,
|
|
||||||
Word: req.Word,
|
|
||||||
Translation: req.Translation,
|
|
||||||
Phonetic: req.Phonetic,
|
|
||||||
ExampleSentence: req.ExampleSentence,
|
|
||||||
AudioUrl: req.AudioURL,
|
|
||||||
ImageUrl: req.ImageURL,
|
|
||||||
SortOrder: req.SortOrder,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return models.Word{}, err
|
|
||||||
}
|
|
||||||
return toWord(row), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update updates an existing word.
|
|
||||||
func (s *WordService) Update(ctx context.Context, id int32, req models.UpdateWordRequest) (models.Word, error) {
|
|
||||||
row, err := s.q.UpdateWord(ctx, queries.UpdateWordParams{
|
|
||||||
ID: id,
|
|
||||||
CategoryID: req.CategoryID,
|
|
||||||
Word: req.Word,
|
|
||||||
Translation: req.Translation,
|
|
||||||
Phonetic: req.Phonetic,
|
|
||||||
ExampleSentence: req.ExampleSentence,
|
|
||||||
AudioUrl: req.AudioURL,
|
|
||||||
ImageUrl: req.ImageURL,
|
|
||||||
SortOrder: req.SortOrder,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return models.Word{}, err
|
|
||||||
}
|
|
||||||
return toWord(row), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete removes a word by id.
|
|
||||||
func (s *WordService) Delete(ctx context.Context, id int32) error {
|
|
||||||
return s.q.DeleteWord(ctx, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func toWord(w queries.Word) models.Word {
|
|
||||||
return models.Word{
|
|
||||||
ID: w.ID,
|
|
||||||
CategoryID: w.CategoryID,
|
|
||||||
Word: w.Word,
|
|
||||||
Translation: w.Translation,
|
|
||||||
Phonetic: nullString(w.Phonetic),
|
|
||||||
ExampleSentence: nullString(w.ExampleSentence),
|
|
||||||
AudioURL: nullString(w.AudioUrl),
|
|
||||||
ImageURL: nullString(w.ImageUrl),
|
|
||||||
SortOrder: w.SortOrder,
|
|
||||||
CreatedAt: w.CreatedAt,
|
|
||||||
UpdatedAt: w.UpdatedAt,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func nullString(s *string) *string {
|
|
||||||
if s == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
v := *s
|
|
||||||
return &v
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
DROP INDEX IF EXISTS idx_words_word;
|
|
||||||
DROP INDEX IF EXISTS idx_words_category_id;
|
|
||||||
DROP TABLE IF EXISTS words;
|
|
||||||
DROP TABLE IF EXISTS categories;
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
CREATE TABLE IF NOT EXISTS categories (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
name VARCHAR(100) NOT NULL,
|
|
||||||
sort_order INT NOT NULL DEFAULT 0,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
UNIQUE (name)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS words (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
category_id INT NOT NULL REFERENCES categories(id) ON DELETE CASCADE,
|
|
||||||
word VARCHAR(255) NOT NULL,
|
|
||||||
translation VARCHAR(255) NOT NULL,
|
|
||||||
phonetic VARCHAR(255),
|
|
||||||
example_sentence TEXT,
|
|
||||||
audio_url VARCHAR(500),
|
|
||||||
image_url VARCHAR(500),
|
|
||||||
sort_order INT NOT NULL DEFAULT 0,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_words_category_id ON words(category_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_words_word ON words(word);
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
-- name: ListCategories :many
|
|
||||||
SELECT id, name, sort_order, created_at, updated_at
|
|
||||||
FROM categories
|
|
||||||
ORDER BY sort_order ASC, id ASC;
|
|
||||||
|
|
||||||
-- name: GetCategory :one
|
|
||||||
SELECT id, name, sort_order, created_at, updated_at
|
|
||||||
FROM categories
|
|
||||||
WHERE id = $1;
|
|
||||||
|
|
||||||
-- name: CreateCategory :one
|
|
||||||
INSERT INTO categories (name, sort_order)
|
|
||||||
VALUES ($1, $2)
|
|
||||||
RETURNING id, name, sort_order, created_at, updated_at;
|
|
||||||
|
|
||||||
-- name: UpdateCategory :one
|
|
||||||
UPDATE categories
|
|
||||||
SET name = $2,
|
|
||||||
sort_order = $3,
|
|
||||||
updated_at = NOW()
|
|
||||||
WHERE id = $1
|
|
||||||
RETURNING id, name, sort_order, created_at, updated_at;
|
|
||||||
|
|
||||||
-- name: DeleteCategory :exec
|
|
||||||
DELETE FROM categories WHERE id = $1;
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
-- name: ListWords :many
|
|
||||||
SELECT w.id, w.category_id, w.word, w.translation, w.phonetic,
|
|
||||||
w.example_sentence, w.audio_url, w.image_url, w.sort_order,
|
|
||||||
w.created_at, w.updated_at
|
|
||||||
FROM words w
|
|
||||||
WHERE
|
|
||||||
(@category_id::int = 0 OR w.category_id = @category_id)
|
|
||||||
AND (@keyword::text = '' OR w.word ILIKE '%' || @keyword || '%' OR w.translation ILIKE '%' || @keyword || '%')
|
|
||||||
ORDER BY w.sort_order ASC, w.id ASC
|
|
||||||
LIMIT $1 OFFSET $2;
|
|
||||||
|
|
||||||
-- name: CountWords :one
|
|
||||||
SELECT COUNT(*) AS total
|
|
||||||
FROM words w
|
|
||||||
WHERE
|
|
||||||
(@category_id::int = 0 OR w.category_id = @category_id)
|
|
||||||
AND (@keyword::text = '' OR w.word ILIKE '%' || @keyword || '%' OR w.translation ILIKE '%' || @keyword || '%');
|
|
||||||
|
|
||||||
-- name: GetWord :one
|
|
||||||
SELECT id, category_id, word, translation, phonetic,
|
|
||||||
example_sentence, audio_url, image_url, sort_order,
|
|
||||||
created_at, updated_at
|
|
||||||
FROM words
|
|
||||||
WHERE id = $1;
|
|
||||||
|
|
||||||
-- name: CreateWord :one
|
|
||||||
INSERT INTO words (category_id, word, translation, phonetic, example_sentence, audio_url, image_url, sort_order)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
||||||
RETURNING id, category_id, word, translation, phonetic,
|
|
||||||
example_sentence, audio_url, image_url, sort_order,
|
|
||||||
created_at, updated_at;
|
|
||||||
|
|
||||||
-- name: UpdateWord :one
|
|
||||||
UPDATE words
|
|
||||||
SET category_id = $2,
|
|
||||||
word = $3,
|
|
||||||
translation = $4,
|
|
||||||
phonetic = $5,
|
|
||||||
example_sentence = $6,
|
|
||||||
audio_url = $7,
|
|
||||||
image_url = $8,
|
|
||||||
sort_order = $9,
|
|
||||||
updated_at = NOW()
|
|
||||||
WHERE id = $1
|
|
||||||
RETURNING id, category_id, word, translation, phonetic,
|
|
||||||
example_sentence, audio_url, image_url, sort_order,
|
|
||||||
created_at, updated_at;
|
|
||||||
|
|
||||||
-- name: DeleteWord :exec
|
|
||||||
DELETE FROM words WHERE id = $1;
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
version: "2"
|
|
||||||
sql:
|
|
||||||
- engine: "postgresql"
|
|
||||||
queries: "sql/"
|
|
||||||
schema: "migrations/"
|
|
||||||
gen:
|
|
||||||
go:
|
|
||||||
package: "queries"
|
|
||||||
out: "internal/repository/queries"
|
|
||||||
sql_package: "pgx/v5"
|
|
||||||
emit_json_tags: true
|
|
||||||
json_tags_case_style: "snake"
|
|
||||||
emit_interface: true
|
|
||||||
emit_empty_slices: true
|
|
||||||
emit_pointers_for_null_types: true
|
|
||||||
overrides:
|
|
||||||
- db_type: "timestamptz"
|
|
||||||
go_type:
|
|
||||||
import: "time"
|
|
||||||
type: "Time"
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
services:
|
|
||||||
db:
|
|
||||||
image: postgres:18.3-alpine3.23
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: language
|
|
||||||
POSTGRES_PASSWORD: language123
|
|
||||||
POSTGRES_DB: language
|
|
||||||
volumes:
|
|
||||||
- postgres_data:/var/lib/postgresql
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U language -d language"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
|
|
||||||
migrate:
|
|
||||||
image: migrate/migrate:v4.18.3
|
|
||||||
volumes:
|
|
||||||
- ./backend/migrations:/migrations
|
|
||||||
command:
|
|
||||||
- -path
|
|
||||||
- /migrations
|
|
||||||
- -database
|
|
||||||
- postgres://language:language123@db:5432/language?sslmode=disable
|
|
||||||
- up
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
|
|
||||||
api:
|
|
||||||
build:
|
|
||||||
context: ./backend
|
|
||||||
dockerfile: docker/Dockerfile
|
|
||||||
target: prod
|
|
||||||
environment:
|
|
||||||
APP_ENV: production
|
|
||||||
DATABASE_URL: postgres://language:language123@db:5432/language?sslmode=disable
|
|
||||||
HTTP_ADDR: :8080
|
|
||||||
depends_on:
|
|
||||||
migrate:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
|
|
||||||
admin:
|
|
||||||
build: ./frontend/admin
|
|
||||||
ports:
|
|
||||||
- "80:80"
|
|
||||||
depends_on:
|
|
||||||
- api
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
postgres_data:
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
node_modules
|
|
||||||
dist
|
|
||||||
.git
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
node_modules
|
|
||||||
dist
|
|
||||||
*.local
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
FROM node:22-alpine AS builder
|
|
||||||
WORKDIR /app
|
|
||||||
COPY package.json package-lock.json ./
|
|
||||||
RUN npm ci
|
|
||||||
COPY . .
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
FROM nginx:1.29.0-alpine
|
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
|
||||||
EXPOSE 80
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>语言学习管理后台</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name _;
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
location /api/ {
|
|
||||||
proxy_pass http://api:8080;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Generated
-4534
File diff suppressed because it is too large
Load Diff
@@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "language-admin",
|
|
||||||
"private": true,
|
|
||||||
"version": "0.1.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "tsc -b && vite build",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@refinedev/antd": "^6.0.3",
|
|
||||||
"@refinedev/core": "^5.0.12",
|
|
||||||
"@refinedev/react-router": "^2.0.4",
|
|
||||||
"antd": "^5.23.0",
|
|
||||||
"axios": "^1.13.6",
|
|
||||||
"react": "^19.2.7",
|
|
||||||
"react-dom": "^19.2.7",
|
|
||||||
"react-router": "^7.13.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/react": "^19.2.14",
|
|
||||||
"@types/react-dom": "^19.2.3",
|
|
||||||
"@vitejs/plugin-react": "^5.1.4",
|
|
||||||
"typescript": "~5.9.3",
|
|
||||||
"vite": "^7.3.4"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import { Refine } from "@refinedev/core";
|
|
||||||
import routerProvider, {
|
|
||||||
DocumentTitleHandler,
|
|
||||||
NavigateToResource,
|
|
||||||
UnsavedChangesNotifier,
|
|
||||||
} from "@refinedev/react-router";
|
|
||||||
import {
|
|
||||||
ErrorComponent,
|
|
||||||
ThemedLayout,
|
|
||||||
ThemedTitle,
|
|
||||||
useNotificationProvider,
|
|
||||||
} from "@refinedev/antd";
|
|
||||||
import { App as AntdApp, ConfigProvider } from "antd";
|
|
||||||
import zhCN from "antd/locale/zh_CN";
|
|
||||||
import { BrowserRouter, Outlet, Route, Routes } from "react-router";
|
|
||||||
import { dataProvider } from "./providers/dataProvider";
|
|
||||||
import { CategoryList } from "./pages/categories/list";
|
|
||||||
import { WordCreate } from "./pages/words/create";
|
|
||||||
import { WordEdit } from "./pages/words/edit";
|
|
||||||
import { WordList } from "./pages/words/list";
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
return (
|
|
||||||
<BrowserRouter>
|
|
||||||
<ConfigProvider locale={zhCN}>
|
|
||||||
<AntdApp>
|
|
||||||
<Refine
|
|
||||||
dataProvider={dataProvider}
|
|
||||||
routerProvider={routerProvider}
|
|
||||||
notificationProvider={useNotificationProvider}
|
|
||||||
resources={[
|
|
||||||
{
|
|
||||||
name: "words",
|
|
||||||
list: "/words",
|
|
||||||
create: "/words/create",
|
|
||||||
edit: "/words/edit/:id",
|
|
||||||
meta: { label: "单词管理" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "categories",
|
|
||||||
list: "/categories",
|
|
||||||
meta: { label: "分类管理" },
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
options={{ syncWithLocation: true, warnWhenUnsavedChanges: true }}
|
|
||||||
>
|
|
||||||
<Routes>
|
|
||||||
<Route
|
|
||||||
element={
|
|
||||||
<ThemedLayout
|
|
||||||
Title={({ collapsed }) => (
|
|
||||||
<ThemedTitle collapsed={collapsed} text="语言学习管理后台" />
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Outlet />
|
|
||||||
</ThemedLayout>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Route index element={<NavigateToResource resource="words" />} />
|
|
||||||
<Route path="/words" element={<WordList />} />
|
|
||||||
<Route path="/words/create" element={<WordCreate />} />
|
|
||||||
<Route path="/words/edit/:id" element={<WordEdit />} />
|
|
||||||
<Route path="/categories" element={<CategoryList />} />
|
|
||||||
<Route path="*" element={<ErrorComponent />} />
|
|
||||||
</Route>
|
|
||||||
</Routes>
|
|
||||||
<UnsavedChangesNotifier />
|
|
||||||
<DocumentTitleHandler />
|
|
||||||
</Refine>
|
|
||||||
</AntdApp>
|
|
||||||
</ConfigProvider>
|
|
||||||
</BrowserRouter>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { createRoot } from "react-dom/client";
|
|
||||||
import App from "./App";
|
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<App />
|
|
||||||
</React.StrictMode>,
|
|
||||||
);
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import { DeleteButton, List, useModalForm, useTable } from "@refinedev/antd";
|
|
||||||
import { Button, Form, Input, InputNumber, Modal, Space, Table } from "antd";
|
|
||||||
import type { Category } from "../../types";
|
|
||||||
|
|
||||||
function CategoryFormFields() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Form.Item
|
|
||||||
label="名称"
|
|
||||||
name="name"
|
|
||||||
rules={[{ required: true, message: "请输入分类名称" }]}
|
|
||||||
>
|
|
||||||
<Input placeholder="如:农场动物、颜色" maxLength={100} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item label="排序" name="sort_order" initialValue={0}>
|
|
||||||
<InputNumber min={0} style={{ width: "100%" }} />
|
|
||||||
</Form.Item>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CategoryList() {
|
|
||||||
const { tableProps } = useTable<Category>({
|
|
||||||
resource: "categories",
|
|
||||||
pagination: { mode: "off" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
formProps: createFormProps,
|
|
||||||
modalProps: createModalProps,
|
|
||||||
show: showCreate,
|
|
||||||
} = useModalForm<Category>({ resource: "categories", action: "create" });
|
|
||||||
|
|
||||||
const {
|
|
||||||
formProps: editFormProps,
|
|
||||||
modalProps: editModalProps,
|
|
||||||
show: showEdit,
|
|
||||||
} = useModalForm<Category>({ resource: "categories", action: "edit" });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<List
|
|
||||||
title="分类管理"
|
|
||||||
headerButtons={
|
|
||||||
<Button type="primary" onClick={() => showCreate()}>
|
|
||||||
新建分类
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Table {...tableProps} rowKey="id" pagination={false}>
|
|
||||||
<Table.Column dataIndex="name" title="名称" />
|
|
||||||
<Table.Column dataIndex="sort_order" title="排序" width={100} />
|
|
||||||
<Table.Column
|
|
||||||
dataIndex="created_at"
|
|
||||||
title="创建时间"
|
|
||||||
width={180}
|
|
||||||
render={(v: string) => new Date(v).toLocaleString("zh-CN")}
|
|
||||||
/>
|
|
||||||
<Table.Column
|
|
||||||
title="操作"
|
|
||||||
width={160}
|
|
||||||
render={(_, record: Category) => (
|
|
||||||
<Space>
|
|
||||||
<Button size="small" onClick={() => showEdit(record.id)}>
|
|
||||||
编辑
|
|
||||||
</Button>
|
|
||||||
<DeleteButton
|
|
||||||
size="small"
|
|
||||||
recordItemId={record.id}
|
|
||||||
confirmTitle="确定删除该分类?仅当分类下没有单词时才能删除。"
|
|
||||||
confirmOkText="删除"
|
|
||||||
confirmCancelText="取消"
|
|
||||||
/>
|
|
||||||
</Space>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Table>
|
|
||||||
</List>
|
|
||||||
|
|
||||||
<Modal {...createModalProps} title="新建分类">
|
|
||||||
<Form {...createFormProps} layout="vertical">
|
|
||||||
<CategoryFormFields />
|
|
||||||
</Form>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Modal {...editModalProps} title="编辑分类">
|
|
||||||
<Form {...editFormProps} layout="vertical">
|
|
||||||
<CategoryFormFields />
|
|
||||||
</Form>
|
|
||||||
</Modal>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { Create, useForm } from "@refinedev/antd";
|
|
||||||
import { Form } from "antd";
|
|
||||||
import type { Word } from "../../types";
|
|
||||||
import { normalizeWordValues, WordFormFields } from "./form";
|
|
||||||
|
|
||||||
export function WordCreate() {
|
|
||||||
const { formProps, saveButtonProps } = useForm<Word>({
|
|
||||||
resource: "words",
|
|
||||||
action: "create",
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Create title="新建单词" saveButtonProps={saveButtonProps}>
|
|
||||||
<Form
|
|
||||||
{...formProps}
|
|
||||||
layout="vertical"
|
|
||||||
onFinish={(values) => formProps.onFinish?.(normalizeWordValues(values))}
|
|
||||||
>
|
|
||||||
<WordFormFields form={formProps.form!} />
|
|
||||||
</Form>
|
|
||||||
</Create>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { Edit, useForm } from "@refinedev/antd";
|
|
||||||
import { Form } from "antd";
|
|
||||||
import type { Word } from "../../types";
|
|
||||||
import { normalizeWordValues, WordFormFields } from "./form";
|
|
||||||
|
|
||||||
export function WordEdit() {
|
|
||||||
const { formProps, saveButtonProps } = useForm<Word>({
|
|
||||||
resource: "words",
|
|
||||||
action: "edit",
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Edit title="编辑单词" saveButtonProps={saveButtonProps}>
|
|
||||||
<Form
|
|
||||||
{...formProps}
|
|
||||||
layout="vertical"
|
|
||||||
onFinish={(values) => formProps.onFinish?.(normalizeWordValues(values))}
|
|
||||||
>
|
|
||||||
<WordFormFields form={formProps.form!} />
|
|
||||||
</Form>
|
|
||||||
</Edit>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import { useList } from "@refinedev/core";
|
|
||||||
import { Form, Image, Input, InputNumber, Select } from "antd";
|
|
||||||
import type { FormInstance } from "antd";
|
|
||||||
import type { Category } from "../../types";
|
|
||||||
|
|
||||||
export function useCategoryOptions() {
|
|
||||||
const { result } = useList<Category>({
|
|
||||||
resource: "categories",
|
|
||||||
pagination: { mode: "off" },
|
|
||||||
});
|
|
||||||
const categories = result?.data ?? [];
|
|
||||||
return {
|
|
||||||
categories,
|
|
||||||
options: categories.map((c) => ({ label: c.name, value: c.id })),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 可选字段的空字符串转为 undefined,避免后端存空串而不是 NULL
|
|
||||||
export function normalizeWordValues<T extends object>(values: T): T {
|
|
||||||
const optionalKeys = ["phonetic", "example_sentence", "audio_url", "image_url"];
|
|
||||||
const out = { ...values } as Record<string, unknown>;
|
|
||||||
for (const key of optionalKeys) {
|
|
||||||
if (out[key] === "" || out[key] === undefined) {
|
|
||||||
out[key] = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WordFormFields({ form }: { form: FormInstance }) {
|
|
||||||
const { options } = useCategoryOptions();
|
|
||||||
const audioUrl = Form.useWatch("audio_url", form);
|
|
||||||
const imageUrl = Form.useWatch("image_url", form);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Form.Item
|
|
||||||
label="所属分类"
|
|
||||||
name="category_id"
|
|
||||||
rules={[{ required: true, message: "请选择分类" }]}
|
|
||||||
>
|
|
||||||
<Select options={options} placeholder="选择分类" showSearch optionFilterProp="label" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
label="单词"
|
|
||||||
name="word"
|
|
||||||
rules={[{ required: true, message: "请输入单词" }]}
|
|
||||||
>
|
|
||||||
<Input placeholder="如:apple" maxLength={255} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
label="中文释义"
|
|
||||||
name="translation"
|
|
||||||
rules={[{ required: true, message: "请输入中文释义" }]}
|
|
||||||
>
|
|
||||||
<Input placeholder="如:苹果" maxLength={255} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item label="音标" name="phonetic">
|
|
||||||
<Input placeholder="如:/ˈæpəl/" maxLength={255} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item label="例句" name="example_sentence">
|
|
||||||
<Input.TextArea rows={3} placeholder="可选" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item label="音频 URL" name="audio_url">
|
|
||||||
<Input placeholder="可选,填写后可试听" maxLength={500} />
|
|
||||||
</Form.Item>
|
|
||||||
{audioUrl ? (
|
|
||||||
<Form.Item label="音频预览">
|
|
||||||
<audio controls src={audioUrl} style={{ width: "100%" }} />
|
|
||||||
</Form.Item>
|
|
||||||
) : null}
|
|
||||||
<Form.Item label="图片 URL" name="image_url">
|
|
||||||
<Input placeholder="可选,填写后可预览" maxLength={500} />
|
|
||||||
</Form.Item>
|
|
||||||
{imageUrl ? (
|
|
||||||
<Form.Item label="图片预览">
|
|
||||||
<Image src={imageUrl} width={160} fallback="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='90'%3E%3Crect width='100%25' height='100%25' fill='%23f0f0f0'/%3E%3Ctext x='50%25' y='50%25' fill='%23999' font-size='12' text-anchor='middle'%3E加载失败%3C/text%3E%3C/svg%3E" />
|
|
||||||
</Form.Item>
|
|
||||||
) : null}
|
|
||||||
<Form.Item label="排序" name="sort_order" initialValue={0}>
|
|
||||||
<InputNumber min={0} style={{ width: "100%" }} />
|
|
||||||
</Form.Item>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import type { CrudFilters, HttpError } from "@refinedev/core";
|
|
||||||
import { DeleteButton, EditButton, List, useTable } from "@refinedev/antd";
|
|
||||||
import { Button, Form, Input, Select, Space, Table } from "antd";
|
|
||||||
import type { Word } from "../../types";
|
|
||||||
import { useCategoryOptions } from "./form";
|
|
||||||
|
|
||||||
interface SearchForm {
|
|
||||||
category_id?: number;
|
|
||||||
keyword?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WordList() {
|
|
||||||
const { categories, options } = useCategoryOptions();
|
|
||||||
const categoryName = (id: number) =>
|
|
||||||
categories.find((c) => c.id === id)?.name ?? `#${id}`;
|
|
||||||
|
|
||||||
const { tableProps, searchFormProps } = useTable<Word, HttpError, SearchForm>({
|
|
||||||
resource: "words",
|
|
||||||
pagination: { pageSize: 20 },
|
|
||||||
onSearch: (values): CrudFilters => [
|
|
||||||
{ field: "category_id", operator: "eq", value: values.category_id },
|
|
||||||
{ field: "keyword", operator: "eq", value: values.keyword },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<List title="单词管理">
|
|
||||||
<Form {...searchFormProps} layout="inline" style={{ marginBottom: 16 }}>
|
|
||||||
<Form.Item name="category_id" label="分类">
|
|
||||||
<Select
|
|
||||||
options={options}
|
|
||||||
placeholder="全部分类"
|
|
||||||
allowClear
|
|
||||||
showSearch
|
|
||||||
optionFilterProp="label"
|
|
||||||
style={{ width: 200 }}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="keyword" label="关键词">
|
|
||||||
<Input placeholder="单词或释义" allowClear style={{ width: 200 }} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item>
|
|
||||||
<Button type="primary" htmlType="submit">
|
|
||||||
查询
|
|
||||||
</Button>
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
|
|
||||||
<Table {...tableProps} rowKey="id">
|
|
||||||
<Table.Column dataIndex="word" title="单词" />
|
|
||||||
<Table.Column dataIndex="phonetic" title="音标" width={140} />
|
|
||||||
<Table.Column dataIndex="translation" title="中文释义" />
|
|
||||||
<Table.Column
|
|
||||||
dataIndex="category_id"
|
|
||||||
title="分类"
|
|
||||||
width={140}
|
|
||||||
render={(id: number) => categoryName(id)}
|
|
||||||
/>
|
|
||||||
<Table.Column dataIndex="sort_order" title="排序" width={80} />
|
|
||||||
<Table.Column
|
|
||||||
title="操作"
|
|
||||||
width={160}
|
|
||||||
render={(_, record: Word) => (
|
|
||||||
<Space>
|
|
||||||
<EditButton size="small" recordItemId={record.id} hideText>
|
|
||||||
编辑
|
|
||||||
</EditButton>
|
|
||||||
<DeleteButton
|
|
||||||
size="small"
|
|
||||||
recordItemId={record.id}
|
|
||||||
confirmTitle="确定删除该单词?"
|
|
||||||
confirmOkText="删除"
|
|
||||||
confirmCancelText="取消"
|
|
||||||
hideText
|
|
||||||
/>
|
|
||||||
</Space>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Table>
|
|
||||||
</List>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import type { CrudFilters, DataProvider } from "@refinedev/core";
|
|
||||||
import axios, { AxiosError } from "axios";
|
|
||||||
import type { ApiEnvelope, ListWordsData } from "../types";
|
|
||||||
|
|
||||||
const http = axios.create({ baseURL: "/api/v1" });
|
|
||||||
|
|
||||||
const ERROR_MESSAGES: Record<number, string> = {
|
|
||||||
10001: "资源不存在",
|
|
||||||
10005: "该分类下还有单词,无法删除",
|
|
||||||
};
|
|
||||||
|
|
||||||
class ApiError extends Error {
|
|
||||||
constructor(
|
|
||||||
public code: number,
|
|
||||||
message: string,
|
|
||||||
public status?: number,
|
|
||||||
) {
|
|
||||||
super(ERROR_MESSAGES[code] ?? message);
|
|
||||||
this.name = "ApiError";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function unwrap<T>(promise: Promise<{ data: ApiEnvelope<T> }>): Promise<T> {
|
|
||||||
try {
|
|
||||||
const res = await promise;
|
|
||||||
if (res.data.code !== 0) {
|
|
||||||
throw new ApiError(res.data.code, res.data.message);
|
|
||||||
}
|
|
||||||
return res.data.data;
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ApiError) throw err;
|
|
||||||
const axErr = err as AxiosError<ApiEnvelope<unknown>>;
|
|
||||||
const envelope = axErr.response?.data;
|
|
||||||
if (envelope) {
|
|
||||||
throw new ApiError(envelope.code, envelope.message, axErr.response?.status);
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function filtersToParams(filters: CrudFilters = []) {
|
|
||||||
const params: Record<string, unknown> = {};
|
|
||||||
for (const f of filters) {
|
|
||||||
if ("field" in f && f.operator === "eq" && f.value !== undefined && f.value !== "" && f.value !== null) {
|
|
||||||
params[f.field] = f.value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return params;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const dataProvider: DataProvider = {
|
|
||||||
getApiUrl: () => "/api/v1",
|
|
||||||
|
|
||||||
getList: async ({ resource, pagination, filters }) => {
|
|
||||||
const params: Record<string, unknown> = { ...filtersToParams(filters) };
|
|
||||||
if (pagination && pagination.mode !== "off") {
|
|
||||||
params.page = pagination.currentPage ?? 1;
|
|
||||||
params.page_size = pagination.pageSize ?? 20;
|
|
||||||
}
|
|
||||||
const data = await unwrap<ListWordsData | unknown[]>(http.get(`/${resource}`, { params }));
|
|
||||||
if (Array.isArray(data)) {
|
|
||||||
return { data: data as never[], total: data.length };
|
|
||||||
}
|
|
||||||
return { data: (data.words ?? []) as never[], total: Number(data.total) };
|
|
||||||
},
|
|
||||||
|
|
||||||
getOne: async ({ resource, id }) => {
|
|
||||||
const data = await unwrap(http.get(`/${resource}/${id}`));
|
|
||||||
return { data: data as never };
|
|
||||||
},
|
|
||||||
|
|
||||||
create: async ({ resource, variables }) => {
|
|
||||||
const data = await unwrap(http.post(`/${resource}`, variables));
|
|
||||||
return { data: data as never };
|
|
||||||
},
|
|
||||||
|
|
||||||
update: async ({ resource, id, variables }) => {
|
|
||||||
const data = await unwrap(http.put(`/${resource}/${id}`, variables));
|
|
||||||
return { data: data as never };
|
|
||||||
},
|
|
||||||
|
|
||||||
deleteOne: async ({ resource, id }) => {
|
|
||||||
const data = await unwrap(http.delete(`/${resource}/${id}`));
|
|
||||||
return { data: (data ?? {}) as never };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
export interface Category {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
sort_order: number;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Word {
|
|
||||||
id: number;
|
|
||||||
category_id: number;
|
|
||||||
word: string;
|
|
||||||
translation: string;
|
|
||||||
phonetic?: string;
|
|
||||||
example_sentence?: string;
|
|
||||||
audio_url?: string;
|
|
||||||
image_url?: string;
|
|
||||||
sort_order: number;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApiEnvelope<T> {
|
|
||||||
code: number;
|
|
||||||
message: string;
|
|
||||||
data: T;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ListWordsData {
|
|
||||||
words: Word[];
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
page_size: number;
|
|
||||||
total_pages: number;
|
|
||||||
}
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
/// <reference types="vite/client" />
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"useDefineForClassFields": true,
|
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
||||||
"module": "ESNext",
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"moduleDetection": "force",
|
|
||||||
"noEmit": true,
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"strict": true,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"noFallthroughCasesInSwitch": true
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { defineConfig } from "vite";
|
|
||||||
import react from "@vitejs/plugin-react";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
server: {
|
|
||||||
port: 5173,
|
|
||||||
proxy: {
|
|
||||||
"/api": {
|
|
||||||
target: "http://localhost:8080",
|
|
||||||
changeOrigin: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user