霖雨寺

This commit is contained in:
wenfp
2026-07-27 09:57:35 +08:00
commit 6e531cf7af
57 changed files with 7529 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
.venv/
.env
__pycache__/
*.pyc
todos.sqlite3
app.sqlite3
front/node_modules/
front/dist/
+115
View File
@@ -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` 通过,真实预下单和支付通知待填写微信商户配置后联调。
+210
View File
@@ -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
View File
@@ -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`。验证:后端编译通过,真实预下单和通知验签待填写微信商户配置后联调。
+1
View File
@@ -0,0 +1 @@
+138
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
+339
View File
@@ -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)
+120
View File
@@ -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")
+132
View File
@@ -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": "成功"}
+222
View File
@@ -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")
+106
View File
@@ -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")
+116
View File
@@ -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")
+68
View File
@@ -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")
+72
View File
@@ -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)
+129
View File
@@ -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
View File
@@ -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
+22
View File
@@ -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)
+20
View File
@@ -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",
)
+152
View File
@@ -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)
+99
View File
@@ -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` 通过。
+12
View File
@@ -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>
+26
View File
@@ -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"
}
}
+1263
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
<template>
<router-view />
</template>
+73
View File
@@ -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',
});
}
+39
View File
@@ -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();
}
+46
View File
@@ -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',
});
}
+25
View File
@@ -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,
});
}
+102
View File
@@ -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',
});
}
+41
View File
@@ -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',
});
}
+42
View File
@@ -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',
});
}
+18
View File
@@ -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,
});
}
+57
View File
@@ -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',
});
}
+4
View File
@@ -0,0 +1,4 @@
export const appConfig = {
name: import.meta.env.VITE_APP_NAME || '自在福田',
subtitle: '管理后台',
};
+144
View File
@@ -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>
+13
View File
@@ -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');
+138
View File
@@ -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;
+279
View File
@@ -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;
}
}
+148
View File
@@ -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>
+342
View File
@@ -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>
+697
View File
@@ -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>
+24
View File
@@ -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>
+339
View File
@@ -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>
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.node.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}
+16
View File
@@ -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
}
}
+1
View File
@@ -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"}
+26
View File
@@ -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/, ''),
},
},
},
};
});
+1
View File
@@ -0,0 +1 @@
from app.main import app
+17
View File
@@ -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 桌面软件。
+7
View File
@@ -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