后端显示;
[02/Sep/2025 15:54:01] "POST /api/login/client/ HTTP/1.1" 200 176
[02/Sep/2025 15:54:02] "GET /api/client/projects/ HTTP/1.1" 200 615
[02/Sep/2025 15:54:04] "GET /api/customer/project/ HTTP/1.1" 200 613
瀹㈡埛瀹㈡埛A鐨勯」鐩按绋诲熀鍥犵紪杈戦」鐩殑鎶ュ憡锛?<QuerySet [<Report: 杞戒綋淇℃伅鎶ュ憡锛堟按绋诲熀鍥犵紪杈戦」鐩級
>, <Report: 鍝佺杞寲鍙婂啀鐢熻兘鍔涙祴璇曟姤鍛婏紙姘寸ɑ鍩哄洜缂栬緫椤圭洰锛?, <Report: 鎶楁€х瓫閫夊強闃虫€ч壌瀹氭姤鍛婏
紙姘寸ɑ鍩哄洜缂栬緫椤圭洰锛?, <Report: 浜や粯鎶ュ憡锛堟按绋诲熀鍥犵紪杈戦」鐩級>]>
>
鎶ュ憡1鐨勬枃浠禪RL锛?http://192.168.1.17:8000/media/reports/2025/09/g10Ik6wo8mueb001a8dd4b4f8056baa6092fd565219c_UOsbHVB.pdf
鎶ュ憡5鐨勬枃浠禪RL锛?http://192.168.1.17:8000/media/reports/2025/09/QHBJk1NWfydeb001a8dd4b4f8056baa6092fd565219c_c0Pdh9v.pdf
鎶ュ憡10鐨勬枃浠禪RL锛?http://192.168.1.17:8000/media/reports/2025/09/PtlshNO88w8Fb001a8dd4b4f8056baa6092fd565219c_LErTtnq.pdf
鎶ュ憡39鐨勬枃浠禪RL锛?http://192.168.1.17:8000/media/reports/2025/09/eYMSdCZWsRI9b001a8dd4b4f8056baa6092fd565219c.pdf
杩斿洖缁欏鎴风殑鎶ュ憡鏁版嵁锛?[{'id': 1, 'project': 5, 'project_name': '姘寸ɑ鍩哄洜缂栬緫椤圭洰', 'report_type': 1, 'report_type_name': '杞戒綋淇℃伅鎶ュ憡', 'file_url': 'http://192.168.1.17:8000/media/reports/2025/09/g10Ik6wo8mueb001a8dd4b4f8056baa6092fd565219c_UOsbHVB.pdf', 'upload_time': '2025-09-02T06:38:34.624515Z', 'uploader': 1}, {'id': 5, 'project': 5, 'project_name': '姘寸ɑ鍩哄洜缂栬緫椤圭洰', 'report_type': 2, 'report_type_name': '鍝佺杞寲鍙婂啀鐢熻兘鍔涙祴璇曟姤鍛?, 'file_url': 'http://192.168.1.17:8000/media/reports/2025/09/QHBJk1NWfydeb001a8dd4b4f8056baa6092fd565219c_c0Pdh9v.pdf', 'upload_time': '2025-09-01T06:46:23.248237Z', 'uploader': 1}, {'id': 10, 'project': 5, 'project_name': '姘寸ɑ鍩哄洜缂栬緫椤圭洰', 'report_type': 3, 'report_type_name': '鎶楁€х瓫閫夊強闃虫€ч壌瀹氭姤鍛?, 'file_url': '前端:cust/report.wxml代码:<view class="container">
<!-- 项目信息头部 -->
<view class="project-header" wx:if="{{selectedProject}}">
<view class="project-title">{{selectedProject.project_name}}</view>
<view class="project-meta">
<text>{{selectedProject.client_name}}</text>
<text class="status-tag">状态:{{selectedProject.statusText}}</text>
</view>
</view>
<!-- 报告文件夹列表 -->
<view class="folder-list" wx:if="{{selectedProject}}">
<!-- 使用block包裹循环以避免解析问题 -->
<block wx:for="{{reportTypes}}" wx:key="type" wx:for-item="item">
<view class="folder-item">
<!-- 移除可能引起解析冲突的调试信息 -->
<!-- 文件夹信息 -->
<view class="folder-info">
<view class="folder-icon">📁</view>
<view class="folder-details">
<view class="folder-name">{{item.name}}</view>
<!-- 报告状态判断 -->
<view class="report-status uploaded"
wx:if="{{hasReported(Number(item.type))}}">
✅ 已上传
</view>
<view class="report-status unuploaded"
wx:else>
⚠️ 未上传
</view>
</view>
</view>
<!-- 下载按钮 -->
<button
class="action-btn view-btn"
bindtap="handleReportView"
data-reporttype="{{item.type}}"
wx:if="{{hasReported(Number(item.type))}}"
>
下载
</button>
</view>
</block>
</view>
<!-- 空状态提示 -->
<view class="empty-tip" wx:if="{{!selectedProject}}">
加载中...或暂无关联项目
</view>
<!-- 客户端底部导航 -->
<customer-tabbar id="tabbar"></customer-tabbar>
</view>
cust/report.js代码:Page({
data: {
selectedProject: null, // 客户唯一关联项目
reportTypes: [ // 固定4类报告,与实验员端一致
{ type: 1, name: "载体信息报告" },
{ type: 2, name: "品种转化及再生能力测试报告" },
{ type: 3, name: "抗性筛选及阳性鉴定报告" },
{ type: 4, name: "交付报告" }
],
projectReports: {}, // 项目-报告类型映射
reportsData: {} // 报告详情存储(key: 项目ID-报告类型)
},
onPullDownRefresh() {
// 仅当项目已加载时,刷新报告列表
if (this.data.selectedProject) {
this.getProjectReports(() => {
// 刷新完成后,停止下拉刷新动画
wx.stopPullDownRefresh();
wx.showToast({ title: "已同步最新报告", icon: "none" });
});
} else {
// 项目未加载时,先刷新项目再刷新报告
this.getCustomerProject(() => {
this.getProjectReports(() => {
wx.stopPullDownRefresh();
wx.showToast({ title: "已同步最新项目和报告", icon: "none" });
});
});
}
},
onLoad() {
this.getCustomerProject(); // 加载客户关联的唯一项目
},
/**
* 1. 获取客户关联的唯一项目(客户与项目一对一)
*/
getCustomerProject() {
const token = wx.getStorageSync("token");
if (!token) {
wx.showToast({ title: "请先登录", icon: "none" });
wx.navigateTo({ url: "/pages/login/login" });
return;
}
wx.request({
url: "http://192.168.1.17:8000/api/customer/project/", // 客户专属项目接口
method: "GET",
header: {
"Authorization": `Token ${token}`,
"content-type": "application/json"
},
success: (res) => {
if (res.statusCode !== 200) {
wx.showToast({ title: `获取项目失败:${res.statusCode}`, icon: "none" });
return;
}
// 客户仅关联1个项目,直接取返回对象(非数组)
const project = res.data || null;
if (!project || !project.project_id) {
wx.showToast({ title: "暂无关联项目", icon: "none" });
return;
}
// 格式化项目状态(数字+文本)
const formattedProject = {
...project,
status: Number(project.status),
statusText: this.getStatusText(Number(project.status)),
reportedTypes: [] // 初始化已上传报告类型数组
};
this.setData({ selectedProject: formattedProject }, () => {
this.getProjectReports(); // 项目加载后获取报告列表
});
},
fail: (err) => {
console.error("客户项目接口请求失败:", err);
wx.showToast({ title: "获取项目失败(网络/接口错误)", icon: "none" });
}
});
},
/**
* 2. 获取当前项目的所有报告
*/
getProjectReports(callback) {
const token = wx.getStorageSync("token");
const { selectedProject } = this.data;
if (!selectedProject) {
if (typeof callback === 'function') callback();
return;
}
wx.request({
url: "http://192.168.1.17:8000/api/customer/reports/",
method: "GET",
header: {
Authorization: `Token ${token}`,
"content-type": "application/json"
},
success: (res) => {
console.log("客户报告接口返回完整数据:", res); // 新增:打印完整响应
if (res.statusCode !== 200) {
wx.showToast({ title: `获取报告失败:${res.statusCode}`, icon: "none" });
if (typeof callback === 'function') callback();
return;
}
const reportData = Array.isArray(res.data) ? res.data : [];
console.log("客户项目ID(前端):", selectedProject.project_id); // 新增:打印前端项目ID
console.log("后端返回的报告列表:", reportData); // 新增:打印后端返回的报告
const projectReports = {};
const reportsData = {};
const projectId = Number(selectedProject.project_id); // 强制转为数字(关键:避免类型不匹配)
reportData.forEach(report => {
console.log("单个报告数据:", report); // 新增:打印每个报告的详情
const reportType = Number(report.report_type); // 强制转为数字
if (!projectReports[projectId]) projectReports[projectId] = [];
if (!projectReports[projectId].includes(reportType)) {
projectReports[projectId].push(reportType);
}
reportsData[`${projectId}-${reportType}`] = report;
});
console.log("最终关联的报告类型:", projectReports[projectId]); // 新增:打印最终关联的报告类型
this.setData({
projectReports,
reportsData,
"selectedProject.reportedTypes": projectReports[projectId] || []
}, () => {
if (typeof callback === 'function') callback();
});
},
fail: (err) => {
console.error("客户报告接口请求失败:", err);
wx.showToast({ title: "获取报告列表失败", icon: "none" });
if (typeof callback === 'function') callback();
}
});
},
/**
* 3. 辅助:获取项目状态文本(与实验员端一致)
*/
getStatusText(status) {
const statusMap = {
1: '载体构建阶段',
2: '品种转化及再生能力测试阶段',
3: '农杆苗侵染阶段',
4: '不定芽诱导阶段',
5: '抗性筛选及阳性鉴定阶段',
6: '温室培养阶段',
7: '交付'
};
return statusMap[status] || '未知状态';
},
/**
* 4. 辅助:判断报告是否已上传(用于渲染状态)
*/
hasReported(type) {
const { selectedProject } = this.data;
// 1. 基础校验:确保项目和报告类型数组存在
if (!selectedProject || !Array.isArray(selectedProject.reportedTypes)) {
console.log("hasReported:项目或报告类型数组不存在", selectedProject);
return false;
}
// 2. 强制统一类型:都转为数字(关键修复)
const targetType = Number(type); // 传入的type可能是字符串(如从wxml中获取)
const reportedTypes = selectedProject.reportedTypes.map(item => Number(item)); // 已上传的类型转为数字
// 3. 打印日志:确认类型和判断结果
console.log(`hasReported:目标类型${targetType},已上传类型${reportedTypes},结果${reportedTypes.includes(targetType)}`);
// 4. 判断是否包含
return reportedTypes.includes(targetType);
},
/**
* 5. 下载/查看报告(核心功能)
*/
handleReportView(e) {
const { reporttype } = e.currentTarget.dataset;
const selectedProject = this.data.selectedProject;
const token = wx.getStorageSync("token");
// 补充Token有效性检查
if (!token) {
wx.showToast({ title: "登录已过期,请重新登录", icon: "none" });
wx.redirectTo({ url: "/pages/login/login" });
return;
}
if (!selectedProject) {
wx.showToast({ title: "请先选择项目", icon: "none" });
return;
}
// 生成报告唯一标识(项目ID+报告类型)
const key = `${selectedProject.project_id}-${reporttype}`;
const report = this.data.reportsData[key];
if (report && report.file_url) {
wx.showLoading({ title: "加载中..." });
wx.downloadFile({
url: report.file_url,
header: {
Authorization: `Token ${token}`, // 确保Token正确传递
'content-type': 'application/pdf' // 补充PDF文件类型标识
},
success: (downloadRes) => {
wx.hideLoading();
if (downloadRes.statusCode === 200) {
// 打开PDF文件(指定文件类型为pdf)
wx.openDocument({
fileType: 'pdf',
filePath: downloadRes.tempFilePath,
success: () => console.log('文件打开成功:', downloadRes.tempFilePath),
fail: (err) => {
console.error('打开PDF失败:', err);
wx.showToast({ title: '请安装PDF阅读器后重试', icon: 'none' });
}
});
} else {
console.error('文件下载失败,状态码:', downloadRes.statusCode);
wx.showToast({ title: `下载失败(状态码:${downloadRes.statusCode})`, icon: 'none' });
}
},
fail: (err) => {
wx.hideLoading();
console.error('下载请求失败(网络/URL错误):', err);
wx.showToast({ title: '网络错误,请检查URL或Token', icon: 'none' });
}
});
} else {
wx.showToast({ title: '报告文件不存在或未上传', icon: 'none' });
}
}
});后端代码:views.py:from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from rest_framework import status,generics
from django.contrib.auth.hashers import check_password
from wxtest.models import Experimenter, Client,ProjectContract,Report # 同应用内的模型
from .serializers import ExperimenterLoginSerializer, ClientLoginSerializer,ProjectContractSerializer,ReportSerializer # 同应用内的序列化器
from rest_framework.permissions import AllowAny # 导入允许匿名访问的权限类
from rest_framework.authtoken.models import Token # 导入Token模型
from django.contrib.auth import authenticate
from rest_framework.exceptions import PermissionDenied
from django.contrib.auth.models import User
from django.db import IntegrityError
import os
from django.utils import timezone
class ExperimenterLoginView(APIView):
permission_classes = [AllowAny]
"""实验员登录视图"""
def post(self, request):
serializer = ExperimenterLoginSerializer(data=request.data)
if serializer.is_valid():
password = serializer.validated_data['password']
username = serializer.validated_data['username']
try:
experimenter = Experimenter.objects.get(username=username)
if password == experimenter.password:
# 检查是否有对应的User记录,如果没有则创建
if not experimenter.user:
user = User.objects.create_user(
username=username,
password=password
)
experimenter.user = user
experimenter.save()
else:
user = experimenter.user
# 生成或获取Token
token, created = Token.objects.get_or_create(user=user)
return Response({
'status': 'success',
'user_type': 'experimenter',
'user_id': experimenter.experimenter_id,
'username': experimenter.username,
'token': token.key, # 返回Token
'message': '登录成功'
})
return Response({'status': 'error', 'message': '密码错误'}, status=status.HTTP_400_BAD_REQUEST)
except Experimenter.DoesNotExist:
return Response({'status': 'error', 'message': '实验员不存在'}, status=status.HTTP_404_NOT_FOUND)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class ClientLoginView(APIView):
permission_classes = [AllowAny] # 客户登录接口也需要添加
"""客户登录视图(合同编号+客户名称验证)"""
def post(self, request):
serializer = ClientLoginSerializer(data=request.data)
if serializer.is_valid():
contract_number = serializer.validated_data['contract_number']
name = serializer.validated_data['name']
try:
client = Client.objects.get(contract_number=contract_number)
if client.name == name: # 验证客户名称
# 为客户创建或获取对应的User记录以生成token
if not hasattr(client, 'user') or not client.user:
# 创建一个与客户关联的User
user = User.objects.create_user(
username=f"client_{contract_number}",
password=contract_number # 使用合同编号作为初始密码
)
# 在Client模型中添加user字段后才能使用下面这行
client.user = user
client.save()
else:
user = client.user
# 生成或获取Token
token, created = Token.objects.get_or_create(user=user)
return Response({
'status': 'success',
'user_type': 'client',
'user_id': client.client_id,
'name': client.name,
'contract_number': client.contract_number,
'token': token.key, # 添加token返回
'message': '登录成功'
})
return Response({'status': 'error', 'message': '客户名称错误'}, status=status.HTTP_400_BAD_REQUEST)
except Client.DoesNotExist:
return Response({'status': 'error', 'message': '客户不存在'}, status=status.HTTP_404_NOT_FOUND)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class ClientProjectListView(generics.ListAPIView):
"""客户项目列表接口 - 返回当前登录客户的所有项目"""
serializer_class = ProjectContractSerializer
permission_classes = [IsAuthenticated] # 需要登录验证
def get_queryset(self):
# 获取当前登录用户关联的客户
user = self.request.user
try:
client = Client.objects.get(user=user) # 通过user关联找到客户
# 返回该客户的所有项目(一对一关联)
return ProjectContract.objects.filter(client=client)
except Client.DoesNotExist:
raise PermissionDenied("没有找到该客户的信息")
class ProjectContractListView(generics.ListAPIView):
"""获取当前实验员的所有项目"""
serializer_class = ProjectContractSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
# 只返回当前实验员负责的项目
return ProjectContract.objects.filter(
experimenter=self.request.user.experimenter
).select_related('client')
class ProjectContractCreateView(generics.CreateAPIView):
"""创建项目合同"""
queryset = ProjectContract.objects.all()
serializer_class = ProjectContractSerializer
permission_classes = [IsAuthenticated]
class ProjectContractUpdateView(generics.UpdateAPIView):
"""修改项目合同信息"""
queryset = ProjectContract.objects.all()
serializer_class = ProjectContractSerializer
permission_classes = [IsAuthenticated]
lookup_field = 'project_id'
def get_object(self):
obj = super().get_object()
# 检查当前用户是否有权修改此项目
if obj.experimenter != self.request.user.experimenter:
raise PermissionDenied("您无权修改此项目")
return obj
class ProjectContractDeleteView(generics.DestroyAPIView):
"""删除项目合同"""
queryset = ProjectContract.objects.all()
permission_classes = [IsAuthenticated]
lookup_field = 'project_id'
def get_object(self):
obj = super().get_object()
# 检查当前用户是否有权删除此项目
if obj.experimenter != self.request.user.experimenter:
raise PermissionDenied("您无权删除此项目")
return obj
def destroy(self, request, *args, **kwargs):
instance = self.get_object()
self.perform_destroy(instance)
return Response(
{"status": "success", "message": "项目删除成功"},
status=status.HTTP_200_OK
)
class ExperimenterProjectReportListView(generics.ListAPIView):
serializer_class = ReportSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
# 仅返回当前实验员负责的项目的所有报告
experimenter = self.request.user.experimenter
return Report.objects.filter(
project__experimenter=experimenter
).select_related("project") # 关联查询优化
# 2. 上传报告(支持新增/覆盖:同一项目同一类型自动覆盖)
class ReportUploadView(generics.CreateAPIView):
serializer_class = ReportSerializer
permission_classes = [IsAuthenticated]
def create(self, request, *args, **kwargs):
# 1. 获取前端传的核心参数:项目ID、报告类型、PDF文件
try:
# 关键:将project_id和report_type转为整数,避免类型错误
project_id = int(request.data.get("project"))
report_type = int(request.data.get("report_type"))
except (ValueError, TypeError):
return Response(
{"error": "项目ID和报告类型必须是数字"},
status=status.HTTP_400_BAD_REQUEST
)
file = request.FILES.get("file") # 注意:文件通过FILES传递
# 2. 基础验证
if not (project_id and report_type and file):
return Response(
{"error": "项目ID、报告类型、PDF文件均为必填"},
status=status.HTTP_400_BAD_REQUEST
)
# 验证文件类型(仅PDF)
if not file.name.endswith(".pdf"):
return Response(
{"error": "仅支持PDF格式文件"},
status=status.HTTP_400_BAD_REQUEST
)
# 3. 查询项目实例(移除权限验证,只检查项目是否存在)
try:
# 仅验证项目是否存在,不限制所属实验员
project = ProjectContract.objects.get(project_id=project_id)
except ProjectContract.DoesNotExist:
return Response(
{"error": "项目不存在"}, # 提示信息也相应修改
status=status.HTTP_404_NOT_FOUND
)
# 4. 处理“新增/覆盖”逻辑:同一项目同一类型仅保留最新文件
report_data = {
"project": project,
"report_type": report_type,
"file": file, # 确保file是request.FILES获取的正确文件对象
"uploader": request.user.experimenter
}
try:
# 尝试创建新报告
report = Report.objects.create(**report_data)
print(f"新报告创建成功,文件路径:{report.file.url}") # 调试日志
return Response(
{"status": "success", "message": "报告上传成功", "file_url": report.file.url}, # 返回URL给前端
status=status.HTTP_201_CREATED
)
except IntegrityError:
# 覆盖已有报告的文件
report = Report.objects.get(project=project, report_type=report_type)
old_file_path = report.file.path # 可选:删除旧文件,避免占用空间
if os.path.exists(old_file_path):
os.remove(old_file_path)
report.file = file # 更新文件
report.upload_time = timezone.now() # 手动更新上传时间(之前代码此处有误,需修复!)
report.save()
print(f"报告文件更新成功,新文件路径:{report.file.url}") # 调试日志
return Response(
{"status": "success", "message": "报告已更新", "file_url": report.file.url}, # 返回最新URL
status=status.HTTP_200_OK
)
# 3. 可选:删除报告(用于错误报告的清理)
class ReportDeleteView(generics.DestroyAPIView):
queryset = Report.objects.all()
permission_classes = [IsAuthenticated]
lookup_field = "id" # 按报告ID删除
def destroy(self, request, *args, **kwargs):
self.perform_destroy(self.get_object())
return Response(
{"status": "success", "message": "报告已删除"},
status=status.HTTP_200_OK
)
# 客户专属接口:获取当前登录客户关联的唯一项目
class CustomerProjectView(APIView):
"""
客户专属接口:获取当前登录客户关联的唯一项目
"""
permission_classes = [IsAuthenticated]
def get(self, request):
# 1. 通过登录用户关联找到客户
try:
client = Client.objects.get(user=request.user)
except Client.DoesNotExist:
raise PermissionDenied("未找到当前用户关联的客户信息")
# 2. 获取客户关联的唯一项目(一对一关系)
try:
project = ProjectContract.objects.get(client=client)
except ProjectContract.DoesNotExist:
return Response(
{"error": "当前客户暂无关联项目"},
status=status.HTTP_404_NOT_FOUND
)
# 3. 序列化返回项目信息
serializer = ProjectContractSerializer(project)
return Response(serializer.data, status=status.HTTP_200_OK)
class CustomerReportListView(APIView):
"""客户专属接口:获取当前客户项目的所有报告"""
permission_classes = [IsAuthenticated]
serializer_class = ReportSerializer
def get(self, request):
# 1. 通过登录用户关联找到客户(必做:确保是客户身份)
try:
client = Client.objects.get(user=request.user)
except Client.DoesNotExist:
raise PermissionDenied("未找到当前用户关联的客户信息")
# 2. 关键修复:通过客户的一对一关联获取项目(client.project_contract)
# 原因:Client与ProjectContract是一对一,反向关联名是project_contract(在models.py中定义)
try:
# 直接通过客户获取关联的项目(避免filter查询,一对一用get或直接访问反向关联)
project = client.project_contract # 这里的project_contract对应models.py中Client的related_name
# 3. 筛选该项目的所有报告
reports = Report.objects.filter(project=project).select_related("project")
# 打印日志:确认获取到的报告(后端调试用)
print(f"客户{client.name}的项目{project.project_name}的报告:", reports)
except ProjectContract.DoesNotExist:
return Response(
{"error": "当前客户暂无关联项目,无法获取报告"},
status=status.HTTP_404_NOT_FOUND
)
# 4. 序列化返回(确保报告数据正确)
serializer = self.serializer_class(reports, many=True)
# 打印日志:确认返回给前端的报告数据
print(f"返回给客户的报告数据:", serializer.data)
return Response(serializer.data, status=status.HTTP_200_OK)
serializers.py代码:from rest_framework import serializers
from wxtest.models import Experimenter, Client,ProjectContract,Report
from django.conf import settings
class ExperimenterLoginSerializer(serializers.ModelSerializer):
"""实验员登录序列化器"""
username = serializers.CharField(required=True)
password = serializers.CharField(required=True, write_only=True)
class Meta:
model = Experimenter
fields = ['password', 'username']
class ClientLoginSerializer(serializers.ModelSerializer):
"""客户登录序列化器 - 使用合同编号+客户名称登录"""
contract_number = serializers.CharField(required=True)
name = serializers.CharField(required=True) # 改为客户名称验证
class Meta:
model = Client
fields = ['contract_number', 'name']
class ProjectContractSerializer(serializers.ModelSerializer):
"""项目合同序列化器(用于创建/修改)"""
# 新增:通过实验员关联获取用户名
experimenter_username = serializers.CharField(source='experimenter.username', read_only=True)
client_name = serializers.CharField(source='client.name', read_only=True)
client_contract_number = serializers.CharField(
source='client.contract_number',
read_only=True
)
client_contract_input = serializers.CharField(
source='client.contract_number',
write_only=True,
required=False, # 默认为非必填,后续动态调整
help_text="客户合同编号(创建项目时必填)"
)
class Meta:
model = ProjectContract
# 关键修改:在 fields 中添加 missing 的字段(variety_name、experiment_purpose)
fields = [
'project_id', 'project_name', 'gene_id', 'start_date', 'end_date', 'status',
'client_name', 'client_contract_number', 'client_contract_input',
'experimenter', 'experimenter_username',
'variety_name', # 品种名称(对应前端 item.variety_name)
'experiment_purpose' # 实验目的(对应前端 item.experiment_purpose)
]
extra_kwargs = {
'project_id': {'read_only': True},
'experimenter': {'read_only': True},
}
# 动态设置client_contract_input的必填性(原有逻辑不变)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance is None:
self.fields['client_contract_input'].required = True
else:
self.fields['client_contract_input'].required = False
# 验证客户合同编号(原有逻辑不变)
def validate_client_contract_input(self, value):
if value and not Client.objects.filter(contract_number=value).exists():
raise serializers.ValidationError("该合同编号对应的客户不存在")
return value
# 创建项目(原有逻辑不变,**validated_data 会自动包含新增字段**)
def create(self, validated_data):
experimenter = self.context['request'].user.experimenter
client_data = validated_data.pop('client')
client = Client.objects.get(contract_number=client_data['contract_number'])
# 新增字段(variety_name、experiment_purpose)会通过 **validated_data 自动存入数据库
project = ProjectContract.objects.create(
client=client,
experimenter=experimenter,
**validated_data
)
return project
# 更新项目(原有逻辑不变,新增字段会自动更新)
def update(self, instance, validated_data):
if 'client' in validated_data:
validated_data.pop('client')
if 'client_contract_number' in validated_data:
validated_data.pop('client_contract_number')
# 新增字段会通过 super().update 自动更新
return super().update(instance, validated_data)
class ReportSerializer(serializers.ModelSerializer):
"""报告序列化器:含文件URL、报告类型名称"""
# 显示报告类型的中文名称(如“载体信息报告”)
report_type_name = serializers.CharField(source="get_report_type_display", read_only=True)
# 显示项目名称(关联项目的基本信息)
project_name = serializers.CharField(source="project.project_name", read_only=True)
# 显示文件的完整访问URL(需配置MEDIA_URL)
file_url = serializers.SerializerMethodField(read_only=True)
class Meta:
model = Report
fields = [
"id", "project", "project_name",
"report_type", "report_type_name",
"file", "file_url", "upload_time", "uploader"
]
extra_kwargs = {
"uploader": {"read_only": True}, # 上传人自动关联当前实验员,无需前端传
"file": {"write_only": True} # 文件二进制数据仅用于上传,返回用file_url
}
# 自定义方法:生成文件的完整访问URL
def get_file_url(self, obj):
if not obj.file or not obj.file.name:
print(f"警告:报告{obj.id}的文件不存在")
return None
# 确保BASE_URL与后端服务地址一致(如http://192.168.1.17:8000)
base_url = getattr(settings, 'BASE_URL', 'http://192.168.1.17:8000')
file_relative_path = obj.file.url # Django自动生成的媒体文件相对路径(如/media/reports/2025/09/xxx.pdf)
# 拼接完整URL(处理相对路径是否带"/"的情况)
if file_relative_path.startswith("/"):
full_url = f"{base_url}{file_relative_path}"
else:
full_url = f"{base_url}/{file_relative_path}"
print(f"报告{obj.id}的文件URL:", full_url) # 调试用,确认URL正确
return full_url
model.spy代码:from django.db import models
from django.core.validators import RegexValidator
from django.contrib.auth.hashers import make_password
from django.contrib.auth.models import User
from django.utils import timezone
from django import forms
class Experimenter(models.Model):
"""实验员表"""
user = models.OneToOneField(
User,
on_delete=models.CASCADE,
related_name='experimenter', # 反向关联名,方便通过User获取Experimenter(如user.experimenter)
null=True # 关键:允许为空,旧数据的 user_id 可以是 NULL
)
experimenter_id = models.AutoField(primary_key=True, verbose_name='实验员ID')
username = models.CharField(max_length=50, unique=True, verbose_name='登录用户名')
password = models.CharField(max_length=100, verbose_name='密码') # 改为普通密码字段
phone = models.CharField(
max_length=20,
verbose_name='手机号',
validators=[RegexValidator(r'^1[3-9]\d{9}$', '请输入有效的手机号')]
)
class Meta:
db_table = 'experimenter'
verbose_name = '实验员'
verbose_name_plural = '实验员列表'
def __str__(self):
return self.username
class Client(models.Model):
"""客户表(与项目合同一对一关联)"""
# 添加与User模型的一对一关联
user = models.OneToOneField(
User,
on_delete=models.CASCADE,
related_name='client', # 反向关联名
null=True # 允许为空,兼容旧数据
)
client_id = models.AutoField(primary_key=True, verbose_name='客户ID')
name = models.CharField(max_length=100, verbose_name='客户姓名')
contract_number = models.CharField(max_length=100, unique=True, verbose_name='合同编号(登录凭证)')
phone = models.CharField(
max_length=20,
verbose_name='手机号',
validators=[RegexValidator(r'^1[3-9]\d{9}$', '请输入有效的手机号')]
)
class Meta:
db_table = 'client'
verbose_name = '客户'
verbose_name_plural = '客户列表'
def __str__(self):
return f"{self.name}({self.contract_number})"
class ProjectContract(models.Model):
"""项目合同表(与客户一对一关联)"""
project_id = models.AutoField(primary_key=True, verbose_name='项目ID')
client = models.OneToOneField(
Client,
on_delete=models.CASCADE,
db_column='client_id',
related_name='project_contract',
verbose_name='关联客户'
)
experimenter = models.ForeignKey(
Experimenter,
on_delete=models.CASCADE,
db_column='experimenter_id',
related_name='managed_projects',
verbose_name='负责人'
)
project_name = models.CharField(max_length=200, verbose_name='项目名称')
gene_id = models.CharField(max_length=50, blank=True, null=True, verbose_name='基因编号')
variety_name = models.CharField(max_length=100, blank=True, null=True, verbose_name='品种名称')
experiment_purpose = models.TextField(blank=True, null=True, verbose_name='实验目的')
start_date = models.DateField(verbose_name='启动日期')
end_date = models.DateField(blank=True, null=True, verbose_name='结束日期')
status = models.IntegerField(
choices=[
(1, '载体构建阶段'),
(2, '品种转化及再生能力测试阶段'),
(3, '农杆苗侵染阶段'),
(4, '不定芽诱导阶段'),
(5, '抗性筛选及阳性鉴定阶段'),
(6, '温室培养阶段'),
(7, '交付')
],
default=1,
verbose_name='项目状态'
)
class Meta:
db_table = 'project_contract'
verbose_name = '项目合同'
verbose_name_plural = '项目合同列表'
def __str__(self):
return f"{self.project_name}({self.client.contract_number})"
#实验报告
class Report(models.Model):
"""报告模型:关联项目+存储4类报告PDF"""
# 报告类型:与需求中的4类报告一一对应
REPORT_TYPE_CHOICES = [
(1, "载体信息报告"),
(2, "品种转化及再生能力测试报告"),
(3, "抗性筛选及阳性鉴定报告"),
(4, "交付报告"),
]
project = models.ForeignKey(
ProjectContract,
on_delete=models.CASCADE,
related_name="reports", # 反向关联:项目→所有报告
verbose_name="关联项目"
)
report_type = models.IntegerField(
choices=REPORT_TYPE_CHOICES,
verbose_name="报告类型"
)
file = models.FileField(
upload_to="reports/%Y/%m/", # 文件存储路径:media/reports/年/月/
verbose_name="PDF报告文件",
null=True, blank=True # 允许初始为空(未上传)
)
uploader = models.ForeignKey(
Experimenter,
on_delete=models.CASCADE,
verbose_name="上传人(实验员)"
)
upload_time = models.DateTimeField(
default=timezone.now,
verbose_name="上传时间"
)
class Meta:
db_table = "report"
verbose_name = "项目报告"
verbose_name_plural = "项目报告列表"
# 唯一约束:同一项目同一报告类型只能有1个
unique_together = ("project", "report_type")
def __str__(self):
return f"{self.get_report_type_display()}({self.project.project_name})"
midedleware.py代码:from django.http import HttpResponseForbidden
from django.utils.deprecation import MiddlewareMixin
from rest_framework.authtoken.models import Token
class MediaFileAuthMiddleware(MiddlewareMixin):
"""仅验证用户登录状态,允许所有登录用户访问报告文件"""
def process_request(self, request):
# 仅拦截报告文件的访问请求
if request.path.startswith("/media/reports/"):
# 验证Token是否存在且有效
token_header = request.META.get("HTTP_AUTHORIZATION")
if not token_header or not token_header.startswith("Token "):
return HttpResponseForbidden("请先登录")
# 提取并验证Token
token_key = token_header.split(" ")[1]
try:
# 只需验证Token存在即可,不检查具体权限
Token.objects.get(key=token_key)
return None # Token有效,允许访问
except Token.DoesNotExist:
return HttpResponseForbidden("无效的登录状态")
# 非报告文件路径,直接放行
return None
,为什么当进入客户首页页面后,进入report页面,显示的报告仍为未上传,但是数据库中的数据是存在的, 前端返回数据:[system] WeChatLib: 3.8.10 (2025.7.4 16:39:26)
[system] Subpackages: N/A
[system] LazyCodeLoading: false
app.js? [sm]:10 App onLaunch 执行
[Deprecation] SharedArrayBuffer will require cross-origin isolation as of M92, around July 2021. See https://developer.chrome.com/blog/enabling-shared-array-buffer/ for more details.
[system] Launch Time: 2624 ms
Tue Sep 02 2025 15:54:01 GMT+0800 (中国标准时间) 配置中关闭合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书检查
工具未校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书。
onLogin @ login.js? [sm]:66
login.js? [sm]:75 登录响应数据: {status: "success", user_type: "client", user_id: 1, name: "客户A", contract_number: "HT2023001", …}
login.js? [sm]:94 登录成功,用户类型: client
login.js? [sm]:95 准备调用 app.loginSuccess
app.js? [sm]:28 loginSuccess 执行,用户信息: {status: "success", user_type: "client", user_id: 1, name: "客户A", contract_number: "HT2023001", …}
app.js? [sm]:44 用户信息存储成功
app.js? [sm]:106 当前用户类型: client
app.js? [sm]:113 尝试跳转到: /pages/customer/index/index
index.js? [sm]:15 首页获取到的userData: {status: "success", user_type: "client", user_id: 1, name: "客户A", contract_number: "HT2023001", …}
[pages/customer/index/index] 提示: text 组件包含了长文本,可以考虑增加 user-select 属性,方便用户复制。
report.js? [sm]:105 客户报告接口返回完整数据: {data: Array(4), header: Proxy, statusCode: 200, cookies: Array(0), accelerateType: "none", …}
report.js? [sm]:113 客户项目ID(前端): 5
report.js? [sm]:114 后端返回的报告列表: (4) [{…}, {…}, {…}, {…}]0: {id: 1, project: 5, project_name: "水稻基因编辑项目", report_type: 1, report_type_name: "载体信息报告", …}1: {id: 5, project: 5, project_name: "水稻基因编辑项目", report_type: 2, report_type_name: "品种转化及再生能力测试报告", …}2: {id: 10, project: 5, project_name: "水稻基因编辑项目", report_type: 3, report_type_name: "抗性筛选及阳性鉴定报告", …}3: {id: 39, project: 5, project_name: "水稻基因编辑项目", report_type: 4, report_type_name: "交付报告", …}length: 4nv_length: (...)__proto__: Array(0)
report.js? [sm]:121 单个报告数据: {id: 1, project: 5, project_name: "水稻基因编辑项目", report_type: 1, report_type_name: "载体信息报告", …}file_url: "http://192.168.1.17:8000/media/reports/2025/09/g10Ik6wo8mueb001a8dd4b4f8056baa6092fd565219c_UOsbHVB.pdf"id: 1project: 5project_name: "水稻基因编辑项目"report_type: 1report_type_name: "载体信息报告"upload_time: "2025-09-02T06:38:34.624515Z"uploader: 1__proto__: Object
report.js? [sm]:121 单个报告数据: {id: 5, project: 5, project_name: "水稻基因编辑项目", report_type: 2, report_type_name: "品种转化及再生能力测试报告", …}file_url: "http://192.168.1.17:8000/media/reports/2025/09/QHBJk1NWfydeb001a8dd4b4f8056baa6092fd565219c_c0Pdh9v.pdf"id: 5project: 5project_name: "水稻基因编辑项目"report_type: 2report_type_name: "品种转化及再生能力测试报告"upload_time: "2025-09-01T06:46:23.248237Z"uploader: 1__proto__: Object
report.js? [sm]:121 单个报告数据: {id: 10, project: 5, project_name: "水稻基因编辑项目", report_type: 3, report_type_name: "抗性筛选及阳性鉴定报告", …}file_url: "http://192.168.1.17:8000/media/reports/2025/09/PtlshNO88w8Fb001a8dd4b4f8056baa6092fd565219c_LErTtnq.pdf"id: 10project: 5project_name: "水稻基因编辑项目"report_type: 3report_type_name: "抗性筛选及阳性鉴定报告"upload_time: "2025-09-01T06:46:28.001618Z"uploader: 1__proto__: Object
report.js? [sm]:121 单个报告数据: {id: 39, project: 5, project_name: "水稻基因编辑项目", report_type: 4, report_type_name: "交付报告", …}file_url: "http://192.168.1.17:8000/media/reports/2025/09/eYMSdCZWsRI9b001a8dd4b4f8056baa6092fd565219c.pdf"id: 39project: 5project_name: "水稻基因编辑项目"report_type: 4report_type_name: "交付报告"upload_time: "2025-09-01T06:46:32.225784Z"uploader: 1__proto__: Object
report.js? [sm]:130 最终关联的报告类型: (4) [1, 2, 3, 4]0: 11: 22: 33: 4length: 4nv_length: (...)__proto__: Array(0)
终端上显示http://192.168.1.17:8000/media/reports/2025/09/PtlshNO88w8Fb001a8dd4b4f8056baa6092fd565219c_LErTtnq.pdf', 'upload_time': '2025-09-01T06:46:28.001618Z', 'uploader': 1}, {'id': 39, 'project': 5, 'project_name': '姘寸ɑ鍩哄洜缂栬緫椤圭洰', 'report_type': 4, 'report_type_name': '浜や
粯鎶ュ憡', 'file_url': 'http://192.168.1.17:8000/media/reports/2025/09/eYMSdCZWsRI9b001a8dd4b4f8056baa6092fd565219c.pdf',
'upload_time': '2025-09-01T06:46:32.225784Z', 'uploader': 1}]
]
[02/Sep/2025 15:54:04] "GET /api/customer/reports/ HTTP/1.1" 200 1197。但是客户页面上显示为未上传,也就无法下载。怎么解决问题,修改相关代码