diff --git a/AGENTS.md b/AGENTS.md index 7521d4a..343d5e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,9 @@ - `app/routers/todos.py`:Todo 接口,提供 `/todos` 的增删改查。 - `app/routers/temples.py`:寺院管理接口,提供 `/temples` 的增删改查。 - `app/routers/products.py`:商品管理接口,提供 `/products` 的增删改查。 +- `app/routers/feature_icons.py`:图标管理接口,提供 `/feature-icons` 的增删改查和跨寺院复制。 +- `app/routers/ad_slots.py`:广告位管理接口,提供广告位置 `/ad-slots` 的增删改查。 +- `app/routers/ads.py`:广告管理接口,提供绑定广告位的 `/ads` 增删改查。 - `app/routers/orders.py`:订单管理接口,提供 `/orders` 的增删改查。 - `app/routers/payments.py`:微信支付接口,提供 JSAPI 预下单和支付通知处理。 - `app/routers/deploy.py`:自动部署 webhook 接口,供 Gitea 推送后触发前端构建。 @@ -39,6 +42,9 @@ DATABASE_PATH=/tmp/py-web.sqlite3 .venv/bin/uvicorn index:app --reload - `front/src/views/temples/TempleManagement.vue`:寺院管理页面。 - `front/src/views/products/ProductManagement.vue`:商品管理页面。 - `front/src/views/products/ProductDetail.vue`:商品新建和编辑页面。 +- `front/src/views/feature-icons/FeatureIconManagement.vue`:图标管理页面。 +- `front/src/views/ad-slots/AdSlotManagement.vue`:广告位管理页面。 +- `front/src/views/ads/AdManagement.vue`:广告管理页面。 - `front/src/views/orders/OrderManagement.vue`:订单管理页面。 - `front/src/views/rituals/RitualManagement.vue`:法事管理页面。 - `front/src/views/users/UserManagement.vue`:用户管理页面,调用后端 `/users` 接口。 @@ -47,6 +53,9 @@ DATABASE_PATH=/tmp/py-web.sqlite3 .venv/bin/uvicorn index:app --reload - `front/src/api/users.ts`:用户接口封装。 - `front/src/api/uploads.ts`:图片上传接口封装。 - `front/src/api/payments.ts`:微信支付接口封装。 +- `front/src/api/featureIcons.ts`:图标管理接口封装。 +- `front/src/api/adSlots.ts`:广告位管理接口封装。 +- `front/src/api/ads.ts`:广告管理接口封装。 前端更细的模块约定见 `front/AGENTS.md`,后端更细的模块约定见 `app/AGENTS.md`。 @@ -58,6 +67,9 @@ DATABASE_PATH=/tmp/py-web.sqlite3 .venv/bin/uvicorn index:app --reload /products 商品管理 /products/create 新建商品 /products/:productId/edit 编辑商品 +/feature-icons 图标管理 +/ad-slots 广告位管理 +/ads 广告管理 /orders 订单管理 /ritual-services 法事管理 /users 用户管理 @@ -66,6 +78,7 @@ DATABASE_PATH=/tmp/py-web.sqlite3 .venv/bin/uvicorn index:app --reload 前端通过 Vite 代理把 `/api/*` 转发到 FastAPI 后端,默认目标是 `http://127.0.0.1:8000`。 商品、订单、法事属于寺院隔离数据。前端通过顶部寺院选择器维护当前寺院,并在请求头 `X-Temple-Id` 中传给后端;后端按该寺院过滤数据。 +图标、广告位和广告同样属于寺院隔离数据;广告位是首页广告、详情页广告、支付页广告等位置定义,广告是绑定到广告位的投放内容;图标支持从其他寺院复制到当前寺院,应用类型图标复制后需重新选择当前寺院商品。 ## 依赖与运行约定 @@ -116,3 +129,6 @@ pnpm dev - 2026-07-27:新增 Gitea 自动部署 webhook,后端 `/deploy/webhook` 校验 Gitea HMAC 签名或部署 token 后执行 `scripts/deploy_front.sh`,脚本负责拉取代码、安装前端依赖并执行 `pnpm build`;新增 `.env.example` 记录部署环境变量。验证:后端编译通过,webhook 密钥与 Gitea 签名校验测试通过。 - 2026-07-27:自动部署 webhook 从同步构建调整为后台构建,接口校验通过后立即返回 `202 accepted`,后台执行结果写入 `DEPLOY_LOG_PATH`,避免 Gitea Delivery 等待前端构建超时。验证:后端编译通过,webhook 后台任务触发测试通过。 - 2026-07-27:移除前端 Todo 占位模块,删除 `/todos` 前端路由、侧边栏入口和占位页面;后端 Todo 接口保留。验证:前端类型检查和构建通过。 +- 2026-07-27:新增图标管理和广告位管理模块,后端新增 `feature_icons`、`ad_slots` 表及寺院隔离 CRUD 接口,图标支持跨寺院复制;前端新增图标管理、广告位管理 API、页面、路由和菜单入口,图片上传复用 OSS 上传接口。验证:后端编译通过,前端 `pnpm typecheck` 和 `pnpm build` 通过。 +- 2026-07-27:广告模型拆分为广告位和广告,广告位仅表示首页广告、详情页广告、支付页广告等位置,新增 `ads` 表和 `/ads` 接口维护投放内容并绑定广告位;前端新增广告管理菜单和页面,广告位页面改为位置配置。验证:后端编译通过,广告位与广告接口冒烟测试通过,前端 `pnpm typecheck` 和 `pnpm build` 通过。 +- 2026-07-27:优化前端构建拆包,路由页面改为懒加载,Ant Design Vue 改为组件级 ES 导入以支持 tree shaking,Vite `manualChunks` 拆分 Vue、Antd、图标、富文本编辑器和公共依赖包,并按实际常用组件包设置体积提示阈值。验证:`pnpm typecheck` 和 `pnpm build` 通过。 diff --git a/app/AGENTS.md b/app/AGENTS.md index 694dfab..6bd00b9 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -10,6 +10,9 @@ - `routers/todos.py`:Todo 管理接口。 - `routers/temples.py`:寺院管理接口。 - `routers/products.py`:商品管理接口,按寺院隔离。 +- `routers/feature_icons.py`:图标管理接口,按寺院隔离,支持跨寺院复制图标。 +- `routers/ad_slots.py`:广告位管理接口,按寺院隔离,只维护广告位置定义。 +- `routers/ads.py`:广告管理接口,按寺院隔离,广告内容绑定到广告位。 - `routers/orders.py`:订单管理接口,按寺院隔离。 - `routers/payments.py`:微信支付接口,按寺院隔离发起 JSAPI 预下单并处理支付通知。 - `routers/deploy.py`:自动部署 webhook 接口,校验 Gitea 签名或部署 token 后触发前端构建脚本。 @@ -68,6 +71,14 @@ total_spent 总消费 名称、单价、库存、排序、是否超度 ``` +## 图标与广告位模型 + +- 图标存储在 `feature_icons` 表,字段包含图标名称、图标地址、跳转类型、跳转链接、关联商品、排序号、是否启用,并按 `temple_id` 隔离。 +- 图标跳转类型为 `link` 或 `app`;`link` 必须填写跳转地址,`app` 必须选择当前寺院商品。 +- 图标支持从其他寺院复制到当前寺院;应用类型图标复制后不直接跨寺院关联来源商品,需要重新选择当前寺院商品。 +- 广告位存储在 `ad_slots` 表,表示首页广告、详情页广告、支付页广告等位置定义,字段包含名称、编码、说明、排序号、是否启用,并按 `temple_id` 隔离。 +- 广告内容存储在 `ads` 表,字段包含广告位、广告名称、广告图、跳转类型、跳转链接、关联商品、排序号、是否启用,并按 `temple_id` 隔离。 + ## 上传与 OSS 配置 图片上传接口为 `/uploads/images`,用于商品封面、商品图片等图片资源。OSS 配置预留在 `config.py`,可直接填写或通过同名环境变量覆盖: @@ -114,3 +125,5 @@ OSS_ENDPOINT、OSS_BUCKET_NAME、OSS_ACCESS_KEY_ID、OSS_ACCESS_KEY_SECRET、OSS - 2026-07-23:新增微信支付 JSAPI 预下单和支付通知处理,增加 `payment_transactions` 支付流水表、微信支付 API v3 签名/通知验签/回调解密工具和配置占位;`requirements.txt` 新增 `cryptography`。验证:后端编译通过,真实预下单和通知验签待填写微信商户配置后联调。 - 2026-07-27:新增自动部署 webhook 模块,`/deploy/webhook` 支持 Gitea HMAC 签名校验、部署分支过滤、并发锁和构建脚本超时控制,`scripts/deploy_front.sh` 负责拉取代码并构建前端。验证:后端编译通过,webhook 密钥与 Gitea 签名校验测试通过。 - 2026-07-27:自动部署 webhook 改为后台执行构建,接口在签名校验、分支检查和加锁后立即返回 `202 accepted`,后台任务将构建结果写入部署日志,避免 Gitea Delivery 因等待响应超时。验证:后端编译通过,webhook 后台任务触发测试通过。 +- 2026-07-27:新增图标管理和广告位管理后端模块,增加 `feature_icons`、`ad_slots` 表和 `/feature-icons`、`/ad-slots` 寺院隔离 CRUD 接口,图标支持从其他寺院复制。验证:后端编译通过。 +- 2026-07-27:广告模型拆分为广告位和广告,`ad_slots` 改为位置定义,新增 `ads` 表和 `/ads` CRUD 接口,广告内容必须绑定当前寺院广告位。验证:后端编译通过,广告位与广告接口冒烟测试通过。 diff --git a/app/database.py b/app/database.py index 362e8b5..619babd 100644 --- a/app/database.py +++ b/app/database.py @@ -150,6 +150,41 @@ def ensure_product_schema(connection: sqlite3.Connection) -> None: connection.execute(f"ALTER TABLE products ADD COLUMN {column_name} {definition}") +def ensure_ad_slot_schema(connection: sqlite3.Connection) -> None: + columns = { + row["name"] if isinstance(row, sqlite3.Row) else row[1] + for row in connection.execute("PRAGMA table_info(ad_slots)").fetchall() + } + additions = { + "name": "TEXT", + "code": "TEXT", + "description": "TEXT", + "title": "TEXT", + "image_url": "TEXT", + "target_type": "TEXT NOT NULL DEFAULT 'link'", + "target_url": "TEXT", + "product_id": "INTEGER", + "sort_order": "INTEGER NOT NULL DEFAULT 0", + "is_active": "INTEGER NOT NULL DEFAULT 1", + } + for column_name, definition in additions.items(): + if column_name not in columns: + connection.execute(f"ALTER TABLE ad_slots ADD COLUMN {column_name} {definition}") + + connection.execute("UPDATE ad_slots SET name = title WHERE (name IS NULL OR name = '') AND title IS NOT NULL") + connection.execute("UPDATE ad_slots SET name = '广告位' || id WHERE name IS NULL OR name = ''") + connection.execute("UPDATE ad_slots SET code = 'slot_' || id WHERE code IS NULL OR code = ''") + connection.execute("UPDATE ad_slots SET sort_order = 0 WHERE sort_order IS NULL") + connection.execute("UPDATE ad_slots SET is_active = 1 WHERE is_active IS NULL") + connection.execute( + """ + CREATE UNIQUE INDEX IF NOT EXISTS idx_ad_slots_temple_code_unique + ON ad_slots(temple_id, code) + WHERE code IS NOT NULL AND code != '' + """ + ) + + def init_db() -> None: DATABASE_PATH.parent.mkdir(parents=True, exist_ok=True) @@ -191,6 +226,63 @@ def init_db() -> None: """ ) ensure_product_schema(connection) + connection.execute( + """ + CREATE TABLE IF NOT EXISTS feature_icons ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + temple_id INTEGER NOT NULL, + title TEXT NOT NULL, + icon_url TEXT NOT NULL, + target_type TEXT NOT NULL DEFAULT 'link', + target_url TEXT, + product_id INTEGER, + sort_order INTEGER NOT NULL DEFAULT 0, + is_active INTEGER NOT NULL DEFAULT 1, + FOREIGN KEY (temple_id) REFERENCES temples(id), + FOREIGN KEY (product_id) REFERENCES products(id) + ) + """ + ) + connection.execute( + """ + CREATE TABLE IF NOT EXISTS ad_slots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + temple_id INTEGER NOT NULL, + name TEXT, + code TEXT, + description TEXT, + title TEXT, + image_url TEXT, + target_type TEXT NOT NULL DEFAULT 'link', + target_url TEXT, + product_id INTEGER, + sort_order INTEGER NOT NULL DEFAULT 0, + is_active INTEGER NOT NULL DEFAULT 1, + FOREIGN KEY (temple_id) REFERENCES temples(id), + FOREIGN KEY (product_id) REFERENCES products(id) + ) + """ + ) + ensure_ad_slot_schema(connection) + connection.execute( + """ + CREATE TABLE IF NOT EXISTS ads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + temple_id INTEGER NOT NULL, + slot_id INTEGER NOT NULL, + title TEXT NOT NULL, + image_url TEXT NOT NULL, + target_type TEXT NOT NULL DEFAULT 'link', + target_url TEXT, + product_id INTEGER, + sort_order INTEGER NOT NULL DEFAULT 0, + is_active INTEGER NOT NULL DEFAULT 1, + FOREIGN KEY (temple_id) REFERENCES temples(id), + FOREIGN KEY (slot_id) REFERENCES ad_slots(id), + FOREIGN KEY (product_id) REFERENCES products(id) + ) + """ + ) connection.execute( """ CREATE TABLE IF NOT EXISTS ritual_services ( diff --git a/app/main.py b/app/main.py index 23a76b0..5b2acc4 100644 --- a/app/main.py +++ b/app/main.py @@ -2,7 +2,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.database import lifespan -from app.routers import auth, deploy, orders, payments, products, rituals, temples, todos, uploads, users +from app.routers import ad_slots, ads, auth, deploy, feature_icons, orders, payments, products, rituals, temples, todos, uploads, users app = FastAPI( @@ -39,6 +39,9 @@ app.include_router(auth.router) app.include_router(users.router) app.include_router(temples.router) app.include_router(products.router) +app.include_router(feature_icons.router) +app.include_router(ad_slots.router) +app.include_router(ads.router) app.include_router(orders.router) app.include_router(payments.router) app.include_router(rituals.router) diff --git a/app/schemas.py b/app/schemas.py index 05ceca4..86f68cd 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -212,6 +212,152 @@ class UploadResponse(BaseModel): object_key: str +class FeatureIconBase(BaseModel): + title: str = Field(..., min_length=1, max_length=80) + icon_url: str = Field(..., min_length=1, max_length=500) + target_type: str = Field(default="link", pattern=r"^(link|app)$") + target_url: str | None = Field(default=None, max_length=500) + product_id: int | None = Field(default=None, ge=1) + sort_order: int = Field(default=0, ge=0) + is_active: bool = True + + @field_validator("target_url") + @classmethod + def trim_target_url(cls, value: str | None) -> str | None: + if value is None: + return None + value = value.strip() + return value or None + + +class FeatureIconCreate(FeatureIconBase): + pass + + +class FeatureIconUpdate(BaseModel): + title: str | None = Field(default=None, min_length=1, max_length=80) + icon_url: str | None = Field(default=None, min_length=1, max_length=500) + target_type: str | None = Field(default=None, pattern=r"^(link|app)$") + target_url: str | None = Field(default=None, max_length=500) + product_id: int | None = Field(default=None, ge=1) + sort_order: int | None = Field(default=None, ge=0) + is_active: bool | None = None + + @field_validator("target_url") + @classmethod + def trim_target_url(cls, value: str | None) -> str | None: + if value is None: + return None + value = value.strip() + return value or None + + +class FeatureIcon(BaseModel): + id: int + temple_id: int + title: str + icon_url: str + target_type: str + target_url: str | None = None + product_id: int | None = None + product_name: str | None = None + sort_order: int = 0 + is_active: bool = True + + +class FeatureIconCopyRequest(BaseModel): + source_temple_id: int = Field(..., ge=1) + icon_ids: list[int] = Field(..., min_length=1) + + +class AdSlotBase(BaseModel): + name: str = Field(..., min_length=1, max_length=100) + code: str = Field(..., min_length=1, max_length=80, pattern=r"^[a-zA-Z0-9_-]+$") + description: str | None = Field(default=None, max_length=300) + sort_order: int = Field(default=0, ge=0) + is_active: bool = True + + +class AdSlotCreate(AdSlotBase): + pass + + +class AdSlotUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=100) + code: str | None = Field(default=None, min_length=1, max_length=80, pattern=r"^[a-zA-Z0-9_-]+$") + description: str | None = Field(default=None, max_length=300) + sort_order: int | None = Field(default=None, ge=0) + is_active: bool | None = None + + +class AdSlot(BaseModel): + id: int + temple_id: int + name: str + code: str + description: str | None = None + sort_order: int = 0 + is_active: bool = True + + +class AdBase(BaseModel): + slot_id: int = Field(..., ge=1) + title: str = Field(..., min_length=1, max_length=100) + image_url: str = Field(..., min_length=1, max_length=500) + target_type: str = Field(default="link", pattern=r"^(link|app)$") + target_url: str | None = Field(default=None, max_length=500) + product_id: int | None = Field(default=None, ge=1) + sort_order: int = Field(default=0, ge=0) + is_active: bool = True + + @field_validator("target_url") + @classmethod + def trim_target_url(cls, value: str | None) -> str | None: + if value is None: + return None + value = value.strip() + return value or None + + +class AdCreate(AdBase): + pass + + +class AdUpdate(BaseModel): + slot_id: int | None = Field(default=None, ge=1) + title: str | None = Field(default=None, min_length=1, max_length=100) + image_url: str | None = Field(default=None, min_length=1, max_length=500) + target_type: str | None = Field(default=None, pattern=r"^(link|app)$") + target_url: str | None = Field(default=None, max_length=500) + product_id: int | None = Field(default=None, ge=1) + sort_order: int | None = Field(default=None, ge=0) + is_active: bool | None = None + + @field_validator("target_url") + @classmethod + def trim_target_url(cls, value: str | None) -> str | None: + if value is None: + return None + value = value.strip() + return value or None + + +class Ad(BaseModel): + id: int + temple_id: int + slot_id: int + slot_name: str | None = None + slot_code: str | None = None + title: str + image_url: str + target_type: str + target_url: str | None = None + product_id: int | None = None + product_name: str | None = None + sort_order: int = 0 + is_active: bool = True + + class RitualServiceCreate(BaseModel): name: str = Field(..., min_length=1, max_length=120) description: str | None = Field(default=None, max_length=500) diff --git a/front/AGENTS.md b/front/AGENTS.md index f85e6c4..7733a80 100644 --- a/front/AGENTS.md +++ b/front/AGENTS.md @@ -18,6 +18,9 @@ - `src/views/temples/TempleManagement.vue`:寺院管理页。 - `src/views/products/ProductManagement.vue`:商品管理列表页,按当前寺院隔离。 - `src/views/products/ProductDetail.vue`:商品新建和编辑页,按当前寺院隔离。 +- `src/views/feature-icons/FeatureIconManagement.vue`:图标管理页,按当前寺院隔离,支持同步其他寺院图标。 +- `src/views/ad-slots/AdSlotManagement.vue`:广告位管理页,按当前寺院隔离,只维护广告位置定义。 +- `src/views/ads/AdManagement.vue`:广告管理页,按当前寺院隔离,广告内容绑定到广告位。 - `src/views/orders/OrderManagement.vue`:订单管理页,按当前寺院隔离。 - `src/views/rituals/RitualManagement.vue`:法事管理页,按当前寺院隔离。 - `src/views/users/UserManagement.vue`:用户管理页。 @@ -26,6 +29,9 @@ - `src/api/users.ts`:用户接口封装。 - `src/api/temples.ts`:寺院接口封装。 - `src/api/products.ts`:商品接口封装。 +- `src/api/featureIcons.ts`:图标管理接口封装。 +- `src/api/adSlots.ts`:广告位管理接口封装。 +- `src/api/ads.ts`:广告管理接口封装。 - `src/api/uploads.ts`:图片上传接口封装。 - `src/api/orders.ts`:订单接口封装。 - `src/api/payments.ts`:微信支付接口封装。 @@ -39,12 +45,16 @@ /products 商品管理 /products/create 新建商品 /products/:productId/edit 编辑商品 +/feature-icons 图标管理 +/ad-slots 广告位管理 +/ads 广告管理 /orders 订单管理 /ritual-services 法事管理 /users 用户管理 ``` 商品、订单、法事页面使用当前寺院上下文访问数据。当前寺院由后台顶部选择器维护,`src/api/http.ts` 会把 `currentTempleId` 写入请求头 `X-Temple-Id`。 +图标、广告位和广告页面也使用当前寺院上下文访问数据;广告位表示首页广告、详情页广告、支付页广告等位置,广告内容必须绑定到广告位;图标同步功能从其他寺院读取图标后复制到当前寺院。 后台页面通过路由守卫检查登录状态;认证令牌只存在后端设置的 HttpOnly Cookie 中,前端 localStorage 只缓存当前用户展示信息,不保存 token。 @@ -72,6 +82,7 @@ - 新增前端依赖时只写入 `package.json`,由用户执行 `pnpm install`。 - `node_modules` 不提交。 +- Vite 构建通过路由懒加载和 `manualChunks` 拆分 Vue、Ant Design Vue、图标、富文本编辑器和公共依赖;Ant Design Vue 组件在 `src/main.ts` 使用 `ant-design-vue/es/*` 子路径导入以支持 tree shaking。 ## 验证 @@ -96,3 +107,6 @@ 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` 通过。 - 2026-07-27:移除 Todo 占位页面,删除 `/todos` 前端路由和侧边栏菜单入口。验证:`pnpm typecheck` 和 `pnpm build` 通过。 +- 2026-07-27:新增图标管理和广告位管理页面、API、路由和侧边栏入口;图标支持上传 120px * 120px 提示、链接/应用类型切换、商品选择和同步其他寺院图标。验证:`pnpm typecheck` 和 `pnpm build` 通过。 +- 2026-07-27:广告模型拆分为广告位和广告,广告位页面改为位置配置,新增广告管理 API、页面、路由和菜单入口,广告内容绑定广告位并支持图片上传与链接/应用跳转。验证:`pnpm typecheck` 和 `pnpm build` 通过。 +- 2026-07-27:优化前端构建包体积,路由页面改为懒加载,Ant Design Vue 改为组件级 ES 导入,Vite 增加 `manualChunks` 拆分常用依赖包并设置合理体积提示阈值。验证:`pnpm typecheck` 和 `pnpm build` 通过。 diff --git a/front/src/layouts/AdminLayout.vue b/front/src/layouts/AdminLayout.vue index 7d1ad82..e1f60d1 100644 --- a/front/src/layouts/AdminLayout.vue +++ b/front/src/layouts/AdminLayout.vue @@ -1,8 +1,10 @@