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(() => {
+
+
-
+
BUSINESS OPPORTUNITY
+
- 客户单位
-
+ 客户单位 *
+
+
+
+
未找到「{{ customerSearch }}」
+
+
+
+
+
+
+
+ 客户经理
+
+
@@ -95,10 +210,43 @@ async function handleSubmit() {
-
+
+
+
+
+
+
+
跟进记录
+
+
暂无跟进记录
+
+
{{ logMethodIcons[l.method] || '📋' }}
+
+
+
+
+
+
+
+
+
+
+
@@ -167,4 +315,46 @@ async function handleSubmit() {
animation: spin 0.6s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
+.quick-create-btn { display: inline-flex; align-items: center; gap: 4px; background: none; border: 1px dashed var(--gold); padding: 8px 14px; color: var(--gold-dark); font-family: var(--font-body); font-size: 13px; cursor: pointer; transition: all 0.2s; letter-spacing: 0.03em; }
+.quick-create-btn:hover { border-color: var(--vermilion); color: var(--vermilion); background: rgba(184,71,46,0.03); }
+.select-empty-create { padding: 8px 12px; text-align: center; }
+.form-actions { display: flex; gap: 10px; margin-top: 24px; }
+.submit-btn { flex: 1; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 16px; background: var(--ink); color: #fff; border: none; font-family: var(--font-heading); font-size: 16px; letter-spacing: 0.08em; cursor: pointer; transition: all 0.25s; }
+.submit-btn:hover { background: var(--ink-light); }
+.submit-btn:active { transform: scale(0.98); }
+.submit-btn:disabled { opacity: 0.6; cursor: not-allowed; }
+.delete-btn { padding: 16px 24px; background: var(--surface); color: var(--vermilion); border: 1px solid var(--vermilion); font-family: var(--font-heading); font-size: 16px; letter-spacing: 0.08em; cursor: pointer; transition: all 0.25s; }
+.delete-btn:hover { background: var(--vermilion); color: #fff; }
+.header-delete-btn { margin-left: auto; margin-top: 4px; padding: 6px 14px; background: none; color: var(--vermilion); border: 1px solid var(--vermilion); font-family: var(--font-body); font-size: 13px; cursor: pointer; transition: all 0.2s; white-space: nowrap; }
+.header-delete-btn:hover { background: var(--vermilion); color: #fff; }
+.btn-loading { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.6s linear infinite; }
+@keyframes spin { to { transform: rotate(360deg); } }
+.required-star { color: var(--vermilion); font-weight: 700; }
+
+/* ── Follow-up Log Section ── */
+.log-section { margin-top: 20px; border-top: 1px solid var(--warm-border); padding-top: 16px; }
+.section-head { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; }
+.section-title { font-family: var(--font-heading); font-size: 18px; color: var(--ink); letter-spacing: 0.06em; white-space: nowrap; }
+.section-line { flex: 1; height: 2px; background: var(--gold); }
+.log-list { display: flex; flex-direction: column; gap: 8px; margin-bottom: 14px; max-height: 260px; overflow-y: auto; }
+.log-empty { text-align: center; color: var(--warm-gray); font-size: 13px; padding: 16px 0; }
+.log-item { display: flex; gap: 8px; align-items: flex-start; padding: 10px; background: var(--surface); border: 1px solid var(--warm-border); }
+.log-icon { font-size: 16px; flex-shrink: 0; margin-top: 2px; }
+.log-body { flex: 1; min-width: 0; }
+.log-header { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; }
+.log-date { font-family: var(--font-mono); font-size: 12px; color: var(--ink); }
+.log-author { font-size: 11px; color: var(--warm-gray); margin-left: auto; }
+.log-content { font-size: 13px; color: var(--c-text); line-height: 1.5; }
+.log-del { flex-shrink: 0; background: none; border: none; color: var(--warm-gray); cursor: pointer; font-size: 14px; padding: 2px; }
+.log-del:hover { color: var(--vermilion); }
+.log-chip { border-radius: 2px; white-space: nowrap; }
+.log-add { border-top: 1px solid var(--warm-border); padding-top: 12px; }
+.log-add-row { display: flex; gap: 6px; align-items: center; }
+.log-chips { display: flex; gap: 3px; }
+.log-chip-btn { padding: 4px 8px; border: 1px solid var(--warm-border); background: var(--surface); font-size: 11px; cursor: pointer; transition: all 0.2s; }
+.log-chip-btn:hover { border-color: var(--ink); }
+.log-chip-btn--active { font-weight: 600; }
+.log-submit { width: 100%; margin-top: 8px; padding: 10px; background: var(--ink); color: #fff; border: none; font-family: var(--font-heading); font-size: 14px; letter-spacing: 0.06em; cursor: pointer; }
+.log-submit:hover { background: var(--ink-light); }
+.log-submit:disabled { opacity: 0.6; cursor: not-allowed; }
diff --git a/frontend/src/views/mobile/NotesList.vue b/frontend/src/views/mobile/NotesList.vue
new file mode 100644
index 0000000..ae99a12
--- /dev/null
+++ b/frontend/src/views/mobile/NotesList.vue
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+
+
+ 搜索
+
+
+
+
+
+
+
+
+
{{ item.content || '暂无内容' }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/mobile/PlansList.vue b/frontend/src/views/mobile/PlansList.vue
new file mode 100644
index 0000000..503a9fe
--- /dev/null
+++ b/frontend/src/views/mobile/PlansList.vue
@@ -0,0 +1,108 @@
+
+
+
+
+
+
+
+
+
+
+ 搜索
+
+
+
+
+
+
+
+
+
{{ item.plan_content }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/mobile/VisitForm.vue b/frontend/src/views/mobile/VisitForm.vue
index 0eac292..a94821b 100644
--- a/frontend/src/views/mobile/VisitForm.vue
+++ b/frontend/src/views/mobile/VisitForm.vue
@@ -30,6 +30,7 @@ const form = ref({
customer_demand: '',
companions: [] as string[],
photos: [] as string[],
+ manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || ''),
})
const timeRangeValue = ref(null)
@@ -67,6 +68,7 @@ onMounted(async () => {
customer_demand: v.customer_demand || '',
companions: (v.companions || []).map(String),
photos: v.photos || [],
+ manager_id: v.manager_id || auth.userId || '',
}
uploadedPhotos.value = v.photos || []
// Parse time range back into picker
@@ -128,7 +130,8 @@ async function handlePhotoUpload(event: Event) {
try {
// Compress before upload to reduce storage & transfer
const compressed = await compressImage(file, { maxPixels: 1920, quality: 0.8 })
- const res = await uploadApi.uploadImage(compressed)
+ const res = await uploadApi.getPresignedUrl(compressed.name, compressed.type || 'image/jpeg')
+ await uploadApi.uploadFile(res.data.upload_url, compressed)
uploadedPhotos.value.push(res.data.object_key)
form.value.photos = [...uploadedPhotos.value]
} catch (e: any) {
@@ -223,7 +226,7 @@ async function handleDelete() {
- 客户单位
+ 客户单位 *
+
+
+
未找到「{{ customerSearch }}」
+
+
+
+
+
+
+
+ 客户经理
+
+
-
-
-
@@ -288,7 +301,7 @@ async function handleDelete() {
- 相关人员 可输入外部人员
+ 相关人员 同访人周报中也会显示此记录
@@ -493,6 +506,7 @@ async function handleDelete() {
color: var(--vermilion);
background: rgba(184,71,46,0.03);
}
+.select-empty-create { padding: 8px 12px; text-align: center; }
/* ═══ Photo Area ═══ */
.photo-area {
@@ -645,4 +659,5 @@ async function handleDelete() {
.method--电话.method-chip--active { background: #5B7FA5; border-color: #5B7FA5; color: #fff; }
.method--微信.method-chip--active { background: #22c55e; border-color: #22c55e; color: #fff; }
.method--出差.method-chip--active { background: #C4934A; border-color: #C4934A; color: #fff; }
+.required-star { color: var(--vermilion); font-weight: 700; }
diff --git a/frontend/src/views/mobile/VisitsList.vue b/frontend/src/views/mobile/VisitsList.vue
new file mode 100644
index 0000000..ca69042
--- /dev/null
+++ b/frontend/src/views/mobile/VisitsList.vue
@@ -0,0 +1,96 @@
+
+
+
+
+
+
+
+
+ 搜索
+
+
+
+
+
+
+
+
+
{{ item.communication_content || '暂无沟通内容' }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/mobile/WorkCenter.vue b/frontend/src/views/mobile/WorkCenter.vue
new file mode 100644
index 0000000..0806e8c
--- /dev/null
+++ b/frontend/src/views/mobile/WorkCenter.vue
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+
+ {{ m.icon }}
+ {{ m.label }}
+ {{ m.count() }} 条
+
+
+
+
+
+
diff --git a/frontend/src/views/mobile/WorkPlanForm.vue b/frontend/src/views/mobile/WorkPlanForm.vue
index af6f4cf..6b27595 100644
--- a/frontend/src/views/mobile/WorkPlanForm.vue
+++ b/frontend/src/views/mobile/WorkPlanForm.vue
@@ -1,23 +1,47 @@
@@ -51,17 +112,31 @@ async function handleSubmit() {
-
+
WORK PLAN
+
- 客户单位
-
+ 客户单位 *
+
+
+
+
未找到「{{ customerSearch }}」
+
+
+
+
+
+
+
+ 客户经理
+
+
@@ -71,14 +146,16 @@ async function handleSubmit() {
- 计划拜访时间
+ 计划拜访时间 *
-
+
+
+
@@ -147,4 +224,19 @@ async function handleSubmit() {
animation: spin 0.6s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
+.quick-create-btn { display: inline-flex; align-items: center; gap: 4px; background: none; border: 1px dashed var(--gold); padding: 8px 14px; color: var(--gold-dark); font-family: var(--font-body); font-size: 13px; cursor: pointer; transition: all 0.2s; letter-spacing: 0.03em; }
+.quick-create-btn:hover { border-color: var(--vermilion); color: var(--vermilion); background: rgba(184,71,46,0.03); }
+.select-empty-create { padding: 8px 12px; text-align: center; }
+.form-actions { display: flex; gap: 10px; margin-top: 24px; }
+.submit-btn { flex: 1; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 16px; background: var(--ink); color: #fff; border: none; font-family: var(--font-heading); font-size: 16px; letter-spacing: 0.08em; cursor: pointer; transition: all 0.25s; }
+.submit-btn:hover { background: var(--ink-light); }
+.submit-btn:active { transform: scale(0.98); }
+.submit-btn:disabled { opacity: 0.6; cursor: not-allowed; }
+.delete-btn { padding: 16px 24px; background: var(--surface); color: var(--vermilion); border: 1px solid var(--vermilion); font-family: var(--font-heading); font-size: 16px; letter-spacing: 0.08em; cursor: pointer; transition: all 0.25s; }
+.delete-btn:hover { background: var(--vermilion); color: #fff; }
+.header-delete-btn { margin-left: auto; margin-top: 4px; padding: 6px 14px; background: none; color: var(--vermilion); border: 1px solid var(--vermilion); font-family: var(--font-body); font-size: 13px; cursor: pointer; transition: all 0.2s; white-space: nowrap; }
+.header-delete-btn:hover { background: var(--vermilion); color: #fff; }
+.btn-loading { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.6s linear infinite; }
+@keyframes spin { to { transform: rotate(360deg); } }
+.required-star { color: var(--vermilion); font-weight: 700; }