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}),
]