From 6e531cf7afb58c56a7d864eb3f019727b0ddb0d7 Mon Sep 17 00:00:00 2001 From: wenfp Date: Mon, 27 Jul 2026 09:57:35 +0800 Subject: [PATCH] =?UTF-8?q?=E9=9C=96=E9=9B=A8=E5=AF=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 8 + AGENTS.md | 115 ++ README.md | 210 +++ app/AGENTS.md | 105 ++ app/__init__.py | 1 + app/config.py | 138 ++ app/database.py | 452 ++++++ app/main.py | 45 + app/routers/__init__.py | 1 + app/routers/auth.py | 339 +++++ app/routers/orders.py | 120 ++ app/routers/payments.py | 132 ++ app/routers/products.py | 222 +++ app/routers/rituals.py | 106 ++ app/routers/temples.py | 116 ++ app/routers/todos.py | 68 + app/routers/uploads.py | 72 + app/routers/users.py | 129 ++ app/schemas.py | 282 ++++ app/security.py | 22 + app/temple_scope.py | 20 + app/wechat_pay.py | 152 ++ front/AGENTS.md | 99 ++ front/index.html | 12 + front/package.json | 26 + front/pnpm-lock.yaml | 1263 +++++++++++++++++ front/src/App.vue | 3 + front/src/api/auth.ts | 73 + front/src/api/http.ts | 39 + front/src/api/orders.ts | 46 + front/src/api/payments.ts | 25 + front/src/api/products.ts | 102 ++ front/src/api/rituals.ts | 41 + front/src/api/temples.ts | 42 + front/src/api/uploads.ts | 18 + front/src/api/users.ts | 57 + front/src/config/app.ts | 4 + front/src/layouts/AdminLayout.vue | 144 ++ front/src/main.ts | 13 + front/src/router/index.ts | 138 ++ front/src/styles.css | 279 ++++ front/src/views/login/LoginPage.vue | 148 ++ front/src/views/orders/OrderManagement.vue | 342 +++++ front/src/views/products/ProductDetail.vue | 697 +++++++++ .../src/views/products/ProductManagement.vue | 169 +++ front/src/views/rituals/RitualManagement.vue | 223 +++ front/src/views/temples/TempleManagement.vue | 229 +++ front/src/views/todos/TodoManagement.vue | 24 + front/src/views/users/UserManagement.vue | 339 +++++ front/src/vite-env.d.ts | 1 + front/tsconfig.json | 10 + front/tsconfig.node.json | 16 + front/tsconfig.tsbuildinfo | 1 + front/vite.config.ts | 26 + index.py | 1 + py_feature.md | 17 + requirements.txt | 7 + 57 files changed, 7529 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 app/AGENTS.md create mode 100644 app/__init__.py create mode 100644 app/config.py create mode 100644 app/database.py create mode 100644 app/main.py create mode 100644 app/routers/__init__.py create mode 100644 app/routers/auth.py create mode 100644 app/routers/orders.py create mode 100644 app/routers/payments.py create mode 100644 app/routers/products.py create mode 100644 app/routers/rituals.py create mode 100644 app/routers/temples.py create mode 100644 app/routers/todos.py create mode 100644 app/routers/uploads.py create mode 100644 app/routers/users.py create mode 100644 app/schemas.py create mode 100644 app/security.py create mode 100644 app/temple_scope.py create mode 100644 app/wechat_pay.py create mode 100644 front/AGENTS.md create mode 100644 front/index.html create mode 100644 front/package.json create mode 100644 front/pnpm-lock.yaml create mode 100644 front/src/App.vue create mode 100644 front/src/api/auth.ts create mode 100644 front/src/api/http.ts create mode 100644 front/src/api/orders.ts create mode 100644 front/src/api/payments.ts create mode 100644 front/src/api/products.ts create mode 100644 front/src/api/rituals.ts create mode 100644 front/src/api/temples.ts create mode 100644 front/src/api/uploads.ts create mode 100644 front/src/api/users.ts create mode 100644 front/src/config/app.ts create mode 100644 front/src/layouts/AdminLayout.vue create mode 100644 front/src/main.ts create mode 100644 front/src/router/index.ts create mode 100644 front/src/styles.css create mode 100644 front/src/views/login/LoginPage.vue create mode 100644 front/src/views/orders/OrderManagement.vue create mode 100644 front/src/views/products/ProductDetail.vue create mode 100644 front/src/views/products/ProductManagement.vue create mode 100644 front/src/views/rituals/RitualManagement.vue create mode 100644 front/src/views/temples/TempleManagement.vue create mode 100644 front/src/views/todos/TodoManagement.vue create mode 100644 front/src/views/users/UserManagement.vue create mode 100644 front/src/vite-env.d.ts create mode 100644 front/tsconfig.json create mode 100644 front/tsconfig.node.json create mode 100644 front/tsconfig.tsbuildinfo create mode 100644 front/vite.config.ts create mode 100644 index.py create mode 100644 py_feature.md create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7bf55ba --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.venv/ +.env +__pycache__/ +*.pyc +todos.sqlite3 +app.sqlite3 +front/node_modules/ +front/dist/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6b97c69 --- /dev/null +++ b/AGENTS.md @@ -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/.py` 和对应 `app/schemas.py` 模型。 +- 新增前端业务模块时,优先新增 `front/src/views//`、`front/src/api/.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` 通过,真实预下单和支付通知待填写微信商户配置后联调。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..dfc4b61 --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/app/AGENTS.md b/app/AGENTS.md new file mode 100644 index 0000000..38cdbb7 --- /dev/null +++ b/app/AGENTS.md @@ -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`。验证:后端编译通过,真实预下单和通知验签待填写微信商户配置后联调。 diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ + diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..a750480 --- /dev/null +++ b/app/config.py @@ -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, +) diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..362e8b5 --- /dev/null +++ b/app/database.py @@ -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}]', + "

祈愿平安顺遂。

", + "平安香", + "为家人祈福平安", + 1, + 32, + ), + ( + 1, + "祈福灯", + "供灯", + "立即供灯", + "随喜供灯", + 99.0, + 50, + 20, + "", + 1, + '[{"name":"一盏","price":99,"stock":50,"sort_order":1,"is_chaodu":false}]', + "

供灯祈福,愿心光明。

", + "祈福灯", + "供灯祈福,照亮善愿", + 1, + 18, + ), + ( + 2, + "南山香礼", + "香礼", + "立即支付", + "随喜", + 39.9, + 80, + 10, + "", + 1, + '[{"name":"单份","price":39.9,"stock":80,"sort_order":1,"is_chaodu":false}]', + "

南山寺香礼。

", + "南山香礼", + "南山寺祈福香礼", + 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)] diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..0d594c2 --- /dev/null +++ b/app/main.py @@ -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) diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/routers/__init__.py @@ -0,0 +1 @@ + diff --git a/app/routers/auth.py b/app/routers/auth.py new file mode 100644 index 0000000..e5e35ac --- /dev/null +++ b/app/routers/auth.py @@ -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) diff --git a/app/routers/orders.py b/app/routers/orders.py new file mode 100644 index 0000000..7c5986e --- /dev/null +++ b/app/routers/orders.py @@ -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") diff --git a/app/routers/payments.py b/app/routers/payments.py new file mode 100644 index 0000000..d97308c --- /dev/null +++ b/app/routers/payments.py @@ -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": "成功"} diff --git a/app/routers/products.py b/app/routers/products.py new file mode 100644 index 0000000..83a5e80 --- /dev/null +++ b/app/routers/products.py @@ -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") diff --git a/app/routers/rituals.py b/app/routers/rituals.py new file mode 100644 index 0000000..29177dd --- /dev/null +++ b/app/routers/rituals.py @@ -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") diff --git a/app/routers/temples.py b/app/routers/temples.py new file mode 100644 index 0000000..b975025 --- /dev/null +++ b/app/routers/temples.py @@ -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") diff --git a/app/routers/todos.py b/app/routers/todos.py new file mode 100644 index 0000000..f944446 --- /dev/null +++ b/app/routers/todos.py @@ -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") diff --git a/app/routers/uploads.py b/app/routers/uploads.py new file mode 100644 index 0000000..1c45938 --- /dev/null +++ b/app/routers/uploads.py @@ -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) diff --git a/app/routers/users.py b/app/routers/users.py new file mode 100644 index 0000000..04e7b83 --- /dev/null +++ b/app/routers/users.py @@ -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") diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..05ceca4 --- /dev/null +++ b/app/schemas.py @@ -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 diff --git a/app/security.py b/app/security.py new file mode 100644 index 0000000..5ea27df --- /dev/null +++ b/app/security.py @@ -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) diff --git a/app/temple_scope.py b/app/temple_scope.py new file mode 100644 index 0000000..11994eb --- /dev/null +++ b/app/temple_scope.py @@ -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", + ) diff --git a/app/wechat_pay.py b/app/wechat_pay.py new file mode 100644 index 0000000..258fc73 --- /dev/null +++ b/app/wechat_pay.py @@ -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) diff --git a/front/AGENTS.md b/front/AGENTS.md new file mode 100644 index 0000000..291defd --- /dev/null +++ b/front/AGENTS.md @@ -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` 通过。 diff --git a/front/index.html b/front/index.html new file mode 100644 index 0000000..3ef1013 --- /dev/null +++ b/front/index.html @@ -0,0 +1,12 @@ + + + + + + 用户管理 + + +
+ + + diff --git a/front/package.json b/front/package.json new file mode 100644 index 0000000..6c23ceb --- /dev/null +++ b/front/package.json @@ -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" + } +} diff --git a/front/pnpm-lock.yaml b/front/pnpm-lock.yaml new file mode 100644 index 0000000..29abc30 --- /dev/null +++ b/front/pnpm-lock.yaml @@ -0,0 +1,1263 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@ant-design/icons-vue': + specifier: ^7.0.1 + version: 7.0.1(vue@3.5.40(typescript@5.9.3)) + '@vueup/vue-quill': + specifier: ^1.5.5 + version: 1.5.5(vue@3.5.40(typescript@5.9.3)) + ant-design-vue: + specifier: ^4.2.6 + version: 4.2.6(vue@3.5.40(typescript@5.9.3)) + vue: + specifier: ^3.5.0 + version: 3.5.40(typescript@5.9.3) + vue-router: + specifier: ^4.5.0 + version: 4.6.4(vue@3.5.40(typescript@5.9.3)) + devDependencies: + '@types/node': + specifier: ^24.0.0 + version: 24.13.3 + '@vitejs/plugin-vue': + specifier: ^6.0.0 + version: 6.0.8(vite@7.3.6(@types/node@24.13.3))(vue@3.5.40(typescript@5.9.3)) + typescript: + specifier: ^5.8.0 + version: 5.9.3 + vite: + specifier: ^7.0.0 + version: 7.3.6(@types/node@24.13.3) + vue-tsc: + specifier: ^2.2.10 + version: 2.2.12(typescript@5.9.3) + +packages: + + '@ant-design/colors@6.0.0': + resolution: {integrity: sha512-qAZRvPzfdWHtfameEGP2Qvuf838NhergR35o+EuVyB5XvSA98xod5r4utvi4TJ3ywmevm290g9nsCG5MryrdWQ==} + + '@ant-design/icons-svg@4.5.0': + resolution: {integrity: sha512-1BTUFyKPTBZ53MuTP8s0k5SFEXL7o3VHEOwLgzaoWKwnBeqIcqUtVshc4SKzhI6uACfqhJqBwBUE9FsWR3uULA==} + + '@ant-design/icons-vue@7.0.1': + resolution: {integrity: sha512-eCqY2unfZK6Fe02AwFlDHLfoyEFreP6rBwAZMIJ1LugmfMiVgwWDYlp1YsRugaPtICYOabV1iWxXdP12u9U43Q==} + peerDependencies: + vue: '>=3.0.3' + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@ctrl/tinycolor@3.6.1': + resolution: {integrity: sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==} + engines: {node: '>=10'} + + '@emotion/hash@0.9.2': + resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} + + '@emotion/unitless@0.8.1': + resolution: {integrity: sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==} + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + + '@simonwep/pickr@1.8.2': + resolution: {integrity: sha512-/l5w8BIkrpP6n1xsetx9MWPWlU6OblN5YgZZphxan0Tq4BByTCETL6lyIeY8lagalS2Nbt4F2W034KHLIiunKA==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + + '@vitejs/plugin-vue@6.0.8': + resolution: {integrity: sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + vue: ^3.2.25 + + '@volar/language-core@2.4.15': + resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==} + + '@volar/source-map@2.4.15': + resolution: {integrity: sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==} + + '@volar/typescript@2.4.15': + resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==} + + '@vue/compiler-core@3.5.40': + resolution: {integrity: sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==} + + '@vue/compiler-dom@3.5.40': + resolution: {integrity: sha512-pwkx4vqlqOspFstrcmzwkKLePVMD3PT65imRzLhanU2V1Fj4K13g6OXjanOyzw3aTAuRk84BOmY8f3rEHqPaVA==} + + '@vue/compiler-sfc@3.5.40': + resolution: {integrity: sha512-gIf497P4kpuALcvs5n3AEg1Vdn0pSY4XbjASIfHNYF1/MP3T2Mf2STERTubysBxCRxzJGJYtF/O7vwJrxFB3Vw==} + + '@vue/compiler-ssr@3.5.40': + resolution: {integrity: sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==} + + '@vue/compiler-vue2@2.7.16': + resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==} + + '@vue/devtools-api@6.6.4': + resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} + + '@vue/language-core@2.2.12': + resolution: {integrity: sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/reactivity@3.5.40': + resolution: {integrity: sha512-B7ot9UlUZOi1zbq61/LvE88ZLTV8IlajTdiZTAEiDQgrnIMIZoPr9kGw0Zw46ObW62O9+H/Be3kMbfb7kYPQZA==} + + '@vue/runtime-core@3.5.40': + resolution: {integrity: sha512-KAZLweuZ6uUJPK1PMSQPgBU5gCjgrrfjUhSglmU9NhH+Zjepa8cnwSydPWDWHDwOgY4g3VcZ+PljbiHlURNCbw==} + + '@vue/runtime-dom@3.5.40': + resolution: {integrity: sha512-ZfrX8ssZQds900L9pr8AuK05ddnMsR4MPMZr8cPN9GoqoPWcXLhjvvbIA2SMv+7a97sJ1vv9pj/zxK0Cq/eEFQ==} + + '@vue/server-renderer@3.5.40': + resolution: {integrity: sha512-XNJym9WpevhTVt1HuwOrCRJ5Q+9z4BjTMrDtjTrvx74SmUll8spNTw6whWJa9mEkO4PKn5TihI/bm/8ds2QVJw==} + + '@vue/shared@3.5.40': + resolution: {integrity: sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==} + + '@vueup/vue-quill@1.5.5': + resolution: {integrity: sha512-ygA61ZZPaRzTHXi8H1w5td1XCtjwRsO6oBuJHdCbfotGQ7hBQukVp5dsYeVXWKuSiF8hGDKwIlROyNNrMalQCQ==} + peerDependencies: + vue: ^3.2.41 + + alien-signals@1.0.13: + resolution: {integrity: sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==} + + ant-design-vue@4.2.6: + resolution: {integrity: sha512-t7eX13Yj3i9+i5g9lqFyYneoIb3OzTvQjq9Tts1i+eiOd3Eva/6GagxBSXM1fOCjqemIu0FYVE1ByZ/38epR3Q==} + engines: {node: '>=12.22.0'} + peerDependencies: + vue: '>=3.2.0' + + array-tree-filter@2.1.0: + resolution: {integrity: sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw==} + + async-validator@4.2.5: + resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + brace-expansion@2.1.2: + resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==} + + compute-scroll-into-view@1.0.20: + resolution: {integrity: sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==} + + core-js@3.49.0: + resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + + de-indent@1.0.2: + resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} + + dom-align@1.12.4: + resolution: {integrity: sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw==} + + dom-scroll-into-view@2.0.1: + resolution: {integrity: sha512-bvVTQe1lfaUr1oFzZX80ce9KLDlZ3iU+XGNE/bz9HnGdklTieqsbmsLHe+rT2XWqopvL0PckkYqN7ksmm5pe3w==} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + fast-diff@1.3.0: + resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + is-plain-object@3.0.1: + resolution: {integrity: sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g==} + engines: {node: '>=0.10.0'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + + lodash.clonedeep@4.5.0: + resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} + + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + nanopop@2.4.2: + resolution: {integrity: sha512-NzOgmMQ+elxxHeIha+OG/Pv3Oc3p4RU2aBhwWwAqDpXrdTbtRylbRLQztLy8dMMwfl6pclznBdfUhccEn9ZIzw==} + + parchment@3.0.0: + resolution: {integrity: sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + postcss@8.5.22: + resolution: {integrity: sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==} + engines: {node: ^10 || ^12 || >=14} + + quill-delta@5.1.0: + resolution: {integrity: sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==} + engines: {node: '>= 12.0.0'} + + quill@2.0.2: + resolution: {integrity: sha512-QfazNrhMakEdRG57IoYFwffUIr04LWJxbS/ZkidRFXYCQt63c1gK6Z7IHUXMx/Vh25WgPBU42oBaNzQ0K1R/xw==} + engines: {npm: '>=8.2.3'} + + resize-observer-polyfill@1.5.1: + resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} + + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + scroll-into-view-if-needed@2.2.31: + resolution: {integrity: sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==} + + shallow-equal@1.2.1: + resolution: {integrity: sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stylis@4.4.0: + resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} + + throttle-debounce@5.0.2: + resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==} + engines: {node: '>=12.22'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + + vue-router@4.6.4: + resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==} + peerDependencies: + vue: ^3.5.0 + + vue-tsc@2.2.12: + resolution: {integrity: sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + + vue-types@3.0.2: + resolution: {integrity: sha512-IwUC0Aq2zwaXqy74h4WCvFCUtoV0iSWr0snWnE9TnU18S66GAQyqQbRf2qfJtUuiFsBf6qp0MEwdonlwznlcrw==} + engines: {node: '>=10.15.0'} + peerDependencies: + vue: ^3.0.0 + + vue@3.5.40: + resolution: {integrity: sha512-+8PJ4SJXdn/cHGImF4CKdxlWHIN5Dkt7DoufRREM6h6uVCx2m7QxgcEQmmzyOK8A9mcafg7sFbJFYsdFVubTig==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + warning@4.0.3: + resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==} + +snapshots: + + '@ant-design/colors@6.0.0': + dependencies: + '@ctrl/tinycolor': 3.6.1 + + '@ant-design/icons-svg@4.5.0': {} + + '@ant-design/icons-vue@7.0.1(vue@3.5.40(typescript@5.9.3))': + dependencies: + '@ant-design/colors': 6.0.0 + '@ant-design/icons-svg': 4.5.0 + vue: 3.5.40(typescript@5.9.3) + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/runtime@7.29.7': {} + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@ctrl/tinycolor@3.6.1': {} + + '@emotion/hash@0.9.2': {} + + '@emotion/unitless@0.8.1': {} + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@rolldown/pluginutils@1.0.1': {} + + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + + '@simonwep/pickr@1.8.2': + dependencies: + core-js: 3.49.0 + nanopop: 2.4.2 + + '@types/estree@1.0.9': {} + + '@types/node@24.13.3': + dependencies: + undici-types: 7.18.2 + + '@vitejs/plugin-vue@6.0.8(vite@7.3.6(@types/node@24.13.3))(vue@3.5.40(typescript@5.9.3))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 7.3.6(@types/node@24.13.3) + vue: 3.5.40(typescript@5.9.3) + + '@volar/language-core@2.4.15': + dependencies: + '@volar/source-map': 2.4.15 + + '@volar/source-map@2.4.15': {} + + '@volar/typescript@2.4.15': + dependencies: + '@volar/language-core': 2.4.15 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + + '@vue/compiler-core@3.5.40': + dependencies: + '@babel/parser': 7.29.7 + '@vue/shared': 3.5.40 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.40': + dependencies: + '@vue/compiler-core': 3.5.40 + '@vue/shared': 3.5.40 + + '@vue/compiler-sfc@3.5.40': + dependencies: + '@babel/parser': 7.29.7 + '@vue/compiler-core': 3.5.40 + '@vue/compiler-dom': 3.5.40 + '@vue/compiler-ssr': 3.5.40 + '@vue/shared': 3.5.40 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.22 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.40': + dependencies: + '@vue/compiler-dom': 3.5.40 + '@vue/shared': 3.5.40 + + '@vue/compiler-vue2@2.7.16': + dependencies: + de-indent: 1.0.2 + he: 1.2.0 + + '@vue/devtools-api@6.6.4': {} + + '@vue/language-core@2.2.12(typescript@5.9.3)': + dependencies: + '@volar/language-core': 2.4.15 + '@vue/compiler-dom': 3.5.40 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.40 + alien-signals: 1.0.13 + minimatch: 9.0.9 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.9.3 + + '@vue/reactivity@3.5.40': + dependencies: + '@vue/shared': 3.5.40 + + '@vue/runtime-core@3.5.40': + dependencies: + '@vue/reactivity': 3.5.40 + '@vue/shared': 3.5.40 + + '@vue/runtime-dom@3.5.40': + dependencies: + '@vue/reactivity': 3.5.40 + '@vue/runtime-core': 3.5.40 + '@vue/shared': 3.5.40 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.40': + dependencies: + '@vue/compiler-ssr': 3.5.40 + '@vue/runtime-dom': 3.5.40 + '@vue/shared': 3.5.40 + + '@vue/shared@3.5.40': {} + + '@vueup/vue-quill@1.5.5(vue@3.5.40(typescript@5.9.3))': + dependencies: + quill: 2.0.2 + quill-delta: 5.1.0 + vue: 3.5.40(typescript@5.9.3) + + alien-signals@1.0.13: {} + + ant-design-vue@4.2.6(vue@3.5.40(typescript@5.9.3)): + dependencies: + '@ant-design/colors': 6.0.0 + '@ant-design/icons-vue': 7.0.1(vue@3.5.40(typescript@5.9.3)) + '@babel/runtime': 7.29.7 + '@ctrl/tinycolor': 3.6.1 + '@emotion/hash': 0.9.2 + '@emotion/unitless': 0.8.1 + '@simonwep/pickr': 1.8.2 + array-tree-filter: 2.1.0 + async-validator: 4.2.5 + csstype: 3.2.3 + dayjs: 1.11.21 + dom-align: 1.12.4 + dom-scroll-into-view: 2.0.1 + lodash: 4.18.1 + lodash-es: 4.18.1 + resize-observer-polyfill: 1.5.1 + scroll-into-view-if-needed: 2.2.31 + shallow-equal: 1.2.1 + stylis: 4.4.0 + throttle-debounce: 5.0.2 + vue: 3.5.40(typescript@5.9.3) + vue-types: 3.0.2(vue@3.5.40(typescript@5.9.3)) + warning: 4.0.3 + + array-tree-filter@2.1.0: {} + + async-validator@4.2.5: {} + + balanced-match@1.0.2: {} + + brace-expansion@2.1.2: + dependencies: + balanced-match: 1.0.2 + + compute-scroll-into-view@1.0.20: {} + + core-js@3.49.0: {} + + csstype@3.2.3: {} + + dayjs@1.11.21: {} + + de-indent@1.0.2: {} + + dom-align@1.12.4: {} + + dom-scroll-into-view@2.0.1: {} + + entities@7.0.1: {} + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + estree-walker@2.0.2: {} + + eventemitter3@5.0.4: {} + + fast-diff@1.3.0: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fsevents@2.3.3: + optional: true + + he@1.2.0: {} + + is-plain-object@3.0.1: {} + + js-tokens@4.0.0: {} + + lodash-es@4.18.1: {} + + lodash.clonedeep@4.5.0: {} + + lodash.isequal@4.5.0: {} + + lodash@4.18.1: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.2 + + muggle-string@0.4.1: {} + + nanoid@3.3.16: {} + + nanopop@2.4.2: {} + + parchment@3.0.0: {} + + path-browserify@1.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + postcss@8.5.22: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + quill-delta@5.1.0: + dependencies: + fast-diff: 1.3.0 + lodash.clonedeep: 4.5.0 + lodash.isequal: 4.5.0 + + quill@2.0.2: + dependencies: + eventemitter3: 5.0.4 + lodash-es: 4.18.1 + parchment: 3.0.0 + quill-delta: 5.1.0 + + resize-observer-polyfill@1.5.1: {} + + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + + scroll-into-view-if-needed@2.2.31: + dependencies: + compute-scroll-into-view: 1.0.20 + + shallow-equal@1.2.1: {} + + source-map-js@1.2.1: {} + + stylis@4.4.0: {} + + throttle-debounce@5.0.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + typescript@5.9.3: {} + + undici-types@7.18.2: {} + + vite@7.3.6(@types/node@24.13.3): + dependencies: + esbuild: 0.28.1 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.22 + rollup: 4.62.2 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.3 + fsevents: 2.3.3 + + vscode-uri@3.1.0: {} + + vue-router@4.6.4(vue@3.5.40(typescript@5.9.3)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.40(typescript@5.9.3) + + vue-tsc@2.2.12(typescript@5.9.3): + dependencies: + '@volar/typescript': 2.4.15 + '@vue/language-core': 2.2.12(typescript@5.9.3) + typescript: 5.9.3 + + vue-types@3.0.2(vue@3.5.40(typescript@5.9.3)): + dependencies: + is-plain-object: 3.0.1 + vue: 3.5.40(typescript@5.9.3) + + vue@3.5.40(typescript@5.9.3): + dependencies: + '@vue/compiler-dom': 3.5.40 + '@vue/compiler-sfc': 3.5.40 + '@vue/runtime-dom': 3.5.40 + '@vue/server-renderer': 3.5.40 + '@vue/shared': 3.5.40 + optionalDependencies: + typescript: 5.9.3 + + warning@4.0.3: + dependencies: + loose-envify: 1.4.0 diff --git a/front/src/App.vue b/front/src/App.vue new file mode 100644 index 0000000..98240ae --- /dev/null +++ b/front/src/App.vue @@ -0,0 +1,3 @@ + diff --git a/front/src/api/auth.ts b/front/src/api/auth.ts new file mode 100644 index 0000000..9e49230 --- /dev/null +++ b/front/src/api/auth.ts @@ -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('/auth/captcha'); +} + +export function login(payload: LoginPayload) { + return request('/auth/login', { + method: 'POST', + json: payload, + }); +} + +export function getCurrentUser() { + return request('/auth/me'); +} + +export function logout() { + return request('/auth/logout', { + method: 'POST', + }); +} diff --git a/front/src/api/http.ts b/front/src/api/http.ts new file mode 100644 index 0000000..43a01f0 --- /dev/null +++ b/front/src/api/http.ts @@ -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(path: string, options: RequestOptions = {}): Promise { + 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(); +} diff --git a/front/src/api/orders.ts b/front/src/api/orders.ts new file mode 100644 index 0000000..3f41bc3 --- /dev/null +++ b/front/src/api/orders.ts @@ -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('/orders'); +} + +export function createOrder(payload: OrderPayload) { + return request('/orders', { + method: 'POST', + json: payload, + }); +} + +export function updateOrder(id: number, payload: Partial) { + return request(`/orders/${id}`, { + method: 'PATCH', + json: payload, + }); +} + +export function deleteOrder(id: number) { + return request(`/orders/${id}`, { + method: 'DELETE', + }); +} diff --git a/front/src/api/payments.ts b/front/src/api/payments.ts new file mode 100644 index 0000000..ce13007 --- /dev/null +++ b/front/src/api/payments.ts @@ -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('/payments/wechat/jsapi', { + method: 'POST', + json: payload, + }); +} diff --git a/front/src/api/products.ts b/front/src/api/products.ts new file mode 100644 index 0000000..892f7d0 --- /dev/null +++ b/front/src/api/products.ts @@ -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('/products'); +} + +export function getProduct(id: number) { + return request(`/products/${id}`); +} + +export function createProduct(payload: ProductPayload) { + return request('/products', { + method: 'POST', + json: payload, + }); +} + +export function updateProduct(id: number, payload: Partial) { + return request(`/products/${id}`, { + method: 'PATCH', + json: payload, + }); +} + +export function deleteProduct(id: number) { + return request(`/products/${id}`, { + method: 'DELETE', + }); +} diff --git a/front/src/api/rituals.ts b/front/src/api/rituals.ts new file mode 100644 index 0000000..ae5ee40 --- /dev/null +++ b/front/src/api/rituals.ts @@ -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('/ritual-services'); +} + +export function createRitualService(payload: RitualServicePayload) { + return request('/ritual-services', { + method: 'POST', + json: payload, + }); +} + +export function updateRitualService(id: number, payload: Partial) { + return request(`/ritual-services/${id}`, { + method: 'PATCH', + json: payload, + }); +} + +export function deleteRitualService(id: number) { + return request(`/ritual-services/${id}`, { + method: 'DELETE', + }); +} diff --git a/front/src/api/temples.ts b/front/src/api/temples.ts new file mode 100644 index 0000000..60129af --- /dev/null +++ b/front/src/api/temples.ts @@ -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('/temples'); +} + +export function createTemple(payload: TemplePayload) { + return request('/temples', { + method: 'POST', + json: payload, + }); +} + +export function updateTemple(id: number, payload: Partial) { + return request(`/temples/${id}`, { + method: 'PATCH', + json: payload, + }); +} + +export function deleteTemple(id: number) { + return request(`/temples/${id}`, { + method: 'DELETE', + }); +} diff --git a/front/src/api/uploads.ts b/front/src/api/uploads.ts new file mode 100644 index 0000000..888f727 --- /dev/null +++ b/front/src/api/uploads.ts @@ -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('/uploads/images', { + method: 'POST', + body: formData, + }); +} diff --git a/front/src/api/users.ts b/front/src/api/users.ts new file mode 100644 index 0000000..76ad5d1 --- /dev/null +++ b/front/src/api/users.ts @@ -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; + +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(query ? `/users?${query}` : '/users'); +} + +export function createUser(payload: CreateUserPayload) { + return request('/users', { + method: 'POST', + json: payload, + }); +} + +export function updateUser(id: number, payload: UpdateUserPayload) { + return request(`/users/${id}`, { + method: 'PATCH', + json: payload, + }); +} + +export function deleteUser(id: number) { + return request(`/users/${id}`, { + method: 'DELETE', + }); +} diff --git a/front/src/config/app.ts b/front/src/config/app.ts new file mode 100644 index 0000000..12f9447 --- /dev/null +++ b/front/src/config/app.ts @@ -0,0 +1,4 @@ +export const appConfig = { + name: import.meta.env.VITE_APP_NAME || '自在福田', + subtitle: '管理后台', +}; diff --git a/front/src/layouts/AdminLayout.vue b/front/src/layouts/AdminLayout.vue new file mode 100644 index 0000000..1ba9373 --- /dev/null +++ b/front/src/layouts/AdminLayout.vue @@ -0,0 +1,144 @@ + + + diff --git a/front/src/main.ts b/front/src/main.ts new file mode 100644 index 0000000..6e0cc55 --- /dev/null +++ b/front/src/main.ts @@ -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'); diff --git a/front/src/router/index.ts b/front/src/router/index.ts new file mode 100644 index 0000000..45898d2 --- /dev/null +++ b/front/src/router/index.ts @@ -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; diff --git a/front/src/styles.css b/front/src/styles.css new file mode 100644 index 0000000..b4ddf51 --- /dev/null +++ b/front/src/styles.css @@ -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; + } +} diff --git a/front/src/views/login/LoginPage.vue b/front/src/views/login/LoginPage.vue new file mode 100644 index 0000000..c91f5ef --- /dev/null +++ b/front/src/views/login/LoginPage.vue @@ -0,0 +1,148 @@ + + + diff --git a/front/src/views/orders/OrderManagement.vue b/front/src/views/orders/OrderManagement.vue new file mode 100644 index 0000000..1ddf941 --- /dev/null +++ b/front/src/views/orders/OrderManagement.vue @@ -0,0 +1,342 @@ + + + diff --git a/front/src/views/products/ProductDetail.vue b/front/src/views/products/ProductDetail.vue new file mode 100644 index 0000000..11a96be --- /dev/null +++ b/front/src/views/products/ProductDetail.vue @@ -0,0 +1,697 @@ + + + diff --git a/front/src/views/products/ProductManagement.vue b/front/src/views/products/ProductManagement.vue new file mode 100644 index 0000000..0c77da4 --- /dev/null +++ b/front/src/views/products/ProductManagement.vue @@ -0,0 +1,169 @@ + + + diff --git a/front/src/views/rituals/RitualManagement.vue b/front/src/views/rituals/RitualManagement.vue new file mode 100644 index 0000000..64b6ba9 --- /dev/null +++ b/front/src/views/rituals/RitualManagement.vue @@ -0,0 +1,223 @@ + + + diff --git a/front/src/views/temples/TempleManagement.vue b/front/src/views/temples/TempleManagement.vue new file mode 100644 index 0000000..d662af1 --- /dev/null +++ b/front/src/views/temples/TempleManagement.vue @@ -0,0 +1,229 @@ + + + diff --git a/front/src/views/todos/TodoManagement.vue b/front/src/views/todos/TodoManagement.vue new file mode 100644 index 0000000..bc67405 --- /dev/null +++ b/front/src/views/todos/TodoManagement.vue @@ -0,0 +1,24 @@ + + + diff --git a/front/src/views/users/UserManagement.vue b/front/src/views/users/UserManagement.vue new file mode 100644 index 0000000..d8751c4 --- /dev/null +++ b/front/src/views/users/UserManagement.vue @@ -0,0 +1,339 @@ + + + diff --git a/front/src/vite-env.d.ts b/front/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/front/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/front/tsconfig.json b/front/tsconfig.json new file mode 100644 index 0000000..dfe1c7a --- /dev/null +++ b/front/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.node.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.vue"] +} diff --git a/front/tsconfig.node.json b/front/tsconfig.node.json new file mode 100644 index 0000000..f967a40 --- /dev/null +++ b/front/tsconfig.node.json @@ -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 + } +} diff --git a/front/tsconfig.tsbuildinfo b/front/tsconfig.tsbuildinfo new file mode 100644 index 0000000..3c464ba --- /dev/null +++ b/front/tsconfig.tsbuildinfo @@ -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"} \ No newline at end of file diff --git a/front/vite.config.ts b/front/vite.config.ts new file mode 100644 index 0000000..40624cf --- /dev/null +++ b/front/vite.config.ts @@ -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/, ''), + }, + }, + }, + }; +}); diff --git a/index.py b/index.py new file mode 100644 index 0000000..7436e63 --- /dev/null +++ b/index.py @@ -0,0 +1 @@ +from app.main import app diff --git a/py_feature.md b/py_feature.md new file mode 100644 index 0000000..b7881ab --- /dev/null +++ b/py_feature.md @@ -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 桌面软件。 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..51f32e6 --- /dev/null +++ b/requirements.txt @@ -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