新增单词管理后台,分类有单词时禁止删除,支持 Docker 一键部署
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -6,9 +6,10 @@
|
|||||||
|
|
||||||
```
|
```
|
||||||
language/
|
language/
|
||||||
├── backend/ Go + PostgreSQL 后台服务
|
├── backend/ Go + PostgreSQL 后台服务
|
||||||
├── frontend/ 未来前端(Web)
|
├── frontend/
|
||||||
├── client/ 未来客户端(App / Desktop)
|
│ └── admin/ 管理后台(Refine + Ant Design)
|
||||||
|
├── client/ 未来客户端(App / Desktop)
|
||||||
└── README.md
|
└── README.md
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -16,8 +17,29 @@ language/
|
|||||||
|
|
||||||
- 类别管理:如「农场动物」「颜色」
|
- 类别管理:如「农场动物」「颜色」
|
||||||
- 单词维护:英文单词、中文释义、音标、例句等
|
- 单词维护:英文单词、中文释义、音标、例句等
|
||||||
|
- 管理后台:分类与单词的增删改查
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
- 后台:Go 1.25.8 + PostgreSQL 18.3 + Docker
|
- 后台:Go 1.25.8 + PostgreSQL 18.3 + Docker,详见 [backend/README.md](backend/README.md)
|
||||||
- 详见 [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
-1
@@ -35,7 +35,7 @@ make test
|
|||||||
- `POST /api/v1/categories`
|
- `POST /api/v1/categories`
|
||||||
- `GET /api/v1/categories/:id`
|
- `GET /api/v1/categories/:id`
|
||||||
- `PUT /api/v1/categories/:id`
|
- `PUT /api/v1/categories/:id`
|
||||||
- `DELETE /api/v1/categories/:id`
|
- `DELETE /api/v1/categories/:id`(分类下仍有单词时返回 409,code 10005)
|
||||||
|
|
||||||
### Words
|
### Words
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/fish/language/backend/internal/services"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,6 +31,9 @@ func MapError(err error) (status int, code int, message string) {
|
|||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return http.StatusNotFound, 10001, "resource not found"
|
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"
|
return http.StatusInternalServerError, 10000, "internal server error"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,15 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
"github.com/fish/language/backend/internal/models"
|
"github.com/fish/language/backend/internal/models"
|
||||||
"github.com/fish/language/backend/internal/repository/queries"
|
"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.
|
// CategoryService handles business logic for categories.
|
||||||
type CategoryService struct {
|
type CategoryService struct {
|
||||||
q *queries.Queries
|
q *queries.Queries
|
||||||
@@ -64,8 +68,15 @@ func (s *CategoryService) Update(ctx context.Context, id int32, req models.Updat
|
|||||||
return toCategory(row), nil
|
return toCategory(row), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete removes a category by id.
|
// 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 {
|
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)
|
return s.q.DeleteCategory(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
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:
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
*.local
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
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;"]
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!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>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import App from "./App";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
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 };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
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
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
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