Compare commits

...

14 Commits

Author SHA1 Message Date
若拙_233
45aae516b2 fix: 上传短信并通知按钮逻辑修改 2025-12-19 13:57:30 +08:00
若拙_233
4110dca428 fix: 提交审核页面逻辑修改 2025-12-18 22:38:13 +08:00
f17c1678c8 Merge branch 'main' of https://git.1024tool.vip/zfc/guzhi 2025-12-18 19:14:06 +08:00
1157704d4b feat: Implement user account soft deletion with token blacklisting, admin management, and SMS verification tracking. 2025-12-18 19:14:03 +08:00
Wei_佳
17b56a1c19 docs: 更新“近三年机构收益/万元”字段的提示文本并优化部分组件属性排版 2025-12-18 14:30:07 +08:00
若拙_233
6718b51fb9 fix: 新增7天浏览量 2025-12-10 16:23:24 +08:00
若拙_233
58f16be457 fix: 新增账号注销功能 2025-12-09 17:51:12 +08:00
Wei_佳
90c0f85972 feat: 为加载图标添加旋转动画并优化评估中提示文案 2025-12-05 15:40:30 +08:00
Wei_佳
7819c60ace fix: 调整发票页面气泡提示框的显示位置。 2025-12-04 17:47:40 +08:00
Wei_佳
1d73f6ed54 feat: 页面初始化时添加加载状态及UI展示,并在加载期间隐藏其他内容 2025-12-04 17:36:44 +08:00
Wei_佳
6b5967a4bb Merge branch 'main' of https://git.1024tool.vip/zfc/guzhi
* 'main' of https://git.1024tool.vip/zfc/guzhi:
  feat(valuation): 添加update_calc方法并更新相关字段类型
2025-12-04 17:23:31 +08:00
Wei_佳
8926e047d4 style: 调整评估中状态页面的布局和间距。 2025-12-04 15:34:29 +08:00
f1c1db580c refactor(user-center): 将企业转账组件中的n-button替换为普通button
简化按钮样式实现,移除不必要的属性配置
2025-12-04 14:44:23 +08:00
b10c357a56 feat(valuation): 添加update_calc方法并更新相关字段类型
为ValuationController添加update_calc方法用于计算更新
将updated_at等字段改为Optional类型
修复heritage_level字段获取方式
更新docker镜像版本至v2.7
2025-12-02 18:50:46 +08:00
54 changed files with 2174 additions and 937 deletions

View File

@ -1,131 +0,0 @@
## 目标概述
* 加强“交易管理-发票附件与发送邮箱”能力,完善数据记录与事务保障。
* 改进“开票弹窗附件上传”支持多文件上传(后端存储结构)。
* 优化“用户管理备注与操作记录”,区分备注维度并完善日志。
* 覆盖单元/集成测试、数据库迁移、API文档与审计日志。
## 数据库与模型变更
* EmailSendLog
* 新增:`extra: JSONField(null=True)` 完整记录发送邮箱的请求数据(收件人、主题、正文、附件列表、重试信息等)。
* Invoice或与开票弹窗相关的业务模型
* 新增:`attachments: JSONField(null=True)` 支持多个附件URL与弹窗上传对应
* <br />
* 迁移脚本:创建/修改上述字段;保留历史数据不丢失。
## 事务与原子性
* 发送邮箱流程(交易管理)
* 封装在 `tortoise.transactions.in_transaction()`邮件发送、EmailSendLog写入含attachments/extra原子提交失败回滚。
* extra写入内容完整`SendEmailRequest`收件人、主题、正文、附件URL/文件名、重试次数、客户端UA等
* 多文件上传至发票附件(开票弹窗)
* 更新发票的 `attachments` 字段在同一事务内写入如任一URL校验失败则回滚。
## 后端接口改造
* 上传组件(后端)
* 新增:`POST /api/v1/upload/files` 接收 `files: List[UploadFile]`,统一返回 `urls: string[]`;保留现有单文件接口向后兼容。
* 交易管理(管理员)
* `POST /api/v1/transactions/send-email` 扩展:
* 入参支持 `file_urls: string[]`(与/或单文件),服务端聚合附件;
* 在事务中记录 EmailSendLog含attachments与extra返回log\_id与状态
* 回显接口(详情/列表)新增 `extra` 字段完整展示发送记录。
* 开票弹窗附件(管理员/或对应端)
* 新增/改造:`PUT /api/v1/invoice/{id}/attachments` 入参 `urls: string[]`,更新发票 `attachments`
* 列表/详情回显 `attachments: string[]`
* 用户管理备注优化(管理员端)
* 新接口:`PUT /api/v1/app-user-admin/{user_id}/notes`
* 入参:`system_notes?: string``user_notes?: string`(可选择性更新)
* 逻辑:仅更新提供的字段;不影响其他字段。
* 修复:`POST /api/v1/app-user-admin/quota` 仅调整次数,不再自动写入 `user.notes`
* 操作日志:在调整配额、更新备注、停用启用等操作时写入 `AppUserOperationLog`
## 前端改造(要点)
* 多文件上传组件
* 改为多选/拖拽支持;对每个文件显示上传进度与失败重试;
* 成功后收集URL数组写入发票 `attachments` 或作为邮件附件来源;
* 兼容旧接口若后端仅返回单URL前端仍正常显示降级为单文件模式
* 开票弹窗
* 支持附件列表预览与移除;提交时调用 `PUT /invoice/{id}/attachments`
* 邮件发送弹窗
* 选择附件来源(已上传的附件/本地文件再上传);提交后在详情页面完整回显 `extra`(含附件清单与正文等)。
## 审计与日志
* 关键操作:邮件发送、发票附件更新、用户备注更新、配额调整
* 统一调用审计记录(路由中间件已存在,补充结构化日志:`logger.info()` + DB审计表/操作日志表写入)。
## 测试方案
* 单元测试
* Email发送控制器事务成功/失败回滚(模拟抛错)
* 多文件上传文件类型校验、URL数组返回、尾随空白处理
* 备注更新:选择性字段更新、不影响其他字段
* 集成测试FastAPI + httpx.AsyncClient
* 发送邮箱请求→持久化校验attachments/extra→回显接口校验
* 附件上传:批量上传、更新发票、列表/详情回显
* 用户备注接口调用→DB值校验→操作日志存在
## 迁移与兼容
* 使用现有迁移工具如Aerich生成并应用迁移新增JSON/Text字段
* 前端保留旧接口兼容若上传仍走单文件后端返回数组长度1
* API文档OpenAPI
* 补充/更新以上端点的schema说明、示例请求/响应、错误码约定;
## 实施步骤与交付
1. 数据模型与迁移脚本编写与应用EmailSendLog、Invoice、AppUser/OperationLog
2. 后端接口改造与事务封装(邮件发送、多文件上传、发票附件更新、备注接口)。
3. 前端组件与弹窗改造多文件上传、进度与错误处理、回显extra
4. 审计日志与结构化日志补充。
5. 单元与集成测试编写,覆盖核心路径。
6. 更新接口文档与部署回归验证。

View File

@ -1,34 +0,0 @@
## 目标
* 添加抬头时将 `公司名称``公司税号``电子邮箱`设为必填,其他字段可为空。
* 现状确认(代码引用)
- 后端必填:`app/schemas/invoice.py:612``InvoiceHeaderCreate` 已要求 `company_name``tax_number` 为必填,`email: EmailStr` 为必填。
- API 入口:`app/api/v1/app_invoices/app_invoices.py:5558` 新增抬头接口使用 `InvoiceHeaderCreate`,后端将严格校验三项必填。
## 修改方案
1. 统一前端校验文案
* 统一三项必填的错误提示为简洁中文,如:“请输入公司名称 / 公司税号 / 电子邮箱”。
* 邮箱格式提示统一为:“请输入有效的电子邮箱”。
2. 后端校验与返回确认
* 保持 `InvoiceHeaderCreate` 的必填与格式限制不变(`app/schemas/invoice.py:612`)。
* 确认更新接口 `InvoiceHeaderUpdate``app/schemas/invoice.py:3239`)允许局部更新、但不影响创建必填逻辑。
3. 验证与测试
* 后端接口验证:对 `POST /app-invoices/headers``app/api/v1/app_invoices/app_invoices.py:5558`)进行用例:缺失任一必填字段应返回 422全部正确应 200/201。
* 可补充最小化单元测试Pydantic 校验用例覆盖必填与格式。
## 交付内容。
* 完成基本交互与接口验证,确保行为符合预期。

225
DEPLOYMENT.md Normal file
View File

@ -0,0 +1,225 @@
# 非遗资产估值系统 - 部署文档
## 项目概述
非遗资产估值系统是一个基于 Vue.js + FastAPI 的全栈应用,用于非物质文化遗产资产的价值评估。
- **前端**: Vue.js + Vite + pnpm
- **后端**: Python 3.11 + FastAPI + Tortoise ORM
- **数据库**: MySQL
- **容器化**: Docker
---
## 目录结构
```
youshu-guzhi/
├── app/ # 后端 FastAPI 应用
│ ├── api/ # API 路由
│ ├── controllers/ # 业务控制器
│ ├── models/ # 数据库模型
│ ├── schemas/ # Pydantic 数据模型
│ ├── settings/ # 配置文件
│ └── utils/ # 工具函数和计算引擎
├── web/ # 前端 Vue.js 应用
├── deploy/ # 部署相关文件
│ ├── entrypoint.sh # 容器启动脚本
│ └── web.conf # Nginx 配置
├── Dockerfile # Docker 构建文件
├── requirements.txt # Python 依赖
└── run.py # 应用启动入口
```
---
## 环境配置
### 数据库配置
#### 使用 Docker 部署 MySQL
```bash
# 创建数据目录
mkdir -p ~/mysql-data
# 启动 MySQL 容器
docker run -d \
--name mysql-valuation \
-p 3306:3306 \
-e MYSQL_ROOT_PASSWORD=your_password \
-e MYSQL_DATABASE=valuation_service \
-v ~/mysql-data:/var/lib/mysql \
--restart=unless-stopped \
mysql:8.0
```
#### 应用配置
配置文件位置: `app/settings/config.py`
```python
TORTOISE_ORM = {
"connections": {
"mysql": {
"engine": "tortoise.backends.mysql",
"credentials": {
"host": "your_mysql_host", # 数据库主机地址
"port": 3306, # 数据库端口
"user": "root", # 数据库用户名
"password": "your_password", # 数据库密码
"database": "valuation_service", # 数据库名称
},
},
},
...
}
```
### 第三方服务配置
| 服务 | 配置项 | 说明 |
|-----|-------|------|
| 阿里云短信 | `ALIBABA_CLOUD_ACCESS_KEY_ID/SECRET` | 短信验证码发送 |
| 阿里云邮件 | `SMTP_*` | 邮件发送 |
---
## 本地开发
### 1. 安装依赖
```bash
# 安装 Python 依赖
pip install -r requirements.txt
# 安装前端依赖
cd web
pnpm install
```
### 2. 启动服务
```bash
# 启动后端 (端口 9999)
python run.py
# 启动前端开发服务器 (另一个终端)
cd web
pnpm run dev
```
---
## Docker 部署
### 1. 构建镜像
```bash
# 设置平台 (M1/M2 Mac 需要)
export DOCKER_DEFAULT_PLATFORM=linux/amd64
# 构建镜像
docker build -t zfc931912343/guzhi-fastapi-admin:v3.9 .
# 推送到 Docker Hub
docker push zfc931912343/guzhi-fastapi-admin:v3.9
```
### 2. 部署到服务器
#### 生产环境
```bash
# 创建数据目录
mkdir -p ~/guzhi-data/static/images
# 拉取并运行
docker pull nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v3.9 \
&& docker rm -f guzhi_pro \
&& docker run -itd \
--name=guzhi_pro \
-p 8080:9999 \
-v ~/guzhi-data/static/images:/opt/vue-fastapi-admin/app/static/images \
--restart=unless-stopped \
-e TZ=Asia/Shanghai \
nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v3.9
```
#### 开发/测试环境
```bash
docker pull nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v3.9 \
&& docker rm -f guzhi_dev \
&& docker run -itd \
--name=guzhi_dev \
-p 9990:9999 \
-v ~/guzhi-data/static:/opt/vue-fastapi-admin/app/static \
--restart=unless-stopped \
-e TZ=Asia/Shanghai \
nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v3.9
```
---
## 端口说明
| 环境 | 容器名 | 主机端口 | 容器端口 |
|-----|-------|---------|---------|
| 生产 | guzhi_pro | 8080 | 9999 |
| 开发 | guzhi_dev | 9990 | 9999 |
---
## 数据持久化
容器挂载的数据目录:
```
~/guzhi-data/static/images -> /opt/vue-fastapi-admin/app/static/images
```
用于存储用户上传的图片文件(如非遗纹样图片、证书图片等)。
---
## 常用运维命令
```bash
# 查看容器日志
docker logs -f guzhi_pro
# 进入容器
docker exec -it guzhi_pro bash
# 重启容器
docker restart guzhi_pro
# 查看容器状态
docker ps | grep guzhi
```
---
## API 接口说明
| 模块 | 路径前缀 | 说明 |
|-----|---------|------|
| 用户端估值 | `/api/v1/app-valuations/` | 用户提交估值请求 |
| 管理端估值 | `/api/v1/valuations/` | 管理后台查看/审核 |
| 计算报告 | `/api/v1/valuations/{id}/report` | 获取计算过程报告 |
---
## 版本历史
| 版本 | 日期 | 说明 |
|-----|------|------|
| v3.9 | 2025-12-18 | 修复风险调整系数B3显示问题添加计算过程详情 |
| v3.8 | 2025-12-18 | 修复历史传承度HI权重计算 |
---
## 联系信息
如有问题,请联系项目负责人。

View File

@ -1,8 +1,14 @@
FROM node:18.12.0-alpine3.16 AS web
FROM node:18-alpine AS web
WORKDIR /opt/vue-fastapi-admin
COPY /web ./web
RUN npm install -g pnpm && cd /opt/vue-fastapi-admin/web && pnpm install --registry=https://registry.npmmirror.com && pnpm run build
# 安装pnpm并设置配置
RUN npm install -g pnpm && \
cd /opt/vue-fastapi-admin/web && \
pnpm config set registry https://registry.npmmirror.com && \
pnpm install && \
pnpm run build
FROM python:3.11-slim-bullseye

View File

@ -1,29 +0,0 @@
2025-11-17 18:13:15.766 | INFO | app.api.v1.app_valuations.app_valuations:calculate_valuation:284 - valuation.task_queued user_id=30 asset_name= industry=
2025-11-17 18:13:15.768 | INFO | app.api.v1.app_valuations.app_valuations:_perform_valuation_calculation:44 - valuation.calc_start user_id=30 asset_name= industry=
2025-11-17 18:13:15 - INFO - 14.145.4.28:0 - "POST /api/v1/app-valuations/ HTTP/1.0" 200 OK
2025-11-17 18:13:16,048 - INFO - API: dajiala.web_search
2025-11-17 18:13:16,049 - ERROR - API: {'code': 20001, 'msg': '', 'data': ''}
2025-11-17 18:13:16,049 - WARNING -
2025-11-17 18:13:16.049 | INFO | app.api.v1.app_valuations.app_valuations:_extract_calculation_params_b1:336 - '' 30: 0.0
2025-11-17 18:13:16,051 - INFO - S2: S2=2200.0
{'orderNo': '202511171813162420295', 'rc': '0001', 'msg': ','}
2025-11-17 18:13:16,827 - INFO - API: chinaz.judgement
{'orderNo': '202511171813169260297', 'rc': '0002', 'msg': ','}
2025-11-17 18:13:17,428 - INFO - API: chinaz.patent
2025-11-17 18:13:17.428 | INFO | app.utils.calculation_engine.final_value_ab.final_value_a:calculate_complete_final_value_a:80 - final_value_a.calculation_start input_data_keys=['model_data', 'market_data'] model_data_keys=['economic_data', 'cultural_data', 'risky_data'] market_data_keys=['weighted_average_price', 'manual_bids', 'expert_valuations', 'daily_browse_volume', 'collection_count', 'issuance_level', 'recent_market_activity']
2025-11-17 18:13:17.429 | INFO | app.utils.calculation_engine.final_value_ab.final_value_a:calculate_complete_final_value_a:89 - final_value_a.economic_data B1: =[169.0, 169.0, 169.0] =3.0 =7.0 =0.0 =18.93491124260355 ESG=5.0 使=0.0 =-0.5
2025-11-17 18:13:17.429 | INFO | app.utils.calculation_engine.final_value_ab.final_value_a:calculate_complete_final_value_a:101 - final_value_a.cultural_data B2: =0.7 =0.3 线=50.0 =67000.0 =0 =0 =1.5 =9 =0.0
2025-11-17 18:13:17.429 | INFO | app.utils.calculation_engine.final_value_ab.final_value_a:calculate_complete_final_value_a:114 - final_value_a.risky_data B3: =3980.0 =1580.0 =0.0 =[0, 0, 2]
2025-11-17 18:13:17.429 | INFO | app.utils.calculation_engine.final_value_ab.final_value_a:calculate_complete_final_value_a:122 - final_value_a.market_data C: =None =[3980.0, 1580.0, 2780.0] =[] =296000.0 =67000 = 100 =
2025-11-17 18:13:17.429 | INFO | app.utils.calculation_engine.final_value_ab.final_value_a:calculate_complete_final_value_a:132 - final_value_a.calculating_model_value_b B
2025-11-17 18:13:17.429 | INFO | app.utils.calculation_engine.final_value_ab.final_value_a:calculate_complete_final_value_a:142 - final_value_a.model_value_b_calculated B: B=336.37180882339413 =0ms =['economic_value_b1', 'cultural_value_b2', 'risk_value_b3', 'model_value_b']
2025-11-17 18:13:17.429 | INFO | app.utils.calculation_engine.final_value_ab.final_value_a:calculate_complete_final_value_a:152 - final_value_a.calculating_market_value_c C
:
2025-11-17 18:13:17.430 | INFO | app.utils.calculation_engine.final_value_ab.final_value_a:calculate_complete_final_value_a:162 - final_value_a.market_value_c_calculated C: C=9452.0 =0ms =['market_bidding_c1', 'heat_coefficient_c2', 'scarcity_multiplier_c3', 'temporal_decay_c4', 'market_value_c']
2025-11-17 18:13:17.430 | INFO | app.utils.calculation_engine.final_value_ab.final_value_a:calculate_complete_final_value_a:172 - final_value_a.calculating_final_value_a A: B=336.37180882339413 C=9452.0
2025-11-17 18:13:17.430 | INFO | app.utils.calculation_engine.final_value_ab.final_value_a:calculate_final_value_a:50 - final_value_a.calculate_final_value_a A: B=336.37180882339413 C=9452.0
2025-11-17 18:13:17.430 | INFO | app.utils.calculation_engine.final_value_ab.final_value_a:calculate_final_value_a:57 - final_value_a.weighted_values : B=235.46026617637588(0.7) C=2835.6(0.3)
2025-11-17 18:13:17.430 | INFO | app.utils.calculation_engine.final_value_ab.final_value_a:calculate_final_value_a:62 - final_value_a.final_calculation A: B=336.37180882339413 C=9452.0 =235.46026617637588 =2835.6 AB=3071.060266176376
2025-11-17 18:13:17.430 | INFO | app.utils.calculation_engine.final_value_ab.final_value_a:calculate_complete_final_value_a:183 - final_value_a.calculation_completed A: AB=3071.060266176376 B=336.37180882339413 C=9452.0 =1ms =0ms =0ms
2025-11-17 18:13:17.430 | INFO | app.api.v1.app_valuations.app_valuations:_perform_valuation_calculation:160 - valuation.calc_done user_id=30 duration_ms=1662 model_value_b=336.37180882339413 market_value_c=9452.0 final_value_ab=3071.060266176376
Traceback (most recent call last):

View File

@ -117,13 +117,23 @@ async def create_with_receipt(payload: AppCreateInvoiceWithReceipt, current_user
)
inv = await invoice_controller.create(inv_data)
if payload.receipt_urls:
receipts = []
for url in payload.receipt_urls:
receipt = await invoice_controller.create_receipt(inv.id, PaymentReceiptCreate(url=url, note=payload.note))
urls = payload.receipt_urls
main_url = urls[0] if isinstance(urls, list) and urls else None
receipt = await invoice_controller.create_receipt(
inv.id,
PaymentReceiptCreate(url=main_url, note=payload.note, extra=urls)
)
detail = await invoice_controller.get_receipt_by_id(receipt.id)
if detail:
receipts.append(detail)
return Success(data={"invoice_id": inv.id, "receipts": receipts}, msg="创建并上传成功")
return Success(data={"invoice_id": inv.id, "receipts": [detail] if detail else []}, msg="创建并上传成功")
if isinstance(payload.receipt_url, list) and payload.receipt_url:
urls = payload.receipt_url
main_url = urls[0]
receipt = await invoice_controller.create_receipt(
inv.id,
PaymentReceiptCreate(url=main_url, note=payload.note, extra=urls)
)
detail = await invoice_controller.get_receipt_by_id(receipt.id)
return Success(data={"invoice_id": inv.id, "receipts": [detail] if detail else []}, msg="创建并上传成功")
if payload.receipt_url:
receipt = await invoice_controller.create_receipt(inv.id, PaymentReceiptCreate(url=payload.receipt_url, note=payload.note))
detail = await invoice_controller.get_receipt_by_id(receipt.id)

View File

@ -16,6 +16,7 @@ admin_app_users_router = APIRouter(dependencies=[DependAuth, DependPermission],
async def list_app_users(
phone: Optional[str] = Query(None),
wechat: Optional[str] = Query(None),
include_deleted: Optional[bool] = Query(False),
id: Optional[str] = Query(None),
created_start: Optional[str] = Query(None),
created_end: Optional[str] = Query(None),
@ -24,6 +25,8 @@ async def list_app_users(
page_size: int = Query(10, ge=1, le=100),
):
qs = AppUser.filter()
if not include_deleted:
qs = qs.filter(is_deleted=False)
if id is not None and id.strip().isdigit():
qs = qs.filter(id=int(id.strip()))
if phone:
@ -133,3 +136,11 @@ async def update_app_user(user_id: int, data: AppUserUpdateSchema):
"updated_at": user.updated_at.isoformat() if user.updated_at else "",
"remaining_quota": int(getattr(user, "remaining_quota", 0) or 0),
}, msg="更新成功")
@admin_app_users_router.delete("/{user_id}", summary="注销App用户", response_model=BasicResponse[dict])
async def admin_delete_app_user(user_id: int):
ok = await app_user_controller.delete_user_account(user_id)
if not ok:
raise HTTPException(status_code=404, detail="用户不存在")
return Success(data={"user_id": user_id}, msg="账号已注销")

View File

@ -15,11 +15,20 @@ from app.schemas.base import BasicResponse, MessageOut, Success
from app.utils.app_user_jwt import (
create_app_user_access_token,
get_current_app_user,
ACCESS_TOKEN_EXPIRE_MINUTES
ACCESS_TOKEN_EXPIRE_MINUTES,
verify_app_user_token
)
from app.models.user import AppUser
from app.controllers.user_valuation import user_valuation_controller
from app.controllers.invoice import invoice_controller
from app.core.token_blacklist import add_to_blacklist
from fastapi import Header
from pydantic import BaseModel, Field
from typing import Optional
import time
from app.models.valuation import ValuationAssessment
from app.services.sms_store import store
from app.settings import settings
router = APIRouter()
@ -78,6 +87,50 @@ async def logout(current_user: AppUser = Depends(get_current_app_user)):
return Success(data={"message": "登出成功"})
class DeleteAccountRequest(BaseModel):
code: Optional[str] = Field(None, description="短信验证码或绕过码")
@router.delete("/account", summary="注销用户信息", response_model=BasicResponse[dict])
async def delete_account(current_user: AppUser = Depends(get_current_app_user), token: str = Header(None), payload: Optional[DeleteAccountRequest] = None):
if payload and payload.code:
if settings.SMS_BYPASS_CODE and payload.code == settings.SMS_BYPASS_CODE:
store.mark_verified(current_user.phone)
else:
ok, reason = store.can_verify(current_user.phone)
if not ok:
raise HTTPException(status_code=423, detail=str(reason))
record = store.get_code(current_user.phone)
if not record:
raise HTTPException(status_code=400, detail="验证码已过期")
code_stored, expires_at = record
if time.time() > expires_at:
store.clear_code(current_user.phone)
raise HTTPException(status_code=400, detail="验证码已过期")
if payload.code != code_stored:
count, locked = store.record_verify_failure(current_user.phone)
if locked:
raise HTTPException(status_code=423, detail="尝试次数过多,已锁定")
raise HTTPException(status_code=401, detail="验证码错误")
store.clear_code(current_user.phone)
store.reset_failures(current_user.phone)
store.mark_verified(current_user.phone)
else:
if not store.is_recently_verified(current_user.phone):
raise HTTPException(status_code=403, detail="请先完成手机号验证码验证")
remaining_quota = int(getattr(current_user, "remaining_quota", 0) or 0)
if remaining_quota > 0:
raise HTTPException(status_code=400, detail="当前剩余估值次数大于0无法注销账号")
ok = await app_user_controller.delete_user_account(current_user.id)
if token:
payload = verify_app_user_token(token)
exp = getattr(payload, "exp", None) if payload else None
await add_to_blacklist(token, current_user.id, exp)
if not ok:
raise HTTPException(status_code=404, detail="用户不存在")
return Success(data={"message": "账号已注销"})
@router.get("/profile", response_model=BasicResponse[dict], summary="获取用户信息")
async def get_profile(current_user: AppUser = Depends(get_current_app_user)):
"""

View File

@ -37,13 +37,13 @@ from app.utils.wechat_index_calculator import wechat_index_calculator
app_valuations_router = APIRouter(tags=["用户端估值评估"])
async def _perform_valuation_calculation(user_id: int, data: UserValuationCreate):
async def _perform_valuation_calculation(user_id: int, valuation_id: int, data: UserValuationCreate):
"""
后台任务执行估值计算
"""
try:
start_ts = time.monotonic()
logger.info("valuation.calc_start user_id={} asset_name={} industry={}", user_id,
logger.info("valuation.calc_start user_id={} valuation_id={} asset_name={} industry={}", user_id, valuation_id,
getattr(data, 'asset_name', None), getattr(data, 'industry', None))
# 根据行业查询 ESG 基准分(优先用行业名称匹配,如用的是行业代码就把 name 改成 code
@ -84,25 +84,53 @@ async def _perform_valuation_calculation(user_id: int, data: UserValuationCreate
# 政策匹配度
input_data_by_b1["policy_match_score"] = policy_match_score
# 侵权分 默认 6
# 法律风险/侵权记录通过司法API查询诉讼状态
# 评分规则:无诉讼(10分), 已解决诉讼(7分), 未解决诉讼(0分)
lawsuit_status_text = "无诉讼" # 默认无诉讼
judicial_api_response = {} # 保存API原始返回用于日志
try:
judicial_data = universal_api.query_judicial_data(data.institution)
_data = judicial_data["data"].get("target", None) # 诉讼标的
if _data:
_data = judicial_data.get("data", {})
judicial_api_response = _data # 保存原始返回
target = _data.get("target", None) # 诉讼标的
total = _data.get("total", 0) # 诉讼总数
if target or total > 0:
# 有诉讼记录,检查是否已解决
settled = _data.get("settled", False)
if settled:
lawsuit_status_text = "已解决诉讼"
infringement_score = 7.0
else:
lawsuit_status_text = "未解决诉讼"
infringement_score = 0.0
else:
lawsuit_status_text = "无诉讼"
infringement_score = 10.0
except:
infringement_score = 0.0
input_data_by_b1["infringement_score"] = infringement_score
# 获取专利信息 TODO 参数
logger.info(f"法律风险查询结果: 机构={data.institution} 诉讼状态={lawsuit_status_text} 评分={infringement_score}")
except Exception as e:
logger.warning(f"法律风险查询失败: {e}")
lawsuit_status_text = "查询失败"
infringement_score = 0.0
judicial_api_response = {"error": str(e)}
input_data_by_b1["infringement_score"] = infringement_score
# 保存诉讼状态文本,用于前端展示
lawsuit_status_for_display = lawsuit_status_text
# 获取专利信息
patent_api_response = {} # 保存API原始返回用于日志
patent_matched_count = 0
patent_years_total = 0
try:
patent_data = universal_api.query_patent_info(data.industry)
patent_api_response = patent_data # 保存原始返回
except Exception as e:
logger.warning("valuation.patent_api_error err={}", repr(e))
input_data_by_b1["patent_count"] = 0.0
input_data_by_b1["patent_score"] = 0.0
patent_api_response = {"error": str(e)}
patent_dict = patent_data if isinstance(patent_data, dict) else {}
inner_data = patent_dict.get("data", {}) if isinstance(patent_dict.get("data", {}), dict) else {}
@ -113,16 +141,17 @@ async def _perform_valuation_calculation(user_id: int, data: UserValuationCreate
# 查询匹配申请号的记录集合
matched = [item for item in data_list if
isinstance(item, dict) and item.get("SQH") == getattr(data, 'patent_application_no', None)]
patent_matched_count = len(matched)
if matched:
patent_count_score = min(len(matched) * 2.5, 10.0)
input_data_by_b1["patent_count"] = float(patent_count_score)
else:
input_data_by_b1["patent_count"] = 0.0
years_total = calculate_total_years(data_list)
if years_total > 10:
patent_years_total = calculate_total_years(data_list)
if patent_years_total > 10:
patent_score = 10.0
elif years_total >= 5:
elif patent_years_total >= 5:
patent_score = 7.0
else:
patent_score = 3.0
@ -152,21 +181,10 @@ async def _perform_valuation_calculation(user_id: int, data: UserValuationCreate
}
calculator = FinalValueACalculator()
# 先创建估值记录以获取ID方便步骤落库关联
initial_detail = await user_valuation_controller.create_valuation(
user_id=user_id,
data=data,
calculation_result=None,
calculation_input=None,
drp_result=None,
status='pending'
)
valuation_id = initial_detail.id
logger.info("valuation.init_created user_id={} valuation_id={}", user_id, valuation_id)
# 步骤1立即更新计算输入参数不管后续是否成功
try:
await valuation_controller.update(
await valuation_controller.update_calc(
valuation_id,
ValuationAssessmentUpdate(
calculation_input=input_data,
@ -187,10 +205,9 @@ async def _perform_valuation_calculation(user_id: int, data: UserValuationCreate
# 政策匹配度
api_calc_fields["policy_matching"] = str(policy_match_score) if policy_match_score is not None else None
# 侵权记录/法律风险
infringement_record_value = "有侵权记录" if infringement_score == 0.0 else "无侵权记录"
api_calc_fields["infringement_record"] = infringement_record_value
api_calc_fields["legal_risk"] = infringement_record_value
# 侵权记录/法律风险 - 使用实际查询到的诉讼状态
api_calc_fields["infringement_record"] = lawsuit_status_for_display
api_calc_fields["legal_risk"] = lawsuit_status_for_display
# 专利使用量
patent_count_value = input_data_by_b1.get("patent_count", 0.0)
@ -212,7 +229,7 @@ async def _perform_valuation_calculation(user_id: int, data: UserValuationCreate
# api_calc_fields["flow_correction"] = None
if api_calc_fields:
await valuation_controller.update(
await valuation_controller.update_calc(
valuation_id,
ValuationAssessmentUpdate(**api_calc_fields)
)
@ -220,12 +237,136 @@ async def _perform_valuation_calculation(user_id: int, data: UserValuationCreate
except Exception as e:
logger.warning("valuation.failed_to_update_api_calc_fields valuation_id={} err={}", valuation_id, repr(e))
# 步骤1.6记录所有API查询结果和参数映射便于检查参数匹配
try:
# 1. ESG评分查询记录
await valuation_controller.log_formula_step(
valuation_id, "API_ESG_QUERY",
status="completed",
input_params={"industry": data.industry},
output_result={"esg_score": esg_score, "source": "ESG表"}
)
# 2. 行业系数查询记录
await valuation_controller.log_formula_step(
valuation_id, "API_INDUSTRY_QUERY",
status="completed",
input_params={"industry": data.industry},
output_result={"industry_coefficient": fix_num_score, "source": "Industry表"}
)
# 3. 政策匹配度查询记录
await valuation_controller.log_formula_step(
valuation_id, "API_POLICY_QUERY",
status="completed",
input_params={"industry": data.industry},
output_result={"policy_match_score": policy_match_score, "source": "Policy表"}
)
# 4. 司法诉讼查询记录
await valuation_controller.log_formula_step(
valuation_id, "API_JUDICIAL_QUERY",
status="completed",
input_params={"institution": data.institution},
output_result={
"api_response": judicial_api_response, # API原始返回
"lawsuit_status": lawsuit_status_for_display,
"infringement_score": infringement_score,
"calculation": f"诉讼标的={judicial_api_response.get('target', '')}, 诉讼总数={judicial_api_response.get('total', 0)}{lawsuit_status_for_display}{infringement_score}",
"score_rule": "无诉讼:10分, 已解决:7分, 未解决:0分"
}
)
# 5. 专利信息查询记录
await valuation_controller.log_formula_step(
valuation_id, "API_PATENT_QUERY",
status="completed",
input_params={
"industry": data.industry,
"patent_application_no": data.patent_application_no
},
output_result={
"api_data_count": len(patent_api_response.get("data", {}).get("dataList", []) if isinstance(patent_api_response.get("data"), dict) else []),
"matched_count": patent_matched_count,
"years_total": patent_years_total,
"patent_count": input_data_by_b1.get("patent_count", 0),
"patent_score": input_data_by_b1.get("patent_score", 0),
"calculation": f"匹配专利数={patent_matched_count} → 专利数分={input_data_by_b1.get('patent_count', 0)}, 剩余年限合计={patent_years_total}年 → 专利分={patent_score}",
"score_rule": "剩余年限>10年:10分, 5-10年:7分, <5年:3分"
}
)
# 6. 微信指数查询记录
await valuation_controller.log_formula_step(
valuation_id, "API_WECHAT_INDEX",
status="completed",
input_params={"asset_name": data.asset_name},
output_result={
"search_index_s1": input_data_by_b1.get("search_index_s1", 0),
"formula": "S1 = 微信指数 / 10"
}
)
# 7. 跨界合作深度映射记录
await valuation_controller.log_formula_step(
valuation_id, "MAPPING_CROSS_BORDER_DEPTH",
status="completed",
input_params={
"user_input": getattr(data, 'cooperation_depth', None),
"mapping": {"0":"无(0分)", "1":"品牌联名(3分)", "2":"科技载体(5分)", "3":"国家外交礼品(10分)"}
},
output_result={"cross_border_depth": input_data_by_b2.get("cross_border_depth", 0)}
)
# 8. 传承人等级映射记录
await valuation_controller.log_formula_step(
valuation_id, "MAPPING_INHERITOR_LEVEL",
status="completed",
input_params={
"user_input": data.inheritor_level,
"mapping": {"国家级传承人":"10分", "省级传承人":"7分", "市级传承人及以下":"4分"}
},
output_result={"inheritor_level_coefficient": input_data_by_b2.get("inheritor_level_coefficient", 0)}
)
# 9. 历史传承度HI计算记录
await valuation_controller.log_formula_step(
valuation_id, "CALC_HISTORICAL_INHERITANCE",
status="completed",
input_params={
"historical_evidence": data.historical_evidence,
"weights": {"出土实物":1.0, "古代文献":0.8, "传承人佐证":0.6, "现代研究":0.4}
},
output_result={
"historical_inheritance": input_data_by_b2.get("historical_inheritance", 0),
"formula": "HI = 出土实物×1.0 + 古代文献×0.8 + 传承人佐证×0.6 + 现代研究×0.4"
}
)
# 11. 市场风险价格波动记录
await valuation_controller.log_formula_step(
valuation_id, "CALC_MARKET_RISK",
status="completed",
input_params={
"price_fluctuation": data.price_fluctuation,
"highest_price": input_data_by_b3.get("highest_price", 0),
"lowest_price": input_data_by_b3.get("lowest_price", 0)
},
output_result={
"volatility_rule": "波动率≤5%:10分, 5-15%:5分, >15%:0分"
}
)
logger.info("valuation.param_mapping_logged valuation_id={}", valuation_id)
except Exception as e:
logger.warning("valuation.failed_to_log_param_mapping valuation_id={} err={}", valuation_id, repr(e))
# 计算最终估值A统一计算传入估值ID以关联步骤落库
calculation_result = await calculator.calculate_complete_final_value_a(valuation_id, input_data)
# 步骤2更新计算结果字段模型估值B、市场估值C、最终估值AB、完整计算结果
try:
await valuation_controller.update(
await valuation_controller.update_calc(
valuation_id,
ValuationAssessmentUpdate(
model_value_b=calculation_result.get('model_value_b'),
@ -280,7 +421,7 @@ async def _perform_valuation_calculation(user_id: int, data: UserValuationCreate
base_pledge_rate_value = "0.5" # 固定值:基础质押率 = 0.5
flow_correction_value = "0.3" # 固定值:流量修正系数 = 0.3
await valuation_controller.update(
await valuation_controller.update_calc(
valuation_id,
ValuationAssessmentUpdate(
dynamic_pledge_rate=drp_result,
@ -307,19 +448,13 @@ async def _perform_valuation_calculation(user_id: int, data: UserValuationCreate
except Exception:
pass
# 步骤4最后更新状态为成功
# 步骤4计算完成,保持状态为 pending等待后台审核
try:
result = await valuation_controller.update(
valuation_id,
ValuationAssessmentUpdate(
status='success'
)
)
logger.info("valuation.status_updated valuation_id={} status=success", valuation_id)
except Exception as e:
logger.warning("valuation.failed_to_update_status valuation_id={} err={}", valuation_id, repr(e))
# 即使状态更新失败,也尝试获取结果用于日志
result = await valuation_controller.get_by_id(valuation_id)
logger.info("valuation.calc_finished valuation_id={} status=pending", valuation_id)
except Exception as e:
logger.warning("valuation.failed_to_fetch_after_calc valuation_id={} err={}", valuation_id, repr(e))
result = None
logger.info("valuation.background_calc_success user_id={} valuation_id={}", user_id, valuation_id)
@ -345,8 +480,12 @@ async def _perform_valuation_calculation(user_id: int, data: UserValuationCreate
api_calc_fields["esg_value"] = str(esg_score) if esg_score is not None else None
if 'policy_match_score' in locals():
api_calc_fields["policy_matching"] = str(policy_match_score) if policy_match_score is not None else None
if 'infringement_score' in locals():
infringement_record_value = "有侵权记录" if infringement_score == 0.0 else "无侵权记录"
if 'lawsuit_status_for_display' in locals():
api_calc_fields["infringement_record"] = lawsuit_status_for_display
api_calc_fields["legal_risk"] = lawsuit_status_for_display
elif 'infringement_score' in locals():
# 兼容旧逻辑
infringement_record_value = "无诉讼" if infringement_score == 10.0 else ("已解决诉讼" if infringement_score == 7.0 else "未解决诉讼")
api_calc_fields["infringement_record"] = infringement_record_value
api_calc_fields["legal_risk"] = infringement_record_value
if 'input_data_by_b1' in locals():
@ -362,7 +501,7 @@ async def _perform_valuation_calculation(user_id: int, data: UserValuationCreate
fail_update_fields.update(api_calc_fields)
try:
await valuation_controller.update(
await valuation_controller.update_calc(
valuation_id,
ValuationAssessmentUpdate(**fail_update_fields)
)
@ -372,7 +511,7 @@ async def _perform_valuation_calculation(user_id: int, data: UserValuationCreate
# 如果保存失败,至少更新状态
try:
fail_update = ValuationAssessmentUpdate(status='rejected')
await valuation_controller.update(valuation_id, fail_update)
await valuation_controller.update_calc(valuation_id, fail_update)
except Exception:
pass
else:
@ -474,17 +613,30 @@ async def calculate_valuation(
except Exception:
pass
background_tasks.add_task(_perform_valuation_calculation, user_id, data)
# 先创建估值记录以获取ID方便用户查询
initial_detail = await user_valuation_controller.create_valuation(
user_id=user_id,
data=data,
calculation_result=None,
calculation_input=None,
drp_result=None,
status='pending'
)
valuation_id = initial_detail.id
logger.info("valuation.task_queued user_id={} asset_name={} industry={}",
user_id, getattr(data, 'asset_name', None), getattr(data, 'industry', None))
background_tasks.add_task(_perform_valuation_calculation, user_id, valuation_id, data)
logger.info("valuation.task_queued user_id={} valuation_id={} asset_name={} industry={}",
user_id, valuation_id, getattr(data, 'asset_name', None), getattr(data, 'industry', None))
return Success(
data={
"task_status": "queued",
"message": "估值计算任务已提交,正在后台处理中",
"user_id": user_id,
"asset_name": getattr(data, 'asset_name', None)
"asset_name": getattr(data, 'asset_name', None),
"valuation_id": valuation_id,
"order_no": str(valuation_id)
}
)
@ -531,8 +683,7 @@ async def _extract_calculation_params_b1(
innovation_ratio = 0.0
# 流量因子B12相关参数
# 近30天搜索指数S1 - 从社交媒体数据计算 TODO 需要使用第三方API
baidu_index = 1
# 近30天搜索指数S1 - 使用微信指数除以10计算
# 获取微信指数并计算近30天平均值
try:
@ -541,10 +692,9 @@ async def _extract_calculation_params_b1(
logger.info(f"资产 '{data.asset_name}' 的微信指数近30天平均值: {wechat_index}")
except Exception as e:
logger.error(f"获取微信指数失败: {e}")
wechat_index = 1
wechat_index = 10 # 失败时默认值,使得 S1 = 1
weibo_index = 1
search_index_s1 = calculate_search_index_s1(baidu_index, wechat_index, weibo_index) # 默认值实际应从API获取
search_index_s1 = calculate_search_index_s1(wechat_index) # S1 = 微信指数 / 10
# 行业均值S2 - 从数据库查询行业数据计算
from app.utils.industry_calculator import calculate_industry_average_s2
@ -591,6 +741,7 @@ async def _extract_calculation_params_b1(
'likes': safe_float(info["likes"]),
'comments': safe_float(info["comments"]),
'shares': safe_float(info["shares"]),
'views': safe_float(info.get("views", 0)),
# followers 非当前计算用键,先移除避免干扰
# click_count 与 view_count 目前未参与计算,先移除
@ -635,10 +786,18 @@ async def _extract_calculation_params_b2(data: UserValuationCreate) -> Dict[str,
kuaishou_views = safe_float(rs.get("kuaishou", None).get("likes", 0)) if rs.get("kuaishou", None) else 0
bilibili_views = safe_float(rs.get("bilibili", None).get("likes", 0)) if rs.get("bilibili", None) else 0
# 跨界合作深度:将枚举映射为项目数;若为数值字符串则直接取数值
# 跨界合作深度:将枚举映射为分值
# 前端传入的是数字字符串 ("0", "1", "2", "3"),后端也支持中文标签
try:
val = getattr(data, 'cooperation_depth', None)
mapping = {
# 前端传入的数字字符串
"0": 0.0, # 无
"1": 3.0, # 品牌联名
"2": 5.0, # 科技载体
"3": 10.0, # 国家外交礼品
# 兼容中文标签(以防其他入口传入)
"": 0.0,
"品牌联名": 3.0,
"科技载体": 5.0,
"国家外交礼品": 10.0,
@ -653,14 +812,28 @@ async def _extract_calculation_params_b2(data: UserValuationCreate) -> Dict[str,
# 纹样基因值B22相关参数
# 以下三项需由后续模型/服务计算;此处提供默认可计算占位
#
# 历史传承度HI(用户填写)
# HI = 证据数量 × 对应权重后加总
# 权重分配:出土实物(1.0) + 古代文献(0.8) + 传承人佐证(0.6) + 现代研究(0.4)
# 示例: (2*1 + 5*0.8 + 5*0.6 + 6*0.4) = 11.4
historical_inheritance = 0.0
try:
evidence_weights = {
"artifacts": 1.0, # 出土实物
"ancient_literature": 0.8, # 古代文献
"inheritor_testimony": 0.6, # 传承人佐证
"modern_research": 0.4, # 现代研究
}
if isinstance(data.historical_evidence, dict):
historical_inheritance = sum([safe_float(v) for v in data.historical_evidence.values()])
for key, weight in evidence_weights.items():
count = safe_float(data.historical_evidence.get(key, 0))
historical_inheritance += count * weight
elif isinstance(data.historical_evidence, (list, tuple)):
historical_inheritance = sum([safe_float(i) for i in data.historical_evidence])
# 列表顺序:[出土实物, 古代文献, 传承人佐证, 现代研究]
weights = [1.0, 0.8, 0.6, 0.4]
for i, weight in enumerate(weights):
if i < len(data.historical_evidence):
historical_inheritance += safe_float(data.historical_evidence[i]) * weight
except Exception:
historical_inheritance = 0.0
structure_complexity = 1.5 # 默认值 纹样基因熵值B22(系统计算)
@ -692,17 +865,36 @@ async def _extract_calculation_params_b2(data: UserValuationCreate) -> Dict[str,
}
# 获取 文化价值B2 相关参数
# 获取 风险调整系数B3 相关参数
async def _extract_calculation_params_b3(data: UserValuationCreate) -> Dict[str, Any]:
# 过去30天最高价格 过去30天最低价格 TODO 需要根据字样进行切分获取最高价和最低价 转换成 float 类型
# 过去30天最高价格 过去30天最低价格
price_fluctuation = [float(i) for i in data.price_fluctuation]
highest_price, lowest_price = max(price_fluctuation), min(price_fluctuation)
# lawsuit_status = "无诉讼" # 诉讼状态 TODO (API获取)
inheritor_ages = [float(i) for i in data.inheritor_age_count] # [45, 60, 75] # 传承人年龄列表
# 传承风险:根据各年龄段传承人数量计算
# 前端传入: inheritor_age_count = [≤50岁人数, 50-70岁人数, ≥70岁人数]
# 评分规则: ≤50岁(10分), 50-70岁(5分), >70岁(0分),取有传承人的最高分
inheritor_age_count = data.inheritor_age_count or [0, 0, 0]
# 根据年龄段人数生成虚拟年龄列表(用于风险计算)
# 如果有≤50岁的传承人添加一个45岁的代表
# 如果有50-70岁的传承人添加一个60岁的代表
# 如果有>70岁的传承人添加一个75岁的代表
inheritor_ages = []
if len(inheritor_age_count) > 0 and safe_float(inheritor_age_count[0]) > 0:
inheritor_ages.append(45) # ≤50岁代表 → 10分
if len(inheritor_age_count) > 1 and safe_float(inheritor_age_count[1]) > 0:
inheritor_ages.append(60) # 50-70岁代表 → 5分
if len(inheritor_age_count) > 2 and safe_float(inheritor_age_count[2]) > 0:
inheritor_ages.append(75) # >70岁代表 → 0分
# 如果没有任何传承人,默认给一个高风险年龄
if not inheritor_ages:
inheritor_ages = [75] # 默认高风险
return {
"highest_price": highest_price,
"lowest_price": lowest_price,
"inheritor_ages": inheritor_ages,
}
@ -797,6 +989,7 @@ async def _extract_calculation_params_c(data: UserValuationCreate) -> Dict[str,
"expert_valuations": expert_valuations, # 专家估值列表 (系统配置)
# 计算热度系数C2
"daily_browse_volume": daily_browse_volume, # 近7日日均浏览量 (API获取)
"platform_views": daily_browse_volume, # 从 platform_accounts/views 或 link_views 获取的浏览量
"collection_count": collection_count, # 收藏数
"issuance_level": circulation, # 默认 限量发行 计算稀缺性乘数C3
"recent_market_activity": recent_market_activity, # 默认 '近一月' 计算市场估值C

View File

@ -276,11 +276,9 @@ async def _extract_calculation_params_b1(data: UserValuationCreate) -> Dict[str,
# 流量因子B12相关参数
# 近30天搜索指数S1 - 从社交媒体数据计算 TODO 需要使用第三方API
baidu_index = 0.0
wechat_index = wechat_index_calculator.process_wechat_index_response(universal_api.wx_index(data.asset_name)) # 通过资产信息获取微信指数 TODO 这里返回的没确认指数参数,有可能返回的图示是指数信息
weibo_index = 0.0
search_index_s1 = calculate_search_index_s1(baidu_index,wechat_index,weibo_index) # 默认值实际应从API获取
# 近30天搜索指数S1 - 使用微信指数除以10计算
wechat_index = wechat_index_calculator.process_wechat_index_response(universal_api.wx_index(data.asset_name)) # 通过资产信息获取微信指数
search_index_s1 = calculate_search_index_s1(wechat_index) # S1 = 微信指数 / 10
# 行业均值S2 TODO 系统内置 未找到相关内容
industry_average_s2 = 0.0
# 社交媒体传播度S3 - TODO 需要使用第三方API,click_count view_count 未找到对应参数
@ -344,8 +342,22 @@ async def _extract_calculation_params_b2(data: UserValuationCreate) -> Dict[str,
douyin_views = 0
kuaishou_views= 0
bilibili_views= 0
# 跨界合作深度 品牌联名0.3科技载体0.5国家外交礼品1.0
cross_border_depth = float(data.cooperation_depth)
# 跨界合作深度:将枚举映射为分值
# 前端传入的是数字字符串 ("0", "1", "2", "3"),后端也支持中文标签
depth_mapping = {
# 前端传入的数字字符串
"0": 0.0, # 无
"1": 3.0, # 品牌联名
"2": 5.0, # 科技载体
"3": 10.0, # 国家外交礼品
# 兼容中文标签(以防其他入口传入)
"": 0.0,
"品牌联名": 3.0,
"科技载体": 5.0,
"国家外交礼品": 10.0,
}
depth_val = str(data.cooperation_depth) if data.cooperation_depth else "0"
cross_border_depth = depth_mapping.get(depth_val, 0.0)
# 纹样基因值B22相关参数

View File

@ -135,6 +135,7 @@ async def verify_code(payload: VerifyCodeRequest) -> BasicResponse[dict]:
from app.settings import settings
if settings.SMS_BYPASS_CODE and payload.code == settings.SMS_BYPASS_CODE:
logger.info("sms.verify_code bypass phone={}", payload.phone)
store.mark_verified(payload.phone)
return Success(data={"status": "OK", "message": "verified"})
ok, reason = store.can_verify(payload.phone)
if not ok:
@ -154,6 +155,7 @@ async def verify_code(payload: VerifyCodeRequest) -> BasicResponse[dict]:
store.clear_code(payload.phone)
store.reset_failures(payload.phone)
logger.info("sms.verify_code success phone={}", payload.phone)
store.mark_verified(payload.phone)
return Success(data={"status": "OK", "message": "verified"})

View File

@ -85,6 +85,11 @@ async def send_email(payload: SendEmailRequest = Body(...)):
raise HTTPException(status_code=400, detail="收件方地址域名不可用或未正确解析")
if payload.file_urls:
urls.extend([u.strip().strip('`') for u in payload.file_urls if isinstance(u, str)])
if payload.file_url:
if isinstance(payload.file_url, str):
urls.append(payload.file_url.strip().strip('`'))
elif isinstance(payload.file_url, list):
urls.extend([u.strip().strip('`') for u in payload.file_url if isinstance(u, str)])
if urls:
try:
async with httpx.AsyncClient(timeout=10) as client:
@ -122,20 +127,25 @@ async def send_email(payload: SendEmailRequest = Body(...)):
else:
logger.error("transactions.email_send_fail email={} err={}", payload.email, error)
if status == "OK" and payload.receipt_id:
if payload.receipt_id:
try:
r = await PaymentReceipt.filter(id=payload.receipt_id).first()
if r:
r.extra = (r.extra or {}) | payload.model_dump()
await r.save()
try:
inv = await r.invoice
if inv:
inv.status = "invoiced"
s = str(payload.status or "").lower()
if s in {"invoiced", "success"}:
target = "invoiced"
elif s in {"refunded", "rejected", "pending"}:
target = s
else:
target = "invoiced"
inv.status = target
await inv.save()
logger.info("transactions.invoice_mark_invoiced receipt_id={} invoice_id={}", payload.receipt_id, inv.id)
logger.info("transactions.invoice_status_updated receipt_id={} invoice_id={} status={}", payload.receipt_id, inv.id, target)
except Exception as e2:
logger.warning("transactions.invoice_mark_invoiced_fail receipt_id={} err={}", payload.receipt_id, str(e2))
logger.warning("transactions.invoice_status_update_fail receipt_id={} err={}", payload.receipt_id, str(e2))
except Exception as e:
logger.error("transactions.email_extra_save_fail id={} err={}", payload.receipt_id, str(e))

View File

@ -21,6 +21,15 @@ class AppUserController(CRUDBase[AppUser, AppUserRegisterSchema, AppUserUpdateSc
# 检查手机号是否已存在
existing_user = await self.model.filter(phone=register_data.phone).first()
if existing_user:
if getattr(existing_user, "is_deleted", False):
default_password = register_data.phone[-6:]
hashed_password = get_password_hash(default_password)
existing_user.is_deleted = False
existing_user.deleted_at = None
existing_user.is_active = True
existing_user.password = hashed_password
await existing_user.save()
return existing_user
raise HTTPException(status_code=400, detail="手机号已存在")
# 生成默认密码:手机号后六位
@ -42,7 +51,7 @@ class AppUserController(CRUDBase[AppUser, AppUserRegisterSchema, AppUserUpdateSc
用户认证
"""
user = await self.model.filter(
phone=login_data.phone, is_active=True
phone=login_data.phone, is_active=True, is_deleted=False
).first()
if not user:
@ -57,13 +66,13 @@ class AppUserController(CRUDBase[AppUser, AppUserRegisterSchema, AppUserUpdateSc
"""
根据ID获取用户
"""
return await self.model.filter(id=user_id, is_active=True).first()
return await self.model.filter(id=user_id, is_active=True, is_deleted=False).first()
async def get_user_by_phone(self, phone: str) -> Optional[AppUser]:
"""
根据手机号获取用户
"""
return await self.model.filter(phone=phone, is_active=True).first()
return await self.model.filter(phone=phone, is_active=True, is_deleted=False).first()
async def update_last_login(self, user_id: int) -> bool:
"""
@ -149,6 +158,27 @@ class AppUserController(CRUDBase[AppUser, AppUserRegisterSchema, AppUserUpdateSc
return True
return False
async def delete_user_account(self, user_id: int) -> bool:
user = await self.model.filter(id=user_id).first()
if not user:
return False
user.is_active = False
user.is_deleted = True
user.deleted_at = datetime.now()
masked = f"deleted_{user.id}"
user.username = None
user.alias = None
user.email = None
user.password = ""
user.company_name = None
user.company_address = None
user.company_contact = None
user.company_phone = None
user.company_email = None
user.phone = masked
await user.save()
return True
# 创建控制器实例
app_user_controller = AppUserController()

View File

@ -271,20 +271,23 @@ class InvoiceController(CRUDBase[Invoice, InvoiceCreate, InvoiceUpdate]):
items = []
for r in rows:
inv = await r.invoice
urls = []
if isinstance(r.extra, list):
urls = [str(u) for u in r.extra if u]
elif isinstance(r.extra, dict):
v = r.extra.get("urls")
if isinstance(v, list):
urls = [str(u) for u in v if u]
if not urls:
urls = [r.url] if r.url else []
receipts = [{"id": r.id, "url": u, "note": r.note, "verified": r.verified} for u in urls]
items.append({
"id": r.id,
"invoice_id": getattr(inv, "id", None),
"submitted_at": r.created_at.isoformat() if r.created_at else "",
"receipt_uploaded_at": r.updated_at.isoformat() if getattr(r, "updated_at", None) else "",
"extra": r.extra,
"receipts": [
{
"id": r.id,
"url": r.url,
"note": r.note,
"verified": r.verified,
}
],
"receipts": receipts,
"phone": inv.phone,
"wechat": inv.wechat,
"company_name": inv.company_name,
@ -313,20 +316,23 @@ class InvoiceController(CRUDBase[Invoice, InvoiceCreate, InvoiceUpdate]):
if not r:
return None
inv = await r.invoice
urls = []
if isinstance(r.extra, list):
urls = [str(u) for u in r.extra if u]
elif isinstance(r.extra, dict):
v = r.extra.get("urls")
if isinstance(v, list):
urls = [str(u) for u in v if u]
if not urls:
urls = [r.url] if r.url else []
receipts = [{"id": r.id, "url": u, "note": r.note, "verified": r.verified} for u in urls]
return {
"id": r.id,
"invoice_id": getattr(inv, "id", None),
"submitted_at": r.created_at.isoformat() if r.created_at else "",
"receipt_uploaded_at": r.updated_at.isoformat() if getattr(r, "updated_at", None) else "",
"extra": r.extra,
"receipts": [
{
"id": r.id,
"url": r.url,
"note": r.note,
"verified": r.verified,
}
],
"receipts": receipts,
"phone": inv.phone,
"wechat": inv.wechat,
"company_name": inv.company_name,

View File

@ -127,7 +127,7 @@ class UserValuationController:
inheritor_ages=valuation.inheritor_ages,
inheritor_age_count=valuation.inheritor_age_count,
inheritor_certificates=valuation.inheritor_certificates,
heritage_level=valuation.heritage_level,
heritage_level=getattr(valuation, "heritage_level", None),
heritage_asset_level=valuation.heritage_asset_level,
patent_application_no=valuation.patent_application_no,
patent_remaining_years=valuation.patent_remaining_years,
@ -197,7 +197,7 @@ class UserValuationController:
inheritor_ages=valuation.inheritor_ages,
inheritor_age_count=valuation.inheritor_age_count,
inheritor_certificates=valuation.inheritor_certificates,
heritage_level=valuation.heritage_level,
heritage_level=getattr(valuation, "heritage_level", None),
heritage_asset_level=valuation.heritage_asset_level,
patent_application_no=valuation.patent_application_no,
patent_remaining_years=valuation.patent_remaining_years,

View File

@ -444,6 +444,30 @@ class ValuationController:
lines.append("```")
lines.append("")
# 计算过程部分(显示详细的计算步骤)
if output_result and isinstance(output_result, dict):
# 首先检查 calculation_detail 字段
calculation_detail = output_result.get('calculation_detail')
if calculation_detail and isinstance(calculation_detail, dict):
lines.append("**计算过程:**")
lines.append("")
# 按步骤顺序显示
steps = []
for key in sorted(calculation_detail.keys()):
if key.startswith('step'):
steps.append(f"> {calculation_detail[key]}")
if steps:
lines.extend(steps)
lines.append("")
# 然后检查旧的 calculation 字段
calculation = output_result.get('calculation')
if calculation and not calculation_detail:
lines.append("**计算过程:**")
lines.append("")
lines.append(f"> {calculation}")
lines.append("")
# 结果部分
if output_result:
# 提取主要结果值
@ -521,6 +545,14 @@ class ValuationController:
if not output_result or not isinstance(output_result, dict):
return None
# 调试打印B3的output_result
if 'risk_value_b3' in str(output_result) or 'legal_risk' in str(output_result):
print(f"=== _extract_main_result 调试 ===")
print(f"formula_name: {formula_name}")
print(f"output_result keys: {list(output_result.keys())}")
print(f"output_result: {output_result}")
print(f"================================")
# 移除 duration_ms 等元数据字段
filtered_result = {k: v for k, v in output_result.items()
if k not in ['duration_ms', 'duration', 'timestamp', 'status']}
@ -538,6 +570,47 @@ class ValuationController:
else:
return json.dumps(value, ensure_ascii=False)
# 优先查找常见的结果字段(优先级从高到低)
# 这个列表的顺序很重要,确保正确的结果字段优先被选中
common_result_keys = [
# 计算引擎实际使用的结果字段名
'risk_value_b3', # 风险调整系数B3
'risk_adjustment_b3', # 风险调整系数B3备选
'economic_value_b1', # 经济价值B1
'cultural_value_b2', # 文化价值B2
'model_value_b', # 模型估值B
'market_value_c', # 市场估值C
'final_value_a', # 最终估值A
'final_value_ab', # 最终估值AB
'basic_value_b11', # 基础价值B11
'traffic_factor_b12', # 流量因子B12
'policy_multiplier_b13', # 政策乘数B13
'living_heritage_b21', # 活态传承系数B21
'pattern_gene_b22', # 纹样基因值B22
# 通用结果字段
'result', 'value', 'output', 'final_value', 'calculated_value',
# 子计算结果字段
'financial_value_f', 'legal_strength_l', 'development_potential_d',
'social_media_spread_s3', 'interaction_index', 'coverage_index', 'conversion_efficiency',
'market_bid_c1', 'heat_coefficient_c2', 'scarcity_multiplier_c3', 'timeliness_decay_c4',
'teaching_frequency', 'inheritor_level_coefficient',
'risk_score_sum', # 风险评分总和R
'dynamic_pledge_rate', # 动态质押率
]
# 首先检查常见结果字段(这个优先级最高,避免错误匹配子风险值)
for key in common_result_keys:
if key in filtered_result:
value = filtered_result[key]
if isinstance(value, (int, float)):
if 'risk' in formula_name.lower() or 'b3' in formula_name.lower():
print(f"=== 返回值调试 (common_keys) ===")
print(f"formula_name: {formula_name}")
print(f"matched key: {key}")
print(f"返回值: {value}")
print(f"================================")
return str(value)
# 尝试根据公式名称匹配字段
# 例如:"财务价值 F" -> 查找 "financial_value_f", "财务价值F" 等
# 提取公式名称中的关键部分(通常是最后一个字母或单词)
@ -577,14 +650,6 @@ class ValuationController:
if isinstance(value, (int, float)):
return str(value)
# 查找常见的结果字段
common_result_keys = ['result', 'value', 'output', 'final_value', 'calculated_value']
for key in common_result_keys:
if key in filtered_result:
value = filtered_result[key]
if isinstance(value, (int, float)):
return str(value)
# 返回第一个数值类型的值
for key, value in filtered_result.items():
if isinstance(value, (int, float)):
@ -813,7 +878,7 @@ class ValuationController:
return None
from datetime import datetime
update_data = {"status": "pending", "audited_at": datetime.now(), "updated_at": datetime.now()}
update_data = {"status": "success", "audited_at": datetime.now(), "updated_at": datetime.now()}
if admin_notes:
update_data["admin_notes"] = admin_notes
@ -847,6 +912,18 @@ class ValuationController:
out = ValuationAssessmentOut.model_validate(valuation)
return await self._attach_user_phone(out)
async def update_calc(self, valuation_id: int, data: ValuationAssessmentUpdate) -> Optional[ValuationAssessmentOut]:
valuation = await self.model.filter(id=valuation_id, is_active=True).first()
if not valuation:
return None
update_data = data.model_dump(exclude_unset=True)
valuation.status ="pending"
if update_data:
await valuation.update_from_dict(update_data)
await valuation.save()
out = ValuationAssessmentOut.model_validate(valuation)
return await self._attach_user_phone(out)
async def _attach_user_phone(self, out: ValuationAssessmentOut) -> ValuationAssessmentOut:
user = await AppUser.filter(id=out.user_id).first()
out.user_phone = getattr(user, "phone", None) if user else None

View File

@ -0,0 +1,13 @@
from datetime import datetime
from typing import Optional
from app.models.token_blacklist import TokenBlacklist
async def add_to_blacklist(token: str, user_id: int, exp: Optional[datetime] = None, jti: Optional[str] = None) -> None:
await TokenBlacklist.create(token=token, user_id=user_id, exp=exp, jti=jti)
async def is_blacklisted(token: str) -> bool:
return await TokenBlacklist.filter(token=token).exists()

View File

@ -7,3 +7,4 @@ from .policy import *
from .user import *
from .valuation import *
from .invoice import *
from .token_blacklist import *

View File

@ -0,0 +1,15 @@
from tortoise import fields
from .base import BaseModel, TimestampMixin
class TokenBlacklist(BaseModel, TimestampMixin):
token = fields.TextField(description="JWT令牌")
jti = fields.CharField(max_length=64, null=True, description="令牌唯一ID", index=True)
user_id = fields.IntField(description="用户ID", index=True)
exp = fields.DatetimeField(null=True, description="过期时间", index=True)
class Meta:
table = "token_blacklist"
table_description = "JWT令牌黑名单"

View File

@ -21,6 +21,8 @@ class AppUser(BaseModel, TimestampMixin):
last_login = fields.DatetimeField(null=True, description="最后登录时间", index=True)
remaining_quota = fields.IntField(default=0, description="剩余估值次数", index=True)
notes = fields.CharField(max_length=256, null=True, description="备注")
is_deleted = fields.BooleanField(default=False, description="是否已注销", index=True)
deleted_at = fields.DatetimeField(null=True, description="注销时间", index=True)
class Meta:
table = "app_user"

View File

@ -1,4 +1,4 @@
from typing import Optional, List
from typing import Optional, List, Union, Dict, Any
from pydantic import BaseModel, Field, EmailStr, field_validator, model_validator
@ -115,7 +115,7 @@ class UpdateType(BaseModel):
class PaymentReceiptCreate(BaseModel):
url: str = Field(..., min_length=1, max_length=512)
note: Optional[str] = Field(None, max_length=256)
extra: Optional[dict] = None
extra: Optional[Union[List[str], Dict[str, Any]]] = None
class PaymentReceiptOut(BaseModel):
@ -124,7 +124,7 @@ class PaymentReceiptOut(BaseModel):
note: Optional[str]
verified: bool
created_at: str
extra: Optional[dict] = None
extra: Optional[Union[List[str], Dict[str, Any]]] = None
class AppCreateInvoiceWithReceipt(BaseModel):
@ -133,7 +133,7 @@ class AppCreateInvoiceWithReceipt(BaseModel):
invoice_type: Optional[str] = Field(None, pattern=r"^(special|normal)$")
# 兼容前端索引字段:"0"→normal"1"→special
invoiceTypeIndex: Optional[str] = None
receipt_url: Optional[str] = Field(None, max_length=512)
receipt_url: Optional[Union[str, List[str]]] = Field(None)
receipt_urls: Optional[List[str]] = None
note: Optional[str] = Field(None, max_length=256)
@ -145,14 +145,26 @@ class AppCreateInvoiceWithReceipt(BaseModel):
@field_validator('receipt_url', mode='before')
@classmethod
def _clean_receipt_url(cls, v):
if isinstance(v, list) and v:
v = v[0]
if isinstance(v, list):
cleaned: List[str] = []
for item in v:
if isinstance(item, str):
s = item.strip()
if s.startswith('`') and s.endswith('`'):
s = s[1:-1].strip()
while s.endswith('\\'):
s = s[:-1].strip()
if s:
cleaned.append(s)
return cleaned or None
if isinstance(v, str):
s = v.strip()
if s.startswith('`') and s.endswith('`'):
s = s[1:-1].strip()
while s.endswith('\\'):
s = s[:-1].strip()
return s or None
return v
return None
@field_validator('receipt_urls', mode='before')
@classmethod
@ -162,10 +174,18 @@ class AppCreateInvoiceWithReceipt(BaseModel):
if isinstance(v, str):
v = [v]
if isinstance(v, list):
seen = set()
cleaned = []
for item in v:
if isinstance(item, str) and item.strip():
cleaned.append(item.strip())
if isinstance(item, str):
s = item.strip()
if s.startswith('`') and s.endswith('`'):
s = s[1:-1].strip()
while s.endswith('\\'):
s = s[:-1].strip()
if s and s not in seen:
seen.add(s)
cleaned.append(s)
return cleaned or None
return None

View File

@ -8,6 +8,8 @@ class SendEmailRequest(BaseModel):
subject: Optional[str] = Field(None, description="邮件主题")
body: str = Field(..., description="文案内容")
file_urls: Optional[List[str]] = Field(None, description="附件URL列表")
file_url: Optional[Union[str, List[str]]] = Field(None, description="附件URL或列表(兼容前端传参)")
status: Optional[str] = Field(None, description="开票状态标记: success|invoiced|rejected|refunded")
class SendEmailBody(BaseModel):

View File

@ -193,7 +193,7 @@ class ValuationAssessmentOut(ValuationAssessmentBase):
status: str = Field(..., description="评估状态")
admin_notes: Optional[str] = Field(None, description="管理员备注")
created_at: datetime = Field(..., description="创建时间")
updated_at: datetime = Field(..., description="更新时间")
updated_at: Optional[datetime] = Field(None, description="更新时间")
audited_at: Optional[datetime] = Field(None, description="审核时间")
is_active: bool = Field(..., description="是否激活")
@ -246,7 +246,7 @@ class UserValuationOut(ValuationAssessmentBase):
status: str = Field(..., description="评估状态")
admin_notes: Optional[str] = Field(None, description="管理员备注")
created_at: datetime = Field(..., description="创建时间")
updated_at: datetime = Field(..., description="更新时间")
updated_at: Optional[datetime] = Field(None, description="更新时间")
is_active: Optional[bool] = Field(None, description="是否激活")
class Config:
@ -290,7 +290,7 @@ class UserValuationDetail(ValuationAssessmentBase):
status: str = Field(..., description="评估状态")
admin_notes: Optional[str] = Field(None, description="管理员备注")
created_at: datetime = Field(..., description="创建时间")
updated_at: datetime = Field(..., description="更新时间")
updated_at: Optional[datetime] = Field(None, description="更新时间")
class Config:
from_attributes = True
@ -422,7 +422,7 @@ class ValuationCalculationStepOut(ValuationCalculationStepBase):
id: int = Field(..., description="主键ID")
valuation_id: int = Field(..., description="关联的估值评估ID")
created_at: datetime = Field(..., description="创建时间")
updated_at: datetime = Field(..., description="更新时间")
updated_at: Optional[datetime] = Field(None, description="更新时间")
class Config:
from_attributes = True

View File

@ -28,6 +28,7 @@ class VerificationStore:
self.codes: Dict[str, Tuple[str, float]] = {}
self.sends: Dict[str, Dict[str, float]] = {}
self.failures: Dict[str, Dict[str, float]] = {}
self.verified: Dict[str, float] = {}
def generate_code(self) -> str:
"""生成数字验证码
@ -144,5 +145,13 @@ class VerificationStore:
"""
self.failures.pop(phone, None)
def mark_verified(self, phone: str, ttl_seconds: int = 300) -> None:
until = time.time() + ttl_seconds
self.verified[phone] = until
def is_recently_verified(self, phone: str) -> bool:
until = self.verified.get(phone, 0.0)
return until > time.time()
store = VerificationStore()

View File

@ -3,6 +3,7 @@ from typing import Optional
import jwt
from fastapi import HTTPException, status, Depends, Header
from app.controllers.app_user import app_user_controller
from app.core.token_blacklist import is_blacklisted
from app.schemas.app_user import AppUserJWTPayload
from app.settings import settings
@ -48,18 +49,24 @@ def verify_app_user_token(token: str) -> Optional[AppUserJWTPayload]:
return None
def get_current_app_user_id(token: str = Header(None)) -> int:
async def get_current_app_user_id(token: str = Header(None)) -> int:
"""
从令牌中获取当前AppUser ID
"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的认证凭据",
detail="未登录,请重新登录",
headers={"WWW-Authenticate": "Bearer"},
)
if not token:
raise credentials_exception
if token and token != "dev":
try:
if await is_blacklisted(token):
raise credentials_exception
except Exception:
pass
payload = verify_app_user_token(token)
if payload is None:

View File

@ -9,7 +9,7 @@
2. pattern_gene_b22: 纹样基因值B22计算
- 结构复杂度SC = Σ(元素权重 × 复杂度系数) / 总元素数
- 归一化信息熵H = -Σ(p_i × log2(p_i)) / log2(n)
- 历史传承度HI = 传承年限权重 × 0.4 + 文化意义权重 × 0.3 + 保护状况权重 × 0.3
- 历史传承度HI = 出土实物×1.0 + 古代文献×0.8 + 传承人佐证×0.6 + 现代研究×0.4
- 纹样基因值B22 = (结构复杂度SC × 0.6 + 归一化信息熵H × 0.4) × 历史传承度HI × 10
- 文化价值B2 = 活态传承系数B21 × 0.6 + (纹样基因值B22 / 10) × 0.4

View File

@ -277,26 +277,19 @@ def calculate_heat_score(daily_views: float, favorites: int) -> float:
return 0.0
# 30天搜索指数S1
def calculate_search_index_s1(baidu_index: float,
wechat_index: float,
weibo_index: float) -> float:
def calculate_search_index_s1(wechat_index: float) -> float:
"""
计算近30天搜索指数S1
近30天搜索指数S1 = 百度搜索指数 × 0.4 + 微信搜索指数 × 0.3 + 微博搜索指数 × 0.3
近30天搜索指数S1 = 微信指数 / 10
args:
baidu_index: 百度搜索指数 (API获取)
wechat_index: 微信搜索指数 (API获取)
weibo_index: 微博搜索指数 (API获取)
returns:
float: 近30天搜索指数S1
"""
#
search_index = (baidu_index * 0.4 +
wechat_index * 0.3 +
weibo_index * 0.3)
search_index = wechat_index / 10.0
return search_index
# 示例使用
@ -306,10 +299,8 @@ if __name__ == "__main__":
processor = PlatformDataProcessor()
# 示例数据
# 搜索指数数据 (API获取)
baidu_index = 6000.0
# 微信指数数据 (API获取)
wechat_index = 4500.0
weibo_index = 3000.0
# 行业均值 (系统配置)
industry_average = 5000.0
@ -339,7 +330,7 @@ if __name__ == "__main__":
view_count = 200
# 计算各项指标
search_index_s1 = calculate_search_index_s1(baidu_index, wechat_index, weibo_index)
search_index_s1 = calculate_search_index_s1(wechat_index) # S1 = 微信指数 / 10
interaction_index, coverage_index = processor.calculate_multi_platform_interaction(platform_data)
conversion_efficiency = calculator.calculate_conversion_efficiency(click_count, view_count)
# 互动量指数 × 0.4 + 覆盖人群指数 × 0.3 + 转化效率 × 0.3

View File

@ -231,6 +231,86 @@ FORMULA_TREE: List[FormulaTreeNode] = [
"40",
group="DYNAMIC_PLEDGE",
),
# API查询结果记录
_node(
"API_ESG_QUERY",
"ESG评分查询",
"根据行业名称查询ESG基准分",
"50.1",
group="API_QUERY",
),
_node(
"API_INDUSTRY_QUERY",
"行业系数查询",
"根据行业名称查询行业修正系数I",
"50.2",
group="API_QUERY",
),
_node(
"API_POLICY_QUERY",
"政策匹配度查询",
"根据行业名称查询政策匹配度评分",
"50.3",
group="API_QUERY",
),
_node(
"API_JUDICIAL_QUERY",
"司法诉讼查询",
"根据机构名称查询诉讼状态,映射为法律风险评分(无诉讼:10分, 已解决:7分, 未解决:0分",
"50.4",
group="API_QUERY",
),
_node(
"API_PATENT_QUERY",
"专利信息查询",
"根据专利申请号查询专利数量和剩余年限,计算专利评分",
"50.5",
group="API_QUERY",
),
_node(
"API_WECHAT_INDEX",
"微信指数查询",
"根据资产名称查询微信指数计算搜索指数S1 = 微信指数 / 10",
"50.6",
group="API_QUERY",
),
# 参数映射记录
_node(
"MAPPING_CROSS_BORDER_DEPTH",
"跨界合作深度映射",
"用户选项映射为评分:无(0分), 品牌联名(3分), 科技载体(5分), 国家外交礼品(10分)",
"51.1",
group="PARAM_MAPPING",
),
_node(
"MAPPING_INHERITOR_LEVEL",
"传承人等级映射",
"用户选项映射为系数:国家级(10分), 省级(7分), 市级及以下(4分)",
"51.2",
group="PARAM_MAPPING",
),
# 权重计算记录
_node(
"CALC_HISTORICAL_INHERITANCE",
"历史传承度计算",
"HI = 出土实物×1.0 + 古代文献×0.8 + 传承人佐证×0.6 + 现代研究×0.4",
"52.1",
group="PARAM_CALC",
),
_node(
"CALC_INHERITANCE_RISK",
"传承风险年龄转换",
"根据各年龄段传承人数量计算传承风险评分≤50岁(10分), 50-70岁(5分), >70岁(0分), 取最高分",
"52.2",
group="PARAM_CALC",
),
_node(
"CALC_MARKET_RISK",
"市场风险价格波动",
"根据30天价格波动计算市场风险评分波动率≤5%(10分), 5-15%(5分), >15%(0分)",
"52.3",
group="PARAM_CALC",
),
],
)
]

View File

@ -222,7 +222,10 @@ class RiskAdjustmentB3Calculator:
valuation_id,
"MODEL_B_RISK_B3_INHERITANCE",
status="completed",
input_params={"inheritor_ages": input_data.get("inheritor_ages")},
input_params={
"inheritor_ages": input_data.get("inheritor_ages"),
"score_rule": "≤50岁:10分, 50-70岁:5分, >70岁:0分, 取最高分"
},
output_result={'inheritance_risk': inheritance_risk},
)
@ -232,12 +235,30 @@ class RiskAdjustmentB3Calculator:
# 计算风险调整系数B3
risk_adjustment_b3 = self.calculate_risk_adjustment_b3(risk_score_sum)
# 调试输出打印B3计算的关键值
print(f"=== B3计算调试 ===")
print(f"市场风险: {market_risk}")
print(f"法律风险: {legal_risk}")
print(f"传承风险: {inheritance_risk}")
print(f"风险评分总和R: {risk_score_sum}")
print(f"风险调整系数B3: {risk_adjustment_b3}")
print(f"=== B3计算完成 ===")
result = {
"risk_value_b3": risk_adjustment_b3,
"risk_score_sum": risk_score_sum,
"market_risk": market_risk,
"legal_risk": legal_risk,
"inheritance_risk": inheritance_risk,
# 详细计算过程
"calculation_detail": {
"step1_market_risk": f"市场风险 = {market_risk}分 (波动率评分)",
"step2_legal_risk": f"法律风险 = {legal_risk}分 (诉讼状态评分)",
"step3_inheritance_risk": f"传承风险 = {inheritance_risk}分 (年龄评分)",
"step4_risk_score_sum": f"R = ({market_risk}×0.3 + {legal_risk}×0.4 + {inheritance_risk}×0.3) / 10 = {risk_score_sum}",
"step5_risk_adjustment_b3": f"B3 = 0.8 + {risk_score_sum}×0.4 = {risk_adjustment_b3}",
"formula": "风险调整系数B3 = 0.8 + R × 0.4, R = (市场风险×0.3 + 法律风险×0.4 + 传承风险×0.3) / 10"
}
}
await self.valuation_controller.log_formula_step(
valuation_id,

View File

@ -1,5 +1,6 @@
#!/bin/sh
set -e
nginx
# nginx
python run.py

View File

@ -1,74 +0,0 @@
-- 完整菜单初始化SQL
-- 创建时间: 2025-11-20
-- 说明: 包含所有新增的菜单项和权限分配
-- ========================================
-- 1. 工作台菜单
-- ========================================
INSERT INTO menu (id, name, menu_type, icon, path, "order", parent_id, is_hidden, component, keepalive, redirect, created_at, updated_at)
VALUES
(22, '工作台', 'menu', 'carbon:dashboard', '/workbench', 1, 0, 0, '/workbench', 1, NULL, datetime('now'), datetime('now'));
-- ========================================
-- 2. 交易管理菜单
-- ========================================
-- 插入一级目录:交易管理
INSERT INTO menu (id, name, menu_type, icon, path, "order", parent_id, is_hidden, component, keepalive, redirect, created_at, updated_at)
VALUES
(16, '交易管理', 'catalog', 'carbon:receipt', '/transaction', 3, 0, 0, 'Layout', 0, '/transaction/invoice', datetime('now'), datetime('now'));
-- 插入二级菜单:交易管理
INSERT INTO menu (id, name, menu_type, icon, path, "order", parent_id, is_hidden, component, keepalive, redirect, created_at, updated_at)
VALUES
(17, '交易管理', 'menu', 'carbon:document', 'invoice', 1, 16, 0, '/transaction/invoice', 0, NULL, datetime('now'), datetime('now'));
-- ========================================
-- 3. 估值管理菜单
-- ========================================
-- 插入一级目录:估值管理
INSERT INTO menu (id, name, menu_type, icon, path, "order", parent_id, is_hidden, component, keepalive, redirect, created_at, updated_at)
VALUES
(18, '估值管理', 'catalog', 'carbon:calculator', '/valuation', 4, 0, 0, 'Layout', 0, '/valuation/audit', datetime('now'), datetime('now'));
-- 插入二级菜单:审核列表
INSERT INTO menu (id, name, menu_type, icon, path, "order", parent_id, is_hidden, component, keepalive, redirect, created_at, updated_at)
VALUES
(19, '审核列表', 'menu', 'carbon:task-approved', 'audit', 1, 18, 0, '/valuation/audit', 0, NULL, datetime('now'), datetime('now'));
-- ========================================
-- 4. 用户管理菜单
-- ========================================
-- 插入一级目录:用户管理
INSERT INTO menu (id, name, menu_type, icon, path, "order", parent_id, is_hidden, component, keepalive, redirect, created_at, updated_at)
VALUES
(20, '用户管理', 'catalog', 'carbon:user-multiple', '/user-management', 5, 0, 0, 'Layout', 0, '/user-management/user-list', datetime('now'), datetime('now'));
-- 插入二级菜单:用户列表
INSERT INTO menu (id, name, menu_type, icon, path, "order", parent_id, is_hidden, component, keepalive, redirect, created_at, updated_at)
VALUES
(21, '用户列表', 'menu', 'carbon:user', 'user-list', 1, 20, 0, '/user-management/user-list', 0, NULL, datetime('now'), datetime('now'));
-- ========================================
-- 角色权限分配
-- ========================================
-- 为管理员角色(role_id=1)分配所有菜单权限
INSERT INTO role_menu (role_id, menu_id)
VALUES
(1, 22), -- 工作台
(1, 16), -- 交易管理
(1, 17), -- 交易管理
(1, 18), -- 估值管理
(1, 19), -- 审核列表
(1, 20), -- 用户管理
(1, 21); -- 用户列表
-- 为普通用户角色(role_id=2)分配基础菜单权限
INSERT INTO role_menu (role_id, menu_id)
VALUES
(2, 22), -- 工作台
(2, 16), -- 交易管理
(2, 17), -- 交易管理
(2, 18), -- 估值管理
(2, 19); -- 审核列表
-- 注意:普通用户不分配用户管理权限

View File

@ -189,7 +189,7 @@ def build_sample_payload() -> Dict[str, Any]:
"market_activity_time": "近一周",
"monthly_transaction_amount": "月交易额100万500万",
"platform_accounts": {
"douyin": {"account": "成都文交所", "likes": "500000", "comments": "89222", "shares": "97412"}
"douyin": {"account": "成都文交所", "likes": "500000", "comments": "89222", "shares": "97412", "views": "100000"}
}
}
# 若 application_coverage 为占位,则用 coverage_area 回填

View File

@ -346,6 +346,18 @@ async def main() -> None:
d_ok = (d_code == 200 and isinstance(d_data, dict) and d_data.get("data", {}).get("deleted"))
results.append({"name": "删除估值", "status": "PASS" if d_ok else "FAIL", "message": "删除成功" if d_ok else "删除失败", "detail": {"http": d_code, "body": d_data}})
# 注销账号
da_resp = await client.delete(make_url(base, "/app-user/account"), headers={"token": use_token})
da_code = da_resp.status_code
da_data = _ensure_dict(da_resp.json() if da_resp.headers.get("content-type", "").startswith("application/json") else {"raw": da_resp.text})
da_ok = (da_code == 200 and isinstance(da_data, dict))
results.append({"name": "注销账号", "status": "PASS" if da_ok else "FAIL", "message": "注销成功" if da_ok else "注销失败", "detail": {"http": da_code, "body": da_data}})
# 注销后旧token访问应失败
vt2_code, vt2_data = await api_get(client, make_url(base, "/app-user/validate-token"), headers={"token": use_token})
vt2_ok = (vt2_code in (401, 403))
results.append({"name": "注销后token访问", "status": "PASS" if vt2_ok else "FAIL", "message": "拒绝访问" if vt2_ok else "未拒绝", "detail": {"http": vt2_code, "body": vt2_data}})
# 登出
lo_code, lo_data = await api_post_json(client, make_url(base, "/app-user/logout"), {}, headers={"token": use_token})
lo_ok = (lo_code == 200)

View File

View File

@ -237,7 +237,8 @@ export const generateReport = async (detailData) => {
if (calcResult.model_value_b !== undefined) data.B = formatNumberValue(calcResult.model_value_b)
if (calcResult.economic_value_b1 !== undefined) data.B1 = formatNumberValue(calcResult.economic_value_b1)
if (calcResult.cultural_value_b2 !== undefined) data.B2 = formatNumberValue(calcResult.cultural_value_b2)
if (calcResult.risk_adjustment_b3 !== undefined) data.B3 = calcResult.risk_adjustment_b3
if (calcResult.risk_value_b3 !== undefined) data.B3 = calcResult.risk_value_b3
else if (calcResult.risk_adjustment_b3 !== undefined) data.B3 = calcResult.risk_adjustment_b3
if (calcResult.market_value_c !== undefined) data.C = formatNumberValue(calcResult.market_value_c)
// ========== 6.7 添加动态质押率DPR ==========

View File

@ -176,7 +176,7 @@ const columns = [
NPopover,
{
trigger: 'hover',
placement: 'top',
placement: 'right',
showArrow: false,
displayDirective: 'show', // keep content mounted so image preview won't flash close
},

View File

@ -46,7 +46,8 @@ const activeDetailTab = ref('audit')
const reportLoading = ref(false)
const reportContent = ref('')
const pickFilledValue = (...values) => values.find((val) => val !== undefined && val !== null && val !== '')
const pickFilledValue = (...values) =>
values.find((val) => val !== undefined && val !== null && val !== '')
const formatEnumField = (key, ...values) => formatEnumByKey(pickFilledValue(...values), key)
//
@ -97,7 +98,11 @@ const detailSections = computed(() => {
fields: [
{ label: '资产名称', type: 'text', value: detail.asset_name || '-' },
{ label: '所属机构/权利人', type: 'text', value: detail.institution || '-' },
{ label: '统一社会信用代码/身份证号', type: 'text', value: detail.credit_code_or_id || '-' },
{
label: '统一社会信用代码/身份证号',
type: 'text',
value: detail.credit_code_or_id || '-',
},
{ label: '所属行业', type: 'text', value: detail.industry || '-' },
{ label: '业务/传承介绍', type: 'text', value: detail.biz_intro || '-' },
],
@ -106,17 +111,37 @@ const detailSections = computed(() => {
key: 'finance',
title: '财务状况',
fields: [
{ label: '近12个月机构营收/万元', type: 'text', value: formatNumberValue(detail.annual_revenue) },
{ label: '近12个月机构研发投入/万元', type: 'text', value: formatNumberValue(detail.rd_investment) },
{ label: '近三年机构收益/万元', type: 'list', value: formatThreeYearIncome(detail.three_year_income) },
{ label: '资产受资助情况', type: 'text', value: formatEnumField('fundingStatus', detail.funding_status) },
{
label: '近12个月机构营收/万元',
type: 'text',
value: formatNumberValue(detail.annual_revenue),
},
{
label: '近12个月机构研发投入/万元',
type: 'text',
value: formatNumberValue(detail.rd_investment),
},
{
label: '近三年机构收益/万元',
type: 'list',
value: formatThreeYearIncome(detail.three_year_income),
},
{
label: '资产受资助情况',
type: 'text',
value: formatEnumField('fundingStatus', detail.funding_status),
},
],
},
{
key: 'tech',
title: '非遗等级与技术',
fields: [
{ label: '非遗传承人等级', type: 'text', value: formatEnumField('inheritorLevel', detail.inheritor_level) },
{
label: '非遗传承人等级',
type: 'text',
value: formatEnumField('inheritorLevel', detail.inheritor_level),
},
{
label: '非遗传承人年龄水平及数量',
type: 'list',
@ -126,10 +151,22 @@ const detailSections = computed(() => {
{
label: '非遗等级',
type: 'text',
value: formatEnumField('heritageLevel', detail.heritage_level, detail.heritage_asset_level),
value: formatEnumField(
'heritageLevel',
detail.heritage_level,
detail.heritage_asset_level
),
},
{
label: '非遗资产所用专利的申请号',
type: 'text',
value: detail.patent_application_no || '-',
},
{
label: '非遗资产历史证明证据及数量',
type: 'list',
value: formatHistoricalEvidence(detail.historical_evidence),
},
{ label: '非遗资产所用专利的申请号', type: 'text', value: detail.patent_application_no || '-' },
{ label: '非遗资产历史证明证据及数量', type: 'list', value: formatHistoricalEvidence(detail.historical_evidence) },
{
label: '非遗资产所用专利/纹样图片',
type: 'images',
@ -144,50 +181,86 @@ const detailSections = computed(() => {
{
label: '非遗资产应用成熟度',
type: 'text',
value: formatEnumField('applicationMaturity', detail.application_maturity, detail.implementation_stage),
value: formatEnumField(
'applicationMaturity',
detail.application_maturity,
detail.implementation_stage
),
},
{
label: '非遗资产应用覆盖范围',
type: 'text',
value: formatEnumField('applicationCoverage', detail.application_coverage, detail.coverage_area),
value: formatEnumField(
'applicationCoverage',
detail.application_coverage,
detail.coverage_area
),
},
{
label: '非遗资产跨界合作深度',
type: 'text',
value: formatEnumField('cooperationDepth', detail.cooperation_depth, detail.collaboration_type),
value: formatEnumField(
'cooperationDepth',
detail.cooperation_depth,
detail.collaboration_type
),
},
{
label: '近12个月线下相关宣讲活动次数',
type: 'text',
value: formatNumberValue(detail.offline_activities ?? detail.offline_teaching_count),
},
{ label: '线上相关宣传账号信息', type: 'list', value: formatPlatformAccounts(detail.platform_accounts) },
{
label: '线上相关宣传账号信息',
type: 'list',
value: formatPlatformAccounts(detail.platform_accounts),
},
],
},
{
key: 'products',
title: '非遗资产衍生商品信息',
fields: [
{ label: '代表产品近12个月销售数量', type: 'text', value: formatNumberValue(detail.sales_volume) },
{
label: '代表产品近12个月销售数量',
type: 'text',
value: formatNumberValue(detail.sales_volume),
},
{ label: '商品链接浏览量', type: 'text', value: formatNumberValue(detail.link_views) },
{ label: '发行量', type: 'text', value: formatEnumField('circulation', detail.circulation, detail.scarcity_level) },
{
label: '发行量',
type: 'text',
value: formatEnumField('circulation', detail.circulation, detail.scarcity_level),
},
{
label: '最近一次市场活动时间',
type: 'text',
value: formatEnumField('marketActivity', detail.last_market_activity, detail.market_activity_time),
value: formatEnumField(
'marketActivity',
detail.last_market_activity,
detail.market_activity_time
),
},
{
label: '月交易额水平',
type: 'text',
value: formatEnumField('monthlyTransaction', detail.monthly_transaction, detail.monthly_transaction_amount),
value: formatEnumField(
'monthlyTransaction',
detail.monthly_transaction,
detail.monthly_transaction_amount
),
},
{
label: '近30天价格区间',
type: 'text',
value: formatPriceRange(detail.price_fluctuation),
},
{ label: '近30天价格区间', type: 'text', value: formatPriceRange(detail.price_fluctuation) },
],
},
]
// section NDataTable columns data
return sections.map(section => {
return sections.map((section) => {
const columns = [
{
title: '字段名',
@ -196,13 +269,15 @@ const detailSections = computed(() => {
align: 'center',
fixed: 'left',
},
...section.fields.map(field => ({
...section.fields.map((field) => ({
title: field.label,
key: field.label,
width: 200,
ellipsis: ['list', 'images'].includes(field.type) ? false : {
ellipsis: ['list', 'images'].includes(field.type)
? false
: {
tooltip: {
style: { maxWidth: '600px', maxHeight: '400px', overflow: 'auto' }
style: { maxWidth: '600px', maxHeight: '400px', overflow: 'auto' },
},
},
render: (row) => {
@ -211,40 +286,48 @@ const detailSections = computed(() => {
if (fieldData.type === 'list') {
if (fieldData.value && fieldData.value.length) {
return h('div', { style: 'display: flex; flex-direction: column; gap: 4px;' },
fieldData.value.map(item => h('span', item))
return h(
'div',
{ style: 'display: flex; flex-direction: column; gap: 4px;' },
fieldData.value.map((item) => h('span', item))
)
}
return '-'
} else if (fieldData.type === 'images') {
if (fieldData.value && fieldData.value.length) {
const createImages = () => h(NImageGroup, {}, () =>
fieldData.value.map(img =>
const createImages = () =>
h(NImageGroup, {}, () =>
fieldData.value.map((img) =>
h(NImage, {
src: img,
width: 72,
height: 48,
objectFit: 'cover',
style: 'margin-right: 8px;'
style: 'margin-right: 8px;',
})
)
)
return h(NPopover, {
return h(
NPopover,
{
trigger: 'hover',
displayDirective: 'show',
keepAliveOnHover: true,
style: { maxWidth: '600px', maxHeight: '400px', overflow: 'auto' }
}, {
trigger: () => h('div', { style: 'display: flex; overflow: hidden;' }, createImages()),
default: () => createImages()
})
style: { maxWidth: '600px', maxHeight: '400px', overflow: 'auto' },
},
{
trigger: () =>
h('div', { style: 'display: flex; overflow: hidden;' }, createImages()),
default: () => createImages(),
}
)
}
return '-'
} else {
return fieldData.value || '-'
}
}
},
})),
]
@ -268,18 +351,15 @@ const detailSections = computed(() => {
const calcFlow = computed(() => props.detailData?.calculation_result?.flow || [])
const renderedFlowHtml = computed(() => {
return marked.parse(reportContent.value || mockReportMarkdown)
})
//
const handleUploadCertificate = () => {
certificateModalMode.value = 'upload'
certificateData.value = {
detailData: props.detailData
detailData: props.detailData,
}
certificateModalVisible.value = true
}
@ -290,20 +370,23 @@ const handleViewCertificate = () => {
const formatFiles = (urlData) => {
if (!urlData) return []
// Handle string (single or comma-separated)
const urls = typeof urlData === 'string' ? urlData.split(',') : (Array.isArray(urlData) ? urlData : [])
const urls =
typeof urlData === 'string' ? urlData.split(',') : Array.isArray(urlData) ? urlData : []
return urls.filter(u => u).map((url, index) => ({
return urls
.filter((u) => u)
.map((url, index) => ({
id: String(index),
name: url.substring(url.lastIndexOf('/') + 1) || 'unknown',
status: 'finished',
url: url
url: url,
}))
}
certificateData.value = {
reportFiles: formatFiles(props.detailData?.report_url),
certificateFiles: formatFiles(props.detailData?.certificate_url),
detailData: props.detailData
detailData: props.detailData,
}
certificateModalVisible.value = true
}
@ -312,21 +395,20 @@ const handleCertificateConfirm = async (data) => {
console.log('证书数据:', data)
try {
const certificateUrl = data.certificateFiles?.map(f => f.url).filter(Boolean) || []
const reportUrl = data.reportFiles?.map(f => f.url).filter(Boolean) || []
const certificateUrl = data.certificateFiles?.map((f) => f.url).filter(Boolean) || []
const reportUrl = data.reportFiles?.map((f) => f.url).filter(Boolean) || []
// 1
const payload = {
...props.detailData,
certificate_url: certificateUrl?.[0],
report_url: reportUrl?.[0],
status: 'success'
status: 'success',
}
console.log("🔥🔥🔥🔥🔥🔥🔥 ~ handleCertificateConfirm ~ payload:", payload);
await api.updateValuation(payload)
$message.success('上传并通知成功')
$message.success('上传成功')
certificateModalVisible.value = false
emit('back') // emit('refresh')
} catch (error) {
@ -359,12 +441,7 @@ const handleCertificateConfirm = async (data) => {
</div>
</div>
<NTabs
v-model:value="activeDetailTab"
type="line"
size="large"
class="audit-tabs"
>
<NTabs v-model:value="activeDetailTab" type="line" size="large" class="audit-tabs">
<NTabPane name="audit" tab="审核信息">
<NSpin :show="loading">
<div v-for="section in detailSections" :key="section.key" class="detail-section">
@ -395,19 +472,11 @@ const handleCertificateConfirm = async (data) => {
<!-- 证书按钮 -->
<div class="certificate-actions">
<NButton
v-if="mode === 'approve'"
type="primary"
@click="handleUploadCertificate"
>
<NButton v-if="mode === 'approve'" type="primary" @click="handleUploadCertificate">
<TheIcon icon="mdi:upload" :size="16" class="mr-4" />
上传证书
</NButton>
<NButton
v-else
type="info"
@click="handleViewCertificate"
>
<NButton v-else type="info" @click="handleViewCertificate">
<TheIcon icon="mdi:eye" :size="16" class="mr-4" />
查看证书
</NButton>
@ -567,10 +636,23 @@ const handleCertificateConfirm = async (data) => {
font-weight: 600;
line-height: 1.25;
}
.markdown-body :deep(h1) { font-size: 2em; border-bottom: 1px solid #eaecef; padding-bottom: .3em; }
.markdown-body :deep(h2) { font-size: 1.5em; border-bottom: 1px solid #eaecef; padding-bottom: .3em; }
.markdown-body :deep(h3) { font-size: 1.25em; }
.markdown-body :deep(p) { margin-top: 0; margin-bottom: 16px; }
.markdown-body :deep(h1) {
font-size: 2em;
border-bottom: 1px solid #eaecef;
padding-bottom: 0.3em;
}
.markdown-body :deep(h2) {
font-size: 1.5em;
border-bottom: 1px solid #eaecef;
padding-bottom: 0.3em;
}
.markdown-body :deep(h3) {
font-size: 1.25em;
}
.markdown-body :deep(p) {
margin-top: 0;
margin-bottom: 16px;
}
.markdown-body :deep(blockquote) {
margin: 0 0 16px;
padding: 0 1em;
@ -592,7 +674,8 @@ const handleCertificateConfirm = async (data) => {
.markdown-body :deep(table tr:nth-child(2n)) {
background-color: #f6f8fa;
}
.markdown-body :deep(table th), .markdown-body :deep(table td) {
.markdown-body :deep(table th),
.markdown-body :deep(table td) {
padding: 6px 13px;
border: 1px solid #dfe2e5;
}
@ -767,6 +850,4 @@ const handleCertificateConfirm = async (data) => {
color: #999;
padding: 40px 0;
}
</style>

View File

@ -1,15 +1,6 @@
<script setup>
import { ref, watch, computed } from 'vue'
import {
NModal,
NCard,
NButton,
NUpload,
NText,
NImage,
NImageGroup,
useMessage
} from 'naive-ui'
import { NModal, NCard, NButton, NUpload, NText, NImage, NImageGroup, useMessage } from 'naive-ui'
//
// import { DownloadIcon } from '@vicons/tabler'
@ -51,8 +42,14 @@ watch(
if (val) {
if (props.mode === 'view') {
//
reportFileList.value = (props.certificateData?.reportFiles || []).map(f => ({ ...f, status: 'finished' }))
certificateFileList.value = (props.certificateData?.certificateFiles || []).map(f => ({ ...f, status: 'finished' }))
reportFileList.value = (props.certificateData?.reportFiles || []).map((f) => ({
...f,
status: 'finished',
}))
certificateFileList.value = (props.certificateData?.certificateFiles || []).map((f) => ({
...f,
status: 'finished',
}))
} else {
//
reportFileList.value = []
@ -70,16 +67,19 @@ const handleClose = () => {
//
//
const handleConfirm = () => {
const getFiles = (list) => list.map(f => ({
const getFiles = (list) =>
list
.map((f) => ({
id: f.id,
name: f.name,
url: f.url,
type: f.type
})).filter(f => f.url)
type: f.type,
}))
.filter((f) => f.url)
emit('confirm', {
reportFiles: getFiles(reportFileList.value),
certificateFiles: getFiles(certificateFileList.value)
certificateFiles: getFiles(certificateFileList.value),
})
handleClose()
}
@ -154,15 +154,12 @@ const handleCertificateUploadFinish = ({ file, event }) => {
}
}
const modalTitle = computed(() => {
return props.mode === 'upload' ? '上传' : '查看'
})
const isUploadMode = computed(() => props.mode === 'upload')
//
const handleDownloadReport = async () => {
try {
@ -200,13 +197,14 @@ const handleSmsNotify = async () => {
}
message.loading('正在发送短信...')
await api.sendSmsReport({
phone: phone
phone: phone,
})
message.success('短信发送成功')
handleConfirm()
handleClose()
emit('confirm', {
reportFiles: [],
certificateFiles: []
certificateFiles: [],
})
} catch (error) {
console.error(error)
@ -231,9 +229,7 @@ const handleSmsNotify = async () => {
<div class="upload-section">
<div class="section-header">
<div class="section-title">报告:</div>
<NButton text type="primary" @click="handleDownloadReport">
点击下载原版报告
</NButton>
<NButton text type="primary" @click="handleDownloadReport"> 点击下载原版报告 </NButton>
</div>
<div class="upload-content">
<NUpload
@ -275,19 +271,12 @@ const handleSmsNotify = async () => {
</div>
</div>
<template #footer>
<div class="modal-footer">
<NButton @click="handleClose">取消</NButton>
<NButton v-if="isUploadMode" type="primary" @click="handleConfirm">
上传
</NButton>
<NButton v-if="isUploadMode" type="primary" @click="handleSmsNotify">
短信通知
</NButton>
<NButton v-else type="primary" @click="handleConfirm">
确定
</NButton>
<NButton v-if="isUploadMode" type="primary" @click="handleConfirm"> 上传 </NButton>
<NButton v-if="isUploadMode" type="primary" @click="handleSmsNotify"> 短信通知 </NButton>
<NButton v-else type="primary" @click="handleConfirm"> 确定 </NButton>
</div>
</template>
</NModal>
@ -353,7 +342,6 @@ const handleSmsNotify = async () => {
margin-bottom: 8px;
}
.file-name {
font-size: 14px;
color: #333;
@ -429,7 +417,6 @@ const handleSmsNotify = async () => {
display: inline-block;
}
/* 底部按钮 */
.modal-footer {
display: flex;
@ -437,12 +424,10 @@ const handleSmsNotify = async () => {
gap: 12px;
}
/* 响应式调整 */
@media (max-width: 768px) {
.certificate-modal {
width: 95vw !important;
}
}
</style>

View File

@ -148,7 +148,7 @@ export const formatPlatformAccounts = (accounts = {}) => {
if (!info) return `${label}-`
return `${label}${info.account || '-'}(赞${formatNumberValue(info.likes)} / 评${formatNumberValue(
info.comments
)} / 转${formatNumberValue(info.shares)}`
)} / 转${formatNumberValue(info.shares)}/ 七日浏览量${formatNumberValue(info.views)}`
})
return list.length ? list : ['暂无账号信息']
}

BIN
web1/dist.zip Normal file

Binary file not shown.

View File

@ -5,6 +5,11 @@ export default {
getUserInfo: () => request.get('/base/userinfo'),
getUserMenu: () => request.get('/base/usermenu'),
getUserApi: () => request.get('/base/userapi'),
deleteAccount: (data) => request.delete('/app-user/account', {
data: {
code: data.code // Body 中携带 code
}
}),
// 手机号
registerPhone: (data) => request.post('/app-user/register', data, { noNeedToken: true }),
loginPhone: (data) => request.post('/app-user/login', data, { noNeedToken: true }),
@ -18,6 +23,7 @@ export default {
getHistoryList: (params) => request.get('/app-valuations/', { params }),
valuations: (data = {}) => request.post('/app-valuations/', data),
deleteValuations: (params = {}) => request.delete(`/app-valuations/${params.id}`),
getValuation: (id) => request.get(`/app-valuations/${id}`),
// profile
updatePassword: (data = {}) => request.post('/base/update_password', data),
// users

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -52,6 +52,14 @@ export const basicRoutes = [
title: '抬头管理',
},
},
{
name: 'Logout',
path: 'logout',
component: () => import('@/views/user-center/components/Logout.vue'),
meta: {
title: '账号注销',
},
},
],
},
{

View File

@ -1,6 +1,21 @@
<template>
<div class="pages">
<AppHeader class="page-header" />
<!-- 页面初始化 loading -->
<div
v-if="pageLoading"
class="right"
style="
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
"
>
<n-spin size="large" />
<div style="font-size: 16px; color: #999999; margin-top: 20px">加载中...</div>
</div>
<!-- 左侧边栏 - 已隐藏 -->
<!-- <div class="left">
<img style="width: 198px; height: 32px; margin: 20px;" src="@/assets/images/logo.png" alt="" @click="navToLogin">
@ -11,7 +26,7 @@
</div>
</div>
</div> -->
<div v-if="status == 'create'" class="right">
<div v-if="!pageLoading && status == 'create'" class="right">
<StepProgressBar
style="width: 800px; margin: auto; margin-top: 40px"
:steps="steps"
@ -186,7 +201,7 @@
<template #trigger>
<img style="width: 14px; height: 14px" src="@/assets/images/ps.png" alt="" />
</template>
填写近3年资产相关的年收益,用以判断资产的商业价值
填写近3年资产相关的年收益,用以判断资产的商业价值,若机构成立时间不足三年其成立前年份的收益值可按成立后最近一个完整年度的数据填报
</n-tooltip>
</div>
</template>
@ -300,7 +315,9 @@
:default-file-list="modalForm.inheritor_certificates"
:max="uploadLimit"
list-type="image-card"
:on-before-upload="(options) => handleBeforeUpload(options, 'inheritor_certificates')"
:on-before-upload="
(options) => handleBeforeUpload(options, 'inheritor_certificates')
"
@finish="handleFinish3"
@remove="delete3"
>
@ -564,8 +581,10 @@
</n-tooltip>
</div>
</template>
<div>
<div style="display: flex">
<n-select
style="width: 330px"
style="width: 220px"
v-model:value="modalForm.online_accounts[0]"
placeholder="请选择"
:options="accountsOptions"
@ -581,10 +600,13 @@
style="width: 220px; margin-left: 10px"
type="number"
/>
</div>
<div style="display: flex; margin-top: 12px">
<NInput
v-model:value="modalForm.online_accounts[3]"
placeholder="请输入评论数量"
style="width: 220px; margin-left: 10px"
style="width: 220px"
type="number"
/>
<NInput
@ -593,6 +615,14 @@
style="width: 220px; margin-left: 10px"
type="number"
/>
<NInput
v-model:value="modalForm.online_accounts[5]"
placeholder="请输入近七天点击量"
style="width: 220px; margin-left: 10px"
type="number"
/>
</div>
</div>
</n-form-item-gi>
</n-grid>
<n-grid v-if="currentStep == 4" :cols="24" :x-gap="0">
@ -742,7 +772,7 @@
</div>
</div>
</div>
<div v-if="status == 'success'" class="right">
<div v-if="!pageLoading && status != 'fail' && status != 'create'" class="right">
<div class="price-container" :style="{ backgroundImage: `url(${backgroundImg})` }">
<div>¥{{ parseInt(selectedObj?.calculation_result?.final_value_ab) }}</div>
<div style="font-size: 20px; color: #303133; line-height: 20px">最终评估结果</div>
@ -866,20 +896,19 @@
</div>
</div>
</div>
<div style="display: flex; justify-content: center">
<div class="retry" @click="gotoHome">返回首页</div>
<div
class="retry"
@click="retry"
@click="gotoHistory"
style="background: #f8f8f8; color: #303133; line-height: 40px"
>
<img
style="width: 16px; height: 16px; transform: translate(0, 2px); margin-right: 6px"
src="@/assets/images/retry.png"
alt=""
/>
重新评估
查看估值记录
</div>
</div>
<div v-if="status == 'fail'" class="right" style="text-align: center">
</div>
<div v-if="!pageLoading && status == 'fail'" class="right" style="text-align: center">
<img
style="width: 100px; height: 100px; margin-top: 30vh"
src="@/assets/images/fail.png"
@ -898,17 +927,19 @@
重新评估
</div>
</div>
<div v-if="status == 'pending'" class="right" style="text-align: center">
<img
style="width: 100px; height: 100px; margin-top: 30vh"
src="@/assets/images/loading.png"
alt=""
/>
<div style="font-size: 20px">评估中</div>
<!-- <div v-if="!pageLoading && status == 'pending'" class="right" style="
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
">
<img class="loading-icon" style="width: 100px; height: 100px" src="@/assets/images/loading.png" alt="" />
<div style="font-size: 20px; margin-top: 20px">评估中</div>
<div style="font-size: 14px; color: #999999; margin-top: 10px">
您的知识产权和非物质文化遗产的价值正在评估中请耐心等候
</div>
您的知识产权和非物质文化遗产的价值正在评估中预计30秒到1分钟请耐心等候
</div>
</div> -->
</div>
</template>
@ -927,6 +958,7 @@ import backgroundImg4 from '@/assets/images/circleg.png'
import backgroundImg5 from '@/assets/images/circley.png'
import backgroundImg6 from '@/assets/images/circlec.png'
const status = ref('create')
const pageLoading = ref(false) // loading
const message = useMessage()
const modalFormRef = ref<FormInst | null>(null)
const isSelected = ref<Number | null>(null)
@ -1161,7 +1193,7 @@ const modalRules = {
required: true,
message: '',
trigger: ['input', 'blur'],
len: 5,
len: 6,
fields: [
{
required: true,
@ -1188,6 +1220,11 @@ const modalRules = {
message: '请输入账号分享量',
trigger: ['input', 'blur'],
},
{
required: true,
message: '请输入近七天点击量',
trigger: ['input', 'blur'],
},
],
},
],
@ -1490,7 +1527,7 @@ const nextStep = () => {
}
})
}
const submit = () => {
const submit = async () => {
const data = {
...modalForm,
industry: industryOptions.value.find((item) => item.value === modalForm.industry).label,
@ -1537,6 +1574,7 @@ const submit = () => {
likes: modalForm.online_accounts[2],
comments: modalForm.online_accounts[3],
shares: modalForm.online_accounts[4],
views: modalForm.online_accounts[5],
},
}
} else if (modalForm.online_accounts[0] == '1') {
@ -1546,6 +1584,7 @@ const submit = () => {
likes: modalForm.online_accounts[2],
comments: modalForm.online_accounts[3],
shares: modalForm.online_accounts[4],
views: modalForm.online_accounts[5],
},
}
} else if (modalForm.online_accounts[0] == '2') {
@ -1555,6 +1594,7 @@ const submit = () => {
likes: modalForm.online_accounts[2],
comments: modalForm.online_accounts[3],
shares: modalForm.online_accounts[4],
views: modalForm.online_accounts[5],
},
}
} else if (modalForm.online_accounts[0] == '3') {
@ -1564,21 +1604,65 @@ const submit = () => {
likes: modalForm.online_accounts[2],
comments: modalForm.online_accounts[3],
shares: modalForm.online_accounts[4],
views: modalForm.online_accounts[5],
},
}
}
data.inheritor_age_count = data.inheritor_ages
message.success('正在评估中,请前往历史记录查看')
loading.value = true
// message.success('')
pageLoading.value = true
api.valuations(data).then((res) => {
loading.value = false
getHistoryList()
message.success('评估完成 将于7个工作日内生成报告与证书 以短信形式通知')
router.push('/user-center')
// loading.value = false
// getHistoryList()
// message.success(' 7 ')
// router.push('/user-center')
// status.value = 'success'
// setTimeout(() => {
// window.location.reload()
// }, 1000)
//
const pollWithLimit = async (maxRetries = 60, valuation_id) => {
let retries = 0
const poll = async () => {
retries++
if (retries > maxRetries) {
return false
}
try {
const res = await api.getValuation(valuation_id)
if (res.data && res.data.calculation_result) {
pageLoading.value = false
selectTimeBox(res.data)
dialog.success({
title: '评估完成',
content: '将于7个工作日内生成报告与证书以短信形式通知',
positiveText: '我已知晓',
negativeText: '取消',
})
return true
} else {
return new Promise((resolve) => {
setTimeout(async () => {
resolve(await poll())
}, 2000)
})
}
} catch (error) {
return new Promise((resolve) => {
setTimeout(async () => {
resolve(await poll())
}, 2000)
})
}
}
await poll()
}
console.log(res)
if (res.code == 200) {
pollWithLimit(60, res.data.valuation_id)
}
})
}
const getHistoryList = () => {
@ -1622,6 +1706,14 @@ async function retry() {
console.log(err)
}
}
function gotoHome() {
router.push('/home')
}
function gotoHistory() {
router.push('/user-center/history')
}
const dialog = useDialog()
const openSubmitConfirm = () => {
dialog.warning({
@ -1632,22 +1724,7 @@ const openSubmitConfirm = () => {
onPositiveClick: () => submit(),
})
}
const deleteValuations = (item) => {
dialog.warning({
title: '确认删除',
content: '是否确认删除记录?',
positiveText: '确定',
negativeText: '取消',
draggable: true,
onPositiveClick: () => {
api.deleteValuations(item).then(() => {
message.success('删除成功')
getHistoryList()
status.value = 'create'
})
},
})
}
import * as echarts from 'echarts'
import type { ECharts, EChartsOption } from 'echarts'
import { useUserStore } from '@/store'
@ -1739,6 +1816,12 @@ onMounted(async () => {
page: 1,
page_size: 99,
}
// id ,, loading
if (route.query.id) {
pageLoading.value = true
}
await getHistoryList()
if (route.query.id) {
@ -1747,6 +1830,8 @@ onMounted(async () => {
if (targetItem) {
selectTimeBox(targetItem)
}
// , loading
pageLoading.value = false
}
// 使await
const res = await api.getIndustryList(params)
@ -1783,6 +1868,7 @@ onMounted(async () => {
display: flex;
flex-direction: column;
background: #f8f8f8;
/* 左侧边栏样式 - 已隐藏 */
/* .left {
width: 270px;
@ -1802,17 +1888,20 @@ onMounted(async () => {
padding-bottom: 60px;
}
}
.page-header {
position: sticky;
top: 0;
z-index: 10;
}
.delete-icon {
float: right;
transform: translate(0, 2px);
width: 16px;
height: 16px;
}
.timeBox {
margin-top: 10px;
width: 200px;
@ -1826,6 +1915,7 @@ onMounted(async () => {
padding: 10px 12px;
cursor: pointer;
}
.timeBox2 {
margin-top: 10px;
width: 200px;
@ -1849,25 +1939,30 @@ onMounted(async () => {
background: #eeeeee;
border-radius: 4px;
}
.form-container {
margin: auto;
margin-top: 30px;
width: 900px;
max-width: 100%;
}
.form-item {
display: inline-block;
width: 330px;
margin-left: 20px;
}
.upload-item {
width: 100%;
}
.form-title-box {
margin: auto;
margin-top: 40px;
width: 900px;
}
.title-left {
width: 6px;
height: 24px;
@ -1876,6 +1971,7 @@ onMounted(async () => {
display: inline-block;
transform: translate(0, 3px);
}
.title-form {
display: inline-block;
font-weight: 500;
@ -1886,11 +1982,13 @@ onMounted(async () => {
font-style: normal;
margin-left: 8px;
}
.submit-box {
width: 900px;
margin: auto;
margin-top: 60px;
}
.submit-btn {
display: inline-block;
margin-left: 20px;
@ -1920,9 +2018,10 @@ onMounted(async () => {
border-radius: 4px;
border: 1px solid #bbbbbb;
}
.retry {
text-align: center;
margin: auto;
margin: 12px;
margin-top: 40px;
width: 180px;
height: 40px;
@ -1931,6 +2030,7 @@ onMounted(async () => {
font-size: 16px;
line-height: 40px;
color: #ffffff;
cursor: pointer;
}
.price-container {
@ -1945,11 +2045,13 @@ onMounted(async () => {
background-position: center;
background-repeat: no-repeat;
/* 文本样式 */
color: #b5906b; /* 假设白色文本在背景上更清晰 */
color: #b5906b;
/* 假设白色文本在背景上更清晰 */
font-size: 52px;
font-weight: bold;
text-align: center;
}
.credibility-badge {
display: inline-flex;
align-items: center;
@ -1964,9 +2066,11 @@ onMounted(async () => {
font-size: 14px;
font-weight: 500;
}
.credibility-label {
color: #595959;
}
.credibility-stars {
display: flex;
position: relative;
@ -1974,15 +2078,18 @@ onMounted(async () => {
height: 18px;
align-items: center;
}
.stars-base,
.stars-fill {
font-size: 18px;
line-height: 1;
letter-spacing: 3px;
}
.stars-base {
color: #d0d0d0;
}
.stars-fill {
position: absolute;
left: 0;
@ -1991,11 +2098,13 @@ onMounted(async () => {
overflow: hidden;
white-space: nowrap;
}
.credibility-percent {
color: #595959;
font-weight: 600;
letter-spacing: 0.2px;
}
.secondary-badge {
display: inline-flex;
align-items: center;
@ -2011,6 +2120,7 @@ onMounted(async () => {
font-size: 14px;
font-weight: 500;
}
.score-box {
padding: 30px;
width: 320px;
@ -2019,11 +2129,13 @@ onMounted(async () => {
margin-left: 25px;
color: #303133;
}
.upload-tip {
margin-top: 0;
font-size: 12px;
color: #9c9c9c;
}
.upload-row {
display: flex;
align-items: center;
@ -2039,3 +2151,19 @@ input[type='number']::-webkit-inner-spin-button {
margin: 0;
}
</style>
<style scoped>
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.loading-icon {
animation: rotate 2s linear infinite;
}
</style>

View File

@ -185,11 +185,7 @@
</n-form-item>
<div class="btn-container">
<button
class="primary-btn"
:disabled="!isFormValid"
@click="handleUploadSubmit"
>
<button class="primary-btn" :disabled="!isFormValid" @click="handleUploadSubmit">
确认上传
</button>
</div>
@ -295,11 +291,7 @@ const message = useMessage()
//
const isFormValid = computed(() => {
return (
uploadedFiles.value.length > 0 &&
!!formModel.invoiceHeader &&
!!formModel.invoiceType
)
return uploadedFiles.value.length > 0 && !!formModel.invoiceHeader && !!formModel.invoiceType
})
const handleUploadFinish = ({ file, event }) => {
@ -384,10 +376,10 @@ defineExpose({
}
.title-bar {
width: 4px;
height: 16px;
width: 6px;
height: 24px;
background: #a30113;
border-radius: 2px;
border-radius: 4px;
}
.title-text {

View File

@ -315,10 +315,10 @@ const rowKey = (row) => row.id ?? row.name ?? row.company_name ?? row.taxId ?? r
}
.title-bar {
width: 4px;
height: 16px;
width: 6px;
height: 24px;
background: #a30113;
border-radius: 2px;
border-radius: 4px;
}
.title-text {

View File

@ -0,0 +1,368 @@
<template>
<div>
<div class="header-left">
<div class="title-bar"></div>
<div class="title-text">账号注销</div>
</div>
<div v-if="deleteAble" class="logout-container">
<!-- 手机号信息 -->
<div class="phone-info">当前绑定的手机号码为{{ maskedPhone }}</div>
<!-- 验证码输入区域 -->
<div class="code-form">
<n-form :model="form" :rules="rules" ref="formRef" label-width="100px">
<n-form-item label="" prop="code" class="verify-code-item">
<!-- 验证码输入框 + 按钮 容器 -->
<div class="code-input-wrapper">
<n-input
v-model:value="form.code"
placeholder="手机验证码"
maxlength="6"
@input="handleCodeInput"
class="custom-code-input"
:bordered="false"
/>
<n-button
class="send-code-btn"
text
@click="handleSendCode"
:disabled="countDown > 0"
>
{{ countDown > 0 ? `${countDown}s后重新获取` : '获取验证码' }}
</n-button>
</div>
</n-form-item>
</n-form>
</div>
<!-- 注销按钮 -->
<n-button class="logout-btn" @click="handleLogout" :disabled="!isFormValid"> 确定 </n-button>
</div>
<div v-else class="logout-container" style="text-align: center">
<img src="@/assets/icon/评估完成.png" mode="scaleToFill" class="warn-icon" />
<p class="warn-title">无法注销</p>
<p class="warn-content">当前有未完成的估值记录/剩余估值次数无法注销账号</p>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onUnmounted } from 'vue'
import { defineProps, defineEmits } from 'vue'
import { useMessage, useDialog } from 'naive-ui' // Naive UI /
// Naive UI
const message = useMessage()
const dialog = useDialog()
//
const props = defineProps({
userPhone: {
type: String,
required: true,
},
deleteAble: {
type: Boolean,
required: true,
},
})
//
const emits = defineEmits(['send-code', 'confirm-logout'])
//
const formRef = ref(null)
//
const form = ref({
code: '',
})
//
const countDown = ref(0)
let timer = null
//
const maskedPhone = computed(() => {
if (!props.userPhone) return ''
return `${props.userPhone.slice(0, 3)}****${props.userPhone.slice(7, 11)}`
})
//
const rules = ref({
code: [
{ required: true, message: '请输入验证码', trigger: 'blur' },
{ len: 6, message: '请输入6位验证码', trigger: 'blur' },
],
})
//
const isFormValid = computed(() => {
return props.userPhone && form.value.code.length === 6
})
//
const handleCodeInput = () => {
if (form.value.code.length > 6) {
form.value.code = form.value.code.slice(0, 6)
}
}
//
const handleSendCode = async () => {
if (!props.userPhone) {
message.warning('手机号不存在,无法发送验证码')
return
}
//
const res = await emits('send-code', props.userPhone)
if (res) {
//
countDown.value = 60
timer = setInterval(() => {
countDown.value--
if (countDown.value <= 0) {
clearInterval(timer)
timer = null
}
}, 1000)
message.success('验证码发送成功')
}
}
//
const handleLogout = () => {
//
formRef.value.validate((errors) => {
if (!errors) {
emits('confirm-logout', {
code: form.value.code,
})
} else {
message.warning('请填写正确的6位验证码')
}
})
}
//
watch(
() => countDown.value,
(val) => {
if (val <= 0 && timer) {
clearInterval(timer)
timer = null
}
}
)
//
onUnmounted(() => {
if (timer) {
clearInterval(timer)
}
})
</script>
<style scoped>
/* 根容器样式 */
.logout-container {
width: 600px;
margin: 50px auto;
padding: 30px;
background: #fff;
border-radius: 8px;
}
/* 标题样式 */
.logout-title {
font-size: 12px;
color: #303133;
line-height: 12px;
text-align: center;
}
/* 手机号信息样式 */
.phone-info {
font-size: 12px;
color: #303133;
}
/* 表单容器 */
.code-form {
margin-bottom: 40px;
margin-top: -20px;
}
/* 验证码表单项 */
.verify-code-item {
margin-bottom: 0 !important;
}
/* 验证码输入框 + 按钮 容器(核心布局) */
.code-input-wrapper {
display: flex;
align-items: center;
width: 100%;
height: 42px;
gap: 10px; /* 输入框和按钮间距 */
}
/* 自定义验证码输入框样式 */
.custom-code-input {
flex: 1;
height: 100%;
border-radius: 4px;
border: 1px solid #e5e5e5;
font-size: 14px;
transition: all 0.3s ease;
}
.warn-icon {
margin-top: 80px;
width: 100px;
height: 100px;
}
.warn-title {
margin-top: 20px;
font-size: 20px;
color: #000000;
font-weight: bold;
}
.warn-content {
margin-top: 10px;
font-size: 14px;
color: #999999;
}
/* 穿透修改 Naive UI 输入框样式 */
:deep(.custom-code-input .n-input-wrapper) {
height: 100%;
padding: 0 10px;
}
:deep(.custom-code-input .n-input__input-el) {
height: 100%;
line-height: 40px;
font-size: 14px;
}
/* 输入框 hover 状态 */
.custom-code-input:hover {
border-color: #a3011380; /* 半透明红 */
}
/* 输入框 聚焦状态 */
:deep(.custom-code-input .n-input-wrapper:focus-within) {
border-color: #a30113;
box-shadow: 0 0 0 2px rgba(163, 1, 19, 0.1);
outline: none;
}
/* 输入框 错误状态 */
:deep(.n-form-item--error .custom-code-input) {
border-color: #d03050;
}
:deep(.n-form-item--error .custom-code-input .n-input-wrapper:focus-within) {
border-color: #d03050;
box-shadow: 0 0 0 2px rgba(208, 48, 80, 0.1);
}
/* 验证码按钮样式 */
.send-code-btn {
height: 100%;
min-width: 120px;
padding: 0 15px;
border-radius: 4px;
color: #a30113;
font-size: 14px;
transition: all 0.3s ease;
white-space: nowrap;
background: #a3011332;
font-weight: bold;
}
/* 按钮 hover非禁用 */
.send-code-btn:not(:disabled):hover {
background: #a3011332;
color: #a30113;
}
/* 按钮 点击态 */
.send-code-btn:not(:disabled):active {
transform: scale(0.98);
}
/* 按钮 禁用态 */
.send-code-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 注销按钮样式 */
.logout-btn {
margin: auto;
display: block;
width: 100%;
height: 48px;
font-size: 16px;
width: 180px;
height: 40px;
background: #a30113;
border-radius: 4px;
border: none;
border-radius: 4px;
color: #fff;
text-align: center;
}
/* 注销按钮 hover非禁用 */
.logout-btn:not(:disabled):hover {
background: #a30113;
color: #fff;
}
/* 注销按钮 禁用态 */
.logout-btn:disabled {
cursor: not-allowed;
opacity: 0.4;
}
.title-bar {
width: 6px;
height: 24px;
background: #a30113;
border-radius: 4px;
}
.title-text {
font-size: 16px;
font-weight: 600;
color: #303133;
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
}
/* 表单错误提示位置调整 */
:deep(.n-form-item-feedback-wrapper) {
position: absolute;
top: 100%;
left: 0;
padding-top: 2px;
line-height: 1;
}
:deep(.custom-code-input) {
/* Chrome/Safari/Edge */
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
input[type='number'] {
-moz-appearance: textfield;
}
}
:deep(.n-button__content) {
justify-content: center;
}
</style>

View File

@ -6,6 +6,8 @@
</div>
<div class="user-phone">{{ userPhone || '18988880000' }}</div>
<div class="valuation-count">剩余评估次数{{ valuationCount }}</div>
<div class="logout-btn" @click="logout">退出登录</div>
</div>
<div class="menu-list">
@ -29,24 +31,48 @@
import iconHistory from '@/assets/icon/估值记录.png'
import iconTransfer from '@/assets/icon/对公转账.png'
import iconInvoice from '@/assets/icon/抬头管理.png'
import iconLogout from '@/assets/icon/iconLogout.png'
import { useDialog } from 'naive-ui'
import { useRouter } from 'vue-router'
const router = useRouter()
const dialog = useDialog()
defineProps({
userPhone: String,
valuationCount: Number,
currentMenu: String,
menuList: Array
menuList: Array,
})
defineEmits(['menu-click'])
function getMenuIcon(id) {
const iconMap = {
'history': iconHistory,
'transfer': iconTransfer,
'invoice': iconInvoice
history: iconHistory,
transfer: iconTransfer,
invoice: iconInvoice,
logout: iconLogout,
}
return iconMap[id]
}
async function logout() {
await new Promise((resolve, reject) => {
dialog.warning({
title: '提示',
content: '确认退出登录',
positiveText: '确认',
negativeText: '取消',
onPositiveClick: () => resolve(),
onNegativeClick: () => reject('cancel'),
onClose: () => reject('cancel'),
})
})
//
localStorage.removeItem('ACCESS_TOKEN')
//
router.push('/login')
}
</script>
<style scoped>
@ -117,12 +143,12 @@ function getMenuIcon(id) {
}
.menu-item:hover {
background: #F5F7FA;
background: #f5f7fa;
}
.menu-item.active {
background: #FFF0F0;
color: #A30113;
background: #fff0f0;
color: #a30113;
font-weight: 500;
}
@ -153,6 +179,19 @@ function getMenuIcon(id) {
font-size: 14px;
}
.logout-btn {
width: 168px;
height: 32px;
background: #f5f5f5;
border-radius: 4px;
font-size: 14px;
color: #909399;
line-height: 32px;
text-align: center;
margin-top: 20px;
cursor: pointer;
}
@media (max-width: 768px) {
.sidebar-card {
width: 100%;

View File

@ -141,7 +141,10 @@ const columns = [
NSpace,
{ size: 8, justify: 'center' },
{
default: () => [
default: () => {
let arr = []
if (row.report_download_urls.length) {
arr.push(
h(
NButton,
{
@ -154,7 +157,11 @@ const columns = [
},
},
{ default: () => '下载报告' }
),
)
)
}
if (row.certificate_download_urls.length)
arr.push(
h(
NButton,
{
@ -167,8 +174,11 @@ const columns = [
},
},
{ default: () => '下载证书' }
),
],
)
)
return arr
},
}
),
},
@ -217,10 +227,10 @@ onMounted(fetchHistory)
}
.title-bar {
width: 4px;
height: 16px;
width: 6px;
height: 24px;
background: #a30113;
border-radius: 2px;
border-radius: 4px;
}
.title-text {

View File

@ -16,23 +16,19 @@
<!-- 右侧内容区 -->
<div class="content-area">
<!-- 返回按钮 (暂时注释) -->
<!-- <div class="back-button" @click="handleBackToHome">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 18L9 12L15 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span>返回首页</span>
</div> -->
<!-- 子路由视图 -->
<router-view
:asset-list="assetList"
:invoice-list="invoiceList"
:user-phone="userPhone"
:deleteAble="deleteAble"
@return-home="handleBackToHome"
@add-invoice="addInvoice"
@update-invoice="updateInvoice"
@delete-invoice="deleteInvoice"
@upload-submit="uploadSubmit"
@send-code="sendVerificationCode"
@confirm-logout="confirmAccountLogout"
/>
</div>
</div>
@ -40,12 +36,17 @@
</template>
<script setup>
import { ref, onMounted, h, watch, computed } from 'vue'
import { ref, onMounted, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useMessage, useDialog } from 'naive-ui' // Naive UI /
import api from '@/api'
import AppHeader from '@/components/AppHeader.vue'
import UserSidebar from './components/UserSidebar.vue'
// Naive UI
const message = useMessage()
const dialog = useDialog()
const router = useRouter()
const route = useRoute()
@ -55,12 +56,14 @@ const currentMenu = computed(() => {
if (path.includes('/history')) return 'history'
if (path.includes('/transfer')) return 'transfer'
if (path.includes('/invoice')) return 'invoice'
if (path.includes('/logout')) return 'logout' //
return 'history'
})
//
const userPhone = ref('')
const valuationCount = ref(0)
const deleteAble = ref(true)
//
const assetList = ref([])
@ -68,7 +71,7 @@ const assetList = ref([])
//
const invoiceList = ref([])
//
//
const menuList = ref([
{
id: 'history',
@ -82,6 +85,10 @@ const menuList = ref([
id: 'invoice',
label: '抬头管理',
},
{
id: 'logout', //
label: '账号注销',
},
])
//
@ -91,7 +98,6 @@ function handleBackToHome() {
//
function handleMenuClick(menu) {
// 使
router.push(`/user-center/${menu.id}`)
}
@ -114,9 +120,11 @@ async function loadData() {
}
} catch (error) {
console.error('加载估值记录失败:', error)
$message.error('加载估值记录失败,请稍后重试')
// ElMessage message
message.error('加载估值记录失败,请稍后重试')
}
}
//
async function loadInvoiceList() {
try {
@ -131,32 +139,38 @@ async function loadInvoiceList() {
async function addInvoice(data) {
try {
await api.addInvoiceHeaders(data)
$message.success('添加成功')
// ElMessage message
message.success('添加成功')
loadInvoiceList()
} catch (error) {
console.error('新增发票抬头失败:', error)
}
}
//
async function updateInvoice(data) {
try {
await api.updateInvoiceHeaders(data)
$message.success('编辑成功')
// ElMessage message
message.success('编辑成功')
loadInvoiceList()
} catch (error) {
console.error('更新发票抬头失败:', error)
}
}
//
async function deleteInvoice(data) {
try {
await api.deleteInvoiceHeaders(data.id)
$message.success('删除成功')
// ElMessage message
message.success('删除成功')
loadInvoiceList()
} catch (error) {
console.error('删除发票抬头失败:', error)
}
}
//
async function uploadSubmit(data) {
try {
@ -167,6 +181,53 @@ async function uploadSubmit(data) {
}
}
//
async function sendVerificationCode(phone) {
try {
await api.sendVerifyCode({ phone })
message.success('验证码已发送,请注意查收')
return true
} catch (error) {
console.error('发送验证码失败:', error)
return false
}
}
//
async function confirmAccountLogout(formData) {
// ElMessageBox.confirm dialog.warning
await new Promise((resolve, reject) => {
dialog.warning({
title: '提示',
content: '提交后账号将注销,不可撤回,确认注销账户?',
positiveText: '确认',
negativeText: '取消',
onPositiveClick: () => resolve(),
onNegativeClick: () => reject('cancel'),
onClose: () => reject('cancel'),
})
})
//
api
.deleteAccount({
code: formData.code,
})
.then((res) => {
console.log(res, '111111111111111')
// ElMessage message
message.success('账号注销成功')
//
localStorage.removeItem('phone')
//
router.push('/login')
})
.catch((err) => {
console.log(err, '111111111111111')
deleteAble.value = false
})
}
onMounted(() => {
loadData()
loadInvoiceList()
@ -200,26 +261,7 @@ onMounted(() => {
min-height: calc(100vh - 100px); /* Adjust based on header + margin */
position: relative;
overflow: hidden; /* Ensure rounded corners */
}
.back-button {
position: absolute;
top: 20px;
left: 20px;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0;
color: #606266;
cursor: pointer;
transition: all 0.3s ease;
font-size: 14px;
background: transparent;
z-index: 10;
}
.back-button:hover {
color: #a30113;
padding: 20px; /* 新增:添加内边距 */
}
@media (max-width: 1200px) {
@ -232,9 +274,5 @@ onMounted(() => {
.main-container {
flex-direction: column;
}
.header {
padding: 10px 20px;
}
}
</style>

View File

@ -37,8 +37,8 @@
export DOCKER_DEFAULT_PLATFORM=linux/amd64
docker build -t zfc931912343/guzhi-fastapi-admin:v2.6 .
docker push zfc931912343/guzhi-fastapi-admin:v2.6
docker build -t zfc931912343/guzhi-fastapi-admin:v3.8 .
docker push zfc931912343/guzhi-fastapi-admin:v3.8
# 运行容器
@ -68,11 +68,12 @@ docker pull nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v1.4 &&
docker pull nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v1.4 && docker rm -f guzhi && docker run -itd --name=guzhi -p 8080:80 -v ~/guzhi-data/static/images:/opt/vue-fastapi-admin/app/static/images --restart=unless-stopped --memory=2g --cpus=1.0 -e TZ=Asia/Shanghai nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v1.4
docker pull nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v3.8 && docker rm -f guzhi && docker run -itd --name=guzhi -p 8080:80 -v ~/guzhi-data/static/images:/opt/vue-fastapi-admin/app/static/images --restart=unless-stopped --memory=2g --cpus=1.0 -e TZ=Asia/Shanghai nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v3.8
docker pull nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v1.7 && docker rm -f guzhi_dev && docker run -itd --name=guzhi_dev -p 9990:80 -v ~/guzhi-data-dev/static/images:/opt/vue-fastapi-admin/app/static/images --restart=unless-stopped -e TZ=Asia/Shanghai nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v1.7
1
docker pull nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v2.5 && docker rm -f guzhi_pro && docker run -itd --name=guzhi_pro -p 8080:80 -v ~/guzhi-data/static/images:/opt/vue-fastapi-admin/app/static/images --restart=unless-stopped -e TZ=Asia/Shanghai nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v2.5
docker pull nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v3.8 && docker rm -f guzhi_pro && docker run -itd --name=guzhi_pro -p 8080:9999 -v ~/guzhi-data/static/images:/opt/vue-fastapi-admin/app/static/images --restart=unless-stopped -e TZ=Asia/Shanghai nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v3.8
docker pull nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v3.3 && docker rm -f guzhi_dev && docker run -itd --name=guzhi_dev -p 9990:9999 -v ~/guzhi-data/static:/opt/vue-fastapi-admin/app/static --restart=unless-stopped -e TZ=Asia/Shanghai nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v3.3

225
部署文档.md Normal file
View File

@ -0,0 +1,225 @@
# 非遗资产估值系统 - 部署文档
## 项目概述
非遗资产估值系统是一个基于 Vue.js + FastAPI 的全栈应用,用于非物质文化遗产资产的价值评估。
- **前端**: Vue.js + Vite + pnpm
- **后端**: Python 3.11 + FastAPI + Tortoise ORM
- **数据库**: MySQL
- **容器化**: Docker
---
## 目录结构
```
youshu-guzhi/
├── app/ # 后端 FastAPI 应用
│ ├── api/ # API 路由
│ ├── controllers/ # 业务控制器
│ ├── models/ # 数据库模型
│ ├── schemas/ # Pydantic 数据模型
│ ├── settings/ # 配置文件
│ └── utils/ # 工具函数和计算引擎
├── web/ # 前端 Vue.js 应用
├── deploy/ # 部署相关文件
│ ├── entrypoint.sh # 容器启动脚本
│ └── web.conf # Nginx 配置
├── Dockerfile # Docker 构建文件
├── requirements.txt # Python 依赖
└── run.py # 应用启动入口
```
---
## 环境配置
### 数据库配置
#### 使用 Docker 部署 MySQL
```bash
# 创建数据目录
mkdir -p ~/mysql-data
# 启动 MySQL 容器
docker run -d \
--name mysql-valuation \
-p 3306:3306 \
-e MYSQL_ROOT_PASSWORD=your_password \
-e MYSQL_DATABASE=valuation_service \
-v ~/mysql-data:/var/lib/mysql \
--restart=unless-stopped \
mysql:8.0
```
#### 应用配置
配置文件位置: `app/settings/config.py`
```python
TORTOISE_ORM = {
"connections": {
"mysql": {
"engine": "tortoise.backends.mysql",
"credentials": {
"host": "your_mysql_host", # 数据库主机地址
"port": 3306, # 数据库端口
"user": "root", # 数据库用户名
"password": "your_password", # 数据库密码
"database": "valuation_service", # 数据库名称
},
},
},
...
}
```
### 第三方服务配置
| 服务 | 配置项 | 说明 |
|-----|-------|------|
| 阿里云短信 | `ALIBABA_CLOUD_ACCESS_KEY_ID/SECRET` | 短信验证码发送 |
| 阿里云邮件 | `SMTP_*` | 邮件发送 |
---
## 本地开发
### 1. 安装依赖
```bash
# 安装 Python 依赖
pip install -r requirements.txt
# 安装前端依赖
cd web
pnpm install
```
### 2. 启动服务
```bash
# 启动后端 (端口 9999)
python run.py
# 启动前端开发服务器 (另一个终端)
cd web
pnpm run dev
```
---
## Docker 部署
### 1. 构建镜像
```bash
# 设置平台 (M1/M2 Mac 需要)
export DOCKER_DEFAULT_PLATFORM=linux/amd64
# 构建镜像
docker build -t zfc931912343/guzhi-fastapi-admin:v3.9 .
# 推送到 Docker Hub
docker push zfc931912343/guzhi-fastapi-admin:v3.9
```
### 2. 部署到服务器
#### 生产环境
```bash
# 创建数据目录
mkdir -p ~/guzhi-data/static/images
# 拉取并运行
docker pull nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v3.9 \
&& docker rm -f guzhi_pro \
&& docker run -itd \
--name=guzhi_pro \
-p 8080:9999 \
-v ~/guzhi-data/static/images:/opt/vue-fastapi-admin/app/static/images \
--restart=unless-stopped \
-e TZ=Asia/Shanghai \
nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v3.9
```
#### 开发/测试环境
```bash
docker pull nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v3.9 \
&& docker rm -f guzhi_dev \
&& docker run -itd \
--name=guzhi_dev \
-p 9990:9999 \
-v ~/guzhi-data/static:/opt/vue-fastapi-admin/app/static \
--restart=unless-stopped \
-e TZ=Asia/Shanghai \
nbg2akd8w5diy8.xuanyuan.run/zfc931912343/guzhi-fastapi-admin:v3.9
```
---
## 端口说明
| 环境 | 容器名 | 主机端口 | 容器端口 |
|-----|-------|---------|---------|
| 生产 | guzhi_pro | 8080 | 9999 |
| 开发 | guzhi_dev | 9990 | 9999 |
---
## 数据持久化
容器挂载的数据目录:
```
~/guzhi-data/static/images -> /opt/vue-fastapi-admin/app/static/images
```
用于存储用户上传的图片文件(如非遗纹样图片、证书图片等)。
---
## 常用运维命令
```bash
# 查看容器日志
docker logs -f guzhi_pro
# 进入容器
docker exec -it guzhi_pro bash
# 重启容器
docker restart guzhi_pro
# 查看容器状态
docker ps | grep guzhi
```
---
## API 接口说明
| 模块 | 路径前缀 | 说明 |
|-----|---------|------|
| 用户端估值 | `/api/v1/app-valuations/` | 用户提交估值请求 |
| 管理端估值 | `/api/v1/valuations/` | 管理后台查看/审核 |
| 计算报告 | `/api/v1/valuations/{id}/report` | 获取计算过程报告 |
---
## 版本历史
| 版本 | 日期 | 说明 |
|-----|------|------|
| v3.9 | 2025-12-18 | 修复风险调整系数B3显示问题添加计算过程详情 |
| v3.8 | 2025-12-18 | 修复历史传承度HI权重计算 |
---
## 联系信息
如有问题,请联系项目负责人。

View File

@ -1,186 +0,0 @@
# 需求文档
### 1.1 项目范围
包含范围:
- 非遗IP价值评估计算引擎
- 用户管理和权限控制系统
- 评估申请和审核流程
- 评估报告生成
- 第三方支付集成
- 第三方登录集成
- 第三方数据集成
## 2. 用户角色定义
### 2.1 管理端用户
#### 2.1.1 系统管理员
角色描述:负责系统整体管理和维护
主要职责:
- 用户账号管理和权限分配
- 系统配置和参数设置
- 基础数据维护行业、ESG、政策等
- 系统监控和日志管理
- 第三方API配置管理
权限范围:
- 所有功能模块的完整访问权限
- 用户创建、编辑、删除权限
- 系统配置修改权限
- 数据导入导出权限
#### 2.1.2 业务审核员
角色描述:负责评估申请的审核和质量控制
主要职责:
- 评估申请的初步审核
- 数据完整性和合理性检查
- 计算结果的人工复核
- 评估报告的审批发布
- 异常情况的处理
权限范围:
- 评估申请查看和审核权限
- 审核状态修改权限
- 审核备注添加权限
- 报告生成和发布权限
### 2.2 应用端用户
#### 2.2.1 个人用户
角色描述:非遗传承人、文化工作者等个人申请者
主要需求:
- 提交个人非遗资产评估申请
- 查看评估进度和结果
- 下载评估报告
- 管理个人信息
## 3. 功能需求
### 3.1 用户管理系统
#### 3.1.1 用户注册登录
功能描述:提供用户注册、登录、密码管理功能
详细需求:
FR-001 手机号注册
- 用户可使用手机号进行注册
- 支持短信验证码验证
- 注册时需填写基本信息(姓名、机构等)
- 系统自动分配默认权限
FR-002 手机号登录
- 支持手机号+密码登录
- 支持手机号+验证码登录
#### 3.1.2 权限管理
功能描述基于RBAC模型的权限控制系统
详细需求:
FR-004 角色管理
- 支持创建、编辑、删除角色
- 角色可分配菜单权限和API权限
- 预设系统管理员、审核员、普通用户角色
### 3.2 估值评估系统
#### 3.2.1 评估申请提交
功能描述:用户提交非遗资产评估申请的完整流程
#### 3.2.2 评估结果管理
功能描述:评估结果的存储、展示和管理
详细需求:
FR-015 结果存储
- 完整计算过程和中间结果保存
- 输入参数和输出结果关联存储
- 计算时间和版本信息记录
FR-016 结果展示
- 估值结果可视化展示
- 计算过程分步骤展示
- 各维度得分雷达图展示
- 风险评估结果展示
FR-017 报告生成
- 自动生成详细评估报告
- 支持PDF格式导出
- 报告包含计算过程和结论
### 3.3 审核管理系统
### 3.4 数据管理系统
#### 3.4.1 基础数据管理
功能描述:系统基础数据的维护和管理
详细需求:
FR-024 行业数据管理
- 行业分类标准维护
- 行业ROE系数管理
FR-025 ESG数据管理
- ESG评级标准维护
- 行业ESG基准分管理
FR-026 政策数据管理
- 政策匹配规则维护
- 资助政策数据库管理
## 4. 移动端开发
### 4.1 微信小程序开发
#### 4.1.1 功能范围
功能描述:基于微信小程序平台的移动端应用开发
详细需求:
FR-027 用户认证
- 支持微信授权登录
- 支持手机号快速登录
- 与PC端用户体系统一
- 自动获取微信用户基本信息
FR-028 评估申请
- 移动端评估表单提交
- 支持拍照上传证书材料
- 表单数据与PC端保持一致
- 支持草稿保存和续填
FR-029 进度查询
- 实时查看评估申请状态
- 接收微信消息推送通知
- 查看评估结果和报告
- 支持报告分享功能