霖雨寺
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
.venv/
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
todos.sqlite3
|
||||
app.sqlite3
|
||||
front/node_modules/
|
||||
front/dist/
|
||||
@@ -0,0 +1,115 @@
|
||||
# py-web 项目协作说明
|
||||
|
||||
## 项目概览
|
||||
|
||||
这是一个 FastAPI + SQLite 后端和 Vue 3 + Ant Design Vue + VueQuill 前端组成的玩具管理系统。
|
||||
|
||||
模块目录可以包含自己的 `AGENTS.md`,更靠近代码的说明优先约束对应目录内的改动。
|
||||
|
||||
## 后端模块
|
||||
|
||||
- `app/main.py`:创建 FastAPI 应用,注册 CORS、健康检查和业务路由。
|
||||
- `app/database.py`:管理 SQLite 数据库路径、建表逻辑和 `Database` 依赖注入。
|
||||
- `app/schemas.py`:集中定义 Pydantic 请求体和响应体模型。
|
||||
- `app/routers/auth.py`:认证接口,提供验证码、登录、当前用户和退出登录。
|
||||
- `app/routers/users.py`:用户管理接口,提供 `/users` 的增删改查。
|
||||
- `app/routers/todos.py`:Todo 接口,提供 `/todos` 的增删改查。
|
||||
- `app/routers/temples.py`:寺院管理接口,提供 `/temples` 的增删改查。
|
||||
- `app/routers/products.py`:商品管理接口,提供 `/products` 的增删改查。
|
||||
- `app/routers/orders.py`:订单管理接口,提供 `/orders` 的增删改查。
|
||||
- `app/routers/payments.py`:微信支付接口,提供 JSAPI 预下单和支付通知处理。
|
||||
- `app/routers/rituals.py`:法事管理接口,提供 `/ritual-services` 的增删改查。
|
||||
- `app/routers/uploads.py`:图片上传接口,提供 `/uploads/images` 并上传到阿里云 OSS。
|
||||
- `app/config.py`:项目运行配置,目前预留阿里云 OSS 配置。
|
||||
- `index.py`:兼容启动入口,导出 `app.main` 中的 `app`。
|
||||
|
||||
后端默认数据库文件是 `app.sqlite3`。开发时可以使用 `DATABASE_PATH` 指定当前用户可写的数据库文件,例如:
|
||||
|
||||
```bash
|
||||
DATABASE_PATH=/tmp/py-web.sqlite3 .venv/bin/uvicorn index:app --reload
|
||||
```
|
||||
|
||||
## 前端模块
|
||||
|
||||
- `front/src/main.ts`:创建 Vue 应用,注册 `vue-router` 和 Ant Design Vue。
|
||||
- `front/src/router/index.ts`:按后端功能模块划分路由。
|
||||
- `front/src/layouts/AdminLayout.vue`:后台主布局,承载业务模块页面。
|
||||
- `front/src/views/login/LoginPage.vue`:登录页面,调用后端认证接口。
|
||||
- `front/src/views/temples/TempleManagement.vue`:寺院管理页面。
|
||||
- `front/src/views/products/ProductManagement.vue`:商品管理页面。
|
||||
- `front/src/views/products/ProductDetail.vue`:商品新建和编辑页面。
|
||||
- `front/src/views/orders/OrderManagement.vue`:订单管理页面。
|
||||
- `front/src/views/rituals/RitualManagement.vue`:法事管理页面。
|
||||
- `front/src/views/users/UserManagement.vue`:用户管理页面,调用后端 `/users` 接口。
|
||||
- `front/src/views/todos/TodoManagement.vue`:Todo 模块占位页,后续接入后端 `/todos` 接口。
|
||||
- `front/src/api/http.ts`:统一前端请求封装。
|
||||
- `front/src/api/auth.ts`:登录认证接口封装。
|
||||
- `front/src/api/users.ts`:用户接口封装。
|
||||
- `front/src/api/uploads.ts`:图片上传接口封装。
|
||||
- `front/src/api/payments.ts`:微信支付接口封装。
|
||||
|
||||
前端更细的模块约定见 `front/AGENTS.md`,后端更细的模块约定见 `app/AGENTS.md`。
|
||||
|
||||
当前前端路由:
|
||||
|
||||
```text
|
||||
/login 静态登录页
|
||||
/temples 寺院管理
|
||||
/products 商品管理
|
||||
/products/create 新建商品
|
||||
/products/:productId/edit 编辑商品
|
||||
/orders 订单管理
|
||||
/ritual-services 法事管理
|
||||
/users 用户管理
|
||||
/todos Todo 管理占位页
|
||||
```
|
||||
|
||||
前端通过 Vite 代理把 `/api/*` 转发到 FastAPI 后端,默认目标是 `http://127.0.0.1:8000`。
|
||||
|
||||
商品、订单、法事属于寺院隔离数据。前端通过顶部寺院选择器维护当前寺院,并在请求头 `X-Temple-Id` 中传给后端;后端按该寺院过滤数据。
|
||||
|
||||
## 依赖与运行约定
|
||||
|
||||
- Python 包写入根目录 `requirements.txt`,由用户在本地安装。
|
||||
- 前端包写入 `front/package.json`,由用户在 `front` 目录执行 `pnpm install`。
|
||||
- 不使用 `sudo` 启动开发服务,避免 SQLite 文件被 root 创建。
|
||||
- 后端启动命令:
|
||||
|
||||
```bash
|
||||
DATABASE_PATH=/tmp/py-web.sqlite3 .venv/bin/uvicorn index:app --reload
|
||||
```
|
||||
|
||||
- 前端启动命令:
|
||||
|
||||
```bash
|
||||
cd front
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## 改动边界
|
||||
|
||||
- 新增后端业务模块时,优先新增 `app/routers/<module>.py` 和对应 `app/schemas.py` 模型。
|
||||
- 新增前端业务模块时,优先新增 `front/src/views/<module>/`、`front/src/api/<module>.ts`,并在 `front/src/router/index.ts` 注册路由。
|
||||
- 登录认证已接入,后台路由由前端守卫检查登录状态;认证令牌由后端 HttpOnly Cookie 管理,前端不保存 token。
|
||||
|
||||
## 修改记录约定
|
||||
|
||||
- 以后只有较重要的代码、配置、依赖、目录结构、路由、数据模型、接口、认证权限或协作规则变更,才同步更新本文件的“修改记录”。
|
||||
- 修改记录使用简体中文,包含日期、改动范围、核心内容和验证情况。
|
||||
- 只记录实际完成的改动,不把待办计划写成已完成事项。
|
||||
- 纯样式微调、文案微调和无行为影响的小改动通常不记录,以节省协作时间。
|
||||
|
||||
## 修改记录
|
||||
|
||||
- 2026-07-22:新增项目级协作说明,记录 FastAPI 后端模块、Vue 前端模块、运行命令和改动边界;同时约定后续修改都同步到本文件。
|
||||
- 2026-07-22:用户管理模型变更为注册时间、姓名、手机号、昵称、头像、是否管理员、总消费;同步更新后端 SQLite 表结构迁移、Pydantic 模型、`/users` CRUD 接口、前端用户表格与表单;新增 `app/AGENTS.md` 和 `front/AGENTS.md` 记录模块级约定。验证:后端临时 pycache 编译通过,用户 CRUD 冒烟测试通过,旧用户表迁移测试通过,前端 `pnpm typecheck` 和 `pnpm build` 通过。
|
||||
- 2026-07-22:前端应用名称改为可配置,默认值为“自在福田”,新增 `front/src/config/app.ts` 并替换布局与登录页写死名称;后台布局新增基于路由 `meta.title` 的导航面包屑。验证:`pnpm typecheck` 和 `pnpm build` 通过,构建仅保留 Ant Design Vue 首包体积提示。
|
||||
- 2026-07-22:用户管理新增手机号查询能力,后端 `/users` 支持 `phone` 查询参数,前端用户管理页增加手机号查询与重置操作。验证:后端编译通过,手机号查询接口测试通过,前端 `pnpm typecheck` 和 `pnpm build` 通过。
|
||||
- 2026-07-22:新增寺院管理、商品管理、订单管理、法事管理模块;后端增加 `temples`、`products`、`orders`、`ritual_services` 表和 CRUD 路由,商品/订单/法事通过 `X-Temple-Id` 做寺院级数据隔离;前端新增对应 API、路由、管理页面和顶部寺院选择器。验证:后端编译通过,寺院隔离接口测试通过,前端 `pnpm typecheck` 和 `pnpm build` 通过。
|
||||
- 2026-07-23:商品详情模型变更为完整详情结构,后端扩展 `products` 表、Pydantic 模型和 `/products` CRUD,前端重做商品列表与详情编辑,支持规格表格行编辑、商品详情编辑和微信分享配置。验证:后端编译通过,商品详情 CRUD 测试通过,旧商品表迁移测试通过,前端 `pnpm typecheck` 和 `pnpm build` 通过。
|
||||
- 2026-07-23:商品新建和编辑从列表弹窗调整为独立页面,新增 `/products/create` 和 `/products/:productId/edit` 路由,详情页通过路由 `meta.module = products` 保持商品管理菜单高亮。验证:`pnpm typecheck` 和 `pnpm build` 通过。
|
||||
- 2026-07-23:商品封面和商品图片改为上传组件,后端新增 `/uploads/images` 阿里云 OSS 上传接口和 `app/config.py` OSS 配置占位,`requirements.txt` 新增 `oss2`、`python-multipart`。验证:后端编译通过,未配置 OSS 上传接口提示测试通过,前端 `pnpm typecheck` 和 `pnpm build` 通过。
|
||||
- 2026-07-23:商品详情编辑器从内置 `contenteditable` 替换为 VueQuill,`front/package.json` 新增 `@vueup/vue-quill` 依赖,详情内容继续以 HTML 保存到 `details_html`。验证:待安装依赖后执行。
|
||||
- 2026-07-23:新增登录认证功能,后端支持动态验证码、bcrypt 密码哈希、失败次数限制、账号临时锁定、HttpOnly/Secure Cookie 会话、退出登录和登录 IP/设备日志;前端登录页支持用户名、密码、验证码、自动聚焦、账号 trim、密码显示/隐藏、提交 loading、友好错误提示和路由守卫;`requirements.txt` 新增 `bcrypt`。验证:后端编译通过,前端 `pnpm typecheck` 和 `pnpm build` 通过,运行态登录测试待安装 `bcrypt` 后执行。
|
||||
- 2026-07-23:商品模型新增商品描述字段,后端补充 `products.description` 迁移、Pydantic 模型和 CRUD 读写,前端商品 API 类型与商品编辑基础信息表单同步。验证:后端编译通过,前端 `pnpm typecheck` 和 `pnpm build` 通过,运行态接口测试待安装 `bcrypt` 后执行。
|
||||
- 2026-07-23:新增微信支付 JSAPI 预下单和支付通知处理,后端增加 `payment_transactions` 支付流水表、微信支付 API v3 签名/通知验签/回调解密工具和配置占位,前端订单管理页增加微信预下单入口;`requirements.txt` 新增 `cryptography`。验证:后端编译通过,前端 `pnpm typecheck` 和 `pnpm build` 通过,真实预下单和支付通知待填写微信商户配置后联调。
|
||||
@@ -0,0 +1,210 @@
|
||||
# FastAPI 微型后端工程
|
||||
|
||||
这是一个简易的 FastAPI 后端,用 SQLite 保存数据,提供 Todo 和用户管理接口。
|
||||
|
||||
## start
|
||||
|
||||
DATABASE_PATH=/tmp/py-web.sqlite3 .venv/bin/uvicorn index:app --reload
|
||||
|
||||
## 工程结构
|
||||
|
||||
```text
|
||||
.
|
||||
├── app
|
||||
│ ├── main.py # 创建 FastAPI 应用,注册中间件和路由
|
||||
│ ├── database.py # 数据库连接、建表、依赖注入
|
||||
│ ├── schemas.py # 请求体和响应体的数据结构
|
||||
│ └── routers
|
||||
│ ├── todos.py # Todo 接口
|
||||
│ └── users.py # 用户接口
|
||||
├── index.py # 兼容入口,暴露 app 给 uvicorn
|
||||
├── requirements.txt
|
||||
└── app.sqlite3 # 启动后自动创建,保存数据
|
||||
```
|
||||
|
||||
## 安装依赖
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
python3 -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## 启动服务
|
||||
|
||||
```bash
|
||||
uvicorn index:app --reload
|
||||
```
|
||||
|
||||
启动后访问:
|
||||
|
||||
- API 文档:http://127.0.0.1:8000/docs
|
||||
- 健康检查:http://127.0.0.1:8000/health
|
||||
|
||||
默认数据库文件是 `app.sqlite3`。如果想指定其他数据库文件:
|
||||
|
||||
```bash
|
||||
DATABASE_PATH=/tmp/py-web.sqlite3 uvicorn index:app --reload
|
||||
```
|
||||
|
||||
如果遇到 `attempt to write a readonly database`,通常说明数据库文件不是当前用户创建的。可以换一个 `DATABASE_PATH`,或者删除旧的数据库文件后重新启动服务。
|
||||
|
||||
## 用户接口
|
||||
|
||||
```text
|
||||
GET /users
|
||||
POST /users
|
||||
GET /users/{user_id}
|
||||
PATCH /users/{user_id}
|
||||
DELETE /users/{user_id}
|
||||
```
|
||||
|
||||
## 寺院隔离模块
|
||||
|
||||
寺院管理接口:
|
||||
|
||||
```text
|
||||
GET /temples
|
||||
POST /temples
|
||||
GET /temples/{temple_id}
|
||||
PATCH /temples/{temple_id}
|
||||
DELETE /temples/{temple_id}
|
||||
```
|
||||
|
||||
商品、订单、法事属于寺院隔离数据。调用这些接口时需要带请求头:
|
||||
|
||||
```text
|
||||
X-Temple-Id: 1
|
||||
```
|
||||
|
||||
隔离接口:
|
||||
|
||||
```text
|
||||
GET /products
|
||||
POST /products
|
||||
GET /products/{product_id}
|
||||
PATCH /products/{product_id}
|
||||
DELETE /products/{product_id}
|
||||
|
||||
GET /orders
|
||||
POST /orders
|
||||
GET /orders/{order_id}
|
||||
PATCH /orders/{order_id}
|
||||
DELETE /orders/{order_id}
|
||||
|
||||
GET /ritual-services
|
||||
POST /ritual-services
|
||||
GET /ritual-services/{ritual_id}
|
||||
PATCH /ritual-services/{ritual_id}
|
||||
DELETE /ritual-services/{ritual_id}
|
||||
```
|
||||
|
||||
商品详情模型包含商品基础信息、按钮名称、展示配置、封面和多图、上架/开售/结束/下架时间、商品规格、商品详情 HTML、微信分享配置、功德证书预留和自动处理。商品规格字段为名称、单价、库存、排序和是否超度。
|
||||
|
||||
创建用户:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8000/users \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"张三","phone":"13800000000","nickname":"小张","avatar":"","is_admin":false,"total_spent":0}'
|
||||
```
|
||||
|
||||
更新用户:
|
||||
|
||||
```bash
|
||||
curl -X PATCH http://127.0.0.1:8000/users/1 \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"nickname":"张老板","is_admin":true,"total_spent":299.9}'
|
||||
```
|
||||
|
||||
## 前端调用示例
|
||||
|
||||
```js
|
||||
async function createUser() {
|
||||
const response = await fetch("http://127.0.0.1:8000/users", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: "张三",
|
||||
phone: "13800000000",
|
||||
nickname: "小张",
|
||||
avatar: "",
|
||||
is_admin: false,
|
||||
total_spent: 0,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("创建用户失败");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
```
|
||||
|
||||
## Todo 接口
|
||||
|
||||
```text
|
||||
GET /todos
|
||||
POST /todos
|
||||
GET /todos/{todo_id}
|
||||
PATCH /todos/{todo_id}
|
||||
DELETE /todos/{todo_id}
|
||||
```
|
||||
|
||||
## 前端工程
|
||||
|
||||
前端目录在 `front`,使用 Vue 3、Vite、vue-router 和 Ant Design Vue。用户管理页面已接入后端 `/users` 接口。
|
||||
|
||||
安装依赖:
|
||||
|
||||
```bash
|
||||
cd front
|
||||
pnpm install
|
||||
```
|
||||
|
||||
启动前端:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
前端默认运行在:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:5173
|
||||
```
|
||||
|
||||
前端路由:
|
||||
|
||||
```text
|
||||
/login 静态登录页
|
||||
/temples 寺院管理
|
||||
/products 商品管理
|
||||
/orders 订单管理
|
||||
/ritual-services 法事管理
|
||||
/users 用户管理
|
||||
/todos Todo 管理占位页
|
||||
```
|
||||
|
||||
Vite 会把前端请求 `/api/users` 代理到后端:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8000/users
|
||||
```
|
||||
|
||||
如果后端端口不是 `8000`,可以启动前端时指定:
|
||||
|
||||
```bash
|
||||
VITE_API_TARGET=http://127.0.0.1:8010 pnpm dev
|
||||
```
|
||||
|
||||
前端应用名默认是“自在福田”,可以启动时覆盖:
|
||||
|
||||
```bash
|
||||
VITE_APP_NAME=自在福田 pnpm dev
|
||||
```
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
# 后端模块说明
|
||||
|
||||
## 模块边界
|
||||
|
||||
- `main.py`:FastAPI 应用入口,负责注册中间件、健康检查和路由。
|
||||
- `database.py`:SQLite 数据库路径、建表、轻量迁移和依赖注入。
|
||||
- `schemas.py`:Pydantic 数据模型。
|
||||
- `routers/auth.py`:登录认证、验证码、Cookie 会话和退出登录接口。
|
||||
- `routers/users.py`:用户管理接口。
|
||||
- `routers/todos.py`:Todo 管理接口。
|
||||
- `routers/temples.py`:寺院管理接口。
|
||||
- `routers/products.py`:商品管理接口,按寺院隔离。
|
||||
- `routers/orders.py`:订单管理接口,按寺院隔离。
|
||||
- `routers/payments.py`:微信支付接口,按寺院隔离发起 JSAPI 预下单并处理支付通知。
|
||||
- `routers/rituals.py`:法事管理接口,按寺院隔离。
|
||||
- `routers/uploads.py`:图片上传接口,上传到阿里云 OSS。
|
||||
- `config.py`:项目运行配置,目前预留阿里云 OSS 配置。
|
||||
- `security.py`:密码哈希、密钥哈希和会话令牌工具。
|
||||
- `temple_scope.py`:读取 `X-Temple-Id` 并校验寺院存在。
|
||||
- `wechat_pay.py`:微信支付 API v3 签名、验签、通知解密和请求工具。
|
||||
|
||||
## 用户模型
|
||||
|
||||
当前用户模型字段:
|
||||
|
||||
```text
|
||||
id 用户 ID
|
||||
registered_at 注册时间,后端创建时生成
|
||||
name 姓名
|
||||
phone 手机号,创建时必填并保持唯一
|
||||
nickname 昵称
|
||||
avatar 头像地址
|
||||
is_admin 是否管理员
|
||||
total_spent 总消费
|
||||
```
|
||||
|
||||
## 数据库约定
|
||||
|
||||
- 开发时优先通过 `DATABASE_PATH` 指定当前用户可写的 SQLite 文件。
|
||||
- 不使用 `sudo` 启动服务,避免数据库文件被 root 创建。
|
||||
- 修改表结构时,在 `database.py` 中补充轻量迁移逻辑,兼容已有 SQLite 文件。
|
||||
- 商品、订单、法事必须带 `temple_id`,接口查询和写入都要通过当前寺院上下文限制数据范围。
|
||||
- 当前寺院上下文通过请求头 `X-Temple-Id` 传入。
|
||||
|
||||
## 认证安全约定
|
||||
|
||||
- 登录接口为 `/auth/login`,验证码接口为 `/auth/captcha`,退出接口为 `/auth/logout`,当前用户接口为 `/auth/me`。
|
||||
- 密码使用 bcrypt 加盐哈希存储,不保存明文密码;默认演示账号为 `admin/admin123456` 和 `demo/demo123456`。
|
||||
- 登录会话使用服务端 `auth_sessions` 表保存,浏览器只接收 `HttpOnly`、`Secure`、`SameSite=Lax` Cookie。
|
||||
- `AUTH_REQUIRE_HTTPS` 默认开启;本地开发允许 localhost 非 HTTPS,生产环境应使用 HTTPS 或反向代理传递 `X-Forwarded-Proto: https`。
|
||||
- 同一账号或 IP 短时间失败 5 次后要求验证码,10 次后临时锁定账号 15 分钟。
|
||||
- 登录尝试和登录事件分别写入 `login_attempts`、`login_events`,用于记录 IP、设备和新环境登录标记;短信/邮箱二次验证和通知通道后续接入。
|
||||
|
||||
## 商品模型
|
||||
|
||||
商品详情模型包含:
|
||||
|
||||
```text
|
||||
商品名称、商品别称、商品描述、支付按钮名称、随喜按钮名称、排序号、分享时显示参与人数、
|
||||
商品封面、商品图片、上架时间、开售时间、结束时间、下架时间、结束倒计时、
|
||||
是否展示到首页、商品规格、商品详情、微信分享配置、功德证书预留、是否自动处理
|
||||
```
|
||||
|
||||
商品规格存储为 JSON 列表,字段为:
|
||||
|
||||
```text
|
||||
名称、单价、库存、排序、是否超度
|
||||
```
|
||||
|
||||
## 上传与 OSS 配置
|
||||
|
||||
图片上传接口为 `/uploads/images`,用于商品封面、商品图片等图片资源。OSS 配置预留在 `config.py`,可直接填写或通过同名环境变量覆盖:
|
||||
|
||||
```text
|
||||
OSS_ENDPOINT、OSS_BUCKET_NAME、OSS_ACCESS_KEY_ID、OSS_ACCESS_KEY_SECRET、OSS_PUBLIC_BASE_URL、OSS_UPLOAD_PREFIX
|
||||
```
|
||||
|
||||
## 微信支付约定
|
||||
|
||||
- 当前接入微信支付 API v3 的 JSAPI 预下单,接口为 `/payments/wechat/jsapi`;支付结果通知接口为 `/payments/wechat/notify`。
|
||||
- 业务订单仍存储在 `orders` 表,支付流水存储在 `payment_transactions` 表,使用 `out_trade_no` 关联微信支付交易。
|
||||
- 预下单需要前端传入微信用户 `openid`;真正调起 `WeixinJSBridge` 通常在用户端 H5/公众号页面完成。
|
||||
- 配置通过 `.env` 或环境变量提供:`WECHAT_PAY_APPID`、`WECHAT_PAY_MCH_ID`、`WECHAT_PAY_MCH_SERIAL_NO`、`WECHAT_PAY_API_V3_KEY`、`WECHAT_PAY_PRIVATE_KEY_PATH`、`WECHAT_PAY_PUBLIC_KEY_PATH`、`WECHAT_PAY_NOTIFY_URL`。
|
||||
- 支付通知需要验签并用 APIv3 密钥解密;生产环境必须配置微信支付公钥或平台证书,不应开启 `WECHAT_PAY_SKIP_NOTIFY_VERIFY`。
|
||||
|
||||
## 验证
|
||||
|
||||
后端修改后优先执行:
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m compileall index.py app
|
||||
```
|
||||
|
||||
接口变更后使用 FastAPI `TestClient` 或 `/docs` 做最小 CRUD 验证。
|
||||
|
||||
## 修改记录
|
||||
|
||||
- 2026-07-22:用户模型变更为注册时间、姓名、手机号、昵称、头像、是否管理员、总消费;`database.py` 增加旧表补列和默认值迁移,`schemas.py` 和 `routers/users.py` 同步新字段。验证:临时 pycache 编译通过,用户 CRUD 冒烟测试通过,旧表迁移测试通过。
|
||||
- 2026-07-22:`/users` 列表接口新增 `phone` 查询参数,支持按手机号模糊查询用户。验证:后端编译通过,手机号查询接口测试通过。
|
||||
- 2026-07-22:新增寺院、商品、订单、法事后端模块;`products`、`orders`、`ritual_services` 通过 `X-Temple-Id` 做寺院级数据隔离。验证:后端编译通过,寺院隔离接口测试通过。
|
||||
- 2026-07-23:商品详情模型扩展为完整详情字段,`products` 表增加别称、按钮名称、展示配置、图片、时间、规格 JSON、详情 HTML、微信分享、功德证书预留、自动处理、销量等字段;商品接口同步新模型并继续按 `X-Temple-Id` 隔离。验证:后端编译通过,商品详情 CRUD 测试通过,旧商品表迁移测试通过。
|
||||
- 2026-07-23:新增 `/uploads/images` 图片上传接口,预留阿里云 OSS 配置并新增 `oss2`、`python-multipart` 依赖;OSS 未配置时接口返回明确 503 提示。验证:后端编译通过,未配置 OSS 上传接口提示测试通过。
|
||||
- 2026-07-23:新增登录认证模块,支持服务端动态验证码、bcrypt 密码哈希、失败次数限制、账号临时锁定、HttpOnly/Secure Cookie 会话、退出登录、登录 IP/设备日志和新环境登录标记;`requirements.txt` 新增 `bcrypt`。验证:后端编译通过,运行态登录测试待安装 `bcrypt` 后执行。
|
||||
- 2026-07-23:商品模型新增 `description` 商品描述字段,后端补充 `products.description` 轻量迁移、Pydantic 模型和 `/products` CRUD 读写。验证:后端编译通过,运行态接口测试待安装 `bcrypt` 后执行。
|
||||
- 2026-07-23:新增微信支付 JSAPI 预下单和支付通知处理,增加 `payment_transactions` 支付流水表、微信支付 API v3 签名/通知验签/回调解密工具和配置占位;`requirements.txt` 新增 `cryptography`。验证:后端编译通过,真实预下单和通知验签待填写微信商户配置后联调。
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def load_local_env() -> None:
|
||||
env_path = Path(__file__).parents[1] / ".env"
|
||||
if not env_path.exists():
|
||||
return
|
||||
|
||||
for raw_line in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
|
||||
|
||||
|
||||
load_local_env()
|
||||
|
||||
|
||||
AUTH_REQUIRE_HTTPS = os.getenv("AUTH_REQUIRE_HTTPS", "1") == "1"
|
||||
AUTH_ALLOW_INSECURE_LOCAL = os.getenv("AUTH_ALLOW_INSECURE_LOCAL", "1") == "1"
|
||||
AUTH_COOKIE_SECURE = os.getenv("AUTH_COOKIE_SECURE", "1") == "1"
|
||||
|
||||
# 阿里云 OSS 配置预留:可以直接在这里填写,也可以用同名环境变量覆盖。
|
||||
OSS_ENDPOINT = os.getenv("OSS_ENDPOINT", "")
|
||||
OSS_INTERNAL_ENDPOINT = os.getenv("OSS_INTERNAL_ENDPOINT", "")
|
||||
OSS_USE_INTERNAL = os.getenv("OSS_USE_INTERNAL", "0") == "1"
|
||||
OSS_BUCKET_NAME = os.getenv("OSS_BUCKET_NAME", "")
|
||||
OSS_ACCESS_KEY_ID = os.getenv("OSS_ACCESS_KEY_ID", "")
|
||||
OSS_ACCESS_KEY_SECRET = os.getenv("OSS_ACCESS_KEY_SECRET", "")
|
||||
OSS_PUBLIC_BASE_URL = os.getenv("OSS_PUBLIC_BASE_URL", "")
|
||||
OSS_UPLOAD_PREFIX = os.getenv("OSS_UPLOAD_PREFIX", "product-images")
|
||||
|
||||
WECHAT_PAY_APPID = os.getenv("WECHAT_PAY_APPID", "")
|
||||
WECHAT_PAY_MCH_ID = os.getenv("WECHAT_PAY_MCH_ID", "")
|
||||
WECHAT_PAY_MCH_SERIAL_NO = os.getenv("WECHAT_PAY_MCH_SERIAL_NO", "")
|
||||
WECHAT_PAY_API_V3_KEY = os.getenv("WECHAT_PAY_API_V3_KEY", "")
|
||||
WECHAT_PAY_PRIVATE_KEY_PATH = os.getenv("WECHAT_PAY_PRIVATE_KEY_PATH", "")
|
||||
WECHAT_PAY_PUBLIC_KEY_PATH = os.getenv("WECHAT_PAY_PUBLIC_KEY_PATH", "")
|
||||
WECHAT_PAY_NOTIFY_URL = os.getenv("WECHAT_PAY_NOTIFY_URL", "")
|
||||
WECHAT_PAY_API_BASE_URL = os.getenv("WECHAT_PAY_API_BASE_URL", "https://api.mch.weixin.qq.com")
|
||||
WECHAT_PAY_SKIP_NOTIFY_VERIFY = os.getenv("WECHAT_PAY_SKIP_NOTIFY_VERIFY", "0") == "1"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OSSSettings:
|
||||
endpoint: str
|
||||
internal_endpoint: str
|
||||
use_internal: bool
|
||||
bucket_name: str
|
||||
access_key_id: str
|
||||
access_key_secret: str
|
||||
public_base_url: str
|
||||
upload_prefix: str
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return all(
|
||||
[
|
||||
self.endpoint,
|
||||
self.bucket_name,
|
||||
self.access_key_id,
|
||||
self.access_key_secret,
|
||||
]
|
||||
)
|
||||
|
||||
@property
|
||||
def upload_endpoint(self) -> str:
|
||||
if self.use_internal and self.internal_endpoint:
|
||||
return self.internal_endpoint
|
||||
return self.endpoint
|
||||
|
||||
|
||||
oss_settings = OSSSettings(
|
||||
endpoint=OSS_ENDPOINT,
|
||||
internal_endpoint=OSS_INTERNAL_ENDPOINT,
|
||||
use_internal=OSS_USE_INTERNAL,
|
||||
bucket_name=OSS_BUCKET_NAME,
|
||||
access_key_id=OSS_ACCESS_KEY_ID,
|
||||
access_key_secret=OSS_ACCESS_KEY_SECRET,
|
||||
public_base_url=OSS_PUBLIC_BASE_URL,
|
||||
upload_prefix=OSS_UPLOAD_PREFIX,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthSettings:
|
||||
require_https: bool
|
||||
allow_insecure_local: bool
|
||||
cookie_secure: bool
|
||||
|
||||
|
||||
auth_settings = AuthSettings(
|
||||
require_https=AUTH_REQUIRE_HTTPS,
|
||||
allow_insecure_local=AUTH_ALLOW_INSECURE_LOCAL,
|
||||
cookie_secure=AUTH_COOKIE_SECURE,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WeChatPaySettings:
|
||||
appid: str
|
||||
mch_id: str
|
||||
mch_serial_no: str
|
||||
api_v3_key: str
|
||||
private_key_path: str
|
||||
public_key_path: str
|
||||
notify_url: str
|
||||
api_base_url: str
|
||||
skip_notify_verify: bool
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return all(
|
||||
[
|
||||
self.appid,
|
||||
self.mch_id,
|
||||
self.mch_serial_no,
|
||||
self.api_v3_key,
|
||||
self.private_key_path,
|
||||
self.notify_url,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
wechat_pay_settings = WeChatPaySettings(
|
||||
appid=WECHAT_PAY_APPID,
|
||||
mch_id=WECHAT_PAY_MCH_ID,
|
||||
mch_serial_no=WECHAT_PAY_MCH_SERIAL_NO,
|
||||
api_v3_key=WECHAT_PAY_API_V3_KEY,
|
||||
private_key_path=WECHAT_PAY_PRIVATE_KEY_PATH,
|
||||
public_key_path=WECHAT_PAY_PUBLIC_KEY_PATH,
|
||||
notify_url=WECHAT_PAY_NOTIFY_URL,
|
||||
api_base_url=WECHAT_PAY_API_BASE_URL,
|
||||
skip_notify_verify=WECHAT_PAY_SKIP_NOTIFY_VERIFY,
|
||||
)
|
||||
+452
@@ -0,0 +1,452 @@
|
||||
import os
|
||||
import sqlite3
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, FastAPI
|
||||
|
||||
from app.security import hash_password
|
||||
|
||||
|
||||
DATABASE_PATH = Path(os.getenv("DATABASE_PATH", Path(__file__).parents[1] / "app.sqlite3"))
|
||||
|
||||
|
||||
def ensure_user_schema(connection: sqlite3.Connection) -> None:
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
registered_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
name TEXT NOT NULL,
|
||||
phone TEXT,
|
||||
nickname TEXT,
|
||||
avatar TEXT,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
total_spent REAL NOT NULL DEFAULT 0
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
columns = {
|
||||
row["name"] if isinstance(row, sqlite3.Row) else row[1]
|
||||
for row in connection.execute("PRAGMA table_info(users)").fetchall()
|
||||
}
|
||||
additions = {
|
||||
"registered_at": "TEXT",
|
||||
"username": "TEXT",
|
||||
"password_hash": "TEXT",
|
||||
"failed_login_count": "INTEGER NOT NULL DEFAULT 0",
|
||||
"locked_until": "TEXT",
|
||||
"name": "TEXT",
|
||||
"phone": "TEXT",
|
||||
"nickname": "TEXT",
|
||||
"avatar": "TEXT",
|
||||
"is_admin": "INTEGER NOT NULL DEFAULT 0",
|
||||
"total_spent": "REAL NOT NULL DEFAULT 0",
|
||||
}
|
||||
|
||||
for column_name, definition in additions.items():
|
||||
if column_name not in columns:
|
||||
connection.execute(f"ALTER TABLE users ADD COLUMN {column_name} {definition}")
|
||||
|
||||
columns = {
|
||||
row["name"] if isinstance(row, sqlite3.Row) else row[1]
|
||||
for row in connection.execute("PRAGMA table_info(users)").fetchall()
|
||||
}
|
||||
|
||||
connection.execute(
|
||||
"UPDATE users SET registered_at = CURRENT_TIMESTAMP WHERE registered_at IS NULL OR registered_at = ''"
|
||||
)
|
||||
if "full_name" in columns:
|
||||
connection.execute("UPDATE users SET name = full_name WHERE (name IS NULL OR name = '') AND full_name IS NOT NULL")
|
||||
if "username" in columns:
|
||||
connection.execute("UPDATE users SET name = username WHERE name IS NULL OR name = ''")
|
||||
|
||||
connection.execute("UPDATE users SET name = 'Unknown User' WHERE name IS NULL OR name = ''")
|
||||
connection.execute("UPDATE users SET is_admin = 0 WHERE is_admin IS NULL")
|
||||
connection.execute("UPDATE users SET total_spent = 0 WHERE total_spent IS NULL")
|
||||
connection.execute("UPDATE users SET failed_login_count = 0 WHERE failed_login_count IS NULL")
|
||||
connection.execute("UPDATE users SET username = lower(nickname) WHERE (username IS NULL OR username = '') AND nickname IS NOT NULL AND nickname != ''")
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_phone_unique
|
||||
ON users(phone)
|
||||
WHERE phone IS NOT NULL AND phone != ''
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username_unique
|
||||
ON users(username)
|
||||
WHERE username IS NOT NULL AND username != ''
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def set_user_password(connection: sqlite3.Connection, username: str, password: str) -> None:
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET username = ?, password_hash = ?
|
||||
WHERE username = ? OR nickname = ? OR phone = ?
|
||||
""",
|
||||
(username, hash_password(password), username, username, username),
|
||||
)
|
||||
|
||||
|
||||
def ensure_default_user_credentials(connection: sqlite3.Connection) -> None:
|
||||
admin = connection.execute(
|
||||
"SELECT id, password_hash FROM users WHERE username = 'admin' OR nickname = 'admin' OR phone = '13800000000'"
|
||||
).fetchone()
|
||||
if admin is not None and not str(admin["password_hash"] or "").startswith("$2"):
|
||||
set_user_password(connection, "admin", "admin123456")
|
||||
|
||||
demo = connection.execute(
|
||||
"SELECT id, password_hash FROM users WHERE username = 'demo' OR nickname = 'demo' OR phone = '13900000000'"
|
||||
).fetchone()
|
||||
if demo is not None and not str(demo["password_hash"] or "").startswith("$2"):
|
||||
set_user_password(connection, "demo", "demo123456")
|
||||
|
||||
rows = connection.execute(
|
||||
"SELECT id FROM users WHERE username IS NULL OR username = '' ORDER BY id"
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
connection.execute("UPDATE users SET username = ? WHERE id = ?", (f"user{row['id']}", row["id"]))
|
||||
|
||||
|
||||
def ensure_product_schema(connection: sqlite3.Connection) -> None:
|
||||
columns = {
|
||||
row["name"] if isinstance(row, sqlite3.Row) else row[1]
|
||||
for row in connection.execute("PRAGMA table_info(products)").fetchall()
|
||||
}
|
||||
additions = {
|
||||
"alias": "TEXT",
|
||||
"description": "TEXT",
|
||||
"payment_button_name": "TEXT NOT NULL DEFAULT '立即支付'",
|
||||
"donation_button_name": "TEXT NOT NULL DEFAULT '随喜'",
|
||||
"sort_order": "INTEGER NOT NULL DEFAULT 0",
|
||||
"show_participant_count": "INTEGER NOT NULL DEFAULT 0",
|
||||
"cover_image": "TEXT",
|
||||
"images_json": "TEXT NOT NULL DEFAULT '[]'",
|
||||
"listed_at": "TEXT",
|
||||
"sale_starts_at": "TEXT",
|
||||
"sale_ends_at": "TEXT",
|
||||
"delisted_at": "TEXT",
|
||||
"show_countdown": "INTEGER NOT NULL DEFAULT 0",
|
||||
"show_on_home": "INTEGER NOT NULL DEFAULT 0",
|
||||
"specs_json": "TEXT NOT NULL DEFAULT '[]'",
|
||||
"details_html": "TEXT",
|
||||
"share_title": "TEXT",
|
||||
"share_description": "TEXT",
|
||||
"share_image": "TEXT",
|
||||
"merit_certificate_reserved": "INTEGER NOT NULL DEFAULT 0",
|
||||
"auto_process": "INTEGER NOT NULL DEFAULT 0",
|
||||
"sales_count": "INTEGER NOT NULL DEFAULT 0",
|
||||
}
|
||||
|
||||
for column_name, definition in additions.items():
|
||||
if column_name not in columns:
|
||||
connection.execute(f"ALTER TABLE products ADD COLUMN {column_name} {definition}")
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
DATABASE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with sqlite3.connect(DATABASE_PATH) as connection:
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS todos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
done INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
"""
|
||||
)
|
||||
ensure_user_schema(connection)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS temples (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
location TEXT,
|
||||
contact_phone TEXT,
|
||||
description TEXT,
|
||||
is_active INTEGER NOT NULL DEFAULT 1
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS products (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
temple_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
price REAL NOT NULL DEFAULT 0,
|
||||
stock INTEGER NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
FOREIGN KEY (temple_id) REFERENCES temples(id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
ensure_product_schema(connection)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS ritual_services (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
temple_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
price REAL NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
FOREIGN KEY (temple_id) REFERENCES temples(id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
temple_id INTEGER NOT NULL,
|
||||
customer_name TEXT NOT NULL,
|
||||
customer_phone TEXT,
|
||||
order_type TEXT NOT NULL,
|
||||
item_name TEXT NOT NULL,
|
||||
amount REAL NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (temple_id) REFERENCES temples(id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS payment_transactions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
temple_id INTEGER NOT NULL,
|
||||
order_id INTEGER NOT NULL,
|
||||
channel TEXT NOT NULL DEFAULT 'wechat',
|
||||
out_trade_no TEXT NOT NULL UNIQUE,
|
||||
amount INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'created',
|
||||
prepay_id TEXT,
|
||||
transaction_id TEXT,
|
||||
raw_notify_json TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (temple_id) REFERENCES temples(id),
|
||||
FOREIGN KEY (order_id) REFERENCES orders(id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS login_captchas (
|
||||
id TEXT PRIMARY KEY,
|
||||
answer_hash TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
used_at TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS auth_sessions (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
revoked_at TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
session_columns = {
|
||||
row["name"] if isinstance(row, sqlite3.Row) else row[1]
|
||||
for row in connection.execute("PRAGMA table_info(auth_sessions)").fetchall()
|
||||
}
|
||||
if "revoked_at" not in session_columns:
|
||||
connection.execute("ALTER TABLE auth_sessions ADD COLUMN revoked_at TEXT")
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS login_attempts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT,
|
||||
ip_address TEXT NOT NULL,
|
||||
user_agent TEXT,
|
||||
success INTEGER NOT NULL DEFAULT 0,
|
||||
failure_reason TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS login_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
ip_address TEXT NOT NULL,
|
||||
user_agent TEXT,
|
||||
is_new_ip INTEGER NOT NULL DEFAULT 0,
|
||||
is_new_device INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
todo_count = connection.execute("SELECT COUNT(*) FROM todos").fetchone()[0]
|
||||
if todo_count == 0:
|
||||
connection.executemany(
|
||||
"INSERT INTO todos (title, done) VALUES (?, ?)",
|
||||
[
|
||||
("Learn FastAPI routes", 0),
|
||||
("Open interactive API docs", 0),
|
||||
],
|
||||
)
|
||||
|
||||
user_count = connection.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||
if user_count == 0:
|
||||
connection.executemany(
|
||||
"""
|
||||
INSERT INTO users
|
||||
(name, phone, nickname, avatar, is_admin, total_spent)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
[
|
||||
("管理员", "13800000000", "admin", "", 1, 0),
|
||||
("演示用户", "13900000000", "demo", "", 0, 128.5),
|
||||
],
|
||||
)
|
||||
ensure_default_user_credentials(connection)
|
||||
|
||||
temple_count = connection.execute("SELECT COUNT(*) FROM temples").fetchone()[0]
|
||||
if temple_count == 0:
|
||||
connection.executemany(
|
||||
"""
|
||||
INSERT INTO temples (name, location, contact_phone, description, is_active)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
[
|
||||
("福田寺", "深圳福田", "0755-10000001", "自在福田示例寺院", 1),
|
||||
("南山寺", "深圳南山", "0755-10000002", "用于验证数据隔离的示例寺院", 1),
|
||||
],
|
||||
)
|
||||
|
||||
product_count = connection.execute("SELECT COUNT(*) FROM products").fetchone()[0]
|
||||
if product_count == 0:
|
||||
connection.executemany(
|
||||
"""
|
||||
INSERT INTO products
|
||||
(
|
||||
temple_id, name, alias, payment_button_name, donation_button_name,
|
||||
price, stock, sort_order, cover_image, show_on_home, specs_json,
|
||||
details_html, share_title, share_description, is_active, sales_count
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
[
|
||||
(
|
||||
1,
|
||||
"平安香",
|
||||
"平安",
|
||||
"立即结缘",
|
||||
"随喜功德",
|
||||
29.9,
|
||||
100,
|
||||
10,
|
||||
"",
|
||||
1,
|
||||
'[{"name":"单份","price":29.9,"stock":100,"sort_order":1,"is_chaodu":false}]',
|
||||
"<p>祈愿平安顺遂。</p>",
|
||||
"平安香",
|
||||
"为家人祈福平安",
|
||||
1,
|
||||
32,
|
||||
),
|
||||
(
|
||||
1,
|
||||
"祈福灯",
|
||||
"供灯",
|
||||
"立即供灯",
|
||||
"随喜供灯",
|
||||
99.0,
|
||||
50,
|
||||
20,
|
||||
"",
|
||||
1,
|
||||
'[{"name":"一盏","price":99,"stock":50,"sort_order":1,"is_chaodu":false}]',
|
||||
"<p>供灯祈福,愿心光明。</p>",
|
||||
"祈福灯",
|
||||
"供灯祈福,照亮善愿",
|
||||
1,
|
||||
18,
|
||||
),
|
||||
(
|
||||
2,
|
||||
"南山香礼",
|
||||
"香礼",
|
||||
"立即支付",
|
||||
"随喜",
|
||||
39.9,
|
||||
80,
|
||||
10,
|
||||
"",
|
||||
1,
|
||||
'[{"name":"单份","price":39.9,"stock":80,"sort_order":1,"is_chaodu":false}]',
|
||||
"<p>南山寺香礼。</p>",
|
||||
"南山香礼",
|
||||
"南山寺祈福香礼",
|
||||
1,
|
||||
11,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
ritual_count = connection.execute("SELECT COUNT(*) FROM ritual_services").fetchone()[0]
|
||||
if ritual_count == 0:
|
||||
connection.executemany(
|
||||
"""
|
||||
INSERT INTO ritual_services (temple_id, name, description, price, is_active)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
[
|
||||
(1, "祈福法会", "为家人祈福平安", 199.0, 1),
|
||||
(1, "超度法事", "超度追思法事", 399.0, 1),
|
||||
(2, "供灯祈福", "南山寺供灯祈福", 129.0, 1),
|
||||
],
|
||||
)
|
||||
|
||||
order_count = connection.execute("SELECT COUNT(*) FROM orders").fetchone()[0]
|
||||
if order_count == 0:
|
||||
connection.executemany(
|
||||
"""
|
||||
INSERT INTO orders
|
||||
(temple_id, customer_name, customer_phone, order_type, item_name, amount, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
[
|
||||
(1, "张三", "13800138000", "product", "平安香", 29.9, "paid"),
|
||||
(1, "李四", "13900139000", "ritual", "祈福法会", 199.0, "pending"),
|
||||
(2, "王五", "13700137000", "product", "南山香礼", 39.9, "paid"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
def get_db():
|
||||
connection = sqlite3.connect(DATABASE_PATH, check_same_thread=False)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
yield connection
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
Database = Annotated[sqlite3.Connection, Depends(get_db)]
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.database import lifespan
|
||||
from app.routers import auth, orders, payments, products, rituals, temples, todos, uploads, users
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Toy User API",
|
||||
description="A tiny FastAPI application with Todo and User CRUD APIs.",
|
||||
version="0.2.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://127.0.0.1:5173", "http://localhost:5173"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def read_root() -> dict[str, str]:
|
||||
return {
|
||||
"message": "Welcome to the Toy API.",
|
||||
"docs": "/docs",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health_check() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
app.include_router(todos.router)
|
||||
app.include_router(auth.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(temples.router)
|
||||
app.include_router(products.router)
|
||||
app.include_router(orders.router)
|
||||
app.include_router(payments.router)
|
||||
app.include_router(rituals.router)
|
||||
app.include_router(uploads.router)
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
from datetime import datetime, timedelta
|
||||
import secrets
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Cookie, HTTPException, Request, Response, status
|
||||
|
||||
from app.config import auth_settings
|
||||
from app.database import Database
|
||||
from app.schemas import AuthUser, CaptchaResponse, LoginRequest, LoginResponse
|
||||
from app.security import create_token, hash_secret, verify_password
|
||||
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
CAPTCHA_TTL_SECONDS = 300
|
||||
SESSION_COOKIE_NAME = "admin_session"
|
||||
SESSION_TTL_SECONDS = 7 * 24 * 60 * 60
|
||||
CAPTCHA_FAILURE_THRESHOLD = 5
|
||||
LOCK_FAILURE_THRESHOLD = 10
|
||||
FAILURE_WINDOW_MINUTES = 15
|
||||
LOCK_MINUTES = 15
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.utcnow().replace(microsecond=0)
|
||||
|
||||
|
||||
def to_db_time(value: datetime) -> str:
|
||||
return value.isoformat()
|
||||
|
||||
|
||||
def from_db_time(value: str | None) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def get_client_ip(request: Request) -> str:
|
||||
forwarded_for = request.headers.get("x-forwarded-for")
|
||||
if forwarded_for:
|
||||
return forwarded_for.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
def get_user_agent(request: Request) -> str:
|
||||
return request.headers.get("user-agent", "")[:300]
|
||||
|
||||
|
||||
def is_local_request(request: Request) -> bool:
|
||||
host = request.url.hostname or ""
|
||||
return host in {"127.0.0.1", "localhost", "::1"}
|
||||
|
||||
|
||||
def ensure_https(request: Request) -> None:
|
||||
if not auth_settings.require_https:
|
||||
return
|
||||
forwarded_proto = request.headers.get("x-forwarded-proto", "")
|
||||
is_https = request.url.scheme == "https" or forwarded_proto.lower() == "https"
|
||||
if is_https:
|
||||
return
|
||||
if auth_settings.allow_insecure_local and is_local_request(request):
|
||||
return
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="登录必须通过 HTTPS 连接,请使用安全地址后重试",
|
||||
)
|
||||
|
||||
|
||||
def create_auth_user(row) -> AuthUser:
|
||||
return AuthUser(
|
||||
id=row["id"],
|
||||
username=row["username"],
|
||||
name=row["name"],
|
||||
is_admin=bool(row["is_admin"]),
|
||||
)
|
||||
|
||||
|
||||
def count_recent_failures(db: Database, username: str, ip_address: str) -> int:
|
||||
since = to_db_time(utc_now() - timedelta(minutes=FAILURE_WINDOW_MINUTES))
|
||||
row = db.execute(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM login_attempts
|
||||
WHERE success = 0
|
||||
AND created_at >= ?
|
||||
AND (lower(username) = ? OR ip_address = ?)
|
||||
""",
|
||||
(since, username, ip_address),
|
||||
).fetchone()
|
||||
return int(row[0])
|
||||
|
||||
|
||||
def record_login_attempt(
|
||||
db: Database,
|
||||
username: str,
|
||||
ip_address: str,
|
||||
user_agent: str,
|
||||
success: bool,
|
||||
failure_reason: str | None = None,
|
||||
) -> None:
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO login_attempts (username, ip_address, user_agent, success, failure_reason)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(username, ip_address, user_agent, int(success), failure_reason),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def detect_new_login_context(db: Database, user_id: int, ip_address: str, user_agent: str) -> tuple[bool, bool]:
|
||||
old_ip = db.execute(
|
||||
"""
|
||||
SELECT 1 FROM login_events
|
||||
WHERE user_id = ? AND ip_address = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
(user_id, ip_address),
|
||||
).fetchone()
|
||||
old_device = db.execute(
|
||||
"""
|
||||
SELECT 1 FROM login_events
|
||||
WHERE user_id = ? AND user_agent = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
(user_id, user_agent),
|
||||
).fetchone()
|
||||
had_login = db.execute("SELECT 1 FROM login_events WHERE user_id = ? LIMIT 1", (user_id,)).fetchone()
|
||||
return had_login is not None and old_ip is None, had_login is not None and old_device is None
|
||||
|
||||
|
||||
def record_login_event(db: Database, user_id: int, ip_address: str, user_agent: str, is_new_ip: bool, is_new_device: bool) -> None:
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO login_events (user_id, ip_address, user_agent, is_new_ip, is_new_device)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(user_id, ip_address, user_agent, int(is_new_ip), int(is_new_device)),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def set_session_cookie(response: Response, token: str) -> None:
|
||||
response.set_cookie(
|
||||
key=SESSION_COOKIE_NAME,
|
||||
value=token,
|
||||
max_age=SESSION_TTL_SECONDS,
|
||||
httponly=True,
|
||||
secure=auth_settings.cookie_secure,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
def clear_session_cookie(response: Response) -> None:
|
||||
response.delete_cookie(key=SESSION_COOKIE_NAME, path="/")
|
||||
|
||||
|
||||
@router.get("/captcha", response_model=CaptchaResponse)
|
||||
def get_captcha(db: Database) -> CaptchaResponse:
|
||||
left = secrets.randbelow(8) + 2
|
||||
right = secrets.randbelow(8) + 2
|
||||
answer = str(left + right)
|
||||
captcha_id = uuid4().hex
|
||||
expires_at = utc_now() + timedelta(seconds=CAPTCHA_TTL_SECONDS)
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO login_captchas (id, answer_hash, expires_at)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(captcha_id, hash_secret(answer.lower()), to_db_time(expires_at)),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return CaptchaResponse(
|
||||
captcha_id=captcha_id,
|
||||
challenge=f"{left} + {right} = ?",
|
||||
expires_in=CAPTCHA_TTL_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
def verify_captcha(db: Database, captcha_id: str | None, captcha_code: str | None) -> None:
|
||||
if not captcha_id or not captcha_code:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请输入验证码后再登录")
|
||||
|
||||
row = db.execute(
|
||||
"""
|
||||
SELECT id, answer_hash, expires_at, used_at
|
||||
FROM login_captchas
|
||||
WHERE id = ?
|
||||
""",
|
||||
(captcha_id,),
|
||||
).fetchone()
|
||||
if row is None or row["used_at"]:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码已失效,请刷新后重试")
|
||||
|
||||
expires_at = from_db_time(row["expires_at"])
|
||||
if expires_at is None or utc_now() > expires_at:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码已过期,请刷新后重试")
|
||||
|
||||
db.execute("UPDATE login_captchas SET used_at = ? WHERE id = ?", (to_db_time(utc_now()), captcha_id))
|
||||
db.commit()
|
||||
|
||||
if hash_secret(captcha_code.strip().lower()) != row["answer_hash"]:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码错误,请重新输入")
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
def login(payload: LoginRequest, request: Request, response: Response, db: Database) -> LoginResponse:
|
||||
ensure_https(request)
|
||||
|
||||
username = payload.username.strip().lower()
|
||||
ip_address = get_client_ip(request)
|
||||
user_agent = get_user_agent(request)
|
||||
recent_failures = count_recent_failures(db, username, ip_address)
|
||||
|
||||
try:
|
||||
verify_captcha(db, payload.captcha_id, payload.captcha_code)
|
||||
except HTTPException as exc:
|
||||
record_login_attempt(db, username, ip_address, user_agent, False, "captcha_error")
|
||||
raise exc
|
||||
|
||||
row = db.execute(
|
||||
"""
|
||||
SELECT id, username, name, is_admin, password_hash, failed_login_count, locked_until
|
||||
FROM users
|
||||
WHERE lower(username) = ?
|
||||
""",
|
||||
(username,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
record_login_attempt(db, username, ip_address, user_agent, False, "unknown_user")
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="账号不存在,请检查用户名")
|
||||
|
||||
locked_until = from_db_time(row["locked_until"])
|
||||
if locked_until is not None and utc_now() < locked_until:
|
||||
record_login_attempt(db, username, ip_address, user_agent, False, "locked")
|
||||
raise HTTPException(status_code=status.HTTP_423_LOCKED, detail="账号已临时锁定,请 15 分钟后再试")
|
||||
|
||||
password_hash = row["password_hash"]
|
||||
if not password_hash:
|
||||
record_login_attempt(db, username, ip_address, user_agent, False, "password_missing")
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="账号尚未设置密码,请联系管理员")
|
||||
|
||||
if not verify_password(payload.password, password_hash):
|
||||
failure_count = int(row["failed_login_count"] or 0) + 1
|
||||
total_failures = max(failure_count, recent_failures + 1)
|
||||
locked_value = None
|
||||
message = "密码错误,请重新输入"
|
||||
if total_failures >= LOCK_FAILURE_THRESHOLD:
|
||||
locked_value = to_db_time(utc_now() + timedelta(minutes=LOCK_MINUTES))
|
||||
message = "密码连续错误次数过多,账号已临时锁定 15 分钟"
|
||||
elif total_failures >= CAPTCHA_FAILURE_THRESHOLD:
|
||||
message = "密码错误,请输入验证码后重试"
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET failed_login_count = ?, locked_until = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(failure_count, locked_value, row["id"]),
|
||||
)
|
||||
db.commit()
|
||||
record_login_attempt(db, username, ip_address, user_agent, False, "wrong_password")
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=message)
|
||||
|
||||
token = create_token()
|
||||
expires_at = utc_now() + timedelta(seconds=SESSION_TTL_SECONDS)
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO auth_sessions (token_hash, user_id, expires_at)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(hash_secret(token), row["id"], to_db_time(expires_at)),
|
||||
)
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET failed_login_count = 0, locked_until = NULL
|
||||
WHERE id = ?
|
||||
""",
|
||||
(row["id"],),
|
||||
)
|
||||
db.commit()
|
||||
set_session_cookie(response, token)
|
||||
record_login_attempt(db, username, ip_address, user_agent, True)
|
||||
|
||||
is_new_ip, is_new_device = detect_new_login_context(db, row["id"], ip_address, user_agent)
|
||||
record_login_event(db, row["id"], ip_address, user_agent, is_new_ip, is_new_device)
|
||||
requires_second_verification = is_new_ip or is_new_device
|
||||
security_notice = None
|
||||
if requires_second_verification:
|
||||
security_notice = "检测到新 IP 或新设备登录,后续应接入短信/邮箱二次验证与通知"
|
||||
|
||||
return LoginResponse(
|
||||
expires_in=SESSION_TTL_SECONDS,
|
||||
user=create_auth_user(row),
|
||||
requires_second_verification=requires_second_verification,
|
||||
security_notice=security_notice,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=AuthUser)
|
||||
def get_current_user(db: Database, admin_session: str | None = Cookie(default=None)) -> AuthUser:
|
||||
if not admin_session:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="请先登录")
|
||||
|
||||
row = db.execute(
|
||||
"""
|
||||
SELECT users.id, users.username, users.name, users.is_admin, auth_sessions.expires_at, auth_sessions.revoked_at
|
||||
FROM auth_sessions
|
||||
JOIN users ON users.id = auth_sessions.user_id
|
||||
WHERE auth_sessions.token_hash = ?
|
||||
""",
|
||||
(hash_secret(admin_session),),
|
||||
).fetchone()
|
||||
if row is None or row["revoked_at"]:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录状态已失效,请重新登录")
|
||||
|
||||
expires_at = from_db_time(row["expires_at"])
|
||||
if expires_at is None or utc_now() > expires_at:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已过期,请重新登录")
|
||||
|
||||
return create_auth_user(row)
|
||||
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def logout(response: Response, db: Database, admin_session: str | None = Cookie(default=None)) -> None:
|
||||
if admin_session:
|
||||
db.execute(
|
||||
"UPDATE auth_sessions SET revoked_at = ? WHERE token_hash = ?",
|
||||
(to_db_time(utc_now()), hash_secret(admin_session)),
|
||||
)
|
||||
db.commit()
|
||||
clear_session_cookie(response)
|
||||
@@ -0,0 +1,120 @@
|
||||
import sqlite3
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from app.database import Database
|
||||
from app.schemas import Order, OrderCreate, OrderUpdate
|
||||
from app.temple_scope import TempleId, ensure_temple_exists
|
||||
|
||||
|
||||
router = APIRouter(prefix="/orders", tags=["orders"])
|
||||
|
||||
|
||||
def row_to_order(row: sqlite3.Row) -> Order:
|
||||
return Order(
|
||||
id=row["id"],
|
||||
temple_id=row["temple_id"],
|
||||
customer_name=row["customer_name"],
|
||||
customer_phone=row["customer_phone"],
|
||||
order_type=row["order_type"],
|
||||
item_name=row["item_name"],
|
||||
amount=float(row["amount"]),
|
||||
status=row["status"],
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=List[Order])
|
||||
def list_orders(db: Database, temple_id: TempleId) -> List[Order]:
|
||||
ensure_temple_exists(db, temple_id)
|
||||
rows = db.execute(
|
||||
"""
|
||||
SELECT id, temple_id, customer_name, customer_phone, order_type, item_name, amount, status, created_at
|
||||
FROM orders
|
||||
WHERE temple_id = ?
|
||||
ORDER BY id DESC
|
||||
""",
|
||||
(temple_id,),
|
||||
).fetchall()
|
||||
return [row_to_order(row) for row in rows]
|
||||
|
||||
|
||||
@router.post("", response_model=Order, status_code=status.HTTP_201_CREATED)
|
||||
def create_order(payload: OrderCreate, db: Database, temple_id: TempleId) -> Order:
|
||||
ensure_temple_exists(db, temple_id)
|
||||
cursor = db.execute(
|
||||
"""
|
||||
INSERT INTO orders
|
||||
(temple_id, customer_name, customer_phone, order_type, item_name, amount, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
temple_id,
|
||||
payload.customer_name,
|
||||
payload.customer_phone,
|
||||
payload.order_type,
|
||||
payload.item_name,
|
||||
payload.amount,
|
||||
payload.status,
|
||||
),
|
||||
)
|
||||
db.commit()
|
||||
return get_order(cursor.lastrowid, db, temple_id)
|
||||
|
||||
|
||||
@router.get("/{order_id}", response_model=Order)
|
||||
def get_order(order_id: int, db: Database, temple_id: TempleId) -> Order:
|
||||
ensure_temple_exists(db, temple_id)
|
||||
row = db.execute(
|
||||
"""
|
||||
SELECT id, temple_id, customer_name, customer_phone, order_type, item_name, amount, status, created_at
|
||||
FROM orders
|
||||
WHERE id = ? AND temple_id = ?
|
||||
""",
|
||||
(order_id, temple_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Order not found")
|
||||
return row_to_order(row)
|
||||
|
||||
|
||||
@router.patch("/{order_id}", response_model=Order)
|
||||
def update_order(order_id: int, payload: OrderUpdate, db: Database, temple_id: TempleId) -> Order:
|
||||
order = get_order(order_id, db, temple_id)
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
|
||||
if not updates:
|
||||
return order
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE orders
|
||||
SET customer_name = ?, customer_phone = ?, order_type = ?, item_name = ?, amount = ?, status = ?
|
||||
WHERE id = ? AND temple_id = ?
|
||||
""",
|
||||
(
|
||||
updates.get("customer_name", order.customer_name),
|
||||
updates.get("customer_phone", order.customer_phone),
|
||||
updates.get("order_type", order.order_type),
|
||||
updates.get("item_name", order.item_name),
|
||||
updates.get("amount", order.amount),
|
||||
updates.get("status", order.status),
|
||||
order_id,
|
||||
temple_id,
|
||||
),
|
||||
)
|
||||
db.commit()
|
||||
return get_order(order_id, db, temple_id)
|
||||
|
||||
|
||||
@router.delete("/{order_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_order(order_id: int, db: Database, temple_id: TempleId) -> None:
|
||||
ensure_temple_exists(db, temple_id)
|
||||
cursor = db.execute(
|
||||
"DELETE FROM orders WHERE id = ? AND temple_id = ?",
|
||||
(order_id, temple_id),
|
||||
)
|
||||
db.commit()
|
||||
if cursor.rowcount == 0:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Order not found")
|
||||
@@ -0,0 +1,132 @@
|
||||
import json
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
|
||||
from app.config import wechat_pay_settings
|
||||
from app.database import Database
|
||||
from app.schemas import WeChatJSAPIPayRequest, WeChatJSAPIPayResponse
|
||||
from app.temple_scope import TempleId, ensure_temple_exists
|
||||
from app.wechat_pay import (
|
||||
build_jsapi_pay_params,
|
||||
decrypt_notify_resource,
|
||||
post_wechat_json,
|
||||
verify_notify_signature,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/payments", tags=["payments"])
|
||||
|
||||
|
||||
def yuan_to_fen(amount: float) -> int:
|
||||
return int((Decimal(str(amount)) * Decimal("100")).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
|
||||
|
||||
|
||||
def get_order_row(db: Database, temple_id: int, order_id: int):
|
||||
row = db.execute(
|
||||
"""
|
||||
SELECT id, temple_id, item_name, amount, status
|
||||
FROM orders
|
||||
WHERE id = ? AND temple_id = ?
|
||||
""",
|
||||
(order_id, temple_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="订单不存在")
|
||||
return row
|
||||
|
||||
|
||||
@router.post("/wechat/jsapi", response_model=WeChatJSAPIPayResponse)
|
||||
def create_wechat_jsapi_payment(payload: WeChatJSAPIPayRequest, db: Database, temple_id: TempleId) -> WeChatJSAPIPayResponse:
|
||||
ensure_temple_exists(db, temple_id)
|
||||
order = get_order_row(db, temple_id, payload.order_id)
|
||||
if order["status"] == "paid":
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="订单已支付,无需重复发起支付")
|
||||
|
||||
amount = yuan_to_fen(float(order["amount"]))
|
||||
if amount <= 0:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="订单金额必须大于 0")
|
||||
|
||||
out_trade_no = f"T{temple_id}O{order['id']}{uuid4().hex[:16]}"
|
||||
description = (payload.description or order["item_name"])[:127]
|
||||
wechat_payload = {
|
||||
"appid": wechat_pay_settings.appid,
|
||||
"mchid": wechat_pay_settings.mch_id,
|
||||
"description": description,
|
||||
"out_trade_no": out_trade_no,
|
||||
"notify_url": wechat_pay_settings.notify_url,
|
||||
"amount": {
|
||||
"total": amount,
|
||||
"currency": "CNY",
|
||||
},
|
||||
"payer": {
|
||||
"openid": payload.openid,
|
||||
},
|
||||
}
|
||||
result = post_wechat_json("/v3/pay/transactions/jsapi", wechat_payload)
|
||||
prepay_id = result.get("prepay_id")
|
||||
if not prepay_id:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="微信支付未返回 prepay_id")
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO payment_transactions
|
||||
(temple_id, order_id, out_trade_no, amount, status, prepay_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(temple_id, order["id"], out_trade_no, amount, "prepay", prepay_id),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return WeChatJSAPIPayResponse(
|
||||
**build_jsapi_pay_params(prepay_id),
|
||||
out_trade_no=out_trade_no,
|
||||
prepay_id=prepay_id,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/wechat/notify")
|
||||
async def handle_wechat_pay_notify(request: Request, db: Database) -> dict[str, str]:
|
||||
body = (await request.body()).decode("utf-8")
|
||||
headers = {key.lower(): value for key, value in request.headers.items()}
|
||||
if not verify_notify_signature(headers, body):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="微信支付通知验签失败")
|
||||
|
||||
notify_payload = json.loads(body)
|
||||
resource = notify_payload.get("resource")
|
||||
if not isinstance(resource, dict):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="微信支付通知缺少 resource")
|
||||
|
||||
trade = decrypt_notify_resource(resource)
|
||||
out_trade_no = trade.get("out_trade_no")
|
||||
trade_state = trade.get("trade_state")
|
||||
transaction_id = trade.get("transaction_id")
|
||||
if not out_trade_no:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="微信支付通知缺少商户订单号")
|
||||
|
||||
payment = db.execute(
|
||||
"""
|
||||
SELECT id, order_id
|
||||
FROM payment_transactions
|
||||
WHERE out_trade_no = ?
|
||||
""",
|
||||
(out_trade_no,),
|
||||
).fetchone()
|
||||
if payment is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="支付流水不存在")
|
||||
|
||||
status_value = "paid" if trade_state == "SUCCESS" else str(trade_state or "unknown").lower()
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE payment_transactions
|
||||
SET status = ?, transaction_id = ?, raw_notify_json = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""",
|
||||
(status_value, transaction_id, json.dumps(trade, ensure_ascii=False), payment["id"]),
|
||||
)
|
||||
if trade_state == "SUCCESS":
|
||||
db.execute("UPDATE orders SET status = 'paid' WHERE id = ?", (payment["order_id"],))
|
||||
db.commit()
|
||||
|
||||
return {"code": "SUCCESS", "message": "成功"}
|
||||
@@ -0,0 +1,222 @@
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from typing import Any, List
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from app.database import Database
|
||||
from app.schemas import Product, ProductCreate, ProductSpec, ProductUpdate
|
||||
from app.temple_scope import TempleId, ensure_temple_exists
|
||||
|
||||
|
||||
router = APIRouter(prefix="/products", tags=["products"])
|
||||
|
||||
PRODUCT_COLUMNS = """
|
||||
id, temple_id, name, alias, description, payment_button_name, donation_button_name,
|
||||
sort_order, show_participant_count, cover_image, images_json, listed_at,
|
||||
sale_starts_at, sale_ends_at, delisted_at, show_countdown, show_on_home,
|
||||
specs_json, details_html, share_title, share_description, share_image,
|
||||
merit_certificate_reserved, auto_process, is_active, sales_count
|
||||
"""
|
||||
|
||||
|
||||
def parse_json_list(value: str | None) -> list[Any]:
|
||||
if not value:
|
||||
return []
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
return parsed if isinstance(parsed, list) else []
|
||||
|
||||
|
||||
def parse_time(value: str | None) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(value.replace("Z", "+00:00")).replace(tzinfo=None)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def get_product_status(row: sqlite3.Row) -> str:
|
||||
now = datetime.now()
|
||||
listed_at = parse_time(row["listed_at"])
|
||||
sale_starts_at = parse_time(row["sale_starts_at"])
|
||||
sale_ends_at = parse_time(row["sale_ends_at"])
|
||||
delisted_at = parse_time(row["delisted_at"])
|
||||
|
||||
if not bool(row["is_active"]):
|
||||
return "inactive"
|
||||
if delisted_at is not None and now >= delisted_at:
|
||||
return "delisted"
|
||||
if sale_ends_at is not None and now >= sale_ends_at:
|
||||
return "ended"
|
||||
if listed_at is not None and now < listed_at:
|
||||
return "pending_list"
|
||||
if sale_starts_at is not None and now < sale_starts_at:
|
||||
return "pending_sale"
|
||||
return "selling"
|
||||
|
||||
|
||||
def row_to_product(row: sqlite3.Row) -> Product:
|
||||
return Product(
|
||||
id=row["id"],
|
||||
temple_id=row["temple_id"],
|
||||
name=row["name"],
|
||||
alias=row["alias"],
|
||||
description=row["description"],
|
||||
payment_button_name=row["payment_button_name"],
|
||||
donation_button_name=row["donation_button_name"],
|
||||
sort_order=row["sort_order"],
|
||||
show_participant_count=bool(row["show_participant_count"]),
|
||||
cover_image=row["cover_image"],
|
||||
images=parse_json_list(row["images_json"]),
|
||||
listed_at=row["listed_at"],
|
||||
sale_starts_at=row["sale_starts_at"],
|
||||
sale_ends_at=row["sale_ends_at"],
|
||||
delisted_at=row["delisted_at"],
|
||||
show_countdown=bool(row["show_countdown"]),
|
||||
show_on_home=bool(row["show_on_home"]),
|
||||
specs=[ProductSpec(**item) for item in parse_json_list(row["specs_json"])],
|
||||
details_html=row["details_html"],
|
||||
share_title=row["share_title"],
|
||||
share_description=row["share_description"],
|
||||
share_image=row["share_image"],
|
||||
merit_certificate_reserved=bool(row["merit_certificate_reserved"]),
|
||||
auto_process=bool(row["auto_process"]),
|
||||
is_active=bool(row["is_active"]),
|
||||
sales_count=row["sales_count"],
|
||||
status=get_product_status(row),
|
||||
)
|
||||
|
||||
|
||||
def product_payload_values(payload: ProductCreate | ProductUpdate, fallback: Product | None = None) -> tuple[Any, ...]:
|
||||
data = payload.model_dump(exclude_unset=fallback is not None)
|
||||
|
||||
def get_value(name: str, default: Any) -> Any:
|
||||
if name in data:
|
||||
return data[name]
|
||||
if fallback is not None:
|
||||
return getattr(fallback, name)
|
||||
return default
|
||||
|
||||
images = get_value("images", [])
|
||||
specs = get_value("specs", [])
|
||||
return (
|
||||
get_value("name", ""),
|
||||
get_value("alias", None),
|
||||
get_value("description", None),
|
||||
get_value("payment_button_name", "立即支付"),
|
||||
get_value("donation_button_name", "随喜"),
|
||||
get_value("sort_order", 0),
|
||||
int(get_value("show_participant_count", False)),
|
||||
get_value("cover_image", None),
|
||||
json.dumps(images, ensure_ascii=False),
|
||||
get_value("listed_at", None),
|
||||
get_value("sale_starts_at", None),
|
||||
get_value("sale_ends_at", None),
|
||||
get_value("delisted_at", None),
|
||||
int(get_value("show_countdown", False)),
|
||||
int(get_value("show_on_home", False)),
|
||||
json.dumps([spec.model_dump() if isinstance(spec, ProductSpec) else spec for spec in specs], ensure_ascii=False),
|
||||
get_value("details_html", None),
|
||||
get_value("share_title", None),
|
||||
get_value("share_description", None),
|
||||
get_value("share_image", None),
|
||||
int(get_value("merit_certificate_reserved", False)),
|
||||
int(get_value("auto_process", False)),
|
||||
int(get_value("is_active", True)),
|
||||
get_value("sales_count", 0),
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=List[Product])
|
||||
def list_products(db: Database, temple_id: TempleId) -> List[Product]:
|
||||
ensure_temple_exists(db, temple_id)
|
||||
rows = db.execute(
|
||||
f"""
|
||||
SELECT {PRODUCT_COLUMNS}
|
||||
FROM products
|
||||
WHERE temple_id = ?
|
||||
ORDER BY sort_order, id
|
||||
""",
|
||||
(temple_id,),
|
||||
).fetchall()
|
||||
return [row_to_product(row) for row in rows]
|
||||
|
||||
|
||||
@router.post("", response_model=Product, status_code=status.HTTP_201_CREATED)
|
||||
def create_product(payload: ProductCreate, db: Database, temple_id: TempleId) -> Product:
|
||||
ensure_temple_exists(db, temple_id)
|
||||
cursor = db.execute(
|
||||
"""
|
||||
INSERT INTO products
|
||||
(
|
||||
temple_id, name, alias, description, payment_button_name, donation_button_name,
|
||||
sort_order, show_participant_count, cover_image, images_json, listed_at,
|
||||
sale_starts_at, sale_ends_at, delisted_at, show_countdown, show_on_home,
|
||||
specs_json, details_html, share_title, share_description, share_image,
|
||||
merit_certificate_reserved, auto_process, is_active, sales_count
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(temple_id, *product_payload_values(payload)),
|
||||
)
|
||||
db.commit()
|
||||
return get_product(cursor.lastrowid, db, temple_id)
|
||||
|
||||
|
||||
@router.get("/{product_id}", response_model=Product)
|
||||
def get_product(product_id: int, db: Database, temple_id: TempleId) -> Product:
|
||||
ensure_temple_exists(db, temple_id)
|
||||
row = db.execute(
|
||||
f"""
|
||||
SELECT {PRODUCT_COLUMNS}
|
||||
FROM products
|
||||
WHERE id = ? AND temple_id = ?
|
||||
""",
|
||||
(product_id, temple_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Product not found")
|
||||
return row_to_product(row)
|
||||
|
||||
|
||||
@router.patch("/{product_id}", response_model=Product)
|
||||
def update_product(product_id: int, payload: ProductUpdate, db: Database, temple_id: TempleId) -> Product:
|
||||
product = get_product(product_id, db, temple_id)
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
|
||||
if not updates:
|
||||
return product
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE products
|
||||
SET
|
||||
name = ?, alias = ?, description = ?, payment_button_name = ?, donation_button_name = ?,
|
||||
sort_order = ?, show_participant_count = ?, cover_image = ?, images_json = ?,
|
||||
listed_at = ?, sale_starts_at = ?, sale_ends_at = ?, delisted_at = ?,
|
||||
show_countdown = ?, show_on_home = ?, specs_json = ?, details_html = ?,
|
||||
share_title = ?, share_description = ?, share_image = ?,
|
||||
merit_certificate_reserved = ?, auto_process = ?, is_active = ?, sales_count = ?
|
||||
WHERE id = ? AND temple_id = ?
|
||||
""",
|
||||
(*product_payload_values(payload, product), product_id, temple_id),
|
||||
)
|
||||
db.commit()
|
||||
return get_product(product_id, db, temple_id)
|
||||
|
||||
|
||||
@router.delete("/{product_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_product(product_id: int, db: Database, temple_id: TempleId) -> None:
|
||||
ensure_temple_exists(db, temple_id)
|
||||
cursor = db.execute(
|
||||
"DELETE FROM products WHERE id = ? AND temple_id = ?",
|
||||
(product_id, temple_id),
|
||||
)
|
||||
db.commit()
|
||||
if cursor.rowcount == 0:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Product not found")
|
||||
@@ -0,0 +1,106 @@
|
||||
import sqlite3
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from app.database import Database
|
||||
from app.schemas import RitualService, RitualServiceCreate, RitualServiceUpdate
|
||||
from app.temple_scope import TempleId, ensure_temple_exists
|
||||
|
||||
|
||||
router = APIRouter(prefix="/ritual-services", tags=["ritual-services"])
|
||||
|
||||
|
||||
def row_to_ritual(row: sqlite3.Row) -> RitualService:
|
||||
return RitualService(
|
||||
id=row["id"],
|
||||
temple_id=row["temple_id"],
|
||||
name=row["name"],
|
||||
description=row["description"],
|
||||
price=float(row["price"]),
|
||||
is_active=bool(row["is_active"]),
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=List[RitualService])
|
||||
def list_rituals(db: Database, temple_id: TempleId) -> List[RitualService]:
|
||||
ensure_temple_exists(db, temple_id)
|
||||
rows = db.execute(
|
||||
"""
|
||||
SELECT id, temple_id, name, description, price, is_active
|
||||
FROM ritual_services
|
||||
WHERE temple_id = ?
|
||||
ORDER BY id
|
||||
""",
|
||||
(temple_id,),
|
||||
).fetchall()
|
||||
return [row_to_ritual(row) for row in rows]
|
||||
|
||||
|
||||
@router.post("", response_model=RitualService, status_code=status.HTTP_201_CREATED)
|
||||
def create_ritual(payload: RitualServiceCreate, db: Database, temple_id: TempleId) -> RitualService:
|
||||
ensure_temple_exists(db, temple_id)
|
||||
cursor = db.execute(
|
||||
"""
|
||||
INSERT INTO ritual_services (temple_id, name, description, price, is_active)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(temple_id, payload.name, payload.description, payload.price, int(payload.is_active)),
|
||||
)
|
||||
db.commit()
|
||||
return get_ritual(cursor.lastrowid, db, temple_id)
|
||||
|
||||
|
||||
@router.get("/{ritual_id}", response_model=RitualService)
|
||||
def get_ritual(ritual_id: int, db: Database, temple_id: TempleId) -> RitualService:
|
||||
ensure_temple_exists(db, temple_id)
|
||||
row = db.execute(
|
||||
"""
|
||||
SELECT id, temple_id, name, description, price, is_active
|
||||
FROM ritual_services
|
||||
WHERE id = ? AND temple_id = ?
|
||||
""",
|
||||
(ritual_id, temple_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ritual service not found")
|
||||
return row_to_ritual(row)
|
||||
|
||||
|
||||
@router.patch("/{ritual_id}", response_model=RitualService)
|
||||
def update_ritual(ritual_id: int, payload: RitualServiceUpdate, db: Database, temple_id: TempleId) -> RitualService:
|
||||
ritual = get_ritual(ritual_id, db, temple_id)
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
|
||||
if not updates:
|
||||
return ritual
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE ritual_services
|
||||
SET name = ?, description = ?, price = ?, is_active = ?
|
||||
WHERE id = ? AND temple_id = ?
|
||||
""",
|
||||
(
|
||||
updates.get("name", ritual.name),
|
||||
updates.get("description", ritual.description),
|
||||
updates.get("price", ritual.price),
|
||||
int(updates.get("is_active", ritual.is_active)),
|
||||
ritual_id,
|
||||
temple_id,
|
||||
),
|
||||
)
|
||||
db.commit()
|
||||
return get_ritual(ritual_id, db, temple_id)
|
||||
|
||||
|
||||
@router.delete("/{ritual_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_ritual(ritual_id: int, db: Database, temple_id: TempleId) -> None:
|
||||
ensure_temple_exists(db, temple_id)
|
||||
cursor = db.execute(
|
||||
"DELETE FROM ritual_services WHERE id = ? AND temple_id = ?",
|
||||
(ritual_id, temple_id),
|
||||
)
|
||||
db.commit()
|
||||
if cursor.rowcount == 0:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ritual service not found")
|
||||
@@ -0,0 +1,116 @@
|
||||
import sqlite3
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from app.database import Database
|
||||
from app.schemas import Temple, TempleCreate, TempleUpdate
|
||||
|
||||
|
||||
router = APIRouter(prefix="/temples", tags=["temples"])
|
||||
|
||||
|
||||
def row_to_temple(row: sqlite3.Row) -> Temple:
|
||||
return Temple(
|
||||
id=row["id"],
|
||||
name=row["name"],
|
||||
location=row["location"],
|
||||
contact_phone=row["contact_phone"],
|
||||
description=row["description"],
|
||||
is_active=bool(row["is_active"]),
|
||||
)
|
||||
|
||||
|
||||
def raise_duplicate_temple_error() -> None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Temple name already exists",
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=List[Temple])
|
||||
def list_temples(db: Database) -> List[Temple]:
|
||||
rows = db.execute(
|
||||
"""
|
||||
SELECT id, name, location, contact_phone, description, is_active
|
||||
FROM temples
|
||||
ORDER BY id
|
||||
"""
|
||||
).fetchall()
|
||||
return [row_to_temple(row) for row in rows]
|
||||
|
||||
|
||||
@router.post("", response_model=Temple, status_code=status.HTTP_201_CREATED)
|
||||
def create_temple(payload: TempleCreate, db: Database) -> Temple:
|
||||
try:
|
||||
cursor = db.execute(
|
||||
"""
|
||||
INSERT INTO temples (name, location, contact_phone, description, is_active)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
payload.name,
|
||||
payload.location,
|
||||
payload.contact_phone,
|
||||
payload.description,
|
||||
int(payload.is_active),
|
||||
),
|
||||
)
|
||||
db.commit()
|
||||
except sqlite3.IntegrityError:
|
||||
raise_duplicate_temple_error()
|
||||
|
||||
return get_temple(cursor.lastrowid, db)
|
||||
|
||||
|
||||
@router.get("/{temple_id}", response_model=Temple)
|
||||
def get_temple(temple_id: int, db: Database) -> Temple:
|
||||
row = db.execute(
|
||||
"""
|
||||
SELECT id, name, location, contact_phone, description, is_active
|
||||
FROM temples
|
||||
WHERE id = ?
|
||||
""",
|
||||
(temple_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Temple not found")
|
||||
return row_to_temple(row)
|
||||
|
||||
|
||||
@router.patch("/{temple_id}", response_model=Temple)
|
||||
def update_temple(temple_id: int, payload: TempleUpdate, db: Database) -> Temple:
|
||||
temple = get_temple(temple_id, db)
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
|
||||
if not updates:
|
||||
return temple
|
||||
|
||||
name = updates.get("name", temple.name)
|
||||
location = updates.get("location", temple.location)
|
||||
contact_phone = updates.get("contact_phone", temple.contact_phone)
|
||||
description = updates.get("description", temple.description)
|
||||
is_active = updates.get("is_active", temple.is_active)
|
||||
|
||||
try:
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE temples
|
||||
SET name = ?, location = ?, contact_phone = ?, description = ?, is_active = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(name, location, contact_phone, description, int(is_active), temple_id),
|
||||
)
|
||||
db.commit()
|
||||
except sqlite3.IntegrityError:
|
||||
raise_duplicate_temple_error()
|
||||
|
||||
return get_temple(temple_id, db)
|
||||
|
||||
|
||||
@router.delete("/{temple_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_temple(temple_id: int, db: Database) -> None:
|
||||
cursor = db.execute("DELETE FROM temples WHERE id = ?", (temple_id,))
|
||||
db.commit()
|
||||
if cursor.rowcount == 0:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Temple not found")
|
||||
@@ -0,0 +1,68 @@
|
||||
import sqlite3
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from app.database import Database
|
||||
from app.schemas import Todo, TodoCreate, TodoUpdate
|
||||
|
||||
|
||||
router = APIRouter(prefix="/todos", tags=["todos"])
|
||||
|
||||
|
||||
def row_to_todo(row: sqlite3.Row) -> Todo:
|
||||
return Todo(id=row["id"], title=row["title"], done=bool(row["done"]))
|
||||
|
||||
|
||||
@router.get("", response_model=List[Todo])
|
||||
def list_todos(db: Database) -> List[Todo]:
|
||||
rows = db.execute("SELECT id, title, done FROM todos ORDER BY id").fetchall()
|
||||
return [row_to_todo(row) for row in rows]
|
||||
|
||||
|
||||
@router.post("", response_model=Todo, status_code=status.HTTP_201_CREATED)
|
||||
def create_todo(payload: TodoCreate, db: Database) -> Todo:
|
||||
cursor = db.execute(
|
||||
"INSERT INTO todos (title, done) VALUES (?, ?)",
|
||||
(payload.title, 0),
|
||||
)
|
||||
db.commit()
|
||||
return Todo(id=cursor.lastrowid, title=payload.title, done=False)
|
||||
|
||||
|
||||
@router.get("/{todo_id}", response_model=Todo)
|
||||
def get_todo(todo_id: int, db: Database) -> Todo:
|
||||
row = db.execute(
|
||||
"SELECT id, title, done FROM todos WHERE id = ?",
|
||||
(todo_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Todo not found")
|
||||
return row_to_todo(row)
|
||||
|
||||
|
||||
@router.patch("/{todo_id}", response_model=Todo)
|
||||
def update_todo(todo_id: int, payload: TodoUpdate, db: Database) -> Todo:
|
||||
todo = get_todo(todo_id, db)
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
|
||||
if not updates:
|
||||
return todo
|
||||
|
||||
title = updates.get("title", todo.title)
|
||||
done = updates.get("done", todo.done)
|
||||
db.execute(
|
||||
"UPDATE todos SET title = ?, done = ? WHERE id = ?",
|
||||
(title, int(done), todo_id),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return get_todo(todo_id, db)
|
||||
|
||||
|
||||
@router.delete("/{todo_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_todo(todo_id: int, db: Database) -> None:
|
||||
cursor = db.execute("DELETE FROM todos WHERE id = ?", (todo_id,))
|
||||
db.commit()
|
||||
if cursor.rowcount == 0:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Todo not found")
|
||||
@@ -0,0 +1,72 @@
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
from starlette.datastructures import UploadFile
|
||||
|
||||
from app.config import oss_settings
|
||||
from app.schemas import UploadResponse
|
||||
|
||||
|
||||
router = APIRouter(prefix="/uploads", tags=["uploads"])
|
||||
|
||||
MAX_IMAGE_SIZE = 5 * 1024 * 1024
|
||||
ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
|
||||
|
||||
|
||||
def get_public_url(object_key: str) -> str:
|
||||
if oss_settings.public_base_url:
|
||||
return f"{oss_settings.public_base_url.rstrip('/')}/{object_key}"
|
||||
|
||||
endpoint = oss_settings.endpoint.replace("https://", "").replace("http://", "").rstrip("/")
|
||||
return f"https://{oss_settings.bucket_name}.{endpoint}/{object_key}"
|
||||
|
||||
|
||||
def build_object_key(filename: str | None, folder: str) -> str:
|
||||
suffix = Path(filename or "").suffix.lower()
|
||||
if not suffix:
|
||||
suffix = mimetypes.guess_extension("image/jpeg") or ".jpg"
|
||||
|
||||
safe_folder = folder.strip("/ ") or oss_settings.upload_prefix
|
||||
return f"{oss_settings.upload_prefix.strip('/')}/{safe_folder}/{uuid4().hex}{suffix}"
|
||||
|
||||
|
||||
@router.post("/images", response_model=UploadResponse)
|
||||
async def upload_image(request: Request) -> UploadResponse:
|
||||
if not oss_settings.is_configured:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="OSS 配置未完成,请先填写 app/config.py 中的 OSS 配置。",
|
||||
)
|
||||
|
||||
form = await request.form()
|
||||
file = form.get("file")
|
||||
folder_value = form.get("folder")
|
||||
folder = str(folder_value) if folder_value else "products"
|
||||
|
||||
if not isinstance(file, UploadFile):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请选择要上传的图片")
|
||||
|
||||
content_type = file.content_type or "application/octet-stream"
|
||||
if content_type not in ALLOWED_IMAGE_TYPES:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="只支持上传图片文件")
|
||||
|
||||
data = await file.read()
|
||||
if len(data) > MAX_IMAGE_SIZE:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="图片大小不能超过 5MB")
|
||||
|
||||
try:
|
||||
import oss2
|
||||
except ImportError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="缺少 oss2 依赖,请先安装 requirements.txt 中的依赖。",
|
||||
) from exc
|
||||
|
||||
object_key = build_object_key(file.filename, folder)
|
||||
auth = oss2.Auth(oss_settings.access_key_id, oss_settings.access_key_secret)
|
||||
bucket = oss2.Bucket(auth, oss_settings.upload_endpoint, oss_settings.bucket_name)
|
||||
bucket.put_object(object_key, data, headers={"Content-Type": content_type})
|
||||
|
||||
return UploadResponse(url=get_public_url(object_key), object_key=object_key)
|
||||
@@ -0,0 +1,129 @@
|
||||
import sqlite3
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from app.database import Database
|
||||
from app.schemas import User, UserCreate, UserUpdate
|
||||
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
|
||||
def row_to_user(row: sqlite3.Row) -> User:
|
||||
return User(
|
||||
id=row["id"],
|
||||
registered_at=row["registered_at"],
|
||||
name=row["name"],
|
||||
phone=row["phone"],
|
||||
nickname=row["nickname"],
|
||||
avatar=row["avatar"],
|
||||
is_admin=bool(row["is_admin"]),
|
||||
total_spent=float(row["total_spent"]),
|
||||
)
|
||||
|
||||
|
||||
def raise_duplicate_user_error() -> None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Phone already exists",
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=List[User])
|
||||
def list_users(db: Database, phone: str | None = None) -> List[User]:
|
||||
params = []
|
||||
where_clause = ""
|
||||
if phone:
|
||||
where_clause = "WHERE phone LIKE ?"
|
||||
params.append(f"%{phone}%")
|
||||
|
||||
rows = db.execute(
|
||||
f"""
|
||||
SELECT id, registered_at, name, phone, nickname, avatar, is_admin, total_spent
|
||||
FROM users
|
||||
{where_clause}
|
||||
ORDER BY id
|
||||
""",
|
||||
params,
|
||||
).fetchall()
|
||||
return [row_to_user(row) for row in rows]
|
||||
|
||||
|
||||
@router.post("", response_model=User, status_code=status.HTTP_201_CREATED)
|
||||
def create_user(payload: UserCreate, db: Database) -> User:
|
||||
try:
|
||||
cursor = db.execute(
|
||||
"""
|
||||
INSERT INTO users
|
||||
(registered_at, name, phone, nickname, avatar, is_admin, total_spent)
|
||||
VALUES (CURRENT_TIMESTAMP, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
payload.name,
|
||||
payload.phone,
|
||||
payload.nickname,
|
||||
payload.avatar,
|
||||
int(payload.is_admin),
|
||||
payload.total_spent,
|
||||
),
|
||||
)
|
||||
db.commit()
|
||||
except sqlite3.IntegrityError:
|
||||
raise_duplicate_user_error()
|
||||
|
||||
return get_user(cursor.lastrowid, db)
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=User)
|
||||
def get_user(user_id: int, db: Database) -> User:
|
||||
row = db.execute(
|
||||
"""
|
||||
SELECT id, registered_at, name, phone, nickname, avatar, is_admin, total_spent
|
||||
FROM users
|
||||
WHERE id = ?
|
||||
""",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
return row_to_user(row)
|
||||
|
||||
|
||||
@router.patch("/{user_id}", response_model=User)
|
||||
def update_user(user_id: int, payload: UserUpdate, db: Database) -> User:
|
||||
user = get_user(user_id, db)
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
|
||||
if not updates:
|
||||
return user
|
||||
|
||||
name = updates.get("name", user.name)
|
||||
phone = updates.get("phone", user.phone)
|
||||
nickname = updates.get("nickname", user.nickname)
|
||||
avatar = updates.get("avatar", user.avatar)
|
||||
is_admin = updates.get("is_admin", user.is_admin)
|
||||
total_spent = updates.get("total_spent", user.total_spent)
|
||||
|
||||
try:
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET name = ?, phone = ?, nickname = ?, avatar = ?, is_admin = ?, total_spent = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(name, phone, nickname, avatar, int(is_admin), total_spent, user_id),
|
||||
)
|
||||
db.commit()
|
||||
except sqlite3.IntegrityError:
|
||||
raise_duplicate_user_error()
|
||||
|
||||
return get_user(user_id, db)
|
||||
|
||||
|
||||
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_user(user_id: int, db: Database) -> None:
|
||||
cursor = db.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
||||
db.commit()
|
||||
if cursor.rowcount == 0:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class TodoCreate(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=100)
|
||||
|
||||
|
||||
class TodoUpdate(BaseModel):
|
||||
title: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
done: bool | None = None
|
||||
|
||||
|
||||
class Todo(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
done: bool = False
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=80)
|
||||
phone: str = Field(..., min_length=5, max_length=20, pattern=r"^[0-9+\-\s]+$")
|
||||
nickname: str | None = Field(default=None, max_length=60)
|
||||
avatar: str | None = Field(default=None, max_length=300)
|
||||
is_admin: bool = False
|
||||
total_spent: float = Field(default=0, ge=0)
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=80)
|
||||
phone: str | None = Field(default=None, min_length=5, max_length=20, pattern=r"^[0-9+\-\s]+$")
|
||||
nickname: str | None = Field(default=None, max_length=60)
|
||||
avatar: str | None = Field(default=None, max_length=300)
|
||||
is_admin: bool | None = None
|
||||
total_spent: float | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
id: int
|
||||
registered_at: str
|
||||
name: str
|
||||
phone: str | None = None
|
||||
nickname: str | None = None
|
||||
avatar: str | None = None
|
||||
is_admin: bool = False
|
||||
total_spent: float = 0
|
||||
|
||||
|
||||
class AuthUser(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
name: str
|
||||
is_admin: bool = False
|
||||
|
||||
|
||||
class CaptchaResponse(BaseModel):
|
||||
captcha_id: str
|
||||
challenge: str
|
||||
expires_in: int
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str = Field(..., min_length=1, max_length=80)
|
||||
password: str = Field(..., min_length=1, max_length=128)
|
||||
captcha_id: str | None = Field(default=None, max_length=80)
|
||||
captcha_code: str | None = Field(default=None, max_length=20)
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
expires_in: int
|
||||
user: AuthUser
|
||||
requires_second_verification: bool = False
|
||||
security_notice: str | None = None
|
||||
|
||||
|
||||
class TempleCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
location: str | None = Field(default=None, max_length=160)
|
||||
contact_phone: str | None = Field(default=None, max_length=30)
|
||||
description: str | None = Field(default=None, max_length=500)
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class TempleUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
location: str | None = Field(default=None, max_length=160)
|
||||
contact_phone: str | None = Field(default=None, max_length=30)
|
||||
description: str | None = Field(default=None, max_length=500)
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class Temple(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
location: str | None = None
|
||||
contact_phone: str | None = None
|
||||
description: str | None = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class ProductSpec(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=80)
|
||||
price: float = Field(default=0, ge=0)
|
||||
stock: int = Field(default=0, ge=0)
|
||||
sort_order: int = Field(default=0, ge=0)
|
||||
is_chaodu: bool = False
|
||||
|
||||
|
||||
class ProductBase(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=120)
|
||||
alias: str | None = Field(default=None, max_length=80)
|
||||
description: str | None = Field(default=None, max_length=500)
|
||||
payment_button_name: str = Field(default="立即支付", min_length=1, max_length=30)
|
||||
donation_button_name: str = Field(default="随喜", min_length=1, max_length=30)
|
||||
sort_order: int = Field(default=0, ge=0)
|
||||
show_participant_count: bool = False
|
||||
cover_image: str | None = Field(default=None, max_length=500)
|
||||
images: list[str] = Field(default_factory=list, max_length=10)
|
||||
listed_at: str | None = Field(default=None, max_length=30)
|
||||
sale_starts_at: str | None = Field(default=None, max_length=30)
|
||||
sale_ends_at: str | None = Field(default=None, max_length=30)
|
||||
delisted_at: str | None = Field(default=None, max_length=30)
|
||||
show_countdown: bool = False
|
||||
show_on_home: bool = False
|
||||
specs: list[ProductSpec] = Field(default_factory=list)
|
||||
details_html: str | None = None
|
||||
share_title: str | None = Field(default=None, max_length=120)
|
||||
share_description: str | None = Field(default=None, max_length=300)
|
||||
share_image: str | None = Field(default=None, max_length=500)
|
||||
merit_certificate_reserved: bool = False
|
||||
auto_process: bool = False
|
||||
is_active: bool = True
|
||||
sales_count: int = Field(default=0, ge=0)
|
||||
|
||||
@field_validator("images")
|
||||
@classmethod
|
||||
def validate_images_count(cls, value: list[str]) -> list[str]:
|
||||
if len(value) > 10:
|
||||
raise ValueError("Product images cannot exceed 10")
|
||||
return value
|
||||
|
||||
|
||||
class ProductCreate(ProductBase):
|
||||
pass
|
||||
|
||||
|
||||
class ProductUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=120)
|
||||
alias: str | None = Field(default=None, max_length=80)
|
||||
description: str | None = Field(default=None, max_length=500)
|
||||
payment_button_name: str | None = Field(default=None, min_length=1, max_length=30)
|
||||
donation_button_name: str | None = Field(default=None, min_length=1, max_length=30)
|
||||
sort_order: int | None = Field(default=None, ge=0)
|
||||
show_participant_count: bool | None = None
|
||||
cover_image: str | None = Field(default=None, max_length=500)
|
||||
images: list[str] | None = Field(default=None, max_length=10)
|
||||
listed_at: str | None = Field(default=None, max_length=30)
|
||||
sale_starts_at: str | None = Field(default=None, max_length=30)
|
||||
sale_ends_at: str | None = Field(default=None, max_length=30)
|
||||
delisted_at: str | None = Field(default=None, max_length=30)
|
||||
show_countdown: bool | None = None
|
||||
show_on_home: bool | None = None
|
||||
specs: list[ProductSpec] | None = None
|
||||
details_html: str | None = None
|
||||
share_title: str | None = Field(default=None, max_length=120)
|
||||
share_description: str | None = Field(default=None, max_length=300)
|
||||
share_image: str | None = Field(default=None, max_length=500)
|
||||
merit_certificate_reserved: bool | None = None
|
||||
auto_process: bool | None = None
|
||||
is_active: bool | None = None
|
||||
sales_count: int | None = Field(default=None, ge=0)
|
||||
|
||||
@field_validator("images")
|
||||
@classmethod
|
||||
def validate_images_count(cls, value: list[str] | None) -> list[str] | None:
|
||||
if value is not None and len(value) > 10:
|
||||
raise ValueError("Product images cannot exceed 10")
|
||||
return value
|
||||
|
||||
|
||||
class Product(BaseModel):
|
||||
id: int
|
||||
temple_id: int
|
||||
name: str
|
||||
alias: str | None = None
|
||||
description: str | None = None
|
||||
payment_button_name: str = "立即支付"
|
||||
donation_button_name: str = "随喜"
|
||||
sort_order: int = 0
|
||||
show_participant_count: bool = False
|
||||
cover_image: str | None = None
|
||||
images: list[str] = Field(default_factory=list)
|
||||
listed_at: str | None = None
|
||||
sale_starts_at: str | None = None
|
||||
sale_ends_at: str | None = None
|
||||
delisted_at: str | None = None
|
||||
show_countdown: bool = False
|
||||
show_on_home: bool = False
|
||||
specs: list[ProductSpec] = Field(default_factory=list)
|
||||
details_html: str | None = None
|
||||
share_title: str | None = None
|
||||
share_description: str | None = None
|
||||
share_image: str | None = None
|
||||
merit_certificate_reserved: bool = False
|
||||
auto_process: bool = False
|
||||
is_active: bool = True
|
||||
sales_count: int = 0
|
||||
status: str
|
||||
|
||||
|
||||
class UploadResponse(BaseModel):
|
||||
url: str
|
||||
object_key: str
|
||||
|
||||
|
||||
class RitualServiceCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=120)
|
||||
description: str | None = Field(default=None, max_length=500)
|
||||
price: float = Field(default=0, ge=0)
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class RitualServiceUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=120)
|
||||
description: str | None = Field(default=None, max_length=500)
|
||||
price: float | None = Field(default=None, ge=0)
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class RitualService(BaseModel):
|
||||
id: int
|
||||
temple_id: int
|
||||
name: str
|
||||
description: str | None = None
|
||||
price: float = 0
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class OrderCreate(BaseModel):
|
||||
customer_name: str = Field(..., min_length=1, max_length=80)
|
||||
customer_phone: str | None = Field(default=None, max_length=30)
|
||||
order_type: str = Field(default="product", pattern=r"^(product|ritual)$")
|
||||
item_name: str = Field(..., min_length=1, max_length=120)
|
||||
amount: float = Field(default=0, ge=0)
|
||||
status: str = Field(default="pending", pattern=r"^(pending|paid|completed|cancelled)$")
|
||||
|
||||
|
||||
class OrderUpdate(BaseModel):
|
||||
customer_name: str | None = Field(default=None, min_length=1, max_length=80)
|
||||
customer_phone: str | None = Field(default=None, max_length=30)
|
||||
order_type: str | None = Field(default=None, pattern=r"^(product|ritual)$")
|
||||
item_name: str | None = Field(default=None, min_length=1, max_length=120)
|
||||
amount: float | None = Field(default=None, ge=0)
|
||||
status: str | None = Field(default=None, pattern=r"^(pending|paid|completed|cancelled)$")
|
||||
|
||||
|
||||
class Order(BaseModel):
|
||||
id: int
|
||||
temple_id: int
|
||||
customer_name: str
|
||||
customer_phone: str | None = None
|
||||
order_type: str
|
||||
item_name: str
|
||||
amount: float = 0
|
||||
status: str
|
||||
created_at: str
|
||||
|
||||
|
||||
class WeChatJSAPIPayRequest(BaseModel):
|
||||
order_id: int
|
||||
openid: str = Field(..., min_length=1, max_length=128)
|
||||
description: str | None = Field(default=None, max_length=127)
|
||||
|
||||
|
||||
class WeChatJSAPIPayResponse(BaseModel):
|
||||
appId: str
|
||||
timeStamp: str
|
||||
nonceStr: str
|
||||
package: str
|
||||
signType: str
|
||||
paySign: str
|
||||
out_trade_no: str
|
||||
prepay_id: str
|
||||
@@ -0,0 +1,22 @@
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
import bcrypt
|
||||
|
||||
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt(rounds=12)).decode("utf-8")
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
import bcrypt
|
||||
|
||||
return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8"))
|
||||
|
||||
|
||||
def hash_secret(value: str) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def create_token() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
@@ -0,0 +1,20 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Header, HTTPException, status
|
||||
|
||||
from app.database import Database
|
||||
|
||||
|
||||
TempleId = Annotated[int, Header(alias="X-Temple-Id")]
|
||||
|
||||
|
||||
def ensure_temple_exists(db: Database, temple_id: int) -> None:
|
||||
row = db.execute(
|
||||
"SELECT id FROM temples WHERE id = ? AND is_active = 1",
|
||||
(temple_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Temple not found",
|
||||
)
|
||||
@@ -0,0 +1,152 @@
|
||||
import base64
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib import request as urllib_request
|
||||
from urllib.error import HTTPError, URLError
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from app.config import wechat_pay_settings
|
||||
|
||||
|
||||
WECHAT_SIGNATURE_TYPE = "WECHATPAY2-SHA256-RSA2048"
|
||||
|
||||
|
||||
def ensure_wechat_pay_configured() -> None:
|
||||
if not wechat_pay_settings.is_configured:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="微信支付配置未完成,请先填写 WECHAT_PAY_* 配置。",
|
||||
)
|
||||
|
||||
|
||||
def load_private_key():
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
|
||||
key_data = Path(wechat_pay_settings.private_key_path).read_bytes()
|
||||
return serialization.load_pem_private_key(key_data, password=None)
|
||||
|
||||
|
||||
def load_public_key():
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
|
||||
if not wechat_pay_settings.public_key_path:
|
||||
return None
|
||||
key_data = Path(wechat_pay_settings.public_key_path).read_bytes()
|
||||
return serialization.load_pem_public_key(key_data)
|
||||
|
||||
|
||||
def rsa_sign(message: str) -> str:
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
|
||||
signature = load_private_key().sign(
|
||||
message.encode("utf-8"),
|
||||
padding.PKCS1v15(),
|
||||
hashes.SHA256(),
|
||||
)
|
||||
return base64.b64encode(signature).decode("ascii")
|
||||
|
||||
|
||||
def rsa_verify(message: str, signature: str) -> bool:
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
|
||||
public_key = load_public_key()
|
||||
if public_key is None:
|
||||
return bool(wechat_pay_settings.skip_notify_verify)
|
||||
|
||||
try:
|
||||
public_key.verify(
|
||||
base64.b64decode(signature),
|
||||
message.encode("utf-8"),
|
||||
padding.PKCS1v15(),
|
||||
hashes.SHA256(),
|
||||
)
|
||||
except InvalidSignature:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def build_authorization(method: str, url_path: str, body: str) -> str:
|
||||
timestamp = str(int(time.time()))
|
||||
nonce = secrets.token_hex(16)
|
||||
message = f"{method}\n{url_path}\n{timestamp}\n{nonce}\n{body}\n"
|
||||
signature = rsa_sign(message)
|
||||
return (
|
||||
f'{WECHAT_SIGNATURE_TYPE} mchid="{wechat_pay_settings.mch_id}",'
|
||||
f'nonce_str="{nonce}",signature="{signature}",'
|
||||
f'timestamp="{timestamp}",serial_no="{wechat_pay_settings.mch_serial_no}"'
|
||||
)
|
||||
|
||||
|
||||
def post_wechat_json(url_path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
ensure_wechat_pay_configured()
|
||||
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||
url = f"{wechat_pay_settings.api_base_url.rstrip('/')}{url_path}"
|
||||
request = urllib_request.Request(
|
||||
url,
|
||||
data=body.encode("utf-8"),
|
||||
method="POST",
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Authorization": build_authorization("POST", url_path, body),
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "py-web-wechat-pay/0.1",
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib_request.urlopen(request, timeout=10) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
except HTTPError as exc:
|
||||
error_body = exc.read().decode("utf-8")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"微信支付请求失败:{error_body or exc.reason}",
|
||||
) from exc
|
||||
except URLError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"微信支付网络请求失败:{exc.reason}",
|
||||
) from exc
|
||||
|
||||
|
||||
def build_jsapi_pay_params(prepay_id: str) -> dict[str, str]:
|
||||
timestamp = str(int(time.time()))
|
||||
nonce = secrets.token_hex(16)
|
||||
package = f"prepay_id={prepay_id}"
|
||||
message = f"{wechat_pay_settings.appid}\n{timestamp}\n{nonce}\n{package}\n"
|
||||
return {
|
||||
"appId": wechat_pay_settings.appid,
|
||||
"timeStamp": timestamp,
|
||||
"nonceStr": nonce,
|
||||
"package": package,
|
||||
"signType": "RSA",
|
||||
"paySign": rsa_sign(message),
|
||||
}
|
||||
|
||||
|
||||
def decrypt_notify_resource(resource: dict[str, str]) -> dict[str, Any]:
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
ciphertext = base64.b64decode(resource["ciphertext"])
|
||||
nonce = resource["nonce"].encode("utf-8")
|
||||
associated_data = resource.get("associated_data", "").encode("utf-8")
|
||||
aesgcm = AESGCM(wechat_pay_settings.api_v3_key.encode("utf-8"))
|
||||
plaintext = aesgcm.decrypt(nonce, ciphertext, associated_data)
|
||||
return json.loads(plaintext.decode("utf-8"))
|
||||
|
||||
|
||||
def verify_notify_signature(headers: dict[str, str], body: str) -> bool:
|
||||
timestamp = headers.get("wechatpay-timestamp", "")
|
||||
nonce = headers.get("wechatpay-nonce", "")
|
||||
signature = headers.get("wechatpay-signature", "")
|
||||
if not timestamp or not nonce or not signature:
|
||||
return False
|
||||
message = f"{timestamp}\n{nonce}\n{body}\n"
|
||||
return rsa_verify(message, signature)
|
||||
@@ -0,0 +1,99 @@
|
||||
# 前端模块说明
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Vue 3
|
||||
- Vite
|
||||
- vue-router
|
||||
- Ant Design Vue
|
||||
- VueQuill
|
||||
|
||||
## 模块边界
|
||||
|
||||
- `src/main.ts`:Vue 应用入口,注册路由和 Ant Design Vue。
|
||||
- `src/config/app.ts`:前端应用配置,应用名默认“自在福田”,支持 `VITE_APP_NAME` 覆盖。
|
||||
- `src/router/index.ts`:路由表,按后端功能模块划分。
|
||||
- `src/layouts/AdminLayout.vue`:后台主布局。
|
||||
- `src/views/login/LoginPage.vue`:登录页,包含用户名、密码、验证码和友好错误提示。
|
||||
- `src/views/temples/TempleManagement.vue`:寺院管理页。
|
||||
- `src/views/products/ProductManagement.vue`:商品管理列表页,按当前寺院隔离。
|
||||
- `src/views/products/ProductDetail.vue`:商品新建和编辑页,按当前寺院隔离。
|
||||
- `src/views/orders/OrderManagement.vue`:订单管理页,按当前寺院隔离。
|
||||
- `src/views/rituals/RitualManagement.vue`:法事管理页,按当前寺院隔离。
|
||||
- `src/views/users/UserManagement.vue`:用户管理页。
|
||||
- `src/views/todos/TodoManagement.vue`:Todo 模块占位页。
|
||||
- `src/api/http.ts`:统一请求封装。
|
||||
- `src/api/auth.ts`:登录认证、验证码、当前用户和退出登录接口封装。
|
||||
- `src/api/users.ts`:用户接口封装。
|
||||
- `src/api/temples.ts`:寺院接口封装。
|
||||
- `src/api/products.ts`:商品接口封装。
|
||||
- `src/api/uploads.ts`:图片上传接口封装。
|
||||
- `src/api/orders.ts`:订单接口封装。
|
||||
- `src/api/payments.ts`:微信支付接口封装。
|
||||
- `src/api/rituals.ts`:法事接口封装。
|
||||
|
||||
## 路由约定
|
||||
|
||||
```text
|
||||
/login 静态登录页
|
||||
/temples 寺院管理
|
||||
/products 商品管理
|
||||
/products/create 新建商品
|
||||
/products/:productId/edit 编辑商品
|
||||
/orders 订单管理
|
||||
/ritual-services 法事管理
|
||||
/users 用户管理
|
||||
/todos Todo 管理占位页
|
||||
```
|
||||
|
||||
商品、订单、法事页面使用当前寺院上下文访问数据。当前寺院由后台顶部选择器维护,`src/api/http.ts` 会把 `currentTempleId` 写入请求头 `X-Temple-Id`。
|
||||
|
||||
后台页面通过路由守卫检查登录状态;认证令牌只存在后端设置的 HttpOnly Cookie 中,前端 localStorage 只缓存当前用户展示信息,不保存 token。
|
||||
|
||||
## 用户管理页面
|
||||
|
||||
用户管理页字段与后端用户模型保持一致:
|
||||
|
||||
```text
|
||||
注册时间、姓名、手机号、昵称、头像、是否管理员、总消费
|
||||
```
|
||||
|
||||
注册时间只展示,不在表单中编辑。
|
||||
|
||||
## 商品管理页面
|
||||
|
||||
商品列表只展示:
|
||||
|
||||
```text
|
||||
商品名称、简称、销量、首页展示、上架时间、开售时间、商品状态、修改删除操作
|
||||
```
|
||||
|
||||
商品新建和编辑打开独立页面,路由 `meta.module` 仍保持 `products`,让商品管理菜单在详情页高亮。商品详情编辑包含基础信息、商品描述、时间与展示、商品规格、商品详情、微信分享配置。商品封面和商品图片使用上传组件,上传后保存 OSS 返回的图片 URL。商品规格在表格中行编辑保存;商品详情使用 VueQuill 富文本编辑器,保存 HTML 内容。
|
||||
|
||||
## 依赖约定
|
||||
|
||||
- 新增前端依赖时只写入 `package.json`,由用户执行 `pnpm install`。
|
||||
- `node_modules` 不提交。
|
||||
|
||||
## 验证
|
||||
|
||||
安装依赖后优先执行:
|
||||
|
||||
```bash
|
||||
pnpm typecheck
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## 修改记录
|
||||
|
||||
- 2026-07-22:用户管理页同步后端新用户模型,表格展示注册时间、姓名、手机号、昵称、头像、是否管理员、总消费,表单支持编辑除注册时间外的字段。验证:`pnpm typecheck` 和 `pnpm build` 通过。
|
||||
- 2026-07-22:前端应用名改为配置化,默认“自在福田”;后台主布局新增路由面包屑,面包屑来源为路由 `meta.title`。验证:`pnpm typecheck` 和 `pnpm build` 通过,构建仅保留 Ant Design Vue 首包体积提示。
|
||||
- 2026-07-22:用户管理页新增手机号查询栏,`src/api/users.ts` 支持向 `/users` 传递 `phone` 查询参数。验证:`pnpm typecheck` 和 `pnpm build` 通过。
|
||||
- 2026-07-22:新增寺院、商品、订单、法事前端模块,后台布局新增模块菜单和当前寺院选择器,隔离模块请求自动携带 `X-Temple-Id`。验证:`pnpm typecheck` 和 `pnpm build` 通过。
|
||||
- 2026-07-23:商品管理页同步新的商品详情模型;列表调整为商品名称、简称、销量、首页展示、上架时间、开售时间、商品状态和操作,编辑弹窗增加商品图片、时间展示、规格行编辑、详情编辑和微信分享配置。验证:`pnpm typecheck` 和 `pnpm build` 通过。
|
||||
- 2026-07-23:商品新建和编辑从列表弹窗调整为独立页面,新增 `/products/create` 和 `/products/:productId/edit` 路由,详情页保持商品管理菜单高亮。验证:`pnpm typecheck` 和 `pnpm build` 通过。
|
||||
- 2026-07-23:商品详情页将商品封面、商品图片改为上传组件,新增 `src/api/uploads.ts` 调用后端 `/uploads/images`,上传成功后保存 OSS 图片 URL。验证:`pnpm typecheck` 和 `pnpm build` 通过。
|
||||
- 2026-07-23:商品详情编辑器从内置 `contenteditable` 替换为 VueQuill,`package.json` 新增 `@vueup/vue-quill` 依赖并保存 HTML 内容。验证:待安装依赖后执行。
|
||||
- 2026-07-23:接入登录功能,登录页支持用户名、密码、服务端验证码、账号自动聚焦、账号实时 trim、密码显示/隐藏、提交 loading 和友好错误提示;新增 `src/api/auth.ts` 与路由登录守卫,退出登录调用后端失效会话。验证:`pnpm typecheck` 和 `pnpm build` 通过。
|
||||
- 2026-07-23:商品编辑基础信息新增商品描述字段,前端商品 API 类型和商品编辑表单同步 `description`。验证:`pnpm typecheck` 和 `pnpm build` 通过。
|
||||
- 2026-07-23:订单管理页新增微信 JSAPI 预下单入口,新增 `src/api/payments.ts` 调用 `/payments/wechat/jsapi`,用于生成支付参数;实际微信调起仍需用户端页面和 openid。验证:`pnpm typecheck` 和 `pnpm build` 通过。
|
||||
@@ -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="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "py-web-front",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1 --port 5173",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview --host 127.0.0.1 --port 4173",
|
||||
"typecheck": "vue-tsc -b"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"@vueup/vue-quill": "^1.5.5",
|
||||
"ant-design-vue": "^4.2.6",
|
||||
"vue": "^3.5.0",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vite": "^7.0.0",
|
||||
"vue-tsc": "^2.2.10"
|
||||
}
|
||||
}
|
||||
Generated
+1263
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
@@ -0,0 +1,73 @@
|
||||
import { request } from './http';
|
||||
|
||||
export const AUTH_USER_KEY = 'authUser';
|
||||
|
||||
export type AuthUser = {
|
||||
id: number;
|
||||
username: string;
|
||||
name: string;
|
||||
is_admin: boolean;
|
||||
};
|
||||
|
||||
export type CaptchaResponse = {
|
||||
captcha_id: string;
|
||||
challenge: string;
|
||||
expires_in: number;
|
||||
};
|
||||
|
||||
export type LoginPayload = {
|
||||
username: string;
|
||||
password: string;
|
||||
captcha_id?: string;
|
||||
captcha_code?: string;
|
||||
};
|
||||
|
||||
export type LoginResponse = {
|
||||
expires_in: number;
|
||||
user: AuthUser;
|
||||
requires_second_verification: boolean;
|
||||
security_notice: string | null;
|
||||
};
|
||||
|
||||
export function getStoredUser() {
|
||||
const raw = localStorage.getItem(AUTH_USER_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(raw) as AuthUser;
|
||||
} catch {
|
||||
localStorage.removeItem(AUTH_USER_KEY);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function storeAuthUser(user: AuthUser) {
|
||||
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(user));
|
||||
}
|
||||
|
||||
export function clearAuthUser() {
|
||||
localStorage.removeItem(AUTH_USER_KEY);
|
||||
}
|
||||
|
||||
export function getCaptcha() {
|
||||
return request<CaptchaResponse>('/auth/captcha');
|
||||
}
|
||||
|
||||
export function login(payload: LoginPayload) {
|
||||
return request<LoginResponse>('/auth/login', {
|
||||
method: 'POST',
|
||||
json: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function getCurrentUser() {
|
||||
return request<AuthUser>('/auth/me');
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
return request<void>('/auth/logout', {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
const API_BASE = '/api';
|
||||
export const CURRENT_TEMPLE_ID_KEY = 'currentTempleId';
|
||||
export const DEFAULT_TEMPLE_ID = '1';
|
||||
|
||||
type RequestOptions = RequestInit & {
|
||||
json?: unknown;
|
||||
};
|
||||
|
||||
export async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const headers = new Headers(options.headers);
|
||||
|
||||
if (options.json !== undefined) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
const currentTempleId = localStorage.getItem(CURRENT_TEMPLE_ID_KEY) || DEFAULT_TEMPLE_ID;
|
||||
if (currentTempleId && !headers.has('X-Temple-Id')) {
|
||||
headers.set('X-Temple-Id', currentTempleId);
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
credentials: options.credentials || 'include',
|
||||
headers,
|
||||
body: options.json === undefined ? options.body : JSON.stringify(options.json),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.json().catch(() => null);
|
||||
const message = errorBody?.detail || `请求失败:${response.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { request } from './http';
|
||||
|
||||
export type Order = {
|
||||
id: number;
|
||||
temple_id: number;
|
||||
customer_name: string;
|
||||
customer_phone: string | null;
|
||||
order_type: 'product' | 'ritual';
|
||||
item_name: string;
|
||||
amount: number;
|
||||
status: 'pending' | 'paid' | 'completed' | 'cancelled';
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type OrderPayload = {
|
||||
customer_name: string;
|
||||
customer_phone?: string | null;
|
||||
order_type: 'product' | 'ritual';
|
||||
item_name: string;
|
||||
amount: number;
|
||||
status?: 'pending' | 'paid' | 'completed' | 'cancelled';
|
||||
};
|
||||
|
||||
export function listOrders() {
|
||||
return request<Order[]>('/orders');
|
||||
}
|
||||
|
||||
export function createOrder(payload: OrderPayload) {
|
||||
return request<Order>('/orders', {
|
||||
method: 'POST',
|
||||
json: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateOrder(id: number, payload: Partial<OrderPayload>) {
|
||||
return request<Order>(`/orders/${id}`, {
|
||||
method: 'PATCH',
|
||||
json: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteOrder(id: number) {
|
||||
return request<void>(`/orders/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { request } from './http';
|
||||
|
||||
export type WeChatJSAPIPayPayload = {
|
||||
order_id: number;
|
||||
openid: string;
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
export type WeChatJSAPIPayParams = {
|
||||
appId: string;
|
||||
timeStamp: string;
|
||||
nonceStr: string;
|
||||
package: string;
|
||||
signType: string;
|
||||
paySign: string;
|
||||
out_trade_no: string;
|
||||
prepay_id: string;
|
||||
};
|
||||
|
||||
export function createWeChatJSAPIPayment(payload: WeChatJSAPIPayPayload) {
|
||||
return request<WeChatJSAPIPayParams>('/payments/wechat/jsapi', {
|
||||
method: 'POST',
|
||||
json: payload,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { request } from './http';
|
||||
|
||||
export type Product = {
|
||||
id: number;
|
||||
temple_id: number;
|
||||
name: string;
|
||||
alias: string | null;
|
||||
description: string | null;
|
||||
payment_button_name: string;
|
||||
donation_button_name: string;
|
||||
sort_order: number;
|
||||
show_participant_count: boolean;
|
||||
cover_image: string | null;
|
||||
images: string[];
|
||||
listed_at: string | null;
|
||||
sale_starts_at: string | null;
|
||||
sale_ends_at: string | null;
|
||||
delisted_at: string | null;
|
||||
show_countdown: boolean;
|
||||
show_on_home: boolean;
|
||||
specs: ProductSpec[];
|
||||
details_html: string | null;
|
||||
share_title: string | null;
|
||||
share_description: string | null;
|
||||
share_image: string | null;
|
||||
merit_certificate_reserved: boolean;
|
||||
auto_process: boolean;
|
||||
is_active: boolean;
|
||||
sales_count: number;
|
||||
status: ProductStatus;
|
||||
};
|
||||
|
||||
export type ProductStatus =
|
||||
| 'delisted'
|
||||
| 'ended'
|
||||
| 'inactive'
|
||||
| 'pending_list'
|
||||
| 'pending_sale'
|
||||
| 'selling';
|
||||
|
||||
export type ProductSpec = {
|
||||
name: string;
|
||||
price: number;
|
||||
stock: number;
|
||||
sort_order: number;
|
||||
is_chaodu: boolean;
|
||||
};
|
||||
|
||||
export type ProductPayload = {
|
||||
name: string;
|
||||
alias?: string | null;
|
||||
description?: string | null;
|
||||
payment_button_name?: string;
|
||||
donation_button_name?: string;
|
||||
sort_order?: number;
|
||||
show_participant_count?: boolean;
|
||||
cover_image?: string | null;
|
||||
images?: string[];
|
||||
listed_at?: string | null;
|
||||
sale_starts_at?: string | null;
|
||||
sale_ends_at?: string | null;
|
||||
delisted_at?: string | null;
|
||||
show_countdown?: boolean;
|
||||
show_on_home?: boolean;
|
||||
specs?: ProductSpec[];
|
||||
details_html?: string | null;
|
||||
share_title?: string | null;
|
||||
share_description?: string | null;
|
||||
share_image?: string | null;
|
||||
merit_certificate_reserved?: boolean;
|
||||
auto_process?: boolean;
|
||||
is_active?: boolean;
|
||||
sales_count?: number;
|
||||
};
|
||||
|
||||
export function listProducts() {
|
||||
return request<Product[]>('/products');
|
||||
}
|
||||
|
||||
export function getProduct(id: number) {
|
||||
return request<Product>(`/products/${id}`);
|
||||
}
|
||||
|
||||
export function createProduct(payload: ProductPayload) {
|
||||
return request<Product>('/products', {
|
||||
method: 'POST',
|
||||
json: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateProduct(id: number, payload: Partial<ProductPayload>) {
|
||||
return request<Product>(`/products/${id}`, {
|
||||
method: 'PATCH',
|
||||
json: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteProduct(id: number) {
|
||||
return request<void>(`/products/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { request } from './http';
|
||||
|
||||
export type RitualService = {
|
||||
id: number;
|
||||
temple_id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
price: number;
|
||||
is_active: boolean;
|
||||
};
|
||||
|
||||
export type RitualServicePayload = {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
price: number;
|
||||
is_active?: boolean;
|
||||
};
|
||||
|
||||
export function listRitualServices() {
|
||||
return request<RitualService[]>('/ritual-services');
|
||||
}
|
||||
|
||||
export function createRitualService(payload: RitualServicePayload) {
|
||||
return request<RitualService>('/ritual-services', {
|
||||
method: 'POST',
|
||||
json: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateRitualService(id: number, payload: Partial<RitualServicePayload>) {
|
||||
return request<RitualService>(`/ritual-services/${id}`, {
|
||||
method: 'PATCH',
|
||||
json: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteRitualService(id: number) {
|
||||
return request<void>(`/ritual-services/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { request } from './http';
|
||||
|
||||
export type Temple = {
|
||||
id: number;
|
||||
name: string;
|
||||
location: string | null;
|
||||
contact_phone: string | null;
|
||||
description: string | null;
|
||||
is_active: boolean;
|
||||
};
|
||||
|
||||
export type TemplePayload = {
|
||||
name: string;
|
||||
location?: string | null;
|
||||
contact_phone?: string | null;
|
||||
description?: string | null;
|
||||
is_active?: boolean;
|
||||
};
|
||||
|
||||
export function listTemples() {
|
||||
return request<Temple[]>('/temples');
|
||||
}
|
||||
|
||||
export function createTemple(payload: TemplePayload) {
|
||||
return request<Temple>('/temples', {
|
||||
method: 'POST',
|
||||
json: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateTemple(id: number, payload: Partial<TemplePayload>) {
|
||||
return request<Temple>(`/temples/${id}`, {
|
||||
method: 'PATCH',
|
||||
json: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteTemple(id: number) {
|
||||
return request<void>(`/temples/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { request } from './http';
|
||||
|
||||
export type UploadResponse = {
|
||||
url: string;
|
||||
object_key: string;
|
||||
};
|
||||
|
||||
export function uploadImage(file: Blob, folder = 'products') {
|
||||
const formData = new FormData();
|
||||
const filename = file instanceof File ? file.name : 'image';
|
||||
formData.append('file', file, filename);
|
||||
formData.append('folder', folder);
|
||||
|
||||
return request<UploadResponse>('/uploads/images', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { request } from './http';
|
||||
|
||||
export type User = {
|
||||
id: number;
|
||||
registered_at: string;
|
||||
name: string;
|
||||
phone: string | null;
|
||||
nickname: string | null;
|
||||
avatar: string | null;
|
||||
is_admin: boolean;
|
||||
total_spent: number;
|
||||
};
|
||||
|
||||
export type CreateUserPayload = {
|
||||
name: string;
|
||||
phone: string;
|
||||
nickname?: string | null;
|
||||
avatar?: string | null;
|
||||
is_admin?: boolean;
|
||||
total_spent?: number;
|
||||
};
|
||||
|
||||
export type UpdateUserPayload = Partial<CreateUserPayload>;
|
||||
|
||||
export type ListUsersParams = {
|
||||
phone?: string;
|
||||
};
|
||||
|
||||
export function listUsers(params: ListUsersParams = {}) {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params.phone) {
|
||||
searchParams.set('phone', params.phone);
|
||||
}
|
||||
|
||||
const query = searchParams.toString();
|
||||
return request<User[]>(query ? `/users?${query}` : '/users');
|
||||
}
|
||||
|
||||
export function createUser(payload: CreateUserPayload) {
|
||||
return request<User>('/users', {
|
||||
method: 'POST',
|
||||
json: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateUser(id: number, payload: UpdateUserPayload) {
|
||||
return request<User>(`/users/${id}`, {
|
||||
method: 'PATCH',
|
||||
json: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteUser(id: number) {
|
||||
return request<void>(`/users/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export const appConfig = {
|
||||
name: import.meta.env.VITE_APP_NAME || '自在福田',
|
||||
subtitle: '管理后台',
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
BankOutlined,
|
||||
FileTextOutlined,
|
||||
LoginOutlined,
|
||||
ProfileOutlined,
|
||||
ShoppingOutlined,
|
||||
TeamOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { clearAuthUser, logout } from '@/api/auth';
|
||||
import { appConfig } from '@/config/app';
|
||||
import { DEFAULT_TEMPLE_ID, CURRENT_TEMPLE_ID_KEY } from '@/api/http';
|
||||
import { listTemples, type Temple } from '@/api/temples';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const temples = ref<Temple[]>([]);
|
||||
const currentTempleId = ref(localStorage.getItem(CURRENT_TEMPLE_ID_KEY) || DEFAULT_TEMPLE_ID);
|
||||
|
||||
const selectedKeys = computed(() => {
|
||||
const moduleName = route.meta.module;
|
||||
return [typeof moduleName === 'string' ? moduleName : 'users'];
|
||||
});
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
return route.matched
|
||||
.filter((matchedRoute) => matchedRoute.meta.title)
|
||||
.map((matchedRoute) => ({
|
||||
path: matchedRoute.path,
|
||||
title: String(matchedRoute.meta.title),
|
||||
}));
|
||||
});
|
||||
|
||||
const templeOptions = computed(() => {
|
||||
return temples.value.map((temple) => ({
|
||||
label: temple.name,
|
||||
value: String(temple.id),
|
||||
}));
|
||||
});
|
||||
|
||||
function openRoute(path: string) {
|
||||
router.push(path);
|
||||
}
|
||||
|
||||
function changeTemple(value: string) {
|
||||
localStorage.setItem(CURRENT_TEMPLE_ID_KEY, value);
|
||||
currentTempleId.value = value;
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
async function submitLogout() {
|
||||
await logout().catch(() => undefined);
|
||||
clearAuthUser();
|
||||
router.push('/login');
|
||||
}
|
||||
|
||||
async function fetchTemples() {
|
||||
temples.value = await listTemples();
|
||||
if (!localStorage.getItem(CURRENT_TEMPLE_ID_KEY) && temples.value.length > 0) {
|
||||
const firstTempleId = String(temples.value[0].id);
|
||||
localStorage.setItem(CURRENT_TEMPLE_ID_KEY, firstTempleId);
|
||||
currentTempleId.value = firstTempleId;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchTemples);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-layout class="app-shell">
|
||||
<a-layout-sider class="sidebar" :width="232">
|
||||
<div class="brand">
|
||||
<div class="brand-mark">F</div>
|
||||
<div>
|
||||
<div class="brand-name">{{ appConfig.name }}</div>
|
||||
<div class="brand-subtitle">{{ appConfig.subtitle }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-menu
|
||||
class="side-menu"
|
||||
mode="inline"
|
||||
theme="light"
|
||||
:selected-keys="selectedKeys"
|
||||
>
|
||||
<a-menu-item key="temples" @click="openRoute('/temples')">
|
||||
<template #icon><BankOutlined /></template>
|
||||
寺院管理
|
||||
</a-menu-item>
|
||||
<a-menu-item key="products" @click="openRoute('/products')">
|
||||
<template #icon><ShoppingOutlined /></template>
|
||||
商品管理
|
||||
</a-menu-item>
|
||||
<a-menu-item key="orders" @click="openRoute('/orders')">
|
||||
<template #icon><FileTextOutlined /></template>
|
||||
订单管理
|
||||
</a-menu-item>
|
||||
<a-menu-item key="ritual-services" @click="openRoute('/ritual-services')">
|
||||
<template #icon><ProfileOutlined /></template>
|
||||
法事管理
|
||||
</a-menu-item>
|
||||
<a-menu-item key="users" @click="openRoute('/users')">
|
||||
<template #icon><TeamOutlined /></template>
|
||||
用户管理
|
||||
</a-menu-item>
|
||||
<a-menu-item key="todos" @click="openRoute('/todos')">
|
||||
<template #icon><ProfileOutlined /></template>
|
||||
Todo 管理
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</a-layout-sider>
|
||||
|
||||
<a-layout>
|
||||
<a-layout-header class="topbar">
|
||||
<div />
|
||||
<a-space>
|
||||
<a-select
|
||||
class="temple-selector"
|
||||
:options="templeOptions"
|
||||
:value="currentTempleId"
|
||||
placeholder="选择寺院"
|
||||
@change="changeTemple"
|
||||
/>
|
||||
<a-button type="link" @click="submitLogout">
|
||||
<template #icon><LoginOutlined /></template>
|
||||
退出登录
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-layout-header>
|
||||
|
||||
<a-layout-content class="content">
|
||||
<a-breadcrumb class="breadcrumb">
|
||||
<a-breadcrumb-item v-for="item in breadcrumbs" :key="item.path">
|
||||
{{ item.title }}
|
||||
</a-breadcrumb-item>
|
||||
</a-breadcrumb>
|
||||
<router-view />
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
import Antd from 'ant-design-vue';
|
||||
import { createApp } from 'vue';
|
||||
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
import './styles.css';
|
||||
|
||||
import App from './App.vue';
|
||||
import { appConfig } from './config/app';
|
||||
import router from './router';
|
||||
|
||||
document.title = appConfig.name;
|
||||
|
||||
createApp(App).use(router).use(Antd).mount('#app');
|
||||
@@ -0,0 +1,138 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
|
||||
import { clearAuthUser, getCurrentUser, getStoredUser, storeAuthUser } from '@/api/auth';
|
||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||
import LoginPage from '@/views/login/LoginPage.vue';
|
||||
import OrderManagement from '@/views/orders/OrderManagement.vue';
|
||||
import ProductDetail from '@/views/products/ProductDetail.vue';
|
||||
import ProductManagement from '@/views/products/ProductManagement.vue';
|
||||
import RitualManagement from '@/views/rituals/RitualManagement.vue';
|
||||
import TempleManagement from '@/views/temples/TempleManagement.vue';
|
||||
import TodoManagement from '@/views/todos/TodoManagement.vue';
|
||||
import UserManagement from '@/views/users/UserManagement.vue';
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/users',
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: LoginPage,
|
||||
meta: {
|
||||
title: '登录',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: AdminLayout,
|
||||
meta: {
|
||||
title: '首页',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'temples',
|
||||
name: 'temples',
|
||||
component: TempleManagement,
|
||||
meta: {
|
||||
module: 'temples',
|
||||
title: '寺院管理',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'products',
|
||||
name: 'products',
|
||||
component: ProductManagement,
|
||||
meta: {
|
||||
module: 'products',
|
||||
title: '商品管理',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'products/create',
|
||||
name: 'product-create',
|
||||
component: ProductDetail,
|
||||
meta: {
|
||||
module: 'products',
|
||||
title: '新建商品',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'products/:productId/edit',
|
||||
name: 'product-edit',
|
||||
component: ProductDetail,
|
||||
meta: {
|
||||
module: 'products',
|
||||
title: '编辑商品',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'orders',
|
||||
name: 'orders',
|
||||
component: OrderManagement,
|
||||
meta: {
|
||||
module: 'orders',
|
||||
title: '订单管理',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'ritual-services',
|
||||
name: 'ritual-services',
|
||||
component: RitualManagement,
|
||||
meta: {
|
||||
module: 'ritual-services',
|
||||
title: '法事管理',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'users',
|
||||
component: UserManagement,
|
||||
meta: {
|
||||
module: 'users',
|
||||
title: '用户管理',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'todos',
|
||||
name: 'todos',
|
||||
component: TodoManagement,
|
||||
meta: {
|
||||
module: 'todos',
|
||||
title: 'Todo 管理',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
if (to.name === 'login') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const storedUser = getStoredUser();
|
||||
if (storedUser) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
storeAuthUser(user);
|
||||
return true;
|
||||
} catch {
|
||||
clearAuthUser();
|
||||
return {
|
||||
name: 'login',
|
||||
query: {
|
||||
redirect: to.fullPath,
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,279 @@
|
||||
:root {
|
||||
color: #1f2937;
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
sans-serif;
|
||||
background: #f3f5f8;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: #fff !important;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.brand {
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
height: 44px;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
align-items: center;
|
||||
background: #1677ff;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
font-weight: 700;
|
||||
height: 32px;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
color: #111827;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.side-menu {
|
||||
background: transparent;
|
||||
border-inline-end: 0 !important;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
align-items: center;
|
||||
background: #fff !important;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
color: #1f2937;
|
||||
display: flex;
|
||||
height: 64px;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.temple-selector {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.page-heading h1 {
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-heading p {
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.query-bar {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.table-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.login-page {
|
||||
align-items: center;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(22, 119, 255, 0.12), transparent 36%),
|
||||
#f3f5f8;
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 18px 45px rgba(15, 23, 42, 0.08);
|
||||
margin: 0 auto;
|
||||
max-width: 420px;
|
||||
padding: 28px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-brand {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.login-brand h1 {
|
||||
font-size: 22px;
|
||||
line-height: 30px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.login-brand p {
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
margin: 2px 0 0;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-options {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: -4px 0 18px;
|
||||
}
|
||||
|
||||
.captcha-row {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: minmax(0, 1fr) 132px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
color: #1677ff;
|
||||
font-size: 56px;
|
||||
}
|
||||
|
||||
.full-width-control {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.product-detail-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 142px);
|
||||
height: calc(100dvh - 142px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.product-detail-body,
|
||||
.product-detail-body .ant-spin-nested-loading,
|
||||
.product-detail-body .ant-spin-container,
|
||||
.product-detail-form,
|
||||
.product-detail-tabs {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.product-detail-tabs .ant-tabs-content-holder {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.product-detail-tabs .ant-tabs-content {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.product-detail-tabs .ant-tabs-tabpane {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.product-rich-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.product-rich-editor .ql-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.product-rich-editor .ql-editor {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-shell {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.page-heading {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.product-rich-editor {
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.login-page {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
import { LockOutlined, ReloadOutlined, SafetyCertificateOutlined, UserOutlined } from '@ant-design/icons-vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { getCaptcha, login, storeAuthUser } from '@/api/auth';
|
||||
import { appConfig } from '@/config/app';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const usernameInputRef = ref();
|
||||
const loading = ref(false);
|
||||
const captchaLoading = ref(false);
|
||||
|
||||
const loginForm = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
captcha_id: '',
|
||||
captcha_code: '',
|
||||
});
|
||||
|
||||
const captchaChallenge = ref('');
|
||||
|
||||
function trimUsername() {
|
||||
loginForm.username = loginForm.username.trim();
|
||||
}
|
||||
|
||||
async function refreshCaptcha() {
|
||||
captchaLoading.value = true;
|
||||
try {
|
||||
const captcha = await getCaptcha();
|
||||
loginForm.captcha_id = captcha.captcha_id;
|
||||
loginForm.captcha_code = '';
|
||||
captchaChallenge.value = captcha.challenge;
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '验证码加载失败,请刷新重试');
|
||||
} finally {
|
||||
captchaLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitLogin() {
|
||||
trimUsername();
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await login({
|
||||
captcha_code: loginForm.captcha_code.trim(),
|
||||
captcha_id: loginForm.captcha_id,
|
||||
password: loginForm.password,
|
||||
username: loginForm.username,
|
||||
});
|
||||
storeAuthUser(result.user);
|
||||
if (result.security_notice) {
|
||||
message.warning(result.security_notice);
|
||||
} else {
|
||||
message.success('登录成功');
|
||||
}
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/users';
|
||||
router.push(redirect);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '登录失败,请稍后重试');
|
||||
await refreshCaptcha();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshCaptcha();
|
||||
usernameInputRef.value?.focus?.();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="login-page">
|
||||
<section class="login-panel">
|
||||
<div class="login-brand">
|
||||
<div class="brand-mark">F</div>
|
||||
<div>
|
||||
<h1>{{ appConfig.name }}</h1>
|
||||
<p>{{ appConfig.subtitle }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-form class="login-form" :model="loginForm" layout="vertical" @finish="submitLogin">
|
||||
<a-form-item
|
||||
label="用户名"
|
||||
name="username"
|
||||
:rules="[{ required: true, message: '请输入用户名' }]"
|
||||
>
|
||||
<a-input
|
||||
ref="usernameInputRef"
|
||||
v-model:value="loginForm.username"
|
||||
autocomplete="username"
|
||||
autofocus
|
||||
placeholder="admin"
|
||||
size="large"
|
||||
@blur="trimUsername"
|
||||
@input="trimUsername"
|
||||
>
|
||||
<template #prefix><UserOutlined /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item
|
||||
label="密码"
|
||||
name="password"
|
||||
:rules="[{ required: true, message: '请输入密码' }]"
|
||||
>
|
||||
<a-input-password
|
||||
v-model:value="loginForm.password"
|
||||
autocomplete="current-password"
|
||||
placeholder="请输入密码"
|
||||
size="large"
|
||||
>
|
||||
<template #prefix><LockOutlined /></template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item
|
||||
label="验证码"
|
||||
name="captcha_code"
|
||||
:rules="[{ required: true, message: '请输入验证码' }]"
|
||||
>
|
||||
<div class="captcha-row">
|
||||
<a-input
|
||||
v-model:value="loginForm.captcha_code"
|
||||
autocomplete="off"
|
||||
placeholder="计算结果"
|
||||
size="large"
|
||||
>
|
||||
<template #prefix><SafetyCertificateOutlined /></template>
|
||||
</a-input>
|
||||
<a-button :loading="captchaLoading" size="large" @click="refreshCaptcha">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
{{ captchaChallenge || '刷新' }}
|
||||
</a-button>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-button block html-type="submit" :loading="loading" size="large" type="primary">
|
||||
登录
|
||||
</a-button>
|
||||
</a-form>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,342 @@
|
||||
<script setup lang="ts">
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
import type { Rule } from 'ant-design-vue/es/form';
|
||||
import type { ColumnsType } from 'ant-design-vue/es/table';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import {
|
||||
createOrder,
|
||||
deleteOrder,
|
||||
listOrders,
|
||||
updateOrder,
|
||||
type Order,
|
||||
type OrderPayload,
|
||||
} from '@/api/orders';
|
||||
import { createWeChatJSAPIPayment } from '@/api/payments';
|
||||
|
||||
type OrderFormState = {
|
||||
id?: number;
|
||||
customer_name: string;
|
||||
customer_phone: string;
|
||||
order_type: 'product' | 'ritual';
|
||||
item_name: string;
|
||||
amount: number;
|
||||
status: 'pending' | 'paid' | 'completed' | 'cancelled';
|
||||
};
|
||||
|
||||
const orders = ref<Order[]>([]);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const modalOpen = ref(false);
|
||||
const payModalOpen = ref(false);
|
||||
const formRef = ref();
|
||||
const payFormRef = ref();
|
||||
|
||||
const formState = reactive<OrderFormState>({
|
||||
customer_name: '',
|
||||
customer_phone: '',
|
||||
order_type: 'product',
|
||||
item_name: '',
|
||||
amount: 0,
|
||||
status: 'pending',
|
||||
});
|
||||
const payFormState = reactive({
|
||||
order_id: 0,
|
||||
item_name: '',
|
||||
openid: '',
|
||||
});
|
||||
|
||||
const isEditing = computed(() => formState.id !== undefined);
|
||||
const modalTitle = computed(() => (isEditing.value ? '编辑订单' : '新建订单'));
|
||||
|
||||
const columns: ColumnsType<Order> = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 72 },
|
||||
{ title: '客户', dataIndex: 'customer_name', width: 120 },
|
||||
{ title: '手机号', dataIndex: 'customer_phone', width: 150 },
|
||||
{ title: '类型', dataIndex: 'order_type', width: 100 },
|
||||
{ title: '项目', dataIndex: 'item_name' },
|
||||
{ title: '金额', dataIndex: 'amount', width: 140 },
|
||||
{ title: '状态', dataIndex: 'status', width: 110 },
|
||||
{ title: '创建时间', dataIndex: 'created_at', width: 190 },
|
||||
{ title: '操作', key: 'actions', width: 140, fixed: 'right' },
|
||||
];
|
||||
|
||||
const rules: Record<string, Rule[]> = {
|
||||
customer_name: [{ required: true, message: '请输入客户姓名', trigger: 'blur' }],
|
||||
item_name: [{ required: true, message: '请输入项目名称', trigger: 'blur' }],
|
||||
amount: [{ type: 'number', min: 0, message: '金额不能小于 0', trigger: 'change' }],
|
||||
};
|
||||
|
||||
const orderTypeOptions = [
|
||||
{ label: '商品', value: 'product' },
|
||||
{ label: '法事', value: 'ritual' },
|
||||
];
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '待处理', value: 'pending' },
|
||||
{ label: '已支付', value: 'paid' },
|
||||
{ label: '已完成', value: 'completed' },
|
||||
{ label: '已取消', value: 'cancelled' },
|
||||
];
|
||||
|
||||
function formatMoney(value: number) {
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
currency: 'CNY',
|
||||
style: 'currency',
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
function getStatusColor(statusValue: Order['status']) {
|
||||
const colors = {
|
||||
cancelled: 'default',
|
||||
completed: 'green',
|
||||
paid: 'blue',
|
||||
pending: 'orange',
|
||||
};
|
||||
return colors[statusValue];
|
||||
}
|
||||
|
||||
function getStatusLabel(statusValue: Order['status']) {
|
||||
return statusOptions.find((item) => item.value === statusValue)?.label || statusValue;
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
formState.id = undefined;
|
||||
formState.customer_name = '';
|
||||
formState.customer_phone = '';
|
||||
formState.order_type = 'product';
|
||||
formState.item_name = '';
|
||||
formState.amount = 0;
|
||||
formState.status = 'pending';
|
||||
formRef.value?.clearValidate?.();
|
||||
}
|
||||
|
||||
async function fetchOrders() {
|
||||
loading.value = true;
|
||||
try {
|
||||
orders.value = await listOrders();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '加载订单失败,请先选择寺院');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
resetForm();
|
||||
modalOpen.value = true;
|
||||
}
|
||||
|
||||
function openEditModal(order: Order) {
|
||||
formState.id = order.id;
|
||||
formState.customer_name = order.customer_name;
|
||||
formState.customer_phone = order.customer_phone || '';
|
||||
formState.order_type = order.order_type;
|
||||
formState.item_name = order.item_name;
|
||||
formState.amount = order.amount;
|
||||
formState.status = order.status;
|
||||
modalOpen.value = true;
|
||||
}
|
||||
|
||||
function openPayModal(order: Order) {
|
||||
payFormState.order_id = order.id;
|
||||
payFormState.item_name = order.item_name;
|
||||
payFormState.openid = '';
|
||||
payModalOpen.value = true;
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
await formRef.value?.validate?.();
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload: OrderPayload = {
|
||||
customer_name: formState.customer_name,
|
||||
customer_phone: formState.customer_phone || null,
|
||||
order_type: formState.order_type,
|
||||
item_name: formState.item_name,
|
||||
amount: formState.amount,
|
||||
status: formState.status,
|
||||
};
|
||||
|
||||
if (isEditing.value && formState.id !== undefined) {
|
||||
await updateOrder(formState.id, payload);
|
||||
message.success('订单已更新');
|
||||
} else {
|
||||
await createOrder(payload);
|
||||
message.success('订单已创建');
|
||||
}
|
||||
|
||||
modalOpen.value = false;
|
||||
resetForm();
|
||||
await fetchOrders();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '保存订单失败');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitPayForm() {
|
||||
await payFormRef.value?.validate?.();
|
||||
saving.value = true;
|
||||
try {
|
||||
const result = await createWeChatJSAPIPayment({
|
||||
description: payFormState.item_name,
|
||||
openid: payFormState.openid.trim(),
|
||||
order_id: payFormState.order_id,
|
||||
});
|
||||
message.success(`微信预下单成功:${result.prepay_id}`);
|
||||
payModalOpen.value = false;
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '微信预下单失败');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(order: Order) {
|
||||
Modal.confirm({
|
||||
title: '删除订单',
|
||||
content: `确认删除订单 ${order.item_name}?`,
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
async onOk() {
|
||||
await deleteOrder(order.id);
|
||||
message.success('订单已删除');
|
||||
await fetchOrders();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(fetchOrders);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="workspace">
|
||||
<div class="page-heading">
|
||||
<h1>订单管理</h1>
|
||||
<a-space>
|
||||
<a-button :loading="loading" @click="fetchOrders">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
<a-button type="primary" @click="openCreateModal">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
新建订单
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="orders"
|
||||
:loading="loading"
|
||||
:pagination="{ pageSize: 8, showSizeChanger: false }"
|
||||
:scroll="{ x: 1180 }"
|
||||
bordered
|
||||
row-key="id"
|
||||
size="middle"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'customer_phone'">
|
||||
{{ record.customer_phone || '-' }}
|
||||
</template>
|
||||
|
||||
<template v-if="column.dataIndex === 'order_type'">
|
||||
{{ record.order_type === 'product' ? '商品' : '法事' }}
|
||||
</template>
|
||||
|
||||
<template v-if="column.dataIndex === 'amount'">
|
||||
{{ formatMoney(record.amount) }}
|
||||
</template>
|
||||
|
||||
<template v-if="column.dataIndex === 'status'">
|
||||
<a-tag :color="getStatusColor(record.status)">
|
||||
{{ getStatusLabel(record.status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-if="column.dataIndex === 'created_at'">
|
||||
{{ formatDate(record.created_at) }}
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'actions'">
|
||||
<a-space>
|
||||
<a-tooltip title="编辑">
|
||||
<a-button type="text" @click="openEditModal(record)">
|
||||
<template #icon><EditOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-button v-if="record.status === 'pending'" type="link" @click="openPayModal(record)">
|
||||
微信支付
|
||||
</a-button>
|
||||
<a-tooltip title="删除">
|
||||
<a-button type="text" danger @click="confirmDelete(record)">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</section>
|
||||
|
||||
<a-modal
|
||||
v-model:open="modalOpen"
|
||||
:confirm-loading="saving"
|
||||
:title="modalTitle"
|
||||
ok-text="保存"
|
||||
cancel-text="取消"
|
||||
@ok="submitForm"
|
||||
>
|
||||
<a-form ref="formRef" :model="formState" :rules="rules" layout="vertical">
|
||||
<a-form-item label="客户姓名" name="customer_name">
|
||||
<a-input v-model:value="formState.customer_name" placeholder="张三" />
|
||||
</a-form-item>
|
||||
<a-form-item label="客户手机号" name="customer_phone">
|
||||
<a-input v-model:value="formState.customer_phone" placeholder="13800138000" />
|
||||
</a-form-item>
|
||||
<a-form-item label="订单类型" name="order_type">
|
||||
<a-select v-model:value="formState.order_type" :options="orderTypeOptions" />
|
||||
</a-form-item>
|
||||
<a-form-item label="项目名称" name="item_name">
|
||||
<a-input v-model:value="formState.item_name" placeholder="平安香" />
|
||||
</a-form-item>
|
||||
<a-form-item label="金额" name="amount">
|
||||
<a-input-number v-model:value="formState.amount" :min="0" :precision="2" addon-before="¥" class="full-width-control" />
|
||||
</a-form-item>
|
||||
<a-form-item label="状态" name="status">
|
||||
<a-select v-model:value="formState.status" :options="statusOptions" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<a-modal
|
||||
v-model:open="payModalOpen"
|
||||
:confirm-loading="saving"
|
||||
title="微信 JSAPI 预下单"
|
||||
ok-text="生成支付参数"
|
||||
cancel-text="取消"
|
||||
@ok="submitPayForm"
|
||||
>
|
||||
<a-form ref="payFormRef" :model="payFormState" layout="vertical">
|
||||
<a-form-item label="项目名称">
|
||||
<a-input v-model:value="payFormState.item_name" disabled />
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
label="用户 OpenID"
|
||||
name="openid"
|
||||
:rules="[{ required: true, message: '请输入微信用户 OpenID' }]"
|
||||
>
|
||||
<a-input v-model:value="payFormState.openid" placeholder="微信 JSAPI 支付需要用户 openid" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
@@ -0,0 +1,697 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowLeftOutlined, PlusOutlined, SaveOutlined } from '@ant-design/icons-vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import type { Rule } from 'ant-design-vue/es/form';
|
||||
import type { UploadFile } from 'ant-design-vue/es/upload/interface';
|
||||
import type { UploadRequestOption } from 'ant-design-vue/es/vc-upload/interface';
|
||||
import { QuillEditor } from '@vueup/vue-quill';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import '@vueup/vue-quill/dist/vue-quill.snow.css';
|
||||
|
||||
import {
|
||||
createProduct,
|
||||
getProduct,
|
||||
updateProduct,
|
||||
type Product,
|
||||
type ProductPayload,
|
||||
type ProductSpec,
|
||||
} from '@/api/products';
|
||||
import { uploadImage, type UploadResponse } from '@/api/uploads';
|
||||
|
||||
type ProductFormState = {
|
||||
name: string;
|
||||
alias: string;
|
||||
description: string;
|
||||
payment_button_name: string;
|
||||
donation_button_name: string;
|
||||
sort_order: number;
|
||||
show_participant_count: boolean;
|
||||
cover_image: string;
|
||||
images: string[];
|
||||
listed_at: string;
|
||||
sale_starts_at: string;
|
||||
sale_ends_at: string;
|
||||
delisted_at: string;
|
||||
show_countdown: boolean;
|
||||
show_on_home: boolean;
|
||||
specs: ProductSpec[];
|
||||
details_html: string;
|
||||
share_title: string;
|
||||
share_description: string;
|
||||
share_image: string;
|
||||
merit_certificate_reserved: boolean;
|
||||
auto_process: boolean;
|
||||
is_active: boolean;
|
||||
sales_count: number;
|
||||
};
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const formRef = ref();
|
||||
const editingSpecIndex = ref<number | null>(null);
|
||||
const coverImageFileList = ref<UploadFile[]>([]);
|
||||
const productImageFileList = ref<UploadFile[]>([]);
|
||||
const shareImageFileList = ref<UploadFile[]>([]);
|
||||
|
||||
const productId = computed(() => {
|
||||
const value = route.params.productId;
|
||||
const rawId = Array.isArray(value) ? value[0] : value;
|
||||
const id = Number(rawId);
|
||||
return Number.isFinite(id) && id > 0 ? id : null;
|
||||
});
|
||||
const isEditing = computed(() => productId.value !== null);
|
||||
const pageTitle = computed(() => (isEditing.value ? '编辑商品' : '新建商品'));
|
||||
|
||||
const formState = reactive<ProductFormState>({
|
||||
name: '',
|
||||
alias: '',
|
||||
description: '',
|
||||
payment_button_name: '立即支付',
|
||||
donation_button_name: '随喜',
|
||||
sort_order: 0,
|
||||
show_participant_count: false,
|
||||
cover_image: '',
|
||||
images: [],
|
||||
listed_at: '',
|
||||
sale_starts_at: '',
|
||||
sale_ends_at: '',
|
||||
delisted_at: '',
|
||||
show_countdown: false,
|
||||
show_on_home: false,
|
||||
specs: [],
|
||||
details_html: '',
|
||||
share_title: '',
|
||||
share_description: '',
|
||||
share_image: '',
|
||||
merit_certificate_reserved: false,
|
||||
auto_process: false,
|
||||
is_active: true,
|
||||
sales_count: 0,
|
||||
});
|
||||
|
||||
const specColumns = [
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{ title: '单价', dataIndex: 'price', width: 130 },
|
||||
{ title: '库存', dataIndex: 'stock', width: 120 },
|
||||
{ title: '排序', dataIndex: 'sort_order', width: 110 },
|
||||
{ title: '是否超度', dataIndex: 'is_chaodu', width: 110 },
|
||||
{ title: '操作', key: 'actions', width: 130 },
|
||||
];
|
||||
|
||||
const rules: Record<string, Rule[]> = {
|
||||
name: [{ required: true, message: '请输入商品名称', trigger: 'blur' }],
|
||||
payment_button_name: [{ required: true, message: '请输入支付按钮名称', trigger: 'blur' }],
|
||||
donation_button_name: [{ required: true, message: '请输入随喜按钮名称', trigger: 'blur' }],
|
||||
};
|
||||
|
||||
const dateTimeValueFormat = 'YYYY-MM-DDTHH:mm:ss';
|
||||
const dateTimeDisplayFormat = 'YYYY-MM-DD HH:mm';
|
||||
|
||||
function padDatePart(value: number) {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
|
||||
function formatDateTimeValue(value: string | null) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return [
|
||||
date.getFullYear(),
|
||||
'-',
|
||||
padDatePart(date.getMonth() + 1),
|
||||
'-',
|
||||
padDatePart(date.getDate()),
|
||||
'T',
|
||||
padDatePart(date.getHours()),
|
||||
':',
|
||||
padDatePart(date.getMinutes()),
|
||||
':',
|
||||
padDatePart(date.getSeconds()),
|
||||
].join('');
|
||||
}
|
||||
|
||||
function toOptionalValue(value: string) {
|
||||
return value.trim() || null;
|
||||
}
|
||||
|
||||
function fillForm(product: Product) {
|
||||
formState.name = product.name;
|
||||
formState.alias = product.alias || '';
|
||||
formState.description = product.description || '';
|
||||
formState.payment_button_name = product.payment_button_name;
|
||||
formState.donation_button_name = product.donation_button_name;
|
||||
formState.sort_order = product.sort_order;
|
||||
formState.show_participant_count = product.show_participant_count;
|
||||
formState.cover_image = product.cover_image || '';
|
||||
formState.images = [...product.images];
|
||||
formState.listed_at = formatDateTimeValue(product.listed_at);
|
||||
formState.sale_starts_at = formatDateTimeValue(product.sale_starts_at);
|
||||
formState.sale_ends_at = formatDateTimeValue(product.sale_ends_at);
|
||||
formState.delisted_at = formatDateTimeValue(product.delisted_at);
|
||||
formState.show_countdown = product.show_countdown;
|
||||
formState.show_on_home = product.show_on_home;
|
||||
formState.specs = product.specs.map((spec) => ({ ...spec }));
|
||||
formState.details_html = product.details_html || '';
|
||||
formState.share_title = product.share_title || '';
|
||||
formState.share_description = product.share_description || '';
|
||||
formState.share_image = product.share_image || '';
|
||||
formState.merit_certificate_reserved = product.merit_certificate_reserved;
|
||||
formState.auto_process = product.auto_process;
|
||||
formState.is_active = product.is_active;
|
||||
formState.sales_count = product.sales_count;
|
||||
editingSpecIndex.value = null;
|
||||
syncUploadFileLists();
|
||||
}
|
||||
|
||||
async function fetchProductDetail() {
|
||||
if (!productId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const product = await getProduct(productId.value);
|
||||
fillForm(product);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '加载商品详情失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function addSpec() {
|
||||
formState.specs.push({
|
||||
is_chaodu: false,
|
||||
name: '',
|
||||
price: 0,
|
||||
sort_order: formState.specs.length + 1,
|
||||
stock: 0,
|
||||
});
|
||||
editingSpecIndex.value = formState.specs.length - 1;
|
||||
}
|
||||
|
||||
function editSpec(index: number) {
|
||||
editingSpecIndex.value = index;
|
||||
}
|
||||
|
||||
function saveSpec() {
|
||||
editingSpecIndex.value = null;
|
||||
}
|
||||
|
||||
function deleteSpec(index: number) {
|
||||
formState.specs.splice(index, 1);
|
||||
editingSpecIndex.value = null;
|
||||
}
|
||||
|
||||
function getFileName(url: string) {
|
||||
try {
|
||||
const pathname = new URL(url).pathname;
|
||||
return pathname.split('/').filter(Boolean).pop() || 'image';
|
||||
} catch {
|
||||
return url.split('/').filter(Boolean).pop() || 'image';
|
||||
}
|
||||
}
|
||||
|
||||
function createUploadFile(url: string, index: number, prefix: string): UploadFile {
|
||||
return {
|
||||
uid: `${prefix}-${index}-${url}`,
|
||||
name: getFileName(url),
|
||||
status: 'done',
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
function syncUploadFileLists() {
|
||||
coverImageFileList.value = formState.cover_image
|
||||
? [createUploadFile(formState.cover_image, 0, 'cover')]
|
||||
: [];
|
||||
productImageFileList.value = formState.images.map((url, index) => createUploadFile(url, index, 'product'));
|
||||
shareImageFileList.value = formState.share_image
|
||||
? [createUploadFile(formState.share_image, 0, 'share')]
|
||||
: [];
|
||||
}
|
||||
|
||||
function getUploadFileUrl(file: UploadFile) {
|
||||
const response = file.response as UploadResponse | undefined;
|
||||
return file.url || response?.url || '';
|
||||
}
|
||||
|
||||
function replaceUploadingFile(fileUid: string, url: string, list: UploadFile[]) {
|
||||
const uploadedFile = createUploadFile(url, list.length, 'uploaded');
|
||||
uploadedFile.uid = fileUid;
|
||||
|
||||
const currentIndex = list.findIndex((item) => item.uid === fileUid);
|
||||
if (currentIndex === -1) {
|
||||
return [...list, uploadedFile];
|
||||
}
|
||||
|
||||
const next = [...list];
|
||||
next[currentIndex] = uploadedFile;
|
||||
return next;
|
||||
}
|
||||
|
||||
function getRequestFile(option: UploadRequestOption) {
|
||||
return option.file instanceof Blob ? option.file : null;
|
||||
}
|
||||
|
||||
function getRequestFileUid(option: UploadRequestOption) {
|
||||
return String((option.file as Blob & { uid?: string }).uid || Date.now());
|
||||
}
|
||||
|
||||
async function handleCoverUpload(option: UploadRequestOption) {
|
||||
const file = getRequestFile(option);
|
||||
if (!file) {
|
||||
message.error('请选择图片文件');
|
||||
option.onError?.(new Error('请选择图片文件'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await uploadImage(file, 'products/cover');
|
||||
formState.cover_image = result.url;
|
||||
coverImageFileList.value = replaceUploadingFile(getRequestFileUid(option), result.url, coverImageFileList.value).slice(-1);
|
||||
option.onSuccess?.(result);
|
||||
} catch (error) {
|
||||
const uploadError = error instanceof Error ? error : new Error('上传失败');
|
||||
message.error(uploadError.message);
|
||||
option.onError?.(uploadError);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleProductImageUpload(option: UploadRequestOption) {
|
||||
const file = getRequestFile(option);
|
||||
if (!file) {
|
||||
message.error('请选择图片文件');
|
||||
option.onError?.(new Error('请选择图片文件'));
|
||||
return;
|
||||
}
|
||||
if (formState.images.length >= 10) {
|
||||
message.error('商品图片不能超过 10 张');
|
||||
option.onError?.(new Error('商品图片不能超过 10 张'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await uploadImage(file, 'products/images');
|
||||
formState.images = [...formState.images, result.url].slice(0, 10);
|
||||
productImageFileList.value = replaceUploadingFile(
|
||||
getRequestFileUid(option),
|
||||
result.url,
|
||||
productImageFileList.value,
|
||||
).slice(0, 10);
|
||||
option.onSuccess?.(result);
|
||||
} catch (error) {
|
||||
const uploadError = error instanceof Error ? error : new Error('上传失败');
|
||||
message.error(uploadError.message);
|
||||
option.onError?.(uploadError);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleShareImageUpload(option: UploadRequestOption) {
|
||||
const file = getRequestFile(option);
|
||||
if (!file) {
|
||||
message.error('请选择图片文件');
|
||||
option.onError?.(new Error('请选择图片文件'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await uploadImage(file, 'products/share');
|
||||
formState.share_image = result.url;
|
||||
shareImageFileList.value = replaceUploadingFile(getRequestFileUid(option), result.url, shareImageFileList.value).slice(-1);
|
||||
option.onSuccess?.(result);
|
||||
} catch (error) {
|
||||
const uploadError = error instanceof Error ? error : new Error('上传失败');
|
||||
message.error(uploadError.message);
|
||||
option.onError?.(uploadError);
|
||||
}
|
||||
}
|
||||
|
||||
function removeCoverImage() {
|
||||
formState.cover_image = '';
|
||||
coverImageFileList.value = [];
|
||||
return true;
|
||||
}
|
||||
|
||||
function removeProductImage(file: UploadFile) {
|
||||
const removedUrl = getUploadFileUrl(file);
|
||||
productImageFileList.value = productImageFileList.value.filter((item) => item.uid !== file.uid);
|
||||
formState.images = removedUrl
|
||||
? formState.images.filter((url) => url !== removedUrl)
|
||||
: productImageFileList.value.map(getUploadFileUrl).filter(Boolean);
|
||||
return true;
|
||||
}
|
||||
|
||||
function removeShareImage() {
|
||||
formState.share_image = '';
|
||||
shareImageFileList.value = [];
|
||||
return true;
|
||||
}
|
||||
|
||||
function backToList() {
|
||||
router.push('/products');
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
await formRef.value?.validate?.();
|
||||
|
||||
if (formState.images.length > 10) {
|
||||
message.error('商品图片不能超过 10 张');
|
||||
return;
|
||||
}
|
||||
if (formState.specs.some((spec) => !spec.name.trim())) {
|
||||
message.error('商品规格名称不能为空');
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload: ProductPayload = {
|
||||
alias: toOptionalValue(formState.alias),
|
||||
auto_process: formState.auto_process,
|
||||
cover_image: toOptionalValue(formState.cover_image),
|
||||
delisted_at: toOptionalValue(formState.delisted_at),
|
||||
description: toOptionalValue(formState.description),
|
||||
details_html: formState.details_html || null,
|
||||
donation_button_name: formState.donation_button_name,
|
||||
images: formState.images,
|
||||
is_active: formState.is_active,
|
||||
listed_at: toOptionalValue(formState.listed_at),
|
||||
merit_certificate_reserved: formState.merit_certificate_reserved,
|
||||
name: formState.name,
|
||||
payment_button_name: formState.payment_button_name,
|
||||
sale_ends_at: toOptionalValue(formState.sale_ends_at),
|
||||
sale_starts_at: toOptionalValue(formState.sale_starts_at),
|
||||
sales_count: formState.sales_count,
|
||||
share_description: toOptionalValue(formState.share_description),
|
||||
share_image: toOptionalValue(formState.share_image),
|
||||
share_title: toOptionalValue(formState.share_title),
|
||||
show_countdown: formState.show_countdown,
|
||||
show_on_home: formState.show_on_home,
|
||||
show_participant_count: formState.show_participant_count,
|
||||
sort_order: formState.sort_order,
|
||||
specs: formState.specs,
|
||||
};
|
||||
|
||||
if (isEditing.value && productId.value !== null) {
|
||||
await updateProduct(productId.value, payload);
|
||||
message.success('商品已更新');
|
||||
} else {
|
||||
await createProduct(payload);
|
||||
message.success('商品已创建');
|
||||
}
|
||||
|
||||
backToList();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '保存商品失败');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchProductDetail);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="workspace product-detail-page">
|
||||
<div class="page-heading">
|
||||
<h1>{{ pageTitle }}</h1>
|
||||
<a-space>
|
||||
<a-button @click="backToList">
|
||||
<template #icon><ArrowLeftOutlined /></template>
|
||||
返回
|
||||
</a-button>
|
||||
<a-button type="primary" :loading="saving" @click="submitForm">
|
||||
<template #icon><SaveOutlined /></template>
|
||||
保存
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<div class="product-detail-body">
|
||||
<a-spin :spinning="loading">
|
||||
<a-form ref="formRef" :model="formState" :rules="rules" class="product-detail-form" layout="vertical">
|
||||
<a-tabs class="product-detail-tabs">
|
||||
<a-tab-pane key="basic" tab="基础信息">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="商品名称" name="name">
|
||||
<a-input v-model:value="formState.name" placeholder="平安香" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="商品别称" name="alias">
|
||||
<a-input v-model:value="formState.alias" placeholder="平安" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="24">
|
||||
<a-form-item label="商品描述" name="description">
|
||||
<a-textarea
|
||||
v-model:value="formState.description"
|
||||
:maxlength="500"
|
||||
:rows="3"
|
||||
placeholder="用于简要说明商品用途、祈福内容或展示摘要"
|
||||
show-count
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="支付按钮名称" name="payment_button_name">
|
||||
<a-input v-model:value="formState.payment_button_name" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="随喜按钮名称" name="donation_button_name">
|
||||
<a-input v-model:value="formState.donation_button_name" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="排序号" name="sort_order">
|
||||
<a-input-number v-model:value="formState.sort_order" :min="0" class="full-width-control" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="销量" name="sales_count">
|
||||
<a-input-number v-model:value="formState.sales_count" :min="0" class="full-width-control" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="是否启用" name="is_active">
|
||||
<a-switch v-model:checked="formState.is_active" checked-children="启用" un-checked-children="停用" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="商品封面" name="cover_image">
|
||||
<a-upload
|
||||
v-model:file-list="coverImageFileList"
|
||||
accept="image/*"
|
||||
list-type="picture-card"
|
||||
:custom-request="handleCoverUpload"
|
||||
:max-count="1"
|
||||
@remove="removeCoverImage"
|
||||
>
|
||||
<div v-if="coverImageFileList.length < 1">
|
||||
<PlusOutlined />
|
||||
<div class="upload-text">上传封面</div>
|
||||
</div>
|
||||
</a-upload>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="24">
|
||||
<a-form-item label="商品图片(不超过 10 张)" name="images">
|
||||
<a-upload
|
||||
v-model:file-list="productImageFileList"
|
||||
accept="image/*"
|
||||
list-type="picture-card"
|
||||
multiple
|
||||
:custom-request="handleProductImageUpload"
|
||||
:max-count="10"
|
||||
@remove="removeProductImage"
|
||||
>
|
||||
<div v-if="productImageFileList.length < 10">
|
||||
<PlusOutlined />
|
||||
<div class="upload-text">上传图片</div>
|
||||
</div>
|
||||
</a-upload>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="time" tab="时间与展示">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="上架时间" name="listed_at">
|
||||
<a-date-picker
|
||||
v-model:value="formState.listed_at"
|
||||
allow-clear
|
||||
class="full-width-control"
|
||||
:format="dateTimeDisplayFormat"
|
||||
placeholder="选择上架时间"
|
||||
show-time
|
||||
:value-format="dateTimeValueFormat"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="开售时间" name="sale_starts_at">
|
||||
<a-date-picker
|
||||
v-model:value="formState.sale_starts_at"
|
||||
allow-clear
|
||||
class="full-width-control"
|
||||
:format="dateTimeDisplayFormat"
|
||||
placeholder="选择开售时间"
|
||||
show-time
|
||||
:value-format="dateTimeValueFormat"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="结束时间" name="sale_ends_at">
|
||||
<a-date-picker
|
||||
v-model:value="formState.sale_ends_at"
|
||||
allow-clear
|
||||
class="full-width-control"
|
||||
:format="dateTimeDisplayFormat"
|
||||
placeholder="选择结束时间"
|
||||
show-time
|
||||
:value-format="dateTimeValueFormat"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="下架时间" name="delisted_at">
|
||||
<a-date-picker
|
||||
v-model:value="formState.delisted_at"
|
||||
allow-clear
|
||||
class="full-width-control"
|
||||
:format="dateTimeDisplayFormat"
|
||||
placeholder="选择下架时间"
|
||||
show-time
|
||||
:value-format="dateTimeValueFormat"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="结束倒计时">
|
||||
<a-switch v-model:checked="formState.show_countdown" checked-children="显示" un-checked-children="隐藏" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="展示到首页">
|
||||
<a-switch v-model:checked="formState.show_on_home" checked-children="是" un-checked-children="否" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="分享参与人数">
|
||||
<a-switch v-model:checked="formState.show_participant_count" checked-children="显示" un-checked-children="隐藏" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="功德证书预留">
|
||||
<a-switch v-model:checked="formState.merit_certificate_reserved" checked-children="预留" un-checked-children="关闭" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="自动处理">
|
||||
<a-switch v-model:checked="formState.auto_process" checked-children="是" un-checked-children="否" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="specs" tab="商品规格">
|
||||
<div class="table-toolbar">
|
||||
<a-button type="primary" @click="addSpec">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
添加规格
|
||||
</a-button>
|
||||
</div>
|
||||
<a-table
|
||||
:columns="specColumns"
|
||||
:data-source="formState.specs"
|
||||
:pagination="false"
|
||||
bordered
|
||||
row-key="sort_order"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record, index }">
|
||||
<template v-if="column.dataIndex === 'name'">
|
||||
<a-input v-if="editingSpecIndex === index" v-model:value="record.name" />
|
||||
<span v-else>{{ record.name || '-' }}</span>
|
||||
</template>
|
||||
<template v-if="column.dataIndex === 'price'">
|
||||
<a-input-number v-if="editingSpecIndex === index" v-model:value="record.price" :min="0" :precision="2" class="full-width-control" />
|
||||
<span v-else>{{ record.price }}</span>
|
||||
</template>
|
||||
<template v-if="column.dataIndex === 'stock'">
|
||||
<a-input-number v-if="editingSpecIndex === index" v-model:value="record.stock" :min="0" class="full-width-control" />
|
||||
<span v-else>{{ record.stock }}</span>
|
||||
</template>
|
||||
<template v-if="column.dataIndex === 'sort_order'">
|
||||
<a-input-number v-if="editingSpecIndex === index" v-model:value="record.sort_order" :min="0" class="full-width-control" />
|
||||
<span v-else>{{ record.sort_order }}</span>
|
||||
</template>
|
||||
<template v-if="column.dataIndex === 'is_chaodu'">
|
||||
<a-switch v-if="editingSpecIndex === index" v-model:checked="record.is_chaodu" checked-children="是" un-checked-children="否" />
|
||||
<a-tag v-else :color="record.is_chaodu ? 'purple' : 'default'">
|
||||
{{ record.is_chaodu ? '是' : '否' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'actions'">
|
||||
<a-space>
|
||||
<a-button v-if="editingSpecIndex === index" type="link" @click="saveSpec">保存</a-button>
|
||||
<a-button v-else type="link" @click="editSpec(index)">编辑</a-button>
|
||||
<a-button danger type="link" @click="deleteSpec(index)">删除</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="details" tab="商品详情">
|
||||
<QuillEditor
|
||||
v-model:content="formState.details_html"
|
||||
class="product-rich-editor"
|
||||
content-type="html"
|
||||
theme="snow"
|
||||
toolbar="full"
|
||||
/>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="share" tab="微信分享">
|
||||
<a-form-item label="分享标题" name="share_title">
|
||||
<a-input v-model:value="formState.share_title" />
|
||||
</a-form-item>
|
||||
<a-form-item label="分享描述" name="share_description">
|
||||
<a-textarea v-model:value="formState.share_description" :rows="3" />
|
||||
</a-form-item>
|
||||
<a-form-item label="分享图片" name="share_image">
|
||||
<a-upload
|
||||
v-model:file-list="shareImageFileList"
|
||||
accept="image/*"
|
||||
list-type="picture-card"
|
||||
:custom-request="handleShareImageUpload"
|
||||
:max-count="1"
|
||||
@remove="removeShareImage"
|
||||
>
|
||||
<div v-if="shareImageFileList.length < 1">
|
||||
<PlusOutlined />
|
||||
<div class="upload-text">上传分享图</div>
|
||||
</div>
|
||||
</a-upload>
|
||||
</a-form-item>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</a-form>
|
||||
</a-spin>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,169 @@
|
||||
<script setup lang="ts">
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
import type { ColumnsType } from 'ant-design-vue/es/table';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import {
|
||||
deleteProduct,
|
||||
listProducts,
|
||||
type Product,
|
||||
type ProductStatus,
|
||||
} from '@/api/products';
|
||||
|
||||
const router = useRouter();
|
||||
const products = ref<Product[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
const columns: ColumnsType<Product> = [
|
||||
{ title: '商品名称', dataIndex: 'name', width: 180 },
|
||||
{ title: '简称', dataIndex: 'alias', width: 120 },
|
||||
{ title: '销量', dataIndex: 'sales_count', width: 100 },
|
||||
{ title: '首页展示', dataIndex: 'show_on_home', width: 110 },
|
||||
{ title: '上架时间', dataIndex: 'listed_at', width: 180 },
|
||||
{ title: '开售时间', dataIndex: 'sale_starts_at', width: 180 },
|
||||
{ title: '商品状态', dataIndex: 'status', width: 120 },
|
||||
{ title: '操作', key: 'actions', width: 140, fixed: 'right' },
|
||||
];
|
||||
|
||||
const statusLabels: Record<ProductStatus, string> = {
|
||||
delisted: '已下架',
|
||||
ended: '已结束',
|
||||
inactive: '未启用',
|
||||
pending_list: '待上架',
|
||||
pending_sale: '待开售',
|
||||
selling: '销售中',
|
||||
};
|
||||
|
||||
const statusColors: Record<ProductStatus, string> = {
|
||||
delisted: 'default',
|
||||
ended: 'default',
|
||||
inactive: 'default',
|
||||
pending_list: 'orange',
|
||||
pending_sale: 'blue',
|
||||
selling: 'green',
|
||||
};
|
||||
|
||||
function getStatusLabel(status: ProductStatus) {
|
||||
return statusLabels[status] || status;
|
||||
}
|
||||
|
||||
function getStatusColor(status: ProductStatus) {
|
||||
return statusColors[status] || 'default';
|
||||
}
|
||||
|
||||
function formatDate(value: string | null) {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
async function fetchProducts() {
|
||||
loading.value = true;
|
||||
try {
|
||||
products.value = await listProducts();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '加载商品失败,请先选择寺院');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreatePage() {
|
||||
router.push('/products/create');
|
||||
}
|
||||
|
||||
function openEditPage(product: Product) {
|
||||
router.push(`/products/${product.id}/edit`);
|
||||
}
|
||||
|
||||
function confirmDelete(product: Product) {
|
||||
Modal.confirm({
|
||||
title: '删除商品',
|
||||
content: `确认删除商品 ${product.name}?`,
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
async onOk() {
|
||||
await deleteProduct(product.id);
|
||||
message.success('商品已删除');
|
||||
await fetchProducts();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(fetchProducts);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="workspace">
|
||||
<div class="page-heading">
|
||||
<h1>商品管理</h1>
|
||||
<a-space>
|
||||
<a-button :loading="loading" @click="fetchProducts">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
<a-button type="primary" @click="openCreatePage">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
新建商品
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="products"
|
||||
:loading="loading"
|
||||
:pagination="{ pageSize: 8, showSizeChanger: false }"
|
||||
:scroll="{ x: 1120 }"
|
||||
bordered
|
||||
row-key="id"
|
||||
size="middle"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'alias'">
|
||||
{{ record.alias || '-' }}
|
||||
</template>
|
||||
|
||||
<template v-if="column.dataIndex === 'show_on_home'">
|
||||
<a-tag :color="record.show_on_home ? 'green' : 'default'">
|
||||
{{ record.show_on_home ? '展示' : '不展示' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-if="column.dataIndex === 'listed_at'">
|
||||
{{ formatDate(record.listed_at) }}
|
||||
</template>
|
||||
|
||||
<template v-if="column.dataIndex === 'sale_starts_at'">
|
||||
{{ formatDate(record.sale_starts_at) }}
|
||||
</template>
|
||||
|
||||
<template v-if="column.dataIndex === 'status'">
|
||||
<a-tag :color="getStatusColor(record.status)">
|
||||
{{ getStatusLabel(record.status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'actions'">
|
||||
<a-space>
|
||||
<a-tooltip title="编辑">
|
||||
<a-button type="text" @click="openEditPage(record)">
|
||||
<template #icon><EditOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip title="删除">
|
||||
<a-button type="text" danger @click="confirmDelete(record)">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,223 @@
|
||||
<script setup lang="ts">
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
import type { Rule } from 'ant-design-vue/es/form';
|
||||
import type { ColumnsType } from 'ant-design-vue/es/table';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import {
|
||||
createRitualService,
|
||||
deleteRitualService,
|
||||
listRitualServices,
|
||||
updateRitualService,
|
||||
type RitualService,
|
||||
} from '@/api/rituals';
|
||||
|
||||
type RitualFormState = {
|
||||
id?: number;
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
is_active: boolean;
|
||||
};
|
||||
|
||||
const rituals = ref<RitualService[]>([]);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const modalOpen = ref(false);
|
||||
const formRef = ref();
|
||||
|
||||
const formState = reactive<RitualFormState>({
|
||||
name: '',
|
||||
description: '',
|
||||
price: 0,
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
const isEditing = computed(() => formState.id !== undefined);
|
||||
const modalTitle = computed(() => (isEditing.value ? '编辑法事' : '新建法事'));
|
||||
|
||||
const columns: ColumnsType<RitualService> = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 72 },
|
||||
{ title: '法事名称', dataIndex: 'name', width: 180 },
|
||||
{ title: '价格', dataIndex: 'price', width: 140 },
|
||||
{ title: '状态', dataIndex: 'is_active', width: 100 },
|
||||
{ title: '说明', dataIndex: 'description' },
|
||||
{ title: '操作', key: 'actions', width: 140, fixed: 'right' },
|
||||
];
|
||||
|
||||
const rules: Record<string, Rule[]> = {
|
||||
name: [{ required: true, message: '请输入法事名称', trigger: 'blur' }],
|
||||
price: [{ type: 'number', min: 0, message: '价格不能小于 0', trigger: 'change' }],
|
||||
};
|
||||
|
||||
function formatMoney(value: number) {
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
currency: 'CNY',
|
||||
style: 'currency',
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
formState.id = undefined;
|
||||
formState.name = '';
|
||||
formState.description = '';
|
||||
formState.price = 0;
|
||||
formState.is_active = true;
|
||||
formRef.value?.clearValidate?.();
|
||||
}
|
||||
|
||||
async function fetchRituals() {
|
||||
loading.value = true;
|
||||
try {
|
||||
rituals.value = await listRitualServices();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '加载法事失败,请先选择寺院');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
resetForm();
|
||||
modalOpen.value = true;
|
||||
}
|
||||
|
||||
function openEditModal(ritual: RitualService) {
|
||||
formState.id = ritual.id;
|
||||
formState.name = ritual.name;
|
||||
formState.description = ritual.description || '';
|
||||
formState.price = ritual.price;
|
||||
formState.is_active = ritual.is_active;
|
||||
modalOpen.value = true;
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
await formRef.value?.validate?.();
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
name: formState.name,
|
||||
description: formState.description || null,
|
||||
price: formState.price,
|
||||
is_active: formState.is_active,
|
||||
};
|
||||
|
||||
if (isEditing.value && formState.id !== undefined) {
|
||||
await updateRitualService(formState.id, payload);
|
||||
message.success('法事已更新');
|
||||
} else {
|
||||
await createRitualService(payload);
|
||||
message.success('法事已创建');
|
||||
}
|
||||
|
||||
modalOpen.value = false;
|
||||
resetForm();
|
||||
await fetchRituals();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '保存法事失败');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(ritual: RitualService) {
|
||||
Modal.confirm({
|
||||
title: '删除法事',
|
||||
content: `确认删除法事 ${ritual.name}?`,
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
async onOk() {
|
||||
await deleteRitualService(ritual.id);
|
||||
message.success('法事已删除');
|
||||
await fetchRituals();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(fetchRituals);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="workspace">
|
||||
<div class="page-heading">
|
||||
<h1>法事管理</h1>
|
||||
<a-space>
|
||||
<a-button :loading="loading" @click="fetchRituals">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
<a-button type="primary" @click="openCreateModal">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
新建法事
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="rituals"
|
||||
:loading="loading"
|
||||
:pagination="{ pageSize: 8, showSizeChanger: false }"
|
||||
bordered
|
||||
row-key="id"
|
||||
size="middle"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'price'">
|
||||
{{ formatMoney(record.price) }}
|
||||
</template>
|
||||
|
||||
<template v-if="column.dataIndex === 'description'">
|
||||
{{ record.description || '-' }}
|
||||
</template>
|
||||
|
||||
<template v-if="column.dataIndex === 'is_active'">
|
||||
<a-tag :color="record.is_active ? 'green' : 'default'">
|
||||
{{ record.is_active ? '启用' : '停用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'actions'">
|
||||
<a-space>
|
||||
<a-tooltip title="编辑">
|
||||
<a-button type="text" @click="openEditModal(record)">
|
||||
<template #icon><EditOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip title="删除">
|
||||
<a-button type="text" danger @click="confirmDelete(record)">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</section>
|
||||
|
||||
<a-modal
|
||||
v-model:open="modalOpen"
|
||||
:confirm-loading="saving"
|
||||
:title="modalTitle"
|
||||
ok-text="保存"
|
||||
cancel-text="取消"
|
||||
@ok="submitForm"
|
||||
>
|
||||
<a-form ref="formRef" :model="formState" :rules="rules" layout="vertical">
|
||||
<a-form-item label="法事名称" name="name">
|
||||
<a-input v-model:value="formState.name" placeholder="祈福法会" />
|
||||
</a-form-item>
|
||||
<a-form-item label="价格" name="price">
|
||||
<a-input-number v-model:value="formState.price" :min="0" :precision="2" addon-before="¥" class="full-width-control" />
|
||||
</a-form-item>
|
||||
<a-form-item label="说明" name="description">
|
||||
<a-textarea v-model:value="formState.description" :rows="3" />
|
||||
</a-form-item>
|
||||
<a-form-item label="状态" name="is_active">
|
||||
<a-switch v-model:checked="formState.is_active" checked-children="启用" un-checked-children="停用" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
@@ -0,0 +1,229 @@
|
||||
<script setup lang="ts">
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
import type { Rule } from 'ant-design-vue/es/form';
|
||||
import type { ColumnsType } from 'ant-design-vue/es/table';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import {
|
||||
createTemple,
|
||||
deleteTemple,
|
||||
listTemples,
|
||||
updateTemple,
|
||||
type Temple,
|
||||
} from '@/api/temples';
|
||||
|
||||
type TempleFormState = {
|
||||
id?: number;
|
||||
name: string;
|
||||
location: string;
|
||||
contact_phone: string;
|
||||
description: string;
|
||||
is_active: boolean;
|
||||
};
|
||||
|
||||
const temples = ref<Temple[]>([]);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const modalOpen = ref(false);
|
||||
const formRef = ref();
|
||||
|
||||
const formState = reactive<TempleFormState>({
|
||||
name: '',
|
||||
location: '',
|
||||
contact_phone: '',
|
||||
description: '',
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
const isEditing = computed(() => formState.id !== undefined);
|
||||
const modalTitle = computed(() => (isEditing.value ? '编辑寺院' : '新建寺院'));
|
||||
|
||||
const columns: ColumnsType<Temple> = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 72 },
|
||||
{ title: '寺院名称', dataIndex: 'name', width: 180 },
|
||||
{ title: '位置', dataIndex: 'location', width: 180 },
|
||||
{ title: '联系电话', dataIndex: 'contact_phone', width: 160 },
|
||||
{ title: '状态', dataIndex: 'is_active', width: 100 },
|
||||
{ title: '简介', dataIndex: 'description' },
|
||||
{ title: '操作', key: 'actions', width: 140, fixed: 'right' },
|
||||
];
|
||||
|
||||
const rules: Record<string, Rule[]> = {
|
||||
name: [{ required: true, message: '请输入寺院名称', trigger: 'blur' }],
|
||||
};
|
||||
|
||||
function resetForm() {
|
||||
formState.id = undefined;
|
||||
formState.name = '';
|
||||
formState.location = '';
|
||||
formState.contact_phone = '';
|
||||
formState.description = '';
|
||||
formState.is_active = true;
|
||||
formRef.value?.clearValidate?.();
|
||||
}
|
||||
|
||||
async function fetchTemples() {
|
||||
loading.value = true;
|
||||
try {
|
||||
temples.value = await listTemples();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '加载寺院失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
resetForm();
|
||||
modalOpen.value = true;
|
||||
}
|
||||
|
||||
function openEditModal(temple: Temple) {
|
||||
formState.id = temple.id;
|
||||
formState.name = temple.name;
|
||||
formState.location = temple.location || '';
|
||||
formState.contact_phone = temple.contact_phone || '';
|
||||
formState.description = temple.description || '';
|
||||
formState.is_active = temple.is_active;
|
||||
modalOpen.value = true;
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
await formRef.value?.validate?.();
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
name: formState.name,
|
||||
location: formState.location || null,
|
||||
contact_phone: formState.contact_phone || null,
|
||||
description: formState.description || null,
|
||||
is_active: formState.is_active,
|
||||
};
|
||||
|
||||
if (isEditing.value && formState.id !== undefined) {
|
||||
await updateTemple(formState.id, payload);
|
||||
message.success('寺院已更新');
|
||||
} else {
|
||||
await createTemple(payload);
|
||||
message.success('寺院已创建');
|
||||
}
|
||||
|
||||
modalOpen.value = false;
|
||||
resetForm();
|
||||
await fetchTemples();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '保存寺院失败');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(temple: Temple) {
|
||||
Modal.confirm({
|
||||
title: '删除寺院',
|
||||
content: `确认删除寺院 ${temple.name}?`,
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
async onOk() {
|
||||
await deleteTemple(temple.id);
|
||||
message.success('寺院已删除');
|
||||
await fetchTemples();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(fetchTemples);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="workspace">
|
||||
<div class="page-heading">
|
||||
<h1>寺院管理</h1>
|
||||
<a-space>
|
||||
<a-button :loading="loading" @click="fetchTemples">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
<a-button type="primary" @click="openCreateModal">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
新建寺院
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="temples"
|
||||
:loading="loading"
|
||||
:pagination="{ pageSize: 8, showSizeChanger: false }"
|
||||
:scroll="{ x: 1000 }"
|
||||
bordered
|
||||
row-key="id"
|
||||
size="middle"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'is_active'">
|
||||
<a-tag :color="record.is_active ? 'green' : 'default'">
|
||||
{{ record.is_active ? '启用' : '停用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-if="column.dataIndex === 'location'">
|
||||
{{ record.location || '-' }}
|
||||
</template>
|
||||
|
||||
<template v-if="column.dataIndex === 'contact_phone'">
|
||||
{{ record.contact_phone || '-' }}
|
||||
</template>
|
||||
|
||||
<template v-if="column.dataIndex === 'description'">
|
||||
{{ record.description || '-' }}
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'actions'">
|
||||
<a-space>
|
||||
<a-tooltip title="编辑">
|
||||
<a-button type="text" @click="openEditModal(record)">
|
||||
<template #icon><EditOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip title="删除">
|
||||
<a-button type="text" danger @click="confirmDelete(record)">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</section>
|
||||
|
||||
<a-modal
|
||||
v-model:open="modalOpen"
|
||||
:confirm-loading="saving"
|
||||
:title="modalTitle"
|
||||
ok-text="保存"
|
||||
cancel-text="取消"
|
||||
@ok="submitForm"
|
||||
>
|
||||
<a-form ref="formRef" :model="formState" :rules="rules" layout="vertical">
|
||||
<a-form-item label="寺院名称" name="name">
|
||||
<a-input v-model:value="formState.name" placeholder="福田寺" />
|
||||
</a-form-item>
|
||||
<a-form-item label="位置" name="location">
|
||||
<a-input v-model:value="formState.location" placeholder="深圳福田" />
|
||||
</a-form-item>
|
||||
<a-form-item label="联系电话" name="contact_phone">
|
||||
<a-input v-model:value="formState.contact_phone" placeholder="0755-10000001" />
|
||||
</a-form-item>
|
||||
<a-form-item label="简介" name="description">
|
||||
<a-textarea v-model:value="formState.description" :rows="3" />
|
||||
</a-form-item>
|
||||
<a-form-item label="状态" name="is_active">
|
||||
<a-switch v-model:checked="formState.is_active" checked-children="启用" un-checked-children="停用" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { ApiOutlined, ProfileOutlined } from '@ant-design/icons-vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="workspace">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<h1>Todo 管理</h1>
|
||||
<p>对应后端 Todo 模块,接口已存在,页面后续接入</p>
|
||||
</div>
|
||||
<a-tag color="processing">
|
||||
<template #icon><ApiOutlined /></template>
|
||||
/todos
|
||||
</a-tag>
|
||||
</div>
|
||||
|
||||
<a-empty description="Todo 前端功能后续接入">
|
||||
<template #image>
|
||||
<ProfileOutlined class="empty-icon" />
|
||||
</template>
|
||||
</a-empty>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,339 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
import type { Rule } from 'ant-design-vue/es/form';
|
||||
import type { ColumnsType } from 'ant-design-vue/es/table';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import {
|
||||
createUser,
|
||||
deleteUser,
|
||||
listUsers,
|
||||
updateUser,
|
||||
type User,
|
||||
} from '@/api/users';
|
||||
|
||||
type UserFormState = {
|
||||
id?: number;
|
||||
name: string;
|
||||
phone: string;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
is_admin: boolean;
|
||||
total_spent: number;
|
||||
};
|
||||
|
||||
const users = ref<User[]>([]);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const modalOpen = ref(false);
|
||||
const formRef = ref();
|
||||
const searchPhone = ref('');
|
||||
|
||||
const formState = reactive<UserFormState>({
|
||||
name: '',
|
||||
phone: '',
|
||||
nickname: '',
|
||||
avatar: '',
|
||||
is_admin: false,
|
||||
total_spent: 0,
|
||||
});
|
||||
|
||||
const isEditing = computed(() => formState.id !== undefined);
|
||||
const modalTitle = computed(() => (isEditing.value ? '编辑用户' : '新建用户'));
|
||||
|
||||
const columns: ColumnsType<User> = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 72 },
|
||||
{ title: '头像', dataIndex: 'avatar', width: 84 },
|
||||
{ title: '姓名', dataIndex: 'name', width: 140 },
|
||||
{ title: '手机号', dataIndex: 'phone', width: 160 },
|
||||
{ title: '昵称', dataIndex: 'nickname', width: 140 },
|
||||
{ title: '管理员', dataIndex: 'is_admin', width: 110 },
|
||||
{ title: '总消费', dataIndex: 'total_spent', width: 140 },
|
||||
{ title: '注册时间', dataIndex: 'registered_at', width: 190 },
|
||||
{ title: '操作', key: 'actions', width: 140, fixed: 'right' },
|
||||
];
|
||||
|
||||
const rules: Record<string, Rule[]> = {
|
||||
name: [
|
||||
{ required: true, message: '请输入姓名', trigger: 'blur' },
|
||||
{ min: 1, max: 80, message: '姓名最多 80 个字符', trigger: 'blur' },
|
||||
],
|
||||
phone: [
|
||||
{ required: true, message: '请输入手机号', trigger: 'blur' },
|
||||
{ min: 5, max: 20, message: '手机号长度为 5-20 个字符', trigger: 'blur' },
|
||||
{
|
||||
pattern: /^[0-9+\-\s]+$/,
|
||||
message: '手机号只能包含数字、空格、+ 或 -',
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
total_spent: [
|
||||
{ type: 'number', min: 0, message: '总消费不能小于 0', trigger: 'change' },
|
||||
],
|
||||
};
|
||||
|
||||
function resetForm() {
|
||||
formState.id = undefined;
|
||||
formState.name = '';
|
||||
formState.phone = '';
|
||||
formState.nickname = '';
|
||||
formState.avatar = '';
|
||||
formState.is_admin = false;
|
||||
formState.total_spent = 0;
|
||||
formRef.value?.clearValidate?.();
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return date.toLocaleString('zh-CN', {
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
function formatMoney(value: number) {
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
currency: 'CNY',
|
||||
style: 'currency',
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
async function fetchUsers() {
|
||||
loading.value = true;
|
||||
try {
|
||||
users.value = await listUsers({
|
||||
phone: searchPhone.value.trim() || undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '加载用户失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resetSearch() {
|
||||
searchPhone.value = '';
|
||||
await fetchUsers();
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
resetForm();
|
||||
modalOpen.value = true;
|
||||
}
|
||||
|
||||
function openEditModal(user: User) {
|
||||
formState.id = user.id;
|
||||
formState.name = user.name;
|
||||
formState.phone = user.phone || '';
|
||||
formState.nickname = user.nickname || '';
|
||||
formState.avatar = user.avatar || '';
|
||||
formState.is_admin = user.is_admin;
|
||||
formState.total_spent = user.total_spent;
|
||||
modalOpen.value = true;
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
await formRef.value?.validate?.();
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
name: formState.name,
|
||||
phone: formState.phone,
|
||||
nickname: formState.nickname || null,
|
||||
avatar: formState.avatar || null,
|
||||
is_admin: formState.is_admin,
|
||||
total_spent: formState.total_spent,
|
||||
};
|
||||
|
||||
if (isEditing.value && formState.id !== undefined) {
|
||||
await updateUser(formState.id, payload);
|
||||
message.success('用户已更新');
|
||||
} else {
|
||||
await createUser(payload);
|
||||
message.success('用户已创建');
|
||||
}
|
||||
|
||||
modalOpen.value = false;
|
||||
resetForm();
|
||||
await fetchUsers();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '保存用户失败');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(user: User) {
|
||||
Modal.confirm({
|
||||
title: '删除用户',
|
||||
content: `确认删除用户 ${user.name}?`,
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
async onOk() {
|
||||
await deleteUser(user.id);
|
||||
message.success('用户已删除');
|
||||
await fetchUsers();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(fetchUsers);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="workspace">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<h1>用户管理</h1>
|
||||
</div>
|
||||
<a-space>
|
||||
<a-button :loading="loading" @click="fetchUsers">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
<a-button type="primary" @click="openCreateModal">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
新建用户
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-form class="query-bar" layout="inline" @finish="fetchUsers">
|
||||
<a-form-item label="手机号">
|
||||
<a-input
|
||||
v-model:value="searchPhone"
|
||||
allow-clear
|
||||
placeholder="输入手机号查询"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-space>
|
||||
<a-button type="primary" html-type="submit" :loading="loading">
|
||||
查询
|
||||
</a-button>
|
||||
<a-button @click="resetSearch">
|
||||
重置
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="users"
|
||||
:loading="loading"
|
||||
:pagination="{ pageSize: 8, showSizeChanger: false }"
|
||||
:scroll="{ x: 1180 }"
|
||||
row-key="id"
|
||||
size="middle"
|
||||
bordered
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'avatar'">
|
||||
<a-avatar :src="record.avatar || undefined">
|
||||
<template #icon><UserOutlined /></template>
|
||||
</a-avatar>
|
||||
</template>
|
||||
|
||||
<template v-if="column.dataIndex === 'phone'">
|
||||
{{ record.phone || '-' }}
|
||||
</template>
|
||||
|
||||
<template v-if="column.dataIndex === 'nickname'">
|
||||
{{ record.nickname || '-' }}
|
||||
</template>
|
||||
|
||||
<template v-if="column.dataIndex === 'is_admin'">
|
||||
<a-tag :color="record.is_admin ? 'gold' : 'default'">
|
||||
{{ record.is_admin ? '管理员' : '普通用户' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-if="column.dataIndex === 'total_spent'">
|
||||
{{ formatMoney(record.total_spent) }}
|
||||
</template>
|
||||
|
||||
<template v-if="column.dataIndex === 'registered_at'">
|
||||
{{ formatDate(record.registered_at) }}
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'actions'">
|
||||
<a-space>
|
||||
<a-tooltip title="编辑">
|
||||
<a-button type="text" @click="openEditModal(record)">
|
||||
<template #icon><EditOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip title="删除">
|
||||
<a-button type="text" danger @click="confirmDelete(record)">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</section>
|
||||
|
||||
<a-modal
|
||||
v-model:open="modalOpen"
|
||||
:confirm-loading="saving"
|
||||
:title="modalTitle"
|
||||
ok-text="保存"
|
||||
cancel-text="取消"
|
||||
@ok="submitForm"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formState"
|
||||
:rules="rules"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-form-item label="姓名" name="name">
|
||||
<a-input v-model:value="formState.name" placeholder="张三" />
|
||||
</a-form-item>
|
||||
<a-form-item label="手机号" name="phone">
|
||||
<a-input v-model:value="formState.phone" placeholder="13800000000" />
|
||||
</a-form-item>
|
||||
<a-form-item label="昵称" name="nickname">
|
||||
<a-input v-model:value="formState.nickname" placeholder="小张" />
|
||||
</a-form-item>
|
||||
<a-form-item label="头像" name="avatar">
|
||||
<a-input v-model:value="formState.avatar" placeholder="https://example.com/avatar.png" />
|
||||
</a-form-item>
|
||||
<a-form-item label="总消费" name="total_spent">
|
||||
<a-input-number
|
||||
v-model:value="formState.total_spent"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="10"
|
||||
addon-before="¥"
|
||||
class="full-width-control"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="管理员" name="is_admin">
|
||||
<a-switch
|
||||
v-model:checked="formState.is_admin"
|
||||
checked-children="是"
|
||||
un-checked-children="否"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.node.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"root":["./src/main.ts","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/http.ts","./src/api/orders.ts","./src/api/payments.ts","./src/api/products.ts","./src/api/rituals.ts","./src/api/temples.ts","./src/api/uploads.ts","./src/api/users.ts","./src/config/app.ts","./src/router/index.ts","./src/app.vue","./src/layouts/adminlayout.vue","./src/views/login/loginpage.vue","./src/views/orders/ordermanagement.vue","./src/views/products/productdetail.vue","./src/views/products/productmanagement.vue","./src/views/rituals/ritualmanagement.vue","./src/views/temples/templemanagement.vue","./src/views/todos/todomanagement.vue","./src/views/users/usermanagement.vue"],"version":"5.9.3"}
|
||||
@@ -0,0 +1,26 @@
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import { fileURLToPath, URL } from 'node:url';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '');
|
||||
const apiTarget = env.VITE_API_TARGET || 'http://127.0.0.1:8000';
|
||||
|
||||
return {
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: apiTarget,
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
Python 是一门“胶水语言”,几乎能覆盖所有开发领域:
|
||||
|
||||
py-web: Web 开发:使用 Django、Flask、FastAPI 等框架搭建网站和 RESTful API 后端。
|
||||
|
||||
数据科学与人工智能:这是 Python 最火爆的领域。用 NumPy/Pandas 做数据分析,用 Matplotlib/Seaborn 做可视化,用 TensorFlow/PyTorch 做机器学习和深度学习。
|
||||
|
||||
自动化脚本与运维:编写脚本批量处理文件、Excel/PDF,自动化测试,以及网络设备自动化运维(Netmiko)。
|
||||
|
||||
网络爬虫:用 Requests 抓取网页,用 Scrapy 框架构建大型爬虫系统,提取数据。
|
||||
|
||||
科学计算与工程:在物理、化学、生物信息学等领域替代 MATLAB 进行复杂计算和仿真。
|
||||
|
||||
游戏开发:用 Pygame 制作 2D 小游戏,或用 Godot(支持 Python 语法)开发。
|
||||
|
||||
嵌入式与物联网(IoT):在树莓派等微型电脑上用 Python 控制传感器、摄像头和电机。
|
||||
|
||||
桌面应用:用 Tkinter、PyQt、wxPython 开发跨平台的 GUI 桌面软件。
|
||||
@@ -0,0 +1,7 @@
|
||||
bcrypt>=4.1.0
|
||||
cryptography>=42.0.0
|
||||
fastapi>=0.111.0
|
||||
httpx2
|
||||
oss2>=2.18.0
|
||||
python-multipart>=0.0.9
|
||||
uvicorn[standard]>=0.30.0
|
||||
Reference in New Issue
Block a user