diff --git a/API_CONTACTS.md b/API_CONTACTS.md new file mode 100644 index 0000000..84441ec --- /dev/null +++ b/API_CONTACTS.md @@ -0,0 +1,318 @@ +# 联系记录接口文档 + +## 1. 查询联系记录列表 + +### 接口信息 +- **URL**: `/api/contacts` +- **方法**: `GET` +- **认证**: 需要登录(Header 携带 Authorization) + +### 请求参数 + +| 参数名 | 类型 | 必填 | 说明 | 示例 | +|--------|------|------|------|------| +| page | integer | 否 | 页码,默认 1 | 1 | +| page_size | integer | 否 | 每页数量,默认 10 | 20 | +| search | string | 否 | 搜索关键词(姓名/岗位/联系方式) | 张三 | +| reply_status | string | 否 | 回复状态筛选 | 已回复 | +| wechat_exchanged | boolean | 否 | 是否交换微信 | true | +| start_date | string | 否 | 开始日期(YYYY-MM-DD) | 2024-01-01 | +| end_date | string | 否 | 结束日期(YYYY-MM-DD) | 2024-12-31 | + +### 请求示例 + +```bash +# 基础查询 +GET /api/contacts?page=1&page_size=20 + +# 关键词搜索 +GET /api/contacts?search=张三 + +# 按回复状态筛选 +GET /api/contacts?reply_status=已回复 + +# 按时间范围筛选 +GET /api/contacts?start_date=2024-01-01&end_date=2024-12-31 + +# 组合筛选 +GET /api/contacts?search=产品经理&reply_status=已回复&wechat_exchanged=true&start_date=2024-01-01&end_date=2024-12-31 +``` + +### 响应示例 + +```json +{ + "code": 200, + "msg": "success", + "data": { + "total": 150, + "page": 1, + "page_size": 20, + "results": [ + { + "id": 1, + "name": "张三", + "position": "产品经理", + "contact": "13800138000", + "reply_status": "已回复", + "wechat_exchanged": true, + "account_id": 1, + "worker_id": "worker-001", + "notes": "沟通顺畅,有意向", + "contacted_at": "2024-03-01T10:30:00", + "created_at": "2024-03-01T10:00:00" + }, + { + "id": 2, + "name": "李四", + "position": "前端工程师", + "contact": "wechat:lisi123", + "reply_status": "未回复", + "wechat_exchanged": false, + "account_id": 2, + "worker_id": "worker-002", + "notes": "", + "contacted_at": "2024-03-02T14:20:00", + "created_at": "2024-03-02T14:00:00" + } + ] + } +} +``` + +--- + +## 2. 导出联系记录为 Excel + +### 接口信息 +- **URL**: `/api/contacts/export` +- **方法**: `GET` +- **认证**: 需要登录(Header 携带 Authorization) +- **响应类型**: `application/json`(返回下载链接) + +### 请求参数 + +| 参数名 | 类型 | 必填 | 说明 | 示例 | +|--------|------|------|------|------| +| search | string | 否 | 搜索关键词(姓名/岗位/联系方式) | 张三 | +| reply_status | string | 否 | 回复状态筛选 | 已回复 | +| wechat_exchanged | boolean | 否 | 是否交换微信 | true | +| start_date | string | 否 | 开始日期(YYYY-MM-DD) | 2024-01-01 | +| end_date | string | 否 | 结束日期(YYYY-MM-DD) | 2024-12-31 | + +### 请求示例 + +```bash +# 导出所有联系记录 +GET /api/contacts/export + +# 导出指定时间段的记录 +GET /api/contacts/export?start_date=2024-01-01&end_date=2024-12-31 + +# 导出已回复且交换微信的记录 +GET /api/contacts/export?reply_status=已回复&wechat_exchanged=true + +# 导出组合筛选结果 +GET /api/contacts/export?search=产品经理&start_date=2024-01-01&end_date=2024-03-31&reply_status=已回复 +``` + +### 响应示例 + +```json +{ + "code": 200, + "msg": "success", + "data": { + "download_url": "/media/exports/contacts_export_20240304_153045_a1b2c3d4.xlsx", + "filename": "contacts_export_20240304_153045_a1b2c3d4.xlsx", + "total_records": 150, + "generated_at": "2024-03-04 15:30:45" + } +} +``` + +### 响应字段说明 + +| 字段名 | 类型 | 说明 | +|--------|------|------| +| download_url | string | 文件下载链接(相对路径) | +| filename | string | 生成的文件名 | +| total_records | integer | 导出的记录总数 | +| generated_at | string | 文件生成时间 | + +### Excel 文件格式 + +**表头**(蓝色背景,白色粗体文字): + +| 列名 | 说明 | 示例 | +|------|------|------| +| ID | 记录 ID | 1 | +| 姓名 | 联系人姓名 | 张三 | +| 岗位 | 应聘岗位 | 产品经理 | +| 联系方式 | 电话或微信 | 13800138000 | +| 回复状态 | 回复状态 | 已回复 | +| 是否交换微信 | 是/否 | 是 | +| Worker ID | Worker 标识 | worker-001 | +| 备注 | 备注信息 | 沟通顺畅,有意向 | +| 联系时间 | 联系时间 | 2024-03-01 10:30:00 | +| 创建时间 | 创建时间 | 2024-03-01 10:00:00 | + +### 前端调用示例 + +```javascript +// 使用 axios +const exportContacts = async (params) => { + try { + const response = await axios.get('/api/contacts/export', { + params: params, + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (response.data.code === 200) { + const { download_url, filename, total_records } = response.data.data; + + // 方式1:直接打开下载链接 + window.open(download_url, '_blank'); + + // 方式2:使用 a 标签下载 + const link = document.createElement('a'); + link.href = download_url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + // 提示用户 + console.log(`成功导出 ${total_records} 条记录`); + } + } catch (error) { + console.error('导出失败:', error); + } +}; + +// 调用示例 +exportContacts({ + start_date: '2024-01-01', + end_date: '2024-12-31', + reply_status: '已回复' +}); +``` + +```javascript +// 使用 fetch +const exportContacts = async (params) => { + const queryString = new URLSearchParams(params).toString(); + + try { + const response = await fetch(`/api/contacts/export?${queryString}`, { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + const result = await response.json(); + + if (result.code === 200) { + const { download_url, filename, total_records } = result.data; + + // 直接下载文件 + const link = document.createElement('a'); + link.href = download_url; + link.download = filename; + link.click(); + + alert(`成功导出 ${total_records} 条记录`); + } + } catch (error) { + console.error('导出失败:', error); + } +}; +``` + +```vue + + + + +``` + +--- + +## 错误码说明 + +| 错误码 | 说明 | +|--------|------| +| 200 | 成功 | +| 400 | 请求参数错误 | +| 401 | 未登录或 token 无效 | +| 500 | 服务器内部错误 | + +## 注意事项 + +1. **时间筛选**: + - `start_date` 从当天 00:00:00 开始 + - `end_date` 到当天 23:59:59 结束 + - 时间筛选基于 `created_at` 字段(创建时间) + +2. **搜索功能**: + - `search` 参数支持模糊匹配 + - 同时搜索姓名、岗位、联系方式三个字段 + +3. **导出机制**: + - 导出接口返回下载链接,不直接返回文件流 + - 文件保存在服务器 `media/exports/` 目录 + - 文件名格式:`contacts_export_{时间戳}_{随机ID}.xlsx` + - 导出接口不分页,会导出所有符合条件的记录 + - 建议在导出大量数据时添加时间范围限制 + +4. **文件管理**: + - 导出的文件会保存在服务器上 + - 建议定期清理过期的导出文件 + - 可以通过定时任务删除超过一定时间的文件 + +5. **认证要求**: + - 所有接口都需要在 Header 中携带 `Authorization` token + - token 格式:`Bearer ` + +6. **下载链接**: + - 返回的 `download_url` 是相对路径 + - 前端需要拼接完整的服务器地址(如果跨域) + - 或直接使用相对路径(如果同域) diff --git a/requirements.txt b/requirements.txt index f7d6254..aca7913 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,6 +6,7 @@ uvicorn>=0.34.0 pydantic>=2.0.0 PyMySQL>=1.1.0 asgiref>=3.8.0 +openpyxl>=3.1.0 # ─── 客户端 GUI ─── PyQt5>=5.15.0 diff --git a/server/api/contacts.py b/server/api/contacts.py index fba4d2d..3cc14a5 100644 --- a/server/api/contacts.py +++ b/server/api/contacts.py @@ -3,16 +3,25 @@ 联系记录 API(需要登录): - GET /api/contacts -> 查询联系记录(支持分页、搜索) - GET /api/contacts/query -> 按电话号码或微信号查询(数据展示) +- GET /api/contacts/export -> 导出联系记录为 Excel 文件(返回下载链接) - POST /api/contacts -> 创建联系记录 - GET /api/contacts/{id} -> 查询单条记录 - PUT /api/contacts/{id} -> 更新记录 - DELETE /api/contacts/{id} -> 删除记录 """ +import os +import uuid +from datetime import datetime + from django.db.models import Q +from django.conf import settings from rest_framework import status from rest_framework.decorators import api_view +from openpyxl import Workbook +from openpyxl.styles import Font, Alignment, PatternFill + from server.core.response import api_success, api_error from server.models import ContactRecord from server.serializers import ContactRecordSerializer @@ -26,6 +35,8 @@ def contact_list(request): page_size = int(request.query_params.get("page_size", 10)) reply_status = request.query_params.get("reply_status") wechat = request.query_params.get("wechat_exchanged") + start_date = request.query_params.get("start_date") + end_date = request.query_params.get("end_date") qs = ContactRecord.objects.all() if search: @@ -38,6 +49,10 @@ def contact_list(request): qs = qs.filter(reply_status=reply_status) if wechat is not None: qs = qs.filter(wechat_exchanged=wechat.lower() in ("true", "1")) + if start_date: + qs = qs.filter(created_at__gte=start_date) + if end_date: + qs = qs.filter(created_at__lte=f"{end_date} 23:59:59") total = qs.count() start = (page - 1) * page_size @@ -84,6 +99,105 @@ def contact_query(request): }) +@api_view(["GET"]) +def contact_export(request): + """ + 导出联系记录为 Excel 文件。 + 支持与列表接口相同的筛选参数:search, reply_status, wechat_exchanged, start_date, end_date + 返回下载链接而不是直接返回文件流 + """ + search = request.query_params.get("search", "") + reply_status = request.query_params.get("reply_status") + wechat = request.query_params.get("wechat_exchanged") + start_date = request.query_params.get("start_date") + end_date = request.query_params.get("end_date") + + # 构建查询 + qs = ContactRecord.objects.all() + if search: + qs = qs.filter( + Q(name__icontains=search) | + Q(position__icontains=search) | + Q(contact__icontains=search) + ) + if reply_status: + qs = qs.filter(reply_status=reply_status) + if wechat is not None: + qs = qs.filter(wechat_exchanged=wechat.lower() in ("true", "1")) + if start_date: + qs = qs.filter(created_at__gte=start_date) + if end_date: + qs = qs.filter(created_at__lte=f"{end_date} 23:59:59") + + # 创建 Excel 工作簿 + wb = Workbook() + ws = wb.active + ws.title = "联系记录" + + # 设置表头样式 + header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid") + header_font = Font(bold=True, color="FFFFFF", size=11) + header_alignment = Alignment(horizontal="center", vertical="center") + + # 定义表头 + headers = ["ID", "姓名", "岗位", "联系方式", "回复状态", "是否交换微信", "Worker ID", "备注", "联系时间", "创建时间"] + ws.append(headers) + + # 应用表头样式 + for cell in ws[1]: + cell.fill = header_fill + cell.font = header_font + cell.alignment = header_alignment + + # 写入数据 + for record in qs.order_by("-created_at"): + ws.append([ + record.id, + record.name, + record.position, + record.contact, + record.reply_status, + "是" if record.wechat_exchanged else "否", + record.worker_id, + record.notes, + record.contacted_at.strftime("%Y-%m-%d %H:%M:%S") if record.contacted_at else "", + record.created_at.strftime("%Y-%m-%d %H:%M:%S"), + ]) + + # 调整列宽 + column_widths = [8, 12, 15, 18, 12, 15, 15, 30, 20, 20] + for i, width in enumerate(column_widths, start=1): + ws.column_dimensions[chr(64 + i)].width = width + + # 设置数据行样式 + for row in ws.iter_rows(min_row=2, max_row=ws.max_row): + for cell in row: + cell.alignment = Alignment(vertical="center") + + # 确保导出目录存在 + export_dir = settings.EXPORT_ROOT + os.makedirs(export_dir, exist_ok=True) + + # 生成唯一文件名 + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + unique_id = uuid.uuid4().hex[:8] + filename = f"contacts_export_{timestamp}_{unique_id}.xlsx" + filepath = os.path.join(export_dir, filename) + + # 保存文件 + wb.save(filepath) + + # 生成下载链接 + download_url = f"{settings.MEDIA_URL}exports/{filename}" + + return api_success({ + "download_url": download_url, + "filename": filename, + "total_records": qs.count(), + "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S") + }) + + @api_view(["GET", "PUT", "DELETE"]) def contact_detail(request, pk): try: diff --git a/server/settings.py b/server/settings.py index 54af651..7e9efd0 100644 --- a/server/settings.py +++ b/server/settings.py @@ -83,3 +83,13 @@ LANGUAGE_CODE = "zh-hans" TIME_ZONE = "Asia/Shanghai" USE_I18N = True USE_TZ = False + +# ─── 静态文件和媒体文件 ─── +STATIC_URL = "/static/" +STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles") + +MEDIA_URL = "/media/" +MEDIA_ROOT = os.path.join(BASE_DIR, "media") + +# 导出文件目录 +EXPORT_ROOT = os.path.join(MEDIA_ROOT, "exports") diff --git a/server/urls.py b/server/urls.py index 23d33db..c6729db 100644 --- a/server/urls.py +++ b/server/urls.py @@ -1,10 +1,11 @@ -# -*- coding: utf-8 -*- """ Django URL 路由配置。 """ -from django.urls import path +from django.urls import path, re_path +from django.conf import settings +from django.views.static import serve -from server.api import auth, accounts, tasks, workers, filters, scripts, contacts, stats, settings +from server.api import auth, accounts, tasks, workers, filters, scripts, contacts, stats, settings as api_settings urlpatterns = [ # ─── 健康检查 ─── @@ -42,6 +43,7 @@ urlpatterns = [ # ─── 联系记录 ─── path("api/contacts", contacts.contact_list), path("api/contacts/query", contacts.contact_query), + path("api/contacts/export", contacts.contact_export), path("api/contacts/", contacts.contact_detail), # ─── 统计分析 ─── @@ -49,6 +51,9 @@ urlpatterns = [ path("api/stats/daily", stats.stats_daily), # ─── 系统配置 ─── - path("api/settings", settings.settings_list), - path("api/settings/", settings.settings_detail), + path("api/settings", api_settings.settings_list), + path("api/settings/", api_settings.settings_detail), + + # ─── 媒体文件服务 ─── + re_path(r'^media/(?P.*)$', serve, {'document_root': settings.MEDIA_ROOT}), ]