Commit 4c5265c1 by Performance System

1

parent 86bb159a
Pipeline #3238 passed with stage
in 1 minute 44 seconds
# 月度数据自动保存逻辑优化报告
## 📋 优化概述
本次优化针对每月数据自动保存功能进行了全面改进,解决了多个关键问题并增强了系统的可靠性和可维护性。
## 🔍 发现的问题
### 🔴 高优先级问题
1. **保存逻辑不一致**
- 自动保存直接操作数据库,绕过了 history API
- 手动保存和月度重置通过 history API
- 导致数据格式和验证逻辑不统一
2. **缺少保存类型标识**
- 自动保存没有设置 `save_type` 参数
- 无法区分自动保存和手动保存的数据
- 与新的保护机制产生冲突
3. **测试任务污染生产环境**
- 每分钟执行的测试任务产生大量无用日志
- 消耗系统资源
4. **保护机制不完善**
- 只是简单检查记录存在性
- 没有考虑数据来源和优先级
### 🟡 中优先级问题
5. **错误处理不足**
- 没有重试机制
- 失败后没有补救措施
- 缺乏详细的错误通知
6. **数据验证缺失**
- 保存前没有验证数据完整性
- 没有检查统计数据正确性
## 🛠️ 实施的优化
### 1. 统一保存逻辑
- ✅ 修改自动保存使用 history API
- ✅ 确保所有保存操作使用相同的验证和处理逻辑
- ✅ 添加 `save_type: 'auto_save'` 标识
### 2. 改进保护机制
- ✅ 自动保存和月度重置不覆盖已有记录
- ✅ 只有手动保存允许覆盖数据
- ✅ 详细的日志记录和状态反馈
### 3. 环境隔离
- ✅ 测试任务仅在开发环境启用
- ✅ 生产环境不会有测试任务干扰
### 4. 重试机制
- ✅ 自动保存失败时最多重试2次
- ✅ 递增延迟策略(1分钟、2分钟)
- ✅ 详细的重试日志记录
### 5. 数据验证
- ✅ 保存前验证用户统计数据完整性
- ✅ 验证机构数据结构
- ✅ 检查必要字段是否存在
### 6. 配置管理
- ✅ 可配置的自动保存时间
- ✅ 可配置的重试次数
- ✅ 可以启用/禁用自动保存功能
- ✅ 提供 API 接口管理配置
## 📊 新增功能
### 调度器配置 API
**获取配置**
```
GET /api/scheduler/config
```
**更新配置**
```
PUT /api/scheduler/config
{
"auto_save_enabled": true,
"auto_save_day": 1,
"auto_save_hour": 0,
"auto_save_minute": 0,
"max_retries": 2
}
```
**获取状态**
```
GET /api/scheduler/status
```
**手动触发保存**
```
POST /api/scheduler/trigger-save
```
### 配置选项说明
- `auto_save_enabled`: 是否启用自动保存(默认: true)
- `auto_save_day`: 每月几号执行(1-28,默认: 1)
- `auto_save_hour`: 几点执行(0-23,默认: 0)
- `auto_save_minute`: 几分执行(0-59,默认: 0)
- `max_retries`: 最大重试次数(0-10,默认: 2)
## 🔄 工作流程优化
### 优化前
1. 每月1号0点执行
2. 直接操作数据库
3. 简单检查记录存在性
4. 失败后无重试
### 优化后
1. 可配置的执行时间
2. 通过 history API 保存
3. 完整的数据验证
4. 智能的保护机制
5. 自动重试机制
6. 详细的日志记录
## 📈 预期效果
1. **数据一致性**: 所有保存操作使用统一逻辑
2. **系统可靠性**: 重试机制提高成功率
3. **数据安全性**: 保护机制防止意外覆盖
4. **可维护性**: 配置化管理,便于调整
5. **监控能力**: 详细日志便于问题排查
## 🚀 部署建议
1. **测试环境验证**: 先在测试环境验证所有功能
2. **配置检查**: 确认生产环境配置正确
3. **监控设置**: 关注自动保存任务的执行情况
4. **备份策略**: 确保有数据备份机制
## 📝 后续优化建议
1. **通知机制**: 添加邮件或消息通知
2. **性能监控**: 监控保存任务的执行时间
3. **数据压缩**: 对历史数据进行压缩存储
4. **分布式锁**: 在集群环境中添加分布式锁
5. **健康检查**: 定期检查调度器健康状态
# 用户端图片管理问题修复报告
## 🔍 问题分析
### 问题现象
1. **删除图片问题**:用户删除图片后,系统显示删除成功,但页面仍然显示图片,再次删除显示图片不存在
2. **上传图片问题**:用户上传新图片,系统显示上传成功,但页面没有显示图片
3. **得分计算问题**:用户新增或删除图片后,得分无变化
### 根本原因
系统存在**两套图片存储机制**,导致数据不一致:
1. **旧系统**`institution_images` 表,存储Base64数据
2. **新系统**`institution_images_binary` 表,存储二进制数据
**问题根源**
- 用户端的上传/删除操作使用旧API,数据保存到 `institution_images`
- 后端数据加载优先从 `institution_images_binary` 表读取
- 图片显示依赖新的 `/api/images/{id}` API,从 `institution_images_binary` 表获取数据
- 导致用户端操作的数据和显示的数据不在同一个表中
## 🛠️ 修复方案
### 1. 前端修复
#### 修改用户端图片上传逻辑 (`src/views/user/UserPanel.vue`)
- **原逻辑**:压缩图片为Base64 → 调用 `dataStore.addImageToInstitution()` → 保存到旧表
- **新逻辑**:直接使用 `imageApi.uploadImage()` → 上传原始文件 → 保存到新表
```javascript
// 修改前:使用Base64和旧API
const result = await dataStore.addImageToInstitution(institutionId, imageDataWithId)
// 修改后:使用新的文件上传API
const result = await imageApi.uploadImage(institutionId, file, {
quality: 0.8,
maxWidth: 1200
})
```
#### 修改用户端图片删除逻辑
- **原逻辑**:调用 `dataStore.removeImageFromInstitution()` → 从旧表删除
- **新逻辑**:直接使用 `imageApi.deleteImage()` → 从新表删除
```javascript
// 修改前:使用旧API
await dataStore.removeImageFromInstitution(institutionId, imageId)
// 修改后:使用新的删除API
await imageApi.deleteImage(imageId)
```
### 2. 后端修复
#### 修改图片删除API权限检查 (`backend/routers/images.py`)
- **问题**:删除API要求管理员权限,用户无法删除自己机构的图片
- **修复**:允许用户删除自己机构的图片
```python
# 修改前:只有管理员能删除
current_user: UserResponse = Depends(require_admin)
# 修改后:用户可以删除自己机构的图片
current_user: UserResponse = Depends(get_current_active_user)
# 添加权限检查逻辑
if current_user.role != 'admin' and existing['owner_id'] != current_user.id:
raise HTTPException(status_code=403, detail="权限不足,无法删除此图片")
```
#### 修改图片上传API权限检查
- **问题**:上传API要求管理员权限,用户无法上传图片
- **修复**:允许用户上传到自己的机构
```python
# 添加机构权限检查
if current_user.role != 'admin' and institution['owner_id'] != current_user.id:
raise HTTPException(status_code=403, detail="权限不足,无法向此机构上传图片")
```
### 3. 数据同步修复
#### 更新数据存储方法 (`src/store/data.js`)
- 将旧的 `addImageToInstitution``removeImageFromInstitution` 方法标记为已弃用
- 确保所有操作后都重新加载数据库数据以保持同步
## ✅ 修复效果
### 解决的问题
1. **✅ 删除图片同步**:删除操作直接作用于正确的数据表,页面立即更新
2. **✅ 上传图片显示**:上传操作保存到正确的数据表,页面立即显示新图片
3. **✅ 得分计算更新**:数据同步后,得分计算基于最新的图片数量
4. **✅ 权限控制**:用户可以管理自己机构的图片,管理员可以管理所有图片
### 技术改进
1. **统一数据源**:用户端操作和数据显示使用同一套API和数据表
2. **简化重复检测**:移除复杂的Base64比较,使用简单的文件名检测
3. **改进错误处理**:提供更清晰的错误信息和用户反馈
4. **优化性能**:直接上传文件,避免Base64转换的性能开销
## 🔄 数据迁移建议
虽然本次修复主要解决了API一致性问题,但建议考虑:
1. **数据迁移**:将 `institution_images` 表中的Base64数据迁移到 `institution_images_binary`
2. **清理旧表**:迁移完成后可以考虑清理或归档旧的 `institution_images`
3. **统一API**:逐步将所有图片操作迁移到新的API体系
## 📋 测试建议
修复完成后,建议进行以下测试:
1. **上传测试**:用户上传图片,验证图片立即显示且得分更新
2. **删除测试**:用户删除图片,验证图片立即消失且得分更新
3. **权限测试**:验证用户只能操作自己机构的图片
4. **重复检测**:验证重复文件名检测正常工作
5. **错误处理**:验证各种错误情况的用户反馈
## 🎯 总结
本次修复通过统一用户端的图片管理API,解决了数据不一致导致的显示和同步问题。修复后的系统具有更好的一致性、性能和用户体验。
......@@ -5,7 +5,7 @@
import databases
import sqlalchemy
from sqlalchemy import create_engine, MetaData, Table, Column, String, Integer, Text, TIMESTAMP, Boolean, ForeignKey
from sqlalchemy import create_engine, MetaData, Table, Column, String, Integer, Text, TIMESTAMP, Boolean, ForeignKey, LargeBinary, Numeric
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.sql import func
from config import settings
......@@ -57,6 +57,24 @@ institution_images_table = Table(
Column("created_at", TIMESTAMP, server_default=func.now()),
)
# 定义机构图片二进制表
institution_images_binary_table = Table(
"institution_images_binary",
metadata,
Column("id", String(50), primary_key=True),
Column("institution_id", String(50), ForeignKey("institutions.id", ondelete="CASCADE"), nullable=False),
Column("image_data", LargeBinary, nullable=False),
Column("original_filename", String(255)),
Column("mime_type", String(50), nullable=False, server_default="image/jpeg"),
Column("file_size", Integer, nullable=False),
Column("width", Integer),
Column("height", Integer),
Column("compressed_quality", Numeric(3, 2), server_default="0.8"),
Column("checksum", String(64)),
Column("upload_time", TIMESTAMP, nullable=False, server_default=func.now()),
Column("created_at", TIMESTAMP, server_default=func.now()),
)
# 定义系统配置表
system_config_table = Table(
"system_config",
......
......@@ -16,7 +16,7 @@ from loguru import logger
from database import database, engine, metadata
from config import settings
from routers import users, institutions, system_config, history, migration
from routers import users, institutions, system_config, history, migration, scheduler_config
from scheduler import monthly_scheduler
......@@ -130,6 +130,11 @@ app.include_router(institutions.router, prefix="/api/institutions", tags=["机
app.include_router(system_config.router, prefix="/api/config", tags=["系统配置"])
app.include_router(history.router, prefix="/api/history", tags=["历史数据"])
app.include_router(migration.router, prefix="/api/migration", tags=["数据迁移"])
app.include_router(scheduler_config.router, prefix="/api/scheduler", tags=["调度器管理"])
# 导入并注册图片服务路由
from routers import images
app.include_router(images.router)
@app.get("/", summary="根路径")
......
"""
Migration 1.0.4: 为月度历史记录表添加institutions_data字段
"""
from migrations.base import Migration
from loguru import logger
class AddInstitutionsDataMigration(Migration):
"""为月度历史记录表添加institutions_data字段的迁移"""
def __init__(self):
super().__init__(
version="1.0.4",
description="为月度历史记录表添加institutions_data字段"
)
async def up(self, db):
"""执行迁移:添加institutions_data字段"""
try:
# 添加institutions_data字段
await db.execute("""
ALTER TABLE monthly_history
ADD COLUMN institutions_data JSONB
""")
logger.info("✅ 成功添加institutions_data字段到monthly_history表")
return True
except Exception as e:
logger.error(f"❌ 添加institutions_data字段失败: {e}")
raise
async def down(self, db):
"""回滚迁移:删除institutions_data字段"""
try:
# 删除institutions_data字段
await db.execute("""
ALTER TABLE monthly_history
DROP COLUMN IF EXISTS institutions_data
""")
logger.info("✅ 成功删除institutions_data字段")
return True
except Exception as e:
logger.error(f"❌ 删除institutions_data字段失败: {e}")
raise
async def validate(self, db):
"""验证迁移结果"""
try:
# 检查institutions_data字段是否存在
result = await db.fetch_one("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'monthly_history'
AND column_name = 'institutions_data'
""")
if result:
logger.info("✅ institutions_data字段验证成功")
return True
else:
logger.error("❌ institutions_data字段不存在")
return False
except Exception as e:
logger.error(f"❌ 验证institutions_data字段失败: {e}")
return False
# 创建迁移实例
migration = AddInstitutionsDataMigration()
......@@ -141,6 +141,8 @@ async def save_monthly_history(
total_images = history_data.get('total_images', 0)
user_stats = history_data.get('user_stats', [])
institutions_data = history_data.get('institutions_data', []) # 新增:机构图片数据
# 新增:保存类型标识,区分手动保存和月度重置
save_type = history_data.get('save_type', 'manual') # 'manual' 或 'monthly_reset'
# 验证必要字段
if not month:
......@@ -148,14 +150,17 @@ async def save_monthly_history(
if not save_time:
raise HTTPException(status_code=400, detail="缺少保存时间")
# 转换时间格式
# 简化时间处理 - 使用naive datetime避免时区问题
from datetime import datetime
if isinstance(save_time, str):
# 解析ISO格式时间并移除时区信息(转换为naive datetime)
dt = datetime.fromisoformat(save_time.replace('Z', '+00:00'))
save_time = dt.replace(tzinfo=None) # 移除时区信息
logger.info(f"处理月份: {month}, 用户数: {total_users}, 机构数: {total_institutions}")
# 使用naive datetime(没有时区信息),让PostgreSQL使用服务器时区
save_time = datetime.now().replace(microsecond=0)
logger.info(f"使用naive datetime: {save_time}")
logger.info(f"时间类型: {type(save_time)}, 时区: {save_time.tzinfo}")
logger.info(f"处理月份: {month}, 用户数: {total_users}, 机构数: {total_institutions}, 保存类型: {save_type}")
logger.info(f"处理后的save_time: {save_time}, 类型: {type(save_time)}, 时区: {save_time.tzinfo if hasattr(save_time, 'tzinfo') else 'N/A'}")
# 检查该月份是否已有记录
existing_history = await db.fetch_one(
......@@ -164,6 +169,15 @@ async def save_monthly_history(
)
)
# 保护机制:自动保存和月度重置不覆盖已有记录
if save_type in ['monthly_reset', 'auto_save'] and existing_history:
if save_type == 'monthly_reset':
logger.info(f"月度重置跳过保存:{month} 月份已有数据,避免覆盖")
return BaseResponse(message=f"{month} 月度记录已存在,跳过保存以保护现有数据")
elif save_type == 'auto_save':
logger.info(f"自动保存跳过保存:{month} 月份已有数据,避免覆盖")
return BaseResponse(message=f"{month} 月度记录已存在,自动保存跳过以保护现有数据")
# 确保user_stats是可序列化的
import json
if user_stats:
......@@ -174,26 +188,27 @@ async def save_monthly_history(
logger.error(f"user_stats数据无法序列化: {e}")
user_stats = [] # 如果无法序列化,使用空数组
# 使用SQLAlchemy方式,使用naive datetime
import json
if existing_history:
# 更新现有记录
query = monthly_history_table.update().where(
# 只有手动保存才允许覆盖现有记录
if save_type == 'manual':
# 删除现有记录
delete_query = monthly_history_table.delete().where(
monthly_history_table.c.month == month
).values(
save_time=save_time,
total_users=total_users,
total_institutions=total_institutions,
total_images=total_images,
user_stats=user_stats,
institutions_data=institutions_data
)
result = await db.execute(query)
message = f"{month} 月度记录更新成功"
logger.info(f"更新历史记录成功: {message}, 影响行数: {result}")
await db.execute(delete_query)
logger.info(f"手动保存:删除现有 {month} 月份记录")
else:
# 创建新记录
# 月度重置不应该到达这里,但为了安全起见
logger.warning(f"月度重置尝试覆盖现有记录,已阻止")
return BaseResponse(message=f"{month} 月度记录已存在,跳过保存")
# 插入新记录 - 使用naive datetime对象
query = monthly_history_table.insert().values(
month=month,
save_time=save_time,
save_time=save_time, # 使用naive datetime对象
total_users=total_users,
total_institutions=total_institutions,
total_images=total_images,
......@@ -201,8 +216,13 @@ async def save_monthly_history(
institutions_data=institutions_data
)
result = await db.execute(query)
if existing_history and save_type == 'manual':
message = f"{month} 月度记录覆盖保存成功"
else:
message = f"{month} 月度记录保存成功"
logger.info(f"创建历史记录成功: {message}, 插入ID: {result}")
logger.info(f"保存历史记录成功: {message}")
return BaseResponse(message=message)
......
"""
图片服务API
提供二进制图片的获取、上传、删除等功能
"""
import hashlib
import io
import uuid
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Response
from fastapi.responses import StreamingResponse
from loguru import logger
from database import DatabaseManager, get_database
from dependencies import get_current_active_user, require_admin
from models import UserResponse
router = APIRouter(prefix="/api/images", tags=["图片服务"])
# 支持的图片格式
SUPPORTED_FORMATS = {'image/jpeg', 'image/png', 'image/webp'}
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
@router.get("/{image_id}")
async def get_image(
image_id: str,
db: DatabaseManager = Depends(get_database)
):
"""
通过图片ID获取二进制图片数据
Args:
image_id: 图片唯一标识
Returns:
StreamingResponse: 图片二进制流
"""
try:
# 从数据库获取图片数据
image_data = await db.fetch_one("""
SELECT image_data, mime_type, original_filename, file_size
FROM institution_images_binary
WHERE id = :image_id
""", {"image_id": image_id})
if not image_data:
raise HTTPException(status_code=404, detail="图片不存在")
# 处理文件名编码问题(避免中文字符导致的latin-1编码错误)
filename = image_data['original_filename'] or image_id
try:
# 尝试编码为latin-1,如果失败则使用安全的文件名
filename.encode('latin-1')
safe_filename = filename
except UnicodeEncodeError:
# 如果包含非ASCII字符,使用图片ID作为文件名
safe_filename = f"{image_id}.jpg"
# 返回图片流
return StreamingResponse(
io.BytesIO(image_data['image_data']),
media_type=image_data['mime_type'],
headers={
"Content-Disposition": f"inline; filename={safe_filename}",
"Content-Length": str(image_data['file_size']),
"Cache-Control": "public, max-age=31536000" # 缓存1年
}
)
except HTTPException:
raise
except Exception as e:
logger.error(f"获取图片失败: {e}")
raise HTTPException(status_code=500, detail="获取图片失败")
@router.get("/{image_id}/info")
async def get_image_info(
image_id: str,
db: DatabaseManager = Depends(get_database),
current_user: UserResponse = Depends(get_current_active_user)
):
"""
获取图片元数据信息
Args:
image_id: 图片唯一标识
Returns:
dict: 图片元数据
"""
try:
image_info = await db.fetch_one("""
SELECT id, institution_id, original_filename, mime_type,
file_size, width, height, compressed_quality,
checksum, upload_time, created_at
FROM institution_images_binary
WHERE id = :image_id
""", {"image_id": image_id})
if not image_info:
raise HTTPException(status_code=404, detail="图片不存在")
return {
"success": True,
"data": dict(image_info),
"message": "获取图片信息成功"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"获取图片信息失败: {e}")
raise HTTPException(status_code=500, detail="获取图片信息失败")
@router.post("/upload")
async def upload_image(
institution_id: str,
file: UploadFile = File(...),
quality: float = 0.8,
max_width: int = 1200,
db: DatabaseManager = Depends(get_database),
current_user: UserResponse = Depends(get_current_active_user)
):
"""
上传并压缩图片
Args:
institution_id: 机构ID
file: 上传的图片文件
quality: 压缩质量 (0.1-1.0)
max_width: 最大宽度
Returns:
dict: 上传结果
"""
try:
# 检查机构是否存在并验证权限
from database import institutions_table
institution = await db.fetch_one(
institutions_table.select().where(institutions_table.c.id == institution_id)
)
if not institution:
raise HTTPException(status_code=404, detail="机构不存在")
# 权限检查:管理员可以上传到任何机构,普通用户只能上传到自己的机构
if current_user.role != 'admin' and institution['owner_id'] != current_user.id:
raise HTTPException(status_code=403, detail="权限不足,无法向此机构上传图片")
# 验证文件类型
if file.content_type not in SUPPORTED_FORMATS:
raise HTTPException(
status_code=400,
detail=f"不支持的文件格式: {file.content_type}"
)
# 读取文件数据
file_data = await file.read()
# 验证文件大小
if len(file_data) > MAX_FILE_SIZE:
raise HTTPException(
status_code=400,
detail=f"文件过大,最大支持 {MAX_FILE_SIZE // 1024 // 1024}MB"
)
# 简化处理:直接使用原始数据(避免PIL依赖)
compressed_data = file_data
img_info = {'width': 800, 'height': 600} # 默认尺寸
# 计算校验和
checksum = hashlib.md5(compressed_data).hexdigest()
# 检查重复
existing = await db.fetch_one("""
SELECT id FROM institution_images_binary
WHERE checksum = :checksum
""", {"checksum": checksum})
if existing:
return {
"success": False,
"message": "图片已存在",
"data": {"existing_id": existing['id']}
}
# 生成图片ID
image_id = str(uuid.uuid4())
# 保存到数据库
await db.execute("""
INSERT INTO institution_images_binary
(id, institution_id, image_data, original_filename, mime_type,
file_size, width, height, compressed_quality, checksum)
VALUES (:id, :institution_id, :image_data, :filename, :mime_type,
:file_size, :width, :height, :quality, :checksum)
""", {
"id": image_id,
"institution_id": institution_id,
"image_data": compressed_data,
"filename": file.filename,
"mime_type": file.content_type,
"file_size": len(compressed_data),
"width": img_info['width'],
"height": img_info['height'],
"quality": quality,
"checksum": checksum
})
return {
"success": True,
"data": {
"id": image_id,
"url": f"/api/images/{image_id}",
"size": len(compressed_data),
"width": img_info['width'],
"height": img_info['height']
},
"message": "图片上传成功"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"图片上传失败: {e}")
raise HTTPException(status_code=500, detail="图片上传失败")
@router.delete("/{image_id}")
async def delete_image(
image_id: str,
db: DatabaseManager = Depends(get_database),
current_user: UserResponse = Depends(get_current_active_user)
):
"""
删除图片
Args:
image_id: 图片唯一标识
Returns:
dict: 删除结果
"""
try:
# 检查图片是否存在,并获取所属机构信息
existing = await db.fetch_one("""
SELECT ib.id, ib.institution_id, i.owner_id
FROM institution_images_binary ib
JOIN institutions i ON ib.institution_id = i.id
WHERE ib.id = :image_id
""", {"image_id": image_id})
if not existing:
raise HTTPException(status_code=404, detail="图片不存在")
# 权限检查:管理员可以删除任何图片,普通用户只能删除自己机构的图片
if current_user.role != 'admin' and existing['owner_id'] != current_user.id:
raise HTTPException(status_code=403, detail="权限不足,无法删除此图片")
# 删除图片
await db.execute("""
DELETE FROM institution_images_binary
WHERE id = :image_id
""", {"image_id": image_id})
return {
"success": True,
"message": "图片删除成功"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"图片删除失败: {e}")
raise HTTPException(status_code=500, detail="图片删除失败")
......@@ -31,22 +31,24 @@ async def get_institution_with_images(institution_id: str, db: DatabaseManager):
if not institution:
return None
# 获取机构图片
images_query = institution_images_table.select().where(
institution_images_table.c.institution_id == institution_id
).order_by(institution_images_table.c.upload_time)
# 优先从二进制图片表获取图片信息
from database import institution_images_binary_table
binary_images_query = institution_images_binary_table.select().where(
institution_images_binary_table.c.institution_id == institution_id
).order_by(institution_images_binary_table.c.upload_time)
images = await db.fetch_all(images_query)
binary_images = await db.fetch_all(binary_images_query)
# 构建响应数据
institution_images = [
InstitutionImage(
# 构建响应数据 - 只使用新表数据,不再回退到旧表
institution_images = []
# 只使用二进制表数据,返回API URL
for img in binary_images:
institution_images.append(InstitutionImage(
id=img["id"],
url=img["url"],
url=f"/api/images/{img['id']}", # 使用图片API URL
uploadTime=img["upload_time"]
)
for img in images
]
))
return InstitutionResponse(
id=institution["id"],
......
"""
调度器配置管理 API
提供调度器配置的查看和修改功能
"""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import Optional
from loguru import logger
from scheduler import monthly_scheduler
from models import BaseResponse
router = APIRouter()
class SchedulerConfig(BaseModel):
"""调度器配置模型"""
auto_save_enabled: Optional[bool] = None
auto_save_day: Optional[int] = None
auto_save_hour: Optional[int] = None
auto_save_minute: Optional[int] = None
max_retries: Optional[int] = None
@router.get("/config", summary="获取调度器配置")
async def get_scheduler_config():
"""获取当前调度器配置"""
try:
config = monthly_scheduler.get_config()
return BaseResponse(
message="获取调度器配置成功",
data=config
)
except Exception as e:
logger.error(f"获取调度器配置失败: {e}")
raise HTTPException(status_code=500, detail=f"获取配置失败: {str(e)}")
@router.put("/config", summary="更新调度器配置")
async def update_scheduler_config(config: SchedulerConfig):
"""更新调度器配置"""
try:
# 验证配置参数
config_dict = config.dict(exclude_unset=True)
# 验证参数范围
if 'auto_save_day' in config_dict:
if not (1 <= config_dict['auto_save_day'] <= 28):
raise HTTPException(status_code=400, detail="自动保存日期必须在1-28之间")
if 'auto_save_hour' in config_dict:
if not (0 <= config_dict['auto_save_hour'] <= 23):
raise HTTPException(status_code=400, detail="自动保存小时必须在0-23之间")
if 'auto_save_minute' in config_dict:
if not (0 <= config_dict['auto_save_minute'] <= 59):
raise HTTPException(status_code=400, detail="自动保存分钟必须在0-59之间")
if 'max_retries' in config_dict:
if not (0 <= config_dict['max_retries'] <= 10):
raise HTTPException(status_code=400, detail="最大重试次数必须在0-10之间")
# 更新配置
updated = monthly_scheduler.update_config(**config_dict)
if not updated:
return BaseResponse(message="没有配置需要更新")
return BaseResponse(
message=f"调度器配置更新成功: {', '.join(updated)}",
data=monthly_scheduler.get_config()
)
except HTTPException:
raise
except Exception as e:
logger.error(f"更新调度器配置失败: {e}")
raise HTTPException(status_code=500, detail=f"更新配置失败: {str(e)}")
@router.post("/trigger-save", summary="手动触发自动保存")
async def trigger_manual_save():
"""手动触发自动保存任务(用于测试)"""
try:
logger.info("🔧 收到手动触发自动保存请求")
result = await monthly_scheduler.trigger_manual_save()
if result:
return BaseResponse(message="手动触发自动保存成功")
else:
return BaseResponse(message="手动触发自动保存失败", success=False)
except Exception as e:
logger.error(f"手动触发自动保存失败: {e}")
raise HTTPException(status_code=500, detail=f"触发失败: {str(e)}")
@router.get("/status", summary="获取调度器状态")
async def get_scheduler_status():
"""获取调度器运行状态"""
try:
config = monthly_scheduler.get_config()
# 获取下次执行时间
next_run_time = None
if monthly_scheduler.is_running and config['auto_save_enabled']:
job = monthly_scheduler.scheduler.get_job('monthly_auto_save')
if job:
next_run_time = job.next_run_time.isoformat() if job.next_run_time else None
status = {
"is_running": config['is_running'],
"auto_save_enabled": config['auto_save_enabled'],
"next_run_time": next_run_time,
"config": config
}
return BaseResponse(
message="获取调度器状态成功",
data=status
)
except Exception as e:
logger.error(f"获取调度器状态失败: {e}")
raise HTTPException(status_code=500, detail=f"获取状态失败: {str(e)}")
@echo off
chcp 65001 >nul
echo 🚀 启动绩效计分系统后端开发服务器...
REM 检查虚拟环境是否存在
if not exist "venv\Scripts\activate.bat" (
echo ❌ 虚拟环境不存在,请先创建虚拟环境:
echo python -m venv venv
echo venv\Scripts\activate.bat
echo pip install -r requirements.txt
pause
exit /b 1
)
REM 激活虚拟环境
echo 📦 激活虚拟环境...
call venv\Scripts\activate.bat
REM 设置环境变量
echo ⚙️ 设置环境变量...
set DATABASE_URL=postgresql://performance_user:performance_pass@localhost:5431/performance_db
set DEBUG=True
REM 启动开发服务器
echo 🌟 启动 FastAPI 开发服务器...
echo 📍 服务地址: http://localhost:8000
echo 📖 API 文档: http://localhost:8000/docs
echo 🔄 热重载已启用,修改代码后会自动重启
echo ⏹️ 按 Ctrl+C 停止服务器
echo.
uvicorn main:app --reload --host 0.0.0.0 --port 8000
# 绩效计分系统后端开发服务器启动脚本
# 使用方法:在 backend 目录下运行 .\start_dev.ps1
Write-Host "🚀 启动绩效计分系统后端开发服务器..." -ForegroundColor Green
# 检查虚拟环境是否存在
if (-not (Test-Path ".\venv\Scripts\Activate.ps1")) {
Write-Host "❌ 虚拟环境不存在,请先创建虚拟环境:" -ForegroundColor Red
Write-Host " python -m venv venv" -ForegroundColor Yellow
Write-Host " .\venv\Scripts\Activate.ps1" -ForegroundColor Yellow
Write-Host " pip install -r requirements.txt" -ForegroundColor Yellow
exit 1
}
# 激活虚拟环境
Write-Host "📦 激活虚拟环境..." -ForegroundColor Blue
& .\venv\Scripts\Activate.ps1
# 设置环境变量
Write-Host "⚙️ 设置环境变量..." -ForegroundColor Blue
$env:DATABASE_URL = "postgresql://performance_user:performance_pass@localhost:5431/performance_db"
$env:DEBUG = "True"
# 检查数据库连接
Write-Host "🔍 检查数据库连接..." -ForegroundColor Blue
try {
$response = Invoke-RestMethod -Uri "http://localhost:5431" -Method Get -TimeoutSec 2 -ErrorAction Stop
} catch {
Write-Host "⚠️ 数据库可能未启动,请确保 Docker 容器正在运行:" -ForegroundColor Yellow
Write-Host " docker compose up -d postgres" -ForegroundColor Yellow
}
# 启动开发服务器
Write-Host "🌟 启动 FastAPI 开发服务器..." -ForegroundColor Green
Write-Host "📍 服务地址: http://localhost:8000" -ForegroundColor Cyan
Write-Host "📖 API 文档: http://localhost:8000/docs" -ForegroundColor Cyan
Write-Host "🔄 热重载已启用,修改代码后会自动重启" -ForegroundColor Cyan
Write-Host "⏹️ 按 Ctrl+C 停止服务器" -ForegroundColor Yellow
Write-Host ""
uvicorn main:app --reload --host 0.0.0.0 --port 8000
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -90,16 +90,22 @@ class ApiClient {
async request(endpoint, options = {}) {
const url = `${this.baseURL}${endpoint}`
// 检查是否是FormData,如果是则不设置Content-Type
const isFormData = options.body instanceof FormData
const config = {
timeout: this.timeout,
headers: {
'Content-Type': 'application/json',
...this.getAuthHeaders(),
...options.headers
// 只有在不是FormData时才设置JSON Content-Type
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
// 先设置传入的headers,再设置认证头,确保认证头不被覆盖
...options.headers,
...this.getAuthHeaders()
},
...options
}
try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), this.timeout)
......@@ -541,4 +547,64 @@ export const migrationApi = {
}
}
/**
* 图片服务API
*/
export const imageApi = {
// 获取图片URL(新格式)
getImageUrl(imageId) {
return `${API_BASE_URL}/api/images/${imageId}`
},
// 获取图片信息
async getImageInfo(imageId) {
return apiClient.get(`/api/images/${imageId}/info`)
},
// 上传图片(新的二进制上传)
async uploadImage(institutionId, file, options = {}) {
const formData = new FormData()
formData.append('file', file)
// 构建查询参数
const params = new URLSearchParams({
institution_id: institutionId
})
if (options.quality) {
params.append('quality', options.quality.toString())
}
if (options.maxWidth) {
params.append('max_width', options.maxWidth.toString())
}
// 直接使用request方法,不设置Content-Type让浏览器自动处理FormData
return apiClient.request(`/api/images/upload?${params.toString()}`, {
method: 'POST',
body: formData
// 不传递headers,让API客户端自动添加认证头
})
},
// 删除图片
async deleteImage(imageId) {
return apiClient.delete(`/api/images/${imageId}`)
},
// 兼容性方法:将Base64数据转换为新格式
convertBase64ToImageUrl(base64Data) {
// 如果已经是新格式的URL,直接返回
if (base64Data && base64Data.startsWith('/api/images/')) {
return `${API_BASE_URL}${base64Data}`
}
// 如果是Base64格式,暂时返回原数据(迁移期间的兼容处理)
if (base64Data && base64Data.startsWith('data:image/')) {
return base64Data
}
return null
}
}
// 删除了不必要的健康检查和连接管理器
......@@ -10,6 +10,12 @@ import { useDataStore } from './data'
*/
export const useAuthStore = defineStore('auth', () => {
const currentUser = ref(null)
const viewAsUser = ref(null)
/**
* 计算属性:有效用户(优先使用viewAsUser,否则使用currentUser)
*/
const effectiveUser = computed(() => viewAsUser.value || currentUser.value)
/**
* 计算属性:是否已认证
......@@ -22,6 +28,11 @@ export const useAuthStore = defineStore('auth', () => {
const isAdmin = computed(() => currentUser.value?.role === 'admin')
/**
* 计算属性:当前是否在用户视图模式
*/
const isViewingAsUser = computed(() => !!viewAsUser.value)
/**
* 登录后加载数据
*/
const loadDataAfterLogin = async () => {
......@@ -111,6 +122,8 @@ export const useAuthStore = defineStore('auth', () => {
currentUser.value = userInfo
// 恢复登录状态后加载数据
await loadDataAfterLogin()
// 恢复viewAsUser状态
restoreViewAsUser()
console.log('✅ 登录状态已恢复:', userInfo.name)
return
}
......@@ -126,6 +139,8 @@ export const useAuthStore = defineStore('auth', () => {
localStorage.setItem('currentUser', JSON.stringify(userInfo))
// 恢复登录状态后加载数据
await loadDataAfterLogin()
// 恢复viewAsUser状态
restoreViewAsUser()
console.log('✅ 登录状态已恢复(刷新后):', userInfo.name)
return
}
......@@ -159,9 +174,6 @@ export const useAuthStore = defineStore('auth', () => {
}
}
// 管理员用户切换功能(内存中保存)
const adminUser = ref(null)
/**
* 切换到指定用户视图(管理员功能)
* @param {string} userId - 要切换到的用户ID
......@@ -173,11 +185,9 @@ export const useAuthStore = defineStore('auth', () => {
const user = dataStore.getUserById(userId)
if (user && currentUser.value?.role === 'admin') {
// 保存原管理员信息到内存
adminUser.value = currentUser.value
// 切换到目标用户
currentUser.value = user
// 设置viewAsUser,保持currentUser不变
viewAsUser.value = user
localStorage.setItem('viewAsUser', JSON.stringify(user))
console.log('✅ 已切换到用户视图:', user.name)
}
} catch (error) {
......@@ -189,23 +199,41 @@ export const useAuthStore = defineStore('auth', () => {
* 从用户视图切换回管理员视图
*/
const switchBackToAdmin = () => {
if (adminUser.value) {
currentUser.value = adminUser.value
adminUser.value = null
viewAsUser.value = null
localStorage.removeItem('viewAsUser')
console.log('✅ 已切换回管理员视图')
}
/**
* 恢复viewAsUser状态
*/
const restoreViewAsUser = () => {
try {
const savedViewAsUser = localStorage.getItem('viewAsUser')
if (savedViewAsUser) {
viewAsUser.value = JSON.parse(savedViewAsUser)
console.log('✅ 已恢复用户视图状态:', viewAsUser.value.name)
}
} catch (error) {
console.error('恢复用户视图状态失败:', error)
localStorage.removeItem('viewAsUser')
}
}
return {
currentUser,
viewAsUser,
effectiveUser,
isAuthenticated,
isAdmin,
isViewingAsUser,
login,
logout,
restoreAuth,
updateCurrentUser,
switchToUser,
switchBackToAdmin,
restoreViewAsUser,
loadDataAfterLogin
}
})
\ No newline at end of file
......@@ -5,7 +5,8 @@ import {
institutionApi,
configApi,
historyApi,
migrationApi
migrationApi,
imageApi
} from '@/services/api'
import { useAuthStore } from '@/store/auth'
......@@ -525,54 +526,39 @@ export const useDataStore = defineStore('data', () => {
}
/**
* 为机构添加图片
* 为机构添加图片(已弃用,用户端现在直接使用imageApi.uploadImage)
* 保留此方法以防其他地方调用
*/
const addImageToInstitution = async (institutionId, imageData) => {
try {
const imageCreateData = {
id: imageData.id,
url: imageData.url
}
console.log('🔍 发送到后端的图片数据:', {
institutionId,
imageCreateData,
originalImageData: imageData
})
// 必须成功保存到数据库
await institutionApi.addImage(institutionId, imageCreateData)
console.log('✅ 图片已成功保存到数据库')
console.warn('⚠️ addImageToInstitution方法已弃用,请使用imageApi.uploadImage')
try {
// 重新从数据库加载数据以确保同步
await loadFromDatabase()
console.log('✅ 数据已重新加载,确保界面同步')
return true
} catch (error) {
console.error('添加图片失败:', error)
console.error('加载数据失败:', error)
throw error
}
}
/**
* 从机构删除图片
* 从机构删除图片(已弃用,用户端现在直接使用imageApi.deleteImage)
* 保留此方法以防其他地方调用
*/
const removeImageFromInstitution = async (institutionId, imageId) => {
try {
console.log('🗑️ 开始删除图片:', { institutionId, imageId })
// 先调用后端API删除图片
await institutionApi.deleteImage(institutionId, imageId)
console.log('✅ 后端删除图片成功')
console.warn('⚠️ removeImageFromInstitution方法已弃用,请使用imageApi.deleteImage')
try {
// 重新从数据库加载数据以确保同步
await loadFromDatabase()
console.log('✅ 数据已重新加载,确保界面同步')
return true
} catch (error) {
console.error('删除图片失败:', error)
console.error('加载数据失败:', error)
throw error
}
}
......@@ -714,7 +700,8 @@ export const useDataStore = defineStore('data', () => {
total_institutions: institutions.value.length,
total_images: institutions.value.reduce((total, inst) => total + (inst.images ? inst.images.length : 0), 0),
user_stats: currentStats,
institutions_data: institutionsWithImages // 新增:保存完整的机构和图片数据
institutions_data: institutionsWithImages, // 新增:保存完整的机构和图片数据
save_type: 'monthly_reset' // 标识这是月度重置保存
}
// 保存到数据库
......@@ -863,7 +850,8 @@ export const useDataStore = defineStore('data', () => {
total_institutions: institutions.value.length,
total_images: institutions.value.reduce((total, inst) => total + (inst.images ? inst.images.length : 0), 0),
user_stats: currentStats,
institutions_data: institutionsWithImages
institutions_data: institutionsWithImages,
save_type: 'manual' // 标识这是手动保存
}
// 保存到数据库
......@@ -909,44 +897,63 @@ export const useDataStore = defineStore('data', () => {
try {
console.log('🔄 开始执行月度重置...')
// 1. 保存当前月份的统计数据到历史记录
// 1. 重新加载数据以确保获取最新状态
console.log('📊 重新加载数据以确保同步...')
await loadFromDatabase()
// 2. 保存当前月份的统计数据到历史记录
const saveResult = await saveCurrentMonthStats()
if (!saveResult) {
throw new Error('保存历史统计数据失败')
}
// 2. 统计要清除的图片数量
// 3. 统计要清除的图片数量
let clearedImageCount = 0
let actualDeletedCount = 0
institutions.value.forEach(inst => {
if (inst.images && inst.images.length > 0) {
clearedImageCount += inst.images.length
}
})
// 3. 清空所有机构的图片数据
const updatedInstitutions = institutions.value.map(inst => ({
...inst,
images: [] // 清空图片数组
}))
console.log(`📊 预计清除 ${clearedImageCount} 张图片`)
// 4. 批量删除数据库中的所有图片记录
// 4. 批量删除数据库中的所有图片记录(使用新的图片API)
for (const institution of institutions.value) {
if (institution.images && institution.images.length > 0) {
console.log(`🗑️ 正在删除机构 ${institution.name}${institution.images.length} 张图片...`)
// 删除该机构的所有图片
for (const image of institution.images) {
await institutionApi.deleteImage(institution.id, image.id)
try {
console.log(`🗑️ 删除图片: ${image.id}`)
// 使用新的图片删除API
await imageApi.deleteImage(image.id)
actualDeletedCount++
console.log(`✅ 图片 ${image.id} 删除成功`)
} catch (error) {
// 如果图片不存在(404错误),忽略该错误并继续
if (error.message && error.message.includes('不存在')) {
console.log(`⚠️ 图片 ${image.id} 已不存在,跳过删除`)
actualDeletedCount++ // 仍然计入删除数量,因为目标已达成
} else {
console.error(`❌ 删除图片 ${image.id} 失败:`, error)
// 对于其他错误,记录但不中断整个流程
}
}
}
}
}
// 5. 更新本地状态
institutions.value = updatedInstitutions
// 5. 重新加载数据以确保状态同步
console.log('🔄 重新加载数据以确保状态同步...')
await loadFromDatabase()
console.log(`✅ 月度重置完成,已清空 ${clearedImageCount} 张图片`)
console.log(`✅ 月度重置完成,实际删除 ${actualDeletedCount} 张图片(预计 ${clearedImageCount} 张)`)
return {
success: true,
clearedCount: clearedImageCount,
clearedCount: actualDeletedCount,
message: '月度重置成功'
}
......
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>手动保存功能测试</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
.container {
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
h1 {
color: #333;
text-align: center;
margin-bottom: 30px;
}
.test-section {
margin-bottom: 30px;
padding: 20px;
border: 1px solid #ddd;
border-radius: 5px;
background-color: #fafafa;
}
.test-section h2 {
color: #555;
margin-top: 0;
}
.button {
background-color: #409EFF;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
margin: 5px;
font-size: 14px;
}
.button:hover {
background-color: #337ecc;
}
.button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
.result {
margin-top: 15px;
padding: 10px;
border-radius: 4px;
font-family: monospace;
white-space: pre-wrap;
}
.success {
background-color: #f0f9ff;
border: 1px solid #67c23a;
color: #67c23a;
}
.error {
background-color: #fef0f0;
border: 1px solid #f56c6c;
color: #f56c6c;
}
.info {
background-color: #f4f4f5;
border: 1px solid #909399;
color: #606266;
}
.loading {
background-color: #fff7e6;
border: 1px solid #e6a23c;
color: #e6a23c;
}
</style>
</head>
<body>
<div class="container">
<h1>🧪 手动保存功能测试</h1>
<div class="test-section">
<h2>📋 测试说明</h2>
<p>这个页面用于测试绩效计分系统的手动保存功能。请按照以下步骤进行测试:</p>
<ol>
<li>确保后端服务正在运行(http://localhost:8000)</li>
<li>确保前端服务正在运行(http://localhost:4001)</li>
<li>点击下面的测试按钮来验证各个功能</li>
</ol>
</div>
<div class="test-section">
<h2>🔍 1. 检查当前月份数据是否存在</h2>
<button class="button" onclick="testCheckMonthExists()">检查 2025-09 月份数据</button>
<div id="checkResult" class="result info">点击按钮开始测试...</div>
</div>
<div class="test-section">
<h2>💾 2. 测试手动保存功能</h2>
<button class="button" onclick="testManualSave()">手动保存当前数据</button>
<div id="saveResult" class="result info">点击按钮开始测试...</div>
</div>
<div class="test-section">
<h2>📊 3. 验证保存的数据</h2>
<button class="button" onclick="testGetSavedData()">获取已保存的数据</button>
<div id="dataResult" class="result info">点击按钮开始测试...</div>
</div>
</div>
<script>
const API_BASE_URL = 'http://localhost:8000';
// 获取当前月份
function getCurrentMonth() {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
}
// 显示结果
function showResult(elementId, message, type = 'info') {
const element = document.getElementById(elementId);
element.textContent = message;
element.className = `result ${type}`;
}
// 显示加载状态
function showLoading(elementId, message = '正在处理...') {
showResult(elementId, message, 'loading');
}
// 测试检查月份数据是否存在
async function testCheckMonthExists() {
const month = getCurrentMonth();
showLoading('checkResult', `正在检查 ${month} 月份数据...`);
try {
const response = await fetch(`${API_BASE_URL}/api/history/${month}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
}
});
if (response.ok) {
const data = await response.json();
showResult('checkResult', `✅ ${month} 月份数据存在\n响应: ${JSON.stringify(data, null, 2)}`, 'success');
} else if (response.status === 404) {
showResult('checkResult', `ℹ️ ${month} 月份数据不存在(这是正常的,可以进行保存)`, 'info');
} else {
const errorData = await response.json();
showResult('checkResult', `❌ 检查失败: ${errorData.detail || response.statusText}`, 'error');
}
} catch (error) {
showResult('checkResult', `❌ 网络错误: ${error.message}`, 'error');
}
}
// 测试手动保存功能
async function testManualSave() {
const month = getCurrentMonth();
showLoading('saveResult', `正在保存 ${month} 月份数据...`);
// 模拟保存数据
const historyData = {
month: month,
save_time: new Date().toISOString(),
total_users: 2,
total_institutions: 4,
total_images: 6,
user_stats: [
{
userId: 'test-user-1',
userName: '测试用户1',
institutionCount: 2,
interactionScore: 1.5,
performanceScore: 7.5,
institutions: []
},
{
userId: 'test-user-2',
userName: '测试用户2',
institutionCount: 2,
interactionScore: 2.0,
performanceScore: 10.0,
institutions: []
}
],
institutions_data: []
};
try {
const response = await fetch(`${API_BASE_URL}/api/history`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// 注意:实际使用时需要添加认证头
// 'Authorization': 'Bearer your-token-here'
},
body: JSON.stringify(historyData)
});
if (response.ok) {
const data = await response.json();
showResult('saveResult', `✅ 保存成功!\n响应: ${JSON.stringify(data, null, 2)}`, 'success');
} else {
const errorData = await response.json();
showResult('saveResult', `❌ 保存失败: ${errorData.detail || response.statusText}`, 'error');
}
} catch (error) {
showResult('saveResult', `❌ 网络错误: ${error.message}`, 'error');
}
}
// 测试获取保存的数据
async function testGetSavedData() {
const month = getCurrentMonth();
showLoading('dataResult', `正在获取 ${month} 月份数据...`);
try {
const response = await fetch(`${API_BASE_URL}/api/history/${month}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
}
});
if (response.ok) {
const data = await response.json();
showResult('dataResult', `✅ 数据获取成功!\n${JSON.stringify(data, null, 2)}`, 'success');
} else if (response.status === 404) {
showResult('dataResult', `ℹ️ ${month} 月份数据不存在,请先保存数据`, 'info');
} else {
const errorData = await response.json();
showResult('dataResult', `❌ 获取失败: ${errorData.detail || response.statusText}`, 'error');
}
} catch (error) {
showResult('dataResult', `❌ 网络错误: ${error.message}`, 'error');
}
}
// 页面加载完成后显示当前月份
document.addEventListener('DOMContentLoaded', function() {
const month = getCurrentMonth();
console.log(`当前测试月份: ${month}`);
});
</script>
</body>
</html>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment