From 9b5805447e2a24491bc95a169a0d127a04d8eb30 Mon Sep 17 00:00:00 2001 From: v6ole Date: Thu, 30 Jul 2026 09:55:08 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E5=8D=8F=E5=90=8C=E6=8B=9C=E8=AE=BF?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E5=8E=BB=E9=87=8D=E8=AE=BE=E8=AE=A1=E6=96=B9?= =?UTF-8?q?=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- .../specs/2026-07-30-visit-dedup-design.md | 156 +++++++++++++ frontend/src/components/MobileLayout.vue | 25 +- frontend/src/views/mobile/DailyNoteForm.vue | 21 +- frontend/src/views/mobile/Home.vue | 32 --- frontend/src/views/mobile/KeyVisitForm.vue | 112 +++++++-- frontend/src/views/mobile/KeyVisitsList.vue | 110 +++++++++ frontend/src/views/mobile/MiniBizList.vue | 111 +++++++++ .../src/views/mobile/MiniBusinessForm.vue | 220 ++++++++++++++++-- frontend/src/views/mobile/NotesList.vue | 98 ++++++++ frontend/src/views/mobile/PlansList.vue | 108 +++++++++ frontend/src/views/mobile/VisitForm.vue | 39 +++- frontend/src/views/mobile/VisitsList.vue | 96 ++++++++ frontend/src/views/mobile/WorkCenter.vue | 84 +++++++ frontend/src/views/mobile/WorkPlanForm.vue | 126 ++++++++-- 14 files changed, 1230 insertions(+), 108 deletions(-) create mode 100644 docs/superpowers/specs/2026-07-30-visit-dedup-design.md create mode 100644 frontend/src/views/mobile/KeyVisitsList.vue create mode 100644 frontend/src/views/mobile/MiniBizList.vue create mode 100644 frontend/src/views/mobile/NotesList.vue create mode 100644 frontend/src/views/mobile/PlansList.vue create mode 100644 frontend/src/views/mobile/VisitsList.vue create mode 100644 frontend/src/views/mobile/WorkCenter.vue diff --git a/docs/superpowers/specs/2026-07-30-visit-dedup-design.md b/docs/superpowers/specs/2026-07-30-visit-dedup-design.md new file mode 100644 index 0000000..a835c8d --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-visit-dedup-design.md @@ -0,0 +1,156 @@ +# 协同拜访记录去重 — 设计方案 + +## 背景 + +当前机制:客户经理 A 创建拜访记录并选择协同人员 B 时,后端为 B 生成一条空内容的草稿副本。B 填写后,同一客户、同一天出现两条独立拜访记录,导致周报重复展示、亮灯表重复计数。 + +## 设计目标 + +1. 周报中同一客户同一次拜访合并展示为一张卡片 +2. 亮灯表不重复计数 +3. 每个参与人的填报进度不受影响(各自计入) +4. 每个参与人能独立编辑自己的副本 +5. 历史数据兼容,不做强制迁移 + +## 方案概要 + +### 1. 数据库:新增 `visit_group_id` + +```sql +ALTER TABLE visits ADD COLUMN visit_group_id UUID; +CREATE INDEX ix_visits_visit_group_id ON visits(visit_group_id); +``` + +- 单人拜访:`visit_group_id = NULL` +- 多人协同拜访:同一次拜访的所有记录共享同一个 `visit_group_id` + +### 2. 后端:POST /visits/ 改造 + +**原逻辑:** 为每个协同人生成一条空内容草稿(`communication_content=""`, `photos=[]`, `companions=[]`) + +**新逻辑:** 为每个协同人生成一条完整副本: + +| 副本字段 | 值 | 说明 | +|----------|-----|------| +| `customer_id` | 同主记录 | | +| `visit_date` | 同主记录 | | +| `visit_method` | 同主记录 | | +| `time_range` | 同主记录 | | +| `visitor_name` | 同主记录 | 实际拜访人不变 | +| `visitor_phone` | 同主记录 | | +| `communication_content` | 原内容 + `(协同XXX)` | 如:`了解项目进度...(协同韦佶秀)` | +| `customer_demand` | 同主记录 | | +| `companions` | `[主记录创建人]` | 互换:协同人看到主访人 | +| `companion_names` | 同主记录 | | +| `photos` | 同主记录 | | +| `manager_id` | 协同人的 UUID | 归属到协同人名下 | +| `visit_group_id` | 同主记录 | **同一 UUID,关联所有副本** | + +**前端表现(创建人韦佶秀视角):** + +``` +相关人员选择了 [韦伦] + → 保存后韦伦收到一条完整拜访记录 + → 韦伦记录内容末尾自动追加"(协同韦佶秀)" +``` + +### 3. 后端:PUT /visits/{id} 不变 + +协同人编辑自己的副本时不影响主记录,各自独立。 + +### 4. 仪表盘统计:按 visit_group_id 去重 + +`get_dashboard_stats` 中的 `week_visits` 改为: + +```python +# 原:count 所有 visit 行 +select count(distinct coalesce(visit_group_id, id)) +from visits +where visit_date between monday and sunday +``` + +每个 visit_group_id 只算一次,跳过 NULL(单人拜访用 id 做 fallback key)。 + +### 5. 填报进度:不变 + +`get_reporting_progress` 按 `manager_id` 分组计数,每个参与人各算一次,不受去重影响。 + +### 6. 亮灯表:按 (customer_id, visit_date, visit_group_id) 去重 + +同一客户同一天同一次拜访只亮一盏灯。 + +### 7. 周报展示:按 visit_group_id 合并 + +`get_weekly_report` 查询时,同一个 `visit_group_id` 的记录合并成一条输出: + +```json +{ + "customer_name": "污水处理厂", + "visit_date": "2026-07-30", + "visit_method": "上门", + "time_range": "09:00-10:30", + "visit_group_id": "abc-123", + "participants": [ + { + "manager_name": "韦佶秀", + "manager_id": "uuid-1", + "role": "primary", + "communication_content": "了解污水处理新项目...", + "customer_demand": "专线扩容至100M...", + "photos": ["photo_001", "photo_002"] + }, + { + "manager_name": "韦伦", + "manager_id": "uuid-2", + "role": "companion", + "communication_content": "了解污水处理新项目...(协同韦佶秀)", + "customer_demand": "专线扩容至100M...", + "photos": ["photo_001", "photo_002"] + } + ] +} +``` + +**前端周报卡片展示:** + +``` +┌─────────────────────────────────────────────┐ +│ 污水处理厂 2026-07-30 上门 │ +│ 09:00-10:30 │ +│ │ +│ ▸ 韦佶秀(主访) │ +│ 了解污水处理新项目建设进度,客户计划新增... │ +│ 客户需求:专线扩容至100M,新增视频监控... │ +│ 📷 2张 │ +│ │ +│ ▸ 韦伦(协同) │ +│ 了解污水处理新项目建设进度...(协同韦佶秀) │ +│ 客户需求:专线扩容至100M... │ +│ 📷 2张 │ +└─────────────────────────────────────────────┘ +``` + +### 8. 前端:协同记录编辑提示 + +协同人打开自己的副本编辑时,沟通内容区域上方显示一行提示: + +> 💡 此记录为协同韦佶秀拜访「污水处理厂」的副本。你可以补充或修改内容,不会影响主记录。 + +## 改动清单 + +| 层 | 文件 | 改动 | +|----|------|------| +| DB | migration | `ALTER TABLE visits ADD visit_group_id UUID` | +| 后端 | `models/visit.py` | 新增 `visit_group_id` 字段 | +| 后端 | `schemas/visit.py` | `VisitOut` 新增 `visit_group_id` | +| 后端 | `api/visits.py` POST | 副本改为完整复制 + `(协同XXX)` 后缀 + 互换 companions + 设置 visit_group_id | +| 后端 | `services/dashboard.py` | `get_dashboard_stats` 的 week_visits 改为 distinct 去重 | +| 后端 | `services/dashboard.py` | `get_weekly_report` 合并同 visit_group_id 记录 | +| 后端 | `services/light_board.py` | 亮灯计数去重 | +| 前端 | 周报 `WeeklyReport.vue` | 拜访卡片支持 participants 展开/折叠 | +| 前端 | 移动端 `VisitForm.vue` | 协同记录编辑时显示提示条 | + +## 兼容性 + +- 历史数据 `visit_group_id = NULL`,行为与现在完全一致 +- 前端 `participants` 字段回退:单 participant 时展示与现在无差异 diff --git a/frontend/src/components/MobileLayout.vue b/frontend/src/components/MobileLayout.vue index 944d253..70f91f0 100644 --- a/frontend/src/components/MobileLayout.vue +++ b/frontend/src/components/MobileLayout.vue @@ -8,25 +8,15 @@ const router = useRouter() const auth = useAuthStore() const tabs = [ - { - path: '/m', - label: '首页', - icon: 'home', - }, - { - path: '/m/visit/new', - label: '拜访', - icon: 'visit', - }, - { - path: '/m/note/new', - label: '纪要', - icon: 'note', - }, + { path: '/m', label: '首页', icon: 'home' }, + { path: '/m/work', label: '工作', icon: 'work' }, + { path: '/m/visit/new', label: '拜访', icon: 'visit' }, + { path: '/m/note/new', label: '纪要', icon: 'note' }, ] const activeTab = computed(() => { if (route.path === '/m') return '/m' + if (route.path === '/m/work' || route.path.startsWith('/m/plans') || route.path.startsWith('/m/mini-biz') || route.path.startsWith('/m/key-visits') || route.path.startsWith('/m/leaves') || route.path.startsWith('/m/leave')) return '/m/work' if (route.path.includes('/visit')) return '/m/visit/new' if (route.path.includes('/note')) return '/m/note/new' return route.path @@ -76,6 +66,11 @@ const activeTab = computed(() => { + + + + + diff --git a/frontend/src/views/mobile/DailyNoteForm.vue b/frontend/src/views/mobile/DailyNoteForm.vue index 6346515..5645d29 100644 --- a/frontend/src/views/mobile/DailyNoteForm.vue +++ b/frontend/src/views/mobile/DailyNoteForm.vue @@ -3,13 +3,16 @@ import { ref, onMounted, computed } from 'vue' import { useRouter, useRoute } from 'vue-router' import { ElMessage, ElMessageBox } from 'element-plus' import { todayStr } from '@/utils' +import { useAuthStore } from '@/stores/auth' import api from '@/api/index' import EditLogPanel from '@/components/EditLogPanel.vue' const router = useRouter() const route = useRoute() +const auth = useAuthStore() const isEdit = computed(() => !!route.params.id) const submitLoading = ref(false) +const allManagers = ref([]) const categories = ['行政事务', '合同整理', '发票处理', '内部会议', '培训学习', '其他'] @@ -24,6 +27,7 @@ const form = ref({ category: '其他', content: '', time_range: '', + manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || ''), }) function onTimeRangeChange(val: [string, string] | null) { @@ -36,6 +40,9 @@ function parseTimeRange(str: string): [string, string] | null { } onMounted(async () => { + if (auth.isDirector || auth.isLeader) { + try { const res = await api.get('/users/', { params: { role: 'manager' } }); allManagers.value = res.data } catch (_) {} + } if (isEdit.value) { try { const res = await api.get(`/daily-notes/${route.params.id}`) @@ -45,6 +52,7 @@ onMounted(async () => { category: n.category, content: n.content || '', time_range: n.time_range || '', + manager_id: n.manager_id || auth.userId || '', } timeRangeValue.value = parseTimeRange(n.time_range) } catch (_) {} @@ -52,6 +60,7 @@ onMounted(async () => { }) async function handleSubmit() { + if (!form.value.note_date) { ElMessage.warning('请选择日期'); return } if (!form.value.content.trim()) { ElMessage.warning('请输入纪要内容'); return } submitLoading.value = true try { @@ -100,11 +109,18 @@ async function handleDelete() { 日期 + 日期 * + + + + + + +