提交
This commit is contained in:
1 parent
c87732fcd5
commit
058811ae77
107 files changed
+16983
-12758
No files matched your search
+16
-2
@@ -19,7 +19,21 @@
|
||||
|
||||
- Vue 3 + Element Plus;主题与皮肤通过 **CSS 变量 + Element 主题变量 + 全局覆盖** 对齐规范,**勿随意改 Token 色值**。
|
||||
- 树形主数据全路径:`web-admin/src/utils/tree-path.ts`(如 `buildTreePathLabelFromFlat`、`buildPathLabelMapFromTreeRoots`、`resolveTreePathDisplay`)。路径分隔符统一为 **` / `**(半角斜杠两侧各一空格)。
|
||||
- 参考实现:商品查看弹窗 `web-admin/src/components/business/product-view-dialog/index.vue`;列表与分页对齐 `#/base/unit` 等已改造页。
|
||||
|
||||
### 页面级 UI 模板(固定五页 + 工作台入口)
|
||||
|
||||
上述页面均挂在侧栏 **「Demo」** 菜单下。新开发的列表/表单页**对照以下页面实现**,勿另起一套布局与类名习惯;仍须同时遵守 `docs/web-admin-ui-spec-fresh-mint.md`。
|
||||
|
||||
| 用途 | Hash 路由 | 源码 |
|
||||
|------|-----------|------|
|
||||
| 工作台(入口与说明) | `/#/demo/console` | `web-admin/src/views/dashboard/console/index.vue` |
|
||||
| 标准列表(筛选、工具栏、表格、分页) | `/#/demo/unit` | `web-admin/src/views/system/unit/index.vue` |
|
||||
| 复杂业务列表(状态、操作列等) | `/#/demo/order` | `web-admin/src/views/system/order/index.vue` |
|
||||
| 商品类列表与查看弹窗联动 | `/#/demo/product` | `web-admin/src/views/system/product/index.vue` |
|
||||
| 树形主数据 | `/#/demo/category` | `web-admin/src/views/system/category/index.vue` |
|
||||
| 系统用户类 CRUD | `/#/demo/user` | `web-admin/src/views/system/user/index.vue` |
|
||||
|
||||
商品**只读查看弹窗**结构对齐:`web-admin/src/components/business/product-view-dialog/index.vue`(由商品管理页打开,可与上表第三行一并参照)。
|
||||
|
||||
### 视觉与 Token(与规范一致)
|
||||
|
||||
@@ -48,7 +62,7 @@
|
||||
- 工具栏:**「已选 N 项」在最前**,其后从左到右排列主按钮与批量操作;**禁止**用 `space-between` 把整组按钮推到行末。
|
||||
- **同一行仅第一个主行动** 使用主色实心(`type="primary"` / `btn-main`);批量删除、导入导出等为中性描边。
|
||||
- **操作列**:`fixed="right"`,预留足够宽度(建议 min-width ≥ 260px),避免按钮被裁切。
|
||||
- 主表:`list-table` + `stripe`;与已对齐订单列表一致时 **勿随意加 `border`**(以规范及参考页为准)。
|
||||
- 主表:`list-table` + `stripe`;与参考页「订单列表」`/#/demo/order` 一致时 **勿随意加 `border`**(以规范及上表模板页为准)。
|
||||
- 分页:表格与 **`el-pagination`** 同放在 **`list-body.list-wrap`** 内;外层 **`.pagination.list-pager-bar`**,`margin-top: 0`,上边框与 padding 见规范第 7.9 节。
|
||||
|
||||
### 树形选择器与查看态
|
||||
|
||||
+11
-2
@@ -1,16 +1,25 @@
|
||||
# 数据库连接配置示例
|
||||
# 数据库连接配置示例(V0.3 库名示例见 docs/sql/zkxw-requirements-v0.3-schema.sql)
|
||||
# DATABASE_URL 格式:mysql+pymysql://user:password@host:port/dbname
|
||||
DATABASE_URL=mysql+pymysql://root:password@localhost:3306/zkxw
|
||||
|
||||
# 与 sys_user.tenant_no 一致;登录按 (tenant_no, user_no) 查用户
|
||||
# DEFAULT_TENANT_NO=zkxw
|
||||
# 用户无 sys_user_role 记录时,Web 管理端 JWT 使用的默认角色(super_admin 可全量菜单)
|
||||
# WEB_AUTH_DEFAULT_ROLE=super_admin
|
||||
|
||||
# JWT配置示例
|
||||
# SECRET_KEY=your-secret-key-change-in-production
|
||||
# ALGORITHM=HS256
|
||||
# ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
|
||||
# 应用配置示例
|
||||
# APP_NAME=中科兴皖一体化门户
|
||||
# APP_NAME=陇耘鲜达
|
||||
# DEBUG=true
|
||||
|
||||
# 浏览器跨域:若设置 BACKEND_CORS_ORIGINS,须为 JSON 数组,会整体覆盖代码默认值,例如:
|
||||
# BACKEND_CORS_ORIGINS=["http://localhost:5173","http://localhost:3006","http://127.0.0.1:3006"]
|
||||
# DEBUG=true 时后端还会允许任意本机端口的 Origin(localhost / 127.0.0.1),便于本地换端口开发。
|
||||
|
||||
# 登录 RSA(Web/小程序加密登录;多实例须配置同一私钥 PEM)
|
||||
# LOGIN_RSA_PRIVATE_KEY_PEM="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
|
||||
# 仅本地用 Swagger/Apifox 明文测登录时可临时打开(生产务必 false)
|
||||
|
||||
+18
-9
@@ -1,6 +1,8 @@
|
||||
# 中科兴皖一体化门户后端(空壳)
|
||||
# zkxw 后端骨架(FastAPI)
|
||||
|
||||
当前 `app.main` **不连接数据库、不注册任何业务路由**,仅提供根路径与 `/health`,便于占位或 CI。原 `app/api` 下 Python 文件已删除(请从 git 历史恢复)。`app/models`、`app/crud` 等目录仍保留为未引用代码,可按需继续清理。
|
||||
基于 FastAPI 的精简后端:**Web 鉴权**(登录 RSA、登录、刷新 Token、登出、当前用户、菜单 code)、**静态运营菜单树**、**运营端路由占位**。原叁强鲜配业务 API 已移除,可按模块在 `app/api/admin.py` 等位置扩展。
|
||||
|
||||
数据库最小初始化见:`docs/sql/zkxw-backend-skeleton.sql`(默认账号 `admin` / `111111`,角色 `super_admin`)。
|
||||
|
||||
## 技术栈
|
||||
|
||||
@@ -15,12 +17,19 @@
|
||||
```
|
||||
backend/
|
||||
├── app/
|
||||
│ ├── api/ # 包占位(路由实现已删除)
|
||||
│ ├── api/ # API 路由
|
||||
│ │ ├── admin/ # 运营端接口
|
||||
│ │ ├── supplier/ # 供应商端接口
|
||||
│ │ ├── client/ # 客户端接口
|
||||
│ │ └── deliver/ # 配送员端接口
|
||||
│ ├── core/ # 核心配置
|
||||
│ ├── models/ # 数据库模型
|
||||
│ ├── schemas/ # Pydantic schemas
|
||||
│ ├── services/ # 业务逻辑
|
||||
│ └── main.py # 应用入口
|
||||
│ │ ├── config.py # 应用配置
|
||||
│ │ ├── security.py # 安全认证
|
||||
│ │ └── database.py # 数据库连接
|
||||
│ ├── models/ # 数据库模型
|
||||
│ ├── schemas/ # Pydantic schemas
|
||||
│ ├── services/ # 业务逻辑
|
||||
│ └── main.py # 应用入口
|
||||
├── requirements.txt
|
||||
└── README.md
|
||||
```
|
||||
@@ -47,7 +56,7 @@ pip install -r requirements.txt
|
||||
|
||||
```env
|
||||
# 数据库配置
|
||||
DATABASE_URL=mysql+pymysql://root:password@localhost:3306/zkxw
|
||||
DATABASE_URL=mysql+pymysql://root:password@localhost:3306/sanqiang
|
||||
|
||||
# JWT配置
|
||||
SECRET_KEY=your-secret-key-change-in-production
|
||||
@@ -55,7 +64,7 @@ ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
|
||||
# 应用配置
|
||||
APP_NAME=中科兴皖一体化门户
|
||||
APP_NAME=陇耘鲜达
|
||||
DEBUG=true
|
||||
```
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
11632
|
||||
47224
|
||||
@@ -1 +1 @@
|
||||
# 中科兴皖一体化门户后端(骨架)
|
||||
# 陇耘鲜达后端
|
||||
@@ -1,3 +1,10 @@
|
||||
"""API 包:业务路由模块已删除,仅保留包占位。恢复历史实现请使用 git。"""
|
||||
"""API 路由包(骨架:公共鉴权 + 静态菜单 + 运营端占位)"""
|
||||
from app.api.public import router as public_router
|
||||
from app.api.menu import router as menu_router
|
||||
from app.api.admin import router as admin_router
|
||||
|
||||
__all__: list[str] = []
|
||||
__all__ = [
|
||||
"public_router",
|
||||
"menu_router",
|
||||
"admin_router",
|
||||
]
|
||||
@@ -0,0 +1,160 @@
|
||||
"""运营端管理接口(租户、角色下拉等)"""
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.menu_guard import require_admin_or_super_admin
|
||||
from app.core.response import success_response, error_response
|
||||
from app.crud import tenant as tenant_svc
|
||||
from app.models.models import SysRole
|
||||
from app.services.web_auth_roles import _map_role_name_to_web
|
||||
|
||||
router = APIRouter(prefix="/web/admin", tags=["运营端管理"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 角色下拉(用户弹窗等)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/roles")
|
||||
async def list_roles_for_select(
|
||||
_user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""返回运营端角色下拉选项(label=库中角色名,value=Web 角色码)。"""
|
||||
rows: List[SysRole] = (
|
||||
db.query(SysRole)
|
||||
.filter(SysRole.del_flag == 0, SysRole.status == "1")
|
||||
.order_by(SysRole.order_num.asc())
|
||||
.all()
|
||||
)
|
||||
seen: set[str] = set()
|
||||
out: List[dict] = []
|
||||
for r in rows:
|
||||
name = (r.role_name or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
code = _map_role_name_to_web(name)
|
||||
key = f"{code}:{name}"
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append({"label": name, "value": code})
|
||||
if not out:
|
||||
out = [
|
||||
{"label": "超级管理员", "value": "super_admin"},
|
||||
{"label": "运营管理员", "value": "admin"},
|
||||
]
|
||||
return success_response(out)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 租户管理
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TenantCreateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
tenant_no: str = Field(..., alias="tenantNo")
|
||||
tenant_name: str = Field(..., alias="tenantName")
|
||||
contact_name: Optional[str] = Field(None, alias="contactName")
|
||||
contact_phone: Optional[str] = Field(None, alias="contactPhone")
|
||||
order_num: Optional[int] = Field(0, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class TenantUpdateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
tenant_no: Optional[str] = Field(None, alias="tenantNo")
|
||||
tenant_name: Optional[str] = Field(None, alias="tenantName")
|
||||
contact_name: Optional[str] = Field(None, alias="contactName")
|
||||
contact_phone: Optional[str] = Field(None, alias="contactPhone")
|
||||
order_num: Optional[int] = Field(None, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/tenants")
|
||||
async def list_tenants(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
|
||||
keyword: Optional[str] = None,
|
||||
_user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
items, total = tenant_svc.list_tenants(db, page=page, page_size=pageSize, keyword=keyword)
|
||||
total_pages = tenant_svc.total_pages(total, pageSize)
|
||||
return success_response(
|
||||
{
|
||||
"list": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pageSize": pageSize,
|
||||
"totalPages": total_pages,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/tenants")
|
||||
async def create_tenant(
|
||||
body: TenantCreateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
obj = tenant_svc.create_tenant(
|
||||
db,
|
||||
tenant_no=body.tenant_no,
|
||||
tenant_name=body.tenant_name,
|
||||
contact_name=body.contact_name,
|
||||
contact_phone=body.contact_phone,
|
||||
order_num=body.order_num,
|
||||
remark=body.remark,
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
return success_response(tenant_svc._row_to_dict(obj), message="创建成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.put("/tenants/{tenant_id}")
|
||||
async def update_tenant(
|
||||
tenant_id: str,
|
||||
body: TenantUpdateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False)
|
||||
obj = tenant_svc.update_tenant(
|
||||
db,
|
||||
tenant_id,
|
||||
tenant_no=payload.get("tenant_no"),
|
||||
tenant_name=payload.get("tenant_name"),
|
||||
contact_name=payload.get("contact_name"),
|
||||
contact_phone=payload.get("contact_phone"),
|
||||
order_num=payload.get("order_num"),
|
||||
remark=payload.get("remark"),
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
return success_response(tenant_svc._row_to_dict(obj), message="更新成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.delete("/tenants/{tenant_id}")
|
||||
async def delete_tenant(
|
||||
tenant_id: str,
|
||||
_user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_svc.soft_delete_tenant(db, tenant_id)
|
||||
return success_response(None, message="删除成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
@@ -0,0 +1,107 @@
|
||||
"""运营端菜单接口(与 web-admin 前端静态路由 sqxp.ts 对齐,供后端菜单模式联调)"""
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.core.menu_guard import require_admin_or_super_admin
|
||||
from app.core.response import success_response
|
||||
|
||||
router = APIRouter(prefix="/web/menu", tags=["菜单管理"])
|
||||
|
||||
# 与 web-admin/src/router/modules/sqxp.ts 一致:「Demo」参考页 +「系统管理」
|
||||
STATIC_MENU_TREE = [
|
||||
{
|
||||
"id": "10000000000000000000000000000002",
|
||||
"name": "Demo",
|
||||
"path": "/demo",
|
||||
"component": "/index/index",
|
||||
"meta": {
|
||||
"title": "Demo",
|
||||
"icon": "ri:layout-grid-line",
|
||||
"keepAlive": False,
|
||||
"roles": ["admin"],
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "10000000000000000000000000000001_child",
|
||||
"name": "Console",
|
||||
"path": "console",
|
||||
"component": "/dashboard/console/index",
|
||||
"meta": {
|
||||
"title": "工作台",
|
||||
"icon": "ri:home-smile-2-line",
|
||||
"keepAlive": True,
|
||||
"fixedTab": True,
|
||||
"menuCode": "dashboard",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "10000000000000000000000000000005",
|
||||
"name": "Unit",
|
||||
"path": "unit",
|
||||
"component": "/system/unit/index",
|
||||
"meta": {"title": "商品单位", "keepAlive": True},
|
||||
},
|
||||
{
|
||||
"id": "10000000000000000000000000000021",
|
||||
"name": "OrderList",
|
||||
"path": "order",
|
||||
"component": "/system/order/index",
|
||||
"meta": {"title": "订单列表", "keepAlive": True},
|
||||
},
|
||||
{
|
||||
"id": "10000000000000000000000000000006",
|
||||
"name": "Product",
|
||||
"path": "product",
|
||||
"component": "/system/product/index",
|
||||
"meta": {"title": "商品管理", "keepAlive": True},
|
||||
},
|
||||
{
|
||||
"id": "10000000000000000000000000000004",
|
||||
"name": "Category",
|
||||
"path": "category",
|
||||
"component": "/system/category/index",
|
||||
"meta": {"title": "商品分类", "keepAlive": True},
|
||||
},
|
||||
{
|
||||
"id": "10000000000000000000000000000007",
|
||||
"name": "User",
|
||||
"path": "user",
|
||||
"component": "/system/user/index",
|
||||
"meta": {"title": "用户管理", "keepAlive": True},
|
||||
},
|
||||
{
|
||||
"id": "10000000000000000000000000000008",
|
||||
"name": "UserDetail",
|
||||
"path": "user/detail",
|
||||
"component": "/system/user/detail",
|
||||
"meta": {"title": "用户详情", "isHide": True, "keepAlive": True},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "20000000000000000000000000000001",
|
||||
"name": "System",
|
||||
"path": "/system",
|
||||
"component": "/index/index",
|
||||
"meta": {
|
||||
"title": "系统管理",
|
||||
"icon": "ri:settings-3-line",
|
||||
"keepAlive": False,
|
||||
"roles": ["admin"],
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "20000000000000000000000000000002",
|
||||
"name": "Tenant",
|
||||
"path": "tenant",
|
||||
"component": "/system/tenant/index",
|
||||
"meta": {"title": "租户管理", "keepAlive": True},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
async def get_menu_list(_user: dict = Depends(require_admin_or_super_admin)):
|
||||
"""获取菜单列表(与当前前端路由一致)"""
|
||||
return success_response({"menuList": STATIC_MENU_TREE})
|
||||
@@ -0,0 +1,289 @@
|
||||
"""公共接口(Web 登录、刷新 Token、登出;用户模型对齐 zkxw-requirements-v0.3-schema.sql)"""
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import verify_password
|
||||
from app.core.auth import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
get_current_user_by_token,
|
||||
extend_session,
|
||||
is_refresh_token_valid,
|
||||
is_session_valid,
|
||||
revoke_refresh_token,
|
||||
revoke_session,
|
||||
store_refresh_token,
|
||||
store_session,
|
||||
)
|
||||
from app.core.response import success_response, error_response
|
||||
from app.models.models import SysUser
|
||||
from app.crud.user import user_crud
|
||||
from app.core.utils import generate_uuid
|
||||
from app.core import login_crypto
|
||||
from app.services.web_auth_roles import iter_user_menu_perm_codes, resolve_web_role_for_user
|
||||
|
||||
router = APIRouter(prefix="", tags=["公共接口"])
|
||||
|
||||
|
||||
def _avatar_url_field(user: SysUser) -> Optional[str]:
|
||||
raw = getattr(user, "avatar", None)
|
||||
if not raw:
|
||||
return None
|
||||
s = str(raw).strip()
|
||||
return s if s else None
|
||||
|
||||
|
||||
def _build_user_info_payload(user: SysUser, web_role: str) -> dict:
|
||||
"""与 web-admin `userInfo` 字段约定一致。"""
|
||||
phone = (getattr(user, "phone_no", None) or "").strip() or None
|
||||
return {
|
||||
"id": user.id,
|
||||
"username": user.user_no,
|
||||
"realName": user.nick_name,
|
||||
"phone": phone,
|
||||
"email": (getattr(user, "email", None) or "").strip() or None,
|
||||
"role": web_role,
|
||||
"regionId": user.dept_id,
|
||||
"regionName": None,
|
||||
"avatarUrl": _avatar_url_field(user),
|
||||
}
|
||||
|
||||
|
||||
def _jwt_user_payload(user: SysUser, web_role: str) -> dict:
|
||||
return {
|
||||
"sub": user.id,
|
||||
"username": user.user_no,
|
||||
"role": web_role,
|
||||
"region_id": user.dept_id,
|
||||
"real_name": user.nick_name,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/web/auth/login-rsa-public-key", response_model=dict)
|
||||
async def web_login_rsa_public_key():
|
||||
"""Web 端登录用 RSA 公钥(PKCS#1 v1.5,与 JSEncrypt 一致)"""
|
||||
login_crypto.ensure_initialized()
|
||||
return success_response({"publicKeyPem": login_crypto.get_public_pem()})
|
||||
|
||||
|
||||
@router.post("/web/auth/login", response_model=dict)
|
||||
async def web_login(
|
||||
request: Request,
|
||||
body: dict,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Web 端登录:账号为 sys_user.user_no,密码为 sys_user.password(bcrypt)。"""
|
||||
login_crypto.ensure_initialized()
|
||||
user_no, password, cred_err = login_crypto.extract_login_credentials(body)
|
||||
if cred_err:
|
||||
return error_response(400, cred_err)
|
||||
|
||||
user = user_crud.get_by_user_no(db, user_no)
|
||||
|
||||
if not user:
|
||||
return error_response(401, "用户名或密码错误")
|
||||
|
||||
pwd_hash = getattr(user, "password", None) or ""
|
||||
if not verify_password(password, pwd_hash):
|
||||
return error_response(401, "用户名或密码错误")
|
||||
|
||||
if user.status != "1":
|
||||
return error_response(403, "账号已被停用,请联系管理员")
|
||||
|
||||
web_role = resolve_web_role_for_user(db, user.id)
|
||||
|
||||
session_id = generate_uuid()
|
||||
access_jti = generate_uuid()
|
||||
refresh_jti = generate_uuid()
|
||||
|
||||
access_token = create_access_token(
|
||||
data=_jwt_user_payload(user, web_role),
|
||||
session_id=session_id,
|
||||
token_jti=access_jti,
|
||||
)
|
||||
refresh_token = create_refresh_token(
|
||||
data={
|
||||
"sub": user.id,
|
||||
"username": user.user_no,
|
||||
"role": web_role,
|
||||
},
|
||||
session_id=session_id,
|
||||
refresh_jti=refresh_jti,
|
||||
)
|
||||
|
||||
await store_session(session_id=session_id, user_id=user.id)
|
||||
await store_refresh_token(refresh_jti=refresh_jti, user_id=user.id)
|
||||
|
||||
return success_response(
|
||||
{
|
||||
"token": access_token,
|
||||
"refreshToken": refresh_token,
|
||||
"expiresIn": 7200,
|
||||
"userInfo": _build_user_info_payload(user, web_role),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/web/auth/refresh-token", response_model=dict)
|
||||
async def refresh_token(body: dict, db: Session = Depends(get_db)):
|
||||
"""刷新 Token"""
|
||||
refresh_token_val = body.get("refreshToken")
|
||||
if not refresh_token_val:
|
||||
return error_response(400, "refreshToken不能为空")
|
||||
|
||||
try:
|
||||
payload = decode_token(refresh_token_val)
|
||||
if payload.get("type") != "refresh":
|
||||
return error_response(401, "无效的refreshToken")
|
||||
|
||||
session_id = payload.get("sid")
|
||||
refresh_jti = payload.get("jti")
|
||||
|
||||
if not await is_session_valid(session_id=session_id):
|
||||
return error_response(401, "refreshToken已失效,请重新登录")
|
||||
if not await is_refresh_token_valid(refresh_jti=refresh_jti):
|
||||
return error_response(401, "无效的refreshToken")
|
||||
|
||||
user_id = payload.get("sub")
|
||||
user = user_crud.get(db, user_id)
|
||||
|
||||
if not user or user.del_flag != 0 or user.status != "1":
|
||||
return error_response(401, "用户不存在或已禁用")
|
||||
|
||||
web_role = resolve_web_role_for_user(db, user.id)
|
||||
|
||||
await revoke_refresh_token(refresh_jti=refresh_jti)
|
||||
await extend_session(session_id=session_id)
|
||||
|
||||
new_access_jti = generate_uuid()
|
||||
new_refresh_jti = generate_uuid()
|
||||
|
||||
new_access_token = create_access_token(
|
||||
data=_jwt_user_payload(user, web_role),
|
||||
session_id=session_id,
|
||||
token_jti=new_access_jti,
|
||||
)
|
||||
new_refresh_token = create_refresh_token(
|
||||
data={
|
||||
"sub": user.id,
|
||||
"username": user.user_no,
|
||||
"role": web_role,
|
||||
},
|
||||
session_id=session_id,
|
||||
refresh_jti=new_refresh_jti,
|
||||
)
|
||||
|
||||
await store_refresh_token(refresh_jti=new_refresh_jti, user_id=user.id)
|
||||
|
||||
return success_response(
|
||||
{
|
||||
"token": new_access_token,
|
||||
"refreshToken": new_refresh_token,
|
||||
"expiresIn": 7200,
|
||||
}
|
||||
)
|
||||
except HTTPException:
|
||||
return error_response(401, "refreshToken已过期")
|
||||
|
||||
|
||||
@router.post("/web/auth/logout", response_model=dict)
|
||||
async def logout(request: Request):
|
||||
"""登出:吊销 Redis 会话(若已配置 REDIS_HOST)。"""
|
||||
authorization = request.headers.get("Authorization", "")
|
||||
if not authorization.startswith("Bearer "):
|
||||
return error_response(401, "未授权")
|
||||
|
||||
token = authorization.replace("Bearer ", "")
|
||||
try:
|
||||
payload = decode_token(token)
|
||||
session_id = payload.get("sid")
|
||||
if session_id:
|
||||
await revoke_session(session_id=session_id)
|
||||
except HTTPException:
|
||||
return error_response(401, "Token无效或已过期")
|
||||
|
||||
return success_response({"message": "登出成功"})
|
||||
|
||||
|
||||
@router.get("/web/auth/current-user", response_model=dict)
|
||||
async def get_current_user_profile(request: Request, db: Session = Depends(get_db)):
|
||||
"""获取当前用户信息(与 sys_user 字段对应)。"""
|
||||
authorization = request.headers.get("Authorization", "")
|
||||
if not authorization.startswith("Bearer "):
|
||||
return error_response(401, "未授权")
|
||||
|
||||
token = authorization.replace("Bearer ", "")
|
||||
try:
|
||||
current_user = await get_current_user_by_token(token=token, db=db)
|
||||
|
||||
au = current_user.get("avatar_url")
|
||||
avatar_url = str(au).strip() if au else None
|
||||
return success_response(
|
||||
{
|
||||
"id": current_user.get("user_id"),
|
||||
"username": current_user.get("username"),
|
||||
"realName": current_user.get("real_name"),
|
||||
"phone": current_user.get("phone"),
|
||||
"email": current_user.get("email"),
|
||||
"role": current_user.get("role"),
|
||||
"isSuperAdmin": current_user.get("role") == "super_admin",
|
||||
"regionId": current_user.get("region_id"),
|
||||
"regionName": None,
|
||||
"regionIds": [],
|
||||
"isActive": current_user.get("is_active"),
|
||||
"avatarUrl": avatar_url if avatar_url else None,
|
||||
}
|
||||
)
|
||||
except HTTPException:
|
||||
return error_response(401, "Token无效或已过期")
|
||||
|
||||
|
||||
@router.get("/web/auth/menu-codes", response_model=dict)
|
||||
async def get_web_auth_menu_codes(request: Request, db: Session = Depends(get_db)):
|
||||
"""获取当前用户菜单权限标识(sys_menu.perms,经角色继承)。"""
|
||||
authorization = request.headers.get("Authorization", "")
|
||||
if not authorization.startswith("Bearer "):
|
||||
return error_response(401, "未授权")
|
||||
|
||||
token = authorization.replace("Bearer ", "")
|
||||
try:
|
||||
current_user = await get_current_user_by_token(token=token, db=db)
|
||||
role = current_user.get("role")
|
||||
if role == "super_admin":
|
||||
return success_response(
|
||||
{
|
||||
"isSuperAdmin": True,
|
||||
"all": True,
|
||||
"menuCodes": [],
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
codes = iter_user_menu_perm_codes(db, current_user.get("user_id") or "")
|
||||
menu_codes = sorted(codes)
|
||||
except OperationalError:
|
||||
menu_codes = []
|
||||
|
||||
if role == "admin" and not menu_codes:
|
||||
return success_response(
|
||||
{
|
||||
"isSuperAdmin": False,
|
||||
"all": True,
|
||||
"menuCodes": [],
|
||||
}
|
||||
)
|
||||
|
||||
return success_response(
|
||||
{
|
||||
"isSuperAdmin": False,
|
||||
"all": False,
|
||||
"menuCodes": menu_codes,
|
||||
}
|
||||
)
|
||||
except HTTPException:
|
||||
return error_response(401, "Token无效或已过期")
|
||||
@@ -6,7 +6,7 @@ import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Optional, Tuple
|
||||
from urllib.parse import urlencode
|
||||
from urllib.parse import parse_qsl, urlencode
|
||||
|
||||
import redis.asyncio as redis
|
||||
from jose import jwt
|
||||
@@ -102,10 +102,56 @@ def _json_error_response(code: int, message: str) -> Response:
|
||||
return Response(content=json.dumps(body, ensure_ascii=False), media_type="application/json", status_code=200)
|
||||
|
||||
|
||||
async def _coerce_post_method_override_to_get(request: Request, call_next) -> Response:
|
||||
"""前端 Axios 在开启加密时会把 GET 改为 POST 并带 X-HTTP-Method-Override: GET。
|
||||
|
||||
当后端未启用 API_ENCRYPT_ENABLED 时,原逻辑直接放行,路由仍按 POST 匹配,导致仅注册了 GET 的接口返回 405。
|
||||
此处在不解密的情况下,将此类请求还原为 GET(空或明文 JSON 体);若仍为 enc=1 密文则提示配置不一致。
|
||||
"""
|
||||
if request.method != "POST":
|
||||
return await call_next(request)
|
||||
override = (request.headers.get("x-http-method-override") or "").upper()
|
||||
if override != "GET":
|
||||
return await call_next(request)
|
||||
|
||||
body_bytes = await request.body()
|
||||
req_json: dict = {}
|
||||
if body_bytes:
|
||||
try:
|
||||
raw = json.loads(body_bytes.decode("utf-8"))
|
||||
req_json = raw if isinstance(raw, dict) else {}
|
||||
except Exception:
|
||||
req_json = {}
|
||||
|
||||
if int(req_json.get("enc", 0) or 0) == 1:
|
||||
return _json_error_response(
|
||||
400,
|
||||
"客户端已开启接口加密(enc=1),但服务端未启用 API_ENCRYPT_ENABLED;"
|
||||
"请在后端开启加密并配置 API_ENCRYPT_FIXED_SECRET,或将前端 VITE_API_ENCRYPT_ENABLED 设为 false。",
|
||||
)
|
||||
|
||||
scope = dict(request.scope)
|
||||
scope["method"] = "GET"
|
||||
payload = req_json if isinstance(req_json, dict) else {}
|
||||
q0 = (request.scope.get("query_string") or b"").decode("utf-8")
|
||||
pairs = dict(parse_qsl(q0, keep_blank_values=True))
|
||||
for k, v in payload.items():
|
||||
if isinstance(v, (dict, list)):
|
||||
continue
|
||||
pairs[k] = "" if v is None else str(v)
|
||||
scope["query_string"] = urlencode(pairs, doseq=True).encode("utf-8") if pairs else b""
|
||||
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
|
||||
new_request = Request(scope, receive)
|
||||
return await call_next(new_request)
|
||||
|
||||
|
||||
class ApiCryptoMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
if not settings.API_ENCRYPT_ENABLED:
|
||||
return await call_next(request)
|
||||
return await _coerce_post_method_override_to_get(request, call_next)
|
||||
if not settings.API_ENCRYPT_FIXED_SECRET:
|
||||
if settings.API_ENCRYPT_FAIL_OPEN:
|
||||
logger.warning("api crypto bypassed: API_ENCRYPT_FIXED_SECRET is empty")
|
||||
|
||||
+17
-11
@@ -12,10 +12,9 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.core.security import verify_password
|
||||
from app.models.models import SysUser
|
||||
from app.crud.user import user_crud
|
||||
from app.core.utils import generate_uuid
|
||||
from app.services.web_auth_roles import resolve_web_role_for_user
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/public/login")
|
||||
|
||||
@@ -197,27 +196,34 @@ async def get_current_user_by_token(
|
||||
)
|
||||
|
||||
user = user_crud.get(db, user_id)
|
||||
if not user or user.is_del == 1:
|
||||
if not user or getattr(user, "del_flag", 0) != 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="用户不存在或已删除",
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
if getattr(user, "status", "1") != "1":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="用户已被禁用",
|
||||
)
|
||||
|
||||
web_role = resolve_web_role_for_user(db, user.id)
|
||||
|
||||
phone = (getattr(user, "phone_no", None) or "").strip() or None
|
||||
email = (getattr(user, "email", None) or "").strip() or None
|
||||
avatar = getattr(user, "avatar", None)
|
||||
|
||||
return {
|
||||
"user_id": user.id,
|
||||
"username": user.username,
|
||||
"real_name": user.real_name,
|
||||
"role": user.role,
|
||||
"region_id": user.region_id,
|
||||
"phone": getattr(user, "phone", None),
|
||||
"avatar_url": getattr(user, "avatar_url", None),
|
||||
"is_active": getattr(user, "is_active", True),
|
||||
"username": user.user_no,
|
||||
"real_name": user.nick_name,
|
||||
"role": web_role,
|
||||
"region_id": user.dept_id,
|
||||
"phone": phone,
|
||||
"email": email,
|
||||
"avatar_url": avatar,
|
||||
"is_active": user.status == "1",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -8,12 +8,12 @@ class Settings(BaseSettings):
|
||||
"""应用配置"""
|
||||
|
||||
# 应用基础配置
|
||||
APP_NAME: str = "中科兴皖一体化门户"
|
||||
APP_NAME: str = "zkxw 管理骨架"
|
||||
DEBUG: bool = True
|
||||
API_V1_PREFIX: str = "/api"
|
||||
|
||||
# 数据库配置
|
||||
DATABASE_URL: str = "mysql+pymysql://root:password@localhost:3306/zkxw"
|
||||
DATABASE_URL: str = "mysql+pymysql://root:password@localhost:3306/sanqiang"
|
||||
|
||||
# JWT配置
|
||||
SECRET_KEY: str = "your-secret-key-change-in-production-please-use-random-string"
|
||||
@@ -100,6 +100,11 @@ class Settings(BaseSettings):
|
||||
WX_TEMPLATE_REFUND_RESULT: str = "" # 退款结果通知模板ID
|
||||
WX_TEMPLATE_DELIVERY_WARNING: str = "" # 配送时效预警模板ID
|
||||
|
||||
# 与 docs/sql/zkxw-requirements-v0.3-schema.sql 一致:登录按租户 + sys_user.user_no
|
||||
DEFAULT_TENANT_NO: str = "zkxw"
|
||||
# 用户未绑定 sys_user_role 时,Web 管理端默认角色(建议 super_admin 便于开发全菜单)
|
||||
WEB_AUTH_DEFAULT_ROLE: str = "super_admin"
|
||||
|
||||
# 登录 RSA:私钥 PEM(多实例/负载均衡须配置同一私钥;留空则进程启动时临时生成,仅适合单机开发)
|
||||
LOGIN_RSA_PRIVATE_KEY_PEM: str = ""
|
||||
# 是否允许明文 username/password 登录(仅建议本地排障临时开启;生产务必 false)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""菜单 code 权限守卫"""
|
||||
"""菜单 code 权限守卫(V0.3:通过 sys_user_role → sys_role_menu → sys_menu.perms)"""
|
||||
from typing import Callable, Set
|
||||
|
||||
from fastapi import Depends, HTTPException
|
||||
@@ -6,7 +6,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.auth import get_current_user
|
||||
from app.core.database import get_db
|
||||
from app.models.models import SysUserMenuScope
|
||||
from app.services.web_auth_roles import iter_user_menu_perm_codes
|
||||
|
||||
ADMIN_ROLES = {"admin", "super_admin"}
|
||||
SUPER_ADMIN_ROLE = "super_admin"
|
||||
@@ -21,18 +21,8 @@ def require_admin_or_super_admin(current_user: dict = Depends(get_current_user))
|
||||
|
||||
|
||||
def get_user_menu_codes(db: Session, user_id: str) -> Set[str]:
|
||||
"""读取用户授权的菜单 code 集合。"""
|
||||
if not user_id:
|
||||
return set()
|
||||
rows = (
|
||||
db.query(SysUserMenuScope.menu_code)
|
||||
.filter(
|
||||
SysUserMenuScope.user_id == user_id,
|
||||
SysUserMenuScope.is_del == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return {str(row[0]).strip() for row in rows if row and row[0]}
|
||||
"""读取用户授权的菜单权限标识集合(与前端 meta.menuCode / perms 对齐)。"""
|
||||
return iter_user_menu_perm_codes(db, user_id or "")
|
||||
|
||||
|
||||
def require_menu_code(menu_code: str) -> Callable:
|
||||
|
||||
@@ -1,73 +1,9 @@
|
||||
"""CRUD模块"""
|
||||
"""CRUD 模块"""
|
||||
from app.crud.base import CRUDBase, HistoryCRUD
|
||||
from app.crud.region import region_crud
|
||||
from app.crud.category import category_crud
|
||||
from app.crud.unit import unit_crud
|
||||
from app.crud.user import user_crud
|
||||
from app.crud.password_history import password_history_crud
|
||||
from app.crud.product import product_crud
|
||||
from app.crud.address import address_crud
|
||||
from app.crud.inquiry import inquiry_crud, inquiry_item_crud
|
||||
from app.crud.quotation import quotation_crud, quotation_item_crud
|
||||
from app.crud.price_list import price_list_crud, price_list_item_crud
|
||||
from app.crud.order import order_crud, order_item_crud, order_status_log_crud
|
||||
from app.crud.delivery import delivery_task_crud, delivery_task_item_crud
|
||||
from app.crud.settlement import settlement_crud, refund_crud
|
||||
from app.crud.ord_settlement import (
|
||||
ord_client_settlement_crud,
|
||||
ord_client_settlement_item_crud,
|
||||
ord_supplier_settlement_crud,
|
||||
ord_supplier_settlement_item_crud,
|
||||
)
|
||||
from app.crud.deliverer_review import deliverer_review_crud
|
||||
from app.crud.product_banner import product_banner, product_banner_his
|
||||
from app.crud.product_detail_media import product_detail_media, product_detail_media_his
|
||||
from app.crud.product_parameter import product_parameter, product_parameter_his
|
||||
from app.crud.category_parameter import category_parameter, category_parameter_his
|
||||
from app.crud.product_parameter_value import product_parameter_value, product_parameter_value_his
|
||||
from app.crud.product_review import product_review, product_review_his
|
||||
from app.crud.product_review_media import product_review_media, product_review_media_his
|
||||
|
||||
__all__ = [
|
||||
"CRUDBase",
|
||||
"HistoryCRUD",
|
||||
"region_crud",
|
||||
"category_crud",
|
||||
"unit_crud",
|
||||
"user_crud",
|
||||
"password_history_crud",
|
||||
"product_crud",
|
||||
"address_crud",
|
||||
"inquiry_crud",
|
||||
"inquiry_item_crud",
|
||||
"quotation_crud",
|
||||
"quotation_item_crud",
|
||||
"price_list_crud",
|
||||
"price_list_item_crud",
|
||||
"order_crud",
|
||||
"order_item_crud",
|
||||
"order_status_log_crud",
|
||||
"delivery_task_crud",
|
||||
"delivery_task_item_crud",
|
||||
"settlement_crud",
|
||||
"refund_crud",
|
||||
"ord_client_settlement_crud",
|
||||
"ord_client_settlement_item_crud",
|
||||
"ord_supplier_settlement_crud",
|
||||
"ord_supplier_settlement_item_crud",
|
||||
"deliverer_review_crud",
|
||||
"product_banner",
|
||||
"product_banner_his",
|
||||
"product_detail_media",
|
||||
"product_detail_media_his",
|
||||
"product_parameter",
|
||||
"product_parameter_his",
|
||||
"category_parameter",
|
||||
"category_parameter_his",
|
||||
"product_parameter_value",
|
||||
"product_parameter_value_his",
|
||||
"product_review",
|
||||
"product_review_his",
|
||||
"product_review_media",
|
||||
"product_review_media_his",
|
||||
]
|
||||
@@ -1,122 +0,0 @@
|
||||
"""地址CRUD"""
|
||||
from typing import Optional, List, Tuple
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_
|
||||
|
||||
from app.models.models import BasAddress, BasAddressHis
|
||||
from app.crud.base import CRUDBase
|
||||
|
||||
|
||||
class AddressCRUD(CRUDBase[BasAddress]):
|
||||
"""地址CRUD操作"""
|
||||
|
||||
def get_by_user(self, db: Session, user_id: str) -> List[BasAddress]:
|
||||
"""获取用户的所有地址"""
|
||||
return db.query(BasAddress).filter(
|
||||
and_(BasAddress.user_id == user_id, BasAddress.is_del == 0)
|
||||
).order_by(BasAddress.create_at.desc(), BasAddress.id.desc()).all()
|
||||
|
||||
def get_by_user_paged(
|
||||
self, db: Session, user_id: str, skip: int = 0, limit: int = 20
|
||||
) -> Tuple[List[BasAddress], int]:
|
||||
"""分页获取用户地址(按创建时间倒序)"""
|
||||
q = db.query(BasAddress).filter(
|
||||
and_(BasAddress.user_id == user_id, BasAddress.is_del == 0)
|
||||
)
|
||||
total = q.count()
|
||||
rows = (
|
||||
q.order_by(BasAddress.create_at.desc(), BasAddress.id.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return rows, total
|
||||
|
||||
def get_by_user_and_type(self, db: Session, user_id: str, address_type: str) -> List[BasAddress]:
|
||||
"""获取用户指定类型的地址"""
|
||||
return db.query(BasAddress).filter(
|
||||
and_(BasAddress.user_id == user_id, BasAddress.address_type == address_type, BasAddress.is_del == 0)
|
||||
).order_by(BasAddress.create_at.desc(), BasAddress.id.desc()).all()
|
||||
|
||||
def get_default(self, db: Session, user_id: str, address_type: str = None) -> Optional[BasAddress]:
|
||||
"""获取用户默认地址"""
|
||||
query = db.query(BasAddress).filter(
|
||||
and_(BasAddress.user_id == user_id, BasAddress.is_default == True, BasAddress.is_del == 0)
|
||||
)
|
||||
if address_type:
|
||||
query = query.filter(BasAddress.address_type == address_type)
|
||||
return query.first()
|
||||
|
||||
def set_default(self, db: Session, user_id: str, address_id: str) -> bool:
|
||||
"""设置默认地址"""
|
||||
# 先取消所有默认地址
|
||||
db.query(BasAddress).filter(
|
||||
and_(BasAddress.user_id == user_id, BasAddress.is_default == True)
|
||||
).update({"is_default": False})
|
||||
|
||||
# 设置新的默认地址
|
||||
address = self.get(db, address_id)
|
||||
if address and address.user_id == user_id:
|
||||
address.is_default = True
|
||||
db.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
def update_with_history(self, db: Session, id: str, obj_in: dict, updater: dict = None) -> Optional[BasAddress]:
|
||||
"""更新地址并记录历史"""
|
||||
original = self.get(db, id)
|
||||
if not original:
|
||||
return None
|
||||
|
||||
his_data = {
|
||||
"id": original.id,
|
||||
"user_id": original.user_id,
|
||||
"address_type": original.address_type,
|
||||
"contact_person": original.contact_person,
|
||||
"contact_phone": original.contact_phone,
|
||||
"province": original.province,
|
||||
"city": original.city,
|
||||
"district": original.district,
|
||||
"detail_address": original.detail_address,
|
||||
"is_default": original.is_default,
|
||||
"create_at": original.create_at,
|
||||
"create_by": original.create_by,
|
||||
"create_name": original.create_name,
|
||||
"update_at": original.update_at,
|
||||
"update_by": original.update_by,
|
||||
}
|
||||
his = BasAddressHis(**his_data)
|
||||
db.add(his)
|
||||
|
||||
return self.update(db, id, obj_in, updater)
|
||||
|
||||
def soft_delete_with_history(self, db: Session, id: str) -> bool:
|
||||
"""删除地址并记录历史"""
|
||||
original = self.get(db, id)
|
||||
if not original:
|
||||
return False
|
||||
|
||||
his_data = {
|
||||
"id": original.id,
|
||||
"user_id": original.user_id,
|
||||
"address_type": original.address_type,
|
||||
"contact_person": original.contact_person,
|
||||
"contact_phone": original.contact_phone,
|
||||
"province": original.province,
|
||||
"city": original.city,
|
||||
"district": original.district,
|
||||
"detail_address": original.detail_address,
|
||||
"is_default": original.is_default,
|
||||
"create_at": original.create_at,
|
||||
"create_by": original.create_by,
|
||||
"create_name": original.create_name,
|
||||
"update_at": original.update_at,
|
||||
"update_by": original.update_by,
|
||||
}
|
||||
his = BasAddressHis(**his_data)
|
||||
db.add(his)
|
||||
|
||||
return self.delete(db, id)
|
||||
|
||||
|
||||
address_crud = AddressCRUD(BasAddress)
|
||||
@@ -1,58 +0,0 @@
|
||||
"""附件CRUD"""
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_
|
||||
|
||||
from app.models.models import BizAttachment
|
||||
from app.crud.base import CRUDBase
|
||||
from app.core.utils import generate_id
|
||||
|
||||
|
||||
class AttachmentCRUD(CRUDBase[BizAttachment]):
|
||||
"""附件CRUD操作"""
|
||||
|
||||
def get_by_business(
|
||||
self,
|
||||
db: Session,
|
||||
business_type: str,
|
||||
business_id: str
|
||||
) -> List[BizAttachment]:
|
||||
"""根据业务类型和业务ID获取附件列表"""
|
||||
return db.query(self.model).filter(
|
||||
and_(
|
||||
self.model.business_type == business_type,
|
||||
self.model.business_id == business_id,
|
||||
self.model.is_del == 0
|
||||
)
|
||||
).order_by(self.model.create_at.desc()).all()
|
||||
|
||||
def create_attachment(
|
||||
self,
|
||||
db: Session,
|
||||
business_type: str,
|
||||
business_id: str,
|
||||
file_name: str,
|
||||
file_url: str,
|
||||
file_size: int = None,
|
||||
file_type: str = None,
|
||||
upload_by: str = None,
|
||||
upload_by_name: str = None
|
||||
) -> BizAttachment:
|
||||
"""创建附件记录"""
|
||||
data = {
|
||||
"id": generate_id(),
|
||||
"business_type": business_type,
|
||||
"business_id": business_id,
|
||||
"file_name": file_name,
|
||||
"file_url": file_url,
|
||||
"file_size": file_size,
|
||||
"file_type": file_type,
|
||||
"upload_by": upload_by,
|
||||
"upload_by_name": upload_by_name,
|
||||
"create_by": upload_by,
|
||||
"create_name": upload_by_name
|
||||
}
|
||||
return self.create(db, data)
|
||||
|
||||
|
||||
attachment_crud = AttachmentCRUD(BizAttachment)
|
||||
@@ -1,112 +0,0 @@
|
||||
"""商品分类CRUD"""
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_
|
||||
|
||||
from app.models.models import BasProductCategory, BasProductCategoryHis
|
||||
from app.crud.base import CRUDBase
|
||||
|
||||
|
||||
class CategoryCRUD(CRUDBase[BasProductCategory]):
|
||||
"""商品分类CRUD操作"""
|
||||
|
||||
def get_by_name(self, db: Session, name: str, parent_id: str = None) -> Optional[BasProductCategory]:
|
||||
"""根据名称获取分类"""
|
||||
query = db.query(BasProductCategory).filter(
|
||||
and_(BasProductCategory.name == name, BasProductCategory.is_del == 0)
|
||||
)
|
||||
if parent_id:
|
||||
query = query.filter(BasProductCategory.parent_id == parent_id)
|
||||
else:
|
||||
query = query.filter(BasProductCategory.parent_id.is_(None))
|
||||
return query.first()
|
||||
|
||||
def get_active_all(self, db: Session) -> List[BasProductCategory]:
|
||||
"""获取所有启用的分类"""
|
||||
return db.query(BasProductCategory).filter(
|
||||
and_(BasProductCategory.is_del == 0, BasProductCategory.is_active == True)
|
||||
).order_by(BasProductCategory.sort_order.asc()).all()
|
||||
|
||||
def get_children(self, db: Session, parent_id: str) -> List[BasProductCategory]:
|
||||
"""获取子分类"""
|
||||
return db.query(BasProductCategory).filter(
|
||||
and_(BasProductCategory.parent_id == parent_id, BasProductCategory.is_del == 0)
|
||||
).order_by(BasProductCategory.sort_order.asc()).all()
|
||||
|
||||
def get_tree(self, db: Session) -> List[dict]:
|
||||
"""获取分类树"""
|
||||
all_categories = self.get_active_all(db)
|
||||
return self._build_tree(all_categories, None)
|
||||
|
||||
def _build_tree(self, categories: List[BasProductCategory], parent_id: str = None) -> List[dict]:
|
||||
"""构建树形结构"""
|
||||
tree = []
|
||||
for cat in categories:
|
||||
if cat.parent_id == parent_id:
|
||||
pc = getattr(cat, "price_coefficient", None)
|
||||
node = {
|
||||
"id": cat.id,
|
||||
"name": cat.name,
|
||||
"parentId": cat.parent_id,
|
||||
"sortOrder": cat.sort_order,
|
||||
"priceCoefficient": float(pc) if pc is not None else 0.0,
|
||||
"isActive": cat.is_active,
|
||||
"children": self._build_tree(categories, cat.id)
|
||||
}
|
||||
tree.append(node)
|
||||
return tree
|
||||
|
||||
def update_with_history(self, db: Session, id: str, obj_in: dict, updater: dict = None) -> Optional[BasProductCategory]:
|
||||
"""更新分类并记录历史"""
|
||||
original = self.get(db, id)
|
||||
if not original:
|
||||
return None
|
||||
|
||||
pc_hist = getattr(original, "price_coefficient", None)
|
||||
his_data = {
|
||||
"id": original.id,
|
||||
"name": original.name,
|
||||
"image_url": getattr(original, "image_url", None),
|
||||
"parent_id": original.parent_id,
|
||||
"sort_order": original.sort_order,
|
||||
"price_coefficient": pc_hist if pc_hist is not None else 0,
|
||||
"is_active": original.is_active,
|
||||
"create_at": original.create_at,
|
||||
"create_by": original.create_by,
|
||||
"create_name": original.create_name,
|
||||
"update_at": original.update_at,
|
||||
"update_by": original.update_by,
|
||||
}
|
||||
his = BasProductCategoryHis(**his_data)
|
||||
db.add(his)
|
||||
|
||||
return self.update(db, id, obj_in, updater)
|
||||
|
||||
def soft_delete_with_history(self, db: Session, id: str) -> bool:
|
||||
"""删除分类并记录历史"""
|
||||
original = self.get(db, id)
|
||||
if not original:
|
||||
return False
|
||||
|
||||
pc_hist = getattr(original, "price_coefficient", None)
|
||||
his_data = {
|
||||
"id": original.id,
|
||||
"name": original.name,
|
||||
"image_url": getattr(original, "image_url", None),
|
||||
"parent_id": original.parent_id,
|
||||
"sort_order": original.sort_order,
|
||||
"price_coefficient": pc_hist if pc_hist is not None else 0,
|
||||
"is_active": original.is_active,
|
||||
"create_at": original.create_at,
|
||||
"create_by": original.create_by,
|
||||
"create_name": original.create_name,
|
||||
"update_at": original.update_at,
|
||||
"update_by": original.update_by,
|
||||
}
|
||||
his = BasProductCategoryHis(**his_data)
|
||||
db.add(his)
|
||||
|
||||
return self.delete(db, id)
|
||||
|
||||
|
||||
category_crud = CategoryCRUD(BasProductCategory)
|
||||
@@ -1,116 +0,0 @@
|
||||
"""CRUD - 分类参数关联"""
|
||||
from typing import List
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_
|
||||
|
||||
from app.crud.base import CRUDBase, HistoryCRUD
|
||||
from app.models.category_parameter import BasCategoryParameter, BasCategoryParameterHis
|
||||
from app.schemas.category_parameter import CategoryParameterBase
|
||||
|
||||
|
||||
class CategoryParameterCRUD(CRUDBase[BasCategoryParameter]):
|
||||
"""分类参数关联CRUD操作"""
|
||||
|
||||
def get_by_category(self, db: Session, category_id: str) -> List[BasCategoryParameter]:
|
||||
"""获取分类的所有关联参数"""
|
||||
return db.query(self.model).filter(
|
||||
self.model.category_id == category_id,
|
||||
self.model.is_del == 0
|
||||
).order_by(self.model.sort_order.asc()).all()
|
||||
|
||||
def get_by_category_with_detail(self, db: Session, category_id: str) -> List[dict]:
|
||||
"""获取分类的所有关联参数(带参数详情)"""
|
||||
from app.models.product_parameter import BasProductParameter
|
||||
|
||||
results = db.query(
|
||||
BasCategoryParameter,
|
||||
BasProductParameter
|
||||
).join(
|
||||
BasProductParameter,
|
||||
and_(
|
||||
BasCategoryParameter.parameter_id == BasProductParameter.id,
|
||||
BasProductParameter.is_del == 0
|
||||
)
|
||||
).filter(
|
||||
BasCategoryParameter.category_id == category_id,
|
||||
BasCategoryParameter.is_del == 0
|
||||
).order_by(
|
||||
BasCategoryParameter.sort_order.asc()
|
||||
).all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": cp.id,
|
||||
"parameterId": cp.parameter_id,
|
||||
"parameterName": pp.name,
|
||||
"parameterCode": pp.code,
|
||||
"paramType": pp.param_type,
|
||||
"options": pp.options,
|
||||
"placeholder": pp.placeholder,
|
||||
"isRequired": cp.is_required,
|
||||
"defaultValue": cp.default_value,
|
||||
"sortOrder": cp.sort_order,
|
||||
}
|
||||
for cp, pp in results
|
||||
]
|
||||
|
||||
def batch_save(
|
||||
self,
|
||||
db: Session,
|
||||
category_id: str,
|
||||
items: List[dict],
|
||||
creator: dict = None
|
||||
) -> List[BasCategoryParameter]:
|
||||
"""批量保存分类参数关联"""
|
||||
# 先获取要删除的记录ID
|
||||
old_records = db.query(self.model).filter(
|
||||
self.model.category_id == category_id,
|
||||
self.model.is_del == 0
|
||||
).all()
|
||||
|
||||
# 物理删除旧记录(释放唯一索引约束)
|
||||
for record in old_records:
|
||||
db.delete(record)
|
||||
db.commit()
|
||||
|
||||
# 创建新的关联
|
||||
saved_items = []
|
||||
for idx, item in enumerate(items):
|
||||
item_data = {
|
||||
"category_id": category_id,
|
||||
"parameter_id": item.get("parameter_id"),
|
||||
"is_required": item.get("is_required", 0),
|
||||
"default_value": item.get("default_value"),
|
||||
"sort_order": item.get("sort_order", idx),
|
||||
}
|
||||
cp = self.create(db, item_data, creator)
|
||||
saved_items.append(cp)
|
||||
|
||||
db.commit()
|
||||
return saved_items
|
||||
|
||||
def delete_by_category(self, db: Session, category_id: str) -> int:
|
||||
"""删除分类的所有关联"""
|
||||
count = db.query(self.model).filter(
|
||||
self.model.category_id == category_id,
|
||||
self.model.is_del == 0
|
||||
).update({"is_del": 1})
|
||||
db.commit()
|
||||
return count
|
||||
|
||||
def exists_link(self, db: Session, category_id: str, parameter_id: str) -> bool:
|
||||
"""检查是否已有关联"""
|
||||
return db.query(self.model).filter(
|
||||
self.model.category_id == category_id,
|
||||
self.model.parameter_id == parameter_id,
|
||||
self.model.is_del == 0
|
||||
).first() is not None
|
||||
|
||||
|
||||
class CategoryParameterHisCRUD(HistoryCRUD[BasCategoryParameterHis]):
|
||||
"""分类参数关联历史CRUD操作"""
|
||||
pass
|
||||
|
||||
|
||||
category_parameter = CategoryParameterCRUD(BasCategoryParameter)
|
||||
category_parameter_his = CategoryParameterHisCRUD(BasCategoryParameterHis)
|
||||
@@ -1,280 +0,0 @@
|
||||
"""客户购物车 CRUD"""
|
||||
from decimal import Decimal
|
||||
from typing import List, Optional, Dict, Any
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_
|
||||
|
||||
from app.models.models import BizClientCart, BizClientCartHis
|
||||
from app.core.utils import generate_id
|
||||
from app.crud.price_list import price_list_crud, price_list_item_crud
|
||||
from app.crud.unit import unit_crud
|
||||
from app.crud.product import product_crud
|
||||
from app.crud.category import category_crud
|
||||
|
||||
|
||||
class ClientCartCRUD:
|
||||
def list_by_client(self, db: Session, client_id: str) -> List[BizClientCart]:
|
||||
return (
|
||||
db.query(BizClientCart)
|
||||
.filter(and_(BizClientCart.client_id == client_id, BizClientCart.is_del == 0))
|
||||
.order_by(BizClientCart.create_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
def sync_to_active_price_list(
|
||||
self,
|
||||
db: Session,
|
||||
client_id: str,
|
||||
region_id: str,
|
||||
operator: dict,
|
||||
rows: Optional[List[BizClientCart]] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
将购物车中仍属于当前生效价格清单的商品行,同步为区域最新生效清单的单价、单位与清单 ID。
|
||||
同一商品多条行(历史清单 ID 不同)合并数量并保留一行。
|
||||
无生效清单时返回 False,不修改数据。
|
||||
"""
|
||||
pl = price_list_crud.get_active(db, region_id)
|
||||
if not pl:
|
||||
return False
|
||||
|
||||
rows = rows if rows is not None else self.list_by_client(db, client_id)
|
||||
if not rows:
|
||||
return True
|
||||
|
||||
# 快速路径:如果购物车所有行已经属于当前生效价格清单,则无需做任何同步。
|
||||
# 这能避免在“频繁加购/改数量”场景下对整车做 update + his insert,导致卡顿。
|
||||
if all(getattr(r, "price_list_id", None) == pl.id for r in rows):
|
||||
return True
|
||||
|
||||
price_items = price_list_item_crud.get_by_price_list(db, pl.id)
|
||||
pmap: Dict[str, Any] = {i.product_id: i for i in price_items}
|
||||
|
||||
by_product: Dict[str, List[BizClientCart]] = defaultdict(list)
|
||||
for r in rows:
|
||||
if r.product_id in pmap:
|
||||
by_product[r.product_id].append(r)
|
||||
|
||||
op_uid = operator.get("user_id")
|
||||
now = datetime.now()
|
||||
|
||||
changed = False
|
||||
|
||||
for product_id, rel in by_product.items():
|
||||
pi = pmap[product_id]
|
||||
total_qty = sum(Decimal(str(r.quantity)) for r in rel)
|
||||
if total_qty <= 0:
|
||||
continue
|
||||
new_unit_price = Decimal(str(pi.price))
|
||||
new_amount = (total_qty * new_unit_price).quantize(Decimal("0.01"))
|
||||
keep = next((r for r in rel if r.price_list_id == pl.id), rel[0])
|
||||
|
||||
for r in rel:
|
||||
if r.id != keep.id:
|
||||
self._copy_to_his(db, r)
|
||||
r.is_del = 1
|
||||
r.update_by = op_uid
|
||||
r.update_at = now
|
||||
changed = True
|
||||
|
||||
# 只有发生实际变化时才写历史并更新主表,避免“重复同步”导致的历史膨胀与写放大
|
||||
product = product_crud.get(db, product_id)
|
||||
unit = unit_crud.get(db, pi.unit_id) if pi.unit_id else None
|
||||
cat_name = keep.category_name or ""
|
||||
if product and product.category_id:
|
||||
cat = category_crud.get(db, product.category_id)
|
||||
cat_name = cat.name if cat else cat_name
|
||||
|
||||
need_update = (
|
||||
keep.price_list_id != pl.id
|
||||
or keep.unit_id != pi.unit_id
|
||||
or Decimal(str(keep.quantity)) != total_qty
|
||||
or Decimal(str(keep.unit_price)) != new_unit_price
|
||||
or Decimal(str(keep.amount)) != new_amount
|
||||
or (product and (keep.product_name != product.name or keep.image_url != product.image_url))
|
||||
or keep.unit_name != (unit.name if unit else keep.unit_name)
|
||||
or keep.category_name != (cat_name or keep.category_name)
|
||||
)
|
||||
if need_update:
|
||||
self._copy_to_his(db, keep)
|
||||
keep.price_list_id = pl.id
|
||||
keep.unit_id = pi.unit_id
|
||||
keep.quantity = total_qty
|
||||
keep.unit_price = new_unit_price
|
||||
keep.amount = new_amount
|
||||
keep.product_name = product.name if product else keep.product_name
|
||||
keep.unit_name = unit.name if unit else keep.unit_name
|
||||
keep.category_name = cat_name or ""
|
||||
keep.image_url = product.image_url if product else keep.image_url
|
||||
keep.update_by = op_uid
|
||||
keep.update_at = now
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
def get(self, db: Session, cart_id: str) -> Optional[BizClientCart]:
|
||||
return (
|
||||
db.query(BizClientCart)
|
||||
.filter(and_(BizClientCart.id == cart_id, BizClientCart.is_del == 0))
|
||||
.first()
|
||||
)
|
||||
|
||||
def _copy_to_his(self, db: Session, row: BizClientCart) -> None:
|
||||
his = BizClientCartHis(
|
||||
id=row.id,
|
||||
client_id=row.client_id,
|
||||
price_list_id=row.price_list_id,
|
||||
product_id=row.product_id,
|
||||
unit_id=row.unit_id,
|
||||
quantity=row.quantity,
|
||||
unit_price=row.unit_price,
|
||||
amount=row.amount,
|
||||
product_name=row.product_name,
|
||||
unit_name=row.unit_name,
|
||||
category_name=row.category_name,
|
||||
image_url=row.image_url,
|
||||
create_at=row.create_at,
|
||||
create_by=row.create_by,
|
||||
create_name=row.create_name,
|
||||
update_at=row.update_at,
|
||||
update_by=row.update_by,
|
||||
his_create_at=datetime.now(),
|
||||
)
|
||||
db.add(his)
|
||||
|
||||
def add_or_update(
|
||||
self,
|
||||
db: Session,
|
||||
client_id: str,
|
||||
price_list_id: str,
|
||||
product_id: str,
|
||||
unit_id: str,
|
||||
quantity: Decimal,
|
||||
unit_price: Decimal,
|
||||
product_name: str,
|
||||
unit_name: str,
|
||||
category_name: str,
|
||||
image_url: str,
|
||||
operator: dict,
|
||||
) -> BizClientCart:
|
||||
matched = (
|
||||
db.query(BizClientCart)
|
||||
.filter(
|
||||
and_(
|
||||
BizClientCart.client_id == client_id,
|
||||
BizClientCart.product_id == product_id,
|
||||
BizClientCart.price_list_id == price_list_id,
|
||||
BizClientCart.is_del == 0,
|
||||
)
|
||||
)
|
||||
.all()
|
||||
)
|
||||
op_uid = operator.get("user_id")
|
||||
now = datetime.now()
|
||||
if matched:
|
||||
# 兼容历史脏数据:同客户+同价目表+同商品可能存在多行,先合并再做增量更新。
|
||||
keep = matched[0]
|
||||
merged_qty = sum(Decimal(str(r.quantity or 0)) for r in matched)
|
||||
if merged_qty < 0:
|
||||
merged_qty = Decimal("0")
|
||||
|
||||
for row in matched[1:]:
|
||||
self._copy_to_his(db, row)
|
||||
row.is_del = 1
|
||||
row.update_by = op_uid
|
||||
row.update_at = now
|
||||
|
||||
self._copy_to_his(db, keep)
|
||||
next_qty = merged_qty + Decimal(str(quantity))
|
||||
amount = (next_qty * unit_price).quantize(Decimal("0.01"))
|
||||
keep.quantity = next_qty
|
||||
keep.unit_price = unit_price
|
||||
keep.amount = amount
|
||||
keep.product_name = product_name
|
||||
keep.unit_name = unit_name
|
||||
keep.category_name = category_name
|
||||
keep.image_url = image_url
|
||||
keep.update_by = op_uid
|
||||
keep.update_at = now
|
||||
db.commit()
|
||||
return keep
|
||||
amount = (quantity * unit_price).quantize(Decimal("0.01"))
|
||||
row = BizClientCart(
|
||||
id=generate_id(),
|
||||
client_id=client_id,
|
||||
price_list_id=price_list_id,
|
||||
product_id=product_id,
|
||||
unit_id=unit_id,
|
||||
quantity=quantity,
|
||||
unit_price=unit_price,
|
||||
amount=amount,
|
||||
product_name=product_name,
|
||||
unit_name=unit_name,
|
||||
category_name=category_name or "",
|
||||
image_url=image_url,
|
||||
is_del=0,
|
||||
create_by=op_uid,
|
||||
create_name=operator.get("real_name"),
|
||||
update_by=op_uid,
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
return row
|
||||
|
||||
def update_quantity(
|
||||
self,
|
||||
db: Session,
|
||||
cart_id: str,
|
||||
client_id: str,
|
||||
quantity: Decimal,
|
||||
operator: dict,
|
||||
current_row: Optional[BizClientCart] = None,
|
||||
) -> Optional[BizClientCart]:
|
||||
row = current_row or self.get(db, cart_id)
|
||||
if not row or row.client_id != client_id:
|
||||
return None
|
||||
self._copy_to_his(db, row)
|
||||
row.quantity = quantity
|
||||
row.amount = (quantity * row.unit_price).quantize(Decimal("0.01"))
|
||||
row.update_by = operator.get("user_id")
|
||||
row.update_at = datetime.now()
|
||||
db.commit()
|
||||
return row
|
||||
|
||||
def soft_delete(self, db: Session, cart_id: str, client_id: str, operator: dict) -> bool:
|
||||
row = self.get(db, cart_id)
|
||||
if not row or row.client_id != client_id:
|
||||
return False
|
||||
self._copy_to_his(db, row)
|
||||
row.is_del = 1
|
||||
row.update_by = operator.get("user_id")
|
||||
row.update_at = datetime.now()
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
def clear(self, db: Session, client_id: str, operator: dict) -> int:
|
||||
rows = self.list_by_client(db, client_id)
|
||||
n = 0
|
||||
for row in rows:
|
||||
self._copy_to_his(db, row)
|
||||
row.is_del = 1
|
||||
row.update_by = operator.get("user_id")
|
||||
row.update_at = datetime.now()
|
||||
n += 1
|
||||
if n:
|
||||
db.commit()
|
||||
return n
|
||||
|
||||
def batch_delete(self, db: Session, client_id: str, cart_ids: List[str], operator: dict) -> int:
|
||||
n = 0
|
||||
for cid in cart_ids:
|
||||
if self.soft_delete(db, cid, client_id, operator):
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
client_cart_crud = ClientCartCRUD()
|
||||
@@ -1,66 +0,0 @@
|
||||
"""配送员评价 CRUD"""
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, func
|
||||
|
||||
from app.crud.base import CRUDBase
|
||||
from app.core.utils import generate_id
|
||||
from app.models.models import BizDelivererReview
|
||||
|
||||
|
||||
class DelivererReviewCRUD(CRUDBase[BizDelivererReview]):
|
||||
"""配送员评价 CRUD"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(BizDelivererReview)
|
||||
|
||||
def get_by_order(self, db: Session, order_id: str) -> Optional[BizDelivererReview]:
|
||||
return db.query(self.model).filter(
|
||||
and_(
|
||||
self.model.order_id == order_id,
|
||||
self.model.is_del == 0
|
||||
)
|
||||
).first()
|
||||
|
||||
def create_review(
|
||||
self,
|
||||
db: Session,
|
||||
order_id: str,
|
||||
task_id: Optional[str],
|
||||
client_id: str,
|
||||
deliverer_id: str,
|
||||
score: int,
|
||||
tags: Optional[List[str]] = None,
|
||||
content: Optional[str] = None,
|
||||
is_anonymous: bool = False,
|
||||
creator: Optional[dict] = None
|
||||
) -> BizDelivererReview:
|
||||
data = {
|
||||
"id": generate_id(),
|
||||
"order_id": order_id,
|
||||
"task_id": task_id,
|
||||
"client_id": client_id,
|
||||
"deliverer_id": deliverer_id,
|
||||
"score": score,
|
||||
"tags": ",".join(tags) if tags else None,
|
||||
"content": content,
|
||||
"is_anonymous": is_anonymous,
|
||||
"create_by": creator.get("user_id") if creator else None,
|
||||
"create_name": creator.get("real_name") if creator else None
|
||||
}
|
||||
return self.create(db, data)
|
||||
|
||||
def get_deliverer_rating_stats(self, db: Session, deliverer_id: str) -> dict:
|
||||
total = db.query(func.count(self.model.id)).filter(
|
||||
and_(self.model.deliverer_id == deliverer_id, self.model.is_del == 0)
|
||||
).scalar() or 0
|
||||
avg_score = db.query(func.avg(self.model.score)).filter(
|
||||
and_(self.model.deliverer_id == deliverer_id, self.model.is_del == 0)
|
||||
).scalar()
|
||||
return {
|
||||
"total": int(total),
|
||||
"avgScore": round(float(avg_score), 2) if avg_score is not None else 0.0
|
||||
}
|
||||
|
||||
|
||||
deliverer_review_crud = DelivererReviewCRUD()
|
||||
@@ -1,872 +0,0 @@
|
||||
"""配送任务CRUD"""
|
||||
import json
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, func, or_, select, exists
|
||||
|
||||
from app.models.models import BizDeliveryTask, BizDeliveryTaskItem, BizDeliveryTaskHis, BizOrder
|
||||
from app.crud.base import CRUDBase
|
||||
from app.core.utils import generate_id
|
||||
|
||||
|
||||
class DeliveryTaskCRUD(CRUDBase[BizDeliveryTask]):
|
||||
"""配送任务CRUD操作"""
|
||||
|
||||
@staticmethod
|
||||
def _sync_legacy_status_from_dual(task: BizDeliveryTask) -> None:
|
||||
"""根据双状态回写兼容字段 status。"""
|
||||
ds = (getattr(task, "deliverer_status", None) or "").strip()
|
||||
ss = (getattr(task, "supplier_prepare_status", None) or "").strip()
|
||||
if ds == "delivered":
|
||||
task.status = "completed"
|
||||
elif ds == "delivering":
|
||||
task.status = "delivering"
|
||||
elif ss == "stocking":
|
||||
task.status = "supplier_stocking"
|
||||
elif ss == "prepared":
|
||||
task.status = "supplier_ready"
|
||||
else:
|
||||
task.status = "assigned" if task.deliver_id else "pending"
|
||||
|
||||
def get_by_order(self, db: Session, order_id: str) -> Optional[BizDeliveryTask]:
|
||||
"""根据订单ID获取配送任务"""
|
||||
return db.query(BizDeliveryTask).filter(
|
||||
and_(
|
||||
BizDeliveryTask.order_id == order_id,
|
||||
BizDeliveryTask.is_del == 0
|
||||
)
|
||||
).first()
|
||||
|
||||
def get_multi_with_filters(
|
||||
self,
|
||||
db: Session,
|
||||
region_id: str = None,
|
||||
supplier_id: str = None,
|
||||
status: str = None,
|
||||
supplier_prepare_status: str = None,
|
||||
order_no: str = None,
|
||||
deliver_id: str = None,
|
||||
keyword: str = None,
|
||||
skip: int = 0,
|
||||
limit: int = 10
|
||||
) -> tuple[List[BizDeliveryTask], int]:
|
||||
"""多条件查询配送任务"""
|
||||
query = db.query(BizDeliveryTask).filter(BizDeliveryTask.is_del == 0)
|
||||
|
||||
if region_id:
|
||||
query = query.filter(BizDeliveryTask.region_id == region_id)
|
||||
if supplier_id:
|
||||
query = query.filter(BizDeliveryTask.supplier_id == supplier_id)
|
||||
if supplier_prepare_status:
|
||||
parts = [s.strip() for s in supplier_prepare_status.split(",") if s.strip()]
|
||||
if parts:
|
||||
if len(parts) > 1:
|
||||
query = query.filter(BizDeliveryTask.supplier_prepare_status.in_(parts))
|
||||
else:
|
||||
p0 = parts[0]
|
||||
if p0 == "pending_prepare":
|
||||
query = query.filter(
|
||||
or_(
|
||||
BizDeliveryTask.supplier_prepare_status == "pending_prepare",
|
||||
BizDeliveryTask.supplier_prepare_status.is_(None),
|
||||
)
|
||||
)
|
||||
else:
|
||||
query = query.filter(BizDeliveryTask.supplier_prepare_status == p0)
|
||||
elif status:
|
||||
parts = [s.strip() for s in status.split(",") if s.strip()]
|
||||
if parts:
|
||||
if len(parts) > 1:
|
||||
query = query.filter(BizDeliveryTask.status.in_(parts))
|
||||
else:
|
||||
query = query.filter(BizDeliveryTask.status == parts[0])
|
||||
if order_no:
|
||||
query = query.filter(BizDeliveryTask.task_no.like(f"%{order_no}%"))
|
||||
if deliver_id:
|
||||
query = query.filter(BizDeliveryTask.deliver_id == deliver_id)
|
||||
if keyword:
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
pattern = f"%{kw}%"
|
||||
order_match = exists(
|
||||
select(1).select_from(BizOrder).where(
|
||||
and_(
|
||||
BizOrder.id == BizDeliveryTask.order_id,
|
||||
BizOrder.is_del == 0,
|
||||
or_(
|
||||
BizOrder.order_no.like(pattern),
|
||||
BizOrder.contact_person.like(pattern),
|
||||
BizOrder.contact_phone.like(pattern),
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
query = query.filter(
|
||||
or_(
|
||||
BizDeliveryTask.task_no.like(pattern),
|
||||
BizDeliveryTask.order_id.like(pattern),
|
||||
order_match,
|
||||
)
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
tasks = query.order_by(BizDeliveryTask.create_at.desc()).offset(skip).limit(limit).all()
|
||||
return tasks, total
|
||||
|
||||
def get_by_deliver(
|
||||
self,
|
||||
db: Session,
|
||||
deliver_id: str,
|
||||
status: str = None,
|
||||
deliverer_status: str = None,
|
||||
skip: int = 0,
|
||||
limit: int = 10
|
||||
) -> tuple[List[BizDeliveryTask], int]:
|
||||
"""根据配送员获取配送任务。优先 deliverer_status(小程序);否则按兼容字段 status。"""
|
||||
query = db.query(BizDeliveryTask).filter(
|
||||
and_(
|
||||
BizDeliveryTask.deliver_id == deliver_id,
|
||||
BizDeliveryTask.is_del == 0,
|
||||
BizDeliveryTask.status != "cancelled",
|
||||
)
|
||||
)
|
||||
|
||||
if deliverer_status:
|
||||
parts = [s.strip() for s in deliverer_status.split(",") if s.strip()]
|
||||
if parts:
|
||||
if len(parts) > 1:
|
||||
query = query.filter(BizDeliveryTask.deliverer_status.in_(parts))
|
||||
elif parts[0] == "pending_pickup":
|
||||
query = query.filter(
|
||||
or_(
|
||||
BizDeliveryTask.deliverer_status == "pending_pickup",
|
||||
BizDeliveryTask.deliverer_status.is_(None),
|
||||
)
|
||||
)
|
||||
else:
|
||||
query = query.filter(BizDeliveryTask.deliverer_status == parts[0])
|
||||
elif status:
|
||||
parts = [s.strip() for s in status.split(",") if s.strip()]
|
||||
if parts:
|
||||
if len(parts) > 1:
|
||||
query = query.filter(BizDeliveryTask.status.in_(parts))
|
||||
else:
|
||||
query = query.filter(BizDeliveryTask.status == parts[0])
|
||||
|
||||
total = query.count()
|
||||
tasks = query.order_by(BizDeliveryTask.create_at.desc()).offset(skip).limit(limit).all()
|
||||
return tasks, total
|
||||
|
||||
def get_by_supplier(
|
||||
self,
|
||||
db: Session,
|
||||
supplier_id: str,
|
||||
status: str = None,
|
||||
skip: int = 0,
|
||||
limit: int = 10
|
||||
) -> tuple[List[BizDeliveryTask], int]:
|
||||
"""根据供应商获取配送任务"""
|
||||
query = db.query(BizDeliveryTask).filter(
|
||||
and_(
|
||||
BizDeliveryTask.supplier_id == supplier_id,
|
||||
BizDeliveryTask.is_del == 0
|
||||
)
|
||||
)
|
||||
|
||||
if status:
|
||||
query = query.filter(BizDeliveryTask.status == status)
|
||||
|
||||
total = query.count()
|
||||
tasks = query.order_by(BizDeliveryTask.create_at.desc()).offset(skip).limit(limit).all()
|
||||
return tasks, total
|
||||
|
||||
def get_pending_list(
|
||||
self,
|
||||
db: Session,
|
||||
region_id: str = None,
|
||||
skip: int = 0,
|
||||
limit: int = 10
|
||||
) -> tuple[List[BizDeliveryTask], int]:
|
||||
"""获取待分配配送的任务"""
|
||||
query = db.query(BizDeliveryTask).filter(
|
||||
and_(
|
||||
BizDeliveryTask.status == "pending",
|
||||
BizDeliveryTask.is_del == 0
|
||||
)
|
||||
)
|
||||
|
||||
if region_id:
|
||||
query = query.filter(BizDeliveryTask.region_id == region_id)
|
||||
|
||||
total = query.count()
|
||||
tasks = query.order_by(BizDeliveryTask.create_at.asc()).offset(skip).limit(limit).all()
|
||||
return tasks, total
|
||||
|
||||
def assign_deliver(
|
||||
self,
|
||||
db: Session,
|
||||
order_id: str,
|
||||
deliver_id: str,
|
||||
operator_id: str = None
|
||||
) -> Optional[BizDeliveryTask]:
|
||||
"""分配配送员"""
|
||||
task = self.get_by_order(db, order_id)
|
||||
if not task:
|
||||
return None
|
||||
|
||||
if task.status != "pending":
|
||||
return None
|
||||
|
||||
task.deliver_id = deliver_id
|
||||
task.deliverer_status = "pending_pickup"
|
||||
task.supplier_prepare_status = "pending_prepare"
|
||||
self._sync_legacy_status_from_dual(task)
|
||||
task.update_at = datetime.now()
|
||||
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
return task
|
||||
|
||||
def assign_deliver_batch(
|
||||
self,
|
||||
db: Session,
|
||||
assignments: List[dict]
|
||||
) -> List[BizDeliveryTask]:
|
||||
"""批量分配配送员"""
|
||||
results = []
|
||||
for assignment in assignments:
|
||||
order_id = assignment.get("orderId")
|
||||
deliver_id = assignment.get("deliverId")
|
||||
task = self.assign_deliver(db, order_id, deliver_id)
|
||||
if task:
|
||||
results.append(task)
|
||||
return results
|
||||
|
||||
def update_status(
|
||||
self,
|
||||
db: Session,
|
||||
task_id: str,
|
||||
new_status: str,
|
||||
operator_id: str = None,
|
||||
remark: str = None,
|
||||
image_urls: List[str] = None,
|
||||
operator_real_name: str = None,
|
||||
operator_role: str = "deliver",
|
||||
) -> Optional[BizDeliveryTask]:
|
||||
"""更新配送任务状态"""
|
||||
task = self.get(db, task_id)
|
||||
if not task:
|
||||
return None
|
||||
|
||||
old_status = task.status
|
||||
task.status = new_status
|
||||
task.update_at = datetime.now()
|
||||
|
||||
if new_status == "picking":
|
||||
# 配送员已取货:进入配送中(勿再保留 pending_pickup,否则列表/标题仍显示「待取货」)
|
||||
task.deliverer_status = "delivering"
|
||||
task.supplier_prepare_status = "picked_up"
|
||||
task.pickup_time = datetime.now()
|
||||
if remark:
|
||||
task.pickup_remark = remark
|
||||
if image_urls:
|
||||
task.pickup_image_url = ",".join(image_urls) if len(image_urls) > 0 else None
|
||||
elif new_status == "supplier_stocking":
|
||||
task.supplier_prepare_status = "stocking"
|
||||
elif new_status == "supplier_ready":
|
||||
task.supplier_prepare_status = "prepared"
|
||||
# 备货完成凭证(与配送员取货凭证共用字段,时间线区分为供应商操作)
|
||||
if remark:
|
||||
task.pickup_remark = remark
|
||||
if image_urls:
|
||||
task.pickup_image_url = ",".join(image_urls) if len(image_urls) > 0 else None
|
||||
elif new_status == "delivering":
|
||||
task.deliverer_status = "delivering"
|
||||
if remark:
|
||||
task.delivery_remark = remark
|
||||
if image_urls:
|
||||
task.delivery_image_url = ",".join(image_urls) if len(image_urls) > 0 else None
|
||||
|
||||
elif new_status == "completed":
|
||||
task.deliverer_status = "delivered"
|
||||
# 送达确认时间:与模型注释一致;小程序取货后直达 completed,必须在此写入
|
||||
task.delivery_time = datetime.now()
|
||||
if remark:
|
||||
task.delivery_remark = remark
|
||||
if image_urls:
|
||||
task.delivery_image_url = ",".join(image_urls) if len(image_urls) > 0 else None
|
||||
|
||||
self._sync_legacy_status_from_dual(task)
|
||||
|
||||
# 写入订单「操作记录」:legacy task.status 若已为 picking,会与取货入参 new_status 相同而漏记;
|
||||
# 配送员确认取货时必须仍写入一条。
|
||||
if task.order_id:
|
||||
from app.crud.order import order_crud
|
||||
|
||||
should_log_order = (old_status != new_status) or new_status == "picking"
|
||||
if should_log_order:
|
||||
order_crud._create_status_log(
|
||||
db,
|
||||
task.order_id,
|
||||
f"dt_{old_status}" if old_status is not None else "",
|
||||
f"dt_{new_status}",
|
||||
operator_id or "system",
|
||||
operator_role or "deliver",
|
||||
reason=remark,
|
||||
create_name=(operator_real_name or "").strip() or None,
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
|
||||
if new_status == "delivering":
|
||||
from app.crud.delivery_warning_resolve import auto_resolve_open_warnings_for_task
|
||||
|
||||
auto_resolve_open_warnings_for_task(db, task_id)
|
||||
|
||||
return task
|
||||
|
||||
def cancel_task(
|
||||
self,
|
||||
db: Session,
|
||||
task_id: str,
|
||||
reason: str = None,
|
||||
operator_id: str = None,
|
||||
operator_real_name: str = None,
|
||||
) -> Optional[BizDeliveryTask]:
|
||||
"""取消配送任务"""
|
||||
task = self.get(db, task_id)
|
||||
if not task:
|
||||
return None
|
||||
|
||||
if task.status in ["completed", "cancelled"]:
|
||||
return None
|
||||
|
||||
old_status = task.status
|
||||
task.status = "cancelled"
|
||||
task.update_at = datetime.now()
|
||||
|
||||
if task.order_id:
|
||||
from app.crud.order import order_crud
|
||||
|
||||
order_crud._create_status_log(
|
||||
db,
|
||||
task.order_id,
|
||||
f"dt_{old_status}",
|
||||
"dt_cancelled",
|
||||
operator_id or "system",
|
||||
"admin",
|
||||
reason=reason,
|
||||
create_name=(operator_real_name or "").strip() or None,
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
|
||||
from app.crud.delivery_warning_resolve import auto_resolve_open_warnings_for_task
|
||||
|
||||
auto_resolve_open_warnings_for_task(db, task_id)
|
||||
|
||||
return task
|
||||
|
||||
def reassign(
|
||||
self,
|
||||
db: Session,
|
||||
task_id: str,
|
||||
new_deliver_id: str,
|
||||
operator_id: str = None,
|
||||
operator_real_name: str = None,
|
||||
) -> Optional[BizDeliveryTask]:
|
||||
"""重新分配配送员"""
|
||||
from app.crud.order import order_crud
|
||||
from app.crud.user import user_crud
|
||||
|
||||
task = self.get(db, task_id)
|
||||
if not task:
|
||||
return None
|
||||
|
||||
if task.status in ["completed", "cancelled"]:
|
||||
return None
|
||||
|
||||
old_deliver_id = task.deliver_id
|
||||
if old_deliver_id == new_deliver_id:
|
||||
return task
|
||||
|
||||
updater = None
|
||||
if operator_id:
|
||||
updater = {"user_id": operator_id, "real_name": operator_real_name}
|
||||
|
||||
self.update_with_history(
|
||||
db,
|
||||
task_id,
|
||||
{"deliver_id": new_deliver_id},
|
||||
updater=updater,
|
||||
)
|
||||
task = self.get(db, task_id)
|
||||
if not task:
|
||||
return None
|
||||
|
||||
def _dn(uid: Optional[str]) -> Optional[str]:
|
||||
if not uid:
|
||||
return None
|
||||
u = user_crud.get(db, uid)
|
||||
return (u.real_name if u else None) or uid
|
||||
|
||||
nu = user_crud.get(db, new_deliver_id)
|
||||
order_crud._create_status_log(
|
||||
db,
|
||||
task.order_id,
|
||||
"",
|
||||
"delivery.reassign",
|
||||
operator_id or "system",
|
||||
"admin",
|
||||
reason=json.dumps(
|
||||
{
|
||||
"fromDeliverId": old_deliver_id,
|
||||
"toDeliverId": new_deliver_id,
|
||||
"fromDeliverName": _dn(old_deliver_id),
|
||||
"toDeliverName": nu.real_name if nu else new_deliver_id,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
create_name=(operator_real_name or "").strip() or None,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
return task
|
||||
|
||||
def get_warning_tasks(
|
||||
self,
|
||||
db: Session,
|
||||
region_id: str = None,
|
||||
within_minutes: int = 60,
|
||||
skip: int = 0,
|
||||
limit: int = 10
|
||||
) -> tuple[List[BizDeliveryTask], int, int]:
|
||||
"""获取时效预警任务"""
|
||||
now = datetime.now()
|
||||
expire_time_threshold = now + timedelta(minutes=within_minutes)
|
||||
|
||||
query = db.query(BizDeliveryTask).filter(
|
||||
and_(
|
||||
BizDeliveryTask.status.in_(["pending", "assigned", "picking"]),
|
||||
BizDeliveryTask.is_del == 0,
|
||||
BizDeliveryTask.effective_time <= expire_time_threshold
|
||||
)
|
||||
)
|
||||
|
||||
if region_id:
|
||||
query = query.filter(BizDeliveryTask.region_id == region_id)
|
||||
|
||||
total = query.count()
|
||||
warning_count = query.filter(
|
||||
BizDeliveryTask.effective_time <= now + timedelta(minutes=30)
|
||||
).count()
|
||||
|
||||
tasks = query.order_by(BizDeliveryTask.effective_time.asc()).offset(skip).limit(limit).all()
|
||||
return tasks, total, warning_count
|
||||
|
||||
def _generate_task_no(self, db: Session) -> str:
|
||||
"""生成配送单号"""
|
||||
today = datetime.now().strftime("%Y%m%d")
|
||||
prefix = f"DLV{today}"
|
||||
|
||||
last_task = db.query(BizDeliveryTask).filter(
|
||||
BizDeliveryTask.task_no.like(f"{prefix}%")
|
||||
).order_by(BizDeliveryTask.task_no.desc()).first()
|
||||
|
||||
if last_task:
|
||||
last_no = int(last_task.task_no[-4:])
|
||||
new_no = last_no + 1
|
||||
else:
|
||||
new_no = 1
|
||||
|
||||
return f"{prefix}{str(new_no).zfill(4)}"
|
||||
|
||||
def create_from_order(
|
||||
self,
|
||||
db: Session,
|
||||
order_id: str,
|
||||
supplier_id: str,
|
||||
region_id: str,
|
||||
creator: dict = None
|
||||
) -> BizDeliveryTask:
|
||||
"""从订单创建配送任务"""
|
||||
task_no = self._generate_task_no(db)
|
||||
|
||||
# 设置配送截止时间(默认4小时)
|
||||
effective_time = datetime.now() + timedelta(hours=4)
|
||||
|
||||
task = BizDeliveryTask(
|
||||
id=generate_id(),
|
||||
task_no=task_no,
|
||||
order_id=order_id,
|
||||
region_id=region_id,
|
||||
supplier_id=supplier_id,
|
||||
status="pending",
|
||||
deliverer_status="pending_pickup",
|
||||
supplier_prepare_status="pending_prepare",
|
||||
effective_time=effective_time,
|
||||
create_by=creator.get("user_id") if creator else None,
|
||||
create_name=creator.get("real_name") if creator else None
|
||||
)
|
||||
db.add(task)
|
||||
db.flush()
|
||||
|
||||
return task
|
||||
|
||||
def create_delivery_task_from_order(
|
||||
self,
|
||||
db: Session,
|
||||
order_id: str,
|
||||
order_items: List[dict],
|
||||
supplier_id: str,
|
||||
region_id: str,
|
||||
creator: dict = None
|
||||
) -> tuple:
|
||||
"""从订单创建完整的配送任务(包含明细)"""
|
||||
from app.crud.order import order_item_crud
|
||||
|
||||
# 创建配送任务
|
||||
task = self.create_from_order(
|
||||
db, order_id, supplier_id, region_id, creator
|
||||
)
|
||||
|
||||
# 创建配送任务明细
|
||||
task_items = []
|
||||
for item_data in order_items:
|
||||
# 获取订单项信息
|
||||
order_item = order_item_crud.get(db, item_data.get("order_item_id"))
|
||||
if not order_item:
|
||||
continue
|
||||
|
||||
task_item = BizDeliveryTaskItem(
|
||||
id=generate_id(),
|
||||
task_id=task.id,
|
||||
order_id=order_id,
|
||||
order_item_id=item_data.get("order_item_id"),
|
||||
product_id=item_data.get("product_id"),
|
||||
client_id=item_data.get("client_id"),
|
||||
delivery_address=item_data.get("delivery_address"),
|
||||
contact_person=item_data.get("contact_person"),
|
||||
contact_phone=item_data.get("contact_phone"),
|
||||
quantity=Decimal(str(item_data.get("quantity", 0))),
|
||||
status="pending",
|
||||
create_by=creator.get("user_id") if creator else None,
|
||||
create_name=creator.get("real_name") if creator else None
|
||||
)
|
||||
db.add(task_item)
|
||||
task_items.append(task_item)
|
||||
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
for item in task_items:
|
||||
db.refresh(item)
|
||||
|
||||
return task, task_items
|
||||
|
||||
def get_expiring_tasks(
|
||||
self,
|
||||
db: Session,
|
||||
within_minutes: int = 60,
|
||||
region_id: str = None
|
||||
) -> List[BizDeliveryTask]:
|
||||
"""获取即将过期的配送任务(用于定时任务)"""
|
||||
now = datetime.now()
|
||||
expire_threshold = now + timedelta(minutes=within_minutes)
|
||||
|
||||
query = db.query(BizDeliveryTask).filter(
|
||||
and_(
|
||||
BizDeliveryTask.status.in_(["pending", "assigned", "picking"]),
|
||||
BizDeliveryTask.is_del == 0,
|
||||
BizDeliveryTask.effective_time <= expire_threshold,
|
||||
BizDeliveryTask.effective_time >= now
|
||||
)
|
||||
)
|
||||
|
||||
if region_id:
|
||||
query = query.filter(BizDeliveryTask.region_id == region_id)
|
||||
|
||||
return query.order_by(BizDeliveryTask.effective_time.asc()).all()
|
||||
|
||||
def update_with_history(self, db: Session, id: str, obj_in: dict, updater: dict = None) -> Optional[BizDeliveryTask]:
|
||||
"""更新配送任务并记录历史"""
|
||||
original = self.get(db, id)
|
||||
if not original:
|
||||
return None
|
||||
|
||||
his_data = {
|
||||
"id": generate_id(),
|
||||
"task_no": original.task_no,
|
||||
"order_id": original.order_id,
|
||||
"region_id": original.region_id,
|
||||
"supplier_id": original.supplier_id,
|
||||
"deliver_id": original.deliver_id,
|
||||
"status": original.status,
|
||||
"deliverer_status": getattr(original, "deliverer_status", None),
|
||||
"supplier_prepare_status": getattr(original, "supplier_prepare_status", None),
|
||||
"pickup_time": original.pickup_time,
|
||||
"pickup_remark": original.pickup_remark,
|
||||
"pickup_image_url": original.pickup_image_url,
|
||||
"delivery_time": original.delivery_time,
|
||||
"delivery_remark": original.delivery_remark,
|
||||
"delivery_image_url": original.delivery_image_url,
|
||||
"expire_warning_sent": getattr(original, "expire_warning_sent", False),
|
||||
"effective_time": original.effective_time,
|
||||
"dispatch_remark": getattr(original, "dispatch_remark", None),
|
||||
"create_at": original.create_at,
|
||||
"create_by": original.create_by,
|
||||
"create_name": original.create_name,
|
||||
"update_at": original.update_at,
|
||||
"update_by": original.update_by,
|
||||
}
|
||||
his = BizDeliveryTaskHis(**his_data)
|
||||
db.add(his)
|
||||
|
||||
return self.update(db, id, obj_in, updater)
|
||||
|
||||
# =========================================
|
||||
# 抢单相关方法
|
||||
# =========================================
|
||||
|
||||
def get_pending_snatch_tasks(
|
||||
self,
|
||||
db: Session,
|
||||
skip: int = 0,
|
||||
limit: int = 10
|
||||
) -> tuple[List[BizDeliveryTask], int]:
|
||||
"""获取待抢单的任务(状态为pending且未分配配送员)"""
|
||||
query = db.query(BizDeliveryTask).filter(
|
||||
and_(
|
||||
BizDeliveryTask.status == "pending",
|
||||
BizDeliveryTask.is_del == 0
|
||||
)
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
tasks = query.order_by(BizDeliveryTask.effective_time.asc()).offset(skip).limit(limit).all()
|
||||
return tasks, total
|
||||
|
||||
def count_pending_snatch_tasks(self, db: Session) -> int:
|
||||
"""统计待抢单的任务数量"""
|
||||
return db.query(BizDeliveryTask).filter(
|
||||
and_(
|
||||
BizDeliveryTask.status == "pending",
|
||||
BizDeliveryTask.is_del == 0
|
||||
)
|
||||
).count()
|
||||
|
||||
def assign_to_deliver(
|
||||
self,
|
||||
db: Session,
|
||||
task_id: str,
|
||||
deliver_id: str,
|
||||
deliver_name: str = None
|
||||
) -> bool:
|
||||
"""分配任务给配送员(用于抢单)"""
|
||||
task = self.get(db, task_id)
|
||||
if not task:
|
||||
return False
|
||||
|
||||
if task.status != "pending":
|
||||
return False
|
||||
|
||||
if task.deliver_id is not None:
|
||||
return False
|
||||
|
||||
task.deliver_id = deliver_id
|
||||
task.deliver_name = deliver_name
|
||||
task.deliverer_status = "pending_pickup"
|
||||
task.supplier_prepare_status = "pending_prepare"
|
||||
self._sync_legacy_status_from_dual(task)
|
||||
task.update_at = datetime.now()
|
||||
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
return True
|
||||
|
||||
def get_snatched_by_me(
|
||||
self,
|
||||
db: Session,
|
||||
deliver_id: str,
|
||||
skip: int = 0,
|
||||
limit: int = 10
|
||||
) -> tuple[List[BizDeliveryTask], int]:
|
||||
"""获取我抢到的任务"""
|
||||
query = db.query(BizDeliveryTask).filter(
|
||||
and_(
|
||||
BizDeliveryTask.deliver_id == deliver_id,
|
||||
BizDeliveryTask.status.in_(["pending", "assigned"]),
|
||||
BizDeliveryTask.is_del == 0
|
||||
)
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
tasks = query.order_by(BizDeliveryTask.effective_time.asc()).offset(skip).limit(limit).all()
|
||||
return tasks, total
|
||||
|
||||
def count_snatched_by_me(self, db: Session, deliver_id: str) -> int:
|
||||
"""统计我抢到的任务数量"""
|
||||
return db.query(BizDeliveryTask).filter(
|
||||
and_(
|
||||
BizDeliveryTask.deliver_id == deliver_id,
|
||||
BizDeliveryTask.status.in_(["pending", "assigned"]),
|
||||
BizDeliveryTask.is_del == 0
|
||||
)
|
||||
).count()
|
||||
|
||||
def count_today_snatched(self, db: Session, deliver_id: str, start_time: datetime) -> int:
|
||||
"""统计今日抢单成功数量"""
|
||||
return db.query(BizDeliveryTask).filter(
|
||||
and_(
|
||||
BizDeliveryTask.deliver_id == deliver_id,
|
||||
BizDeliveryTask.status.in_(["pending", "assigned", "picking", "delivering", "completed"]),
|
||||
BizDeliveryTask.is_del == 0,
|
||||
BizDeliveryTask.update_at >= start_time
|
||||
)
|
||||
).count()
|
||||
|
||||
def cancel_snatch(self, db: Session, task_id: str) -> bool:
|
||||
"""取消抢单"""
|
||||
task = self.get(db, task_id)
|
||||
if not task:
|
||||
return False
|
||||
|
||||
if task.status not in ["pending", "assigned"]:
|
||||
return False
|
||||
|
||||
task.deliver_id = None
|
||||
task.deliver_name = None
|
||||
task.status = "pending"
|
||||
task.update_at = datetime.now()
|
||||
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
return True
|
||||
|
||||
def get_history_by_deliver(
|
||||
self,
|
||||
db: Session,
|
||||
deliver_id: str,
|
||||
start_date: str = None,
|
||||
end_date: str = None,
|
||||
skip: int = 0,
|
||||
limit: int = 10
|
||||
) -> tuple[List[BizDeliveryTask], int]:
|
||||
"""获取配送员历史任务(已完成和已取消的)"""
|
||||
query = db.query(BizDeliveryTask).filter(
|
||||
and_(
|
||||
BizDeliveryTask.deliver_id == deliver_id,
|
||||
BizDeliveryTask.status.in_(["completed", "cancelled"]),
|
||||
BizDeliveryTask.is_del == 0
|
||||
)
|
||||
)
|
||||
|
||||
if start_date:
|
||||
query = query.filter(BizDeliveryTask.create_at >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(BizDeliveryTask.create_at <= end_date + " 23:59:59")
|
||||
|
||||
total = query.count()
|
||||
tasks = query.order_by(BizDeliveryTask.delivery_time.desc()).offset(skip).limit(limit).all()
|
||||
return tasks, total
|
||||
|
||||
|
||||
class DeliveryTaskItemCRUD(CRUDBase[BizDeliveryTaskItem]):
|
||||
"""配送任务明细CRUD操作"""
|
||||
|
||||
def get_by_task(self, db: Session, task_id: str) -> List[BizDeliveryTaskItem]:
|
||||
"""获取配送任务的所有明细"""
|
||||
return db.query(BizDeliveryTaskItem).filter(
|
||||
and_(
|
||||
BizDeliveryTaskItem.task_id == task_id,
|
||||
BizDeliveryTaskItem.is_del == 0
|
||||
)
|
||||
).all()
|
||||
|
||||
def get_delivered_quantity_by_order_item(
|
||||
self, db: Session, order_id: str
|
||||
) -> dict[str, float]:
|
||||
"""按订单行汇总当前配送任务明细上的已送达数量(与 biz_order_item.delivered_quantity 对齐展示用)"""
|
||||
task = db.query(BizDeliveryTask).filter(
|
||||
and_(BizDeliveryTask.order_id == order_id, BizDeliveryTask.is_del == 0)
|
||||
).first()
|
||||
if not task:
|
||||
return {}
|
||||
out: dict[str, float] = {}
|
||||
for ti in self.get_by_task(db, task.id):
|
||||
oid = ti.order_item_id
|
||||
if not oid:
|
||||
continue
|
||||
q = float(ti.delivered_quantity or 0)
|
||||
prev = out.get(oid, 0.0)
|
||||
if q > prev:
|
||||
out[oid] = q
|
||||
return out
|
||||
|
||||
def create_items(
|
||||
self,
|
||||
db: Session,
|
||||
task_id: str,
|
||||
items: List[dict],
|
||||
creator: dict = None
|
||||
) -> List[BizDeliveryTaskItem]:
|
||||
"""批量创建配送任务明细"""
|
||||
result = []
|
||||
for item in items:
|
||||
task_item = BizDeliveryTaskItem(
|
||||
id=generate_id(),
|
||||
task_id=task_id,
|
||||
order_id=item.get("order_id"),
|
||||
order_item_id=item.get("order_item_id"),
|
||||
product_id=item.get("product_id"),
|
||||
client_id=item.get("client_id"),
|
||||
delivery_address=item.get("delivery_address"),
|
||||
contact_person=item.get("contact_person"),
|
||||
contact_phone=item.get("contact_phone"),
|
||||
quantity=Decimal(str(item.get("quantity", 0))),
|
||||
status="pending",
|
||||
create_by=creator.get("user_id") if creator else None,
|
||||
create_name=creator.get("real_name") if creator else None
|
||||
)
|
||||
db.add(task_item)
|
||||
result.append(task_item)
|
||||
db.commit()
|
||||
for item in result:
|
||||
db.refresh(item)
|
||||
return result
|
||||
|
||||
def update_item_status(
|
||||
self,
|
||||
db: Session,
|
||||
item_id: str,
|
||||
delivered_quantity: Decimal,
|
||||
status: str = "delivered"
|
||||
) -> Optional[BizDeliveryTaskItem]:
|
||||
"""更新配送明细状态"""
|
||||
item = self.get(db, item_id)
|
||||
if not item:
|
||||
return None
|
||||
|
||||
item.delivered_quantity = delivered_quantity
|
||||
item.status = status
|
||||
if status == "delivered":
|
||||
item.delivery_time = datetime.now()
|
||||
|
||||
if item.order_item_id:
|
||||
from app.crud.order import order_item_crud
|
||||
|
||||
oi = order_item_crud.get(db, item.order_item_id)
|
||||
if oi is not None:
|
||||
oi.delivered_quantity = delivered_quantity
|
||||
oi.update_at = datetime.now()
|
||||
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
delivery_task_crud = DeliveryTaskCRUD(BizDeliveryTask)
|
||||
delivery_task_item_crud = DeliveryTaskItemCRUD(BizDeliveryTaskItem)
|
||||
@@ -1,55 +0,0 @@
|
||||
"""配送预警:任务完成/取消时自动结案(写历史后更新主表)"""
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.models import BizDeliveryWarning, BizDeliveryWarningHis
|
||||
|
||||
|
||||
def auto_resolve_open_warnings_for_task(db: Session, task_id: str) -> int:
|
||||
"""将某配送任务下未结案预警全部自动处理(配送完成等场景)。"""
|
||||
warnings = (
|
||||
db.query(BizDeliveryWarning)
|
||||
.filter(
|
||||
BizDeliveryWarning.task_id == task_id,
|
||||
BizDeliveryWarning.is_del == 0,
|
||||
BizDeliveryWarning.is_resolved == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
if not warnings:
|
||||
return 0
|
||||
|
||||
now = datetime.now()
|
||||
for warning in warnings:
|
||||
his = BizDeliveryWarningHis(
|
||||
id=warning.id,
|
||||
task_id=warning.task_id,
|
||||
order_id=warning.order_id,
|
||||
warning_type=warning.warning_type,
|
||||
urgency_level=warning.urgency_level,
|
||||
remaining_minutes=warning.remaining_minutes,
|
||||
message=warning.message,
|
||||
is_sent=warning.is_sent,
|
||||
sent_time=warning.sent_time,
|
||||
is_resolved=warning.is_resolved,
|
||||
resolved_time=warning.resolved_time,
|
||||
resolved_by=warning.resolved_by,
|
||||
resolved_note=warning.resolved_note,
|
||||
create_at=warning.create_at,
|
||||
create_by=warning.create_by,
|
||||
create_name=warning.create_name,
|
||||
update_at=warning.update_at,
|
||||
update_by=warning.update_by,
|
||||
his_create_at=now,
|
||||
)
|
||||
db.add(his)
|
||||
warning.is_resolved = 1
|
||||
warning.resolved_time = now
|
||||
warning.resolved_by = "system"
|
||||
warning.resolved_note = "auto:配送已完成"
|
||||
warning.update_at = now
|
||||
warning.update_by = "system"
|
||||
|
||||
db.commit()
|
||||
return len(warnings)
|
||||
@@ -1,231 +0,0 @@
|
||||
"""询价单CRUD"""
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, func
|
||||
|
||||
from app.models.models import BizInquiry, BizInquiryItem, BizInquiryItemHis, BizInquiryHis
|
||||
from app.crud.base import CRUDBase
|
||||
from app.core.utils import generate_id
|
||||
|
||||
|
||||
class InquiryCRUD(CRUDBase[BizInquiry]):
|
||||
"""询价单CRUD操作"""
|
||||
|
||||
def get_by_region(
|
||||
self,
|
||||
db: Session,
|
||||
region_id: str,
|
||||
status: str = None,
|
||||
skip: int = 0,
|
||||
limit: int = 20
|
||||
) -> List[BizInquiry]:
|
||||
"""根据区域获取询价单列表"""
|
||||
query = db.query(BizInquiry).filter(
|
||||
and_(
|
||||
BizInquiry.region_id == region_id,
|
||||
BizInquiry.is_del == 0
|
||||
)
|
||||
)
|
||||
if status:
|
||||
query = query.filter(BizInquiry.status == status)
|
||||
return query.order_by(BizInquiry.create_at.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
def get_published_by_region(
|
||||
self,
|
||||
db: Session,
|
||||
region_id: str,
|
||||
skip: int = 0,
|
||||
limit: int = 20
|
||||
) -> List[BizInquiry]:
|
||||
"""获取区域已发布的询价单列表"""
|
||||
return self.get_by_region(db, region_id, status="published", skip=skip, limit=limit)
|
||||
|
||||
def get_multi_with_filters(
|
||||
self,
|
||||
db: Session,
|
||||
region_id: str = None,
|
||||
status: str = None,
|
||||
keyword: str = None,
|
||||
skip: int = 0,
|
||||
limit: int = 10
|
||||
) -> tuple[List[BizInquiry], int]:
|
||||
"""多条件查询询价单"""
|
||||
query = db.query(BizInquiry).filter(BizInquiry.is_del == 0)
|
||||
|
||||
if region_id:
|
||||
query = query.filter(BizInquiry.region_id == region_id)
|
||||
if status:
|
||||
query = query.filter(BizInquiry.status == status)
|
||||
if keyword:
|
||||
query = query.filter(BizInquiry.title.like(f"%{keyword}%"))
|
||||
|
||||
total = query.count()
|
||||
inquiries = query.order_by(BizInquiry.create_at.desc()).offset(skip).limit(limit).all()
|
||||
return inquiries, total
|
||||
|
||||
def publish(self, db: Session, id: str) -> Optional[BizInquiry]:
|
||||
"""发布询价单"""
|
||||
inquiry = self.get(db, id)
|
||||
if inquiry and inquiry.status == "draft":
|
||||
inquiry.status = "published"
|
||||
inquiry.publish_time = datetime.now()
|
||||
db.commit()
|
||||
db.refresh(inquiry)
|
||||
return inquiry
|
||||
|
||||
def unpublish(self, db: Session, id: str) -> Optional[BizInquiry]:
|
||||
"""取消发布询价单(published -> draft)"""
|
||||
inquiry = self.get(db, id)
|
||||
if inquiry and inquiry.status == "published":
|
||||
inquiry.status = "draft"
|
||||
# 保留 publish_time 作为历史痕迹;如需清空可在这里置 None
|
||||
db.commit()
|
||||
db.refresh(inquiry)
|
||||
return inquiry
|
||||
|
||||
def get_current_published_by_region(self, db: Session, region_id: str) -> Optional[BizInquiry]:
|
||||
"""获取区域当前发布中的询价单(最多1条,业务上应保证互斥)"""
|
||||
return db.query(BizInquiry).filter(
|
||||
and_(
|
||||
BizInquiry.region_id == region_id,
|
||||
BizInquiry.status == "published",
|
||||
BizInquiry.is_del == 0
|
||||
)
|
||||
).order_by(BizInquiry.publish_time.desc(), BizInquiry.create_at.desc()).first()
|
||||
|
||||
def close(self, db: Session, id: str) -> Optional[BizInquiry]:
|
||||
"""关闭询价单"""
|
||||
inquiry = self.get(db, id)
|
||||
if inquiry and inquiry.status == "published":
|
||||
inquiry.status = "closed"
|
||||
inquiry.close_time = datetime.now()
|
||||
db.commit()
|
||||
db.refresh(inquiry)
|
||||
return inquiry
|
||||
|
||||
def update_with_history(self, db: Session, id: str, obj_in: dict, updater: dict = None) -> Optional[BizInquiry]:
|
||||
"""更新询价单并记录历史"""
|
||||
original = self.get(db, id)
|
||||
if not original:
|
||||
return None
|
||||
|
||||
his_data = {
|
||||
"id": original.id,
|
||||
"region_id": original.region_id,
|
||||
"title": original.title,
|
||||
"status": original.status,
|
||||
"publish_time": original.publish_time,
|
||||
"close_time": original.close_time,
|
||||
"remark": original.remark,
|
||||
"create_at": original.create_at,
|
||||
"create_by": original.create_by,
|
||||
"create_name": original.create_name,
|
||||
"update_at": original.update_at,
|
||||
"update_by": original.update_by,
|
||||
}
|
||||
his = BizInquiryHis(**his_data)
|
||||
db.add(his)
|
||||
|
||||
return self.update(db, id, obj_in, updater)
|
||||
|
||||
def soft_delete_with_history(self, db: Session, id: str) -> bool:
|
||||
"""删除询价单并记录历史"""
|
||||
original = self.get(db, id)
|
||||
if not original:
|
||||
return False
|
||||
|
||||
his_data = {
|
||||
"id": original.id,
|
||||
"region_id": original.region_id,
|
||||
"title": original.title,
|
||||
"status": original.status,
|
||||
"publish_time": original.publish_time,
|
||||
"close_time": original.close_time,
|
||||
"remark": original.remark,
|
||||
"create_at": original.create_at,
|
||||
"create_by": original.create_by,
|
||||
"create_name": original.create_name,
|
||||
"update_at": original.update_at,
|
||||
"update_by": original.update_by,
|
||||
}
|
||||
his = BizInquiryHis(**his_data)
|
||||
db.add(his)
|
||||
|
||||
return self.delete(db, id)
|
||||
|
||||
|
||||
class InquiryItemCRUD(CRUDBase[BizInquiryItem]):
|
||||
"""询价单项CRUD操作"""
|
||||
|
||||
def get_by_inquiry(self, db: Session, inquiry_id: str) -> List[BizInquiryItem]:
|
||||
"""获取询价单的所有询价项"""
|
||||
return db.query(BizInquiryItem).filter(
|
||||
and_(
|
||||
BizInquiryItem.inquiry_id == inquiry_id,
|
||||
BizInquiryItem.is_del == 0
|
||||
)
|
||||
).all()
|
||||
|
||||
def get_multi_with_inquiry_ids(
|
||||
self,
|
||||
db: Session,
|
||||
inquiry_ids: List[str]
|
||||
) -> List[BizInquiryItem]:
|
||||
"""根据多个询价单ID获取询价项"""
|
||||
return db.query(BizInquiryItem).filter(
|
||||
and_(
|
||||
BizInquiryItem.inquiry_id.in_(inquiry_ids),
|
||||
BizInquiryItem.is_del == 0
|
||||
)
|
||||
).all()
|
||||
|
||||
def create_items(
|
||||
self,
|
||||
db: Session,
|
||||
inquiry_id: str,
|
||||
product_ids: List[str],
|
||||
creator: dict = None
|
||||
) -> List[BizInquiryItem]:
|
||||
"""批量创建询价项"""
|
||||
items = []
|
||||
for product_id in product_ids:
|
||||
item = BizInquiryItem(
|
||||
id=generate_id(),
|
||||
inquiry_id=inquiry_id,
|
||||
product_id=product_id,
|
||||
create_by=creator.get("user_id") if creator else None,
|
||||
create_name=creator.get("real_name") if creator else None
|
||||
)
|
||||
db.add(item)
|
||||
items.append(item)
|
||||
db.commit()
|
||||
for item in items:
|
||||
db.refresh(item)
|
||||
return items
|
||||
|
||||
def update_items(
|
||||
self,
|
||||
db: Session,
|
||||
inquiry_id: str,
|
||||
product_ids: List[str],
|
||||
creator: dict = None
|
||||
) -> List[BizInquiryItem]:
|
||||
"""更新询价项(先删除旧的,再创建新的)"""
|
||||
existing_items = self.get_by_inquiry(db, inquiry_id)
|
||||
for item in existing_items:
|
||||
item.is_del = 1
|
||||
|
||||
return self.create_items(db, inquiry_id, product_ids, creator)
|
||||
|
||||
def delete_by_inquiry(self, db: Session, inquiry_id: str) -> bool:
|
||||
"""删除询价单的所有询价项"""
|
||||
items = self.get_by_inquiry(db, inquiry_id)
|
||||
for item in items:
|
||||
item.is_del = 1
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
|
||||
inquiry_crud = InquiryCRUD(BizInquiry)
|
||||
inquiry_item_crud = InquiryItemCRUD(BizInquiryItem)
|
||||
@@ -1,314 +0,0 @@
|
||||
"""客户/供应商结算单 CRUD(含 UPDATE 写历史表)"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.crud.base import CRUDBase
|
||||
from app.models.models import (
|
||||
BizOrder,
|
||||
OrdClientSettlement,
|
||||
OrdClientSettlementHis,
|
||||
OrdClientSettlementItem,
|
||||
OrdClientSettlementItemHis,
|
||||
OrdSupplierSettlement,
|
||||
OrdSupplierSettlementHis,
|
||||
OrdSupplierSettlementItem,
|
||||
OrdSupplierSettlementItemHis,
|
||||
)
|
||||
|
||||
|
||||
class OrdClientSettlementCRUD(CRUDBase[OrdClientSettlement]):
|
||||
def _snapshot_main(self, row: OrdClientSettlement) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": row.id,
|
||||
"settlement_no": row.settlement_no,
|
||||
"client_id": row.client_id,
|
||||
"client_name": row.client_name,
|
||||
"order_count": row.order_count,
|
||||
"total_amount": row.total_amount,
|
||||
"voucher_urls": row.voucher_urls,
|
||||
"remark": row.remark,
|
||||
"settlement_at": row.settlement_at,
|
||||
"status": row.status,
|
||||
"create_at": row.create_at,
|
||||
"create_by": row.create_by,
|
||||
"create_name": row.create_name,
|
||||
"update_at": row.update_at,
|
||||
"update_by": row.update_by,
|
||||
}
|
||||
|
||||
def get_multi_with_filters(
|
||||
self,
|
||||
db: Session,
|
||||
*,
|
||||
client_id: Optional[str] = None,
|
||||
settlement_no: Optional[str] = None,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None,
|
||||
region_id: Optional[str] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 10,
|
||||
) -> Tuple[List[OrdClientSettlement], int]:
|
||||
q = db.query(OrdClientSettlement).filter(OrdClientSettlement.is_del == 0)
|
||||
if client_id:
|
||||
q = q.filter(OrdClientSettlement.client_id == client_id)
|
||||
if settlement_no:
|
||||
q = q.filter(OrdClientSettlement.settlement_no.like(f"%{settlement_no}%"))
|
||||
if start_date:
|
||||
q = q.filter(OrdClientSettlement.settlement_at >= start_date)
|
||||
if end_date:
|
||||
q = q.filter(OrdClientSettlement.settlement_at <= end_date)
|
||||
if region_id:
|
||||
sub = (
|
||||
db.query(OrdClientSettlementItem.settlement_id)
|
||||
.join(BizOrder, BizOrder.id == OrdClientSettlementItem.order_id)
|
||||
.filter(
|
||||
OrdClientSettlementItem.is_del == 0,
|
||||
BizOrder.is_del == 0,
|
||||
BizOrder.region_id == region_id,
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
q = q.filter(OrdClientSettlement.id.in_(sub))
|
||||
total = q.count()
|
||||
rows = q.order_by(OrdClientSettlement.settlement_at.desc()).offset(skip).limit(limit).all()
|
||||
return rows, total
|
||||
|
||||
def get_with_items(
|
||||
self, db: Session, settlement_id: str
|
||||
) -> Tuple[Optional[OrdClientSettlement], List[OrdClientSettlementItem]]:
|
||||
main = self.get(db, settlement_id)
|
||||
if not main or main.is_del == 1:
|
||||
return None, []
|
||||
items = (
|
||||
db.query(OrdClientSettlementItem)
|
||||
.filter(
|
||||
OrdClientSettlementItem.settlement_id == settlement_id,
|
||||
OrdClientSettlementItem.is_del == 0,
|
||||
)
|
||||
.order_by(OrdClientSettlementItem.create_at.asc())
|
||||
.all()
|
||||
)
|
||||
return main, items
|
||||
|
||||
def belongs_to_region(self, db: Session, settlement_id: str, region_id: str) -> bool:
|
||||
if not region_id:
|
||||
return True
|
||||
return (
|
||||
db.query(OrdClientSettlementItem.id)
|
||||
.join(BizOrder, BizOrder.id == OrdClientSettlementItem.order_id)
|
||||
.filter(
|
||||
OrdClientSettlementItem.settlement_id == settlement_id,
|
||||
OrdClientSettlementItem.is_del == 0,
|
||||
BizOrder.is_del == 0,
|
||||
BizOrder.region_id == region_id,
|
||||
)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
|
||||
def update_with_history(
|
||||
self,
|
||||
db: Session,
|
||||
settlement_id: str,
|
||||
obj_in: dict,
|
||||
updater: Optional[dict] = None,
|
||||
) -> Optional[OrdClientSettlement]:
|
||||
original = self.get(db, settlement_id)
|
||||
if not original or original.is_del == 1:
|
||||
return None
|
||||
his = OrdClientSettlementHis(**self._snapshot_main(original))
|
||||
db.add(his)
|
||||
return self.update(db, settlement_id, obj_in, updater)
|
||||
|
||||
|
||||
class OrdClientSettlementItemCRUD(CRUDBase[OrdClientSettlementItem]):
|
||||
def _snapshot_item(self, row: OrdClientSettlementItem) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": row.id,
|
||||
"settlement_id": row.settlement_id,
|
||||
"order_id": row.order_id,
|
||||
"order_no": row.order_no,
|
||||
"client_id": row.client_id,
|
||||
"order_amount": row.order_amount,
|
||||
"create_at": row.create_at,
|
||||
"create_by": row.create_by,
|
||||
"create_name": row.create_name,
|
||||
"update_at": row.update_at,
|
||||
"update_by": row.update_by,
|
||||
}
|
||||
|
||||
def update_with_history(
|
||||
self,
|
||||
db: Session,
|
||||
item_id: str,
|
||||
obj_in: dict,
|
||||
updater: Optional[dict] = None,
|
||||
) -> Optional[OrdClientSettlementItem]:
|
||||
original = self.get(db, item_id)
|
||||
if not original or original.is_del == 1:
|
||||
return None
|
||||
his = OrdClientSettlementItemHis(**self._snapshot_item(original))
|
||||
db.add(his)
|
||||
return self.update(db, item_id, obj_in, updater)
|
||||
|
||||
def soft_delete_with_history(self, db: Session, item_id: str, updater: Optional[dict] = None) -> bool:
|
||||
original = self.get(db, item_id)
|
||||
if not original or original.is_del == 1:
|
||||
return False
|
||||
his = OrdClientSettlementItemHis(**self._snapshot_item(original))
|
||||
db.add(his)
|
||||
return self.update(db, item_id, {"is_del": 1}, updater) is not None
|
||||
|
||||
|
||||
class OrdSupplierSettlementCRUD(CRUDBase[OrdSupplierSettlement]):
|
||||
def _snapshot_main(self, row: OrdSupplierSettlement) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": row.id,
|
||||
"settlement_no": row.settlement_no,
|
||||
"supplier_id": row.supplier_id,
|
||||
"supplier_name": row.supplier_name,
|
||||
"order_count": row.order_count,
|
||||
"total_amount": row.total_amount,
|
||||
"voucher_urls": row.voucher_urls,
|
||||
"remark": row.remark,
|
||||
"settlement_at": row.settlement_at,
|
||||
"status": row.status,
|
||||
"create_at": row.create_at,
|
||||
"create_by": row.create_by,
|
||||
"create_name": row.create_name,
|
||||
"update_at": row.update_at,
|
||||
"update_by": row.update_by,
|
||||
}
|
||||
|
||||
def get_multi_with_filters(
|
||||
self,
|
||||
db: Session,
|
||||
*,
|
||||
supplier_id: Optional[str] = None,
|
||||
settlement_no: Optional[str] = None,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None,
|
||||
region_id: Optional[str] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 10,
|
||||
) -> Tuple[List[OrdSupplierSettlement], int]:
|
||||
q = db.query(OrdSupplierSettlement).filter(OrdSupplierSettlement.is_del == 0)
|
||||
if supplier_id:
|
||||
q = q.filter(OrdSupplierSettlement.supplier_id == supplier_id)
|
||||
if settlement_no:
|
||||
q = q.filter(OrdSupplierSettlement.settlement_no.like(f"%{settlement_no}%"))
|
||||
if start_date:
|
||||
q = q.filter(OrdSupplierSettlement.settlement_at >= start_date)
|
||||
if end_date:
|
||||
q = q.filter(OrdSupplierSettlement.settlement_at <= end_date)
|
||||
if region_id:
|
||||
sub = (
|
||||
db.query(OrdSupplierSettlementItem.settlement_id)
|
||||
.join(BizOrder, BizOrder.id == OrdSupplierSettlementItem.order_id)
|
||||
.filter(
|
||||
OrdSupplierSettlementItem.is_del == 0,
|
||||
BizOrder.is_del == 0,
|
||||
BizOrder.region_id == region_id,
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
q = q.filter(OrdSupplierSettlement.id.in_(sub))
|
||||
total = q.count()
|
||||
rows = q.order_by(OrdSupplierSettlement.settlement_at.desc()).offset(skip).limit(limit).all()
|
||||
return rows, total
|
||||
|
||||
def get_with_items(
|
||||
self, db: Session, settlement_id: str
|
||||
) -> Tuple[Optional[OrdSupplierSettlement], List[OrdSupplierSettlementItem]]:
|
||||
main = self.get(db, settlement_id)
|
||||
if not main or main.is_del == 1:
|
||||
return None, []
|
||||
items = (
|
||||
db.query(OrdSupplierSettlementItem)
|
||||
.filter(
|
||||
OrdSupplierSettlementItem.settlement_id == settlement_id,
|
||||
OrdSupplierSettlementItem.is_del == 0,
|
||||
)
|
||||
.order_by(OrdSupplierSettlementItem.create_at.asc())
|
||||
.all()
|
||||
)
|
||||
return main, items
|
||||
|
||||
def belongs_to_region(self, db: Session, settlement_id: str, region_id: str) -> bool:
|
||||
if not region_id:
|
||||
return True
|
||||
return (
|
||||
db.query(OrdSupplierSettlementItem.id)
|
||||
.join(BizOrder, BizOrder.id == OrdSupplierSettlementItem.order_id)
|
||||
.filter(
|
||||
OrdSupplierSettlementItem.settlement_id == settlement_id,
|
||||
OrdSupplierSettlementItem.is_del == 0,
|
||||
BizOrder.is_del == 0,
|
||||
BizOrder.region_id == region_id,
|
||||
)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
|
||||
def update_with_history(
|
||||
self,
|
||||
db: Session,
|
||||
settlement_id: str,
|
||||
obj_in: dict,
|
||||
updater: Optional[dict] = None,
|
||||
) -> Optional[OrdSupplierSettlement]:
|
||||
original = self.get(db, settlement_id)
|
||||
if not original or original.is_del == 1:
|
||||
return None
|
||||
his = OrdSupplierSettlementHis(**self._snapshot_main(original))
|
||||
db.add(his)
|
||||
return self.update(db, settlement_id, obj_in, updater)
|
||||
|
||||
|
||||
class OrdSupplierSettlementItemCRUD(CRUDBase[OrdSupplierSettlementItem]):
|
||||
def _snapshot_item(self, row: OrdSupplierSettlementItem) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": row.id,
|
||||
"settlement_id": row.settlement_id,
|
||||
"order_id": row.order_id,
|
||||
"order_no": row.order_no,
|
||||
"supplier_id": row.supplier_id,
|
||||
"order_amount": row.order_amount,
|
||||
"create_at": row.create_at,
|
||||
"create_by": row.create_by,
|
||||
"create_name": row.create_name,
|
||||
"update_at": row.update_at,
|
||||
"update_by": row.update_by,
|
||||
}
|
||||
|
||||
def update_with_history(
|
||||
self,
|
||||
db: Session,
|
||||
item_id: str,
|
||||
obj_in: dict,
|
||||
updater: Optional[dict] = None,
|
||||
) -> Optional[OrdSupplierSettlementItem]:
|
||||
original = self.get(db, item_id)
|
||||
if not original or original.is_del == 1:
|
||||
return None
|
||||
his = OrdSupplierSettlementItemHis(**self._snapshot_item(original))
|
||||
db.add(his)
|
||||
return self.update(db, item_id, obj_in, updater)
|
||||
|
||||
def soft_delete_with_history(self, db: Session, item_id: str, updater: Optional[dict] = None) -> bool:
|
||||
original = self.get(db, item_id)
|
||||
if not original or original.is_del == 1:
|
||||
return False
|
||||
his = OrdSupplierSettlementItemHis(**self._snapshot_item(original))
|
||||
db.add(his)
|
||||
return self.update(db, item_id, {"is_del": 1}, updater) is not None
|
||||
|
||||
|
||||
ord_client_settlement_crud = OrdClientSettlementCRUD(OrdClientSettlement)
|
||||
ord_client_settlement_item_crud = OrdClientSettlementItemCRUD(OrdClientSettlementItem)
|
||||
ord_supplier_settlement_crud = OrdSupplierSettlementCRUD(OrdSupplierSettlement)
|
||||
ord_supplier_settlement_item_crud = OrdSupplierSettlementItemCRUD(OrdSupplierSettlementItem)
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,148 +0,0 @@
|
||||
"""订单调整记录 CRUD"""
|
||||
from typing import Optional, List, Dict, Any
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_
|
||||
|
||||
from app.core.utils import generate_id
|
||||
from app.core.database import Base
|
||||
from app.crud.base import CRUDBase
|
||||
from app.models.models import BizOrderAdjust, BizOrderAdjustHis
|
||||
|
||||
|
||||
class OrderAdjustCRUD(CRUDBase[BizOrderAdjust]):
|
||||
"""订单调整记录CRUD"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(BizOrderAdjust)
|
||||
|
||||
def create_adjust(
|
||||
self,
|
||||
db: Session,
|
||||
order_id: str,
|
||||
order_item_id: str,
|
||||
adjust_type: str,
|
||||
before_quantity: float,
|
||||
after_quantity: float,
|
||||
before_amount: float,
|
||||
after_amount: float,
|
||||
reason: str,
|
||||
operator_id: str,
|
||||
operator_name: str,
|
||||
substitute_product_id: str = None,
|
||||
create_by: str = None,
|
||||
create_name: str = None
|
||||
) -> BizOrderAdjust:
|
||||
"""创建订单调整记录"""
|
||||
data = {
|
||||
"id": generate_id(),
|
||||
"order_id": order_id,
|
||||
"order_item_id": order_item_id,
|
||||
"adjust_type": adjust_type,
|
||||
"before_quantity": before_quantity,
|
||||
"after_quantity": after_quantity,
|
||||
"before_amount": before_amount,
|
||||
"after_amount": after_amount,
|
||||
"difference_amount": before_amount - after_amount,
|
||||
"reason": reason,
|
||||
"operator_id": operator_id,
|
||||
"operator_name": operator_name,
|
||||
"substitute_product_id": substitute_product_id,
|
||||
"create_by": create_by or operator_id,
|
||||
"create_name": create_name or operator_name
|
||||
}
|
||||
return self.create(db, data)
|
||||
|
||||
def get_by_order(self, db: Session, order_id: str) -> List[BizOrderAdjust]:
|
||||
"""获取订单的所有调整记录"""
|
||||
return db.query(self.model).filter(
|
||||
and_(
|
||||
self.model.order_id == order_id,
|
||||
self.model.is_del == 0
|
||||
)
|
||||
).order_by(self.model.create_at.desc()).all()
|
||||
|
||||
def get_by_order_item(self, db: Session, order_item_id: str) -> List[BizOrderAdjust]:
|
||||
"""获取订单项的所有调整记录"""
|
||||
return db.query(self.model).filter(
|
||||
and_(
|
||||
self.model.order_item_id == order_item_id,
|
||||
self.model.is_del == 0
|
||||
)
|
||||
).order_by(self.model.create_at.desc()).all()
|
||||
|
||||
def get_total_adjust_amount(self, db: Session, order_id: str) -> float:
|
||||
"""获取订单的总调整金额(负数表示退款)"""
|
||||
records = self.get_by_order(db, order_id)
|
||||
return sum(r.difference_amount or 0 for r in records)
|
||||
|
||||
def get_list(
|
||||
self,
|
||||
db: Session,
|
||||
page: int = 1,
|
||||
pageSize: int = 10,
|
||||
keyword: Optional[str] = None,
|
||||
adjust_type: Optional[str] = None,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None
|
||||
) -> tuple:
|
||||
"""获取订单调整列表(支持分页和搜索)"""
|
||||
query = db.query(self.model).filter(self.model.is_del == 0)
|
||||
|
||||
# 关键词搜索(搜索订单号)
|
||||
if keyword:
|
||||
query = query.filter(self.model.order_id.like(f"%{keyword}%"))
|
||||
|
||||
# 调整类型筛选
|
||||
if adjust_type:
|
||||
query = query.filter(self.model.adjust_type == adjust_type)
|
||||
|
||||
# 日期范围筛选
|
||||
if start_date:
|
||||
query = query.filter(self.model.create_at >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(self.model.create_at <= f"{end_date} 23:59:59")
|
||||
|
||||
# 获取总数
|
||||
total = query.count()
|
||||
|
||||
# 分页
|
||||
offset = (page - 1) * pageSize
|
||||
records = query.order_by(self.model.create_at.desc()).offset(offset).limit(pageSize).all()
|
||||
|
||||
return records, total
|
||||
|
||||
def create_adjust_history(
|
||||
self,
|
||||
db: Session,
|
||||
original_record: BizOrderAdjust,
|
||||
operator_id: str,
|
||||
operator_name: str
|
||||
) -> BizOrderAdjustHis:
|
||||
"""创建订单调整历史记录"""
|
||||
from app.crud.base import HistoryCRUD
|
||||
|
||||
history_crud = HistoryCRUD(BizOrderAdjustHis)
|
||||
|
||||
data = {
|
||||
"id": generate_id(),
|
||||
"order_id": original_record.order_id,
|
||||
"order_item_id": original_record.order_item_id,
|
||||
"adjust_type": original_record.adjust_type,
|
||||
"before_quantity": original_record.before_quantity,
|
||||
"after_quantity": original_record.after_quantity,
|
||||
"before_amount": original_record.before_amount,
|
||||
"after_amount": original_record.after_amount,
|
||||
"difference_amount": original_record.difference_amount,
|
||||
"reason": original_record.reason,
|
||||
"substitute_product_id": original_record.substitute_product_id,
|
||||
"operator_id": operator_id,
|
||||
"operator_name": operator_name,
|
||||
"create_by": original_record.create_by,
|
||||
"create_name": original_record.create_name
|
||||
}
|
||||
|
||||
return history_crud.create(db, data)
|
||||
|
||||
|
||||
# 全局实例
|
||||
order_adjust_crud = OrderAdjustCRUD()
|
||||
@@ -1,44 +0,0 @@
|
||||
"""密码历史CRUD"""
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.models import SysPasswordHistory
|
||||
from app.crud.base import CRUDBase
|
||||
from app.core.utils import generate_id
|
||||
|
||||
|
||||
class PasswordHistoryCRUD(CRUDBase[SysPasswordHistory]):
|
||||
"""密码历史CRUD操作"""
|
||||
|
||||
def create_password_history(
|
||||
self,
|
||||
db: Session,
|
||||
user_id: str,
|
||||
operation_type: str,
|
||||
operator_id: str = None,
|
||||
operator_name: str = None,
|
||||
ip_address: str = None
|
||||
) -> SysPasswordHistory:
|
||||
"""创建密码修改历史"""
|
||||
history = SysPasswordHistory(
|
||||
id=generate_id(),
|
||||
user_id=user_id,
|
||||
operation_type=operation_type,
|
||||
operator_id=operator_id,
|
||||
operator_name=operator_name,
|
||||
ip_address=ip_address
|
||||
)
|
||||
db.add(history)
|
||||
db.commit()
|
||||
db.refresh(history)
|
||||
return history
|
||||
|
||||
def get_user_history(self, db: Session, user_id: str, limit: int = 5) -> List[SysPasswordHistory]:
|
||||
"""获取用户密码修改历史"""
|
||||
return db.query(SysPasswordHistory).filter(
|
||||
SysPasswordHistory.user_id == user_id,
|
||||
SysPasswordHistory.is_del == 0
|
||||
).order_by(SysPasswordHistory.create_at.desc()).limit(limit).all()
|
||||
|
||||
|
||||
password_history_crud = PasswordHistoryCRUD(SysPasswordHistory)
|
||||
@@ -1,387 +0,0 @@
|
||||
"""价格清单CRUD"""
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, func
|
||||
|
||||
from app.models.models import BizPriceList, BizPriceListItem, BizPriceListHis
|
||||
from app.crud.base import CRUDBase
|
||||
from app.core.utils import generate_id
|
||||
|
||||
|
||||
class PriceListCRUD(CRUDBase[BizPriceList]):
|
||||
"""价格清单CRUD操作"""
|
||||
|
||||
def get_by_region(self, db: Session, region_id: str) -> List[BizPriceList]:
|
||||
"""根据区域获取价格清单列表"""
|
||||
return db.query(BizPriceList).filter(
|
||||
and_(
|
||||
BizPriceList.region_id == region_id,
|
||||
BizPriceList.is_del == 0
|
||||
)
|
||||
).order_by(BizPriceList.create_at.desc()).all()
|
||||
|
||||
def get_active(self, db: Session, region_id: str) -> Optional[BizPriceList]:
|
||||
"""获取区域当前生效的价格清单"""
|
||||
return db.query(BizPriceList).filter(
|
||||
and_(
|
||||
BizPriceList.region_id == region_id,
|
||||
BizPriceList.status == "active",
|
||||
BizPriceList.is_del == 0
|
||||
)
|
||||
).first()
|
||||
|
||||
def get_multi_with_filters(
|
||||
self,
|
||||
db: Session,
|
||||
region_id: str = None,
|
||||
supplier_id: str = None,
|
||||
status: str = None,
|
||||
skip: int = 0,
|
||||
limit: int = 10
|
||||
) -> tuple[List[BizPriceList], int]:
|
||||
"""多条件查询价格清单"""
|
||||
query = db.query(BizPriceList).filter(BizPriceList.is_del == 0)
|
||||
|
||||
if region_id:
|
||||
query = query.filter(BizPriceList.region_id == region_id)
|
||||
if supplier_id:
|
||||
query = query.filter(BizPriceList.supplier_id == supplier_id)
|
||||
if status:
|
||||
query = query.filter(BizPriceList.status == status)
|
||||
|
||||
total = query.count()
|
||||
price_lists = query.order_by(BizPriceList.create_at.desc()).offset(skip).limit(limit).all()
|
||||
return price_lists, total
|
||||
|
||||
def create_with_items(
|
||||
self,
|
||||
db: Session,
|
||||
obj_in: dict,
|
||||
items: List[dict],
|
||||
creator: dict = None
|
||||
) -> BizPriceList:
|
||||
"""创建价格清单及其明细"""
|
||||
# 计算版本号
|
||||
version = 1
|
||||
if obj_in.get("region_id"):
|
||||
existing = db.query(BizPriceList).filter(
|
||||
and_(
|
||||
BizPriceList.region_id == obj_in["region_id"],
|
||||
BizPriceList.is_del == 0
|
||||
)
|
||||
).order_by(BizPriceList.version.desc()).first()
|
||||
if existing:
|
||||
version = existing.version + 1
|
||||
|
||||
price_list = BizPriceList(
|
||||
id=generate_id(),
|
||||
region_id=obj_in["region_id"],
|
||||
supplier_id=obj_in["supplier_id"],
|
||||
version=version,
|
||||
status="draft",
|
||||
effective_time=obj_in.get("effective_time"),
|
||||
remark=obj_in.get("remark"),
|
||||
create_by=creator.get("user_id") if creator else None,
|
||||
create_name=creator.get("real_name") if creator else None
|
||||
)
|
||||
db.add(price_list)
|
||||
db.flush()
|
||||
|
||||
# 创建价格明细
|
||||
for item in items:
|
||||
price_item = BizPriceListItem(
|
||||
id=generate_id(),
|
||||
price_list_id=price_list.id,
|
||||
product_id=item.get("product_id"),
|
||||
unit_id=item.get("unit_id"),
|
||||
price=Decimal(str(item.get("price", 0))),
|
||||
create_by=creator.get("user_id") if creator else None,
|
||||
create_name=creator.get("real_name") if creator else None
|
||||
)
|
||||
db.add(price_item)
|
||||
|
||||
db.commit()
|
||||
db.refresh(price_list)
|
||||
return price_list
|
||||
|
||||
def update_with_items(
|
||||
self,
|
||||
db: Session,
|
||||
id: str,
|
||||
obj_in: dict,
|
||||
items: List[dict] = None,
|
||||
updater: dict = None
|
||||
) -> Optional[BizPriceList]:
|
||||
"""更新价格清单及其明细(仅草稿状态可编辑)"""
|
||||
price_list = self.get(db, id)
|
||||
if not price_list:
|
||||
return None
|
||||
|
||||
if price_list.status != "draft":
|
||||
return None
|
||||
|
||||
# 更新主表
|
||||
if obj_in.get("effective_time") is not None:
|
||||
price_list.effective_time = obj_in["effective_time"]
|
||||
if obj_in.get("remark") is not None:
|
||||
price_list.remark = obj_in["remark"]
|
||||
if updater:
|
||||
price_list.update_by = updater.get("user_id")
|
||||
|
||||
# 如果有新的价格项,则替换
|
||||
if items is not None:
|
||||
# 删除旧的价格项
|
||||
db.query(BizPriceListItem).filter(
|
||||
BizPriceListItem.price_list_id == id
|
||||
).delete()
|
||||
|
||||
# 添加新的价格项
|
||||
for item in items:
|
||||
price_item = BizPriceListItem(
|
||||
id=generate_id(),
|
||||
price_list_id=id,
|
||||
product_id=item.get("product_id"),
|
||||
unit_id=item.get("unit_id"),
|
||||
price=Decimal(str(item.get("price", 0))),
|
||||
create_by=updater.get("user_id") if updater else None,
|
||||
create_name=updater.get("real_name") if updater else None
|
||||
)
|
||||
db.add(price_item)
|
||||
|
||||
db.commit()
|
||||
db.refresh(price_list)
|
||||
return price_list
|
||||
|
||||
def publish(
|
||||
self,
|
||||
db: Session,
|
||||
id: str,
|
||||
publisher_id: str,
|
||||
effective_time: datetime = None,
|
||||
effective_type: str = "immediate"
|
||||
) -> Optional[BizPriceList]:
|
||||
"""发布价格清单"""
|
||||
price_list = self.get(db, id)
|
||||
if not price_list:
|
||||
return None
|
||||
|
||||
if price_list.status != "draft":
|
||||
return None
|
||||
|
||||
# 如果是立即生效,设为active状态
|
||||
if effective_type == "immediate":
|
||||
# 先将其他生效中的价格清单设为失效
|
||||
existing_active = db.query(BizPriceList).filter(
|
||||
and_(
|
||||
BizPriceList.region_id == price_list.region_id,
|
||||
BizPriceList.status == "active",
|
||||
BizPriceList.id != id,
|
||||
BizPriceList.is_del == 0
|
||||
)
|
||||
).all()
|
||||
for pl in existing_active:
|
||||
pl.status = "inactive"
|
||||
|
||||
price_list.status = "active"
|
||||
price_list.effective_time = datetime.now()
|
||||
else:
|
||||
price_list.status = "pending"
|
||||
if effective_time:
|
||||
price_list.effective_time = effective_time
|
||||
|
||||
price_list.published_by = publisher_id
|
||||
price_list.published_time = datetime.now()
|
||||
|
||||
db.commit()
|
||||
db.refresh(price_list)
|
||||
return price_list
|
||||
|
||||
def invalidate(
|
||||
self,
|
||||
db: Session,
|
||||
id: str
|
||||
) -> Optional[BizPriceList]:
|
||||
"""使价格清单失效"""
|
||||
price_list = self.get(db, id)
|
||||
if not price_list:
|
||||
return None
|
||||
|
||||
if price_list.status in ["active", "pending"]:
|
||||
price_list.status = "inactive"
|
||||
db.commit()
|
||||
db.refresh(price_list)
|
||||
|
||||
return price_list
|
||||
|
||||
def update_with_history(self, db: Session, id: str, obj_in: dict, updater: dict = None) -> Optional[BizPriceList]:
|
||||
"""更新价格清单并记录历史"""
|
||||
original = self.get(db, id)
|
||||
if not original:
|
||||
return None
|
||||
|
||||
his_data = {
|
||||
"id": original.id,
|
||||
"region_id": original.region_id,
|
||||
"supplier_id": original.supplier_id,
|
||||
"version": original.version,
|
||||
"status": original.status,
|
||||
"effective_time": original.effective_time,
|
||||
"published_by": original.published_by,
|
||||
"published_time": original.published_time,
|
||||
"remark": original.remark,
|
||||
"create_at": original.create_at,
|
||||
"create_by": original.create_by,
|
||||
"create_name": original.create_name,
|
||||
"update_at": original.update_at,
|
||||
"update_by": original.update_by,
|
||||
}
|
||||
his = BizPriceListHis(**his_data)
|
||||
db.add(his)
|
||||
|
||||
return self.update(db, id, obj_in, updater)
|
||||
|
||||
def soft_delete_with_history(self, db: Session, id: str) -> bool:
|
||||
"""删除价格清单并记录历史"""
|
||||
original = self.get(db, id)
|
||||
if not original:
|
||||
return False
|
||||
|
||||
his_data = {
|
||||
"id": original.id,
|
||||
"region_id": original.region_id,
|
||||
"supplier_id": original.supplier_id,
|
||||
"version": original.version,
|
||||
"status": original.status,
|
||||
"effective_time": original.effective_time,
|
||||
"published_by": original.published_by,
|
||||
"published_time": original.published_time,
|
||||
"remark": original.remark,
|
||||
"create_at": original.create_at,
|
||||
"create_by": original.create_by,
|
||||
"create_name": original.create_name,
|
||||
"update_at": original.update_at,
|
||||
"update_by": original.update_by,
|
||||
}
|
||||
his = BizPriceListHis(**his_data)
|
||||
db.add(his)
|
||||
|
||||
return self.delete(db, id)
|
||||
|
||||
|
||||
class PriceListItemCRUD(CRUDBase[BizPriceListItem]):
|
||||
"""价格清单项CRUD操作"""
|
||||
|
||||
def get_by_price_list(self, db: Session, price_list_id: str) -> List[BizPriceListItem]:
|
||||
"""获取价格清单的所有明细"""
|
||||
return db.query(BizPriceListItem).filter(
|
||||
and_(
|
||||
BizPriceListItem.price_list_id == price_list_id,
|
||||
BizPriceListItem.is_del == 0
|
||||
)
|
||||
).all()
|
||||
|
||||
def get_multi_by_price_lists(
|
||||
self,
|
||||
db: Session,
|
||||
price_list_ids: List[str]
|
||||
) -> List[BizPriceListItem]:
|
||||
"""根据多个价格清单ID获取明细"""
|
||||
return db.query(BizPriceListItem).filter(
|
||||
and_(
|
||||
BizPriceListItem.price_list_id.in_(price_list_ids),
|
||||
BizPriceListItem.is_del == 0
|
||||
)
|
||||
).all()
|
||||
|
||||
def get_product_price(
|
||||
self,
|
||||
db: Session,
|
||||
region_id: str,
|
||||
product_id: str
|
||||
) -> Optional[BizPriceListItem]:
|
||||
"""获取当前生效价格清单中指定商品的价格"""
|
||||
# 先获取当前生效的价格清单
|
||||
active_price_list = db.query(BizPriceList).filter(
|
||||
and_(
|
||||
BizPriceList.region_id == region_id,
|
||||
BizPriceList.status == "active",
|
||||
BizPriceList.is_del == 0
|
||||
)
|
||||
).first()
|
||||
|
||||
if not active_price_list:
|
||||
return None
|
||||
|
||||
return db.query(BizPriceListItem).filter(
|
||||
and_(
|
||||
BizPriceListItem.price_list_id == active_price_list.id,
|
||||
BizPriceListItem.product_id == product_id,
|
||||
BizPriceListItem.is_del == 0
|
||||
)
|
||||
).first()
|
||||
|
||||
def get_product_price_history(
|
||||
self,
|
||||
db: Session,
|
||||
region_id: str,
|
||||
product_id: str,
|
||||
skip: int = 0,
|
||||
limit: int = 20
|
||||
) -> tuple[List[dict], int]:
|
||||
"""获取商品价格历史"""
|
||||
# 获取所有生效过的价格清单及其明细
|
||||
price_lists = db.query(BizPriceList).filter(
|
||||
and_(
|
||||
BizPriceList.region_id == region_id,
|
||||
BizPriceList.status.in_(["active", "inactive"]),
|
||||
BizPriceList.is_del == 0
|
||||
)
|
||||
).order_by(BizPriceList.published_time.desc()).all()
|
||||
|
||||
price_list_ids = [pl.id for pl in price_lists]
|
||||
|
||||
items = db.query(BizPriceListItem).filter(
|
||||
and_(
|
||||
BizPriceListItem.price_list_id.in_(price_list_ids),
|
||||
BizPriceListItem.product_id == product_id,
|
||||
BizPriceListItem.is_del == 0
|
||||
)
|
||||
).all()
|
||||
|
||||
# 关联价格清单信息
|
||||
result = []
|
||||
for item in items:
|
||||
price_list = next((pl for pl in price_lists if pl.id == item.price_list_id), None)
|
||||
if price_list:
|
||||
result.append({
|
||||
"price": item.price,
|
||||
"unit_id": item.unit_id,
|
||||
"price_list_id": item.price_list_id,
|
||||
"version": price_list.version,
|
||||
"published_time": price_list.published_time,
|
||||
"status": price_list.status
|
||||
})
|
||||
|
||||
total = len(result)
|
||||
return result[skip:skip+limit], total
|
||||
|
||||
def get_by_product_and_price_list(
|
||||
self,
|
||||
db: Session,
|
||||
product_id: str,
|
||||
price_list_id: str
|
||||
) -> Optional[BizPriceListItem]:
|
||||
"""根据商品ID和价格清单ID获取价格项"""
|
||||
return db.query(BizPriceListItem).filter(
|
||||
and_(
|
||||
BizPriceListItem.product_id == product_id,
|
||||
BizPriceListItem.price_list_id == price_list_id,
|
||||
BizPriceListItem.is_del == 0
|
||||
)
|
||||
).first()
|
||||
|
||||
|
||||
price_list_crud = PriceListCRUD(BizPriceList)
|
||||
price_list_item_crud = PriceListItemCRUD(BizPriceListItem)
|
||||
@@ -1,123 +0,0 @@
|
||||
"""商品CRUD"""
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.orm import Session, defer
|
||||
from sqlalchemy import and_, func, or_
|
||||
|
||||
from app.models.models import BasProduct, BasProductHis
|
||||
from app.crud.base import CRUDBase
|
||||
|
||||
|
||||
class ProductCRUD(CRUDBase[BasProduct]):
|
||||
"""商品CRUD操作"""
|
||||
|
||||
def get_by_name(self, db: Session, name: str) -> Optional[BasProduct]:
|
||||
"""根据名称获取商品"""
|
||||
return db.query(BasProduct).filter(
|
||||
and_(BasProduct.name == name, BasProduct.is_del == 0)
|
||||
).first()
|
||||
|
||||
def get_active_all(self, db: Session) -> List[BasProduct]:
|
||||
"""获取所有启用的商品"""
|
||||
return db.query(BasProduct).filter(
|
||||
and_(BasProduct.is_del == 0, BasProduct.is_active == True)
|
||||
).order_by(BasProduct.create_at.desc()).all()
|
||||
|
||||
def get_multi_by_category(self, db: Session, category_id: str) -> List[BasProduct]:
|
||||
"""根据分类获取商品"""
|
||||
return db.query(BasProduct).filter(
|
||||
and_(BasProduct.category_id == category_id, BasProduct.is_del == 0, BasProduct.is_active == True)
|
||||
).order_by(BasProduct.create_at.desc()).all()
|
||||
|
||||
def get_multi_with_filters(
|
||||
self,
|
||||
db: Session,
|
||||
category_id: str = None,
|
||||
keyword: str = None,
|
||||
is_active: bool = None,
|
||||
skip: int = 0,
|
||||
limit: int = 10,
|
||||
*,
|
||||
defer_detail_content: bool = False,
|
||||
) -> tuple[List[BasProduct], int]:
|
||||
"""多条件查询商品"""
|
||||
filters = [BasProduct.is_del == 0]
|
||||
if category_id:
|
||||
filters.append(BasProduct.category_id == category_id)
|
||||
if is_active is not None:
|
||||
filters.append(BasProduct.is_active == is_active)
|
||||
if keyword:
|
||||
filters.append(
|
||||
or_(
|
||||
BasProduct.name.like(f"%{keyword}%"),
|
||||
BasProduct.description.like(f"%{keyword}%"),
|
||||
)
|
||||
)
|
||||
|
||||
total = (
|
||||
db.query(func.count(BasProduct.id))
|
||||
.filter(*filters)
|
||||
.order_by(None)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
query = db.query(BasProduct).filter(*filters)
|
||||
if defer_detail_content:
|
||||
query = query.options(defer(BasProduct.detail_content))
|
||||
products = query.order_by(BasProduct.create_at.desc()).offset(skip).limit(limit).all()
|
||||
return products, total
|
||||
|
||||
def update_with_history(self, db: Session, id: str, obj_in: dict, updater: dict = None) -> Optional[BasProduct]:
|
||||
"""更新商品并记录历史"""
|
||||
original = self.get(db, id)
|
||||
if not original:
|
||||
return None
|
||||
|
||||
his_data = {
|
||||
"id": original.id,
|
||||
"name": original.name,
|
||||
"category_id": original.category_id,
|
||||
"unit_id": original.unit_id,
|
||||
"measure_value": getattr(original, "measure_value", None),
|
||||
"description": original.description,
|
||||
"image_url": original.image_url,
|
||||
"is_active": original.is_active,
|
||||
"create_at": original.create_at,
|
||||
"create_by": original.create_by,
|
||||
"create_name": original.create_name,
|
||||
"update_at": original.update_at,
|
||||
"update_by": original.update_by,
|
||||
}
|
||||
his = BasProductHis(**his_data)
|
||||
db.add(his)
|
||||
|
||||
return self.update(db, id, obj_in, updater)
|
||||
|
||||
def soft_delete_with_history(self, db: Session, id: str) -> bool:
|
||||
"""删除商品并记录历史"""
|
||||
original = self.get(db, id)
|
||||
if not original:
|
||||
return False
|
||||
|
||||
his_data = {
|
||||
"id": original.id,
|
||||
"name": original.name,
|
||||
"category_id": original.category_id,
|
||||
"unit_id": original.unit_id,
|
||||
"measure_value": getattr(original, "measure_value", None),
|
||||
"description": original.description,
|
||||
"image_url": original.image_url,
|
||||
"is_active": original.is_active,
|
||||
"create_at": original.create_at,
|
||||
"create_by": original.create_by,
|
||||
"create_name": original.create_name,
|
||||
"update_at": original.update_at,
|
||||
"update_by": original.update_by,
|
||||
}
|
||||
his = BasProductHis(**his_data)
|
||||
db.add(his)
|
||||
|
||||
return self.delete(db, id)
|
||||
|
||||
|
||||
product_crud = ProductCRUD(BasProduct)
|
||||
@@ -1,73 +0,0 @@
|
||||
"""CRUD - 商品轮播图"""
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.crud.base import CRUDBase, HistoryCRUD
|
||||
from app.models.product_banner import BasProductBanner, BasProductBannerHis
|
||||
from app.schemas.product_banner import ProductBannerCreate, ProductBannerUpdate
|
||||
|
||||
|
||||
class ProductBannerCRUD(CRUDBase[BasProductBanner]):
|
||||
"""商品轮播图CRUD操作"""
|
||||
|
||||
def get_by_product(self, db: Session, product_id: str) -> List[BasProductBanner]:
|
||||
"""获取商品的所有轮播图"""
|
||||
return db.query(self.model).filter(
|
||||
self.model.product_id == product_id,
|
||||
self.model.is_del == 0
|
||||
).order_by(self.model.sort_order.asc()).all()
|
||||
|
||||
def create_batch(
|
||||
self,
|
||||
db: Session,
|
||||
product_id: str,
|
||||
items: List[dict],
|
||||
creator: dict = None
|
||||
) -> List[BasProductBanner]:
|
||||
"""批量创建轮播图"""
|
||||
banners = []
|
||||
for idx, item in enumerate(items):
|
||||
item_data = {
|
||||
"product_id": product_id,
|
||||
"media_type": item.get("media_type", "image"),
|
||||
"file_url": item.get("file_url", ""),
|
||||
"thumbnail_url": item.get("thumbnail_url"),
|
||||
"sort_order": item.get("sort_order", idx),
|
||||
}
|
||||
banner = self.create(db, item_data, creator)
|
||||
banners.append(banner)
|
||||
return banners
|
||||
|
||||
def update_sort(
|
||||
self,
|
||||
db: Session,
|
||||
items: List[dict]
|
||||
) -> bool:
|
||||
"""批量更新排序"""
|
||||
for item in items:
|
||||
banner_id = item.get("id")
|
||||
sort_order = item.get("sort_order")
|
||||
if banner_id and sort_order is not None:
|
||||
db.query(self.model).filter(self.model.id == banner_id).update({
|
||||
"sort_order": sort_order
|
||||
})
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
def delete_by_product(self, db: Session, product_id: str) -> int:
|
||||
"""删除商品的所有轮播图"""
|
||||
count = db.query(self.model).filter(
|
||||
self.model.product_id == product_id,
|
||||
self.model.is_del == 0
|
||||
).update({"is_del": 1})
|
||||
db.commit()
|
||||
return count
|
||||
|
||||
|
||||
class ProductBannerHisCRUD(HistoryCRUD[BasProductBannerHis]):
|
||||
"""商品轮播图历史CRUD操作"""
|
||||
pass
|
||||
|
||||
|
||||
product_banner = ProductBannerCRUD(BasProductBanner)
|
||||
product_banner_his = ProductBannerHisCRUD(BasProductBannerHis)
|
||||
@@ -1,75 +0,0 @@
|
||||
"""CRUD - 商品详情媒体"""
|
||||
from typing import List
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.crud.base import CRUDBase, HistoryCRUD
|
||||
from app.models.product_detail_media import BasProductDetailMedia, BasProductDetailMediaHis
|
||||
from app.schemas.product_detail_media import ProductDetailMediaCreate, ProductDetailMediaUpdate
|
||||
|
||||
|
||||
class ProductDetailMediaCRUD(CRUDBase[BasProductDetailMedia]):
|
||||
"""商品详情媒体CRUD操作"""
|
||||
|
||||
def get_by_product(self, db: Session, product_id: str) -> List[BasProductDetailMedia]:
|
||||
"""获取商品的所有详情媒体"""
|
||||
return db.query(self.model).filter(
|
||||
self.model.product_id == product_id,
|
||||
self.model.is_del == 0
|
||||
).order_by(self.model.sort_order.asc()).all()
|
||||
|
||||
def create_batch(
|
||||
self,
|
||||
db: Session,
|
||||
product_id: str,
|
||||
items: List[dict],
|
||||
creator: dict = None
|
||||
) -> List[BasProductDetailMedia]:
|
||||
"""批量创建详情媒体"""
|
||||
media_list = []
|
||||
for idx, item in enumerate(items):
|
||||
item_data = {
|
||||
"product_id": product_id,
|
||||
"content_type": item.get("content_type", "image"),
|
||||
"content_text": item.get("content_text"),
|
||||
"file_url": item.get("file_url"),
|
||||
"thumbnail_url": item.get("thumbnail_url"),
|
||||
"description": item.get("description"),
|
||||
"sort_order": item.get("sort_order", idx),
|
||||
}
|
||||
media = self.create(db, item_data, creator)
|
||||
media_list.append(media)
|
||||
return media_list
|
||||
|
||||
def update_sort(
|
||||
self,
|
||||
db: Session,
|
||||
items: List[dict]
|
||||
) -> bool:
|
||||
"""批量更新排序"""
|
||||
for item in items:
|
||||
media_id = item.get("id")
|
||||
sort_order = item.get("sort_order")
|
||||
if media_id and sort_order is not None:
|
||||
db.query(self.model).filter(self.model.id == media_id).update({
|
||||
"sort_order": sort_order
|
||||
})
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
def delete_by_product(self, db: Session, product_id: str) -> int:
|
||||
"""删除商品的所有详情媒体"""
|
||||
count = db.query(self.model).filter(
|
||||
self.model.product_id == product_id,
|
||||
self.model.is_del == 0
|
||||
).update({"is_del": 1})
|
||||
db.commit()
|
||||
return count
|
||||
|
||||
|
||||
class ProductDetailMediaHisCRUD(HistoryCRUD[BasProductDetailMediaHis]):
|
||||
"""商品详情媒体历史CRUD操作"""
|
||||
pass
|
||||
|
||||
|
||||
product_detail_media = ProductDetailMediaCRUD(BasProductDetailMedia)
|
||||
product_detail_media_his = ProductDetailMediaHisCRUD(BasProductDetailMediaHis)
|
||||
@@ -1,83 +0,0 @@
|
||||
"""CRUD - 商品参数模板"""
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import or_
|
||||
|
||||
from app.crud.base import CRUDBase, HistoryCRUD
|
||||
from app.models.product_parameter import BasProductParameter, BasProductParameterHis
|
||||
from app.schemas.product_parameter import ProductParameterCreate, ProductParameterUpdate
|
||||
|
||||
|
||||
class ProductParameterCRUD(CRUDBase[BasProductParameter]):
|
||||
"""商品参数模板CRUD操作"""
|
||||
|
||||
def get_multi_paged(
|
||||
self,
|
||||
db: Session,
|
||||
skip: int = 0,
|
||||
limit: int = 10,
|
||||
is_active: Optional[int] = None,
|
||||
keyword: Optional[str] = None,
|
||||
order_by: str = "sort_order",
|
||||
order_type: str = "asc"
|
||||
) -> tuple:
|
||||
"""分页获取参数列表"""
|
||||
query = db.query(self.model).filter(self.model.is_del == 0)
|
||||
|
||||
if is_active is not None:
|
||||
query = query.filter(self.model.is_active == is_active)
|
||||
|
||||
if keyword:
|
||||
query = query.filter(
|
||||
or_(
|
||||
self.model.name.like(f"%{keyword}%"),
|
||||
self.model.code.like(f"%{keyword}%")
|
||||
)
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
|
||||
order_column = getattr(self.model, order_by, None)
|
||||
if order_column:
|
||||
if order_type == "asc":
|
||||
query = query.order_by(order_column.asc())
|
||||
else:
|
||||
query = query.order_by(order_column.desc())
|
||||
else:
|
||||
query = query.order_by(self.model.sort_order.asc())
|
||||
|
||||
items = query.offset(skip).limit(limit).all()
|
||||
return items, total
|
||||
|
||||
def get_active_all(self, db: Session) -> List[BasProductParameter]:
|
||||
"""获取所有启用的参数"""
|
||||
return db.query(self.model).filter(
|
||||
self.model.is_del == 0,
|
||||
self.model.is_active == 1
|
||||
).order_by(self.model.sort_order.asc()).all()
|
||||
|
||||
def get_by_code(self, db: Session, code: str) -> Optional[BasProductParameter]:
|
||||
"""根据编码获取参数"""
|
||||
return db.query(self.model).filter(
|
||||
self.model.code == code,
|
||||
self.model.is_del == 0
|
||||
).first()
|
||||
|
||||
def code_exists(self, db: Session, code: str, exclude_id: str = None) -> bool:
|
||||
"""检查编码是否已存在"""
|
||||
query = db.query(self.model).filter(
|
||||
self.model.code == code,
|
||||
self.model.is_del == 0
|
||||
)
|
||||
if exclude_id:
|
||||
query = query.filter(self.model.id != exclude_id)
|
||||
return query.first() is not None
|
||||
|
||||
|
||||
class ProductParameterHisCRUD(HistoryCRUD[BasProductParameterHis]):
|
||||
"""商品参数模板历史CRUD操作"""
|
||||
pass
|
||||
|
||||
|
||||
product_parameter = ProductParameterCRUD(BasProductParameter)
|
||||
product_parameter_his = ProductParameterHisCRUD(BasProductParameterHis)
|
||||
@@ -1,117 +0,0 @@
|
||||
"""CRUD - 商品参数值"""
|
||||
from typing import List
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_
|
||||
|
||||
from app.crud.base import CRUDBase, HistoryCRUD
|
||||
from app.models.product_parameter_value import BasProductParameterValue, BasProductParameterValueHis
|
||||
from app.schemas.product_parameter_value import ProductParameterValueCreate
|
||||
|
||||
|
||||
class ProductParameterValueCRUD(CRUDBase[BasProductParameterValue]):
|
||||
"""商品参数值CRUD操作"""
|
||||
|
||||
def get_by_product(self, db: Session, product_id: str) -> List[BasProductParameterValue]:
|
||||
"""获取商品的所有参数值"""
|
||||
return db.query(self.model).filter(
|
||||
self.model.product_id == product_id,
|
||||
self.model.is_del == 0
|
||||
).order_by(self.model.sort_order.asc()).all()
|
||||
|
||||
def get_by_product_with_detail(self, db: Session, product_id: str) -> List[dict]:
|
||||
"""获取商品的所有参数值(带参数详情)"""
|
||||
from app.models.product_parameter import BasProductParameter
|
||||
|
||||
results = db.query(
|
||||
BasProductParameterValue,
|
||||
BasProductParameter
|
||||
).join(
|
||||
BasProductParameter,
|
||||
and_(
|
||||
BasProductParameterValue.parameter_id == BasProductParameter.id,
|
||||
BasProductParameter.is_del == 0
|
||||
)
|
||||
).filter(
|
||||
BasProductParameterValue.product_id == product_id,
|
||||
BasProductParameterValue.is_del == 0
|
||||
).order_by(
|
||||
BasProductParameterValue.sort_order.asc()
|
||||
).all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": ppv.id,
|
||||
"product_id": ppv.product_id,
|
||||
"parameter_id": ppv.parameter_id,
|
||||
"parameter_name": pp.name,
|
||||
"parameter_code": pp.code,
|
||||
"param_type": pp.param_type,
|
||||
"options": pp.options,
|
||||
"placeholder": pp.placeholder,
|
||||
"param_value": ppv.param_value,
|
||||
"sort_order": ppv.sort_order,
|
||||
}
|
||||
for ppv, pp in results
|
||||
]
|
||||
|
||||
def batch_save(
|
||||
self,
|
||||
db: Session,
|
||||
product_id: str,
|
||||
values: List[dict],
|
||||
creator: dict = None
|
||||
) -> List[BasProductParameterValue]:
|
||||
"""批量保存商品参数值"""
|
||||
from app.models.product_parameter import BasProductParameter
|
||||
|
||||
# 先删除原有的参数值
|
||||
db.query(self.model).filter(
|
||||
self.model.product_id == product_id,
|
||||
self.model.is_del == 0
|
||||
).update({"is_del": 1})
|
||||
|
||||
# 创建新的参数值
|
||||
saved_values = []
|
||||
for idx, value_item in enumerate(values):
|
||||
parameter_id = value_item.get("parameter_id")
|
||||
param_value = value_item.get("param_value")
|
||||
|
||||
# 获取参数信息
|
||||
param = db.query(BasProductParameter).filter(
|
||||
BasProductParameter.id == parameter_id,
|
||||
BasProductParameter.is_del == 0
|
||||
).first()
|
||||
|
||||
if param:
|
||||
item_data = {
|
||||
"product_id": product_id,
|
||||
"parameter_id": parameter_id,
|
||||
"parameter_name": param.name,
|
||||
"parameter_code": param.code,
|
||||
"param_type": param.param_type,
|
||||
"param_value": param_value,
|
||||
"sort_order": idx,
|
||||
}
|
||||
pv = self.create(db, item_data, creator)
|
||||
saved_values.append(pv)
|
||||
|
||||
db.commit()
|
||||
return saved_values
|
||||
|
||||
def delete_by_product(self, db: Session, product_id: str) -> int:
|
||||
"""删除商品的所有参数值"""
|
||||
count = db.query(self.model).filter(
|
||||
self.model.product_id == product_id,
|
||||
self.model.is_del == 0
|
||||
).update({"is_del": 1})
|
||||
db.commit()
|
||||
return count
|
||||
|
||||
|
||||
class ProductParameterValueHisCRUD(HistoryCRUD[BasProductParameterValueHis]):
|
||||
"""商品参数值历史CRUD操作"""
|
||||
pass
|
||||
|
||||
|
||||
product_parameter_value = ProductParameterValueCRUD(BasProductParameterValue)
|
||||
product_parameter_value_his = ProductParameterValueHisCRUD(BasProductParameterValueHis)
|
||||
@@ -1,242 +0,0 @@
|
||||
"""CRUD - 售后评价"""
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, func
|
||||
|
||||
from app.crud.base import CRUDBase, HistoryCRUD
|
||||
from app.models.product_review import BizProductReview, BizProductReviewHis
|
||||
from app.schemas.product_review import ProductReviewCreate, ProductReviewReplyRequest
|
||||
|
||||
|
||||
class ProductReviewCRUD(CRUDBase[BizProductReview]):
|
||||
"""售后评价CRUD操作"""
|
||||
|
||||
def get_by_product(
|
||||
self,
|
||||
db: Session,
|
||||
product_id: str,
|
||||
skip: int = 0,
|
||||
limit: int = 10,
|
||||
status: str = "approved",
|
||||
has_media: int = None
|
||||
) -> tuple:
|
||||
"""获取商品的评价列表"""
|
||||
query = db.query(self.model).filter(
|
||||
self.model.product_id == product_id,
|
||||
self.model.is_del == 0
|
||||
)
|
||||
|
||||
if status:
|
||||
query = query.filter(self.model.status == status)
|
||||
|
||||
if has_media == 1:
|
||||
from app.models.product_review_media import BizProductReviewMedia
|
||||
review_ids_with_media = db.query(BizProductReviewMedia.review_id).filter(
|
||||
BizProductReviewMedia.is_del == 0
|
||||
).distinct().subquery()
|
||||
query = query.filter(self.model.id.in_(review_ids_with_media))
|
||||
|
||||
total = query.count()
|
||||
|
||||
items = query.order_by(
|
||||
self.model.is_top.desc(),
|
||||
self.model.create_at.desc()
|
||||
).offset(skip).limit(limit).all()
|
||||
|
||||
return items, total
|
||||
|
||||
def get_admin_list(
|
||||
self,
|
||||
db: Session,
|
||||
skip: int = 0,
|
||||
limit: int = 10,
|
||||
product_id: str = None,
|
||||
product_name: str = None,
|
||||
user_name: str = None,
|
||||
status: str = None,
|
||||
rating: int = None,
|
||||
start_date: str = None,
|
||||
end_date: str = None,
|
||||
) -> tuple:
|
||||
"""管理员获取评价列表(支持多条件筛选)"""
|
||||
query = db.query(self.model).filter(self.model.is_del == 0)
|
||||
|
||||
if product_id:
|
||||
query = query.filter(self.model.product_id == product_id)
|
||||
|
||||
if user_name:
|
||||
query = query.filter(self.model.user_name.like(f"%{user_name}%"))
|
||||
|
||||
if status:
|
||||
query = query.filter(self.model.status == status)
|
||||
|
||||
if rating:
|
||||
query = query.filter(self.model.rating == rating)
|
||||
|
||||
if start_date:
|
||||
query = query.filter(self.model.create_at >= start_date)
|
||||
|
||||
if end_date:
|
||||
query = query.filter(self.model.create_at <= end_date)
|
||||
|
||||
total = query.count()
|
||||
|
||||
items = query.order_by(
|
||||
self.model.is_top.desc(),
|
||||
self.model.create_at.desc()
|
||||
).offset(skip).limit(limit).all()
|
||||
|
||||
return items, total
|
||||
|
||||
def get_statistics(self, db: Session, product_id: str = None) -> dict:
|
||||
"""获取评价统计"""
|
||||
query = db.query(self.model).filter(
|
||||
self.model.is_del == 0,
|
||||
self.model.status == "approved"
|
||||
)
|
||||
|
||||
if product_id:
|
||||
query = query.filter(self.model.product_id == product_id)
|
||||
|
||||
total_count = query.count()
|
||||
|
||||
if total_count > 0:
|
||||
avg_rating = db.query(func.avg(self.model.rating)).filter(
|
||||
self.model.is_del == 0,
|
||||
self.model.status == "approved",
|
||||
self.model.product_id == product_id if product_id else True
|
||||
).scalar() or 0
|
||||
else:
|
||||
avg_rating = 0
|
||||
|
||||
rating_dist = {}
|
||||
for r in range(1, 6):
|
||||
count = db.query(self.model).filter(
|
||||
self.model.is_del == 0,
|
||||
self.model.status == "approved",
|
||||
self.model.rating == r,
|
||||
self.model.product_id == product_id if product_id else True
|
||||
).count()
|
||||
rating_dist[str(r)] = count
|
||||
|
||||
pending_count = db.query(self.model).filter(
|
||||
self.model.is_del == 0,
|
||||
self.model.status == "pending",
|
||||
self.model.product_id == product_id if product_id else True
|
||||
).count()
|
||||
|
||||
rejected_count = db.query(self.model).filter(
|
||||
self.model.is_del == 0,
|
||||
self.model.status == "rejected",
|
||||
self.model.product_id == product_id if product_id else True
|
||||
).count()
|
||||
|
||||
return {
|
||||
"total_count": total_count,
|
||||
"pending_count": pending_count,
|
||||
"approved_count": total_count,
|
||||
"rejected_count": rejected_count,
|
||||
"avg_rating": round(float(avg_rating), 1),
|
||||
"rating_distribution": rating_dist,
|
||||
}
|
||||
|
||||
def reply(
|
||||
self,
|
||||
db: Session,
|
||||
review_id: str,
|
||||
reply_content: str,
|
||||
updater: dict = None
|
||||
) -> Optional[BizProductReview]:
|
||||
"""回复评价"""
|
||||
review = self.get(db, review_id)
|
||||
if review:
|
||||
review.reply_content = reply_content
|
||||
review.reply_time = func.now()
|
||||
if updater:
|
||||
review.reply_by = updater.get("user_id")
|
||||
review.reply_by_name = updater.get("real_name")
|
||||
db.commit()
|
||||
db.refresh(review)
|
||||
return review
|
||||
|
||||
def reject(
|
||||
self,
|
||||
db: Session,
|
||||
review_id: str,
|
||||
reject_reason: str,
|
||||
updater: dict = None
|
||||
) -> Optional[BizProductReview]:
|
||||
"""拒绝评价"""
|
||||
review = self.get(db, review_id)
|
||||
if review:
|
||||
review.status = "rejected"
|
||||
review.reject_reason = reject_reason
|
||||
db.commit()
|
||||
db.refresh(review)
|
||||
return review
|
||||
|
||||
def approve(
|
||||
self,
|
||||
db: Session,
|
||||
review_id: str
|
||||
) -> Optional[BizProductReview]:
|
||||
"""审核通过评价"""
|
||||
review = self.get(db, review_id)
|
||||
if review:
|
||||
review.status = "approved"
|
||||
db.commit()
|
||||
db.refresh(review)
|
||||
return review
|
||||
|
||||
def toggle_top(
|
||||
self,
|
||||
db: Session,
|
||||
review_id: str,
|
||||
is_top: int
|
||||
) -> Optional[BizProductReview]:
|
||||
"""切换置顶状态"""
|
||||
review = self.get(db, review_id)
|
||||
if review:
|
||||
review.is_top = is_top
|
||||
db.commit()
|
||||
db.refresh(review)
|
||||
return review
|
||||
|
||||
def like(
|
||||
self,
|
||||
db: Session,
|
||||
review_id: str
|
||||
) -> Optional[BizProductReview]:
|
||||
"""点赞评价"""
|
||||
review = self.get(db, review_id)
|
||||
if review:
|
||||
review.like_count = (review.like_count or 0) + 1
|
||||
db.commit()
|
||||
db.refresh(review)
|
||||
return review
|
||||
|
||||
def user_can_review(
|
||||
self,
|
||||
db: Session,
|
||||
user_id: str,
|
||||
product_id: str,
|
||||
order_id: str = None
|
||||
) -> bool:
|
||||
"""检查用户是否已评价过该商品"""
|
||||
query = db.query(self.model).filter(
|
||||
self.model.user_id == user_id,
|
||||
self.model.product_id == product_id,
|
||||
self.model.is_del == 0
|
||||
)
|
||||
if order_id:
|
||||
query = query.filter(self.model.order_id == order_id)
|
||||
return query.first() is not None
|
||||
|
||||
|
||||
class ProductReviewHisCRUD(HistoryCRUD[BizProductReviewHis]):
|
||||
"""售后评价历史CRUD操作"""
|
||||
pass
|
||||
|
||||
|
||||
product_review = ProductReviewCRUD(BizProductReview)
|
||||
product_review_his = ProductReviewHisCRUD(BizProductReviewHis)
|
||||
@@ -1,63 +0,0 @@
|
||||
"""CRUD - 评价媒体"""
|
||||
from typing import List
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.crud.base import CRUDBase, HistoryCRUD
|
||||
from app.models.product_review_media import BizProductReviewMedia, BizProductReviewMediaHis
|
||||
|
||||
|
||||
class ProductReviewMediaCRUD(CRUDBase[BizProductReviewMedia]):
|
||||
"""评价媒体CRUD操作"""
|
||||
|
||||
def get_by_review(self, db: Session, review_id: str) -> List[BizProductReviewMedia]:
|
||||
"""获取评价的所有媒体"""
|
||||
return db.query(self.model).filter(
|
||||
self.model.review_id == review_id,
|
||||
self.model.is_del == 0
|
||||
).order_by(self.model.sort_order.asc()).all()
|
||||
|
||||
def create_batch(
|
||||
self,
|
||||
db: Session,
|
||||
review_id: str,
|
||||
items: List[dict],
|
||||
creator: dict = None
|
||||
) -> List[BizProductReviewMedia]:
|
||||
"""批量创建评价媒体"""
|
||||
media_list = []
|
||||
for idx, item in enumerate(items):
|
||||
item_data = {
|
||||
"review_id": review_id,
|
||||
"media_type": item.get("media_type", "image"),
|
||||
"file_url": item.get("file_url", ""),
|
||||
"thumbnail_url": item.get("thumbnail_url"),
|
||||
"sort_order": item.get("sort_order", idx),
|
||||
}
|
||||
media = self.create(db, item_data, creator)
|
||||
media_list.append(media)
|
||||
return media_list
|
||||
|
||||
def delete_by_review(self, db: Session, review_id: str) -> int:
|
||||
"""删除评价的所有媒体"""
|
||||
count = db.query(self.model).filter(
|
||||
self.model.review_id == review_id,
|
||||
self.model.is_del == 0
|
||||
).update({"is_del": 1})
|
||||
db.commit()
|
||||
return count
|
||||
|
||||
def get_media_count(self, db: Session, review_id: str) -> int:
|
||||
"""获取评价的媒体数量"""
|
||||
return db.query(self.model).filter(
|
||||
self.model.review_id == review_id,
|
||||
self.model.is_del == 0
|
||||
).count()
|
||||
|
||||
|
||||
class ProductReviewMediaHisCRUD(HistoryCRUD[BizProductReviewMediaHis]):
|
||||
"""评价媒体历史CRUD操作"""
|
||||
pass
|
||||
|
||||
|
||||
product_review_media = ProductReviewMediaCRUD(BizProductReviewMedia)
|
||||
product_review_media_his = ProductReviewMediaHisCRUD(BizProductReviewMediaHis)
|
||||
@@ -1,201 +0,0 @@
|
||||
"""报价单CRUD"""
|
||||
from decimal import Decimal
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, func
|
||||
|
||||
from app.models.models import BizQuotation, BizQuotationItem, BizQuotationHis, BizQuotationItemHis
|
||||
from app.crud.base import CRUDBase
|
||||
from app.core.utils import generate_id
|
||||
|
||||
|
||||
class QuotationCRUD(CRUDBase[BizQuotation]):
|
||||
"""报价单CRUD操作"""
|
||||
|
||||
def get_by_inquiry(self, db: Session, inquiry_id: str) -> List[BizQuotation]:
|
||||
"""根据询价单获取报价列表"""
|
||||
return db.query(BizQuotation).filter(
|
||||
and_(
|
||||
BizQuotation.inquiry_id == inquiry_id,
|
||||
BizQuotation.is_del == 0
|
||||
)
|
||||
).all()
|
||||
|
||||
def get_by_supplier(self, db: Session, supplier_id: str) -> List[BizQuotation]:
|
||||
"""根据供应商获取报价列表"""
|
||||
return db.query(BizQuotation).filter(
|
||||
and_(
|
||||
BizQuotation.supplier_id == supplier_id,
|
||||
BizQuotation.is_del == 0
|
||||
)
|
||||
).order_by(BizQuotation.create_at.desc()).all()
|
||||
|
||||
def get_by_inquiry_and_supplier(
|
||||
self,
|
||||
db: Session,
|
||||
inquiry_id: str,
|
||||
supplier_id: str
|
||||
) -> Optional[BizQuotation]:
|
||||
"""根据询价单和供应商获取报价"""
|
||||
return db.query(BizQuotation).filter(
|
||||
and_(
|
||||
BizQuotation.inquiry_id == inquiry_id,
|
||||
BizQuotation.supplier_id == supplier_id,
|
||||
BizQuotation.is_del == 0
|
||||
)
|
||||
).first()
|
||||
|
||||
def get_latest_by_inquiry_and_supplier(
|
||||
self,
|
||||
db: Session,
|
||||
inquiry_id: str,
|
||||
supplier_id: str
|
||||
) -> Optional[BizQuotation]:
|
||||
"""获取某供应商在某询价单下最新一次报价"""
|
||||
return db.query(BizQuotation).filter(
|
||||
and_(
|
||||
BizQuotation.inquiry_id == inquiry_id,
|
||||
BizQuotation.supplier_id == supplier_id,
|
||||
BizQuotation.is_del == 0
|
||||
)
|
||||
).order_by(BizQuotation.create_at.desc()).first()
|
||||
|
||||
def get_multi_with_filters(
|
||||
self,
|
||||
db: Session,
|
||||
inquiry_id: str = None,
|
||||
supplier_id: str = None,
|
||||
region_id: str = None,
|
||||
status: str = None,
|
||||
skip: int = 0,
|
||||
limit: int = 10
|
||||
) -> tuple[List[BizQuotation], int]:
|
||||
"""多条件查询报价单"""
|
||||
query = db.query(BizQuotation).filter(BizQuotation.is_del == 0)
|
||||
|
||||
if inquiry_id:
|
||||
query = query.filter(BizQuotation.inquiry_id == inquiry_id)
|
||||
if supplier_id:
|
||||
query = query.filter(BizQuotation.supplier_id == supplier_id)
|
||||
if region_id:
|
||||
query = query.filter(BizQuotation.region_id == region_id)
|
||||
if status:
|
||||
query = query.filter(BizQuotation.status == status)
|
||||
|
||||
total = query.count()
|
||||
quotations = query.order_by(BizQuotation.create_at.desc()).offset(skip).limit(limit).all()
|
||||
return quotations, total
|
||||
|
||||
def update_with_history(self, db: Session, id: str, obj_in: dict, updater: dict = None) -> Optional[BizQuotation]:
|
||||
"""更新报价单并记录历史"""
|
||||
original = self.get(db, id)
|
||||
if not original:
|
||||
return None
|
||||
|
||||
his_data = {
|
||||
"id": original.id,
|
||||
"inquiry_id": original.inquiry_id,
|
||||
"supplier_id": original.supplier_id,
|
||||
"region_id": original.region_id,
|
||||
"status": original.status,
|
||||
"total_amount": original.total_amount,
|
||||
"submit_time": original.submit_time,
|
||||
"create_at": original.create_at,
|
||||
"create_by": original.create_by,
|
||||
"create_name": original.create_name,
|
||||
"update_at": original.update_at,
|
||||
"update_by": original.update_by,
|
||||
}
|
||||
his = BizQuotationHis(**his_data)
|
||||
db.add(his)
|
||||
|
||||
return self.update(db, id, obj_in, updater)
|
||||
|
||||
def soft_delete_with_history(self, db: Session, id: str) -> bool:
|
||||
"""删除报价单并记录历史"""
|
||||
original = self.get(db, id)
|
||||
if not original:
|
||||
return False
|
||||
|
||||
his_data = {
|
||||
"id": original.id,
|
||||
"inquiry_id": original.inquiry_id,
|
||||
"supplier_id": original.supplier_id,
|
||||
"region_id": original.region_id,
|
||||
"status": original.status,
|
||||
"total_amount": original.total_amount,
|
||||
"submit_time": original.submit_time,
|
||||
"create_at": original.create_at,
|
||||
"create_by": original.create_by,
|
||||
"create_name": original.create_name,
|
||||
"update_at": original.update_at,
|
||||
"update_by": original.update_by,
|
||||
}
|
||||
his = BizQuotationHis(**his_data)
|
||||
db.add(his)
|
||||
|
||||
return self.delete(db, id)
|
||||
|
||||
|
||||
class QuotationItemCRUD(CRUDBase[BizQuotationItem]):
|
||||
"""报价单项CRUD操作"""
|
||||
|
||||
def get_by_quotation(self, db: Session, quotation_id: str) -> List[BizQuotationItem]:
|
||||
"""获取报价单的所有报价项"""
|
||||
return db.query(BizQuotationItem).filter(
|
||||
and_(
|
||||
BizQuotationItem.quotation_id == quotation_id,
|
||||
BizQuotationItem.is_del == 0
|
||||
)
|
||||
).all()
|
||||
|
||||
def get_multi_by_quotations(
|
||||
self,
|
||||
db: Session,
|
||||
quotation_ids: List[str]
|
||||
) -> List[BizQuotationItem]:
|
||||
"""根据多个报价单ID获取报价项"""
|
||||
return db.query(BizQuotationItem).filter(
|
||||
and_(
|
||||
BizQuotationItem.quotation_id.in_(quotation_ids),
|
||||
BizQuotationItem.is_del == 0
|
||||
)
|
||||
).all()
|
||||
|
||||
def create_items(
|
||||
self,
|
||||
db: Session,
|
||||
quotation_id: str,
|
||||
items: List[dict],
|
||||
creator: dict = None
|
||||
) -> List[BizQuotationItem]:
|
||||
"""批量创建报价项"""
|
||||
result = []
|
||||
for item in items:
|
||||
quotation_item = BizQuotationItem(
|
||||
id=generate_id(),
|
||||
quotation_id=quotation_id,
|
||||
product_id=item.get("product_id"),
|
||||
unit_id=item.get("unit_id"),
|
||||
price=Decimal(str(item.get("price", 0))),
|
||||
create_by=creator.get("user_id") if creator else None,
|
||||
create_name=creator.get("real_name") if creator else None
|
||||
)
|
||||
db.add(quotation_item)
|
||||
result.append(quotation_item)
|
||||
db.commit()
|
||||
for item in result:
|
||||
db.refresh(item)
|
||||
return result
|
||||
|
||||
def delete_by_quotation(self, db: Session, quotation_id: str) -> bool:
|
||||
"""删除报价单的所有报价项"""
|
||||
items = self.get_by_quotation(db, quotation_id)
|
||||
for item in items:
|
||||
item.is_del = 1
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
|
||||
quotation_crud = QuotationCRUD(BizQuotation)
|
||||
quotation_item_crud = QuotationItemCRUD(BizQuotationItem)
|
||||
@@ -1,81 +0,0 @@
|
||||
"""区域管理CRUD"""
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_
|
||||
|
||||
from app.models.models import SysRegion, SysRegionHis
|
||||
from app.crud.base import CRUDBase
|
||||
|
||||
|
||||
class RegionCRUD(CRUDBase[SysRegion]):
|
||||
"""区域CRUD操作"""
|
||||
|
||||
def get_by_code(self, db: Session, code: str) -> Optional[SysRegion]:
|
||||
"""根据编码获取区域(含已删除,匹配数据库唯一索引约束)"""
|
||||
return db.query(SysRegion).filter(SysRegion.code == code).first()
|
||||
|
||||
def get_active_all(self, db: Session) -> List[SysRegion]:
|
||||
"""获取所有启用的区域"""
|
||||
return db.query(SysRegion).filter(
|
||||
and_(SysRegion.is_del == 0, SysRegion.is_active == True)
|
||||
).order_by(SysRegion.sort_order.asc()).all()
|
||||
|
||||
def create_with_history(self, db: Session, obj_in: dict, creator: dict = None) -> SysRegion:
|
||||
"""创建区域并记录历史"""
|
||||
obj = self.create(db, obj_in, creator)
|
||||
return obj
|
||||
|
||||
def update_with_history(self, db: Session, id: str, obj_in: dict, updater: dict = None) -> Optional[SysRegion]:
|
||||
"""更新区域并记录历史"""
|
||||
original = self.get(db, id)
|
||||
if not original:
|
||||
return None
|
||||
|
||||
his_data = {
|
||||
"id": original.id,
|
||||
"name": original.name,
|
||||
"code": original.code,
|
||||
"description": original.description,
|
||||
"sort_order": original.sort_order,
|
||||
"expected_delivery_minutes": original.expected_delivery_minutes,
|
||||
"delivery_warn_advance_minutes": original.delivery_warn_advance_minutes,
|
||||
"is_active": original.is_active,
|
||||
"create_at": original.create_at,
|
||||
"create_by": original.create_by,
|
||||
"create_name": original.create_name,
|
||||
"update_at": original.update_at,
|
||||
"update_by": original.update_by,
|
||||
}
|
||||
his = SysRegionHis(**his_data)
|
||||
db.add(his)
|
||||
|
||||
return self.update(db, id, obj_in, updater)
|
||||
|
||||
def soft_delete_with_history(self, db: Session, id: str) -> bool:
|
||||
"""删除区域并记录历史"""
|
||||
original = self.get(db, id)
|
||||
if not original:
|
||||
return False
|
||||
|
||||
his_data = {
|
||||
"id": original.id,
|
||||
"name": original.name,
|
||||
"code": original.code,
|
||||
"description": original.description,
|
||||
"sort_order": original.sort_order,
|
||||
"expected_delivery_minutes": original.expected_delivery_minutes,
|
||||
"delivery_warn_advance_minutes": original.delivery_warn_advance_minutes,
|
||||
"is_active": original.is_active,
|
||||
"create_at": original.create_at,
|
||||
"create_by": original.create_by,
|
||||
"create_name": original.create_name,
|
||||
"update_at": original.update_at,
|
||||
"update_by": original.update_by,
|
||||
}
|
||||
his = SysRegionHis(**his_data)
|
||||
db.add(his)
|
||||
|
||||
return self.delete(db, id)
|
||||
|
||||
|
||||
region_crud = RegionCRUD(SysRegion)
|
||||
@@ -1,604 +0,0 @@
|
||||
"""结算与退款CRUD"""
|
||||
from typing import Optional, List, Tuple
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, func
|
||||
|
||||
from app.models.models import BizSettlement, BizSettlementHis, BizRefund, BizRefundHis, BizOrder
|
||||
from app.crud.base import CRUDBase
|
||||
from app.crud.order import order_crud
|
||||
from app.core.utils import generate_id
|
||||
from app.core.order_status_compat import derive_legacy_status_from_three
|
||||
|
||||
|
||||
class SettlementCRUD(CRUDBase[BizSettlement]):
|
||||
"""结算记录CRUD操作"""
|
||||
|
||||
def get_multi_with_filters(
|
||||
self,
|
||||
db: Session,
|
||||
region_id: str = None,
|
||||
client_id: str = None,
|
||||
status: str = None,
|
||||
start_date: str = None,
|
||||
end_date: str = None,
|
||||
skip: int = 0,
|
||||
limit: int = 10
|
||||
) -> Tuple[List[BizSettlement], int]:
|
||||
"""多条件查询结算记录"""
|
||||
query = db.query(BizSettlement).filter(BizSettlement.is_del == 0)
|
||||
|
||||
if region_id:
|
||||
query = query.filter(BizSettlement.region_id == region_id)
|
||||
if client_id:
|
||||
query = query.filter(BizSettlement.client_id == client_id)
|
||||
if status:
|
||||
query = query.filter(BizSettlement.status == status)
|
||||
if start_date:
|
||||
query = query.filter(BizSettlement.create_at >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(BizSettlement.create_at <= end_date)
|
||||
|
||||
total = query.count()
|
||||
settlements = query.order_by(BizSettlement.create_at.desc()).offset(skip).limit(limit).all()
|
||||
return settlements, total
|
||||
|
||||
def get_by_order(self, db: Session, order_id: str) -> List[BizSettlement]:
|
||||
"""根据订单获取结算记录"""
|
||||
return db.query(BizSettlement).filter(
|
||||
and_(
|
||||
BizSettlement.order_id == order_id,
|
||||
BizSettlement.is_del == 0
|
||||
)
|
||||
).all()
|
||||
|
||||
def create_settlement(
|
||||
self,
|
||||
db: Session,
|
||||
order_id: str,
|
||||
client_id: str,
|
||||
region_id: str,
|
||||
amount: Decimal,
|
||||
settlement_method: str,
|
||||
settlement_time: datetime = None,
|
||||
attachment_url: str = None,
|
||||
note: str = None,
|
||||
creator: dict = None
|
||||
) -> BizSettlement:
|
||||
"""创建结算记录"""
|
||||
settlement = BizSettlement(
|
||||
id=generate_id(),
|
||||
order_id=order_id,
|
||||
client_id=client_id,
|
||||
region_id=region_id,
|
||||
amount=amount,
|
||||
settlement_method=settlement_method,
|
||||
settlement_time=settlement_time or datetime.now(),
|
||||
settlement_file_url=attachment_url,
|
||||
settlement_note=note,
|
||||
status="confirmed",
|
||||
reviewed_by=creator.get("user_id") if creator else None,
|
||||
reviewed_time=datetime.now(),
|
||||
create_by=creator.get("user_id") if creator else None,
|
||||
create_name=creator.get("real_name") if creator else None
|
||||
)
|
||||
db.add(settlement)
|
||||
db.flush()
|
||||
|
||||
# 更新订单的已付金额
|
||||
order = db.query(BizOrder).filter(BizOrder.id == order_id).first()
|
||||
if order:
|
||||
order.paid_amount = (order.paid_amount or Decimal("0")) + amount
|
||||
if order.paid_amount >= order.receivable_amount:
|
||||
order.paid_amount = order.receivable_amount
|
||||
order.update_at = datetime.now()
|
||||
order_crud.sync_pay_fields_from_amounts(order)
|
||||
|
||||
db.commit()
|
||||
db.refresh(settlement)
|
||||
return settlement
|
||||
|
||||
def batch_create_settlements(
|
||||
self,
|
||||
db: Session,
|
||||
order_ids: List[str],
|
||||
settlement_method: str,
|
||||
settlement_time: datetime = None,
|
||||
attachment_url: str = None,
|
||||
note: str = None,
|
||||
creator: dict = None
|
||||
) -> List[BizSettlement]:
|
||||
"""批量创建结算记录"""
|
||||
settlements = []
|
||||
for order_id in order_ids:
|
||||
order = db.query(BizOrder).filter(
|
||||
and_(
|
||||
BizOrder.id == order_id,
|
||||
BizOrder.is_del == 0
|
||||
)
|
||||
).first()
|
||||
|
||||
if order and order.status not in ["cancelled", "completed"]:
|
||||
# 计算未付金额
|
||||
unpaid_amount = (order.receivable_amount or Decimal("0")) - (order.paid_amount or Decimal("0"))
|
||||
|
||||
settlement = BizSettlement(
|
||||
id=generate_id(),
|
||||
order_id=order_id,
|
||||
client_id=order.client_id,
|
||||
region_id=order.region_id,
|
||||
amount=unpaid_amount,
|
||||
settlement_method=settlement_method,
|
||||
settlement_time=settlement_time or datetime.now(),
|
||||
settlement_file_url=attachment_url,
|
||||
settlement_note=note,
|
||||
status="confirmed",
|
||||
reviewed_by=creator.get("user_id") if creator else None,
|
||||
reviewed_time=datetime.now(),
|
||||
create_by=creator.get("user_id") if creator else None,
|
||||
create_name=creator.get("real_name") if creator else None
|
||||
)
|
||||
db.add(settlement)
|
||||
settlements.append(settlement)
|
||||
|
||||
# 更新订单的已付金额
|
||||
order.paid_amount = order.receivable_amount
|
||||
order.update_at = datetime.now()
|
||||
order_crud.sync_pay_fields_from_amounts(order)
|
||||
|
||||
db.commit()
|
||||
return settlements
|
||||
|
||||
def confirm_settlement(
|
||||
self,
|
||||
db: Session,
|
||||
settlement_id: str,
|
||||
reviewer_id: str,
|
||||
note: str = None
|
||||
) -> Optional[BizSettlement]:
|
||||
"""确认结算"""
|
||||
settlement = self.get(db, settlement_id)
|
||||
if not settlement:
|
||||
return None
|
||||
|
||||
if settlement.status != "pending":
|
||||
return None
|
||||
|
||||
settlement.status = "confirmed"
|
||||
settlement.reviewed_by = reviewer_id
|
||||
settlement.reviewed_time = datetime.now()
|
||||
if note:
|
||||
settlement.settlement_note = note
|
||||
settlement.update_at = datetime.now()
|
||||
|
||||
# 更新订单的已付金额
|
||||
order = db.query(BizOrder).filter(BizOrder.id == settlement.order_id).first()
|
||||
if order:
|
||||
order.paid_amount = (order.paid_amount or Decimal("0")) + settlement.amount
|
||||
if order.paid_amount >= order.receivable_amount:
|
||||
order.paid_amount = order.receivable_amount
|
||||
order.update_at = datetime.now()
|
||||
order_crud.sync_pay_fields_from_amounts(order)
|
||||
|
||||
db.commit()
|
||||
db.refresh(settlement)
|
||||
return settlement
|
||||
|
||||
def update_with_history(self, db: Session, id: str, obj_in: dict, updater: dict = None) -> Optional[BizSettlement]:
|
||||
"""更新结算记录并记录历史"""
|
||||
original = self.get(db, id)
|
||||
if not original:
|
||||
return None
|
||||
|
||||
his_data = {
|
||||
"id": original.id,
|
||||
"order_id": original.order_id,
|
||||
"client_id": original.client_id,
|
||||
"region_id": original.region_id,
|
||||
"amount": original.amount,
|
||||
"settlement_method": original.settlement_method,
|
||||
"settlement_time": original.settlement_time,
|
||||
"settlement_file_url": original.settlement_file_url,
|
||||
"settlement_note": original.settlement_note,
|
||||
"status": original.status,
|
||||
"reviewed_by": original.reviewed_by,
|
||||
"reviewed_time": original.reviewed_time,
|
||||
"create_at": original.create_at,
|
||||
"create_by": original.create_by,
|
||||
"create_name": original.create_name,
|
||||
"update_at": original.update_at,
|
||||
"update_by": original.update_by,
|
||||
}
|
||||
his = BizSettlementHis(**his_data)
|
||||
db.add(his)
|
||||
|
||||
return self.update(db, id, obj_in, updater)
|
||||
|
||||
def get_client_summary(
|
||||
self,
|
||||
db: Session,
|
||||
region_id: str = None,
|
||||
client_id: str = None,
|
||||
skip: int = 0,
|
||||
limit: int = 10
|
||||
) -> Tuple[List[dict], int]:
|
||||
"""获取客户待结算汇总"""
|
||||
query = db.query(
|
||||
BizOrder.client_id,
|
||||
func.count(BizOrder.id).label("order_count"),
|
||||
func.sum(BizOrder.receivable_amount - BizOrder.paid_amount).label("total_unpaid")
|
||||
).filter(
|
||||
and_(
|
||||
BizOrder.is_del == 0,
|
||||
BizOrder.receivable_amount > BizOrder.paid_amount,
|
||||
BizOrder.status.notin_(["cancelled"])
|
||||
)
|
||||
)
|
||||
|
||||
if region_id:
|
||||
query = query.filter(BizOrder.region_id == region_id)
|
||||
if client_id:
|
||||
query = query.filter(BizOrder.client_id == client_id)
|
||||
|
||||
query = query.group_by(BizOrder.client_id)
|
||||
|
||||
total = query.count()
|
||||
results = query.offset(skip).limit(limit).all()
|
||||
|
||||
return [{"client_id": r.client_id, "order_count": r.order_count, "total_unpaid": r.total_unpaid or Decimal("0")} for r in results], total
|
||||
|
||||
def get_statistics(
|
||||
self,
|
||||
db: Session,
|
||||
region_id: str = None,
|
||||
start_date: str = None,
|
||||
end_date: str = None
|
||||
) -> dict:
|
||||
"""获取结算统计数据"""
|
||||
# 待结算金额(使用单条SQL查询,避免N+1问题)
|
||||
pending_query = db.query(
|
||||
func.count(BizOrder.id).label("order_count"),
|
||||
func.coalesce(func.sum(BizOrder.receivable_amount - BizOrder.paid_amount), 0).label("pending_amount")
|
||||
).filter(
|
||||
and_(
|
||||
BizOrder.is_del == 0,
|
||||
BizOrder.receivable_amount > BizOrder.paid_amount,
|
||||
BizOrder.status.notin_(["cancelled", "completed"])
|
||||
)
|
||||
)
|
||||
if region_id:
|
||||
pending_query = pending_query.filter(BizOrder.region_id == region_id)
|
||||
pending_result = pending_query.first()
|
||||
|
||||
# 已结算金额
|
||||
settled_query = db.query(
|
||||
func.count(BizSettlement.id).label("settlement_count"),
|
||||
func.coalesce(func.sum(BizSettlement.amount), 0).label("settled_amount")
|
||||
).filter(
|
||||
and_(
|
||||
BizSettlement.is_del == 0,
|
||||
BizSettlement.status == "confirmed"
|
||||
)
|
||||
)
|
||||
if region_id:
|
||||
settled_query = settled_query.filter(BizSettlement.region_id == region_id)
|
||||
if start_date:
|
||||
settled_query = settled_query.filter(BizSettlement.reviewed_time >= start_date)
|
||||
if end_date:
|
||||
settled_query = settled_query.filter(BizSettlement.reviewed_time <= end_date)
|
||||
settled_result = settled_query.first()
|
||||
|
||||
# 待处理退款数
|
||||
refund_pending_count = db.query(func.count(BizRefund.id)).filter(
|
||||
and_(
|
||||
BizRefund.is_del == 0,
|
||||
BizRefund.status.in_(["pending", "processing"])
|
||||
)
|
||||
)
|
||||
if region_id:
|
||||
refund_pending_count = refund_pending_count.join(
|
||||
BizOrder, BizOrder.id == BizRefund.order_id
|
||||
).filter(BizOrder.region_id == region_id)
|
||||
refund_pending_count = refund_pending_count.scalar() or 0
|
||||
|
||||
return {
|
||||
"pendingOrderCount": pending_result.order_count or 0,
|
||||
"pendingAmount": float(pending_result.pending_amount or 0),
|
||||
"settledCount": settled_result.settlement_count or 0,
|
||||
"settledAmount": float(settled_result.settled_amount or 0),
|
||||
"refundPendingCount": refund_pending_count
|
||||
}
|
||||
|
||||
|
||||
class RefundCRUD(CRUDBase[BizRefund]):
|
||||
"""退款记录CRUD操作"""
|
||||
|
||||
def get_multi_with_filters(
|
||||
self,
|
||||
db: Session,
|
||||
region_id: str = None,
|
||||
order_id: str = None,
|
||||
order_no: str = None,
|
||||
status: str = None,
|
||||
refund_method: str = None,
|
||||
start_date: str = None,
|
||||
end_date: str = None,
|
||||
skip: int = 0,
|
||||
limit: int = 10
|
||||
) -> Tuple[List[BizRefund], int]:
|
||||
"""多条件查询退款记录"""
|
||||
query = db.query(BizRefund).filter(BizRefund.is_del == 0)
|
||||
|
||||
if region_id:
|
||||
# 需要关联订单获取区域
|
||||
query = query.join(BizOrder, BizOrder.id == BizRefund.order_id).filter(
|
||||
BizOrder.region_id == region_id
|
||||
)
|
||||
if order_id:
|
||||
query = query.filter(BizRefund.order_id == order_id)
|
||||
if order_no:
|
||||
query = query.join(BizOrder, BizOrder.id == BizRefund.order_id).filter(
|
||||
BizOrder.order_no.like(f"%{order_no}%")
|
||||
)
|
||||
if status:
|
||||
query = query.filter(BizRefund.status == status)
|
||||
if refund_method:
|
||||
query = query.filter(BizRefund.refund_method == refund_method)
|
||||
if start_date:
|
||||
query = query.filter(BizRefund.create_at >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(BizRefund.create_at <= end_date)
|
||||
|
||||
total = query.count()
|
||||
refunds = query.order_by(BizRefund.create_at.desc()).offset(skip).limit(limit).all()
|
||||
return refunds, total
|
||||
|
||||
def get_by_order(self, db: Session, order_id: str) -> List[BizRefund]:
|
||||
"""根据订单获取退款记录"""
|
||||
return db.query(BizRefund).filter(
|
||||
and_(
|
||||
BizRefund.order_id == order_id,
|
||||
BizRefund.is_del == 0
|
||||
)
|
||||
).all()
|
||||
|
||||
def get_by_refund_no(self, db: Session, refund_no: str) -> Optional[BizRefund]:
|
||||
"""根据退款单号获取退款记录"""
|
||||
return db.query(BizRefund).filter(
|
||||
and_(
|
||||
BizRefund.refund_no == refund_no,
|
||||
BizRefund.is_del == 0
|
||||
)
|
||||
).first()
|
||||
|
||||
def create_refund(
|
||||
self,
|
||||
db: Session,
|
||||
order_id: str,
|
||||
refund_amount: Decimal,
|
||||
refund_method: str,
|
||||
reason: str = None,
|
||||
creator: dict = None
|
||||
) -> BizRefund:
|
||||
"""创建退款记录"""
|
||||
refund_no = self._generate_refund_no(db)
|
||||
|
||||
refund = BizRefund(
|
||||
id=generate_id(),
|
||||
order_id=order_id,
|
||||
refund_no=refund_no,
|
||||
refund_amount=refund_amount,
|
||||
refund_method=refund_method,
|
||||
refund_result="processing",
|
||||
status="pending",
|
||||
create_by=creator.get("user_id") if creator else None,
|
||||
create_name=creator.get("real_name") if creator else None
|
||||
)
|
||||
db.add(refund)
|
||||
|
||||
# 更新订单的退款金额和状态
|
||||
order = db.query(BizOrder).filter(BizOrder.id == order_id).first()
|
||||
if order:
|
||||
order.refund_amount = (order.refund_amount or Decimal("0")) + refund_amount
|
||||
order.update_at = datetime.now()
|
||||
|
||||
# 如果是微信退款,更新订单状态为退款中
|
||||
if refund_method == "wechat":
|
||||
order.pay_status = "refunding"
|
||||
order.status = derive_legacy_status_from_three(
|
||||
order_status=order.order_status,
|
||||
delivery_status=order.delivery_status,
|
||||
pay_status=order.pay_status,
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(refund)
|
||||
return refund
|
||||
|
||||
def process_refund(
|
||||
self,
|
||||
db: Session,
|
||||
refund_id: str,
|
||||
refund_result: str,
|
||||
refund_no_wechat: str = None,
|
||||
updater: dict = None
|
||||
) -> Optional[BizRefund]:
|
||||
"""处理退款(成功/失败)"""
|
||||
refund = self.get(db, refund_id)
|
||||
if not refund:
|
||||
return None
|
||||
|
||||
refund.refund_result = refund_result
|
||||
refund.refund_time = datetime.now()
|
||||
if refund_no_wechat:
|
||||
refund.refund_no_wechat = refund_no_wechat
|
||||
|
||||
if refund_result == "success":
|
||||
refund.status = "completed"
|
||||
# 更新订单退款金额
|
||||
order = db.query(BizOrder).filter(BizOrder.id == refund.order_id).first()
|
||||
if order:
|
||||
order.refund_amount = (order.refund_amount or Decimal("0")) + refund.refund_amount
|
||||
order.paid_amount = max(Decimal("0"), (order.paid_amount or Decimal("0")) - refund.refund_amount)
|
||||
order.pay_status = "refunded" if (order.paid_amount or Decimal("0")) <= Decimal("0") else "paid"
|
||||
order.status = derive_legacy_status_from_three(
|
||||
order_status=order.order_status,
|
||||
delivery_status=order.delivery_status,
|
||||
pay_status=order.pay_status,
|
||||
)
|
||||
order.update_at = datetime.now()
|
||||
|
||||
# 如果退款金额等于已支付金额,订单变为已取消
|
||||
if order.refund_amount >= (order.total_amount - order.paid_amount + order.refund_amount):
|
||||
# 保留部分退款状态
|
||||
pass
|
||||
else:
|
||||
refund.status = "failed"
|
||||
# 恢复订单状态
|
||||
order = db.query(BizOrder).filter(BizOrder.id == refund.order_id).first()
|
||||
if order and order.pay_status == "refunding":
|
||||
order.pay_status = "paid" if (order.paid_amount or Decimal("0")) > Decimal("0") else "pending_payment"
|
||||
order.status = derive_legacy_status_from_three(
|
||||
order_status=order.order_status,
|
||||
delivery_status=order.delivery_status,
|
||||
pay_status=order.pay_status,
|
||||
)
|
||||
|
||||
refund.update_at = datetime.now()
|
||||
db.commit()
|
||||
db.refresh(refund)
|
||||
return refund
|
||||
|
||||
def handle_refund_failure(
|
||||
self,
|
||||
db: Session,
|
||||
refund_id: str,
|
||||
fail_reason: str
|
||||
) -> Optional[BizRefund]:
|
||||
"""处理退款失败"""
|
||||
refund = self.get(db, refund_id)
|
||||
if not refund:
|
||||
return None
|
||||
|
||||
refund.refund_result = "failed"
|
||||
refund.refund_fail_reason = fail_reason
|
||||
refund.refund_time = datetime.now()
|
||||
refund.status = "failed"
|
||||
refund.update_at = datetime.now()
|
||||
|
||||
# 恢复订单状态
|
||||
order = db.query(BizOrder).filter(BizOrder.id == refund.order_id).first()
|
||||
if order and order.pay_status == "refunding":
|
||||
order.pay_status = "paid" if (order.paid_amount or Decimal("0")) > Decimal("0") else "pending_payment"
|
||||
order.status = derive_legacy_status_from_three(
|
||||
order_status=order.order_status,
|
||||
delivery_status=order.delivery_status,
|
||||
pay_status=order.pay_status,
|
||||
)
|
||||
order.update_at = datetime.now()
|
||||
|
||||
db.commit()
|
||||
db.refresh(refund)
|
||||
return refund
|
||||
|
||||
def _generate_refund_no(self, db: Session) -> str:
|
||||
"""生成退款单号"""
|
||||
today = datetime.now().strftime("%Y%m%d")
|
||||
prefix = f"REF{today}"
|
||||
|
||||
last_refund = db.query(BizRefund).filter(
|
||||
BizRefund.refund_no.like(f"{prefix}%")
|
||||
).order_by(BizRefund.refund_no.desc()).first()
|
||||
|
||||
if last_refund:
|
||||
last_no = int(last_refund.refund_no[-4:])
|
||||
new_no = last_no + 1
|
||||
else:
|
||||
new_no = 1
|
||||
|
||||
return f"{prefix}{str(new_no).zfill(4)}"
|
||||
|
||||
def update_with_history(self, db: Session, id: str, obj_in: dict, updater: dict = None) -> Optional[BizRefund]:
|
||||
"""更新退款记录并记录历史"""
|
||||
original = self.get(db, id)
|
||||
if not original:
|
||||
return None
|
||||
|
||||
his_data = {
|
||||
"id": original.id,
|
||||
"order_id": original.order_id,
|
||||
"refund_no": original.refund_no,
|
||||
"refund_amount": original.refund_amount,
|
||||
"refund_method": original.refund_method,
|
||||
"refund_no_wechat": original.refund_no_wechat,
|
||||
"refund_time": original.refund_time,
|
||||
"refund_result": original.refund_result,
|
||||
"refund_fail_reason": original.refund_fail_reason,
|
||||
"status": original.status,
|
||||
"create_at": original.create_at,
|
||||
"create_by": original.create_by,
|
||||
"create_name": original.create_name,
|
||||
"update_at": original.update_at,
|
||||
"update_by": original.update_by,
|
||||
}
|
||||
his = BizRefundHis(**his_data)
|
||||
db.add(his)
|
||||
|
||||
return self.update(db, id, obj_in, updater)
|
||||
|
||||
def get_refund_statistics(
|
||||
self,
|
||||
db: Session,
|
||||
start_date: str = None,
|
||||
end_date: str = None
|
||||
) -> dict:
|
||||
"""获取退款统计数据"""
|
||||
base_query = db.query(BizRefund).filter(
|
||||
and_(
|
||||
BizRefund.is_del == 0,
|
||||
BizRefund.status == "completed"
|
||||
)
|
||||
)
|
||||
|
||||
if start_date:
|
||||
base_query = base_query.filter(BizRefund.refund_time >= start_date)
|
||||
if end_date:
|
||||
base_query = base_query.filter(BizRefund.refund_time <= end_date)
|
||||
|
||||
# 成功退款金额
|
||||
success_amount = base_query.filter(
|
||||
BizRefund.refund_result == "success"
|
||||
).func.sum(BizRefund.refund_amount).scalar() or Decimal("0")
|
||||
|
||||
# 失败退款金额
|
||||
failed_amount = base_query.filter(
|
||||
BizRefund.refund_result == "failed"
|
||||
).func.sum(BizRefund.refund_amount).scalar() or Decimal("0")
|
||||
|
||||
# 待处理退款数
|
||||
pending_count = db.query(func.count(BizRefund.id)).filter(
|
||||
and_(
|
||||
BizRefund.is_del == 0,
|
||||
BizRefund.status.in_(["pending", "processing"])
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
# 退款失败数
|
||||
failed_count = db.query(func.count(BizRefund.id)).filter(
|
||||
and_(
|
||||
BizRefund.is_del == 0,
|
||||
BizRefund.status == "failed"
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
return {
|
||||
"successAmount": float(success_amount),
|
||||
"failedAmount": float(failed_amount),
|
||||
"pendingCount": pending_count,
|
||||
"failedCount": failed_count
|
||||
}
|
||||
|
||||
|
||||
settlement_crud = SettlementCRUD(BizSettlement)
|
||||
refund_crud = RefundCRUD(BizRefund)
|
||||
@@ -1,916 +0,0 @@
|
||||
"""数据统计 CRUD"""
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, func, desc, extract
|
||||
from typing import Optional, List, Dict, Any, Tuple
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from app.models.models import (
|
||||
BizOrder, BizOrderItem, BizDeliveryTask,
|
||||
BizSettlement, BizRefund, BizPriceList, BizPriceListItem,
|
||||
SysUser, SysRegion, BasProduct,
|
||||
BizInquiry, BizQuotation, BizDeliveryWarning,
|
||||
)
|
||||
|
||||
|
||||
class StatisticsCRUD:
|
||||
"""数据统计CRUD操作"""
|
||||
|
||||
@staticmethod
|
||||
def get_order_statistics(
|
||||
db: Session,
|
||||
region_id: str = None,
|
||||
start_date: str = None,
|
||||
end_date: str = None
|
||||
) -> Dict[str, Any]:
|
||||
"""获取订单统计数据"""
|
||||
query = db.query(BizOrder).filter(BizOrder.is_del == 0)
|
||||
|
||||
if region_id:
|
||||
query = query.filter(BizOrder.region_id == region_id)
|
||||
|
||||
if start_date:
|
||||
try:
|
||||
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
|
||||
query = query.filter(BizOrder.create_at >= start_dt)
|
||||
except:
|
||||
pass
|
||||
|
||||
if end_date:
|
||||
try:
|
||||
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
|
||||
end_dt = end_dt.replace(hour=23, minute=59, second=59)
|
||||
query = query.filter(BizOrder.create_at <= end_dt)
|
||||
except:
|
||||
pass
|
||||
|
||||
orders = query.all()
|
||||
|
||||
total_orders = len(orders)
|
||||
pending_payment = sum(1 for o in orders if o.status == "pending_payment")
|
||||
pending_dispatch = sum(1 for o in orders if getattr(o, "delivery_status", None) == "pending_dispatch")
|
||||
delivering = sum(1 for o in orders if o.status == "delivering")
|
||||
completed = sum(1 for o in orders if o.status == "completed")
|
||||
cancelled = sum(1 for o in orders if o.status == "cancelled")
|
||||
|
||||
total_amount = sum(float(o.total_amount or 0) for o in orders)
|
||||
paid_amount = sum(float(o.paid_amount or 0) for o in orders)
|
||||
refund_amount = sum(float(o.refund_amount or 0) for o in orders)
|
||||
|
||||
return {
|
||||
"totalOrders": total_orders,
|
||||
"pendingPayment": pending_payment,
|
||||
"pendingDelivery": pending_dispatch,
|
||||
"delivering": delivering,
|
||||
"completed": completed,
|
||||
"cancelled": cancelled,
|
||||
"totalAmount": round(total_amount, 2),
|
||||
"paidAmount": round(paid_amount, 2),
|
||||
"refundAmount": round(refund_amount, 2)
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_order_trend(
|
||||
db: Session,
|
||||
region_id: str = None,
|
||||
days: int = 7,
|
||||
include_inactive_regions: bool = False,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""获取订单趋势数据(按日统计)"""
|
||||
result = []
|
||||
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
region_active_clause = [] if include_inactive_regions else [SysRegion.is_active == True]
|
||||
|
||||
for i in range(days - 1, -1, -1):
|
||||
day_start = today - timedelta(days=i)
|
||||
day_end = day_start + timedelta(days=1)
|
||||
|
||||
query = (
|
||||
db.query(BizOrder)
|
||||
.join(SysRegion, BizOrder.region_id == SysRegion.id)
|
||||
.filter(
|
||||
and_(
|
||||
BizOrder.is_del == 0,
|
||||
BizOrder.create_at >= day_start,
|
||||
BizOrder.create_at < day_end,
|
||||
SysRegion.is_del == 0,
|
||||
*region_active_clause,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if region_id:
|
||||
query = query.filter(BizOrder.region_id == region_id)
|
||||
|
||||
orders = query.all()
|
||||
order_count = len(orders)
|
||||
order_amount = sum(float(o.total_amount or 0) for o in orders)
|
||||
|
||||
result.append({
|
||||
"date": day_start.strftime("%Y-%m-%d"),
|
||||
"orderCount": order_count,
|
||||
"orderAmount": round(order_amount, 2)
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def get_today_order_stats_by_region(
|
||||
db: Session,
|
||||
region_id: str = None,
|
||||
include_inactive_regions: bool = False,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""今日各区域营业额(元)、订单笔数;列出未删除的区域,无单则为 0。"""
|
||||
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
day_end = today + timedelta(days=1)
|
||||
region_active_clause = [] if include_inactive_regions else [SysRegion.is_active == True]
|
||||
|
||||
reg_q = db.query(SysRegion).filter(
|
||||
SysRegion.is_del == 0,
|
||||
)
|
||||
if region_id:
|
||||
reg_q = reg_q.filter(SysRegion.id == region_id)
|
||||
regions = reg_q.order_by(SysRegion.sort_order, SysRegion.name).all()
|
||||
|
||||
agg_q = (
|
||||
db.query(
|
||||
BizOrder.region_id,
|
||||
func.count(BizOrder.id).label("order_count"),
|
||||
func.coalesce(func.sum(BizOrder.total_amount), 0).label("order_amount"),
|
||||
)
|
||||
.join(SysRegion, BizOrder.region_id == SysRegion.id)
|
||||
.filter(
|
||||
BizOrder.is_del == 0,
|
||||
BizOrder.create_at >= today,
|
||||
BizOrder.create_at < day_end,
|
||||
SysRegion.is_del == 0,
|
||||
*region_active_clause,
|
||||
)
|
||||
.group_by(BizOrder.region_id)
|
||||
)
|
||||
if region_id:
|
||||
agg_q = agg_q.filter(BizOrder.region_id == region_id)
|
||||
agg_map: Dict[str, Any] = {r.region_id: r for r in agg_q.all() if r.region_id}
|
||||
|
||||
out: List[Dict[str, Any]] = []
|
||||
for reg in regions:
|
||||
row = agg_map.get(reg.id)
|
||||
cnt = int(row.order_count or 0) if row else 0
|
||||
amt = float(row.order_amount or 0) if row else 0.0
|
||||
out.append({
|
||||
"regionId": reg.id,
|
||||
"regionName": reg.name or reg.id,
|
||||
"orderCount": cnt,
|
||||
"orderAmount": round(amt, 2),
|
||||
})
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def get_today_order_stats_by_client(
|
||||
db: Session,
|
||||
region_id: str = None,
|
||||
limit: int = 30,
|
||||
include_inactive_regions: bool = False,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""今日各下单客户:下单金额合计(元)、商品数量合计(订单行数量之和);按金额降序,最多 limit 条。"""
|
||||
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
day_end = today + timedelta(days=1)
|
||||
region_active_clause = [] if include_inactive_regions else [SysRegion.is_active == True]
|
||||
|
||||
amt_q = (
|
||||
db.query(
|
||||
BizOrder.client_id,
|
||||
func.coalesce(func.sum(BizOrder.total_amount), 0).label("order_amount"),
|
||||
)
|
||||
.join(SysRegion, BizOrder.region_id == SysRegion.id)
|
||||
.filter(
|
||||
BizOrder.is_del == 0,
|
||||
BizOrder.create_at >= today,
|
||||
BizOrder.create_at < day_end,
|
||||
SysRegion.is_del == 0,
|
||||
*region_active_clause,
|
||||
)
|
||||
)
|
||||
if region_id:
|
||||
amt_q = amt_q.filter(BizOrder.region_id == region_id)
|
||||
amt_rows = amt_q.group_by(BizOrder.client_id).all()
|
||||
|
||||
qty_q = (
|
||||
db.query(
|
||||
BizOrder.client_id,
|
||||
func.coalesce(func.sum(BizOrderItem.quantity), 0).label("qty"),
|
||||
)
|
||||
.select_from(BizOrderItem)
|
||||
.join(
|
||||
BizOrder,
|
||||
and_(
|
||||
BizOrder.id == BizOrderItem.order_id,
|
||||
BizOrder.is_del == 0,
|
||||
BizOrder.create_at >= today,
|
||||
BizOrder.create_at < day_end,
|
||||
),
|
||||
)
|
||||
.join(SysRegion, BizOrder.region_id == SysRegion.id)
|
||||
.filter(BizOrderItem.is_del == 0)
|
||||
.filter(SysRegion.is_del == 0, *region_active_clause)
|
||||
)
|
||||
if region_id:
|
||||
qty_q = qty_q.filter(BizOrder.region_id == region_id)
|
||||
qty_rows = qty_q.group_by(BizOrder.client_id).all()
|
||||
qty_map: Dict[str, float] = {
|
||||
r.client_id: float(r.qty or 0) for r in qty_rows if r.client_id
|
||||
}
|
||||
|
||||
merged: List[Dict[str, Any]] = []
|
||||
for r in amt_rows:
|
||||
cid = r.client_id
|
||||
if not cid:
|
||||
continue
|
||||
merged.append({
|
||||
"clientId": cid,
|
||||
"orderAmount": round(float(r.order_amount or 0), 2),
|
||||
"productQuantity": round(float(qty_map.get(cid, 0) or 0), 4),
|
||||
})
|
||||
|
||||
merged.sort(key=lambda x: -x["orderAmount"])
|
||||
merged = merged[: max(1, min(int(limit or 30), 100))]
|
||||
|
||||
client_ids = [m["clientId"] for m in merged]
|
||||
names: Dict[str, str] = {}
|
||||
if client_ids:
|
||||
for u in (
|
||||
db.query(SysUser)
|
||||
.filter(and_(SysUser.is_del == 0, SysUser.id.in_(client_ids)))
|
||||
.all()
|
||||
):
|
||||
names[u.id] = (u.real_name or u.phone or u.username or u.id) or u.id
|
||||
|
||||
out: List[Dict[str, Any]] = []
|
||||
for m in merged:
|
||||
cid = m["clientId"]
|
||||
nm = names.get(cid, cid)
|
||||
out.append({
|
||||
"clientId": cid,
|
||||
"clientName": nm,
|
||||
"orderAmount": m["orderAmount"],
|
||||
"productQuantity": m["productQuantity"],
|
||||
})
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def get_financial_statistics(
|
||||
db: Session,
|
||||
region_id: str = None,
|
||||
start_date: str = None,
|
||||
end_date: str = None
|
||||
) -> Dict[str, Any]:
|
||||
"""获取财务统计数据"""
|
||||
order_query = db.query(BizOrder).filter(BizOrder.is_del == 0)
|
||||
settlement_query = db.query(BizSettlement).filter(BizSettlement.is_del == 0)
|
||||
refund_query = db.query(BizRefund).filter(BizRefund.is_del == 0)
|
||||
|
||||
if region_id:
|
||||
order_query = order_query.filter(BizOrder.region_id == region_id)
|
||||
settlement_query = settlement_query.filter(BizSettlement.region_id == region_id)
|
||||
|
||||
if start_date:
|
||||
try:
|
||||
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
|
||||
order_query = order_query.filter(BizOrder.create_at >= start_dt)
|
||||
settlement_query = settlement_query.filter(BizSettlement.create_at >= start_dt)
|
||||
refund_query = refund_query.filter(BizRefund.create_at >= start_dt)
|
||||
except:
|
||||
pass
|
||||
|
||||
if end_date:
|
||||
try:
|
||||
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
|
||||
end_dt = end_dt.replace(hour=23, minute=59, second=59)
|
||||
order_query = order_query.filter(BizOrder.create_at <= end_dt)
|
||||
settlement_query = settlement_query.filter(BizSettlement.create_at <= end_dt)
|
||||
refund_query = refund_query.filter(BizRefund.create_at <= end_dt)
|
||||
except:
|
||||
pass
|
||||
|
||||
orders = order_query.all()
|
||||
settlements = settlement_query.filter(BizSettlement.status == "confirmed").all()
|
||||
refunds = refund_query.filter(BizRefund.status == "completed").all()
|
||||
|
||||
total_revenue = sum(float(o.total_amount or 0) for o in orders)
|
||||
total_received = sum(float(s.amount or 0) for s in settlements)
|
||||
total_refund = sum(float(r.refund_amount or 0) for r in refunds)
|
||||
net_revenue = total_received - total_refund
|
||||
|
||||
return {
|
||||
"totalRevenue": round(total_revenue, 2),
|
||||
"totalReceived": round(total_received, 2),
|
||||
"totalRefund": round(total_refund, 2),
|
||||
"netRevenue": round(net_revenue, 2),
|
||||
"orderCount": len(orders),
|
||||
"settlementCount": len(settlements),
|
||||
"refundCount": len(refunds)
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_financial_trend(
|
||||
db: Session,
|
||||
region_id: str = None,
|
||||
days: int = 7
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""获取财务趋势数据(按日统计)"""
|
||||
result = []
|
||||
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
for i in range(days - 1, -1, -1):
|
||||
day_start = today - timedelta(days=i)
|
||||
day_end = day_start + timedelta(days=1)
|
||||
|
||||
settlement_query = db.query(BizSettlement).filter(
|
||||
and_(
|
||||
BizSettlement.is_del == 0,
|
||||
BizSettlement.status == "confirmed",
|
||||
BizSettlement.create_at >= day_start,
|
||||
BizSettlement.create_at < day_end
|
||||
)
|
||||
)
|
||||
|
||||
refund_query = db.query(BizRefund).filter(
|
||||
and_(
|
||||
BizRefund.is_del == 0,
|
||||
BizRefund.status == "completed",
|
||||
BizRefund.create_at >= day_start,
|
||||
BizRefund.create_at < day_end
|
||||
)
|
||||
)
|
||||
|
||||
if region_id:
|
||||
settlement_query = settlement_query.filter(BizSettlement.region_id == region_id)
|
||||
|
||||
settlements = settlement_query.all()
|
||||
refunds = refund_query.all()
|
||||
|
||||
received = sum(float(s.amount or 0) for s in settlements)
|
||||
refund = sum(float(r.refund_amount or 0) for r in refunds)
|
||||
|
||||
result.append({
|
||||
"date": day_start.strftime("%Y-%m-%d"),
|
||||
"received": round(received, 2),
|
||||
"refund": round(refund, 2),
|
||||
"net": round(received - refund, 2)
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def get_delivery_statistics(
|
||||
db: Session,
|
||||
region_id: str = None,
|
||||
start_date: str = None,
|
||||
end_date: str = None
|
||||
) -> Dict[str, Any]:
|
||||
"""获取配送统计数据"""
|
||||
query = db.query(BizDeliveryTask).filter(BizDeliveryTask.is_del == 0)
|
||||
|
||||
if region_id:
|
||||
query = query.filter(BizDeliveryTask.region_id == region_id)
|
||||
|
||||
if start_date:
|
||||
try:
|
||||
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
|
||||
query = query.filter(BizDeliveryTask.create_at >= start_dt)
|
||||
except:
|
||||
pass
|
||||
|
||||
if end_date:
|
||||
try:
|
||||
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
|
||||
end_dt = end_dt.replace(hour=23, minute=59, second=59)
|
||||
query = query.filter(BizDeliveryTask.create_at <= end_dt)
|
||||
except:
|
||||
pass
|
||||
|
||||
tasks = query.all()
|
||||
|
||||
pending = sum(1 for t in tasks if t.status == "pending")
|
||||
picked_up = sum(1 for t in tasks if t.status == "picked_up")
|
||||
delivering = sum(1 for t in tasks if t.status == "delivering")
|
||||
completed = sum(1 for t in tasks if t.status == "completed")
|
||||
cancelled = sum(1 for t in tasks if t.status == "cancelled")
|
||||
|
||||
return {
|
||||
"totalTasks": len(tasks),
|
||||
"pending": pending,
|
||||
"pickedUp": picked_up,
|
||||
"delivering": delivering,
|
||||
"completed": completed,
|
||||
"cancelled": cancelled
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_product_statistics(
|
||||
db: Session,
|
||||
region_id: str = None,
|
||||
start_date: str = None,
|
||||
end_date: str = None,
|
||||
limit: int = 10
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""获取商品销售排行"""
|
||||
query = db.query(
|
||||
BizOrderItem.product_id,
|
||||
func.sum(BizOrderItem.quantity).label("total_quantity"),
|
||||
func.sum(BizOrderItem.amount).label("total_amount")
|
||||
).join(
|
||||
BizOrder,
|
||||
and_(
|
||||
BizOrder.id == BizOrderItem.order_id,
|
||||
BizOrder.is_del == 0,
|
||||
BizOrder.status.in_(["completed", "delivering", "pending_confirm"])
|
||||
)
|
||||
).filter(
|
||||
BizOrderItem.is_del == 0
|
||||
)
|
||||
|
||||
if region_id:
|
||||
query = query.filter(BizOrder.region_id == region_id)
|
||||
|
||||
if start_date:
|
||||
try:
|
||||
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
|
||||
query = query.filter(BizOrder.create_at >= start_dt)
|
||||
except:
|
||||
pass
|
||||
|
||||
if end_date:
|
||||
try:
|
||||
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
|
||||
end_dt = end_dt.replace(hour=23, minute=59, second=59)
|
||||
query = query.filter(BizOrder.create_at <= end_dt)
|
||||
except:
|
||||
pass
|
||||
|
||||
results = query.group_by(BizOrderItem.product_id).order_by(
|
||||
desc("total_amount")
|
||||
).limit(limit).all()
|
||||
|
||||
product_stats = []
|
||||
for r in results:
|
||||
product = db.query(BasProduct).filter(
|
||||
and_(BasProduct.id == r.product_id, BasProduct.is_del == 0)
|
||||
).first()
|
||||
|
||||
product_stats.append({
|
||||
"productId": r.product_id,
|
||||
"productName": product.name if product else None,
|
||||
"totalQuantity": float(r.total_quantity or 0),
|
||||
"totalAmount": float(r.total_amount or 0)
|
||||
})
|
||||
|
||||
return product_stats
|
||||
|
||||
@staticmethod
|
||||
def get_customer_statistics(
|
||||
db: Session,
|
||||
region_id: str = None,
|
||||
start_date: str = None,
|
||||
end_date: str = None,
|
||||
limit: int = 10
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""获取客户订单排行"""
|
||||
query = db.query(
|
||||
BizOrder.client_id,
|
||||
func.count(BizOrder.id).label("order_count"),
|
||||
func.sum(BizOrder.total_amount).label("total_amount")
|
||||
).filter(
|
||||
and_(
|
||||
BizOrder.is_del == 0,
|
||||
BizOrder.status.in_(["completed", "delivering", "pending_confirm", "pending_payment"])
|
||||
)
|
||||
)
|
||||
|
||||
if region_id:
|
||||
query = query.filter(BizOrder.region_id == region_id)
|
||||
|
||||
if start_date:
|
||||
try:
|
||||
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
|
||||
query = query.filter(BizOrder.create_at >= start_dt)
|
||||
except:
|
||||
pass
|
||||
|
||||
if end_date:
|
||||
try:
|
||||
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
|
||||
end_dt = end_dt.replace(hour=23, minute=59, second=59)
|
||||
query = query.filter(BizOrder.create_at <= end_dt)
|
||||
except:
|
||||
pass
|
||||
|
||||
results = query.group_by(BizOrder.client_id).order_by(
|
||||
desc("total_amount")
|
||||
).limit(limit).all()
|
||||
|
||||
customer_stats = []
|
||||
for r in results:
|
||||
customer = db.query(SysUser).filter(
|
||||
and_(SysUser.id == r.client_id, SysUser.is_del == 0)
|
||||
).first()
|
||||
|
||||
customer_stats.append({
|
||||
"customerId": r.client_id,
|
||||
"customerName": customer.real_name if customer else None,
|
||||
"customerPhone": customer.phone if customer else None,
|
||||
"orderCount": r.order_count,
|
||||
"totalAmount": float(r.total_amount or 0)
|
||||
})
|
||||
|
||||
return customer_stats
|
||||
|
||||
@staticmethod
|
||||
def _dashboard_order_region_clause(region_id: str = None):
|
||||
return [BizOrder.region_id == region_id] if region_id else []
|
||||
|
||||
@staticmethod
|
||||
def get_dashboard_summary(
|
||||
db: Session,
|
||||
region_id: str = None,
|
||||
include_inactive_regions: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""获取仪表盘汇总数据(运营工作台;含待办摘要、最新订单、预警数等,字段驼峰)"""
|
||||
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
tomorrow_start = today + timedelta(days=1)
|
||||
month_start = today.replace(day=1)
|
||||
yesterday_start = today - timedelta(days=1)
|
||||
region_clause = StatisticsCRUD._dashboard_order_region_clause(region_id)
|
||||
region_active_clause = [] if include_inactive_regions else [SysRegion.is_active == True]
|
||||
|
||||
order_query = (
|
||||
db.query(BizOrder)
|
||||
.join(SysRegion, BizOrder.region_id == SysRegion.id)
|
||||
.filter(
|
||||
and_(
|
||||
BizOrder.is_del == 0,
|
||||
BizOrder.create_at >= month_start,
|
||||
SysRegion.is_del == 0,
|
||||
*region_active_clause,
|
||||
*region_clause,
|
||||
)
|
||||
)
|
||||
)
|
||||
month_orders = order_query.all()
|
||||
today_orders = [
|
||||
o for o in month_orders if o.create_at and o.create_at >= today
|
||||
]
|
||||
prev_day_orders = [
|
||||
o
|
||||
for o in month_orders
|
||||
if o.create_at and yesterday_start <= o.create_at < today
|
||||
]
|
||||
|
||||
pending_delivery_q = (
|
||||
db.query(BizOrder)
|
||||
.join(SysRegion, BizOrder.region_id == SysRegion.id)
|
||||
.filter(
|
||||
and_(
|
||||
BizOrder.is_del == 0,
|
||||
BizOrder.delivery_status == "pending_dispatch",
|
||||
SysRegion.is_del == 0,
|
||||
*region_active_clause,
|
||||
*region_clause,
|
||||
)
|
||||
)
|
||||
)
|
||||
pending_delivery_count = pending_delivery_q.count()
|
||||
|
||||
def _delta_hint(cur_n: int, prev_n: int, unit: str) -> str:
|
||||
d = cur_n - prev_n
|
||||
if d > 0:
|
||||
return f"较昨日 +{d}{unit}"
|
||||
if d < 0:
|
||||
return f"较昨日 {d}{unit}"
|
||||
return "与昨日持平"
|
||||
|
||||
def _amount_delta_hint(cur_amt: float, prev_amt: float) -> str:
|
||||
d = round(cur_amt - prev_amt, 2)
|
||||
if d > 0.0001:
|
||||
return f"较昨日 +¥{d:,.2f}"
|
||||
if d < -0.0001:
|
||||
return f"较昨日 ¥{d:,.2f}"
|
||||
return "与昨日持平"
|
||||
|
||||
today_order_count = len(today_orders)
|
||||
today_order_amount = round(sum(float(o.total_amount or 0) for o in today_orders), 2)
|
||||
prev_count = len(prev_day_orders)
|
||||
prev_amount = round(sum(float(o.total_amount or 0) for o in prev_day_orders), 2)
|
||||
|
||||
# 未解决的配送时效预警(可按区域通过任务表关联)
|
||||
warn_q = (
|
||||
db.query(BizDeliveryWarning)
|
||||
.join(
|
||||
BizDeliveryTask,
|
||||
and_(
|
||||
BizDeliveryTask.id == BizDeliveryWarning.task_id,
|
||||
BizDeliveryTask.is_del == 0,
|
||||
),
|
||||
)
|
||||
.join(SysRegion, BizDeliveryTask.region_id == SysRegion.id)
|
||||
.filter(
|
||||
and_(
|
||||
BizDeliveryWarning.is_del == 0,
|
||||
BizDeliveryWarning.is_resolved == 0,
|
||||
SysRegion.is_del == 0,
|
||||
*region_active_clause,
|
||||
)
|
||||
)
|
||||
)
|
||||
if region_id:
|
||||
warn_q = warn_q.filter(BizDeliveryTask.region_id == region_id)
|
||||
delivery_warning_count = warn_q.count()
|
||||
|
||||
# 今日新提交报价:状态为已提交,且提交时间(无则用创建时间)落在当日 0 点~次日 0 点
|
||||
quot_submitted_at = func.coalesce(BizQuotation.submit_time, BizQuotation.create_at)
|
||||
sub_quot_q = (
|
||||
db.query(BizQuotation)
|
||||
.join(SysRegion, BizQuotation.region_id == SysRegion.id)
|
||||
.filter(
|
||||
and_(
|
||||
BizQuotation.is_del == 0,
|
||||
BizQuotation.status == "submitted",
|
||||
quot_submitted_at >= today,
|
||||
quot_submitted_at < tomorrow_start,
|
||||
SysRegion.is_del == 0,
|
||||
*region_active_clause,
|
||||
)
|
||||
)
|
||||
)
|
||||
if region_id:
|
||||
sub_quot_q = sub_quot_q.filter(BizQuotation.region_id == region_id)
|
||||
pending_quotation_count = sub_quot_q.count()
|
||||
|
||||
sub_quot_preview = (
|
||||
db.query(BizQuotation, BizInquiry)
|
||||
.join(BizInquiry, BizInquiry.id == BizQuotation.inquiry_id)
|
||||
.join(SysRegion, BizQuotation.region_id == SysRegion.id)
|
||||
.filter(
|
||||
BizQuotation.is_del == 0,
|
||||
BizQuotation.status == "submitted",
|
||||
quot_submitted_at >= today,
|
||||
quot_submitted_at < tomorrow_start,
|
||||
BizInquiry.is_del == 0,
|
||||
SysRegion.is_del == 0,
|
||||
*region_active_clause,
|
||||
*([BizQuotation.region_id == region_id] if region_id else []),
|
||||
)
|
||||
.order_by(desc(BizQuotation.submit_time), desc(BizQuotation.create_at))
|
||||
.limit(2)
|
||||
.all()
|
||||
)
|
||||
quotation_lines = []
|
||||
for q, inq in sub_quot_preview:
|
||||
title = (inq.title or "询价单")[:48]
|
||||
quotation_lines.append(title)
|
||||
|
||||
pd_preview = (
|
||||
db.query(BizOrder)
|
||||
.join(SysRegion, BizOrder.region_id == SysRegion.id)
|
||||
.filter(
|
||||
and_(
|
||||
BizOrder.is_del == 0,
|
||||
BizOrder.delivery_status == "pending_dispatch",
|
||||
SysRegion.is_del == 0,
|
||||
*region_active_clause,
|
||||
*region_clause,
|
||||
)
|
||||
)
|
||||
.order_by(desc(BizOrder.create_at))
|
||||
.limit(2)
|
||||
.all()
|
||||
)
|
||||
delivery_lines = [o.order_no for o in pd_preview]
|
||||
|
||||
cancel_preview = (
|
||||
db.query(BizOrder)
|
||||
.join(SysRegion, BizOrder.region_id == SysRegion.id)
|
||||
.filter(
|
||||
and_(
|
||||
BizOrder.is_del == 0,
|
||||
BizOrder.cancel_status == "pending_review",
|
||||
SysRegion.is_del == 0,
|
||||
*region_active_clause,
|
||||
*region_clause,
|
||||
)
|
||||
)
|
||||
.order_by(desc(BizOrder.update_at))
|
||||
.limit(2)
|
||||
.all()
|
||||
)
|
||||
cancel_client_ids = list({o.client_id for o in cancel_preview if o.client_id})
|
||||
cancel_clients = {}
|
||||
if cancel_client_ids:
|
||||
for u in (
|
||||
db.query(SysUser)
|
||||
.filter(and_(SysUser.is_del == 0, SysUser.id.in_(cancel_client_ids)))
|
||||
.all()
|
||||
):
|
||||
cancel_clients[u.id] = u.real_name or u.phone or u.id
|
||||
cancel_lines = []
|
||||
for o in cancel_preview:
|
||||
nm = cancel_clients.get(o.client_id, "")
|
||||
cancel_lines.append(f"{o.order_no}" + (f" · {nm[:16]}" if nm else ""))
|
||||
|
||||
settle_preview = (
|
||||
db.query(BizOrder)
|
||||
.join(SysRegion, BizOrder.region_id == SysRegion.id)
|
||||
.filter(
|
||||
and_(
|
||||
BizOrder.is_del == 0,
|
||||
BizOrder.client_settlement_status == "pending",
|
||||
BizOrder.pay_status == "paid",
|
||||
BizOrder.order_status.in_(["pending_confirm", "completed"]),
|
||||
SysRegion.is_del == 0,
|
||||
*region_active_clause,
|
||||
*region_clause,
|
||||
)
|
||||
)
|
||||
.order_by(desc(BizOrder.update_at))
|
||||
.limit(2)
|
||||
.all()
|
||||
)
|
||||
settle_client_ids = list({o.client_id for o in settle_preview if o.client_id})
|
||||
settle_clients = {}
|
||||
if settle_client_ids:
|
||||
for u in (
|
||||
db.query(SysUser)
|
||||
.filter(and_(SysUser.is_del == 0, SysUser.id.in_(settle_client_ids)))
|
||||
.all()
|
||||
):
|
||||
settle_clients[u.id] = u.real_name or u.phone or u.id
|
||||
settle_lines = []
|
||||
for o in settle_preview:
|
||||
nm = settle_clients.get(o.client_id, "")
|
||||
settle_lines.append(f"{o.order_no}" + (f" · {nm[:16]}" if nm else ""))
|
||||
|
||||
pending_settlement_count = (
|
||||
db.query(BizOrder)
|
||||
.join(SysRegion, BizOrder.region_id == SysRegion.id)
|
||||
.filter(
|
||||
and_(
|
||||
BizOrder.is_del == 0,
|
||||
BizOrder.client_settlement_status == "pending",
|
||||
BizOrder.pay_status == "paid",
|
||||
BizOrder.order_status.in_(["pending_confirm", "completed"]),
|
||||
SysRegion.is_del == 0,
|
||||
*region_active_clause,
|
||||
*region_clause,
|
||||
)
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
pending_cancel_count = (
|
||||
db.query(BizOrder)
|
||||
.join(SysRegion, BizOrder.region_id == SysRegion.id)
|
||||
.filter(
|
||||
and_(
|
||||
BizOrder.is_del == 0,
|
||||
BizOrder.cancel_status == "pending_review",
|
||||
SysRegion.is_del == 0,
|
||||
*region_active_clause,
|
||||
*region_clause,
|
||||
)
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
# 最新订单(工作台列表)
|
||||
recent_rows = (
|
||||
db.query(BizOrder)
|
||||
.join(SysRegion, BizOrder.region_id == SysRegion.id)
|
||||
.filter(and_(BizOrder.is_del == 0, SysRegion.is_del == 0, *region_active_clause, *region_clause))
|
||||
.order_by(desc(BizOrder.create_at))
|
||||
.limit(8)
|
||||
.all()
|
||||
)
|
||||
rid_set = list({o.region_id for o in recent_rows if o.region_id})
|
||||
cid_set = list({o.client_id for o in recent_rows if o.client_id})
|
||||
region_name_map = {}
|
||||
if rid_set:
|
||||
for r in db.query(SysRegion).filter(
|
||||
and_(SysRegion.is_del == 0, *region_active_clause, SysRegion.id.in_(rid_set))
|
||||
).all():
|
||||
region_name_map[r.id] = r.name
|
||||
client_name_map = {}
|
||||
if cid_set:
|
||||
for u in db.query(SysUser).filter(and_(SysUser.is_del == 0, SysUser.id.in_(cid_set))).all():
|
||||
client_name_map[u.id] = u.real_name or u.phone or "-"
|
||||
|
||||
recent_orders = []
|
||||
for o in recent_rows:
|
||||
order_status = getattr(o, "order_status", None) or "pending_payment"
|
||||
recent_orders.append(
|
||||
{
|
||||
"id": o.id,
|
||||
"orderNo": o.order_no,
|
||||
"clientName": client_name_map.get(o.client_id, "-"),
|
||||
"regionName": region_name_map.get(o.region_id, "-"),
|
||||
"totalAmount": float(o.total_amount) if o.total_amount else 0,
|
||||
"orderStatus": order_status,
|
||||
"cancelStatus": getattr(o, "cancel_status", None) or "none",
|
||||
"deliveryStatus": getattr(o, "delivery_status", None) or "pending_dispatch",
|
||||
"createAt": str(o.create_at) if o.create_at else None,
|
||||
}
|
||||
)
|
||||
|
||||
todo_snapshots = [
|
||||
{
|
||||
"type": "quotation",
|
||||
"title": "今日新提交报价",
|
||||
"count": pending_quotation_count,
|
||||
"items": quotation_lines,
|
||||
},
|
||||
{
|
||||
"type": "delivery",
|
||||
"title": "待分配配送",
|
||||
"count": pending_delivery_count,
|
||||
"items": delivery_lines,
|
||||
},
|
||||
{
|
||||
"type": "cancel",
|
||||
"title": "取消待审核",
|
||||
"count": pending_cancel_count,
|
||||
"items": cancel_lines,
|
||||
},
|
||||
{
|
||||
"type": "settlement",
|
||||
"title": "待客户结算",
|
||||
"count": pending_settlement_count,
|
||||
"items": settle_lines,
|
||||
},
|
||||
]
|
||||
|
||||
# “客户总数/供应商总数”口径:统计用户表(sys_user)并按 region_id 区分
|
||||
# - region_id 为空:不过滤全局
|
||||
# - region_id 传入:按 SysUser.region_id 过滤
|
||||
customer_count = (
|
||||
db.query(SysUser)
|
||||
.join(SysRegion, SysUser.region_id == SysRegion.id)
|
||||
.filter(
|
||||
and_(
|
||||
SysUser.is_del == 0,
|
||||
SysUser.role == "client",
|
||||
SysRegion.is_del == 0,
|
||||
*region_active_clause,
|
||||
*([SysUser.region_id == region_id] if region_id else []),
|
||||
)
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
supplier_count = (
|
||||
db.query(SysUser)
|
||||
.join(SysRegion, SysUser.region_id == SysRegion.id)
|
||||
.filter(
|
||||
and_(
|
||||
SysUser.is_del == 0,
|
||||
SysUser.role == "supplier",
|
||||
SysRegion.is_del == 0,
|
||||
*region_active_clause,
|
||||
*([SysUser.region_id == region_id] if region_id else []),
|
||||
)
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
return {
|
||||
"todayOrderCount": today_order_count,
|
||||
"todayOrderAmount": today_order_amount,
|
||||
"monthOrderCount": len(month_orders),
|
||||
"monthOrderAmount": round(sum(float(o.total_amount or 0) for o in month_orders), 2),
|
||||
"pendingDeliveryCount": pending_delivery_count,
|
||||
"completedOrderCount": sum(
|
||||
1 for o in month_orders if (getattr(o, "order_status", None) or o.status) == "completed"
|
||||
),
|
||||
"activePriceListCount": (
|
||||
db.query(BizPriceList)
|
||||
.join(SysRegion, BizPriceList.region_id == SysRegion.id)
|
||||
.filter(
|
||||
and_(
|
||||
BizPriceList.is_del == 0,
|
||||
BizPriceList.status == "active",
|
||||
SysRegion.is_del == 0,
|
||||
*region_active_clause,
|
||||
*([BizPriceList.region_id == region_id] if region_id else []),
|
||||
)
|
||||
)
|
||||
.count()
|
||||
),
|
||||
"customerCount": customer_count,
|
||||
"supplierCount": supplier_count,
|
||||
"todayOrderCountHint": _delta_hint(today_order_count, prev_count, " 单"),
|
||||
"todayOrderAmountHint": _amount_delta_hint(today_order_amount, prev_amount),
|
||||
"deliveryWarningCount": delivery_warning_count,
|
||||
"recentOrders": recent_orders,
|
||||
"todoSnapshots": todo_snapshots,
|
||||
}
|
||||
|
||||
|
||||
statistics_crud = StatisticsCRUD()
|
||||
@@ -0,0 +1,167 @@
|
||||
"""租户 CRUD(sys_tenant)"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.models import SysTenant
|
||||
|
||||
|
||||
def _row_to_dict(t: SysTenant) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": t.id,
|
||||
"tenantNo": t.tenant_no,
|
||||
"tenantName": t.tenant_name,
|
||||
"contactName": t.contact_name or "",
|
||||
"contactPhone": t.contact_phone or "",
|
||||
"orderNum": t.order_num if t.order_num is not None else 0,
|
||||
"remark": t.remark or "",
|
||||
"createTime": t.create_time.strftime("%Y-%m-%d %H:%M:%S") if t.create_time else "",
|
||||
"updateTime": t.update_time.strftime("%Y-%m-%d %H:%M:%S") if t.update_time else "",
|
||||
}
|
||||
|
||||
|
||||
def list_tenants(
|
||||
db: Session,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
keyword: Optional[str] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
def _filtered():
|
||||
q0 = db.query(SysTenant).filter(SysTenant.del_flag == 0)
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q0 = q0.filter(or_(SysTenant.tenant_no.like(like), SysTenant.tenant_name.like(like)))
|
||||
return q0
|
||||
|
||||
total = _filtered().order_by(None).with_entities(func.count(SysTenant.id)).scalar() or 0
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
_filtered()
|
||||
.order_by(SysTenant.order_num.asc(), SysTenant.create_time.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
return [_row_to_dict(x) for x in rows], int(total)
|
||||
|
||||
|
||||
def get_tenant(db: Session, tenant_id: str) -> Optional[SysTenant]:
|
||||
return (
|
||||
db.query(SysTenant)
|
||||
.filter(and_(SysTenant.id == tenant_id, SysTenant.del_flag == 0))
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def tenant_no_exists(db: Session, tenant_no: str, exclude_id: Optional[str] = None) -> bool:
|
||||
q = db.query(SysTenant).filter(and_(SysTenant.tenant_no == tenant_no.strip(), SysTenant.del_flag == 0))
|
||||
if exclude_id:
|
||||
q = q.filter(SysTenant.id != exclude_id)
|
||||
return q.first() is not None
|
||||
|
||||
|
||||
def create_tenant(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
tenant_name: str,
|
||||
contact_name: Optional[str] = None,
|
||||
contact_phone: Optional[str] = None,
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> SysTenant:
|
||||
tn = (tenant_no or "").strip()
|
||||
if not tn:
|
||||
raise ValueError("租户编号不能为空")
|
||||
if tenant_no_exists(db, tn):
|
||||
raise ValueError("租户编号已存在")
|
||||
obj = SysTenant(
|
||||
id=generate_uuid(),
|
||||
tenant_no=tn,
|
||||
tenant_name=(tenant_name or "").strip() or tn,
|
||||
contact_name=(contact_name or "").strip() or None,
|
||||
contact_phone=(contact_phone or "").strip() or None,
|
||||
order_num=order_num if order_num is not None else 0,
|
||||
remark=(remark or "").strip() or None,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=datetime.now(),
|
||||
update_by=user_id,
|
||||
update_time=datetime.now(),
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def update_tenant(
|
||||
db: Session,
|
||||
tenant_id: str,
|
||||
*,
|
||||
tenant_no: Optional[str] = None,
|
||||
tenant_name: Optional[str] = None,
|
||||
contact_name: Optional[str] = None,
|
||||
contact_phone: Optional[str] = None,
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> SysTenant:
|
||||
obj = get_tenant(db, tenant_id)
|
||||
if not obj:
|
||||
raise ValueError("租户不存在")
|
||||
if tenant_no is not None:
|
||||
new_no = tenant_no.strip()
|
||||
if not new_no:
|
||||
raise ValueError("租户编号不能为空")
|
||||
if obj.tenant_no == settings.DEFAULT_TENANT_NO and new_no != settings.DEFAULT_TENANT_NO:
|
||||
raise ValueError("默认租户编号不可修改")
|
||||
if new_no != obj.tenant_no and tenant_no_exists(db, new_no, exclude_id=tenant_id):
|
||||
raise ValueError("租户编号已存在")
|
||||
obj.tenant_no = new_no
|
||||
if tenant_name is not None:
|
||||
obj.tenant_name = (tenant_name or "").strip() or obj.tenant_name
|
||||
if contact_name is not None:
|
||||
obj.contact_name = (contact_name or "").strip() or None
|
||||
if contact_phone is not None:
|
||||
obj.contact_phone = (contact_phone or "").strip() or None
|
||||
if order_num is not None:
|
||||
obj.order_num = order_num
|
||||
if remark is not None:
|
||||
obj.remark = (remark or "").strip() or None
|
||||
obj.update_by = user_id
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def soft_delete_tenant(db: Session, tenant_id: str) -> None:
|
||||
obj = get_tenant(db, tenant_id)
|
||||
if not obj:
|
||||
raise ValueError("租户不存在")
|
||||
if obj.tenant_no == settings.DEFAULT_TENANT_NO:
|
||||
raise ValueError("默认租户不可删除")
|
||||
obj.del_flag = 1
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
|
||||
|
||||
def total_pages(total: int, page_size: int) -> int:
|
||||
if total <= 0:
|
||||
return 0
|
||||
return int(math.ceil(total / float(page_size)))
|
||||
@@ -1,72 +0,0 @@
|
||||
"""商品单位CRUD"""
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_
|
||||
|
||||
from app.models.models import BasProductUnit, BasProductUnitHis
|
||||
from app.crud.base import CRUDBase
|
||||
|
||||
|
||||
class UnitCRUD(CRUDBase[BasProductUnit]):
|
||||
"""商品单位CRUD操作"""
|
||||
|
||||
def get_by_code(self, db: Session, code: str) -> Optional[BasProductUnit]:
|
||||
"""根据编码获取单位"""
|
||||
return db.query(BasProductUnit).filter(
|
||||
and_(BasProductUnit.code == code, BasProductUnit.is_del == 0)
|
||||
).first()
|
||||
|
||||
def get_active_all(self, db: Session) -> List[BasProductUnit]:
|
||||
"""获取所有启用的单位"""
|
||||
return db.query(BasProductUnit).filter(
|
||||
and_(BasProductUnit.is_del == 0, BasProductUnit.is_active == True)
|
||||
).order_by(BasProductUnit.sort_order.asc(), BasProductUnit.id.asc()).all()
|
||||
|
||||
def update_with_history(self, db: Session, id: str, obj_in: dict, updater: dict = None) -> Optional[BasProductUnit]:
|
||||
"""更新单位并记录历史"""
|
||||
original = self.get(db, id)
|
||||
if not original:
|
||||
return None
|
||||
|
||||
his_data = {
|
||||
"id": original.id,
|
||||
"name": original.name,
|
||||
"code": original.code,
|
||||
"is_active": original.is_active,
|
||||
"sort_order": original.sort_order,
|
||||
"create_at": original.create_at,
|
||||
"create_by": original.create_by,
|
||||
"create_name": original.create_name,
|
||||
"update_at": original.update_at,
|
||||
"update_by": original.update_by,
|
||||
}
|
||||
his = BasProductUnitHis(**his_data)
|
||||
db.add(his)
|
||||
|
||||
return self.update(db, id, obj_in, updater)
|
||||
|
||||
def soft_delete_with_history(self, db: Session, id: str) -> bool:
|
||||
"""删除单位并记录历史"""
|
||||
original = self.get(db, id)
|
||||
if not original:
|
||||
return False
|
||||
|
||||
his_data = {
|
||||
"id": original.id,
|
||||
"name": original.name,
|
||||
"code": original.code,
|
||||
"is_active": original.is_active,
|
||||
"sort_order": original.sort_order,
|
||||
"create_at": original.create_at,
|
||||
"create_by": original.create_by,
|
||||
"create_name": original.create_name,
|
||||
"update_at": original.update_at,
|
||||
"update_by": original.update_by,
|
||||
}
|
||||
his = BasProductUnitHis(**his_data)
|
||||
db.add(his)
|
||||
|
||||
return self.delete(db, id)
|
||||
|
||||
|
||||
unit_crud = UnitCRUD(BasProductUnit)
|
||||
+21
-165
@@ -1,177 +1,33 @@
|
||||
"""用户CRUD"""
|
||||
from typing import Optional, List, Tuple
|
||||
from sqlalchemy.orm import Session
|
||||
"""用户 CRUD(V0.3 sys_user)"""
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import and_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.models import SysUser, SysUserHis
|
||||
from app.core.config import settings
|
||||
from app.models.models import SysUser
|
||||
from app.crud.base import CRUDBase
|
||||
from app.core.security import get_password_hash
|
||||
from app.core.utils import generate_random_password
|
||||
|
||||
# 运营端「重置密码」专用明文:仅服务端使用,禁止由 HTTP 请求传入
|
||||
DEFAULT_ADMIN_RESET_PLAIN_PASSWORD = "111111"
|
||||
|
||||
|
||||
class UserCRUD(CRUDBase[SysUser]):
|
||||
"""用户CRUD操作"""
|
||||
"""用户 CRUD:按租户 + 用户账号查询"""
|
||||
|
||||
def get_by_username(self, db: Session, username: str) -> Optional[SysUser]:
|
||||
"""根据用户名获取用户"""
|
||||
return db.query(SysUser).filter(
|
||||
and_(SysUser.username == username, SysUser.is_del == 0)
|
||||
).first()
|
||||
|
||||
def get_by_phone(self, db: Session, phone: str) -> Optional[SysUser]:
|
||||
"""根据手机号获取用户"""
|
||||
return db.query(SysUser).filter(
|
||||
and_(SysUser.phone == phone, SysUser.is_del == 0)
|
||||
).first()
|
||||
|
||||
def get_by_wx_mini_openid(self, db: Session, openid: str) -> Optional[SysUser]:
|
||||
"""根据微信小程序 openid 获取用户"""
|
||||
if not openid:
|
||||
def get_by_user_no(self, db: Session, user_no: str, tenant_no: Optional[str] = None) -> Optional[SysUser]:
|
||||
tenant = tenant_no or settings.DEFAULT_TENANT_NO
|
||||
un = (user_no or "").strip()
|
||||
if not un:
|
||||
return None
|
||||
return db.query(SysUser).filter(
|
||||
and_(SysUser.wx_mini_openid == openid, SysUser.is_del == 0)
|
||||
).first()
|
||||
|
||||
def get_multi_by_role(self, db: Session, role: str, skip: int = 0, limit: int = 100) -> List[SysUser]:
|
||||
"""根据角色获取用户列表"""
|
||||
return db.query(SysUser).filter(
|
||||
and_(SysUser.role == role, SysUser.is_del == 0)
|
||||
).offset(skip).limit(limit).all()
|
||||
|
||||
def get_multi_by_region(self, db: Session, region_id: str, skip: int = 0, limit: int = 100) -> List[SysUser]:
|
||||
"""根据区域获取用户列表"""
|
||||
return db.query(SysUser).filter(
|
||||
and_(SysUser.region_id == region_id, SysUser.is_del == 0)
|
||||
).offset(skip).limit(limit).all()
|
||||
|
||||
def get_multi_with_filters(
|
||||
self,
|
||||
db: Session,
|
||||
role: str = None,
|
||||
roles: Optional[List[str]] = None,
|
||||
region_id: str = None,
|
||||
keyword: str = None,
|
||||
is_active: bool = None,
|
||||
skip: int = 0,
|
||||
limit: int = 10
|
||||
) -> tuple[List[SysUser], int]:
|
||||
"""多条件查询用户"""
|
||||
query = db.query(SysUser).filter(SysUser.is_del == 0)
|
||||
|
||||
if role:
|
||||
query = query.filter(SysUser.role == role)
|
||||
elif roles:
|
||||
role_values = [str(x).strip() for x in roles if str(x).strip()]
|
||||
if role_values:
|
||||
query = query.filter(SysUser.role.in_(role_values))
|
||||
if region_id:
|
||||
query = query.filter(SysUser.region_id == region_id)
|
||||
if is_active is not None:
|
||||
query = query.filter(SysUser.is_active == is_active)
|
||||
if keyword:
|
||||
query = query.filter(
|
||||
(SysUser.username.like(f"%{keyword}%")) |
|
||||
(SysUser.real_name.like(f"%{keyword}%")) |
|
||||
(SysUser.phone.like(f"%{keyword}%"))
|
||||
return (
|
||||
db.query(SysUser)
|
||||
.filter(
|
||||
and_(
|
||||
SysUser.user_no == un,
|
||||
SysUser.tenant_no == tenant,
|
||||
SysUser.del_flag == 0,
|
||||
)
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
users = query.order_by(SysUser.create_at.desc()).offset(skip).limit(limit).all()
|
||||
return users, total
|
||||
|
||||
def create_with_password(self, db: Session, obj_in: dict, creator: dict = None) -> Tuple[SysUser, Optional[str]]:
|
||||
"""创建用户。
|
||||
|
||||
若请求体包含非空 password(运营端表单),则使用该密码写入 password_hash;
|
||||
否则生成随机密码,并在返回值中给出明文 initialPassword(仅此种情况返回,供管理员告知用户)。
|
||||
"""
|
||||
obj_in = dict(obj_in)
|
||||
raw_pwd = obj_in.pop("password", None)
|
||||
plain: Optional[str] = None
|
||||
if isinstance(raw_pwd, str):
|
||||
plain = raw_pwd.strip()
|
||||
if plain:
|
||||
if len(plain) < 6:
|
||||
raise ValueError("密码至少6位")
|
||||
obj_in["password_hash"] = get_password_hash(plain)
|
||||
initial_reveal: Optional[str] = None
|
||||
else:
|
||||
initial_reveal = generate_random_password()
|
||||
obj_in["password_hash"] = get_password_hash(initial_reveal)
|
||||
obj_in["is_active"] = obj_in.get("is_active", True)
|
||||
user = self.create(db, obj_in, creator)
|
||||
return user, initial_reveal
|
||||
|
||||
def reset_password_to_admin_default(self, db: Session, id: str) -> bool:
|
||||
"""将密码重置为服务端固定默认(DEFAULT_ADMIN_RESET_PLAIN_PASSWORD),不由请求传入。"""
|
||||
user = self.get(db, id)
|
||||
if not user:
|
||||
return False
|
||||
plain = DEFAULT_ADMIN_RESET_PLAIN_PASSWORD
|
||||
if len(plain) < 6:
|
||||
raise ValueError("默认重置密码配置无效:至少6位")
|
||||
user.password_hash = get_password_hash(plain)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
def update_with_history(self, db: Session, id: str, obj_in: dict, updater: dict = None) -> Optional[SysUser]:
|
||||
"""更新用户并记录历史"""
|
||||
original = self.get(db, id)
|
||||
if not original:
|
||||
return None
|
||||
|
||||
his_data = {
|
||||
"id": original.id,
|
||||
"username": original.username,
|
||||
"password_hash": original.password_hash,
|
||||
"real_name": original.real_name,
|
||||
"phone": original.phone,
|
||||
"avatar_url": getattr(original, "avatar_url", None),
|
||||
"wx_mini_openid": getattr(original, "wx_mini_openid", None),
|
||||
"role": original.role,
|
||||
"region_id": original.region_id,
|
||||
"is_active": original.is_active,
|
||||
"create_at": original.create_at,
|
||||
"create_by": original.create_by,
|
||||
"create_name": original.create_name,
|
||||
"update_at": original.update_at,
|
||||
"update_by": original.update_by,
|
||||
}
|
||||
his = SysUserHis(**his_data)
|
||||
db.add(his)
|
||||
|
||||
return self.update(db, id, obj_in, updater)
|
||||
|
||||
def soft_delete_with_history(self, db: Session, id: str) -> bool:
|
||||
"""删除用户并记录历史"""
|
||||
original = self.get(db, id)
|
||||
if not original:
|
||||
return False
|
||||
|
||||
his_data = {
|
||||
"id": original.id,
|
||||
"username": original.username,
|
||||
"password_hash": original.password_hash,
|
||||
"real_name": original.real_name,
|
||||
"phone": original.phone,
|
||||
"avatar_url": getattr(original, "avatar_url", None),
|
||||
"wx_mini_openid": getattr(original, "wx_mini_openid", None),
|
||||
"role": original.role,
|
||||
"region_id": original.region_id,
|
||||
"is_active": original.is_active,
|
||||
"create_at": original.create_at,
|
||||
"create_by": original.create_by,
|
||||
"create_name": original.create_name,
|
||||
"update_at": original.update_at,
|
||||
"update_by": original.update_by,
|
||||
}
|
||||
his = SysUserHis(**his_data)
|
||||
db.add(his)
|
||||
|
||||
return self.delete(db, id)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
user_crud = UserCRUD(SysUser)
|
||||
+70
-9
@@ -1,28 +1,89 @@
|
||||
"""FastAPI 骨架入口:无业务路由、无数据库依赖,仅健康检查与文档。"""
|
||||
"""FastAPI 应用入口(骨架工程)"""
|
||||
from contextlib import asynccontextmanager
|
||||
import logging
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import SessionLocal
|
||||
from app.core.api_request_log_middleware import ApiRequestLogMiddleware
|
||||
from app.core.api_crypto_middleware import ApiCryptoMiddleware
|
||||
from app.models import models # noqa: F401
|
||||
from app.api import public_router, menu_router, admin_router
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""应用生命周期管理"""
|
||||
from app.core import login_crypto
|
||||
|
||||
login_crypto.ensure_initialized()
|
||||
|
||||
from app.tasks import init_scheduled_tasks
|
||||
|
||||
try:
|
||||
init_scheduled_tasks(SessionLocal)
|
||||
except Exception as e:
|
||||
logging.warning("定时任务调度器启动失败: %s", e)
|
||||
|
||||
yield
|
||||
|
||||
from app.tasks import shutdown_scheduler
|
||||
|
||||
try:
|
||||
shutdown_scheduler()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger = logging.getLogger("zkxw")
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.APP_NAME,
|
||||
description="中科兴皖一体化门户后端骨架(业务代码已移除,可按需从版本库恢复)",
|
||||
description="zkxw 管理端骨架后端(鉴权与菜单)",
|
||||
version="1.0.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.BACKEND_CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
_cors_kw: dict = {
|
||||
"allow_origins": settings.BACKEND_CORS_ORIGINS,
|
||||
"allow_credentials": True,
|
||||
"allow_methods": ["*"],
|
||||
"allow_headers": ["*"],
|
||||
}
|
||||
if settings.DEBUG:
|
||||
_cors_kw["allow_origin_regex"] = r"^https?://(localhost|127\.0\.0\.1)(:\d+)?$"
|
||||
app.add_middleware(CORSMiddleware, **_cors_kw)
|
||||
app.add_middleware(ApiRequestLogMiddleware)
|
||||
app.add_middleware(ApiCryptoMiddleware)
|
||||
|
||||
app.include_router(public_router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(menu_router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(admin_router, prefix=settings.API_V1_PREFIX)
|
||||
|
||||
logger = logging.getLogger("sqxp")
|
||||
|
||||
|
||||
def _mask_secret(value: str) -> str:
|
||||
if not value:
|
||||
return "(empty)"
|
||||
if len(value) <= 6:
|
||||
return "*" * len(value)
|
||||
return f"{value[:3]}***{value[-3:]}"
|
||||
|
||||
|
||||
_api_crypto_boot_msg = (
|
||||
"API_CRYPTO_BOOT "
|
||||
f"enabled={settings.API_ENCRYPT_ENABLED} "
|
||||
f"fail_open={settings.API_ENCRYPT_FAIL_OPEN} "
|
||||
f"fixed_secret={_mask_secret(settings.API_ENCRYPT_FIXED_SECRET)} "
|
||||
f"ts_skew_sec={settings.API_ENCRYPT_TS_SKEW_SEC} "
|
||||
f"nonce_ttl_sec={settings.API_ENCRYPT_NONCE_TTL_SEC} "
|
||||
f"exclude_count={len([x for x in (settings.API_ENCRYPT_EXCLUDE_PATHS or '').split(',') if x.strip()])}"
|
||||
)
|
||||
print(_api_crypto_boot_msg)
|
||||
logger.info(_api_crypto_boot_msg)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
@@ -1,95 +1,30 @@
|
||||
"""数据模型模块"""
|
||||
from app.models.base import BaseModel, SoftDeleteModel
|
||||
from app.models.models import (
|
||||
SysUser, SysUserHis,
|
||||
SysRegion, SysRegionHis,
|
||||
SysApiRequestLog, SysPasswordHistory,
|
||||
SysApiRequestLogHis, SysPasswordHistoryHis,
|
||||
BasProductCategory, BasProductCategoryHis,
|
||||
BasProductUnit, BasProductUnitHis,
|
||||
BasProduct, BasProductHis,
|
||||
BasChinaRegion,
|
||||
BasAddress, BasAddressHis,
|
||||
BizInquiry, BizInquiryItem,
|
||||
BizQuotation, BizQuotationItem,
|
||||
BizPriceList, BizPriceListItem,
|
||||
BizOrder, BizOrderItem, BizOrderStatusLog, BizOrderAdjust,
|
||||
BizDeliveryTask, BizDeliveryTaskItem,
|
||||
BizSettlement, BizRefund, BizWechatPay, BizAttachment,
|
||||
OrdClientSettlement, OrdClientSettlementHis, OrdClientSettlementItem, OrdClientSettlementItemHis,
|
||||
OrdSupplierSettlement, OrdSupplierSettlementHis, OrdSupplierSettlementItem, OrdSupplierSettlementItemHis,
|
||||
BizInvoice, BizSystemConfig, BizDelivererReview,
|
||||
BizDelivererReviewHis, BizDeliveryWarning, BizDeliveryWarningHis, BizOrderStatusLogHis,
|
||||
UserRole
|
||||
SysUser,
|
||||
SysRole,
|
||||
SysUserRole,
|
||||
SysRoleMenu,
|
||||
SysMenu,
|
||||
SysApiRequestLog,
|
||||
SysApiRequestLogHis,
|
||||
UserRole,
|
||||
)
|
||||
from app.models.product_banner import BasProductBanner, BasProductBannerHis
|
||||
from app.models.product_detail_media import BasProductDetailMedia, BasProductDetailMediaHis
|
||||
from app.models.product_parameter import BasProductParameter, BasProductParameterHis
|
||||
from app.models.category_parameter import BasCategoryParameter, BasCategoryParameterHis
|
||||
from app.models.product_parameter_value import BasProductParameterValue, BasProductParameterValueHis
|
||||
from app.models.product_review import BizProductReview, BizProductReviewHis
|
||||
from app.models.product_review_media import BizProductReviewMedia, BizProductReviewMediaHis
|
||||
|
||||
# 兼容旧的模型名称别名
|
||||
Supplier = BizDelivererReview
|
||||
Client = SysUser # 客户复用SysUser,通过role字段区分
|
||||
Deliver = SysUser # 配送员复用SysUser,通过role字段区分
|
||||
Inquiry = BizInquiry
|
||||
Quotation = BizQuotation
|
||||
QuotationItem = BizQuotationItem
|
||||
PriceList = BizPriceList
|
||||
PriceListItem = BizPriceListItem
|
||||
Order = BizOrder
|
||||
OrderItem = BizOrderItem
|
||||
OrderStatus = BizOrderStatusLog
|
||||
DeliveryTask = BizDeliveryTask
|
||||
DeliveryTaskItem = BizDeliveryTaskItem
|
||||
Product = BasProduct
|
||||
ProductUnit = BasProductUnit
|
||||
Region = SysRegion
|
||||
User = SysUser
|
||||
ApiRequestLog = SysApiRequestLog
|
||||
|
||||
__all__ = [
|
||||
"BaseModel",
|
||||
"SoftDeleteModel",
|
||||
# 系统模块
|
||||
"SysUser", "SysUserHis",
|
||||
"SysRegion", "SysRegionHis",
|
||||
"SysApiRequestLog", "SysPasswordHistory",
|
||||
"SysApiRequestLogHis", "SysPasswordHistoryHis",
|
||||
# 基础模块
|
||||
"BasProductCategory", "BasProductCategoryHis",
|
||||
"BasProductUnit", "BasProductUnitHis",
|
||||
"BasProduct", "BasProductHis",
|
||||
"BasChinaRegion",
|
||||
"BasAddress", "BasAddressHis",
|
||||
# 商品多媒体
|
||||
"BasProductBanner", "BasProductBannerHis",
|
||||
"BasProductDetailMedia", "BasProductDetailMediaHis",
|
||||
# 商品参数
|
||||
"BasProductParameter", "BasProductParameterHis",
|
||||
"BasCategoryParameter", "BasCategoryParameterHis",
|
||||
"BasProductParameterValue", "BasProductParameterValueHis",
|
||||
# 售后评价
|
||||
"BizProductReview", "BizProductReviewHis",
|
||||
"BizProductReviewMedia", "BizProductReviewMediaHis",
|
||||
# 业务模块
|
||||
"BizInquiry", "BizInquiryItem",
|
||||
"BizQuotation", "BizQuotationItem",
|
||||
"BizPriceList", "BizPriceListItem",
|
||||
"BizOrder", "BizOrderItem", "BizOrderStatusLog", "BizOrderAdjust",
|
||||
"BizDeliveryTask", "BizDeliveryTaskItem",
|
||||
"BizSettlement", "BizRefund", "BizWechatPay", "BizAttachment",
|
||||
"OrdClientSettlement", "OrdClientSettlementHis", "OrdClientSettlementItem", "OrdClientSettlementItemHis",
|
||||
"OrdSupplierSettlement", "OrdSupplierSettlementHis", "OrdSupplierSettlementItem", "OrdSupplierSettlementItemHis",
|
||||
"BizInvoice", "BizSystemConfig", "BizDelivererReview",
|
||||
"BizDelivererReviewHis", "BizDeliveryWarning", "BizDeliveryWarningHis", "BizOrderStatusLogHis",
|
||||
"SysUser",
|
||||
"SysRole",
|
||||
"SysUserRole",
|
||||
"SysRoleMenu",
|
||||
"SysMenu",
|
||||
"SysApiRequestLog",
|
||||
"SysApiRequestLogHis",
|
||||
"UserRole",
|
||||
# 兼容别名
|
||||
"Supplier", "Inquiry", "Quotation", "QuotationItem",
|
||||
"PriceList", "PriceListItem", "Order", "OrderItem",
|
||||
"OrderStatus", "DeliveryTask", "DeliveryTaskItem",
|
||||
"Product", "ProductUnit", "Region", "User",
|
||||
"User",
|
||||
"ApiRequestLog",
|
||||
]
|
||||
@@ -1,42 +0,0 @@
|
||||
"""分类参数关联模型"""
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Column, String, DateTime, Integer
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class BasCategoryParameter(Base):
|
||||
"""商品分类-参数关联表"""
|
||||
__tablename__ = "bas_category_parameter"
|
||||
|
||||
id = Column(String(32), primary_key=True, comment="主键ID")
|
||||
category_id = Column(String(32), nullable=False, comment="分类ID")
|
||||
parameter_id = Column(String(32), nullable=False, comment="参数ID")
|
||||
is_required = Column(Integer, default=0, comment="该分类下是否必填:0-否, 1-是")
|
||||
default_value = Column(String(500), nullable=True, comment="默认值")
|
||||
sort_order = Column(Integer, default=0, comment="排序")
|
||||
is_del = Column(Integer, default=0, comment="是否删除:0-否, 1-是")
|
||||
create_at = Column(DateTime, default=datetime.now, comment="创建时间")
|
||||
create_by = Column(String(32), nullable=True, comment="创建人ID")
|
||||
create_name = Column(String(100), nullable=True, comment="创建人姓名")
|
||||
update_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="修改时间")
|
||||
update_by = Column(String(32), nullable=True, comment="修改人ID")
|
||||
|
||||
|
||||
class BasCategoryParameterHis(Base):
|
||||
"""商品分类-参数关联表历史表"""
|
||||
__tablename__ = "bas_category_parameter_his"
|
||||
|
||||
id = Column(String(32), primary_key=True, nullable=False, comment="主键ID")
|
||||
category_id = Column(String(32), nullable=True, comment="分类ID")
|
||||
parameter_id = Column(String(32), nullable=True, comment="参数ID")
|
||||
is_required = Column(Integer, default=0, comment="是否必填")
|
||||
default_value = Column(String(500), nullable=True, comment="默认值")
|
||||
sort_order = Column(Integer, default=0, comment="排序")
|
||||
create_at = Column(DateTime, nullable=True, comment="创建时间")
|
||||
create_by = Column(String(32), nullable=True, comment="创建人ID")
|
||||
create_name = Column(String(100), nullable=True, comment="创建人姓名")
|
||||
update_at = Column(DateTime, nullable=True, comment="修改时间")
|
||||
update_by = Column(String(32), nullable=True, comment="修改人ID")
|
||||
his_create_at = Column(DateTime, default=datetime.now, comment="历史记录创建时间")
|
||||
+137
-1481
File diff suppressed because it is too large.
Load diff
@@ -1,166 +0,0 @@
|
||||
"""订单相关模型"""
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, Column, DateTime, Integer, String, Enum as SQLEnum, Numeric, Text, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
import enum
|
||||
|
||||
|
||||
class OrderStatus(str, enum.Enum):
|
||||
"""订单主状态"""
|
||||
PENDING_PAYMENT = "pending_payment" # 待支付
|
||||
PENDING_CONFIRM = "pending_confirm" # 待客户确认收货
|
||||
COMPLETED = "completed" # 已完结
|
||||
CANCELLED = "cancelled" # 已取消
|
||||
|
||||
|
||||
class PaymentMethod(str, enum.Enum):
|
||||
"""支付方式"""
|
||||
WECHAT = "wechat" # 微信支付
|
||||
OFFLINE = "offline" # 线下结算
|
||||
|
||||
|
||||
class Order(Base):
|
||||
"""订单主表"""
|
||||
__tablename__ = "orders"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
order_no = Column(String(50), unique=True, nullable=False, comment="订单编号")
|
||||
region_id = Column(Integer, nullable=False, comment="区域ID")
|
||||
client_id = Column(Integer, nullable=False, comment="客户ID")
|
||||
price_list_id = Column(Integer, nullable=False, comment="使用的价格清单ID")
|
||||
price_version = Column(Integer, nullable=False, comment="价格版本号")
|
||||
|
||||
status = Column(SQLEnum(OrderStatus), default=OrderStatus.PENDING_PAYMENT, comment="订单状态")
|
||||
payment_method = Column(SQLEnum(PaymentMethod), nullable=True, comment="支付方式")
|
||||
|
||||
total_amount = Column(Numeric(12, 2), default=0, comment="订单总金额")
|
||||
paid_amount = Column(Numeric(12, 2), default=0, comment="已付金额")
|
||||
receivable_amount = Column(Numeric(12, 2), default=0, comment="应收金额")
|
||||
refund_amount = Column(Numeric(12, 2), default=0, comment="退款金额")
|
||||
|
||||
contact_person = Column(String(100), nullable=True, comment="联系人")
|
||||
contact_phone = Column(String(20), nullable=True, comment="联系电话")
|
||||
delivery_address = Column(String(500), nullable=True, comment="配送地址")
|
||||
|
||||
cancel_reason = Column(String(500), nullable=True, comment="取消原因")
|
||||
cancel_review_time = Column(DateTime, nullable=True, comment="取消审核时间")
|
||||
cancel_reviewed_by = Column(Integer, nullable=True, comment="取消审核人ID")
|
||||
cancel_review_note = Column(String(500), nullable=True, comment="取消审核备注")
|
||||
|
||||
completed_time = Column(DateTime, nullable=True, comment="完结时间")
|
||||
completed_by = Column(Integer, nullable=True, comment="完结人ID")
|
||||
|
||||
remark = Column(Text, nullable=True, comment="订单备注")
|
||||
|
||||
payment_time = Column(DateTime, nullable=True, comment="支付时间")
|
||||
delivery_assign_time = Column(DateTime, nullable=True, comment="配送分配时间")
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow, comment="创建时间")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment="更新时间")
|
||||
|
||||
# 关联
|
||||
client = relationship("Client", back_populates="orders")
|
||||
items = relationship("OrderItem", back_populates="order")
|
||||
delivery_task = relationship("DeliveryTask", back_populates="order", uselist=False)
|
||||
status_logs = relationship("OrderStatusLog", back_populates="order")
|
||||
|
||||
|
||||
class OrderItem(Base):
|
||||
"""订单项表"""
|
||||
__tablename__ = "order_items"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
order_id = Column(Integer, nullable=False, comment="订单ID")
|
||||
product_id = Column(Integer, nullable=False, comment="商品ID")
|
||||
unit_id = Column(Integer, nullable=False, comment="单位ID")
|
||||
|
||||
quantity = Column(Numeric(12, 4), default=0, comment="数量")
|
||||
unit_price = Column(Numeric(10, 2), nullable=False, comment="单价")
|
||||
amount = Column(Numeric(12, 2), default=0, comment="金额")
|
||||
|
||||
delivered_quantity = Column(Numeric(12, 4), default=0, comment="已配送数量")
|
||||
|
||||
status = Column(String(20), default="normal", comment="项状态:normal-正常,reduced-已减量,deleted-已删除,substituted-已替代")
|
||||
note = Column(String(500), nullable=True, comment="备注(如缺货原因)")
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow, comment="创建时间")
|
||||
|
||||
order = relationship("Order", back_populates="items")
|
||||
product = relationship("Product", back_populates="order_items")
|
||||
|
||||
|
||||
class OrderStatusLog(Base):
|
||||
"""订单状态变更日志"""
|
||||
__tablename__ = "order_status_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
order_id = Column(Integer, nullable=False, comment="订单ID")
|
||||
from_status = Column(String(30), nullable=True, comment="变更前状态")
|
||||
to_status = Column(String(30), nullable=False, comment="变更后状态")
|
||||
operator_id = Column(Integer, nullable=False, comment="操作人ID")
|
||||
operator_role = Column(String(20), nullable=False, comment="操作人角色")
|
||||
reason = Column(String(500), nullable=True, comment="变更原因")
|
||||
note = Column(String(1000), nullable=True, comment="备注")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, comment="创建时间")
|
||||
|
||||
order = relationship("Order", back_populates="status_logs")
|
||||
|
||||
|
||||
class DeliveryTask(Base):
|
||||
"""配送任务表"""
|
||||
__tablename__ = "delivery_tasks"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
task_no = Column(String(50), unique=True, nullable=False, comment="配送单编号")
|
||||
order_id = Column(Integer, nullable=False, comment="订单ID")
|
||||
region_id = Column(Integer, nullable=False, comment="区域ID")
|
||||
supplier_id = Column(Integer, nullable=False, comment="供应商ID")
|
||||
deliver_id = Column(Integer, nullable=True, comment="配送员ID")
|
||||
|
||||
status = Column(String(20), default="pending", comment="状态:pending-待取货,picking-取货中,delivering-配送中,completed-已完成,cancelled-已取消")
|
||||
deliverer_status = Column(String(20), default="pending_pickup", comment="配送员状态: pending_pickup/delivering/delivered")
|
||||
supplier_prepare_status = Column(String(20), default="pending_prepare", comment="供应商备货状态: pending_prepare/prepared/picked_up")
|
||||
|
||||
pickup_time = Column(DateTime, nullable=True, comment="取货时间")
|
||||
pickup_remark = Column(String(500), nullable=True, comment="取货备注")
|
||||
pickup_image_url = Column(String(500), nullable=True, comment="取货凭证图片")
|
||||
|
||||
delivery_time = Column(DateTime, nullable=True, comment="送达时间")
|
||||
delivery_remark = Column(String(500), nullable=True, comment="送达备注")
|
||||
delivery_image_url = Column(String(500), nullable=True, comment="送达凭证图片")
|
||||
|
||||
expire_warning_sent = Column(Boolean, default=False, comment="是否已发送过期预警")
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow, comment="创建时间")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment="更新时间")
|
||||
|
||||
# 关联
|
||||
order = relationship("Order", back_populates="delivery_task")
|
||||
supplier = relationship("Supplier", back_populates="delivery_tasks")
|
||||
deliver = relationship("Deliver", back_populates="delivery_tasks")
|
||||
|
||||
|
||||
class Settlement(Base):
|
||||
"""线下结算记录表"""
|
||||
__tablename__ = "settlements"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
order_id = Column(Integer, nullable=False, comment="订单ID")
|
||||
client_id = Column(Integer, nullable=False, comment="客户ID")
|
||||
region_id = Column(Integer, nullable=False, comment="区域ID")
|
||||
|
||||
amount = Column(Numeric(12, 2), nullable=False, comment="结算金额")
|
||||
settlement_method = Column(String(20), nullable=False, comment="结算方式:transfer-转账,cash-现金,other-其他")
|
||||
settlement_time = Column(DateTime, nullable=True, comment="结算时间")
|
||||
settlement_file_url = Column(String(500), nullable=True, comment="结算凭证文件URL")
|
||||
settlement_note = Column(String(500), nullable=True, comment="结算备注")
|
||||
|
||||
status = Column(String(20), default="pending", comment="状态:pending-待确认,completed-已完成")
|
||||
reviewed_by = Column(Integer, nullable=True, comment="审核人ID")
|
||||
reviewed_time = Column(DateTime, nullable=True, comment="审核时间")
|
||||
|
||||
created_by = Column(Integer, nullable=False, comment="创建人ID")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, comment="创建时间")
|
||||
@@ -1,42 +0,0 @@
|
||||
"""商品轮播图模型"""
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Column, String, DateTime, Integer, Text
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class BasProductBanner(Base):
|
||||
"""商品轮播图表"""
|
||||
__tablename__ = "bas_product_banner"
|
||||
|
||||
id = Column(String(32), primary_key=True, comment="主键ID")
|
||||
product_id = Column(String(32), nullable=False, comment="商品ID")
|
||||
media_type = Column(String(20), nullable=False, default="image", comment="媒体类型:image-图片, video-视频")
|
||||
file_url = Column(String(500), nullable=False, comment="文件URL地址")
|
||||
thumbnail_url = Column(String(500), nullable=True, comment="缩略图URL")
|
||||
sort_order = Column(Integer, default=0, comment="排序")
|
||||
is_del = Column(Integer, default=0, comment="是否删除")
|
||||
create_at = Column(DateTime, default=datetime.now, comment="创建时间")
|
||||
create_by = Column(String(32), nullable=True, comment="创建人ID")
|
||||
create_name = Column(String(100), nullable=True, comment="创建人姓名")
|
||||
update_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="修改时间")
|
||||
update_by = Column(String(32), nullable=True, comment="修改人ID")
|
||||
|
||||
|
||||
class BasProductBannerHis(Base):
|
||||
"""商品轮播图历史表"""
|
||||
__tablename__ = "bas_product_banner_his"
|
||||
|
||||
id = Column(String(32), primary_key=True, nullable=False, comment="主键ID")
|
||||
product_id = Column(String(32), nullable=True, comment="商品ID")
|
||||
media_type = Column(String(20), default="image", comment="媒体类型")
|
||||
file_url = Column(String(500), nullable=True, comment="文件URL地址")
|
||||
thumbnail_url = Column(String(500), nullable=True, comment="缩略图URL")
|
||||
sort_order = Column(Integer, default=0, comment="排序")
|
||||
create_at = Column(DateTime, nullable=True, comment="创建时间")
|
||||
create_by = Column(String(32), nullable=True, comment="创建人ID")
|
||||
create_name = Column(String(100), nullable=True, comment="创建人姓名")
|
||||
update_at = Column(DateTime, nullable=True, comment="修改时间")
|
||||
update_by = Column(String(32), nullable=True, comment="修改人ID")
|
||||
his_create_at = Column(DateTime, default=datetime.now, comment="历史记录创建时间")
|
||||
@@ -1,46 +0,0 @@
|
||||
"""商品详情媒体模型"""
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Column, String, DateTime, Integer, Text
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class BasProductDetailMedia(Base):
|
||||
"""商品详情媒体表"""
|
||||
__tablename__ = "bas_product_detail_media"
|
||||
|
||||
id = Column(String(32), primary_key=True, comment="主键ID")
|
||||
product_id = Column(String(32), nullable=False, comment="商品ID")
|
||||
content_type = Column(String(20), nullable=False, default="image", comment="内容类型:image-图片, video-视频, text-文本")
|
||||
content_text = Column(Text, nullable=True, comment="文本内容")
|
||||
file_url = Column(String(500), nullable=True, comment="文件URL地址")
|
||||
thumbnail_url = Column(String(500), nullable=True, comment="缩略图URL")
|
||||
description = Column(String(500), nullable=True, comment="描述")
|
||||
sort_order = Column(Integer, default=0, comment="排序")
|
||||
is_del = Column(Integer, default=0, comment="是否删除")
|
||||
create_at = Column(DateTime, default=datetime.now, comment="创建时间")
|
||||
create_by = Column(String(32), nullable=True, comment="创建人ID")
|
||||
create_name = Column(String(100), nullable=True, comment="创建人姓名")
|
||||
update_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="修改时间")
|
||||
update_by = Column(String(32), nullable=True, comment="修改人ID")
|
||||
|
||||
|
||||
class BasProductDetailMediaHis(Base):
|
||||
"""商品详情媒体历史表"""
|
||||
__tablename__ = "bas_product_detail_media_his"
|
||||
|
||||
id = Column(String(32), primary_key=True, nullable=False, comment="主键ID")
|
||||
product_id = Column(String(32), nullable=True, comment="商品ID")
|
||||
content_type = Column(String(20), default="image", comment="内容类型")
|
||||
content_text = Column(Text, nullable=True, comment="文本内容")
|
||||
file_url = Column(String(500), nullable=True, comment="文件URL地址")
|
||||
thumbnail_url = Column(String(500), nullable=True, comment="缩略图URL")
|
||||
description = Column(String(500), nullable=True, comment="描述")
|
||||
sort_order = Column(Integer, default=0, comment="排序")
|
||||
create_at = Column(DateTime, nullable=True, comment="创建时间")
|
||||
create_by = Column(String(32), nullable=True, comment="创建人ID")
|
||||
create_name = Column(String(100), nullable=True, comment="创建人姓名")
|
||||
update_at = Column(DateTime, nullable=True, comment="修改时间")
|
||||
update_by = Column(String(32), nullable=True, comment="修改人ID")
|
||||
his_create_at = Column(DateTime, default=datetime.now, comment="历史记录创建时间")
|
||||
@@ -1,50 +0,0 @@
|
||||
"""商品参数码表模型"""
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Column, String, DateTime, Integer, Boolean, Text
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class BasProductParameter(Base):
|
||||
"""商品参数码表"""
|
||||
__tablename__ = "bas_product_parameter"
|
||||
|
||||
id = Column(String(32), primary_key=True, comment="主键ID")
|
||||
name = Column(String(100), nullable=False, comment="参数名称")
|
||||
code = Column(String(50), nullable=False, unique=True, comment="参数编码")
|
||||
param_type = Column(String(20), nullable=False, default="text", comment="参数类型:text-文本输入, select-单选下拉, multi_select-多选")
|
||||
options = Column(Text, nullable=True, comment="可选值列表,JSON数组")
|
||||
placeholder = Column(String(100), nullable=True, comment="输入提示文字")
|
||||
is_required = Column(Integer, default=0, comment="是否必填:0-否, 1-是")
|
||||
is_active = Column(Integer, default=1, comment="是否启用:0-禁用, 1-启用")
|
||||
is_del = Column(Integer, default=0, comment="是否删除:0-否, 1-是")
|
||||
sort_order = Column(Integer, default=0, comment="排序")
|
||||
remark = Column(String(500), nullable=True, comment="备注说明")
|
||||
create_at = Column(DateTime, default=datetime.now, comment="创建时间")
|
||||
create_by = Column(String(32), nullable=True, comment="创建人ID")
|
||||
create_name = Column(String(100), nullable=True, comment="创建人姓名")
|
||||
update_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="修改时间")
|
||||
update_by = Column(String(32), nullable=True, comment="修改人ID")
|
||||
|
||||
|
||||
class BasProductParameterHis(Base):
|
||||
"""商品参数码表历史表"""
|
||||
__tablename__ = "bas_product_parameter_his"
|
||||
|
||||
id = Column(String(32), primary_key=True, nullable=False, comment="主键ID")
|
||||
name = Column(String(100), nullable=True, comment="参数名称")
|
||||
code = Column(String(50), nullable=True, comment="参数编码")
|
||||
param_type = Column(String(20), default="text", comment="参数类型")
|
||||
options = Column(Text, nullable=True, comment="可选值列表")
|
||||
placeholder = Column(String(100), nullable=True, comment="输入提示文字")
|
||||
is_required = Column(Integer, default=0, comment="是否必填")
|
||||
is_active = Column(Integer, default=1, comment="是否启用")
|
||||
sort_order = Column(Integer, default=0, comment="排序")
|
||||
remark = Column(String(500), nullable=True, comment="备注说明")
|
||||
create_at = Column(DateTime, nullable=True, comment="创建时间")
|
||||
create_by = Column(String(32), nullable=True, comment="创建人ID")
|
||||
create_name = Column(String(100), nullable=True, comment="创建人姓名")
|
||||
update_at = Column(DateTime, nullable=True, comment="修改时间")
|
||||
update_by = Column(String(32), nullable=True, comment="修改人ID")
|
||||
his_create_at = Column(DateTime, default=datetime.now, comment="历史记录创建时间")
|
||||
@@ -1,46 +0,0 @@
|
||||
"""商品参数值模型"""
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Column, String, DateTime, Integer, Text
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class BasProductParameterValue(Base):
|
||||
"""商品参数值表"""
|
||||
__tablename__ = "bas_product_parameter_value"
|
||||
|
||||
id = Column(String(32), primary_key=True, comment="主键ID")
|
||||
product_id = Column(String(32), nullable=False, comment="商品ID")
|
||||
parameter_id = Column(String(32), nullable=False, comment="参数ID")
|
||||
parameter_name = Column(String(100), nullable=True, comment="参数名称快照")
|
||||
parameter_code = Column(String(50), nullable=True, comment="参数编码快照")
|
||||
param_type = Column(String(20), nullable=True, comment="参数类型快照")
|
||||
param_value = Column(Text, nullable=True, comment="参数值")
|
||||
sort_order = Column(Integer, default=0, comment="排序")
|
||||
is_del = Column(Integer, default=0, comment="是否删除:0-否, 1-是")
|
||||
create_at = Column(DateTime, default=datetime.now, comment="创建时间")
|
||||
create_by = Column(String(32), nullable=True, comment="创建人ID")
|
||||
create_name = Column(String(100), nullable=True, comment="创建人姓名")
|
||||
update_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="修改时间")
|
||||
update_by = Column(String(32), nullable=True, comment="修改人ID")
|
||||
|
||||
|
||||
class BasProductParameterValueHis(Base):
|
||||
"""商品参数值表历史表"""
|
||||
__tablename__ = "bas_product_parameter_value_his"
|
||||
|
||||
id = Column(String(32), primary_key=True, nullable=False, comment="主键ID")
|
||||
product_id = Column(String(32), nullable=True, comment="商品ID")
|
||||
parameter_id = Column(String(32), nullable=True, comment="参数ID")
|
||||
parameter_name = Column(String(100), nullable=True, comment="参数名称快照")
|
||||
parameter_code = Column(String(50), nullable=True, comment="参数编码快照")
|
||||
param_type = Column(String(20), nullable=True, comment="参数类型快照")
|
||||
param_value = Column(Text, nullable=True, comment="参数值")
|
||||
sort_order = Column(Integer, default=0, comment="排序")
|
||||
create_at = Column(DateTime, nullable=True, comment="创建时间")
|
||||
create_by = Column(String(32), nullable=True, comment="创建人ID")
|
||||
create_name = Column(String(100), nullable=True, comment="创建人姓名")
|
||||
update_at = Column(DateTime, nullable=True, comment="修改时间")
|
||||
update_by = Column(String(32), nullable=True, comment="修改人ID")
|
||||
his_create_at = Column(DateTime, default=datetime.now, comment="历史记录创建时间")
|
||||
@@ -1,66 +0,0 @@
|
||||
"""售后评价模型"""
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Column, String, DateTime, Integer, Text
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class BizProductReview(Base):
|
||||
"""商品售后评价表"""
|
||||
__tablename__ = "biz_product_review"
|
||||
|
||||
id = Column(String(32), primary_key=True, comment="主键ID")
|
||||
product_id = Column(String(32), nullable=False, comment="商品ID")
|
||||
order_id = Column(String(32), nullable=True, comment="订单ID")
|
||||
order_item_id = Column(String(32), nullable=True, comment="订单项ID")
|
||||
user_id = Column(String(32), nullable=False, comment="评价用户ID")
|
||||
user_name = Column(String(100), nullable=True, comment="评价用户姓名")
|
||||
rating = Column(Integer, nullable=False, default=5, comment="评分(1-5星)")
|
||||
rating_tags = Column(String(500), nullable=True, comment="评价标签,多个逗号分隔")
|
||||
content = Column(Text, nullable=True, comment="评价内容")
|
||||
is_anonymous = Column(Integer, default=0, comment="是否匿名:0-否, 1-是")
|
||||
reply_content = Column(Text, nullable=True, comment="商家回复内容")
|
||||
reply_time = Column(DateTime, nullable=True, comment="商家回复时间")
|
||||
reply_by = Column(String(32), nullable=True, comment="回复人ID")
|
||||
reply_by_name = Column(String(100), nullable=True, comment="回复人姓名")
|
||||
like_count = Column(Integer, default=0, comment="点赞数")
|
||||
is_top = Column(Integer, default=0, comment="是否置顶:0-否, 1-是")
|
||||
status = Column(String(20), default="pending", comment="状态:pending-待审核, approved-已通过, rejected-已拒绝")
|
||||
reject_reason = Column(String(500), nullable=True, comment="拒绝原因")
|
||||
is_del = Column(Integer, default=0, comment="是否删除:0-否, 1-是")
|
||||
create_at = Column(DateTime, default=datetime.now, comment="创建时间")
|
||||
create_by = Column(String(32), nullable=True, comment="创建人ID")
|
||||
create_name = Column(String(100), nullable=True, comment="创建人姓名")
|
||||
update_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="修改时间")
|
||||
update_by = Column(String(32), nullable=True, comment="修改人ID")
|
||||
|
||||
|
||||
class BizProductReviewHis(Base):
|
||||
"""商品售后评价历史表"""
|
||||
__tablename__ = "biz_product_review_his"
|
||||
|
||||
id = Column(String(32), primary_key=True, nullable=False, comment="主键ID")
|
||||
product_id = Column(String(32), nullable=True, comment="商品ID")
|
||||
order_id = Column(String(32), nullable=True, comment="订单ID")
|
||||
order_item_id = Column(String(32), nullable=True, comment="订单项ID")
|
||||
user_id = Column(String(32), nullable=True, comment="评价用户ID")
|
||||
user_name = Column(String(100), nullable=True, comment="评价用户姓名")
|
||||
rating = Column(Integer, default=5, comment="评分")
|
||||
rating_tags = Column(String(500), nullable=True, comment="评价标签")
|
||||
content = Column(Text, nullable=True, comment="评价内容")
|
||||
is_anonymous = Column(Integer, default=0, comment="是否匿名")
|
||||
reply_content = Column(Text, nullable=True, comment="商家回复内容")
|
||||
reply_time = Column(DateTime, nullable=True, comment="商家回复时间")
|
||||
reply_by = Column(String(32), nullable=True, comment="回复人ID")
|
||||
reply_by_name = Column(String(100), nullable=True, comment="回复人姓名")
|
||||
like_count = Column(Integer, default=0, comment="点赞数")
|
||||
is_top = Column(Integer, default=0, comment="是否置顶")
|
||||
status = Column(String(20), default="pending", comment="状态")
|
||||
reject_reason = Column(String(500), nullable=True, comment="拒绝原因")
|
||||
create_at = Column(DateTime, nullable=True, comment="创建时间")
|
||||
create_by = Column(String(32), nullable=True, comment="创建人ID")
|
||||
create_name = Column(String(100), nullable=True, comment="创建人姓名")
|
||||
update_at = Column(DateTime, nullable=True, comment="修改时间")
|
||||
update_by = Column(String(32), nullable=True, comment="修改人ID")
|
||||
his_create_at = Column(DateTime, default=datetime.now, comment="历史记录创建时间")
|
||||
@@ -1,42 +0,0 @@
|
||||
"""售后评价媒体模型"""
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Column, String, DateTime, Integer
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class BizProductReviewMedia(Base):
|
||||
"""商品评价媒体表"""
|
||||
__tablename__ = "biz_product_review_media"
|
||||
|
||||
id = Column(String(32), primary_key=True, comment="主键ID")
|
||||
review_id = Column(String(32), nullable=False, comment="评价ID")
|
||||
media_type = Column(String(20), nullable=False, default="image", comment="媒体类型:image-图片, video-视频")
|
||||
file_url = Column(String(500), nullable=False, comment="文件URL地址")
|
||||
thumbnail_url = Column(String(500), nullable=True, comment="缩略图URL(视频时使用)")
|
||||
sort_order = Column(Integer, default=0, comment="排序(数字越小越靠前)")
|
||||
is_del = Column(Integer, default=0, comment="是否删除:0-否, 1-是")
|
||||
create_at = Column(DateTime, default=datetime.now, comment="创建时间")
|
||||
create_by = Column(String(32), nullable=True, comment="创建人ID")
|
||||
create_name = Column(String(100), nullable=True, comment="创建人姓名")
|
||||
update_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="修改时间")
|
||||
update_by = Column(String(32), nullable=True, comment="修改人ID")
|
||||
|
||||
|
||||
class BizProductReviewMediaHis(Base):
|
||||
"""商品评价媒体历史表"""
|
||||
__tablename__ = "biz_product_review_media_his"
|
||||
|
||||
id = Column(String(32), primary_key=True, nullable=False, comment="主键ID")
|
||||
review_id = Column(String(32), nullable=True, comment="评价ID")
|
||||
media_type = Column(String(20), default="image", comment="媒体类型")
|
||||
file_url = Column(String(500), nullable=True, comment="文件URL地址")
|
||||
thumbnail_url = Column(String(500), nullable=True, comment="缩略图URL")
|
||||
sort_order = Column(Integer, default=0, comment="排序")
|
||||
create_at = Column(DateTime, nullable=True, comment="创建时间")
|
||||
create_by = Column(String(32), nullable=True, comment="创建人ID")
|
||||
create_name = Column(String(100), nullable=True, comment="创建人姓名")
|
||||
update_at = Column(DateTime, nullable=True, comment="修改时间")
|
||||
update_by = Column(String(32), nullable=True, comment="修改人ID")
|
||||
his_create_at = Column(DateTime, default=datetime.now, comment="历史记录创建时间")
|
||||
@@ -1,70 +0,0 @@
|
||||
"""中国行政区划(bas_china_region)树形结构,供运营端与小程序共用。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from typing import Dict, List
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.models import BasChinaRegion
|
||||
|
||||
_CHINA_REGION_TREE_CACHE_TTL_SEC = 600
|
||||
_china_region_tree_cache = {"expires_at": 0.0, "tree": None}
|
||||
_china_region_tree_cache_lock = threading.Lock()
|
||||
|
||||
|
||||
def query_china_regions_active(db: Session) -> List[BasChinaRegion]:
|
||||
return (
|
||||
db.query(
|
||||
BasChinaRegion.id,
|
||||
BasChinaRegion.parent_id,
|
||||
BasChinaRegion.region_code,
|
||||
BasChinaRegion.region_name,
|
||||
BasChinaRegion.sort_order,
|
||||
)
|
||||
.filter(BasChinaRegion.is_del == 0)
|
||||
.order_by(BasChinaRegion.sort_order.asc(), BasChinaRegion.region_code.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def build_china_region_tree(rows: List[BasChinaRegion]) -> List[dict]:
|
||||
"""{ value: region_code, label: region_name, children?: [...] },与历史 china-region-cascader.json 结构一致。"""
|
||||
children_map: Dict[str, List[BasChinaRegion]] = defaultdict(list)
|
||||
for r in rows:
|
||||
key = getattr(r, "parent_id", None) or ""
|
||||
children_map[key].append(r)
|
||||
for key in children_map:
|
||||
children_map[key].sort(key=lambda x: (getattr(x, "sort_order", 0), getattr(x, "region_code", "")))
|
||||
|
||||
def to_node(r: BasChinaRegion) -> dict:
|
||||
rid = getattr(r, "id", None)
|
||||
subs = children_map.get(rid, [])
|
||||
node: dict = {"value": getattr(r, "region_code", None), "label": getattr(r, "region_name", None)}
|
||||
if subs:
|
||||
node["children"] = [to_node(c) for c in subs]
|
||||
return node
|
||||
|
||||
roots = children_map.get("", [])
|
||||
return [to_node(r) for r in roots]
|
||||
|
||||
|
||||
def get_china_region_tree_cached(db: Session) -> List[dict]:
|
||||
"""获取中国行政区划树,带进程内短 TTL 缓存。"""
|
||||
now = time.time()
|
||||
with _china_region_tree_cache_lock:
|
||||
cache_tree = _china_region_tree_cache.get("tree")
|
||||
expires_at = float(_china_region_tree_cache.get("expires_at") or 0.0)
|
||||
if cache_tree is not None and now < expires_at:
|
||||
return copy.deepcopy(cache_tree)
|
||||
|
||||
rows = query_china_regions_active(db)
|
||||
tree = build_china_region_tree(rows)
|
||||
|
||||
with _china_region_tree_cache_lock:
|
||||
_china_region_tree_cache["tree"] = tree
|
||||
_china_region_tree_cache["expires_at"] = now + _CHINA_REGION_TREE_CACHE_TTL_SEC
|
||||
return copy.deepcopy(tree)
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Web 管理端登录:基于 V0.3 sys_user / sys_user_role / sys_role 的角色与菜单权限解析"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Set
|
||||
|
||||
from sqlalchemy import and_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.models import SysMenu, SysRole, SysRoleMenu, SysUserRole
|
||||
|
||||
|
||||
def _map_role_name_to_web(role_name: str) -> str:
|
||||
"""将 sys_role.role_name 映射为 web-admin 使用的 role 字符串。"""
|
||||
n = (role_name or "").strip()
|
||||
if not n:
|
||||
return "admin"
|
||||
if "超级" in n or n in ("系统管理员", "超级管理员"):
|
||||
return "super_admin"
|
||||
if "管理" in n or "运营" in n:
|
||||
return "admin"
|
||||
return "admin"
|
||||
|
||||
|
||||
def resolve_web_role_for_user(db: Session, user_id: str) -> str:
|
||||
"""根据用户关联角色解析 Web 端 role(admin / super_admin)。"""
|
||||
if not user_id:
|
||||
return settings.WEB_AUTH_DEFAULT_ROLE
|
||||
tenant = settings.DEFAULT_TENANT_NO
|
||||
rows = (
|
||||
db.query(SysRole.role_name)
|
||||
.join(SysUserRole, SysUserRole.role_id == SysRole.id)
|
||||
.filter(
|
||||
SysUserRole.user_id == user_id,
|
||||
SysUserRole.del_flag == 0,
|
||||
SysUserRole.tenant_no == tenant,
|
||||
SysRole.del_flag == 0,
|
||||
SysRole.status == "1",
|
||||
SysRole.tenant_no == tenant,
|
||||
)
|
||||
.order_by(SysRole.order_num.asc())
|
||||
.all()
|
||||
)
|
||||
names = [str(r[0]).strip() for r in rows if r and r[0]]
|
||||
if not names:
|
||||
return settings.WEB_AUTH_DEFAULT_ROLE
|
||||
mapped = [_map_role_name_to_web(n) for n in names]
|
||||
if "super_admin" in mapped:
|
||||
return "super_admin"
|
||||
return "admin"
|
||||
|
||||
|
||||
def iter_user_menu_perm_codes(db: Session, user_id: str) -> Set[str]:
|
||||
"""用户通过角色关联得到的菜单权限标识(sys_menu.perms,空则退回菜单 id)。"""
|
||||
if not user_id:
|
||||
return set()
|
||||
tenant = settings.DEFAULT_TENANT_NO
|
||||
try:
|
||||
q = (
|
||||
db.query(SysMenu.perms, SysMenu.id)
|
||||
.join(
|
||||
SysRoleMenu,
|
||||
and_(
|
||||
SysRoleMenu.menu_id == SysMenu.id,
|
||||
SysRoleMenu.del_flag == 0,
|
||||
SysRoleMenu.tenant_no == tenant,
|
||||
),
|
||||
)
|
||||
.join(
|
||||
SysUserRole,
|
||||
and_(
|
||||
SysUserRole.role_id == SysRoleMenu.role_id,
|
||||
SysUserRole.user_id == user_id,
|
||||
SysUserRole.del_flag == 0,
|
||||
SysUserRole.tenant_no == tenant,
|
||||
),
|
||||
)
|
||||
.filter(
|
||||
SysMenu.del_flag == 0,
|
||||
SysMenu.tenant_no == tenant,
|
||||
SysMenu.status == "1",
|
||||
SysMenu.visible == "1",
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
out: Set[str] = set()
|
||||
for perms, mid in q.all():
|
||||
p = (perms or "").strip()
|
||||
if p:
|
||||
out.add(p)
|
||||
elif mid:
|
||||
out.add(str(mid).strip())
|
||||
return out
|
||||
except Exception:
|
||||
return set()
|
||||
@@ -1,20 +1,28 @@
|
||||
"""定时任务模块(骨架:不启动叁强遗留的订单/配送定时任务)"""
|
||||
"""定时任务模块(骨架:不注册业务任务)"""
|
||||
import logging
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
scheduler = BackgroundScheduler()
|
||||
|
||||
|
||||
def init_scheduled_tasks(get_db_session):
|
||||
"""中科兴皖骨架工程 — 不注册 APScheduler 业务任务。"""
|
||||
logger.info("init_scheduled_tasks: 已跳过业务定时任务(骨架模式)")
|
||||
"""初始化定时任务(骨架工程无默认任务)"""
|
||||
if scheduler.running:
|
||||
return
|
||||
scheduler.start()
|
||||
logger.info("定时任务调度器已启动(骨架模式:无业务 Job)")
|
||||
|
||||
|
||||
def shutdown_scheduler():
|
||||
"""无调度器实例时为空操作。"""
|
||||
pass
|
||||
if scheduler.running:
|
||||
scheduler.shutdown(wait=False)
|
||||
logger.info("定时任务调度器已关闭")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"scheduler",
|
||||
"init_scheduled_tasks",
|
||||
"shutdown_scheduler",
|
||||
]
|
||||
@@ -1,236 +0,0 @@
|
||||
"""配送时效预警定时任务
|
||||
|
||||
依据订单分配时写入的配送截止时间(biz_delivery_task.effective_time)及区域表 sys_region.delivery_warn_advance_minutes(缺省 30 分钟)生成运营端预警:
|
||||
|
||||
- 进入「截止时间前 N 分钟」窗口且仍未完成配送:生成一条 advance_deadline 预警;
|
||||
- 已超过截止时间仍未完成:生成 timeout 预警;
|
||||
- 配送员确认送达(任务状态 delivering)或任务取消/完成时,由业务侧自动结案预警。
|
||||
|
||||
建议:每 1~5 分钟执行一次 check_delivery_warnings(db)。
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.models import BizDeliveryTask, BizDeliveryWarning, SysRegion
|
||||
from app.core.utils import generate_id
|
||||
from app.crud.delivery_warning_resolve import auto_resolve_open_warnings_for_task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_ADVANCE_MINUTES = 30
|
||||
|
||||
|
||||
class DeliveryWarningTask:
|
||||
"""配送时效预警任务"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def check_delivery_warnings(self) -> dict:
|
||||
"""检查配送任务并生成预警"""
|
||||
result = {
|
||||
"checked": 0,
|
||||
"warnings_created": 0,
|
||||
"notifications_sent": 0,
|
||||
"errors": []
|
||||
}
|
||||
|
||||
try:
|
||||
tasks = (
|
||||
self.db.query(BizDeliveryTask)
|
||||
.filter(BizDeliveryTask.is_del == 0)
|
||||
.all()
|
||||
)
|
||||
result["checked"] = len(tasks)
|
||||
|
||||
for task in tasks:
|
||||
try:
|
||||
if task.status in ("delivering", "completed", "cancelled"):
|
||||
auto_resolve_open_warnings_for_task(self.db, task.id)
|
||||
continue
|
||||
|
||||
if task.status not in ("pending", "assigned", "picking"):
|
||||
continue
|
||||
|
||||
warnings = self._check_task_warnings(task)
|
||||
result["warnings_created"] += len(warnings)
|
||||
|
||||
for warning in warnings:
|
||||
self._send_warning_notification(warning)
|
||||
result["notifications_sent"] += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"处理任务 {task.id} 时出错: {str(e)}")
|
||||
result["errors"].append({
|
||||
"task_id": task.id,
|
||||
"error": str(e)
|
||||
})
|
||||
|
||||
self.db.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"配送时效预警任务执行失败: {str(e)}")
|
||||
self.db.rollback()
|
||||
result["errors"].append({"task_id": "all", "error": str(e)})
|
||||
|
||||
return result
|
||||
|
||||
def _region_advance_minutes(self, region_id: str) -> int:
|
||||
reg = self.db.query(SysRegion).filter(SysRegion.id == region_id, SysRegion.is_del == 0).first()
|
||||
if not reg:
|
||||
return DEFAULT_ADVANCE_MINUTES
|
||||
m = getattr(reg, "delivery_warn_advance_minutes", None)
|
||||
if m is None or int(m) < 1:
|
||||
return DEFAULT_ADVANCE_MINUTES
|
||||
return int(m)
|
||||
|
||||
def _check_task_warnings(self, task: BizDeliveryTask) -> list:
|
||||
warnings = []
|
||||
|
||||
if not task.effective_time:
|
||||
return warnings
|
||||
|
||||
now = datetime.now()
|
||||
remaining_minutes = int((task.effective_time - now).total_seconds() // 60)
|
||||
advance = self._region_advance_minutes(task.region_id)
|
||||
|
||||
warning_type = None
|
||||
urgency_level = None
|
||||
message = None
|
||||
if remaining_minutes < 0:
|
||||
warning_type = "timeout"
|
||||
urgency_level = "已超时"
|
||||
message = f"已超过配送时效 {abs(remaining_minutes)} 分钟,仍未完成配送"
|
||||
elif remaining_minutes <= advance:
|
||||
warning_type = "advance_deadline"
|
||||
urgency_level = "时效预警"
|
||||
message = f"距配送截止不足 {advance} 分钟(剩余约 {remaining_minutes} 分钟),请督促配送"
|
||||
else:
|
||||
return warnings
|
||||
|
||||
w = self._upsert_task_warning(
|
||||
task=task,
|
||||
warning_type=warning_type,
|
||||
urgency_level=urgency_level,
|
||||
remaining_minutes=remaining_minutes,
|
||||
message=message,
|
||||
)
|
||||
if w:
|
||||
warnings.append(w)
|
||||
|
||||
return warnings
|
||||
|
||||
def _upsert_task_warning(
|
||||
self,
|
||||
task: BizDeliveryTask,
|
||||
warning_type: str,
|
||||
urgency_level: str,
|
||||
remaining_minutes: int,
|
||||
message: str
|
||||
) -> Optional[BizDeliveryWarning]:
|
||||
"""单任务单预警:存在则更新,不存在则创建"""
|
||||
try:
|
||||
warning = self._get_latest_warning_by_task(task.id)
|
||||
now = datetime.now()
|
||||
if warning:
|
||||
warning.warning_type = warning_type
|
||||
warning.urgency_level = urgency_level
|
||||
warning.remaining_minutes = remaining_minutes
|
||||
warning.message = message
|
||||
warning.order_id = task.order_id
|
||||
warning.is_sent = 0
|
||||
# 未完成前统一保持未结案(若历史上被手工结案,则重新激活)
|
||||
warning.is_resolved = 0
|
||||
warning.resolved_time = None
|
||||
warning.resolved_by = None
|
||||
warning.update_at = now
|
||||
warning.update_by = "system"
|
||||
self.db.flush()
|
||||
return warning
|
||||
|
||||
warning = BizDeliveryWarning(
|
||||
id=generate_id(),
|
||||
task_id=task.id,
|
||||
order_id=task.order_id,
|
||||
warning_type=warning_type,
|
||||
urgency_level=urgency_level,
|
||||
remaining_minutes=remaining_minutes,
|
||||
message=message,
|
||||
is_sent=0,
|
||||
is_resolved=0,
|
||||
create_by="system",
|
||||
create_name="系统",
|
||||
)
|
||||
self.db.add(warning)
|
||||
self.db.flush()
|
||||
return warning
|
||||
except Exception as e:
|
||||
logger.error(f"创建预警记录失败: {str(e)}")
|
||||
return None
|
||||
|
||||
def _get_latest_warning_by_task(self, task_id: str) -> Optional[BizDeliveryWarning]:
|
||||
return self.db.query(BizDeliveryWarning).filter(
|
||||
BizDeliveryWarning.task_id == task_id,
|
||||
BizDeliveryWarning.is_del == 0,
|
||||
).order_by(BizDeliveryWarning.update_at.desc(), BizDeliveryWarning.create_at.desc()).first()
|
||||
|
||||
def _send_warning_notification(self, warning: BizDeliveryWarning) -> bool:
|
||||
"""发送预警通知(可通过微信模板消息等)"""
|
||||
try:
|
||||
# 获取配送员信息
|
||||
task = self.db.query(BizDeliveryTask).filter(
|
||||
BizDeliveryTask.id == warning.task_id
|
||||
).first()
|
||||
|
||||
if not task or not task.deliver_id:
|
||||
logger.warning(f"任务 {warning.task_id} 未分配配送员,跳过通知")
|
||||
return False
|
||||
|
||||
# TODO: 调用微信模板消息接口发送通知
|
||||
# from app.core.wechat_pay import send_delivery_warning_message
|
||||
# send_delivery_warning_message(
|
||||
# deliver_id=task.deliver_id,
|
||||
# task_no=task.task_no,
|
||||
# message=warning.message
|
||||
# )
|
||||
|
||||
logger.info(f"已发送预警通知: {task.task_no} - {warning.message}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"发送预警通知失败: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def check_delivery_warnings(db: Session) -> dict:
|
||||
"""执行配送时效预警检查(供外部调用)"""
|
||||
task = DeliveryWarningTask(db)
|
||||
return task.check_delivery_warnings()
|
||||
|
||||
|
||||
def cleanup_expired_warnings(db: Session, days: int = 7) -> int:
|
||||
"""清理过期的预警记录
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
days: 保留天数,默认7天
|
||||
|
||||
Returns:
|
||||
int: 删除的记录数
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
cutoff_date = datetime.now() - timedelta(days=days)
|
||||
|
||||
count = db.query(BizDeliveryWarning).filter(
|
||||
BizDeliveryWarning.create_at < cutoff_date,
|
||||
BizDeliveryWarning.is_resolved == 1,
|
||||
BizDeliveryWarning.is_del == 0
|
||||
).update({"is_del": 1})
|
||||
|
||||
db.commit()
|
||||
logger.info(f"已清理 {count} 条过期预警记录")
|
||||
return count
|
||||
@@ -1,28 +0,0 @@
|
||||
"""送达后超时未确认收货,系统自动确认。"""
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.crud.order import order_crud
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_auto_confirm_receive_overdue(db: Session) -> int:
|
||||
from app.api.system_config import get_auto_confirm_receive_days_for_job
|
||||
|
||||
days = get_auto_confirm_receive_days_for_job(db)
|
||||
before = datetime.now() - timedelta(days=days)
|
||||
count = order_crud.auto_confirm_receive_overdue(
|
||||
db,
|
||||
before_threshold=before,
|
||||
configured_days=days,
|
||||
)
|
||||
return count
|
||||
|
||||
|
||||
def run_auto_confirm_receive_job(db: Session):
|
||||
count = run_auto_confirm_receive_overdue(db)
|
||||
if count:
|
||||
logger.info(f"自动确认收货完成: {count} 条")
|
||||
@@ -1,27 +0,0 @@
|
||||
"""订单支付超时自动关闭任务。"""
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.crud.order import order_crud
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def close_timeout_online_orders(db: Session) -> int:
|
||||
"""关闭超时未支付的线上订单(超时分钟数来自系统设置 auto_cancel_minutes)。"""
|
||||
from app.api.system_config import get_auto_cancel_minutes_for_job
|
||||
|
||||
timeout_minutes = get_auto_cancel_minutes_for_job(db)
|
||||
before_time = datetime.now() - timedelta(minutes=timeout_minutes)
|
||||
timeout_note = f"超过未支付自动取消时限({timeout_minutes} 分钟)"
|
||||
return order_crud.close_unpaid_online_orders(
|
||||
db, before_time=before_time, timeout_note=timeout_note
|
||||
)
|
||||
|
||||
|
||||
def run_close_timeout_online_orders(db: Session):
|
||||
"""定时任务包装。"""
|
||||
count = close_timeout_online_orders(db)
|
||||
logger.info(f"线上支付超时订单自动关闭完成: {count} 条")
|
||||
@@ -1,187 +0,0 @@
|
||||
"""WebSocket:区域价格清单更新推送(客户端实时刷新)"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Set, Optional, Any
|
||||
|
||||
import anyio
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
|
||||
from app.core.database import SessionLocal
|
||||
from app.core.auth import get_current_user_by_token
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PriceListUpdatedEvent:
|
||||
regionId: str
|
||||
priceListId: str
|
||||
priceVersion: int
|
||||
publishedTime: Optional[str] = None
|
||||
|
||||
def to_message(self) -> dict:
|
||||
return {
|
||||
"event": "price_list_updated",
|
||||
"data": {
|
||||
"regionId": self.regionId,
|
||||
"priceListId": self.priceListId,
|
||||
"priceVersion": self.priceVersion,
|
||||
"publishedTime": self.publishedTime,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class RegionWSManager:
|
||||
"""按 region_id 管理 websocket 连接并广播。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = asyncio.Lock()
|
||||
self._rooms: Dict[str, Set[WebSocket]] = {}
|
||||
|
||||
async def connect(self, websocket: WebSocket, *, token: str) -> dict:
|
||||
"""鉴权并接入房间。返回 current_user dict。"""
|
||||
# 验证 token(包含 Redis 会话校验 + DB 用户校验)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
current_user = await get_current_user_by_token(token, db)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
region_id = (current_user.get("region_id") or "").strip()
|
||||
if not region_id:
|
||||
logger.warning("[ws-mini] reject: empty region_id user=%s", current_user.get("id"))
|
||||
await websocket.close(code=1008)
|
||||
raise RuntimeError("user has no region_id")
|
||||
|
||||
await websocket.accept()
|
||||
|
||||
async with self._lock:
|
||||
self._rooms.setdefault(region_id, set()).add(websocket)
|
||||
room_size = len(self._rooms.get(region_id, set()))
|
||||
logger.warning(
|
||||
"[ws-mini] connected user=%s role=%s region=%s room_size=%s",
|
||||
current_user.get("id"),
|
||||
current_user.get("role"),
|
||||
region_id,
|
||||
room_size,
|
||||
)
|
||||
|
||||
return current_user
|
||||
|
||||
async def disconnect(self, websocket: WebSocket, *, region_id: str) -> None:
|
||||
async with self._lock:
|
||||
s = self._rooms.get(region_id)
|
||||
if not s:
|
||||
return
|
||||
if websocket in s:
|
||||
s.remove(websocket)
|
||||
room_size = len(s)
|
||||
if not s:
|
||||
self._rooms.pop(region_id, None)
|
||||
logger.warning("[ws-mini] disconnected region=%s room_size=%s", region_id, room_size if "room_size" in locals() else 0)
|
||||
|
||||
async def broadcast_region(self, region_id: str, message: dict) -> int:
|
||||
"""向 region 房间广播消息。返回尝试发送的连接数。"""
|
||||
async with self._lock:
|
||||
targets = list(self._rooms.get(region_id, set()))
|
||||
|
||||
if not targets:
|
||||
logger.warning("[ws-mini] broadcast skip: no targets region=%s event=%s", region_id, message.get("event"))
|
||||
return 0
|
||||
|
||||
logger.warning(
|
||||
"[ws-mini] broadcasting region=%s event=%s target_count=%s",
|
||||
region_id,
|
||||
message.get("event"),
|
||||
len(targets),
|
||||
)
|
||||
dead: list[WebSocket] = []
|
||||
for ws in targets:
|
||||
try:
|
||||
await ws.send_json(message)
|
||||
except Exception:
|
||||
dead.append(ws)
|
||||
|
||||
if dead:
|
||||
async with self._lock:
|
||||
room = self._rooms.get(region_id)
|
||||
if room:
|
||||
for ws in dead:
|
||||
room.discard(ws)
|
||||
if not room:
|
||||
self._rooms.pop(region_id, None)
|
||||
|
||||
return len(targets)
|
||||
|
||||
|
||||
manager = RegionWSManager()
|
||||
|
||||
|
||||
async def ws_mini_price_updates(websocket: WebSocket) -> None:
|
||||
"""小程序端 WebSocket 入口:/ws/mini"""
|
||||
token = (websocket.query_params.get("token") or "").strip()
|
||||
if not token:
|
||||
# 兼容 Authorization: Bearer <token>
|
||||
auth = (websocket.headers.get("authorization") or "").strip()
|
||||
if auth.lower().startswith("bearer "):
|
||||
token = auth[7:].strip()
|
||||
|
||||
if not token:
|
||||
logger.warning("[ws-mini] reject: missing token ip=%s", websocket.client.host if websocket.client else "")
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
|
||||
try:
|
||||
current_user = await manager.connect(websocket, token=token)
|
||||
except Exception:
|
||||
logger.exception("[ws-mini] connect failed")
|
||||
# connect 内部会 close;这里兜底
|
||||
try:
|
||||
await websocket.close(code=1008)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
region_id = (current_user.get("region_id") or "").strip()
|
||||
try:
|
||||
# 当前实现:仅服务器推送;客户端无需主动发消息。
|
||||
while True:
|
||||
await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
logger.warning("[ws-mini] client disconnected user=%s region=%s", current_user.get("id"), region_id)
|
||||
pass
|
||||
except Exception:
|
||||
logger.exception("[ws-mini] receive loop error user=%s region=%s", current_user.get("id"), region_id)
|
||||
pass
|
||||
finally:
|
||||
await manager.disconnect(websocket, region_id=region_id)
|
||||
|
||||
|
||||
def notify_price_list_updated(event: PriceListUpdatedEvent) -> None:
|
||||
"""线程/同步上下文安全:广播价格更新事件。"""
|
||||
|
||||
async def _run():
|
||||
sent = await manager.broadcast_region(event.regionId, event.to_message())
|
||||
logger.warning(
|
||||
"[ws-mini] notify price update region=%s price_list_id=%s version=%s sent=%s",
|
||||
event.regionId,
|
||||
event.priceListId,
|
||||
event.priceVersion,
|
||||
sent,
|
||||
)
|
||||
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
loop.create_task(_run())
|
||||
except RuntimeError:
|
||||
# sync endpoint 常在 worker thread:使用 anyio 跳回事件循环
|
||||
anyio.from_thread.run(_run)
|
||||
|
||||
|
||||
def register_ws_routes(app: Any) -> None:
|
||||
"""在 FastAPI app 上注册 websocket 路由。"""
|
||||
app.websocket("/ws/mini")(ws_mini_price_updates)
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -2,6 +2,8 @@
|
||||
|
||||
> **设计源文件**:`fresh-mint-green.html`(中科兴皖一体化门户 · 配送调度演示页)
|
||||
> **适用范围**:`web-admin` 工程(Vue 3 + Element Plus + SCSS/Tailwind)。实现时通过 **CSS 变量 + Element 主题变量 + 少量全局覆盖** 对齐本规范;**类名可与 demo 不同,但视觉参数以下文为准**。
|
||||
>
|
||||
> **Hash 路由约定(与当前工程一致)**:模板与参考页统一挂在 **`#/demo/*`**(侧栏「Demo」菜单)。下文凡出现 **`#/demo/console`** 指工作台;**`#/demo/unit` / `product` / `category` / `user` / `order`** 等同理。§13 修订记录中仍可能出现的 **`#/system/*`、`#/delivery/*`、`#/business/*`、`#/order/supplier-settlement`、`#/order/client-settlement`** 等为**历史路由**(对应页面可能已裁剪),实现时以 **仍在仓库内的 Demo 页** 与组件源码为准。
|
||||
|
||||
---
|
||||
|
||||
@@ -202,7 +204,7 @@
|
||||
- **`.stat .hint`**:`font-size: 11px`,`color: var(--text-muted)`,`margin-top: 4px`
|
||||
- **`.stat.warn`**:图标区背景 `#fffbeb`,边框 `rgba(217,119,6,0.2)`,图标与数值/ hint 色 `#d97706`(见 §2)
|
||||
|
||||
**工作台**(`#/dashboard/console`,根容器 **`console-container`**):页头 **`page-head` + `h1` + `page-desc`(§4.1)**;**时效预警条**与全局 **`console-container .alert-bar`** 一致(琥珀底 **`#fffbeb`**、边 **`#fde68a`**)。**数据概览(指标区)**的盒型与 **`fresh-mint-green.html`** 的 **`.stats` / `.stat`** 同构:**`stat-hd` + `stat-ic`**(默认 **`#ecfdf5` / `#059669`**,**`44×44px`**)+ **`stat-main`**(**`.k`** 指标名、**`.stat-side`** 内 **`.v` / `.hint`**);**待配送**等需强调预警的卡片加 **`.warn`**(图标区 **`#fffbeb` / `#d97706`**,**`.v` 与 `.hint`** 同步琥珀色,与 demo **`.stat.warn`** 一致)。**不含**独立「快捷菜单」区块(导航以侧栏为准)。**近30日趋势**:**`trend-charts-panel`** 内 **`trend-charts-grid`** 双列(窄屏单列)**`ArtLineChart`**(面积浅绿),分别展示每日营业额(元)与每日订单总数(笔),数据 **`GET /web/admin/statistics/orders/trend?days=30`**。**今日各区域与客户**:**`region-today-panel`** 内 **`region-today-split`** 双列(窄屏单列);**`title-bar-heading`** 用于「近30日趋势」「今日各区域」等标题时由 **`App.vue`** 规则切换为绿色竖条(**`::before`**),与 **`h2.title-bar-heading`**(**`app.scss`**)一致。左:**`RegionTodayDualBarChart`**(区域营业额 + 订单笔,**`/statistics/orders/today-by-region`**);右:**`RegionTodayDualBarChart`**(客户下单金额 + 商品数量,**`/statistics/orders/today-by-client`**),子标题 **`region-today-subtitle`** 带绿色小竖条。**最新订单**:**`section.panel`** + **`panel-top`** + **`list-body list-wrap`**(顶 **`border-top`**、**`list-wrap`** **`border-radius: 0 0 10px`** 与业务列表页一致)+ **`el-table`** **`list-table` + `stripe`**(**勿**加 **`border`**,与 **`#/order/order`** 等主表一致),**`row-key="id"`**,金额列 **`#059669`**;**「查看更多」**为 **`type="primary"` `link`**。全局 **`el-ui.scss`**:**`console-container .el-table`** 与 **`page-container .el-table`** 共用表头/hover token;**`console-container .el-button--primary`** 与菜单页主按钮同色链一致。
|
||||
**工作台**(`#/demo/console`;**当前仓库为轻量入口页**,以下统计/图表白述为**历史版式参考**。根容器 **`console-container`**):页头 **`page-head` + `h1` + `page-desc`(§4.1)**;**时效预警条**与全局 **`console-container .alert-bar`** 一致(琥珀底 **`#fffbeb`**、边 **`#fde68a`**)。**数据概览(指标区)**的盒型与 **`fresh-mint-green.html`** 的 **`.stats` / `.stat`** 同构:**`stat-hd` + `stat-ic`**(默认 **`#ecfdf5` / `#059669`**,**`44×44px`**)+ **`stat-main`**(**`.k`** 指标名、**`.stat-side`** 内 **`.v` / `.hint`**);**待配送**等需强调预警的卡片加 **`.warn`**(图标区 **`#fffbeb` / `#d97706`**,**`.v` 与 `.hint`** 同步琥珀色,与 demo **`.stat.warn`** 一致)。**不含**独立「快捷菜单」区块(导航以侧栏为准)。**近30日趋势**:**`trend-charts-panel`** 内 **`trend-charts-grid`** 双列(窄屏单列)**`ArtLineChart`**(面积浅绿),分别展示每日营业额(元)与每日订单总数(笔),数据 **`GET /web/admin/statistics/orders/trend?days=30`**。**今日各区域与客户**:**`region-today-panel`** 内 **`region-today-split`** 双列(窄屏单列);**`title-bar-heading`** 用于「近30日趋势」「今日各区域」等标题时由 **`App.vue`** 规则切换为绿色竖条(**`::before`**),与 **`h2.title-bar-heading`**(**`app.scss`**)一致。左:**`RegionTodayDualBarChart`**(区域营业额 + 订单笔,**`/statistics/orders/today-by-region`**);右:**`RegionTodayDualBarChart`**(客户下单金额 + 商品数量,**`/statistics/orders/today-by-client`**),子标题 **`region-today-subtitle`** 带绿色小竖条。**最新订单**:**`section.panel`** + **`panel-top`** + **`list-body list-wrap`**(顶 **`border-top`**、**`list-wrap`** **`border-radius: 0 0 10px`** 与业务列表页一致)+ **`el-table`** **`list-table` + `stripe`**(**勿**加 **`border`**,与 **`#/demo/order`** 等主表一致),**`row-key="id"`**,金额列 **`#059669`**;**「查看更多」**为 **`type="primary"` `link`**。全局 **`el-ui.scss`**:**`console-container .el-table`** 与 **`page-container .el-table`** 共用表头/hover token;**`console-container .el-button--primary`** 与菜单页主按钮同色链一致。
|
||||
|
||||
### 4.3 面板 `.panel` / `.panel-top`
|
||||
|
||||
@@ -254,7 +256,7 @@
|
||||
|
||||
- **场景**:检索条件为**多级分类**等树形数据时,**下拉树节点**仅展示**本级名称**(结构已表达层级,避免每行重复冗长前缀);选中收起后,输入框内回显 **自根到当前节点的全路径**(如 `一级分类 / 二级分类 / 三级分类`),避免仅末级名称产生歧义。
|
||||
- **实现要点**:树数据里 **`label` 仍用本级名称**。`el-tree-select` 内部基于 `ElSelect`,可将 **`#label` 插槽** 透传至 Select,在插槽内根据当前选中 `value`(节点 id)在**扁平分类列表**中沿 `parentId` 向上拼接全路径作为展示文案;**`filter-node-method`** 建议同时匹配本级名与全路径,便于用上级关键词检索。
|
||||
- **落地参考**:`#/base/product` 筛选与表单「商品分类」。
|
||||
- **落地参考**:`#/demo/product` 筛选与表单「商品分类」。
|
||||
|
||||
### 5.6 树形业务数据全链路展示规律(通用,不限于商品分类)
|
||||
|
||||
@@ -276,7 +278,7 @@
|
||||
- 需要 **「有路径用路径,否则用接口给的末级名」**:使用 **`resolveTreePathDisplay`**。
|
||||
- 仅有末级名、且 **无** `id`、无法关联主数据时:只能展示接口文案;**推荐接口**在可行时返回 `categoryId`(或对应树节点 id)以便前端拼路径。
|
||||
|
||||
**已对齐页面(随改造持续补充)**:`#/base/product`(列表列、筛选/表单树选、导出、查看弹窗组件)、`#/business/inquiry-quotation`(分类列/卡片等使用分类树路径映射)。
|
||||
**已对齐页面(随改造持续补充)**:`#/demo/product`(列表列、筛选/表单树选、导出、查看弹窗组件);原询价报价页已裁剪,分类树路径映射与 **`#/demo/category`** / **`#/demo/product`** 一致。
|
||||
|
||||
---
|
||||
|
||||
@@ -420,7 +422,7 @@
|
||||
|
||||
### 7.9 分页(Element Plus:`el-pagination` 与 `.pagination.list-pager-bar`)
|
||||
|
||||
`web-admin` 列表页(如 **单位管理** `#/base/unit`、**商品管理** `#/base/product`)在 **`el-table` 下方**放置 **`el-pagination`**,外层使用 **同一套容器类**,保证与表格同属一块白底面板、顶部分割线清晰,**右对齐**控件区(总数、每页条数、翻页、跳转)。
|
||||
`web-admin` 列表页(如 **单位管理** `#/demo/unit`、**商品管理** `#/demo/product`)在 **`el-table` 下方**放置 **`el-pagination`**,外层使用 **同一套容器类**,保证与表格同属一块白底面板、顶部分割线清晰,**右对齐**控件区(总数、每页条数、翻页、跳转)。
|
||||
|
||||
**容器(推荐类名)**:**`.pagination.list-pager-bar`**(可同时保留 **`.pagination`** 单类,但样式以下列为准)。
|
||||
|
||||
@@ -435,7 +437,7 @@
|
||||
|
||||
**结构约定**:分页与表格包在同一 **`.list-body.list-wrap`**(或等价)内,使 **`.list-wrap`** 的 **`border-radius: 0 0 10px 10px`** 覆盖整块「表 + 分页」;分页条上边框与表体底边衔接,形成明确的「列表分页区」。
|
||||
|
||||
**对齐参考页**:`#/base/unit`;其他业务列表页改造时应复用上述容器类与数值,避免各页自定义分页外边距或缺上边框。
|
||||
**对齐参考页**:`#/demo/unit`;其他业务列表页改造时应复用上述容器类与数值,避免各页自定义分页外边距或缺上边框。
|
||||
|
||||
---
|
||||
|
||||
@@ -529,7 +531,7 @@
|
||||
- **底部动作**:`dialog-foot` 固定在底部,查看弹窗仅放“关闭/返回”类动作,避免在查看态塞入提交语义。
|
||||
- **交互与可访问性**:支持点击遮罩关闭、`Esc` 关闭;滚动仅发生在 `dialog-scroll`;图片提供可预览与 `alt`,长文本按省略/换行策略保持稳定排版。
|
||||
- **组件抽象建议**:可统一封装为业务级查看组件(如 `ProductViewDialog`),内部固定使用 `view-hero / view-section / view-kv-grid / dialog-foot` 语义类,业务页只传入数据与开关状态,避免每页重复造样式。
|
||||
- **落地示例**:`#/base/product` 已按该模板改造(`web-admin/src/components/business/product-view-dialog/index.vue`),可直接作为后续页面的实现参考。
|
||||
- **落地示例**:`#/demo/product` 已按该模板改造(`web-admin/src/components/business/product-view-dialog/index.vue`),可直接作为后续页面的实现参考。
|
||||
- **树形字段展示**:查看弹窗中凡**树形主数据**(分类等)均与 **§5.6** 一致,展示**全路径**;实现上优先复用 `utils/tree-path` 与已加载的扁平列表或树。
|
||||
|
||||
---
|
||||
@@ -563,7 +565,7 @@
|
||||
- **`.upload-preview`**:`flex`,`gap: 10px`,`margin-top: 12px`
|
||||
- **`.upload-item`**:**72×72**,圆角 **8px**,边 `var(--border)`;**`.rm`**(删除缩略图):**20×20**,绝对定位右上 **2px**,背景 `rgba(0,0,0,0.55)`,白字,圆角 **4px**
|
||||
|
||||
### 10.6 商品分类 · 弹窗内「参数」卡片(`#/base/category`)
|
||||
### 10.6 商品分类 · 弹窗内「参数」卡片(`#/demo/category`)
|
||||
|
||||
单张卡片结构(自上而下):
|
||||
|
||||
@@ -616,23 +618,23 @@
|
||||
4. 表格与 art-table:`el-table` 对齐 §7(含 sticky、图文列;**Element 列表分页条**见 **§7.9**)。
|
||||
5. `el-dialog` / `el-drawer`:§9。
|
||||
6. 树形页面:§8。
|
||||
7. **工作台**(`#/dashboard/console`):见 **§4.2** 工作台小节——与 **`fresh-mint-green.html`** **`.stat`** 同构的指标卡、**近30日** **`ArtLineChart`** 双图(`/statistics/orders/trend`)、**今日各区域** 双轴柱状(`/statistics/orders/today-by-region`)、**`list-body list-wrap` + `list-table` + `stripe`**(无 **`border`**)最新订单;与 **`el-ui.scss`** 中 **`console-container`** 表格/主按钮覆盖一致。
|
||||
8. **商品分类**(`#/base/category`):与 **区域管理** 同构——页头说明、筛选 §5、工具栏 §7.3(已选 + 左起操作)、`list-body` + 表格 + **Element 分页**、状态 pill 与 **Fresh Mint** token;新增/编辑/选参弹窗底栏 **`dialog-footer`** + **Close / Check** 图标,图标间距 **6px**(与工具栏一致);弹窗内 **参数卡片** 布局与 **`.parameter-assoc-form`** 见 **§10.6**;列表操作列统一为 **首个按钮主色,后续按钮中性**(父分类:新增子分类主色;编辑/删除中性。子分类:编辑主色;删除中性)。
|
||||
9. **商品单位**(`#/base/unit`):与 **区域管理** 同构(页头说明、筛选、工具栏 §7.3、`list-body` 顶部分割线、表格、分页、`scroll-to-error` + 校验失败 **`ElMessageBox.alert`**、**`unit-dialog`** 与 **两列表单**);列表操作列 **编辑主色 / 删除中性**。
|
||||
10. **商品管理**(`#/base/product`):筛选区切换为 §5 结构(非 `inline`)、工具栏升级为 §7.3(已选计数 + 左起操作)并保持 `list-body` 顶部分割线;**商品分类** 树形字段遵循 **§5.6**:下拉树节点为本级名,收起/列表/导出/查看等为 **全路径**(` / ` 分隔)。列表操作列遵循“首个按钮主色、后续中性”;新增/编辑与参数选择弹窗底栏统一 **`dialog-footer`** + **Close / Check** 图标与 **6px** 间距。商品列表补充:工具栏按钮遵循“**第一个按钮有颜色,后面的按钮都是普通按钮**”(即首个主色、其余中性描边);操作列固定右侧且预留足够宽度完整展示全部按钮;商品图片与名称合并为一列(表头居中、内容左对齐、名称超长省略)。弹窗补充:基本信息表单与商品单位页一致,采用 **label 在上、输入在下**(`label-position="top"`),避免左右并排造成间距过大;图片上传等 **primary-plain** 按钮需保证常态下图标与文字可见(非 hover 才显示);弹窗底栏取消/确定前缀图标必须可见;新增/编辑、参数选择、图片预览、视频播放等弹窗统一使用无模糊遮罩(`modal-class` + `backdrop-filter: none`);并在全局覆盖中移除 `.page-container .el-overlay, .console-container .el-overlay` 的 `backdrop-filter`,避免任何菜单页弹窗出现背景模糊。
|
||||
11. **询价报价管理**(`#/business/inquiry-quotation`):**页头** **`page-desc`**;**区域切换**为 **Mint 分段按钮**(**`region-tab-btn`**:白底描边 **`#d9e8e2`**,激活 **`#10b981`** 实心);**询价 / 报价** 两块均为 **`iq-panel`**(**`#d9e8e2`** 边、**10px** 圆角、轻阴影),卡片头 **`title-ic` + Operation / Document** 图标与 **§3** 工具栏语义一致;**询价表** **`list-table`**,**已发布行** 浅绿底 **`#ecfdf5` / hover `#d1fae5`**;操作列 **「查看」`btn-op-item primary`**,其余 **`btn-op-item`** + 前缀图标。**报价** 内 **Tab** 为 **`quote-tab-chip`**(与区域切换同构);**已选中** 说明用 **`iq-alert`**;**矩阵对比** 最低价格与 **供应商报价完整度** 色:绿 **`#059669`**、红 **`#dc2626`**、字 **`#1e293b`**(对齐运营端 token,非小程序角色色)。**历史报价** 筛选改为 **§5** 式 **`filter-block` + `history-filter-grid`(label 11px)+ `filter-actions-row`**(**`btn-ghost` / `btn-main`** + RefreshLeft / Search);**`history-filter-grid`** 桌面宽度下 **三列同排**(供应商|状态|时间),**`grid-template-columns: minmax(0,1fr) minmax(0,1fr) minmax(220px,1.35fr)`**,**勿**对日期表单项使用 **`grid-column: 1 / -1`** 独占一行。分页 **`list-pager-bar`**。**查看询价单** 弹窗顶部信息与 **§9.4–9.5** 对齐:**`inquiry-view-hero`**(**72×72** 图标区 + 标题 / 状态 **`el-tag`** / 创建时间摘要)+ **`inquiry-view-kv`** 内 **`view-kv-grid` + `view-kv`**(备注 **`view-multiline`** 跨两列),**不用** 与下方选品区混排的 **`el-descriptions` border** 块。**大弹窗**(询价表单 / 详情 / 报价明细 / 调价确认)统一 **`inquiry-quotation-dialog`** + **`modal-class="inquiry-quotation-dialog-mask"`**(无模糊遮罩),**`el-dialog__header`** 薄荷渐变底栏,**`dialog-footer`** **36px** 高、**8px** 圆角、主 **`#10b981`**;询价表单 footer **左右分布**(左侧已选数量 hint)。**内嵌选品区** 树「当前」高亮、卡片选中描边、面板边框均用 **Mint / `#d9e8e2`**,替换原默认蓝。列表/卡片中「分类」展示仍与 **§5.6** 一致(全路径、` / ` 分隔);工具函数见 **`web-admin/src/utils/tree-path.ts`**。
|
||||
12. **参数模板**(`#/base/parameter`):与 **商品单位** 同构——页头 **`page-desc`**、筛选 **§5**(`filter-grid` + `filter-actions-row`)、工具栏 **§7.3**(`list-toolbar-row`,已选 **`sel-num`** + **`toolbar-actions`** 左起)、**`list-body`** 顶部分割线、表格与分页 **`list-pager-bar`**;列表操作列 **「详情」主色、编辑/删除中性**(与 §9.8 查看优先一致)。新增/编辑弹窗 **`parameter-dialog`**:**`720px`**、**`label-position="top"`**、**`form-grid-2`**、**`scroll-to-error`** + 校验失败 **`ElMessageBox.alert`**、**`parameter-dialog-mask`** 无模糊遮罩。表单栅格 **尽量双列排布**:名称|编码;**输入类型** 与 **排序** 同一行(类型使用 **`param-type-radio-group`** 允许折行);**可选值**(非文本类型时)仍 **`form-col-span-2`**;**输入提示** 与 **是否必填** 同一行;**状态** 与 **备注**(各一列,备注为双行文本域)同一行。弹窗底栏与 **区域 / 单位 / 商品** 一致:**`dialog-footer`** 内取消为 **`btn-ghost`**、确定为 **`btn-main`** + **`type="primary"`**,**Close / Check** 图标与 **6px** 间距;非 scoped 样式中对 **`.parameter-dialog .el-dialog__footer .el-button`** 统一 **36px 高、8px 圆角、600 字重**,次要按钮白底 **`#d9e8e2`** 描边,主按钮 **`#10b981`** 色链(与 **`product-view-dialog`** 底栏规则对齐)。**详情**弹窗(**`parameter-view-dialog`**):与 **`#/base/product`** 的 **`ProductViewDialog`** 对齐——**`.el-dialog__body`** 无内边距、**`.parameter-view-dialog .dialog-scroll`** 为 **`padding: 0`**,水平留白由 **`.view-hero`**(**`padding: 20px 22px`**,与商品查看一致)与 **`.view-section`**(**`padding: 18px 22px`**、块间 **`border-bottom: 1px solid #d9e8e2`**、**`:last-child`** 去底边,见 **`product-view-dialog`**)共同承担;**禁止**在本页再写仅含 **`padding: 4px 0 8px`** 的 **`.view-section`**,否则后会覆盖前述规则,导致键值区贴边、观感拥挤。其下 **`view-kv-grid`** 展示完整字段;底栏 **关闭** 使用 **`btn-ghost`** + Close 图标。筛选「状态」与单位页一致(全部为空串,请求时转 `undefined`)。
|
||||
13. **用户管理**(`#/base/user`):与 **区域 / 单位** 同构——页头 **`page-desc`**、筛选 **§5**(四列 **`filter-grid`**:关键词支持 **Enter** 查询)、工具栏 **§7.3**(**`list-toolbar-row`**、**`sel-num`**、**`toolbar-actions`**;仅 **批量导出**、无导入模板)、**`list-body list-wrap`** + 表头/单元格 **居中**(头像列保留 **§7** 图文列语义)、分页 **`list-pager-bar`**。列表操作列:**「详情」** **`btn-op-item primary`**,**编辑 / 重置密码 / 删除** 为中性 **`btn-op-item`**;操作列 **`min-width`** 需容纳四个按钮。新增/编辑弹窗:**`user-dialog`** **`720px`**、**`modal-class="user-dialog-mask"`**、**`close-on-click-modal="false"`**、**`label-position="top"`** 的 **`user-form`**,分节 **账号信息**(仅新增:用户名|密码)**基本信息**(姓名|手机|角色|所属区域 **`clearable`**)**头像**(**`primary` `plain`** 上传 + **`btn-ghost` 清除**)**状态设置**(仅编辑);**`scroll-to-error`** + 校验失败 **`ElMessageBox.alert`**(与单位页一致)。**详情**弹窗:**`user-dialog user-view-dialog`**,**`body` 无 padding** + **`.dialog-scroll` `padding: 0`**,**`view-hero`**(左侧 **72×72** 圆角头像区 + **`view-hero-main`** 摘要)+ **`view-section`** + **`view-kv-grid`**,状态用 **`status-pill`**(禁用为 **info**,与列表一致);底栏 **关闭** **`btn-ghost`** + Close。**`user-dialog-mask`**:**`backdrop-filter: none`**。
|
||||
14. **订单列表**(`#/order/order`):页头 **`page-desc`**;**`section.panel`** 内 **`panel-top`**(**`title-ic` + Operation**);筛选 **`filter-block`**(**§5.3**:多行栅格 / **`filter-extra`** / **`filter-actions`** 之间 **无** 横向分割线,仅用间距)对齐 **`fresh-mint-green.html`**:**`filter-hint`** + 常用条件 **`filter-grid order-filter-main-grid`**(桌面 **四列** 一排)+ **`filter-extra`**(**`v-show`**:支付方式、区域)+ **`filter-actions`**(左侧 **`btn-filter-toggle`** 链式按钮:**展开更多条件 / 收起更多条件**,**`aria-expanded` + `aria-controls`**;右侧 **`btn-ghost` / `btn-main`**);重置时 **`filterExpanded=false`** 收起附加条件。关键词 **Enter** 查询。**`list-body list-wrap`** 内主表 **`order-list-table order-list-main-table list-table`**(主表单元格 flex 居中**勿**作用到嵌套表),分页 **`list-pager-bar`**。**行展开子表** 对齐 demo **树形子列表`§8`**:**`expanded-cell` 背景 `#f8fafc`**、**`order-tree-nested-inner`** 左 **`3px #10b981`** + **`order-tree-nested-label`**;子表 **`order-tree-child-table`**:**`border`**、表头底 **`#e2e8f0`**、hover **`#f1f5f9`**,首列(商品)左对齐、数值列 **`is-right`**。订单编号链式按钮 **`order-no-link`**(**`#059669`**)。操作列:**「查看/运营」`btn-op-item primary` + View**,**「分配配送员」`btn-op-item` + Bicycle**,**「审核取消」`btn-op-item op-warn` + DocumentChecked**;**`min-width`** 需容纳多枚操作钮。**订单详情(查看/运营)** **`order-detail-dialog`** + **`modal-class="order-module-dialog-mask"`**:**`el-dialog__body` `padding: 0`**,内容区 **`dialog-scroll order-detail-dialog-scroll`** 内纵向滚动;顶部 **`view-hero order-view-hero`**(**72×72** **`order-view-hero-icon` + ShoppingBag + **`view-hero-main`** 标题 / **`view-hero-tags`** / **`view-hero-meta`**),各 Tab 下区块为 **`view-section`** + **`h3`** + **`view-kv-grid` / `view-kv` / `view-multiline`**(与 **参数详情 / 询价查看** 同构,不再用 **`el-descriptions` border** 作为主信息区);底栏 **`dialog-footer`**,**关闭** **`btn-ghost` + Close**。**`el-dialog__header`** 薄荷渐变、**`el-dialog__footer`** **`#f8fafc`**;主操作 **`btn-main`**,**取消订单** **`type="danger"`**。**分配 / 审核取消 / 取消订单 / 调整 / 价格版本明细** 等辅弹窗 **`order-module-dialog`** + 同款 **`modal-class`** 与 **36px / 8px** 底栏规则(与询价大弹窗同构;无模糊遮罩)。
|
||||
15. **商家结算管理**(`#/order/supplier-settlement`):页头 **`page-desc`**;**`region-tab-strip` + `region-tab-btn`**(与询价区域切换同构);**`el-tabs` `settlement-page-tabs`**(Mint 指示条);各 Tab 内 **`section.panel`**:**`panel-top`**(**`title-ic` + Wallet / Document**)、**`filter-block`**(**三列 `filter-grid`**、**`filter-actions-row`** + **`btn-ghost` / `btn-main`** + Refresh / Search、关键词 **Enter**);**待结算** 下 **`settlement-stats`**(**`stat-card` / `stat-card--accent`**);**`list-toolbar-row` + `toolbar-actions`**;主表 **`settlement-pending-main-table settlement-root-table list-table`**;**结算单记录** 主表 **`settlement-sheets-main-table settlement-root-table list-table`**;分页 **`list-pager-bar`**。**树形多层级**(对齐 **`fresh-mint-green.html` §树形列表**):待结算订单行展开为 **子列表**(**`settlement-tree-inner`** 左 **`3px #10b981`** + **`settlement-tree-label`** + **`settlement-child-table`** **`border`**、表头 **`#e2e8f0`**);结算单记录为 **结算单 → 订单 → 商品** 三层:**L1** **`settlement-tree-l1`** 内嵌 **`settlement-mid-table`**;**L2** **`settlement-tree-l2`**(琥珀左边线 + 浅黄渐变,对应 demo **`tree-nested-deep`**)+ **`settlement-line-table`**(孙表头 **`#fef3c7`** / 字 **`#92400e`**);**`expanded-cell`** 主层 **`#f8fafc`**、订单下商品层 **`#fffbeb`**。操作列:**详情 `btn-op-item primary` + View**,**编辑 `btn-op-item op-warn` + Edit**。业务弹窗 **`order-module-dialog`** + **`order-module-dialog-mask`**;本页另附 **非 scoped** 的 **`order-module-dialog`** 规则,避免未加载订单页 chunk 时弹窗无样式。订单号链式 **`settlement-order-no-link`**。
|
||||
16. **客户结算管理**(`#/order/client-settlement`):与 **商家结算管理** 同构应用 Fresh Mint 壳层与交互(页头 `page-desc`、`region-tab-strip`、`settlement-page-tabs`、`panel`、`filter-block`、`list-toolbar-row`、`list-pager-bar`、`order-module-dialog`)。业务语义改为客户维度(客户筛选、客户结算状态、`client_settlement` 凭证业务类型、同客户约束)。树形多层级同样按 **结算单 → 订单 → 商品** 实现,沿用 **`settlement-tree-inner / settlement-tree-l1 / settlement-tree-l2`** 视觉分层:L1 薄荷左边线 + 灰蓝子表头,L2 琥珀左边线 + 浅黄渐变 + 孙表琥珀表头;主层与深层 expanded cell 分别使用 **`#f8fafc`** 与 **`#fffbeb`**,保证多层树形在客户结算页与标准 demo 统一。
|
||||
7. **工作台**(`#/demo/console`):见 **§4.2** 工作台小节——与 **`fresh-mint-green.html`** **`.stat`** 同构的指标卡、**近30日** **`ArtLineChart`** 双图(`/statistics/orders/trend`)、**今日各区域** 双轴柱状(`/statistics/orders/today-by-region`)、**`list-body list-wrap` + `list-table` + `stripe`**(无 **`border`**)最新订单;与 **`el-ui.scss`** 中 **`console-container`** 表格/主按钮覆盖一致。
|
||||
8. **商品分类**(`#/demo/category`):与 **区域管理** 同构——页头说明、筛选 §5、工具栏 §7.3(已选 + 左起操作)、`list-body` + 表格 + **Element 分页**、状态 pill 与 **Fresh Mint** token;新增/编辑/选参弹窗底栏 **`dialog-footer`** + **Close / Check** 图标,图标间距 **6px**(与工具栏一致);弹窗内 **参数卡片** 布局与 **`.parameter-assoc-form`** 见 **§10.6**;列表操作列统一为 **首个按钮主色,后续按钮中性**(父分类:新增子分类主色;编辑/删除中性。子分类:编辑主色;删除中性)。
|
||||
9. **商品单位**(`#/demo/unit`):与 **区域管理** 同构(页头说明、筛选、工具栏 §7.3、`list-body` 顶部分割线、表格、分页、`scroll-to-error` + 校验失败 **`ElMessageBox.alert`**、**`unit-dialog`** 与 **两列表单**);列表操作列 **编辑主色 / 删除中性**。
|
||||
10. **商品管理**(`#/demo/product`):筛选区切换为 §5 结构(非 `inline`)、工具栏升级为 §7.3(已选计数 + 左起操作)并保持 `list-body` 顶部分割线;**商品分类** 树形字段遵循 **§5.6**:下拉树节点为本级名,收起/列表/导出/查看等为 **全路径**(` / ` 分隔)。列表操作列遵循“首个按钮主色、后续中性”;新增/编辑与参数选择弹窗底栏统一 **`dialog-footer`** + **Close / Check** 图标与 **6px** 间距。商品列表补充:工具栏按钮遵循“**第一个按钮有颜色,后面的按钮都是普通按钮**”(即首个主色、其余中性描边);操作列固定右侧且预留足够宽度完整展示全部按钮;商品图片与名称合并为一列(表头居中、内容左对齐、名称超长省略)。弹窗补充:基本信息表单与商品单位页一致,采用 **label 在上、输入在下**(`label-position="top"`),避免左右并排造成间距过大;图片上传等 **primary-plain** 按钮需保证常态下图标与文字可见(非 hover 才显示);弹窗底栏取消/确定前缀图标必须可见;新增/编辑、参数选择、图片预览、视频播放等弹窗统一使用无模糊遮罩(`modal-class` + `backdrop-filter: none`);并在全局覆盖中移除 `.page-container .el-overlay, .console-container .el-overlay` 的 `backdrop-filter`,避免任何菜单页弹窗出现背景模糊。
|
||||
11. **询价报价管理**(历史路由 **`#/business/inquiry-quotation`**,页面已裁剪;复杂列表与弹窗可对照 **`#/demo/order`**):**页头** **`page-desc`**;**区域切换**为 **Mint 分段按钮**(**`region-tab-btn`**:白底描边 **`#d9e8e2`**,激活 **`#10b981`** 实心);**询价 / 报价** 两块均为 **`iq-panel`**(**`#d9e8e2`** 边、**10px** 圆角、轻阴影),卡片头 **`title-ic` + Operation / Document** 图标与 **§3** 工具栏语义一致;**询价表** **`list-table`**,**已发布行** 浅绿底 **`#ecfdf5` / hover `#d1fae5`**;操作列 **「查看」`btn-op-item primary`**,其余 **`btn-op-item`** + 前缀图标。**报价** 内 **Tab** 为 **`quote-tab-chip`**(与区域切换同构);**已选中** 说明用 **`iq-alert`**;**矩阵对比** 最低价格与 **供应商报价完整度** 色:绿 **`#059669`**、红 **`#dc2626`**、字 **`#1e293b`**(对齐运营端 token,非小程序角色色)。**历史报价** 筛选改为 **§5** 式 **`filter-block` + `history-filter-grid`(label 11px)+ `filter-actions-row`**(**`btn-ghost` / `btn-main`** + RefreshLeft / Search);**`history-filter-grid`** 桌面宽度下 **三列同排**(供应商|状态|时间),**`grid-template-columns: minmax(0,1fr) minmax(0,1fr) minmax(220px,1.35fr)`**,**勿**对日期表单项使用 **`grid-column: 1 / -1`** 独占一行。分页 **`list-pager-bar`**。**查看询价单** 弹窗顶部信息与 **§9.4–9.5** 对齐:**`inquiry-view-hero`**(**72×72** 图标区 + 标题 / 状态 **`el-tag`** / 创建时间摘要)+ **`inquiry-view-kv`** 内 **`view-kv-grid` + `view-kv`**(备注 **`view-multiline`** 跨两列),**不用** 与下方选品区混排的 **`el-descriptions` border** 块。**大弹窗**(询价表单 / 详情 / 报价明细 / 调价确认)统一 **`inquiry-quotation-dialog`** + **`modal-class="inquiry-quotation-dialog-mask"`**(无模糊遮罩),**`el-dialog__header`** 薄荷渐变底栏,**`dialog-footer`** **36px** 高、**8px** 圆角、主 **`#10b981`**;询价表单 footer **左右分布**(左侧已选数量 hint)。**内嵌选品区** 树「当前」高亮、卡片选中描边、面板边框均用 **Mint / `#d9e8e2`**,替换原默认蓝。列表/卡片中「分类」展示仍与 **§5.6** 一致(全路径、` / ` 分隔);工具函数见 **`web-admin/src/utils/tree-path.ts`**。
|
||||
12. **参数模板**(独立菜单已裁剪;列表与弹窗版式同 **`#/demo/unit`** 等 CRUD 清单页):与 **商品单位** 同构——页头 **`page-desc`**、筛选 **§5**(`filter-grid` + `filter-actions-row`)、工具栏 **§7.3**(`list-toolbar-row`,已选 **`sel-num`** + **`toolbar-actions`** 左起)、**`list-body`** 顶部分割线、表格与分页 **`list-pager-bar`**;列表操作列 **「详情」主色、编辑/删除中性**(与 §9.8 查看优先一致)。新增/编辑弹窗 **`parameter-dialog`**:**`720px`**、**`label-position="top"`**、**`form-grid-2`**、**`scroll-to-error`** + 校验失败 **`ElMessageBox.alert`**、**`parameter-dialog-mask`** 无模糊遮罩。表单栅格 **尽量双列排布**:名称|编码;**输入类型** 与 **排序** 同一行(类型使用 **`param-type-radio-group`** 允许折行);**可选值**(非文本类型时)仍 **`form-col-span-2`**;**输入提示** 与 **是否必填** 同一行;**状态** 与 **备注**(各一列,备注为双行文本域)同一行。弹窗底栏与 **区域 / 单位 / 商品** 一致:**`dialog-footer`** 内取消为 **`btn-ghost`**、确定为 **`btn-main`** + **`type="primary"`**,**Close / Check** 图标与 **6px** 间距;非 scoped 样式中对 **`.parameter-dialog .el-dialog__footer .el-button`** 统一 **36px 高、8px 圆角、600 字重**,次要按钮白底 **`#d9e8e2`** 描边,主按钮 **`#10b981`** 色链(与 **`product-view-dialog`** 底栏规则对齐)。**详情**弹窗(**`parameter-view-dialog`**):与 **`#/demo/product`** 的 **`ProductViewDialog`** 对齐——**`.el-dialog__body`** 无内边距、**`.parameter-view-dialog .dialog-scroll`** 为 **`padding: 0`**,水平留白由 **`.view-hero`**(**`padding: 20px 22px`**,与商品查看一致)与 **`.view-section`**(**`padding: 18px 22px`**、块间 **`border-bottom: 1px solid #d9e8e2`**、**`:last-child`** 去底边,见 **`product-view-dialog`**)共同承担;**禁止**在本页再写仅含 **`padding: 4px 0 8px`** 的 **`.view-section`**,否则后会覆盖前述规则,导致键值区贴边、观感拥挤。其下 **`view-kv-grid`** 展示完整字段;底栏 **关闭** 使用 **`btn-ghost`** + Close 图标。筛选「状态」与单位页一致(全部为空串,请求时转 `undefined`)。
|
||||
13. **用户管理**(`#/demo/user`):与 **区域 / 单位** 同构——页头 **`page-desc`**、筛选 **§5**(四列 **`filter-grid`**:关键词支持 **Enter** 查询)、工具栏 **§7.3**(**`list-toolbar-row`**、**`sel-num`**、**`toolbar-actions`**;仅 **批量导出**、无导入模板)、**`list-body list-wrap`** + 表头/单元格 **居中**(头像列保留 **§7** 图文列语义)、分页 **`list-pager-bar`**。列表操作列:**「详情」** **`btn-op-item primary`**,**编辑 / 重置密码 / 删除** 为中性 **`btn-op-item`**;操作列 **`min-width`** 需容纳四个按钮。新增/编辑弹窗:**`user-dialog`** **`720px`**、**`modal-class="user-dialog-mask"`**、**`close-on-click-modal="false"`**、**`label-position="top"`** 的 **`user-form`**,分节 **账号信息**(仅新增:用户名|密码)**基本信息**(姓名|手机|角色|所属区域 **`clearable`**)**头像**(**`primary` `plain`** 上传 + **`btn-ghost` 清除**)**状态设置**(仅编辑);**`scroll-to-error`** + 校验失败 **`ElMessageBox.alert`**(与单位页一致)。**详情**弹窗:**`user-dialog user-view-dialog`**,**`body` 无 padding** + **`.dialog-scroll` `padding: 0`**,**`view-hero`**(左侧 **72×72** 圆角头像区 + **`view-hero-main`** 摘要)+ **`view-section`** + **`view-kv-grid`**,状态用 **`status-pill`**(禁用为 **info**,与列表一致);底栏 **关闭** **`btn-ghost`** + Close。**`user-dialog-mask`**:**`backdrop-filter: none`**。
|
||||
14. **订单列表**(`#/demo/order`):页头 **`page-desc`**;**`section.panel`** 内 **`panel-top`**(**`title-ic` + Operation**);筛选 **`filter-block`**(**§5.3**:多行栅格 / **`filter-extra`** / **`filter-actions`** 之间 **无** 横向分割线,仅用间距)对齐 **`fresh-mint-green.html`**:**`filter-hint`** + 常用条件 **`filter-grid order-filter-main-grid`**(桌面 **四列** 一排)+ **`filter-extra`**(**`v-show`**:支付方式、区域)+ **`filter-actions`**(左侧 **`btn-filter-toggle`** 链式按钮:**展开更多条件 / 收起更多条件**,**`aria-expanded` + `aria-controls`**;右侧 **`btn-ghost` / `btn-main`**);重置时 **`filterExpanded=false`** 收起附加条件。关键词 **Enter** 查询。**`list-body list-wrap`** 内主表 **`order-list-table order-list-main-table list-table`**(主表单元格 flex 居中**勿**作用到嵌套表),分页 **`list-pager-bar`**。**行展开子表** 对齐 demo **树形子列表`§8`**:**`expanded-cell` 背景 `#f8fafc`**、**`order-tree-nested-inner`** 左 **`3px #10b981`** + **`order-tree-nested-label`**;子表 **`order-tree-child-table`**:**`border`**、表头底 **`#e2e8f0`**、hover **`#f1f5f9`**,首列(商品)左对齐、数值列 **`is-right`**。订单编号链式按钮 **`order-no-link`**(**`#059669`**)。操作列:**「查看/运营」`btn-op-item primary` + View**,**「分配配送员」`btn-op-item` + Bicycle**,**「审核取消」`btn-op-item op-warn` + DocumentChecked**;**`min-width`** 需容纳多枚操作钮。**订单详情(查看/运营)** **`order-detail-dialog`** + **`modal-class="order-module-dialog-mask"`**:**`el-dialog__body` `padding: 0`**,内容区 **`dialog-scroll order-detail-dialog-scroll`** 内纵向滚动;顶部 **`view-hero order-view-hero`**(**72×72** **`order-view-hero-icon` + ShoppingBag + **`view-hero-main`** 标题 / **`view-hero-tags`** / **`view-hero-meta`**),各 Tab 下区块为 **`view-section`** + **`h3`** + **`view-kv-grid` / `view-kv` / `view-multiline`**(与 **参数详情 / 询价查看** 同构,不再用 **`el-descriptions` border** 作为主信息区);底栏 **`dialog-footer`**,**关闭** **`btn-ghost` + Close**。**`el-dialog__header`** 薄荷渐变、**`el-dialog__footer`** **`#f8fafc`**;主操作 **`btn-main`**,**取消订单** **`type="danger"`**。**分配 / 审核取消 / 取消订单 / 调整 / 价格版本明细** 等辅弹窗 **`order-module-dialog`** + 同款 **`modal-class`** 与 **36px / 8px** 底栏规则(与询价大弹窗同构;无模糊遮罩)。
|
||||
15. **商家结算管理**(历史路由 **`#/order/supplier-settlement`**,页面已裁剪):页头 **`page-desc`**;**`region-tab-strip` + `region-tab-btn`**(与询价区域切换同构);**`el-tabs` `settlement-page-tabs`**(Mint 指示条);各 Tab 内 **`section.panel`**:**`panel-top`**(**`title-ic` + Wallet / Document**)、**`filter-block`**(**三列 `filter-grid`**、**`filter-actions-row`** + **`btn-ghost` / `btn-main`** + Refresh / Search、关键词 **Enter**);**待结算** 下 **`settlement-stats`**(**`stat-card` / `stat-card--accent`**);**`list-toolbar-row` + `toolbar-actions`**;主表 **`settlement-pending-main-table settlement-root-table list-table`**;**结算单记录** 主表 **`settlement-sheets-main-table settlement-root-table list-table`**;分页 **`list-pager-bar`**。**树形多层级**(对齐 **`fresh-mint-green.html` §树形列表**):待结算订单行展开为 **子列表**(**`settlement-tree-inner`** 左 **`3px #10b981`** + **`settlement-tree-label`** + **`settlement-child-table`** **`border`**、表头 **`#e2e8f0`**);结算单记录为 **结算单 → 订单 → 商品** 三层:**L1** **`settlement-tree-l1`** 内嵌 **`settlement-mid-table`**;**L2** **`settlement-tree-l2`**(琥珀左边线 + 浅黄渐变,对应 demo **`tree-nested-deep`**)+ **`settlement-line-table`**(孙表头 **`#fef3c7`** / 字 **`#92400e`**);**`expanded-cell`** 主层 **`#f8fafc`**、订单下商品层 **`#fffbeb`**。操作列:**详情 `btn-op-item primary` + View**,**编辑 `btn-op-item op-warn` + Edit**。业务弹窗 **`order-module-dialog`** + **`order-module-dialog-mask`**;本页另附 **非 scoped** 的 **`order-module-dialog`** 规则,避免未加载订单页 chunk 时弹窗无样式。订单号链式 **`settlement-order-no-link`**。
|
||||
16. **客户结算管理**(历史路由 **`#/order/client-settlement`**,页面已裁剪):与 **商家结算管理** 同构应用 Fresh Mint 壳层与交互(页头 `page-desc`、`region-tab-strip`、`settlement-page-tabs`、`panel`、`filter-block`、`list-toolbar-row`、`list-pager-bar`、`order-module-dialog`)。业务语义改为客户维度(客户筛选、客户结算状态、`client_settlement` 凭证业务类型、同客户约束)。树形多层级同样按 **结算单 → 订单 → 商品** 实现,沿用 **`settlement-tree-inner / settlement-tree-l1 / settlement-tree-l2`** 视觉分层:L1 薄荷左边线 + 灰蓝子表头,L2 琥珀左边线 + 浅黄渐变 + 孙表琥珀表头;主层与深层 expanded cell 分别使用 **`#f8fafc`** 与 **`#fffbeb`**,保证多层树形在客户结算页与标准 demo 统一。
|
||||
17. **配送列表**(`#/delivery/delivery`):改造为 Fresh Mint 列表壳层——页头 **`page-head` + `page-desc`**,主体 **`section.panel`** + **`panel-top`**(`title-ic` + `Van`),筛选区升级为 **`filter-block` + `search-form` + 三列 `filter-grid` + `filter-actions-row`**(`btn-ghost` / `btn-main` + Refresh/Search,关键词支持 Enter 查询),列表与分页收口到 **`list-body list-wrap` + `list-table` + `list-pager-bar`**。配送单展开子表按树形子列表规范对齐:展开层 **`delivery-tree-inner`**(左侧薄荷 3px 线)+ 标题 **`delivery-tree-label`** + 子表滚动容器,expanded cell 背景与边框对齐全局列表规范。操作列统一为 **`btn-op-item primary` + View**;详情弹窗接入 **`order-module-dialog` + `order-module-dialog-mask`**(薄荷渐变头、无遮罩模糊)以保持订单/结算/配送弹窗视觉一致。
|
||||
18. **时效预警**(`#/delivery/delivery-warning`):改造为与配送列表同构的 Fresh Mint 壳层——页头 **`page-head` + `page-desc`**,主体 **`panel` + `panel-top`**(`title-ic` + `Bell`),筛选区统一 **`filter-block` + `search-form` + `filter-grid` + `filter-actions-row`**(`btn-ghost` / `btn-main` + Refresh/Search),列表区统一 **`list-body list-wrap` + `list-table` + `list-pager-bar`**。预警行展开商品明细同样采用树形子列表样式:**`delivery-tree-inner`** 左 3px 薄荷线、**`delivery-tree-label`** 标题、子表滚动容器与 expanded cell 背景边框。操作列统一按钮语义:**查看** `btn-op-item primary + View`,**处理** `btn-op-item op-warn + Finished`。处理弹窗与详情弹窗接入 **`order-module-dialog` + `order-module-dialog-mask`**,与订单/结算/配送页面维持一致的薄荷渐变头和无遮罩模糊策略。
|
||||
19. **系统设置**(`#/system/system-config`):将原单块白底表单改造为 Fresh Mint 配置页结构——页头 **`page-head` + `page-desc`**,配置项拆分为两张 **`settings-panel`**(订单设置 / 客服设置),每块使用 **`panel-top` + `title-ic`**(`Setting` / `PhoneFilled`)与 **`panel-body`**。表单统一为 **`label-position="top"`** 与 Mint 输入态(11px label、8px 圆角、`#d9e8e2` 边),采用 **`form-grid`**(桌面两列、窄屏一列)排版;数字输入配单位说明与 hint 文案。保存区使用统一主按钮 **`btn-main`**(`Check` 图标 + 36px 高 + 薄荷色链),整体与已改造业务页面的卡片、标题、按钮体系一致。
|
||||
20. **接口日志**(`#/system/api-request-log`):改造为 Fresh Mint 列表页模板——页头 **`page-head` + `page-desc`**,主体 **`panel` + `panel-top`**(`title-ic` + `Document`),筛选区统一为 **`filter-block` + `search-form` + 四列 `filter-grid` + `filter-actions-row`**(`btn-ghost` / `btn-main` + Refresh/Search,关键词支持 Enter 查询);日志表格与分页统一到 **`list-body list-wrap` + `list-table` + `list-pager-bar`**。操作列“详情”改为 **`btn-op-item primary + View`**。请求详情弹窗接入 **`order-module-dialog` + `order-module-dialog-mask`**(薄荷渐变头、无遮罩模糊、统一 footer),请求体/响应体保留 `pre` 滚动展示能力。
|
||||
21. **全局壳层回归(2026-04-17)**:针对 `git restore` 导致的样式回退,补齐 `web-admin` Fresh Mint 基线:`index.scss` 重新引入 `fresh-mint-tokens.scss`;`app.scss` 回收全局 token 映射(`--theme-color`、`--default-bg-color`、`--default-box-color`、`--art-card-border`)、统一页头/面板/侧栏骨架;`views/index/style.scss` 恢复内容区 **`24px`** 内边距与「壳层不包二次卡片」策略;`el-ui.scss` 重设 Element 主题变量、弹窗头体脚间距、表格与主按钮 Mint 色链;`#/dashboard/console` 恢复 **page-head + panel/list-body/list-table** 结构与统计卡的 Mint 视觉层级。
|
||||
22. **第二轮批量回归(2026-04-17)**:新增跨页面全局兜底(`app.scss` + `el-ui.scss`)用于统一 **筛选区 / 工具栏 / 列表体 / 分页条 / 表单控件 / 主次按钮 / 行内操作按钮**。约定类(`filter-block`、`filter-grid`、`filter-actions-row`、`list-toolbar-row`、`list-body`、`list-wrap`、`list-pager-bar`、`btn-main`、`btn-ghost`、`btn-op-item`)在 `page-container` 与 `console-container` 下自动应用 Mint 视觉,降低逐页重复样式。同步修正历史弹窗组件 `#/base/user`(`user-dialog`)与 `#/system/menu`(`menu-dialog`)底栏按钮与表单布局,使其对齐 **36px/8px** 按钮与顶部标签表单规范。
|
||||
23. **第三轮业务页收口(2026-04-17)**:针对运营高频页面做残留旧色清理与语义色统一:`#/delivery/delivery`、`#/delivery/delivery-warning`、`#/system/system-config`、`#/order/order` 中遗留的旧角色色(`#1989fa/#07c160/#ff976a/#ee0a24`)已替换为 Fresh Mint 语义色链(主色 `#10b981/#059669`、警告 `#d97706`、危险 `#dc2626`),覆盖 section 左边线、金额强调、Tab 激活态、地址卡片强调边、时间轴节点、风险提示等关键视觉点,避免后台页面出现“蓝/绿/橙/红混搭”的历史风格。
|
||||
21. **全局壳层回归(2026-04-17)**:针对 `git restore` 导致的样式回退,补齐 `web-admin` Fresh Mint 基线:`index.scss` 重新引入 `fresh-mint-tokens.scss`;`app.scss` 回收全局 token 映射(`--theme-color`、`--default-bg-color`、`--default-box-color`、`--art-card-border`)、统一页头/面板/侧栏骨架;`views/index/style.scss` 恢复内容区 **`24px`** 内边距与「壳层不包二次卡片」策略;`el-ui.scss` 重设 Element 主题变量、弹窗头体脚间距、表格与主按钮 Mint 色链;`#/demo/console` 恢复 **page-head + panel/list-body/list-table** 结构与统计卡的 Mint 视觉层级。
|
||||
22. **第二轮批量回归(2026-04-17)**:新增跨页面全局兜底(`app.scss` + `el-ui.scss`)用于统一 **筛选区 / 工具栏 / 列表体 / 分页条 / 表单控件 / 主次按钮 / 行内操作按钮**。约定类(`filter-block`、`filter-grid`、`filter-actions-row`、`list-toolbar-row`、`list-body`、`list-wrap`、`list-pager-bar`、`btn-main`、`btn-ghost`、`btn-op-item`)在 `page-container` 与 `console-container` 下自动应用 Mint 视觉,降低逐页重复样式。同步修正历史弹窗组件 `#/demo/user`(`user-dialog`)与 `#/system/menu`(`menu-dialog`)底栏按钮与表单布局,使其对齐 **36px/8px** 按钮与顶部标签表单规范。
|
||||
23. **第三轮业务页收口(2026-04-17)**:针对运营高频页面做残留旧色清理与语义色统一:`#/delivery/delivery`、`#/delivery/delivery-warning`、`#/system/system-config`、`#/demo/order` 中遗留的旧角色色(`#1989fa/#07c160/#ff976a/#ee0a24`)已替换为 Fresh Mint 语义色链(主色 `#10b981/#059669`、警告 `#d97706`、危险 `#dc2626`),覆盖 section 左边线、金额强调、Tab 激活态、地址卡片强调边、时间轴节点、风险提示等关键视觉点,避免后台页面出现“蓝/绿/橙/红混搭”的历史风格。
|
||||
24. **第四轮一致性细节(2026-04-17)**:补充跨页面细节兜底:`app.scss` 新增 `page-container/console-container` 下通用 `.pagination`(贴表格的分页条样式)与 `.search-bar`(白底卡片化);`el-ui.scss` 新增弹窗 footer 按钮统一规则(**36px 高、8px 圆角、600 字重**,非主按钮中性描边)及 footer 内图标右间距 **6px**,用于收敛历史页面中未显式加 `btn-main/btn-ghost` 的弹窗底栏视觉差异。
|
||||
25. **第五轮验收清单(2026-04-17)**:对 `web-admin/src/views/system` 全量页执行旧色值巡检并完成批量替换,遗留角色色 `#1989fa/#07c160/#ff976a/#ee0a24` 已在 system 目录清零;重点修正页面包括 `price-list`、`client/supplier-settlement`、`category`、`order-adjust`、`quotation`、`user/detail`、`statistics`、`quotation/compare`、`price-list/version`、`refund`、`inquiry-quotation`、`invoice`、`settlement`、`product`、`inquiry`。同时补齐角色管理弹窗 (`role-edit-dialog`、`role-permission-dialog`) footer 按钮语义类(`btn-ghost` / `btn-main`),与全局弹窗底栏规范保持一致。
|
||||
26. **第六轮结构统一(进行中,2026-04-17)**:开始将“视觉已统一但结构仍旧”的页面改为标准模板。首批完成 `#/system/review`、`#/system/settlement`、`#/system/invoice`:统一为 **`page-head + panel + filter-block/filter-grid/filter-actions-row + list-body/list-wrap + list-pager-bar`**,并将操作列按钮改为 **`btn-op-item`** 体系、弹窗 footer 改为 **`btn-ghost / btn-main`** 语义按钮,减少历史 `search-bar + inline form + link 按钮` 结构差异。
|
||||
@@ -645,15 +647,15 @@
|
||||
33. **第六轮结构统一(第八批,2026-04-17)**:继续按 `#/delivery/delivery-demo` 模板推进 `#/system/api-request-log` 与 `#/system/user`:统一为 `page-head + panel + panel-top + filter-block + filter-grid + filter-actions-row + list-toolbar + list-body/list-wrap + list-pager-bar`;操作列链接按钮改为 `btn-op-item`;新增/编辑弹窗 footer 统一 `btn-ghost / btn-main`,并清理页面内 `search-bar/toolbar/pagination` 的重复 scoped 壳层样式。
|
||||
34. **第六轮结构统一(第九批,2026-04-17)**:完成 `#/system/statistics` 的旧 `filter-bar/card` 体系替换,整体对齐 `#/delivery/delivery-demo`:新增 `page-head` 与 `panel` 分段结构,筛选区切为 `filter-block + filter-grid + filter-actions-row`(补齐“搜索/重置”动作),运营概览改为统一统计卡网格(`stats-grid`),图表与明细改为 `panel + panel-top + list-body/list-wrap + list-table` 组合,显著降低该页与其他业务页的视觉割裂感。
|
||||
35. **第六轮结构统一(第十批,2026-04-17)**:推进 `#/system/product`、`#/system/inquiry`、`#/system/supplier-settlement`、`#/system/client-settlement` 对齐 `#/delivery/delivery-demo`。本批聚焦“高流量复杂页”的外层一致性:统一页头说明(`page-head/page-desc`)、筛选动作文案(“搜索/重置”)、主次按钮语义类(`btn-main/btn-ghost`)、列表操作按钮(`btn-op-item`)、分页条(`list-pager-bar`)与弹窗 footer 按钮语义。`product` 与 `inquiry` 同步补齐 `panel/panel-top/list-toolbar` 骨架;`supplier/client-settlement` 保持双 Tab 与树形明细业务结构不变,仅做 Mint 交互壳层与动作区收口,确保改造不触及结算逻辑。
|
||||
36. **商品分类对齐区域管理流程(2026-04-17)**:`#/base/category` 参考 `#/system/region` 的改造方法完成收口:列表工具栏补齐 **`list-toolbar-selected + sel-num`**,并保持“首按钮主色、其余中性描边”(含批量导入);表格列头/列值统一居中对齐,状态标签语义统一为 **启用=success、禁用=info**;操作列去除 link 形态,统一 **`btn-op-item`** 体系;新增/编辑弹窗统一 **`label-position="top"`** 与两列排布,footer 统一 **`btn-ghost/btn-main + Close/Check`** 图标,确保与区域管理及全局 Fresh Mint 规范一致。
|
||||
37. **商品单位按同构流程收口(2026-04-17)**:`#/base/unit` 参照 `#/system/region` 与 `#/base/category` 的改造路径统一壳层与交互细节:工具栏增加 **`list-toolbar-selected + sel-num`**,并保持“首按钮主色、其余中性描边”(含批量导入);状态标签语义统一为 **启用=success、禁用=info**;操作列固定右侧并预留宽度(≥260);新增/编辑弹窗改为 **`label-position="top"`** 两列布局,footer 统一 **`btn-ghost/btn-main + Close/Check`** 图标规范。
|
||||
38. **商品管理同构改造(2026-04-17)**:`#/base/product` 对齐 `fresh-mint-green` 与区域/分类/单位页面规范:页头改为 **`page-head + h1 + page-desc`**;筛选区改为 **`filter-form + filter-grid + filter-actions-row`**(非 inline);列表工具栏合并为单行 **`list-toolbar-row`**(含 **`list-toolbar-selected + sel-num`** 与主次按钮);表格列头/列值统一居中,状态标签语义为 **启用=success、禁用=info**;操作列改为 **`btn-op-item`**(去 link 形态)并固定右侧预留宽度;新增/编辑与选择参数弹窗 footer 统一 **`btn-ghost/btn-main + Close/Check`**,参数选择表格去 `border` 并改为 Mint 容器边框与透明检索栏。
|
||||
36. **商品分类对齐区域管理流程(2026-04-17)**:`#/demo/category` 参考 `#/system/region` 的改造方法完成收口:列表工具栏补齐 **`list-toolbar-selected + sel-num`**,并保持“首按钮主色、其余中性描边”(含批量导入);表格列头/列值统一居中对齐,状态标签语义统一为 **启用=success、禁用=info**;操作列去除 link 形态,统一 **`btn-op-item`** 体系;新增/编辑弹窗统一 **`label-position="top"`** 与两列排布,footer 统一 **`btn-ghost/btn-main + Close/Check`** 图标,确保与区域管理及全局 Fresh Mint 规范一致。
|
||||
37. **商品单位按同构流程收口(2026-04-17)**:`#/demo/unit` 参照 `#/system/region` 与 `#/demo/category` 的改造路径统一壳层与交互细节:工具栏增加 **`list-toolbar-selected + sel-num`**,并保持“首按钮主色、其余中性描边”(含批量导入);状态标签语义统一为 **启用=success、禁用=info**;操作列固定右侧并预留宽度(≥260);新增/编辑弹窗改为 **`label-position="top"`** 两列布局,footer 统一 **`btn-ghost/btn-main + Close/Check`** 图标规范。
|
||||
38. **商品管理同构改造(2026-04-17)**:`#/demo/product` 对齐 `fresh-mint-green` 与区域/分类/单位页面规范:页头改为 **`page-head + h1 + page-desc`**;筛选区改为 **`filter-form + filter-grid + filter-actions-row`**(非 inline);列表工具栏合并为单行 **`list-toolbar-row`**(含 **`list-toolbar-selected + sel-num`** 与主次按钮);表格列头/列值统一居中,状态标签语义为 **启用=success、禁用=info**;操作列改为 **`btn-op-item`**(去 link 形态)并固定右侧预留宽度;新增/编辑与选择参数弹窗 footer 统一 **`btn-ghost/btn-main + Close/Check`**,参数选择表格去 `border` 并改为 Mint 容器边框与透明检索栏。
|
||||
39. **树形字段统一路径展示(全局,2026-04-17)**:凡树形字段(商品分类等)统一执行“**下拉展示本级、选中回显全路径、列表展示全路径**”规则:`el-tree-select` 展开态节点文案保留本级名称;选中后输入框通过 `#label` 插槽显示 `根 / 子 / 孙` 全路径;表格/卡片中的分类字段同样优先展示全路径(缺路径时回退接口字段)。实现统一复用 `web-admin/src/utils/tree-path.ts`(`buildTreePathLabelFromFlat`、`findTreeSelectShortLabel`),避免各页面重复拼接逻辑。
|
||||
40. **询价报价管理二轮收口(2026-04-17)**:`#/business/inquiry-quotation` 按 Fresh Mint 模板完成结构升级:补齐 `page-head + panel + panel-top`,区域切换统一为 `region-tab-strip/region-tab-btn`;询价与历史报价列表改为 `list-body/list-wrap + list-table` 组合;历史报价筛选改为 `filter-block + history-filter-grid + filter-actions-row`(重置/搜索按钮采用 `btn-ghost/btn-main` + 图标);操作列从 link 统一为 `btn-op-item`(首动作主色),并将历史列表分页条对齐为 `list-pager-bar`;关键弹窗 footer 同步 `btn-ghost/btn-main + Close/Check`,保持与已改造基础数据页同一视觉语言。
|
||||
41. **询价报价管理二轮精修(2026-04-17)**:继续对 `#/business/inquiry-quotation` 弹窗与内嵌选品区细节收口:`新增/编辑询价`、`询价详情`、`报价明细`、`调价确认` 统一接入 `inquiry-quotation-dialog` + `inquiry-quotation-dialog-mask`(header 薄荷渐变、footer 浅底、无遮罩模糊);弹窗 footer 按全站规范使用 `btn-ghost/btn-main` 与 `Close/Check` 图标;选品区(左树/右列表/已选区/卡片选中态)统一替换旧灰蓝边框为 Mint 语义边框与主色阴影;历史报价操作列保持固定右侧并使用 `btn-op-item primary`,补齐“弹窗内按钮层级、间距密度、操作列语义”与基础数据页一致性。
|
||||
42. **工作台快捷菜单(2026-04-18)**:曾实现 **`shortcuts-grid` + `shortcut-tile`**(`ArtSvgIcon` + **`meta.icon`**);**(2026-04-18 后续)已移除**——`#/dashboard/console` **不再展示**独立快捷菜单区,常用入口以侧栏导航为准;**§4.2** 工作台描述已同步。
|
||||
43. **工作台近30日折线(2026-04-18)**:`#/dashboard/console` 在数据概览下增加 **`trend-charts-panel`**:双 **`ArtLineChart`**(营业额元 / 订单数笔),接口 **`fetchOrderTrend` → `/web/admin/statistics/orders/trend?days=30`**(复用 **`statistics_crud.get_order_trend`**);**§4.2** 已补充。
|
||||
44. **工作台今日各区域柱状(2026-04-18)**:`#/dashboard/console` 在折线图下增加 **`region-today-panel`**:**`RegionTodayDualBarChart`** 分组柱 + 双 **Y** 轴(左营业额元、右订单笔),接口 **`fetchTodayOrdersByRegion` → `/web/admin/statistics/orders/today-by-region`**(**`statistics_crud.get_today_order_stats_by_region`**:启用区域全量,无单为 0);**§4.2**、实现清单 **§7** 已同步。
|
||||
40. **询价报价管理二轮收口(2026-04-17)**(历史页 **`#/business/inquiry-quotation`**):补齐 `page-head + panel + panel-top`,区域切换统一为 `region-tab-strip/region-tab-btn`;询价与历史报价列表改为 `list-body/list-wrap + list-table` 组合;历史报价筛选改为 `filter-block + history-filter-grid + filter-actions-row`(重置/搜索按钮采用 `btn-ghost/btn-main` + 图标);操作列从 link 统一为 `btn-op-item`(首动作主色),并将历史列表分页条对齐为 `list-pager-bar`;关键弹窗 footer 同步 `btn-ghost/btn-main + Close/Check`,保持与已改造基础数据页同一视觉语言。
|
||||
41. **询价报价管理二轮精修(2026-04-17)**(历史页 **`#/business/inquiry-quotation`**):继续对 **`新增/编辑询价`**、**`询价详情`**、**`报价明细`**、**`调价确认`** 统一接入 `inquiry-quotation-dialog` + `inquiry-quotation-dialog-mask`(header 薄荷渐变、footer 浅底、无遮罩模糊);弹窗 footer 按全站规范使用 `btn-ghost/btn-main` 与 `Close/Check` 图标;选品区(左树/右列表/已选区/卡片选中态)统一替换旧灰蓝边框为 Mint 语义边框与主色阴影;历史报价操作列保持固定右侧并使用 `btn-op-item primary`,补齐“弹窗内按钮层级、间距密度、操作列语义”与基础数据页一致性。
|
||||
42. **工作台快捷菜单(2026-04-18)**:曾实现 **`shortcuts-grid` + `shortcut-tile`**(`ArtSvgIcon` + **`meta.icon`**);**(2026-04-18 后续)已移除**——`#/demo/console` **不再展示**独立快捷菜单区,常用入口以侧栏导航为准;**§4.2** 工作台描述已同步。
|
||||
43. **工作台近30日折线(2026-04-18)**:`#/demo/console` 在数据概览下增加 **`trend-charts-panel`**:双 **`ArtLineChart`**(营业额元 / 订单数笔),接口 **`fetchOrderTrend` → `/web/admin/statistics/orders/trend?days=30`**(复用 **`statistics_crud.get_order_trend`**);**§4.2** 已补充。
|
||||
44. **工作台今日各区域柱状(2026-04-18)**:`#/demo/console` 在折线图下增加 **`region-today-panel`**:**`RegionTodayDualBarChart`** 分组柱 + 双 **Y** 轴(左营业额元、右订单笔),接口 **`fetchTodayOrdersByRegion` → `/web/admin/statistics/orders/today-by-region`**(**`statistics_crud.get_today_order_stats_by_region`**:启用区域全量,无单为 0);**§4.2**、实现清单 **§7** 已同步。
|
||||
45. **工作台今日客户柱状与标题竖条(2026-04-18)**:**`region-today-split`** 右列「今日下单客户」:**`fetchTodayOrdersByClient` → `/web/admin/statistics/orders/today-by-client`**(**`get_today_order_stats_by_client`**:今日下单金额合计 + **`biz_order_item.quantity`** 合计,金额降序至多 30 条);**`RegionTodayDualBarChart`** 支持系列名/空文案等 props;**`App.vue`** **`panelTitleBarTextRules`** 增加 **近30日趋势**、**今日各区域** 以使用 **`title-bar-heading`** 绿色竖条。
|
||||
|
||||
---
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>中科兴皖一体化门户 - 管理后台</title>
|
||||
<title>陇耘鲜达 - 运营管理后台</title>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
content="中科兴皖一体化门户管理后台"
|
||||
content="陇耘鲜达运营管理后台 - 生鲜配送系统"
|
||||
/>
|
||||
<link rel="icon" type="image/x-icon" href="/logo1.jpg" />
|
||||
<link rel="icon" type="image/png" href="/logo1.jpg" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "zkxw-web-admin",
|
||||
"name": "sanqiang-web-admin",
|
||||
"version": "1.0.0",
|
||||
"description": "中科兴皖一体化门户管理后台",
|
||||
"description": "陇耘鲜达运营管理后台",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20.19.0",
|
||||
|
||||
+53
-795
@@ -1,627 +1,44 @@
|
||||
// scripts/clean-dev.ts
|
||||
/**
|
||||
* 可选本地维护脚本(`pnpm run clean:dev`)
|
||||
*
|
||||
* 说明:历史上本脚本会批量删除模板演示资源并重写 `router/modules`、README 等,
|
||||
* 与当前 zkxw 已裁剪的 `web-admin` 冲突。**已移除所有破坏性步骤**。
|
||||
*
|
||||
* 现仅支持:在确认后清空 `src/mock/upgrade/changeLog.ts` 中的演示升级记录(可选)。
|
||||
*/
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
|
||||
// 现代化颜色主题
|
||||
const theme = {
|
||||
// 基础颜色
|
||||
reset: '\x1b[0m',
|
||||
bold: '\x1b[1m',
|
||||
dim: '\x1b[2m',
|
||||
|
||||
// 前景色
|
||||
primary: '\x1b[38;5;75m', // 亮蓝色
|
||||
success: '\x1b[38;5;82m', // 亮绿色
|
||||
warning: '\x1b[38;5;220m', // 亮黄色
|
||||
error: '\x1b[38;5;196m', // 亮红色
|
||||
info: '\x1b[38;5;159m', // 青色
|
||||
purple: '\x1b[38;5;141m', // 紫色
|
||||
orange: '\x1b[38;5;208m', // 橙色
|
||||
gray: '\x1b[38;5;245m', // 灰色
|
||||
white: '\x1b[38;5;255m', // 白色
|
||||
|
||||
// 背景色
|
||||
bgDark: '\x1b[48;5;235m', // 深灰背景
|
||||
bgBlue: '\x1b[48;5;24m', // 蓝色背景
|
||||
bgGreen: '\x1b[48;5;22m', // 绿色背景
|
||||
bgRed: '\x1b[48;5;52m' // 红色背景
|
||||
primary: '\x1b[38;5;75m',
|
||||
success: '\x1b[38;5;82m',
|
||||
warning: '\x1b[38;5;220m',
|
||||
error: '\x1b[38;5;196m',
|
||||
gray: '\x1b[38;5;245m',
|
||||
white: '\x1b[38;5;255m'
|
||||
}
|
||||
|
||||
// 现代化图标集
|
||||
const icons = {
|
||||
rocket: '🚀',
|
||||
fire: '🔥',
|
||||
star: '⭐',
|
||||
gem: '💎',
|
||||
crown: '👑',
|
||||
magic: '✨',
|
||||
warning: '⚠️',
|
||||
success: '✅',
|
||||
error: '❌',
|
||||
info: 'ℹ️',
|
||||
folder: '📁',
|
||||
file: '📄',
|
||||
image: '🖼️',
|
||||
code: '💻',
|
||||
data: '📊',
|
||||
globe: '🌐',
|
||||
map: '🗺️',
|
||||
chat: '💬',
|
||||
bolt: '⚡',
|
||||
shield: '🛡️',
|
||||
key: '🔑',
|
||||
link: '🔗',
|
||||
clean: '🧹',
|
||||
trash: '🗑️',
|
||||
check: '✓',
|
||||
cross: '✗',
|
||||
arrow: '→',
|
||||
loading: '⏳'
|
||||
}
|
||||
|
||||
// 格式化工具
|
||||
const fmt = {
|
||||
title: (text: string) => `${theme.bold}${theme.primary}${text}${theme.reset}`,
|
||||
subtitle: (text: string) => `${theme.purple}${text}${theme.reset}`,
|
||||
success: (text: string) => `${theme.success}${text}${theme.reset}`,
|
||||
error: (text: string) => `${theme.error}${text}${theme.reset}`,
|
||||
warning: (text: string) => `${theme.warning}${text}${theme.reset}`,
|
||||
info: (text: string) => `${theme.info}${text}${theme.reset}`,
|
||||
highlight: (text: string) => `${theme.bold}${theme.white}${text}${theme.reset}`,
|
||||
dim: (text: string) => `${theme.dim}${theme.gray}${text}${theme.reset}`,
|
||||
orange: (text: string) => `${theme.orange}${text}${theme.reset}`,
|
||||
|
||||
// 带背景的文本
|
||||
badge: (text: string, bg: string = theme.bgBlue) =>
|
||||
`${bg}${theme.white}${theme.bold} ${text} ${theme.reset}`,
|
||||
|
||||
// 渐变效果模拟
|
||||
gradient: (text: string) => {
|
||||
const colors = ['\x1b[38;5;75m', '\x1b[38;5;81m', '\x1b[38;5;87m', '\x1b[38;5;159m']
|
||||
const chars = text.split('')
|
||||
return chars.map((char, i) => `${colors[i % colors.length]}${char}`).join('') + theme.reset
|
||||
}
|
||||
title: (t: string) => `${theme.bold}${theme.primary}${t}${theme.reset}`,
|
||||
success: (t: string) => `${theme.success}${t}${theme.reset}`,
|
||||
error: (t: string) => `${theme.error}${t}${theme.reset}`,
|
||||
warning: (t: string) => `${theme.warning}${t}${theme.reset}`,
|
||||
highlight: (t: string) => `${theme.bold}${theme.white}${t}${theme.reset}`,
|
||||
dim: (t: string) => `${theme.dim}${theme.gray}${t}${theme.reset}`
|
||||
}
|
||||
|
||||
// 创建现代化标题横幅
|
||||
function createModernBanner() {
|
||||
console.log()
|
||||
console.log(
|
||||
fmt.gradient(' ╔══════════════════════════════════════════════════════════════════╗')
|
||||
)
|
||||
console.log(
|
||||
fmt.gradient(' ║ ║')
|
||||
)
|
||||
console.log(
|
||||
` ║ ${icons.rocket} ${fmt.title('ART DESIGN PRO')} ${fmt.subtitle('· 代码精简程序')} ${icons.magic} ║`
|
||||
)
|
||||
console.log(
|
||||
` ║ ${fmt.dim('为项目移除演示数据,快速切换至开发模式')} ║`
|
||||
)
|
||||
console.log(
|
||||
fmt.gradient(' ║ ║')
|
||||
)
|
||||
console.log(
|
||||
fmt.gradient(' ╚══════════════════════════════════════════════════════════════════╝')
|
||||
)
|
||||
console.log()
|
||||
}
|
||||
|
||||
// 创建分割线
|
||||
function createDivider(char = '─', color = theme.primary) {
|
||||
console.log(`${color}${' ' + char.repeat(66)}${theme.reset}`)
|
||||
}
|
||||
|
||||
// 创建卡片样式容器
|
||||
function createCard(title: string, content: string[]) {
|
||||
console.log(` ${fmt.badge('', theme.bgBlue)} ${fmt.title(title)}`)
|
||||
console.log()
|
||||
content.forEach((line) => {
|
||||
console.log(` ${line}`)
|
||||
})
|
||||
console.log()
|
||||
}
|
||||
|
||||
// 进度条动画
|
||||
function createProgressBar(current: number, total: number, text: string, width = 40) {
|
||||
const percentage = Math.round((current / total) * 100)
|
||||
const filled = Math.round((current / total) * width)
|
||||
const empty = width - filled
|
||||
|
||||
const filledBar = '█'.repeat(filled)
|
||||
const emptyBar = '░'.repeat(empty)
|
||||
|
||||
process.stdout.write(
|
||||
`\r ${fmt.info('进度')} [${theme.success}${filledBar}${theme.gray}${emptyBar}${theme.reset}] ${fmt.highlight(percentage + '%')})}`
|
||||
)
|
||||
|
||||
if (current === total) {
|
||||
console.log()
|
||||
}
|
||||
}
|
||||
|
||||
// 统计信息
|
||||
const stats = {
|
||||
deletedFiles: 0,
|
||||
deletedPaths: 0,
|
||||
failedPaths: 0,
|
||||
startTime: Date.now(),
|
||||
totalFiles: 0
|
||||
}
|
||||
|
||||
// 清理目标
|
||||
const targets = [
|
||||
'README.md',
|
||||
'README.zh-CN.md',
|
||||
'CHANGELOG.md',
|
||||
'CHANGELOG.zh-CN.md',
|
||||
'src/views/change',
|
||||
'src/views/safeguard',
|
||||
'src/views/article',
|
||||
'src/views/examples',
|
||||
'src/views/system/nested',
|
||||
'src/views/widgets',
|
||||
'src/views/template',
|
||||
'src/views/dashboard/analysis',
|
||||
'src/views/dashboard/ecommerce',
|
||||
'src/mock/json',
|
||||
'src/mock/temp/articleList.ts',
|
||||
'src/mock/temp/commentDetail.ts',
|
||||
'src/mock/temp/commentList.ts',
|
||||
'src/assets/images/cover',
|
||||
'src/assets/images/safeguard',
|
||||
'src/assets/images/3d',
|
||||
'src/components/core/charts/art-map-chart',
|
||||
'src/components/business/comment-widget'
|
||||
]
|
||||
|
||||
// 递归统计文件数量
|
||||
async function countFiles(targetPath: string): Promise<number> {
|
||||
const fullPath = path.resolve(process.cwd(), targetPath)
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(fullPath)
|
||||
|
||||
if (stat.isFile()) {
|
||||
return 1
|
||||
} else if (stat.isDirectory()) {
|
||||
const entries = await fs.readdir(fullPath)
|
||||
let count = 0
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(targetPath, entry)
|
||||
count += await countFiles(entryPath)
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// 统计所有目标的文件数量
|
||||
async function countAllFiles(): Promise<number> {
|
||||
let totalCount = 0
|
||||
|
||||
for (const target of targets) {
|
||||
const count = await countFiles(target)
|
||||
totalCount += count
|
||||
}
|
||||
|
||||
return totalCount
|
||||
}
|
||||
|
||||
// 删除文件和目录
|
||||
async function remove(targetPath: string, index: number) {
|
||||
const fullPath = path.resolve(process.cwd(), targetPath)
|
||||
|
||||
createProgressBar(index + 1, targets.length, targetPath)
|
||||
|
||||
try {
|
||||
const fileCount = await countFiles(targetPath)
|
||||
await fs.rm(fullPath, { recursive: true, force: true })
|
||||
stats.deletedFiles += fileCount
|
||||
stats.deletedPaths++
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
} catch (err) {
|
||||
stats.failedPaths++
|
||||
console.log()
|
||||
console.log(` ${icons.error} ${fmt.error('删除失败')}: ${fmt.highlight(targetPath)}`)
|
||||
console.log(` ${fmt.dim('错误详情: ' + err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 清理路由模块
|
||||
async function cleanRouteModules() {
|
||||
const modulesPath = path.resolve(process.cwd(), 'src/router/modules')
|
||||
|
||||
try {
|
||||
// 删除演示相关的路由模块
|
||||
const modulesToRemove = [
|
||||
'template.ts',
|
||||
'widgets.ts',
|
||||
'examples.ts',
|
||||
'article.ts',
|
||||
'safeguard.ts',
|
||||
'help.ts'
|
||||
]
|
||||
|
||||
for (const module of modulesToRemove) {
|
||||
const modulePath = path.join(modulesPath, module)
|
||||
try {
|
||||
await fs.rm(modulePath, { force: true })
|
||||
} catch {
|
||||
// 文件不存在时忽略错误
|
||||
}
|
||||
}
|
||||
|
||||
// 重写 dashboard.ts - 只保留 console
|
||||
const dashboardContent = `import { AppRouteRecord } from '@/types/router'
|
||||
|
||||
export const dashboardRoutes: AppRouteRecord = {
|
||||
name: 'Dashboard',
|
||||
path: '/dashboard',
|
||||
component: '/index/index',
|
||||
meta: {
|
||||
title: 'menus.dashboard.title',
|
||||
icon: 'ri:pie-chart-line',
|
||||
roles: ['R_SUPER', 'R_ADMIN']
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'console',
|
||||
name: 'Console',
|
||||
component: '/dashboard/console',
|
||||
meta: {
|
||||
title: 'menus.dashboard.console',
|
||||
keepAlive: false,
|
||||
fixedTab: true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
`
|
||||
await fs.writeFile(path.join(modulesPath, 'dashboard.ts'), dashboardContent, 'utf-8')
|
||||
|
||||
// 重写 system.ts - 移除 nested 嵌套菜单
|
||||
const systemContent = `import { AppRouteRecord } from '@/types/router'
|
||||
|
||||
export const systemRoutes: AppRouteRecord = {
|
||||
path: '/system',
|
||||
name: 'System',
|
||||
component: '/index/index',
|
||||
meta: {
|
||||
title: 'menus.system.title',
|
||||
icon: 'ri:user-3-line',
|
||||
roles: ['R_SUPER', 'R_ADMIN']
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'user',
|
||||
name: 'User',
|
||||
component: '/system/user',
|
||||
meta: {
|
||||
title: 'menus.system.user',
|
||||
keepAlive: true,
|
||||
roles: ['R_SUPER', 'R_ADMIN']
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'role',
|
||||
name: 'Role',
|
||||
component: '/system/role',
|
||||
meta: {
|
||||
title: 'menus.system.role',
|
||||
keepAlive: true,
|
||||
roles: ['R_SUPER']
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'user-center',
|
||||
name: 'UserCenter',
|
||||
component: '/system/user-center',
|
||||
meta: {
|
||||
title: 'menus.system.userCenter',
|
||||
isHide: true,
|
||||
keepAlive: true,
|
||||
isHideTab: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'menu',
|
||||
name: 'Menus',
|
||||
component: '/system/menu',
|
||||
meta: {
|
||||
title: 'menus.system.menu',
|
||||
keepAlive: true,
|
||||
roles: ['R_SUPER'],
|
||||
authList: [
|
||||
{ title: '新增', authMark: 'add' },
|
||||
{ title: '编辑', authMark: 'edit' },
|
||||
{ title: '删除', authMark: 'delete' }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
`
|
||||
await fs.writeFile(path.join(modulesPath, 'system.ts'), systemContent, 'utf-8')
|
||||
|
||||
// 重写 index.ts - 只导入保留的模块
|
||||
const indexContent = `import { AppRouteRecord } from '@/types/router'
|
||||
import { dashboardRoutes } from './dashboard'
|
||||
import { systemRoutes } from './system'
|
||||
import { resultRoutes } from './result'
|
||||
import { exceptionRoutes } from './exception'
|
||||
|
||||
/**
|
||||
* 导出所有模块化路由
|
||||
*/
|
||||
export const routeModules: AppRouteRecord[] = [
|
||||
dashboardRoutes,
|
||||
systemRoutes,
|
||||
resultRoutes,
|
||||
exceptionRoutes
|
||||
]
|
||||
`
|
||||
await fs.writeFile(path.join(modulesPath, 'index.ts'), indexContent, 'utf-8')
|
||||
|
||||
console.log(` ${icons.success} ${fmt.success('清理路由模块完成')}`)
|
||||
} catch (err) {
|
||||
console.log(` ${icons.error} ${fmt.error('清理路由模块失败')}`)
|
||||
console.log(` ${fmt.dim('错误详情: ' + err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 清理路由别名
|
||||
async function cleanRoutesAlias() {
|
||||
const routesAliasPath = path.resolve(process.cwd(), 'src/router/routesAlias.ts')
|
||||
|
||||
try {
|
||||
const cleanedAlias = `/**
|
||||
* 公共路由别名
|
||||
# 存放系统级公共路由路径,如布局容器、登录页等
|
||||
*/
|
||||
export enum RoutesAlias {
|
||||
Layout = '/index/index', // 布局容器
|
||||
Login = '/auth/login' // 登录页
|
||||
}
|
||||
`
|
||||
|
||||
await fs.writeFile(routesAliasPath, cleanedAlias, 'utf-8')
|
||||
console.log(` ${icons.success} ${fmt.success('重写路由别名配置完成')}`)
|
||||
} catch (err) {
|
||||
console.log(` ${icons.error} ${fmt.error('清理路由别名失败')}`)
|
||||
console.log(` ${fmt.dim('错误详情: ' + err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 清理变更日志
|
||||
async function cleanChangeLog() {
|
||||
const changeLogPath = path.resolve(process.cwd(), 'src/mock/upgrade/changeLog.ts')
|
||||
|
||||
try {
|
||||
const cleanedChangeLog = `import { ref } from 'vue'
|
||||
|
||||
interface UpgradeLog {
|
||||
version: string // 版本号
|
||||
title: string // 更新标题
|
||||
date: string // 更新日期
|
||||
detail?: string[] // 更新内容
|
||||
requireReLogin?: boolean // 是否需要重新登录
|
||||
remark?: string // 备注
|
||||
}
|
||||
|
||||
export const upgradeLogList = ref<UpgradeLog[]>([])
|
||||
`
|
||||
|
||||
await fs.writeFile(changeLogPath, cleanedChangeLog, 'utf-8')
|
||||
console.log(` ${icons.success} ${fmt.success('清空变更日志数据完成')}`)
|
||||
} catch (err) {
|
||||
console.log(` ${icons.error} ${fmt.error('清理变更日志失败')}`)
|
||||
console.log(` ${fmt.dim('错误详情: ' + err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 清理语言文件
|
||||
async function cleanLanguageFiles() {
|
||||
const languageFiles = [
|
||||
{ path: 'src/locales/langs/zh.json', name: '中文语言文件' },
|
||||
{ path: 'src/locales/langs/en.json', name: '英文语言文件' }
|
||||
]
|
||||
|
||||
for (const { path: langPath, name } of languageFiles) {
|
||||
try {
|
||||
const fullPath = path.resolve(process.cwd(), langPath)
|
||||
const content = await fs.readFile(fullPath, 'utf-8')
|
||||
const langData = JSON.parse(content)
|
||||
|
||||
const menusToRemove = [
|
||||
'widgets',
|
||||
'template',
|
||||
'article',
|
||||
'examples',
|
||||
'safeguard',
|
||||
'plan',
|
||||
'help'
|
||||
]
|
||||
|
||||
if (langData.menus) {
|
||||
menusToRemove.forEach((menuKey) => {
|
||||
if (langData.menus[menuKey]) {
|
||||
delete langData.menus[menuKey]
|
||||
}
|
||||
})
|
||||
|
||||
if (langData.menus.dashboard) {
|
||||
if (langData.menus.dashboard.analysis) {
|
||||
delete langData.menus.dashboard.analysis
|
||||
}
|
||||
if (langData.menus.dashboard.ecommerce) {
|
||||
delete langData.menus.dashboard.ecommerce
|
||||
}
|
||||
}
|
||||
|
||||
if (langData.menus.system) {
|
||||
const systemKeysToRemove = [
|
||||
'nested',
|
||||
'menu1',
|
||||
'menu2',
|
||||
'menu21',
|
||||
'menu3',
|
||||
'menu31',
|
||||
'menu32',
|
||||
'menu321'
|
||||
]
|
||||
systemKeysToRemove.forEach((key) => {
|
||||
if (langData.menus.system[key]) {
|
||||
delete langData.menus.system[key]
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await fs.writeFile(fullPath, JSON.stringify(langData, null, 2), 'utf-8')
|
||||
console.log(` ${icons.success} ${fmt.success(`清理${name}完成`)}`)
|
||||
} catch (err) {
|
||||
console.log(` ${icons.error} ${fmt.error(`清理${name}失败`)}`)
|
||||
console.log(` ${fmt.dim('错误详情: ' + err)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 清理快速入口组件
|
||||
async function cleanFastEnterComponent() {
|
||||
const fastEnterPath = path.resolve(process.cwd(), 'src/config/fastEnter.ts')
|
||||
|
||||
try {
|
||||
const cleanedFastEnter = `/**
|
||||
* 快速入口配置
|
||||
* 包含:应用列表、快速链接等配置
|
||||
*/
|
||||
import { WEB_LINKS } from '@/utils/constants'
|
||||
import type { FastEnterConfig } from '@/types/config'
|
||||
|
||||
const fastEnterConfig: FastEnterConfig = {
|
||||
// 显示条件(屏幕宽度)
|
||||
minWidth: 1200,
|
||||
// 应用列表
|
||||
applications: [
|
||||
{
|
||||
name: '工作台',
|
||||
description: '系统概览与数据统计',
|
||||
icon: 'ri:pie-chart-line',
|
||||
iconColor: '#377dff',
|
||||
enabled: true,
|
||||
order: 1,
|
||||
routeName: 'Console'
|
||||
},
|
||||
{
|
||||
name: '官方文档',
|
||||
description: '使用指南与开发文档',
|
||||
icon: 'ri:bill-line',
|
||||
iconColor: '#ffb100',
|
||||
enabled: true,
|
||||
order: 2,
|
||||
link: WEB_LINKS.DOCS
|
||||
},
|
||||
{
|
||||
name: '技术支持',
|
||||
description: '技术支持与问题反馈',
|
||||
icon: 'ri:user-location-line',
|
||||
iconColor: '#ff6b6b',
|
||||
enabled: true,
|
||||
order: 3,
|
||||
link: WEB_LINKS.COMMUNITY
|
||||
},
|
||||
{
|
||||
name: '哔哩哔哩',
|
||||
description: '技术分享与交流',
|
||||
icon: 'ri:bilibili-line',
|
||||
iconColor: '#FB7299',
|
||||
enabled: true,
|
||||
order: 4,
|
||||
link: WEB_LINKS.BILIBILI
|
||||
}
|
||||
],
|
||||
// 快速链接
|
||||
quickLinks: [
|
||||
{
|
||||
name: '登录',
|
||||
enabled: true,
|
||||
order: 1,
|
||||
routeName: 'Login'
|
||||
},
|
||||
{
|
||||
name: '注册',
|
||||
enabled: true,
|
||||
order: 2,
|
||||
routeName: 'Register'
|
||||
},
|
||||
{
|
||||
name: '忘记密码',
|
||||
enabled: true,
|
||||
order: 3,
|
||||
routeName: 'ForgetPassword'
|
||||
},
|
||||
{
|
||||
name: '个人中心',
|
||||
enabled: true,
|
||||
order: 4,
|
||||
routeName: 'UserCenter'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export default Object.freeze(fastEnterConfig)
|
||||
`
|
||||
|
||||
await fs.writeFile(fastEnterPath, cleanedFastEnter, 'utf-8')
|
||||
console.log(` ${icons.success} ${fmt.success('清理快速入口配置完成')}`)
|
||||
} catch (err) {
|
||||
console.log(` ${icons.error} ${fmt.error('清理快速入口配置失败')}`)
|
||||
console.log(` ${fmt.dim('错误详情: ' + err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新菜单接口
|
||||
async function updateMenuApi() {
|
||||
const apiPath = path.resolve(process.cwd(), 'src/api/system-manage.ts')
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(apiPath, 'utf-8')
|
||||
const updatedContent = content.replace(
|
||||
"url: '/api/v3/system/menus'",
|
||||
"url: '/api/v3/system/menus/simple'"
|
||||
)
|
||||
|
||||
await fs.writeFile(apiPath, updatedContent, 'utf-8')
|
||||
console.log(` ${icons.success} ${fmt.success('更新菜单接口完成')}`)
|
||||
} catch (err) {
|
||||
console.log(` ${icons.error} ${fmt.error('更新菜单接口失败')}`)
|
||||
console.log(` ${fmt.dim('错误详情: ' + err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 用户确认函数
|
||||
async function getUserConfirmation(): Promise<boolean> {
|
||||
const { createInterface } = await import('readline')
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const rl = createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
})
|
||||
|
||||
console.log(
|
||||
` ${fmt.highlight('请输入')} ${fmt.success('yes')} ${fmt.highlight('确认执行清理操作,或按 Enter 取消')}`
|
||||
)
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout })
|
||||
console.log()
|
||||
process.stdout.write(` ${icons.arrow} `)
|
||||
|
||||
console.log(` ${fmt.highlight('清空 mock 升级日志')} ${fmt.dim('(src/mock/upgrade/changeLog.ts)')}`)
|
||||
console.log(` ${fmt.dim('输入 yes 并回车确认,其它键取消')}`)
|
||||
console.log()
|
||||
process.stdout.write(' > ')
|
||||
rl.question('', (answer: string) => {
|
||||
rl.close()
|
||||
resolve(answer.toLowerCase().trim() === 'yes')
|
||||
@@ -629,210 +46,51 @@ async function getUserConfirmation(): Promise<boolean> {
|
||||
})
|
||||
}
|
||||
|
||||
// 显示清理警告
|
||||
async function showCleanupWarning() {
|
||||
createCard('安全警告', [
|
||||
`${fmt.warning('此操作将永久删除以下演示内容,且无法恢复!')}`,
|
||||
`${fmt.dim('请仔细阅读清理列表,确认后再继续操作')}`
|
||||
])
|
||||
async function resetChangeLog(): Promise<void> {
|
||||
const changeLogPath = path.resolve(process.cwd(), 'src/mock/upgrade/changeLog.ts')
|
||||
const cleaned = `import { ref } from 'vue'
|
||||
|
||||
const cleanupItems = [
|
||||
{
|
||||
icon: icons.image,
|
||||
name: '图片资源',
|
||||
desc: '演示用的封面图片、3D图片、运维图片等',
|
||||
color: theme.orange
|
||||
},
|
||||
{
|
||||
icon: icons.file,
|
||||
name: '演示页面',
|
||||
desc: 'widgets、template、article、examples、safeguard等页面',
|
||||
color: theme.purple
|
||||
},
|
||||
{
|
||||
icon: icons.code,
|
||||
name: '路由模块文件',
|
||||
desc: '删除演示路由模块,只保留核心模块(dashboard、system、result、exception)',
|
||||
color: theme.primary
|
||||
},
|
||||
{
|
||||
icon: icons.link,
|
||||
name: '路由别名',
|
||||
desc: '重写routesAlias.ts,移除演示路由别名',
|
||||
color: theme.info
|
||||
},
|
||||
{
|
||||
icon: icons.data,
|
||||
name: 'Mock数据',
|
||||
desc: '演示用的JSON数据、文章列表、评论数据等',
|
||||
color: theme.success
|
||||
},
|
||||
{
|
||||
icon: icons.globe,
|
||||
name: '多语言文件',
|
||||
desc: '清理中英文语言包中的演示菜单项',
|
||||
color: theme.warning
|
||||
},
|
||||
{ icon: icons.map, name: '地图组件', desc: '移除art-map-chart地图组件', color: theme.error },
|
||||
{ icon: icons.chat, name: '评论组件', desc: '移除comment-widget评论组件', color: theme.orange },
|
||||
{
|
||||
icon: icons.bolt,
|
||||
name: '快速入口',
|
||||
desc: '移除分析页、礼花效果、聊天、更新日志、定价、留言管理等无效项目',
|
||||
color: theme.purple
|
||||
}
|
||||
]
|
||||
|
||||
console.log(` ${fmt.badge('', theme.bgRed)} ${fmt.title('将要清理的内容')}`)
|
||||
console.log()
|
||||
|
||||
cleanupItems.forEach((item, index) => {
|
||||
console.log(` ${item.color}${theme.reset} ${fmt.highlight(`${index + 1}. ${item.name}`)}`)
|
||||
console.log(` ${fmt.dim(item.desc)}`)
|
||||
})
|
||||
|
||||
console.log()
|
||||
console.log(` ${fmt.badge('', theme.bgGreen)} ${fmt.title('保留的功能模块')}`)
|
||||
console.log()
|
||||
|
||||
const preservedModules = [
|
||||
{ name: 'Dashboard', desc: '工作台页面' },
|
||||
{ name: 'System', desc: '系统管理模块' },
|
||||
{ name: 'Result', desc: '结果页面' },
|
||||
{ name: 'Exception', desc: '异常页面' },
|
||||
{ name: 'Auth', desc: '登录注册功能' },
|
||||
{ name: 'Core Components', desc: '核心组件库' }
|
||||
]
|
||||
|
||||
preservedModules.forEach((module) => {
|
||||
console.log(` ${icons.check} ${fmt.success(module.name)} ${fmt.dim(`- ${module.desc}`)}`)
|
||||
})
|
||||
|
||||
console.log()
|
||||
createDivider()
|
||||
console.log()
|
||||
interface UpgradeLog {
|
||||
version: string
|
||||
title: string
|
||||
date: string
|
||||
detail?: string[]
|
||||
requireReLogin?: boolean
|
||||
remark?: string
|
||||
}
|
||||
|
||||
// 显示统计信息
|
||||
async function showStats() {
|
||||
const duration = Date.now() - stats.startTime
|
||||
const seconds = (duration / 1000).toFixed(2)
|
||||
|
||||
console.log()
|
||||
createCard('清理统计', [
|
||||
`${fmt.success('成功删除')}: ${fmt.highlight(stats.deletedFiles.toString())} 个文件`,
|
||||
`${fmt.info('涉及路径')}: ${fmt.highlight(stats.deletedPaths.toString())} 个目录/文件`,
|
||||
...(stats.failedPaths > 0
|
||||
? [
|
||||
`${icons.error} ${fmt.error('删除失败')}: ${fmt.highlight(stats.failedPaths.toString())} 个路径`
|
||||
]
|
||||
: []),
|
||||
`${fmt.info('耗时')}: ${fmt.highlight(seconds)} 秒`
|
||||
])
|
||||
export const upgradeLogList = ref<UpgradeLog[]>([])
|
||||
`
|
||||
await fs.writeFile(changeLogPath, cleaned, 'utf-8')
|
||||
console.log(` ${fmt.success('已写入')}: ${changeLogPath}`)
|
||||
}
|
||||
|
||||
// 创建成功横幅
|
||||
function createSuccessBanner() {
|
||||
console.log()
|
||||
console.log(
|
||||
fmt.gradient(' ╔══════════════════════════════════════════════════════════════════╗')
|
||||
)
|
||||
console.log(
|
||||
fmt.gradient(' ║ ║')
|
||||
)
|
||||
console.log(
|
||||
` ║ ${icons.star} ${fmt.success('清理完成!项目已准备就绪')} ${icons.rocket} ║`
|
||||
)
|
||||
console.log(
|
||||
` ║ ${fmt.dim('现在可以开始您的开发之旅了!')} ║`
|
||||
)
|
||||
console.log(
|
||||
fmt.gradient(' ║ ║')
|
||||
)
|
||||
console.log(
|
||||
fmt.gradient(' ╚══════════════════════════════════════════════════════════════════╝')
|
||||
)
|
||||
console.log()
|
||||
}
|
||||
|
||||
// 主函数
|
||||
async function main() {
|
||||
// 清屏并显示横幅
|
||||
console.clear()
|
||||
createModernBanner()
|
||||
|
||||
// 显示清理警告
|
||||
await showCleanupWarning()
|
||||
|
||||
// 统计文件数量
|
||||
console.log(` ${fmt.info('正在统计文件数量...')}`)
|
||||
stats.totalFiles = await countAllFiles()
|
||||
|
||||
console.log(` ${fmt.info('即将清理')}: ${fmt.highlight(stats.totalFiles.toString())} 个文件`)
|
||||
console.log(` ${fmt.dim(`涉及 ${targets.length} 个目录/文件路径`)}`)
|
||||
console.log()
|
||||
console.log(` ${fmt.title('web-admin · clean-dev(安全模式)')}`)
|
||||
console.log(` ${fmt.dim('不会删除路由、README、业务页面或重写 routesAlias。')}`)
|
||||
console.log()
|
||||
|
||||
// 用户确认
|
||||
const confirmed = await getUserConfirmation()
|
||||
|
||||
if (!confirmed) {
|
||||
console.log(` ${fmt.warning('操作已取消,清理中止')}`)
|
||||
const ok = await getUserConfirmation()
|
||||
if (!ok) {
|
||||
console.log(` ${fmt.warning('已取消')}`)
|
||||
console.log()
|
||||
return
|
||||
}
|
||||
|
||||
console.log()
|
||||
console.log(` ${icons.check} ${fmt.success('确认成功,开始清理...')}`)
|
||||
console.log()
|
||||
|
||||
// 开始清理过程
|
||||
console.log(` ${fmt.badge('步骤 1/6', theme.bgBlue)} ${fmt.title('删除演示文件')}`)
|
||||
console.log()
|
||||
for (let i = 0; i < targets.length; i++) {
|
||||
await remove(targets[i], i)
|
||||
try {
|
||||
await resetChangeLog()
|
||||
console.log()
|
||||
console.log(` ${fmt.success('完成')}`)
|
||||
} catch (e) {
|
||||
console.log(` ${fmt.error('失败: ' + String(e))}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log()
|
||||
|
||||
console.log(` ${fmt.badge('步骤 2/6', theme.bgBlue)} ${fmt.title('清理路由模块')}`)
|
||||
console.log()
|
||||
await cleanRouteModules()
|
||||
console.log()
|
||||
|
||||
console.log(` ${fmt.badge('步骤 3/6', theme.bgBlue)} ${fmt.title('重写路由别名')}`)
|
||||
console.log()
|
||||
await cleanRoutesAlias()
|
||||
console.log()
|
||||
|
||||
console.log(` ${fmt.badge('步骤 4/6', theme.bgBlue)} ${fmt.title('清空变更日志')}`)
|
||||
console.log()
|
||||
await cleanChangeLog()
|
||||
console.log()
|
||||
|
||||
console.log(` ${fmt.badge('步骤 5/6', theme.bgBlue)} ${fmt.title('清理语言文件')}`)
|
||||
console.log()
|
||||
await cleanLanguageFiles()
|
||||
console.log()
|
||||
|
||||
console.log(` ${fmt.badge('步骤 6/7', theme.bgBlue)} ${fmt.title('清理快速入口')}`)
|
||||
console.log()
|
||||
await cleanFastEnterComponent()
|
||||
console.log()
|
||||
|
||||
console.log(` ${fmt.badge('步骤 7/7', theme.bgBlue)} ${fmt.title('更新菜单接口')}`)
|
||||
console.log()
|
||||
await updateMenuApi()
|
||||
|
||||
// 显示统计信息
|
||||
await showStats()
|
||||
|
||||
// 显示成功横幅
|
||||
createSuccessBanner()
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.log()
|
||||
console.log(` ${icons.error} ${fmt.error('清理脚本执行出错')}`)
|
||||
console.log(` ${fmt.dim('错误详情: ' + err)}`)
|
||||
console.log()
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
+171
-422
@@ -1,8 +1,87 @@
|
||||
import request from '@/utils/http'
|
||||
import type { AppRouteRecord } from '@/types/router'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 后端菜单 / 角色(与 router 后端模式、用户弹窗对齐)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 后端菜单模式:拉取动态路由 */
|
||||
export async function fetchGetMenuList() {
|
||||
const res = await request.get<AppRouteRecord[] | { menuList?: AppRouteRecord[] }>({
|
||||
url: '/web/menu/list'
|
||||
})
|
||||
if (Array.isArray(res)) return res
|
||||
return res?.menuList || []
|
||||
}
|
||||
|
||||
/** 用户表单等:角色下拉 */
|
||||
export function fetchGetRoleList() {
|
||||
return request.get<Array<{ label: string; value: string }>>({
|
||||
url: '/web/admin/roles'
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 租户(系统管理)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function fetchTenantList(params?: { page?: number; pageSize?: number; keyword?: string }) {
|
||||
return request.get<{
|
||||
list: Api.Tenant.TenantItem[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}>({
|
||||
url: '/web/admin/tenants',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchCreateTenant(data: {
|
||||
tenantNo: string
|
||||
tenantName: string
|
||||
contactName?: string
|
||||
contactPhone?: string
|
||||
orderNum?: number
|
||||
remark?: string
|
||||
}) {
|
||||
return request.post<Api.Tenant.TenantItem>({
|
||||
url: '/web/admin/tenants',
|
||||
data,
|
||||
showSuccessMessage: true
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchUpdateTenant(
|
||||
id: string,
|
||||
data: Partial<{
|
||||
tenantNo: string
|
||||
tenantName: string
|
||||
contactName: string
|
||||
contactPhone: string
|
||||
orderNum: number
|
||||
remark: string
|
||||
}>
|
||||
) {
|
||||
return request.put<Api.Tenant.TenantItem>({
|
||||
url: `/web/admin/tenants/${id}`,
|
||||
data,
|
||||
showSuccessMessage: true
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchDeleteTenant(id: string) {
|
||||
return request.del({
|
||||
url: `/web/admin/tenants/${id}`,
|
||||
showSuccessMessage: true
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 用户
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 获取用户列表
|
||||
*/
|
||||
export function fetchUserList(params: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
@@ -24,18 +103,12 @@ export function fetchUserList(params: {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户详情
|
||||
*/
|
||||
export function fetchUserDetail(id: string) {
|
||||
return request.get<Api.User.UserDetail>({
|
||||
url: `/web/admin/users/${id}`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建用户
|
||||
*/
|
||||
export function fetchCreateUser(data: {
|
||||
username: string
|
||||
password: string
|
||||
@@ -52,27 +125,24 @@ export function fetchCreateUser(data: {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户
|
||||
*/
|
||||
export function fetchUpdateUser(id: string, data: Partial<{
|
||||
realName: string
|
||||
phone?: string
|
||||
role: string
|
||||
regionId?: string
|
||||
regionIds?: string[]
|
||||
isActive: boolean
|
||||
avatarUrl?: string | null
|
||||
}>) {
|
||||
export function fetchUpdateUser(
|
||||
id: string,
|
||||
data: Partial<{
|
||||
realName: string
|
||||
phone?: string
|
||||
role: string
|
||||
regionId?: string
|
||||
regionIds?: string[]
|
||||
isActive: boolean
|
||||
avatarUrl?: string | null
|
||||
}>
|
||||
) {
|
||||
return request.put({
|
||||
url: `/web/admin/users/${id}`,
|
||||
params: data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 可授权菜单 code 列表
|
||||
*/
|
||||
export function fetchAssignableMenuCodes() {
|
||||
return request.get<{
|
||||
list: Array<{
|
||||
@@ -87,9 +157,6 @@ export function fetchAssignableMenuCodes() {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户菜单授权
|
||||
*/
|
||||
export function fetchUserMenuScopes(userId: string) {
|
||||
return request.get<{
|
||||
userId: string
|
||||
@@ -99,9 +166,6 @@ export function fetchUserMenuScopes(userId: string) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户菜单授权(全量覆盖)
|
||||
*/
|
||||
export function fetchUpdateUserMenuScopes(userId: string, menuCodes: string[]) {
|
||||
return request.post({
|
||||
url: `/web/admin/users/${userId}/menu-scopes`,
|
||||
@@ -109,41 +173,31 @@ export function fetchUpdateUserMenuScopes(userId: string, menuCodes: string[]) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户
|
||||
*/
|
||||
export function fetchDeleteUser(id: string) {
|
||||
return request.del({
|
||||
url: `/web/admin/users/${id}`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置用户密码
|
||||
*/
|
||||
export function fetchResetPassword(id: string) {
|
||||
return request.post({
|
||||
url: `/web/admin/users/${id}/reset-password`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改用户密码
|
||||
*/
|
||||
export function fetchChangePassword(data: {
|
||||
oldPassword: string
|
||||
newPassword: string
|
||||
}) {
|
||||
return request.post({
|
||||
url: '/web/admin/users/change-password',
|
||||
params: data
|
||||
/** 上传用户头像,返回存储后的 URL,供写入 avatarUrl */
|
||||
export function fetchUploadUserAvatar(data: FormData) {
|
||||
return request.post<{ url: string }>({
|
||||
url: '/web/admin/users/upload-avatar',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取区域列表
|
||||
*/
|
||||
export function fetchRegionList(params?: {
|
||||
// ---------------------------------------------------------------------------
|
||||
// 区域(仅列表分页供 fetchNotDeletedRegions 使用 + 启用列表)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function fetchRegionList(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
@@ -161,36 +215,29 @@ export function fetchRegionList(params?: {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取启用的区域列表
|
||||
*/
|
||||
export function fetchActiveRegions() {
|
||||
// 简单缓存:区域列表变化频率低,避免页面间重复请求导致“区域加载慢”
|
||||
// 5 分钟 TTL;需要实时刷新时可在调用方自行绕过(后续可升级为 Pinia store)
|
||||
const now = Date.now()
|
||||
const anyFn = fetchActiveRegions as any
|
||||
const cache = anyFn.__cache as undefined | { ts: number; data: Api.Region.RegionItem[] }
|
||||
if (cache && now - cache.ts < 5 * 60 * 1000) return Promise.resolve(cache.data)
|
||||
|
||||
return request.get<Api.Region.RegionItem[]>({
|
||||
url: '/web/admin/regions/active'
|
||||
}).then((data) => {
|
||||
anyFn.__cache = { ts: now, data: data || [] }
|
||||
return data
|
||||
})
|
||||
return request
|
||||
.get<Api.Region.RegionItem[]>({
|
||||
url: '/web/admin/regions/active'
|
||||
})
|
||||
.then((data) => {
|
||||
anyFn.__cache = { ts: now, data: data || [] }
|
||||
return data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有未删除的区域(包含禁用态 isActive=0)
|
||||
* 用于后台各模块“区域 tab”展示全量范围。
|
||||
*/
|
||||
/** 未删除的全量区域(含禁用),供各页区域 Tab */
|
||||
export async function fetchNotDeletedRegions() {
|
||||
const now = Date.now()
|
||||
const anyFn = fetchNotDeletedRegions as any
|
||||
const cache = anyFn.__cache as undefined | { ts: number; data: Api.Region.RegionItem[] }
|
||||
if (cache && now - cache.ts < 5 * 60 * 1000) return cache.data
|
||||
|
||||
// 注意:后端 `GET /web/admin/regions` 的 pageSize 有上限(<=100),不能一次性拉太大。
|
||||
const pageSize = 100
|
||||
const all: Api.Region.RegionItem[] = []
|
||||
let page = 1
|
||||
@@ -201,7 +248,6 @@ export async function fetchNotDeletedRegions() {
|
||||
all.push(...list)
|
||||
|
||||
const total = Number(res?.total ?? 0)
|
||||
// 没有更多数据:直接退出(避免死循环)
|
||||
if (!total || all.length >= total || list.length < pageSize) break
|
||||
page++
|
||||
}
|
||||
@@ -210,64 +256,10 @@ export async function fetchNotDeletedRegions() {
|
||||
return all
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取区域详情
|
||||
*/
|
||||
export function fetchRegionDetail(id: string) {
|
||||
return request.get<Api.Region.RegionDetail>({
|
||||
url: `/web/admin/regions/${id}`
|
||||
})
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// 商品分类
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 创建区域
|
||||
*/
|
||||
export function fetchCreateRegion(data: {
|
||||
name: string
|
||||
code: string
|
||||
description?: string
|
||||
sortOrder?: number
|
||||
/** 预计配送时长(分钟) */
|
||||
expectedDeliveryMinutes?: number | null
|
||||
/** 计划到货前未送达时预警提前时间(分钟) */
|
||||
deliveryWarnAdvanceMinutes?: number | null
|
||||
}) {
|
||||
return request.post({
|
||||
url: '/web/admin/regions',
|
||||
params: data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新区域
|
||||
*/
|
||||
export function fetchUpdateRegion(id: string, data: Partial<{
|
||||
name: string
|
||||
code: string
|
||||
description?: string
|
||||
sortOrder?: number
|
||||
isActive?: boolean
|
||||
expectedDeliveryMinutes?: number | null
|
||||
deliveryWarnAdvanceMinutes?: number | null
|
||||
}>) {
|
||||
return request.put({
|
||||
url: `/web/admin/regions/${id}`,
|
||||
params: data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除区域
|
||||
*/
|
||||
export function fetchDeleteRegion(id: string) {
|
||||
return request.del({
|
||||
url: `/web/admin/regions/${id}`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取商品分类列表
|
||||
*/
|
||||
export function fetchCategoryList(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
@@ -286,24 +278,11 @@ export function fetchCategoryList(params?: {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取商品分类树
|
||||
*/
|
||||
export function fetchCategoryTree() {
|
||||
return request.get<any[]>({
|
||||
url: '/web/admin/categories/tree'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建商品分类
|
||||
*/
|
||||
export function fetchCreateCategory(data: {
|
||||
name: string
|
||||
parentId?: string
|
||||
sortOrder?: number
|
||||
imageUrl?: string
|
||||
/** 价格系数,百分比点数(如 5 表示 5%) */
|
||||
priceCoefficient?: number
|
||||
}) {
|
||||
return request.post({
|
||||
@@ -312,35 +291,29 @@ export function fetchCreateCategory(data: {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新商品分类
|
||||
*/
|
||||
export function fetchUpdateCategory(id: string, data: Partial<{
|
||||
name: string
|
||||
parentId?: string
|
||||
sortOrder?: number
|
||||
isActive?: boolean
|
||||
imageUrl?: string
|
||||
priceCoefficient?: number
|
||||
}>) {
|
||||
export function fetchUpdateCategory(
|
||||
id: string,
|
||||
data: Partial<{
|
||||
name: string
|
||||
parentId?: string
|
||||
sortOrder?: number
|
||||
isActive?: boolean
|
||||
imageUrl?: string
|
||||
priceCoefficient?: number
|
||||
}>
|
||||
) {
|
||||
return request.put({
|
||||
url: `/web/admin/categories/${id}`,
|
||||
params: data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除商品分类
|
||||
*/
|
||||
export function fetchDeleteCategory(id: string) {
|
||||
return request.del({
|
||||
url: `/web/admin/categories/${id}`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传商品分类图片(MinIO)并返回图片URL
|
||||
*/
|
||||
export function fetchUploadCategoryImage(data: FormData) {
|
||||
return request.post({
|
||||
url: '/web/admin/categories/upload-image',
|
||||
@@ -348,17 +321,10 @@ export function fetchUploadCategoryImage(data: FormData) {
|
||||
})
|
||||
}
|
||||
|
||||
/** 上传用户头像,返回 MinIO 等存储后的 URL,供写入 avatarUrl */
|
||||
export function fetchUploadUserAvatar(data: FormData) {
|
||||
return request.post<{ url: string }>({
|
||||
url: '/web/admin/users/upload-avatar',
|
||||
data
|
||||
})
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// 商品单位
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 获取商品单位列表
|
||||
*/
|
||||
export function fetchUnitList(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
@@ -377,45 +343,37 @@ export function fetchUnitList(params?: {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建商品单位
|
||||
*/
|
||||
export function fetchCreateUnit(data: {
|
||||
name: string
|
||||
code: string
|
||||
}) {
|
||||
export function fetchCreateUnit(data: { name: string; code: string }) {
|
||||
return request.post({
|
||||
url: '/web/admin/units',
|
||||
params: data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新商品单位
|
||||
*/
|
||||
export function fetchUpdateUnit(id: string, data: Partial<{
|
||||
name: string
|
||||
code: string
|
||||
isActive?: boolean
|
||||
}>) {
|
||||
export function fetchUpdateUnit(
|
||||
id: string,
|
||||
data: Partial<{
|
||||
name: string
|
||||
code: string
|
||||
isActive?: boolean
|
||||
}>
|
||||
) {
|
||||
return request.put({
|
||||
url: `/web/admin/units/${id}`,
|
||||
params: data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除商品单位
|
||||
*/
|
||||
export function fetchDeleteUnit(id: string) {
|
||||
return request.del({
|
||||
url: `/web/admin/units/${id}`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取商品列表
|
||||
*/
|
||||
// ---------------------------------------------------------------------------
|
||||
// 商品
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function fetchProductList(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
@@ -435,18 +393,12 @@ export function fetchProductList(params?: {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取商品详情
|
||||
*/
|
||||
export function fetchProductDetail(id: string) {
|
||||
return request.get<Api.Product.ProductDetail>({
|
||||
url: `/web/admin/products/${id}`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 商品在各启用区域的当前生效价(来自该区域生效中的价格清单)
|
||||
*/
|
||||
export function fetchProductRegionPrices(productId: string) {
|
||||
return request.get<{
|
||||
list: Array<{
|
||||
@@ -461,9 +413,6 @@ export function fetchProductRegionPrices(productId: string) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建商品
|
||||
*/
|
||||
export function fetchCreateProduct(data: {
|
||||
name: string
|
||||
skuCode?: string
|
||||
@@ -472,7 +421,6 @@ export function fetchCreateProduct(data: {
|
||||
measureValue?: number | null
|
||||
description?: string
|
||||
imageUrl?: string
|
||||
/** 调价系数(%):百分比点数,5 表示 5% */
|
||||
priceCoefficient?: number
|
||||
isActive?: boolean
|
||||
detailContent?: string
|
||||
@@ -483,41 +431,35 @@ export function fetchCreateProduct(data: {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新商品
|
||||
*/
|
||||
export function fetchUpdateProduct(id: string, data: Partial<{
|
||||
name: string
|
||||
skuCode?: string
|
||||
categoryId?: string
|
||||
unitId?: string
|
||||
measureValue?: number | null
|
||||
description?: string
|
||||
imageUrl?: string
|
||||
priceCoefficient?: number
|
||||
isActive?: boolean
|
||||
detailContent?: string
|
||||
}>) {
|
||||
export function fetchUpdateProduct(
|
||||
id: string,
|
||||
data: Partial<{
|
||||
name: string
|
||||
skuCode?: string
|
||||
categoryId?: string
|
||||
unitId?: string
|
||||
measureValue?: number | null
|
||||
description?: string
|
||||
imageUrl?: string
|
||||
priceCoefficient?: number
|
||||
isActive?: boolean
|
||||
detailContent?: string
|
||||
}>
|
||||
) {
|
||||
return request.put({
|
||||
url: `/web/admin/products/${id}`,
|
||||
params: data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除商品
|
||||
*/
|
||||
export function fetchDeleteProduct(id: string) {
|
||||
return request.del({
|
||||
url: `/web/admin/products/${id}`
|
||||
})
|
||||
}
|
||||
|
||||
// ========== 商品轮播图 ==========
|
||||
// --- 轮播图 ---
|
||||
|
||||
/**
|
||||
* 获取商品轮播图列表
|
||||
*/
|
||||
export function fetchProductBannerList(productId: string) {
|
||||
return request.get<{
|
||||
list: Api.Product.ProductBanner[]
|
||||
@@ -527,9 +469,6 @@ export function fetchProductBannerList(productId: string) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量添加商品轮播图
|
||||
*/
|
||||
export function fetchCreateProductBanners(productId: string, items: Api.Product.ProductBannerCreate[]) {
|
||||
return request.post({
|
||||
url: `/web/admin/product-banners/product/${productId}`,
|
||||
@@ -537,19 +476,6 @@ export function fetchCreateProductBanners(productId: string, items: Api.Product.
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新轮播图
|
||||
*/
|
||||
export function fetchUpdateProductBanner(id: string, data: Partial<Api.Product.ProductBannerUpdate>) {
|
||||
return request.put({
|
||||
url: `/web/admin/product-banners/${id}`,
|
||||
params: data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新轮播图排序
|
||||
*/
|
||||
export function fetchUpdateProductBannerSort(productId: string, items: { id: string; sortOrder: number }[]) {
|
||||
return request.put({
|
||||
url: `/web/admin/product-banners/product/${productId}/sort`,
|
||||
@@ -557,20 +483,14 @@ export function fetchUpdateProductBannerSort(productId: string, items: { id: str
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除轮播图
|
||||
*/
|
||||
export function fetchDeleteProductBanner(id: string) {
|
||||
return request.del({
|
||||
url: `/web/admin/product-banners/${id}`
|
||||
})
|
||||
}
|
||||
|
||||
// ========== 商品详情媒体 ==========
|
||||
// --- 详情媒体 ---
|
||||
|
||||
/**
|
||||
* 获取商品详情媒体列表
|
||||
*/
|
||||
export function fetchProductDetailMediaList(productId: string) {
|
||||
return request.get<{
|
||||
list: Api.Product.ProductDetailMedia[]
|
||||
@@ -580,29 +500,16 @@ export function fetchProductDetailMediaList(productId: string) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量添加商品详情媒体
|
||||
*/
|
||||
export function fetchCreateProductDetailMedia(productId: string, items: Api.Product.ProductDetailMediaCreate[]) {
|
||||
export function fetchCreateProductDetailMedia(
|
||||
productId: string,
|
||||
items: Api.Product.ProductDetailMediaCreate[]
|
||||
) {
|
||||
return request.post({
|
||||
url: `/web/admin/product-detail-media/product/${productId}`,
|
||||
params: items
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新详情媒体
|
||||
*/
|
||||
export function fetchUpdateProductDetailMedia(id: string, data: Partial<Api.Product.ProductDetailMediaUpdate>) {
|
||||
return request.put({
|
||||
url: `/web/admin/product-detail-media/${id}`,
|
||||
params: data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新详情媒体排序
|
||||
*/
|
||||
export function fetchUpdateProductDetailMediaSort(productId: string, items: { id: string; sortOrder: number }[]) {
|
||||
return request.put({
|
||||
url: `/web/admin/product-detail-media/product/${productId}/sort`,
|
||||
@@ -610,40 +517,14 @@ export function fetchUpdateProductDetailMediaSort(productId: string, items: { id
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除详情媒体
|
||||
*/
|
||||
export function fetchDeleteProductDetailMedia(id: string) {
|
||||
return request.del({
|
||||
url: `/web/admin/product-detail-media/${id}`
|
||||
})
|
||||
}
|
||||
|
||||
// ========== 商品参数模板 ==========
|
||||
// --- 参数模板(简洁列表供分类/商品选择) ---
|
||||
|
||||
/**
|
||||
* 获取参数模板列表
|
||||
*/
|
||||
export function fetchProductParameterList(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
isActive?: number
|
||||
}) {
|
||||
return request.get<{
|
||||
list: Api.Product.ProductParameter[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}>({
|
||||
url: '/web/admin/product-parameters',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取参数模板简洁列表
|
||||
*/
|
||||
export function fetchProductParameterSimple() {
|
||||
return request.get<{
|
||||
list: Api.Product.ProductParameterSimple[]
|
||||
@@ -653,49 +534,8 @@ export function fetchProductParameterSimple() {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取参数模板详情
|
||||
*/
|
||||
export function fetchProductParameterDetail(id: string) {
|
||||
return request.get<Api.Product.ProductParameter>({
|
||||
url: `/web/admin/product-parameters/${id}`
|
||||
})
|
||||
}
|
||||
// --- 分类关联参数 ---
|
||||
|
||||
/**
|
||||
* 创建参数模板
|
||||
*/
|
||||
export function fetchCreateProductParameter(data: Api.Product.ProductParameterCreate) {
|
||||
return request.post({
|
||||
url: '/web/admin/product-parameters',
|
||||
params: data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新参数模板
|
||||
*/
|
||||
export function fetchUpdateProductParameter(id: string, data: Partial<Api.Product.ProductParameterUpdate>) {
|
||||
return request.put({
|
||||
url: `/web/admin/product-parameters/${id}`,
|
||||
params: data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除参数模板
|
||||
*/
|
||||
export function fetchDeleteProductParameter(id: string) {
|
||||
return request.del({
|
||||
url: `/web/admin/product-parameters/${id}`
|
||||
})
|
||||
}
|
||||
|
||||
// ========== 分类参数关联 ==========
|
||||
|
||||
/**
|
||||
* 获取分类关联的参数列表
|
||||
*/
|
||||
export function fetchCategoryParameters(categoryId: string) {
|
||||
return request.get<{
|
||||
list: Api.Product.CategoryParameter[]
|
||||
@@ -705,9 +545,6 @@ export function fetchCategoryParameters(categoryId: string) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新分类关联的参数
|
||||
*/
|
||||
export function fetchUpdateCategoryParameters(categoryId: string, items: Api.Product.CategoryParameterCreate[]) {
|
||||
return request.put({
|
||||
url: `/web/admin/categories/${categoryId}/parameters`,
|
||||
@@ -715,11 +552,8 @@ export function fetchUpdateCategoryParameters(categoryId: string, items: Api.Pro
|
||||
})
|
||||
}
|
||||
|
||||
// ========== 商品参数值 ==========
|
||||
// --- 商品参数值 ---
|
||||
|
||||
/**
|
||||
* 获取商品参数值列表
|
||||
*/
|
||||
export function fetchProductParameterValues(productId: string) {
|
||||
return request.get<{
|
||||
list: Api.Product.ProductParameterValue[]
|
||||
@@ -729,97 +563,12 @@ export function fetchProductParameterValues(productId: string) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量保存商品参数值
|
||||
*/
|
||||
export function fetchSaveProductParameterValues(productId: string, values: Api.Product.ProductParameterValueCreate[]) {
|
||||
export function fetchSaveProductParameterValues(
|
||||
productId: string,
|
||||
values: Api.Product.ProductParameterValueCreate[]
|
||||
) {
|
||||
return request.post({
|
||||
url: `/web/admin/product-parameter-values/product/${productId}`,
|
||||
params: { productId, values }
|
||||
})
|
||||
}
|
||||
|
||||
// ========== 售后评价 ==========
|
||||
|
||||
/**
|
||||
* 获取评价列表
|
||||
*/
|
||||
export function fetchReviewList(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
productId?: string
|
||||
productName?: string
|
||||
userName?: string
|
||||
status?: string
|
||||
rating?: number
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}) {
|
||||
return request.get<{
|
||||
list: Api.Product.ProductReview[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}>({
|
||||
url: '/web/admin/reviews',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取评价统计
|
||||
*/
|
||||
export function fetchReviewStatistics(productId?: string) {
|
||||
return request.get<Api.Product.ProductReviewStatistics>({
|
||||
url: '/web/admin/reviews/statistics',
|
||||
params: { productId }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取评价详情
|
||||
*/
|
||||
export function fetchReviewDetail(id: string) {
|
||||
return request.get<Api.Product.ProductReview>({
|
||||
url: `/web/admin/reviews/${id}`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核通过评价
|
||||
*/
|
||||
export function fetchApproveReview(id: string) {
|
||||
return request.put({
|
||||
url: `/web/admin/reviews/${id}/approve`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 拒绝评价
|
||||
*/
|
||||
export function fetchRejectReview(id: string, rejectReason: string) {
|
||||
return request.put({
|
||||
url: `/web/admin/reviews/${id}/reject`,
|
||||
params: { rejectReason }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 回复评价
|
||||
*/
|
||||
export function fetchReplyReview(id: string, replyContent: string) {
|
||||
return request.put({
|
||||
url: `/web/admin/reviews/${id}/reply`,
|
||||
params: { replyContent }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 置顶/取消置顶评价
|
||||
*/
|
||||
export function fetchToggleReviewTop(id: string, isTop: number) {
|
||||
return request.put({
|
||||
url: `/web/admin/reviews/${id}/top`,
|
||||
params: { isTop }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
/**
|
||||
* 订单与订单页依赖的少量接口(其余业务 API 已移除,需要时再加回)。
|
||||
*/
|
||||
|
||||
export function fetchOrderList(params: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
status?: string
|
||||
orderStatus?: string
|
||||
deliveryStatus?: string
|
||||
payStatus?: string
|
||||
paymentMethod?: string
|
||||
regionId?: string
|
||||
clientId?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}) {
|
||||
return request.get<{
|
||||
list: Api.Order.OrderItem[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}>({
|
||||
url: '/web/admin/orders',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchOrderDetail(id: string) {
|
||||
return request.get<Api.Order.OrderDetail>({
|
||||
url: `/web/admin/orders/${id}`
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchAssignDelivery(data: {
|
||||
orderId: string
|
||||
deliverId: string
|
||||
deliveryDeadlineAt: string
|
||||
dispatchRemark?: string
|
||||
}) {
|
||||
return request.post({
|
||||
url: `/web/admin/orders/${data.orderId}/assign-deliverer`,
|
||||
params: {
|
||||
deliverId: data.deliverId,
|
||||
deliveryDeadlineAt: data.deliveryDeadlineAt,
|
||||
dispatchRemark: data.dispatchRemark || ''
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchReviewCancel(data: { orderId: string; approved: boolean; note?: string }) {
|
||||
return request.post({
|
||||
url: `/web/admin/orders/${data.orderId}/review-cancel`,
|
||||
params: { approved: data.approved, note: data.note }
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchCancelOrder(data: {
|
||||
orderId: string
|
||||
reason: string
|
||||
note?: string
|
||||
evidenceImageUrls?: string[]
|
||||
}) {
|
||||
return request.post({
|
||||
url: `/web/admin/orders/${data.orderId}/cancel`,
|
||||
params: {
|
||||
reason: data.reason,
|
||||
note: data.note,
|
||||
evidenceImageUrls: data.evidenceImageUrls || []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchOrderAdjusts(orderId: string) {
|
||||
return request.get({
|
||||
url: `/web/admin/orders/${orderId}/adjusts`
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchAdjustOrder(data: {
|
||||
orderId: string
|
||||
adjustments?: Array<{
|
||||
orderItemId: string
|
||||
adjustType: 'reduce' | 'delete' | 'substitute' | 'increase'
|
||||
newQuantity?: number
|
||||
newProductId?: string
|
||||
reason: string
|
||||
}>
|
||||
addressUpdate?: {
|
||||
deliveryAddress?: string
|
||||
contactPerson?: string
|
||||
contactPhone?: string
|
||||
reason?: string
|
||||
}
|
||||
}) {
|
||||
return request.post({
|
||||
url: `/web/admin/orders/${data.orderId}/adjust`,
|
||||
params: {
|
||||
adjustments: data.adjustments || [],
|
||||
addressUpdate: data.addressUpdate || undefined
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchOrderItems(orderId: string) {
|
||||
return request.get({
|
||||
url: `/web/admin/orders/${orderId}/items`
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchPriceVersionDetail(id: string) {
|
||||
return request.get({
|
||||
url: `/web/admin/price-versions/${id}`
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchUploadAttachment(data: FormData) {
|
||||
return request.post<{
|
||||
id: string
|
||||
fileName: string
|
||||
fileUrl: string
|
||||
fileSize: number
|
||||
fileSizeDisplay?: string
|
||||
fileType?: string
|
||||
}>({
|
||||
url: '/web/admin/attachment/upload',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchUploadProductImage(data: FormData) {
|
||||
return request.post({
|
||||
url: '/web/admin/products/upload-image',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchSystemConfig() {
|
||||
return request.get({
|
||||
url: '/web/admin/system-config/settings'
|
||||
})
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import request from '@/utils/http'
|
||||
import type { AppRouteRecord } from '@/types/router'
|
||||
|
||||
/**
|
||||
* 获取菜单列表
|
||||
* 从后端获取运营端菜单
|
||||
*/
|
||||
export async function fetchGetMenuList() {
|
||||
const res = await request.get<AppRouteRecord[] | { menuList?: AppRouteRecord[] }>({
|
||||
url: '/web/menu/list'
|
||||
})
|
||||
if (Array.isArray(res)) return res
|
||||
return res?.menuList || []
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取角色列表
|
||||
*/
|
||||
export function fetchGetRoleList() {
|
||||
return request.get<Array<{ label: string; value: string }>>({
|
||||
url: '/web/admin/roles'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 示例表格用户列表(兼容旧示例页调用)
|
||||
*/
|
||||
export function fetchGetUserList(params?: Record<string, any>) {
|
||||
return request.get<{
|
||||
list: any[]
|
||||
total: number
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}>({
|
||||
url: '/web/admin/users',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<div v-show="visibleProxy" class="order-operate-bridge-layer" @click.self="visibleProxy = false">
|
||||
<div class="order-operate-bridge-dialog">
|
||||
<iframe ref="frameRef" class="order-operate-bridge-frame" :src="frameSrc" frameborder="0" />
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
orderId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const visibleProxy = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const frameSrc = computed(() => {
|
||||
return `#/demo/order?embedded=1`
|
||||
})
|
||||
|
||||
const frameRef = ref<HTMLIFrameElement | null>(null)
|
||||
|
||||
const handleMessage = (ev: MessageEvent) => {
|
||||
const data = ev?.data as any
|
||||
if (!data || typeof data !== 'object') return
|
||||
if (data.type === 'sqxp-order-operate-close') {
|
||||
visibleProxy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const postOpen = (id: string) => {
|
||||
const win = frameRef.value?.contentWindow
|
||||
if (!win) return
|
||||
win.postMessage({ type: 'sqxp-order-operate-open', orderId: id }, '*')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('message', handleMessage)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('message', handleMessage)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [visibleProxy.value, props.orderId] as const,
|
||||
([vis, id]) => {
|
||||
if (!vis) return
|
||||
const orderId = (id || '').trim()
|
||||
if (!orderId) return
|
||||
// 给 iframe 一帧时间完成渲染/初始化后再发消息,避免首次打开时丢消息
|
||||
requestAnimationFrame(() => postOpen(orderId))
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.order-operate-bridge-layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 4000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.28);
|
||||
padding: 48px 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.order-operate-bridge-dialog {
|
||||
width: min(1400px, calc(100vw - 48px));
|
||||
max-height: calc(100vh - 96px);
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 20px 64px rgba(15, 23, 42, 0.25),
|
||||
0 2px 12px rgba(15, 23, 42, 0.12);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.order-operate-bridge-frame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 680px;
|
||||
max-height: calc(100vh - 96px);
|
||||
display: block;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.order-operate-bridge-empty {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.order-operate-bridge-layer {
|
||||
padding: 20px 10px;
|
||||
}
|
||||
|
||||
.order-operate-bridge-dialog {
|
||||
width: calc(100vw - 20px);
|
||||
max-height: calc(100vh - 40px);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.order-operate-bridge-frame {
|
||||
min-height: 520px;
|
||||
max-height: calc(100vh - 40px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<div v-show="visibleProxy" class="order-operate-local-layer" @click.self="visibleProxy = false">
|
||||
<iframe class="order-operate-local-frame" :src="frameSrc" frameborder="0" allowtransparency="true" />
|
||||
</div>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
orderId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const visibleProxy = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const frameSrc = computed(() => {
|
||||
const id = encodeURIComponent((props.orderId || '').trim())
|
||||
return id ? `#/demo/order?bridge=1&viewOrderId=${id}` : '#/demo/order?bridge=1'
|
||||
})
|
||||
|
||||
const handleMessage = (ev: MessageEvent) => {
|
||||
const data = ev?.data as any
|
||||
if (!data || typeof data !== 'object') return
|
||||
if (data.type === 'sqxp-order-operate-close') {
|
||||
visibleProxy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('message', handleMessage)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('message', handleMessage)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.order-operate-local-layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 4000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.order-operate-local-frame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: block;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -4,7 +4,7 @@
|
||||
<img
|
||||
:style="logoStyle"
|
||||
src="/logo1.jpg"
|
||||
alt="中科兴皖一体化门户"
|
||||
alt="陇耘鲜达"
|
||||
class="block object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,330 +0,0 @@
|
||||
<!-- 地图图表 -->
|
||||
<template>
|
||||
<div class="relative w-full" :style="{ height: 'calc(100vh - 120px)' }">
|
||||
<div v-if="isEmpty" class="h-full flex-cc">
|
||||
<ElEmpty description="暂无地图数据" />
|
||||
</div>
|
||||
|
||||
<div v-else id="china-map" ref="chinaMapRef" class="h-full w-full overflow-hidden rounded-lg" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, shallowRef, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { echarts } from '@/plugins/echarts'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import { fetchGetChinaMapData } from '@/api/system'
|
||||
import type { MapChartProps } from '@/types/component/chart'
|
||||
|
||||
defineOptions({ name: 'ArtMapChart' })
|
||||
|
||||
const chinaMapRef = ref<HTMLElement | null>(null)
|
||||
const chartInstance = shallowRef<echarts.ECharts | null>(null)
|
||||
const settingStore = useSettingStore()
|
||||
const { isDark } = storeToRefs(settingStore)
|
||||
|
||||
// 默认地图数据
|
||||
const defaultMapJson = {
|
||||
type: 'FeatureCollection' as const,
|
||||
features: []
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<MapChartProps>(), {
|
||||
mapData: () => [],
|
||||
selectedRegion: '',
|
||||
showLabels: true,
|
||||
showScatter: true,
|
||||
isEmpty: false
|
||||
})
|
||||
|
||||
// 地图GeoJSON数据
|
||||
const mapJsonData = ref<{ type: 'FeatureCollection'; features: unknown[] }>(defaultMapJson)
|
||||
|
||||
// 定义 emit
|
||||
const emit = defineEmits<{
|
||||
renderComplete: []
|
||||
regionClick: [region: { name: string; adcode: string; level: string }]
|
||||
}>()
|
||||
|
||||
// 检查是否为空数据
|
||||
const isEmpty = computed(() => {
|
||||
return props.isEmpty || (!props.mapData?.length && !mapJsonData.value.features.length)
|
||||
})
|
||||
|
||||
// 获取地图数据
|
||||
const fetchMapData = async () => {
|
||||
try {
|
||||
const { data } = await fetchGetChinaMapData()
|
||||
if (data && data.features && data.features.length > 0) {
|
||||
mapJsonData.value = data as { type: 'FeatureCollection'; features: unknown[] }
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取地图数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 根据 geoJson 数据准备地图数据
|
||||
const prepareMapData = (geoJson: {
|
||||
features: Array<{ properties: Record<string, unknown> }>
|
||||
}) => {
|
||||
return geoJson.features.map((feature) => ({
|
||||
name: feature.properties.name as string,
|
||||
value: Math.round(Math.random() * 1000),
|
||||
adcode: feature.properties.adcode as string,
|
||||
level: feature.properties.level as string,
|
||||
selected: false
|
||||
}))
|
||||
}
|
||||
|
||||
// 获取主题相关的样式配置
|
||||
const getThemeStyles = () => ({
|
||||
borderColor: isDark.value ? 'rgba(255,255,255,0.6)' : 'rgba(147,235,248,1)',
|
||||
shadowColor: isDark.value ? 'rgba(0,0,0,0.8)' : 'rgba(128,217,248,1)',
|
||||
labelColor: isDark.value ? '#fff' : '#333',
|
||||
backgroundColor: isDark.value ? 'rgba(0,0,0,0.8)' : 'rgba(255,255,255,0.9)'
|
||||
})
|
||||
|
||||
// 构造 ECharts 配置项
|
||||
const createChartOption = (mapData: Array<Record<string, unknown>>) => {
|
||||
const themeStyles = getThemeStyles()
|
||||
|
||||
return {
|
||||
animation: false, // 关闭动画效果,减少鼠标移动高亮时的掉帧感
|
||||
tooltip: {
|
||||
show: true,
|
||||
backgroundColor: themeStyles.backgroundColor,
|
||||
borderColor: isDark.value ? '#333' : '#ddd',
|
||||
borderWidth: 1,
|
||||
textStyle: {
|
||||
color: themeStyles.labelColor
|
||||
},
|
||||
formatter: ({ data }: { data?: Record<string, unknown> }) => {
|
||||
const { name, adcode, level } = data || {}
|
||||
return `
|
||||
<div style="padding: 8px;">
|
||||
<div><strong>名称:</strong> ${name || '未知区域'}</div>
|
||||
<div><strong>代码:</strong> ${adcode || '暂无'}</div>
|
||||
<div><strong>级别:</strong> ${level || '暂无'}</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
},
|
||||
geo: {
|
||||
map: 'china',
|
||||
zoom: 1,
|
||||
show: true,
|
||||
roam: false,
|
||||
scaleLimit: {
|
||||
min: 0.8,
|
||||
max: 3
|
||||
},
|
||||
layoutSize: '100%',
|
||||
emphasis: {
|
||||
label: { show: props.showLabels },
|
||||
itemStyle: {
|
||||
areaColor: 'rgba(82,180,255,0.9)',
|
||||
borderColor: '#fff',
|
||||
borderWidth: 3
|
||||
}
|
||||
},
|
||||
itemStyle: {
|
||||
borderColor: themeStyles.borderColor,
|
||||
borderWidth: 2,
|
||||
shadowColor: themeStyles.shadowColor,
|
||||
shadowOffsetX: 2,
|
||||
shadowOffsetY: 15,
|
||||
shadowBlur: 15
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'map',
|
||||
map: 'china',
|
||||
aspectScale: 0.75,
|
||||
zoom: 1,
|
||||
label: {
|
||||
show: props.showLabels,
|
||||
color: '#fff',
|
||||
fontSize: 10
|
||||
},
|
||||
itemStyle: {
|
||||
borderColor: 'rgba(147,235,248,0.8)',
|
||||
borderWidth: 2,
|
||||
areaColor: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(147,235,248,0.3)' },
|
||||
{ offset: 1, color: 'rgba(32,120,207,0.9)' }
|
||||
]
|
||||
},
|
||||
shadowColor: 'rgba(32,120,207,1)',
|
||||
shadowOffsetY: 15,
|
||||
shadowBlur: 20
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
color: '#fff',
|
||||
fontSize: 12
|
||||
},
|
||||
itemStyle: {
|
||||
areaColor: 'rgba(82,180,255,0.9)',
|
||||
borderColor: '#fff',
|
||||
borderWidth: 3
|
||||
}
|
||||
},
|
||||
select: {
|
||||
label: {
|
||||
show: true,
|
||||
color: '#fff',
|
||||
fontWeight: 'bold'
|
||||
},
|
||||
itemStyle: {
|
||||
areaColor: '#4FAEFB',
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
}
|
||||
},
|
||||
data: mapData
|
||||
},
|
||||
// 散点标记配置(例如:城市标记)
|
||||
...(props.showScatter
|
||||
? [
|
||||
{
|
||||
name: '城市',
|
||||
type: 'scatter',
|
||||
coordinateSystem: 'geo',
|
||||
symbol: 'pin',
|
||||
symbolSize: 15,
|
||||
label: { show: false },
|
||||
itemStyle: {
|
||||
color: '#F99020',
|
||||
shadowBlur: 10,
|
||||
shadowColor: '#333'
|
||||
},
|
||||
data: [
|
||||
{ name: '北京', value: [116.405285, 39.904989, 100] },
|
||||
{ name: '上海', value: [121.472644, 31.231706, 100] },
|
||||
{ name: '深圳', value: [114.085947, 22.547, 100] }
|
||||
]
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化并渲染地图
|
||||
const initMap = async (): Promise<void> => {
|
||||
if (!chinaMapRef.value) return
|
||||
|
||||
chartInstance.value = echarts.init(chinaMapRef.value)
|
||||
|
||||
// 注册地图
|
||||
const mapData = mapJsonData.value.features.length > 0 ? mapJsonData.value : defaultMapJson
|
||||
echarts.registerMap('china', mapData as any)
|
||||
|
||||
const preparedData = props.mapData.length > 0 ? props.mapData : prepareMapData(mapData as any)
|
||||
const option = createChartOption(preparedData)
|
||||
|
||||
chartInstance.value.setOption(option)
|
||||
|
||||
// 绑定事件
|
||||
chartInstance.value.on('click', handleMapClick)
|
||||
|
||||
emit('renderComplete')
|
||||
}
|
||||
|
||||
// 处理地图点击事件
|
||||
const handleMapClick = (params: Record<string, unknown>) => {
|
||||
if (params.componentType === 'series') {
|
||||
const data = params.data as Record<string, unknown> | undefined
|
||||
const regionData = {
|
||||
name: params.name as string,
|
||||
adcode: (data?.adcode as string) || '',
|
||||
level: (data?.level as string) || ''
|
||||
}
|
||||
|
||||
console.log(`选中区域: ${params.name}`, params)
|
||||
|
||||
// 高亮选中区域
|
||||
chartInstance.value?.dispatchAction({
|
||||
type: 'select',
|
||||
seriesIndex: 0,
|
||||
dataIndex: params.dataIndex as number
|
||||
})
|
||||
|
||||
emit('regionClick', regionData)
|
||||
}
|
||||
}
|
||||
|
||||
// 窗口 resize 时调整图表大小
|
||||
const resizeChart = () => {
|
||||
chartInstance.value?.resize()
|
||||
}
|
||||
|
||||
// 处理组件销毁
|
||||
const cleanupChart = () => {
|
||||
if (chartInstance.value) {
|
||||
chartInstance.value.off('click', handleMapClick)
|
||||
chartInstance.value.dispose()
|
||||
chartInstance.value = null
|
||||
}
|
||||
window.removeEventListener('resize', resizeChart)
|
||||
}
|
||||
|
||||
// 生命周期钩子
|
||||
onMounted(async () => {
|
||||
await fetchMapData()
|
||||
if (!isEmpty.value) {
|
||||
initMap().then(() => {
|
||||
setTimeout(resizeChart, 100)
|
||||
})
|
||||
}
|
||||
window.addEventListener('resize', resizeChart)
|
||||
})
|
||||
|
||||
onUnmounted(cleanupChart)
|
||||
|
||||
// 监听主题变化,重新初始化地图
|
||||
watch(isDark, (newVal, oldVal) => {
|
||||
if (newVal !== oldVal && chartInstance.value) {
|
||||
cleanupChart()
|
||||
nextTick(() => {
|
||||
if (!isEmpty.value) {
|
||||
initMap()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 监听数据变化
|
||||
watch(
|
||||
() => props.mapData,
|
||||
() => {
|
||||
if (chartInstance.value && !isEmpty.value) {
|
||||
const mapData = props.mapData.length > 0 ? props.mapData : prepareMapData(mapJsonData.value as any)
|
||||
const option = createChartOption(mapData)
|
||||
chartInstance.value.setOption(option)
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// 监听地图数据变化
|
||||
watch(
|
||||
mapJsonData,
|
||||
() => {
|
||||
if (chartInstance.value && !isEmpty.value) {
|
||||
const mapData = props.mapData.length > 0 ? props.mapData : prepareMapData(mapJsonData.value as any)
|
||||
const option = createChartOption(mapData)
|
||||
chartInstance.value.setOption(option)
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
</script>
|
||||
@@ -83,9 +83,9 @@
|
||||
opacity: 1
|
||||
}"
|
||||
>
|
||||
<template v-if="AppConfig.systemInfo.name === '中科兴皖一体化门户'">
|
||||
<span class="system-name-main">中科兴皖</span>
|
||||
<span class="system-name-accent">一体化门户</span>
|
||||
<template v-if="AppConfig.systemInfo.name === '陇耘鲜达'">
|
||||
<span class="system-name-main">陇耘</span>
|
||||
<span class="system-name-accent">鲜达</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ AppConfig.systemInfo.name }}
|
||||
|
||||
@@ -38,7 +38,7 @@ import { headerBarConfig } from './modules/headerBar'
|
||||
const appConfig: SystemConfig = {
|
||||
// 系统信息
|
||||
systemInfo: {
|
||||
name: '中科兴皖一体化门户' // 系统名称
|
||||
name: '陇耘鲜达' // 系统名称
|
||||
},
|
||||
// 系统主题
|
||||
systemThemeStyles: {
|
||||
@@ -128,9 +128,7 @@ const appConfig: SystemConfig = {
|
||||
// 快速入口配置
|
||||
fastEnter: fastEnterConfig,
|
||||
// 顶部栏功能配置
|
||||
headerBar: headerBarConfig,
|
||||
/** 纯样式骨架:不接后端业务接口 */
|
||||
stylePreviewOnly: true
|
||||
headerBar: headerBarConfig
|
||||
}
|
||||
|
||||
export default Object.freeze(appConfig)
|
||||
@@ -1,125 +1,37 @@
|
||||
/**
|
||||
* 快速入口配置
|
||||
* 包含:应用列表、快速链接等配置
|
||||
* 快速入口配置(已裁剪为当前工程实际存在的路由)
|
||||
*/
|
||||
import { WEB_LINKS } from '@/utils/constants'
|
||||
import type { FastEnterConfig } from '@/types/config'
|
||||
|
||||
const fastEnterConfig: FastEnterConfig = {
|
||||
// 显示条件(屏幕宽度)
|
||||
minWidth: 1200,
|
||||
// 应用列表
|
||||
applications: [
|
||||
{
|
||||
name: '工作台',
|
||||
description: '系统概览与数据统计',
|
||||
description: '入口与参考页跳转',
|
||||
icon: 'ri:pie-chart-line',
|
||||
iconColor: '#377dff',
|
||||
iconColor: '#10b981',
|
||||
enabled: true,
|
||||
order: 1,
|
||||
routeName: 'Console'
|
||||
},
|
||||
{
|
||||
name: '分析页',
|
||||
description: '数据分析与可视化',
|
||||
icon: 'ri:game-line',
|
||||
iconColor: '#ff3b30',
|
||||
enabled: true,
|
||||
order: 2,
|
||||
routeName: 'Analysis'
|
||||
},
|
||||
{
|
||||
name: '礼花效果',
|
||||
description: '动画特效展示',
|
||||
icon: 'ri:loader-line',
|
||||
iconColor: '#7A7FFF',
|
||||
enabled: true,
|
||||
order: 3,
|
||||
routeName: 'Fireworks'
|
||||
},
|
||||
{
|
||||
name: '聊天',
|
||||
description: '即时通讯功能',
|
||||
icon: 'ri:user-line',
|
||||
iconColor: '#13DEB9',
|
||||
enabled: true,
|
||||
order: 4,
|
||||
routeName: 'Chat'
|
||||
},
|
||||
{
|
||||
name: '官方文档',
|
||||
description: '使用指南与开发文档',
|
||||
icon: 'ri:bill-line',
|
||||
iconColor: '#ffb100',
|
||||
enabled: true,
|
||||
order: 5,
|
||||
order: 2,
|
||||
link: WEB_LINKS.DOCS
|
||||
},
|
||||
{
|
||||
name: '技术支持',
|
||||
description: '技术支持与问题反馈',
|
||||
icon: 'ri:user-location-line',
|
||||
iconColor: '#ff6b6b',
|
||||
enabled: true,
|
||||
order: 6,
|
||||
link: WEB_LINKS.COMMUNITY
|
||||
},
|
||||
{
|
||||
name: '更新日志',
|
||||
description: '版本更新与变更记录',
|
||||
icon: 'ri:gamepad-line',
|
||||
iconColor: '#38C0FC',
|
||||
enabled: true,
|
||||
order: 7,
|
||||
routeName: 'ChangeLog'
|
||||
},
|
||||
{
|
||||
name: '哔哩哔哩',
|
||||
description: '技术分享与交流',
|
||||
icon: 'ri:bilibili-line',
|
||||
iconColor: '#FB7299',
|
||||
enabled: true,
|
||||
order: 8,
|
||||
link: WEB_LINKS.BILIBILI
|
||||
}
|
||||
],
|
||||
// 快速链接
|
||||
quickLinks: [
|
||||
{
|
||||
name: '登录',
|
||||
enabled: true,
|
||||
order: 1,
|
||||
routeName: 'Login'
|
||||
},
|
||||
{
|
||||
name: '注册',
|
||||
enabled: true,
|
||||
order: 2,
|
||||
routeName: 'Register'
|
||||
},
|
||||
{
|
||||
name: '忘记密码',
|
||||
enabled: true,
|
||||
order: 3,
|
||||
routeName: 'ForgetPassword'
|
||||
},
|
||||
{
|
||||
name: '定价',
|
||||
enabled: true,
|
||||
order: 4,
|
||||
routeName: 'Pricing'
|
||||
},
|
||||
{
|
||||
name: '个人中心',
|
||||
enabled: true,
|
||||
order: 5,
|
||||
routeName: 'UserCenter'
|
||||
},
|
||||
{
|
||||
name: '留言管理',
|
||||
enabled: true,
|
||||
order: 6,
|
||||
routeName: 'ArticleComment'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@ import '@styles/index.scss' // 样式
|
||||
import '@utils/sys/console.ts' // 控制台输出内容
|
||||
import { setupGlobDirectives } from './directives'
|
||||
import { setupErrorHandle } from './utils/sys/error-handle'
|
||||
import AppConfig from './config'
|
||||
import { useUserStore } from './store/modules/user'
|
||||
|
||||
function maskSecret(value?: string) {
|
||||
if (!value) return '(empty)'
|
||||
@@ -49,23 +47,6 @@ logApiCryptoBootInfo()
|
||||
|
||||
const app = createApp(App)
|
||||
initStore(app)
|
||||
|
||||
if (AppConfig.stylePreviewOnly) {
|
||||
const u = useUserStore()
|
||||
u.setLoginStatus(true)
|
||||
u.setToken('style-preview-token', 'style-preview-refresh')
|
||||
u.setUserInfo({
|
||||
id: '0',
|
||||
username: 'style-preview',
|
||||
realName: '样式预览',
|
||||
role: 'super_admin',
|
||||
phone: '',
|
||||
regionId: '',
|
||||
regionName: '',
|
||||
isActive: true
|
||||
} as Api.Auth.UserInfo)
|
||||
}
|
||||
|
||||
initRouter(app)
|
||||
setupGlobDirectives(app)
|
||||
setupErrorHandle(app)
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import type { AppRouteRecord } from '@/types/router'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { useAppMode } from '@/hooks/core/useAppMode'
|
||||
import { fetchGetMenuList } from '@/api/system-manage'
|
||||
import { fetchGetMenuList } from '@/api/admin'
|
||||
import { fetchGetMenuCodes } from '@/api/auth'
|
||||
import { asyncRoutes } from '../routes/asyncRoutes'
|
||||
import { RoutesAlias } from '../routesAlias'
|
||||
|
||||
@@ -52,7 +52,6 @@ import { fetchGetUserInfo } from '@/api/auth'
|
||||
import { ApiStatus } from '@/utils/http/status'
|
||||
import { isHttpError } from '@/utils/http/error'
|
||||
import { RouteRegistry, MenuProcessor, IframeRouteManager, RoutePermissionValidator } from '../core'
|
||||
import AppConfig from '@/config'
|
||||
|
||||
// 路由注册器实例
|
||||
let routeRegistry: RouteRegistry | null = null
|
||||
@@ -207,11 +206,6 @@ function handleLoginStatus(
|
||||
userStore: ReturnType<typeof useUserStore>,
|
||||
next: NavigationGuardNext
|
||||
): boolean {
|
||||
// 纯样式骨架:不校验登录态
|
||||
if (AppConfig.stylePreviewOnly) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 已登录或访问登录页或静态路由,直接放行
|
||||
if (userStore.isLogin || to.path === RoutesAlias.Login || isStaticRoute(to.path)) {
|
||||
return true
|
||||
@@ -272,10 +266,8 @@ async function handleDynamicRoutes(
|
||||
loadingService.showLoading()
|
||||
|
||||
try {
|
||||
// 1. 获取用户信息(样式骨架模式跳过接口)
|
||||
if (!AppConfig.stylePreviewOnly) {
|
||||
await fetchUserInfo()
|
||||
}
|
||||
// 1. 获取用户信息
|
||||
await fetchUserInfo()
|
||||
|
||||
// 2. 获取菜单数据
|
||||
const menuList = await menuProcessor.getMenuList()
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { AppRouteRecord } from '@/types/router'
|
||||
|
||||
export const articleRoutes: AppRouteRecord = {
|
||||
path: '/article',
|
||||
name: 'Article',
|
||||
component: '/index/index',
|
||||
meta: {
|
||||
title: 'menus.article.title',
|
||||
icon: 'ri:book-2-line',
|
||||
roles: ['R_SUPER', 'R_ADMIN']
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'article-list',
|
||||
name: 'ArticleList',
|
||||
component: '/article/list',
|
||||
meta: {
|
||||
title: 'menus.article.articleList',
|
||||
icon: 'ri:article-line',
|
||||
keepAlive: true,
|
||||
authList: [
|
||||
{ title: '新增', authMark: 'add' },
|
||||
{ title: '编辑', authMark: 'edit' }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'detail/:id',
|
||||
name: 'ArticleDetail',
|
||||
component: '/article/detail',
|
||||
meta: {
|
||||
title: 'menus.article.articleDetail',
|
||||
isHide: true,
|
||||
keepAlive: true,
|
||||
activePath: '/article/article-list'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'comment',
|
||||
name: 'ArticleComment',
|
||||
component: '/article/comment',
|
||||
meta: {
|
||||
title: 'menus.article.comment',
|
||||
icon: 'ri:mail-line',
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'publish',
|
||||
name: 'ArticlePublish',
|
||||
component: '/article/publish',
|
||||
meta: {
|
||||
title: 'menus.article.articlePublish',
|
||||
icon: 'ri:telegram-2-line',
|
||||
keepAlive: true,
|
||||
authList: [{ title: '发布', authMark: 'add' }]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { AppRouteRecord } from '@/types/router'
|
||||
import { WEB_LINKS } from '@/utils/constants'
|
||||
|
||||
export const helpRoutes: AppRouteRecord[] = [
|
||||
{
|
||||
name: 'Document',
|
||||
path: '',
|
||||
component: '',
|
||||
meta: {
|
||||
title: 'menus.help.document',
|
||||
icon: 'ri:bill-line',
|
||||
link: WEB_LINKS.DOCS,
|
||||
isIframe: false,
|
||||
keepAlive: false
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'LiteVersion',
|
||||
path: '',
|
||||
component: '',
|
||||
meta: {
|
||||
title: 'menus.help.liteVersion',
|
||||
icon: 'ri:bus-2-line',
|
||||
link: WEB_LINKS.LiteVersion,
|
||||
isIframe: false,
|
||||
keepAlive: false
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'OldVersion',
|
||||
path: '',
|
||||
component: '',
|
||||
meta: {
|
||||
title: 'menus.help.oldVersion',
|
||||
icon: 'ri:subway-line',
|
||||
link: WEB_LINKS.OldVersion,
|
||||
isIframe: false,
|
||||
keepAlive: false
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'ChangeLog',
|
||||
path: '/change/log',
|
||||
component: '/change/log/index',
|
||||
meta: {
|
||||
title: 'menus.plan.log',
|
||||
showTextBadge: `v${__APP_VERSION__}`,
|
||||
icon: 'ri:gamepad-line',
|
||||
keepAlive: false
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,7 +1,8 @@
|
||||
import { AppRouteRecord } from '@/types/router'
|
||||
import { styleReferenceRoutes } from './style-reference'
|
||||
import { sqxpAdminRoutes } from './sqxp'
|
||||
|
||||
/**
|
||||
* 动态路由(纯样式骨架:无业务菜单)
|
||||
* 动态菜单与路由(前端权限模式 VITE_ACCESS_MODE=frontend 时使用)
|
||||
* 陇耘鲜达业务菜单,与 backend/app/api/menu.py 中 get_admin_menu_list 对齐
|
||||
*/
|
||||
export const routeModules: AppRouteRecord[] = [...styleReferenceRoutes]
|
||||
export const routeModules: AppRouteRecord[] = [...sqxpAdminRoutes]
|
||||
@@ -1,33 +0,0 @@
|
||||
import { AppRouteRecord } from '@/types/router'
|
||||
|
||||
export const resultRoutes: AppRouteRecord = {
|
||||
path: '/result',
|
||||
name: 'Result',
|
||||
component: '/index/index',
|
||||
meta: {
|
||||
title: 'menus.result.title',
|
||||
icon: 'ri:checkbox-circle-line'
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'success',
|
||||
name: 'ResultSuccess',
|
||||
component: '/result/success/index',
|
||||
meta: {
|
||||
title: 'menus.result.success',
|
||||
icon: 'ri:checkbox-circle-line',
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'fail',
|
||||
name: 'ResultFail',
|
||||
component: '/result/fail/index',
|
||||
meta: {
|
||||
title: 'menus.result.fail',
|
||||
icon: 'ri:close-circle-line',
|
||||
keepAlive: true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { AppRouteRecord } from '@/types/router'
|
||||
|
||||
export const safeguardRoutes: AppRouteRecord = {
|
||||
path: '/safeguard',
|
||||
name: 'Safeguard',
|
||||
component: '/index/index',
|
||||
meta: {
|
||||
title: 'menus.safeguard.title',
|
||||
icon: 'ri:shield-check-line',
|
||||
keepAlive: false
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'server',
|
||||
name: 'SafeguardServer',
|
||||
component: '/safeguard/server/index',
|
||||
meta: {
|
||||
title: 'menus.safeguard.server',
|
||||
icon: 'ri:hard-drive-3-line',
|
||||
keepAlive: true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { AppRouteRecord } from '@/types/router'
|
||||
|
||||
/**
|
||||
* 前端路由(Demo 参考页 + 系统管理)
|
||||
* 与后端菜单对齐时请同步 `backend/app/api/menu.py`。
|
||||
*/
|
||||
export const sqxpAdminRoutes: AppRouteRecord[] = [
|
||||
{
|
||||
id: '10000000000000000000000000000002',
|
||||
name: 'Demo',
|
||||
path: '/demo',
|
||||
component: '/index/index',
|
||||
meta: {
|
||||
title: 'Demo',
|
||||
icon: 'ri:layout-grid-line',
|
||||
keepAlive: false,
|
||||
roles: ['admin']
|
||||
},
|
||||
children: [
|
||||
{
|
||||
id: '10000000000000000000000000000001_child',
|
||||
name: 'Console',
|
||||
path: 'console',
|
||||
component: '/dashboard/console/index',
|
||||
meta: {
|
||||
title: '工作台',
|
||||
icon: 'ri:home-smile-2-line',
|
||||
keepAlive: true,
|
||||
fixedTab: true,
|
||||
menuCode: 'dashboard'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '10000000000000000000000000000005',
|
||||
name: 'Unit',
|
||||
path: 'unit',
|
||||
component: '/system/unit/index',
|
||||
meta: { title: '商品单位', keepAlive: true }
|
||||
},
|
||||
{
|
||||
id: '10000000000000000000000000000021',
|
||||
name: 'OrderList',
|
||||
path: 'order',
|
||||
component: '/system/order/index',
|
||||
meta: { title: '订单列表', keepAlive: true }
|
||||
},
|
||||
{
|
||||
id: '10000000000000000000000000000006',
|
||||
name: 'Product',
|
||||
path: 'product',
|
||||
component: '/system/product/index',
|
||||
meta: { title: '商品管理', keepAlive: true }
|
||||
},
|
||||
{
|
||||
id: '10000000000000000000000000000004',
|
||||
name: 'Category',
|
||||
path: 'category',
|
||||
component: '/system/category/index',
|
||||
meta: { title: '商品分类', keepAlive: true }
|
||||
},
|
||||
{
|
||||
id: '10000000000000000000000000000007',
|
||||
name: 'User',
|
||||
path: 'user',
|
||||
component: '/system/user/index',
|
||||
meta: { title: '用户管理', keepAlive: true }
|
||||
},
|
||||
{
|
||||
id: '10000000000000000000000000000008',
|
||||
name: 'UserDetail',
|
||||
path: 'user/detail',
|
||||
component: '/system/user/detail',
|
||||
meta: { title: '用户详情', isHide: true, keepAlive: true }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '20000000000000000000000000000001',
|
||||
name: 'System',
|
||||
path: '/system',
|
||||
component: '/index/index',
|
||||
meta: {
|
||||
title: '系统管理',
|
||||
icon: 'ri:settings-3-line',
|
||||
keepAlive: false,
|
||||
roles: ['admin']
|
||||
},
|
||||
children: [
|
||||
{
|
||||
id: '20000000000000000000000000000002',
|
||||
name: 'Tenant',
|
||||
path: 'tenant',
|
||||
component: '/system/tenant/index',
|
||||
meta: { title: '租户管理', keepAlive: true }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1,47 +0,0 @@
|
||||
import type { AppRouteRecord } from '@/types/router'
|
||||
|
||||
/**
|
||||
* 仅保留界面样式参考:Mint 列表演示、登录区布局、Token 面板
|
||||
*/
|
||||
export const styleReferenceRoutes: AppRouteRecord[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'StylePreview',
|
||||
path: '/preview',
|
||||
component: '/index/index',
|
||||
meta: {
|
||||
title: '样式参考',
|
||||
icon: 'ri:palette-line',
|
||||
keepAlive: false,
|
||||
roles: ['admin']
|
||||
},
|
||||
children: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'MintListDemo',
|
||||
path: 'mint-list',
|
||||
component: '/style-reference/delivery-demo/index',
|
||||
meta: {
|
||||
title: 'Mint 列表骨架',
|
||||
keepAlive: true,
|
||||
fixedTab: true,
|
||||
menuCode: 'dashboard'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'LoginSample',
|
||||
path: 'login-sample',
|
||||
component: '/style-reference/login-sample/index',
|
||||
meta: { title: '登录页样式', keepAlive: false }
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'MintTokens',
|
||||
path: 'mint-tokens',
|
||||
component: '/style-reference/mint-tokens/index',
|
||||
meta: { title: 'Mint Token 面板', keepAlive: false }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1,15 +1,41 @@
|
||||
import { AppRouteRecordRaw } from '@/utils/router'
|
||||
|
||||
/**
|
||||
* 静态路由(样式骨架:无登录/注册页)
|
||||
* 静态路由配置(不需要权限就能访问的路由)
|
||||
*
|
||||
* 属性说明:
|
||||
* isHideTab: true 表示不在标签页中显示
|
||||
*
|
||||
* 注意事项:
|
||||
* 1、path、name 不要和动态路由冲突,否则会导致路由冲突无法访问
|
||||
* 2、静态路由不管是否登录都可以访问
|
||||
*/
|
||||
export const staticRoutes: AppRouteRecordRaw[] = [
|
||||
// 不需要登录就能访问的路由示例
|
||||
// {
|
||||
// path: '/welcome',
|
||||
// name: 'WelcomeStatic',
|
||||
// component: () => import('@views/dashboard/console/index.vue'),
|
||||
// meta: { title: 'menus.dashboard.title' }
|
||||
// },
|
||||
{
|
||||
path: '/auth/login',
|
||||
name: 'Login',
|
||||
component: () => import('@views/auth/login/index.vue'),
|
||||
meta: { title: 'menus.login.title', isHideTab: true }
|
||||
},
|
||||
{
|
||||
path: '/403',
|
||||
name: 'Exception403',
|
||||
component: () => import('@views/exception/403/index.vue'),
|
||||
meta: { title: '403', isHideTab: true }
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'Exception404',
|
||||
component: () => import('@views/exception/404/index.vue'),
|
||||
meta: { title: '404', isHideTab: true }
|
||||
},
|
||||
{
|
||||
path: '/500',
|
||||
name: 'Exception500',
|
||||
@@ -17,9 +43,18 @@ export const staticRoutes: AppRouteRecordRaw[] = [
|
||||
meta: { title: '500', isHideTab: true }
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'Exception404',
|
||||
component: () => import('@views/exception/404/index.vue'),
|
||||
meta: { title: '404', isHideTab: true }
|
||||
path: '/outside',
|
||||
component: () => import('@views/index/index.vue'),
|
||||
name: 'Outside',
|
||||
meta: { title: 'menus.outside.title' },
|
||||
children: [
|
||||
// iframe 内嵌页面
|
||||
{
|
||||
path: '/outside/iframe/:path',
|
||||
name: 'Iframe',
|
||||
component: () => import('@/views/outside/Iframe.vue'),
|
||||
meta: { title: 'iframe' }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -91,10 +91,10 @@ export const useWorktabStore = defineStore(
|
||||
|
||||
/**
|
||||
* 过滤不应展示在 worktab 中的父级占位路由。
|
||||
* `/dashboard` 仅用于承载与重定向,真实标签页应为 `/dashboard/console`。
|
||||
* `/demo` 仅用于承载与重定向,真实标签页为其子路由(如 `/demo/console`)。
|
||||
*/
|
||||
const shouldIgnoreTab = (tab: Partial<WorkTab>): boolean => {
|
||||
return tab.path === '/dashboard'
|
||||
return tab.path === '/demo' || tab.path === '/system'
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Vendored
+15
-1
@@ -14,7 +14,7 @@ declare namespace Api {
|
||||
regionName?: string
|
||||
email?: string
|
||||
roles?: string[]
|
||||
/** 自定义头像 URL,空则前端使用系统默认 logo */
|
||||
/** 自定义头像 URL,空则前端使用叁强 logo */
|
||||
avatarUrl?: string | null
|
||||
}
|
||||
|
||||
@@ -92,6 +92,20 @@ declare namespace Api {
|
||||
}
|
||||
}
|
||||
|
||||
namespace Tenant {
|
||||
interface TenantItem {
|
||||
id: string
|
||||
tenantNo: string
|
||||
tenantName: string
|
||||
contactName: string
|
||||
contactPhone: string
|
||||
orderNum: number
|
||||
remark: string
|
||||
createTime: string
|
||||
updateTime: string
|
||||
}
|
||||
}
|
||||
|
||||
namespace Product {
|
||||
interface ProductItem {
|
||||
id: string
|
||||
|
||||
@@ -144,8 +144,6 @@ export interface SystemConfig {
|
||||
fastEnter?: FastEnterConfig
|
||||
// 顶部栏功能配置
|
||||
headerBar?: HeaderBarFeatureConfig
|
||||
/** 仅保留界面样式参考:跳过登录与后端菜单/用户信息请求 */
|
||||
stylePreviewOnly?: boolean
|
||||
}
|
||||
|
||||
// 环境配置
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** 未设置头像时统一使用系统默认 logo(与登录页、侧栏 ArtLogo 一致) */
|
||||
/** 未设置头像时统一使用叁强 logo(与登录页、侧栏 ArtLogo 一致) */
|
||||
export const DEFAULT_USER_AVATAR_URL = '/logo1.jpg'
|
||||
|
||||
/** @deprecated 各角色默认已统一为 logo,保留参数仅为兼容旧调用 */
|
||||
@@ -6,7 +6,7 @@ export function defaultAvatarPathByRole(_role: string | undefined): string {
|
||||
return DEFAULT_USER_AVATAR_URL
|
||||
}
|
||||
|
||||
/** 展示用头像:有自定义 URL 用自定义,否则用系统默认 logo */
|
||||
/** 展示用头像:有自定义 URL 用自定义,否则用叁强 logo */
|
||||
export function resolveUserAvatarUrl(avatarUrl: string | null | undefined, role: string | undefined): string {
|
||||
const u = avatarUrl != null ? String(avatarUrl).trim() : ''
|
||||
if (u) return u
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
<!-- 登录页面 -->
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<!-- 左侧品牌区域 -->
|
||||
<div class="login-left">
|
||||
<div class="brand-content">
|
||||
<div class="logo">
|
||||
<img class="logo-img" src="/logo1.jpg" alt="陇耘鲜达" />
|
||||
</div>
|
||||
<h1 class="slogan">专业生鲜配送管理平台</h1>
|
||||
<p class="description">
|
||||
高效 · 智能 · 可信赖
|
||||
</p>
|
||||
<div class="features">
|
||||
<div class="feature-item">
|
||||
<i class="el-icon-present"></i>
|
||||
<span>订单管理</span>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<i class="el-icon-truck"></i>
|
||||
<span>配送调度</span>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<i class="el-icon-data-analysis"></i>
|
||||
<span>数据统计</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧登录表单区域 -->
|
||||
<div class="login-right">
|
||||
<div class="form-container">
|
||||
<div class="form-header">
|
||||
<h2>欢迎登录</h2>
|
||||
<p>请输入您的账号信息</p>
|
||||
</div>
|
||||
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
@keyup.enter="handleSubmit"
|
||||
class="login-form"
|
||||
>
|
||||
<el-form-item prop="username">
|
||||
<el-input
|
||||
v-model.trim="formData.username"
|
||||
placeholder="请输入用户名"
|
||||
size="large"
|
||||
:prefix-icon="User"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model.trim="formData.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
size="large"
|
||||
:prefix-icon="Lock"
|
||||
show-password
|
||||
autocomplete="off"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="loading"
|
||||
class="login-btn"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
登 录
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="form-footer">
|
||||
<span class="copyright">陇耘鲜达 © {{ currentYear }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { fetchLogin } from '@/api/auth'
|
||||
import { ElNotification, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { User, Lock } from '@element-plus/icons-vue'
|
||||
|
||||
defineOptions({ name: 'Login' })
|
||||
|
||||
const currentYear = new Date().getFullYear()
|
||||
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
const formData = reactive({
|
||||
username: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const rules: FormRules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
// 登录
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
try {
|
||||
const valid = await formRef.value.validate()
|
||||
if (!valid) return
|
||||
|
||||
loading.value = true
|
||||
|
||||
const { token, refreshToken, userInfo } = await fetchLogin({
|
||||
username: formData.username,
|
||||
password: formData.password
|
||||
})
|
||||
|
||||
if (!token) {
|
||||
throw new Error('登录失败:未收到令牌')
|
||||
}
|
||||
|
||||
// 存储 token 和登录状态
|
||||
userStore.setToken(token, refreshToken)
|
||||
userStore.setLoginStatus(true)
|
||||
userStore.setUserInfo(userInfo)
|
||||
|
||||
ElNotification({
|
||||
title: '登录成功',
|
||||
type: 'success',
|
||||
duration: 2500,
|
||||
message: `欢迎回来,${userInfo.realName || userInfo.username}!`
|
||||
})
|
||||
|
||||
// 获取 redirect 参数,如果存在则跳转到指定页面,否则跳转到首页
|
||||
const redirect = route.query.redirect as string
|
||||
router.push(redirect || '/')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '登录失败,请稍后重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.login-container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.login-left {
|
||||
flex: 1;
|
||||
background: linear-gradient(135deg, #1989fa 0%, #07c160 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.brand-content {
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.logo-img {
|
||||
width: min(200px, 55vw);
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.slogan {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 18px;
|
||||
opacity: 0.9;
|
||||
margin: 0 0 48px 0;
|
||||
}
|
||||
|
||||
.features {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 40px;
|
||||
}
|
||||
|
||||
.feature-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
i {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.login-right {
|
||||
width: 480px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
padding: 40px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.form-container {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.form-header {
|
||||
margin-bottom: 40px;
|
||||
text-align: center;
|
||||
|
||||
h2 {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.login-form {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
:deep(.el-input__wrapper) {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
:deep(.el-input__inner) {
|
||||
height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
font-size: 16px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #1989fa 0%, #07c160 100%);
|
||||
border: none;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.form-footer {
|
||||
margin-top: 40px;
|
||||
text-align: center;
|
||||
|
||||
.copyright {
|
||||
font-size: 12px;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,38 @@
|
||||
@reference '@styles/core/tailwind.css';
|
||||
|
||||
/* 授权页右侧区域 */
|
||||
.auth-right-wrap {
|
||||
@apply absolute inset-0 w-[440px] h-[650px] py-[5px] m-auto overflow-hidden
|
||||
max-sm:px-7 max-sm:w-full
|
||||
animate-[slideInRight_0.6s_cubic-bezier(0.25,0.46,0.45,0.94)_forwards]
|
||||
max-md:animate-none;
|
||||
|
||||
.form {
|
||||
@apply h-full py-[40px];
|
||||
}
|
||||
|
||||
.title {
|
||||
@apply text-g-900 text-4xl font-semibold max-md:text-3xl max-sm:pt-10;
|
||||
}
|
||||
|
||||
.sub-title {
|
||||
@apply mt-[10px] text-g-600 text-sm;
|
||||
}
|
||||
|
||||
.custom-height {
|
||||
@apply !h-[40px];
|
||||
}
|
||||
}
|
||||
|
||||
/* 滑入动画 */
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(30px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<!-- 精简工作台:无业务统计接口,仅作入口与 UI 规范说明 -->
|
||||
<template>
|
||||
<div class="console-container console-lite">
|
||||
<div class="page-head">
|
||||
<h1>工作台</h1>
|
||||
<p class="page-desc">以下为固定参考页,新增管理端界面请对照布局与类名;视觉以文档为准。</p>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-top">
|
||||
<h2 class="title-bar-heading">快速入口</h2>
|
||||
</div>
|
||||
<div class="console-lite__body">
|
||||
<p class="console-lite__hint">详见仓库根目录 `.cursorrules` 中「页面级 UI 模板(固定五页)」。</p>
|
||||
<div class="console-lite__links">
|
||||
<el-button
|
||||
v-for="item in links"
|
||||
:key="item.path"
|
||||
class="btn-main"
|
||||
type="primary"
|
||||
@click="go(item.path)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
defineOptions({ name: 'Console' })
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const links = [
|
||||
{ label: '商品单位', path: '/demo/unit' },
|
||||
{ label: '订单列表', path: '/demo/order' },
|
||||
{ label: '商品管理', path: '/demo/product' },
|
||||
{ label: '商品分类', path: '/demo/category' },
|
||||
{ label: '用户管理', path: '/demo/user' }
|
||||
]
|
||||
|
||||
const go = (path: string) => {
|
||||
router.push(path)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.console-lite__body {
|
||||
padding: 20px 24px 24px;
|
||||
}
|
||||
|
||||
.console-lite__hint {
|
||||
margin: 0 0 16px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--art-text-gray-600, #64748b);
|
||||
}
|
||||
|
||||
.console-lite__links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,36 +1,42 @@
|
||||
<!-- 外链 iframe 占位(动态路由中 isIframe 时使用) -->
|
||||
<template>
|
||||
<div class="iframe-page">
|
||||
<iframe v-if="src" class="iframe-page__frame" :src="src" title="external" />
|
||||
<p v-else class="iframe-page__empty">未配置链接</p>
|
||||
<div class="box-border w-full h-full" v-loading="isLoading">
|
||||
<iframe
|
||||
ref="iframeRef"
|
||||
:src="iframeUrl"
|
||||
frameborder="0"
|
||||
class="w-full h-full min-h-[calc(100vh-120px)] border-none"
|
||||
@load="handleIframeLoad"
|
||||
></iframe>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { IframeRouteManager } from '@/router/core'
|
||||
|
||||
defineOptions({ name: 'IframePage' })
|
||||
defineOptions({ name: 'IframeView' })
|
||||
|
||||
const route = useRoute()
|
||||
const src = computed(() => (route.meta?.link as string) || '')
|
||||
const isLoading = ref(true)
|
||||
const iframeUrl = ref('')
|
||||
const iframeRef = ref<HTMLIFrameElement | null>(null)
|
||||
|
||||
/**
|
||||
* 初始化 iframe URL
|
||||
* 从路由配置中获取对应的外部链接地址
|
||||
*/
|
||||
onMounted(() => {
|
||||
const iframeRoute = IframeRouteManager.getInstance().findByPath(route.path)
|
||||
|
||||
if (iframeRoute?.meta) {
|
||||
iframeUrl.value = iframeRoute.meta.link || ''
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 处理 iframe 加载完成事件
|
||||
* 隐藏加载状态
|
||||
*/
|
||||
const handleIframeLoad = (): void => {
|
||||
isLoading.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.iframe-page {
|
||||
height: 100%;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.iframe-page__frame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 400px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.iframe-page__empty {
|
||||
padding: 24px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,205 +0,0 @@
|
||||
<!-- 登录页布局样式参考(不请求后端) -->
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-left">
|
||||
<div class="brand-content">
|
||||
<div class="logo">
|
||||
<img class="logo-img" src="/logo1.jpg" alt="中科兴皖一体化门户" />
|
||||
</div>
|
||||
<h1 class="slogan">一体化业务管理与服务平台</h1>
|
||||
<p class="description">本页仅作布局与配色参考</p>
|
||||
<div class="features">
|
||||
<div class="feature-item"><span>面板</span></div>
|
||||
<div class="feature-item"><span>表格</span></div>
|
||||
<div class="feature-item"><span>表单</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="login-right">
|
||||
<div class="form-container">
|
||||
<div class="form-header">
|
||||
<h2>欢迎登录</h2>
|
||||
<p>演示表单,提交不会调用接口</p>
|
||||
</div>
|
||||
<el-form ref="formRef" :model="formData" :rules="rules" class="login-form">
|
||||
<el-form-item prop="username">
|
||||
<el-input v-model.trim="formData.username" placeholder="用户名" size="large" :prefix-icon="User" />
|
||||
</el-form-item>
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model.trim="formData.password"
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
size="large"
|
||||
:prefix-icon="Lock"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" size="large" class="login-btn" @click="onSubmit">登 录</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="form-footer">
|
||||
<span class="copyright">中科兴皖一体化门户 © {{ currentYear }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { User, Lock } from '@element-plus/icons-vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
defineOptions({ name: 'LoginSample' })
|
||||
|
||||
const currentYear = new Date().getFullYear()
|
||||
const formRef = ref<FormInstance>()
|
||||
const formData = reactive({ username: '', password: '' })
|
||||
const rules: FormRules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate().catch(() => {})
|
||||
ElMessage.info('样式参考页:未连接后端')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.login-container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.login-left {
|
||||
flex: 1;
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.brand-content {
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.logo-img {
|
||||
width: min(200px, 55vw);
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.slogan {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 16px;
|
||||
opacity: 0.95;
|
||||
margin: 0 0 48px 0;
|
||||
}
|
||||
|
||||
.features {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 40px;
|
||||
}
|
||||
|
||||
.feature-item span {
|
||||
font-size: 14px;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.login-right {
|
||||
width: 480px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
padding: 40px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.form-container {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.form-header {
|
||||
margin-bottom: 40px;
|
||||
text-align: center;
|
||||
|
||||
h2 {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.login-form {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
:deep(.el-input__wrapper) {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
:deep(.el-input__inner) {
|
||||
height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
font-size: 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.form-footer {
|
||||
margin-top: 40px;
|
||||
text-align: center;
|
||||
|
||||
.copyright {
|
||||
font-size: 12px;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,98 +0,0 @@
|
||||
<!-- Fresh Mint 设计 Token 与面板片段参考(无接口) -->
|
||||
<template>
|
||||
<div class="page-container mint-tokens-page">
|
||||
<header class="page-head">
|
||||
<h1>Mint Token 与面板</h1>
|
||||
<p class="page-desc">与全局 SCSS 变量、Element 主色链对齐的局部示意,便于新页面拷贝结构。</p>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-top">
|
||||
<h2 class="title-bar-heading">色板</h2>
|
||||
</div>
|
||||
<div class="token-grid">
|
||||
<div v-for="t in tokens" :key="t.name" class="token-card">
|
||||
<div class="token-swatch" :style="{ background: t.value }" />
|
||||
<div class="token-meta">
|
||||
<div class="token-name">{{ t.name }}</div>
|
||||
<div class="token-value">{{ t.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-top">
|
||||
<h2 class="title-bar-heading">按钮</h2>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<el-button type="primary">主按钮</el-button>
|
||||
<el-button>次要</el-button>
|
||||
<el-button plain>描边</el-button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'MintTokensSample' })
|
||||
|
||||
const tokens = [
|
||||
{ name: '--primary', value: '#10b981' },
|
||||
{ name: '--primary-dark', value: '#059669' },
|
||||
{ name: '--primary-bg', value: '#ecfdf5' },
|
||||
{ name: '--text', value: '#1e293b' },
|
||||
{ name: '--text-muted', value: '#64748b' },
|
||||
{ name: '--border', value: '#d9e8e2' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.mint-tokens-page {
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.token-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.token-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--default-border-color, #d9e8e2);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.token-swatch {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.token-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.token-value {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
|
||||
.btn-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user