From efc200b6debaad3948c7717e2148ee13cc4643c1 Mon Sep 17 00:00:00 2001 From: v6ole Date: Sun, 12 Jul 2026 16:21:38 +0800 Subject: [PATCH 01/65] =?UTF-8?q?feat:=20=E5=B7=A5=E4=BD=9C=E8=AE=A1?= =?UTF-8?q?=E5=88=92=E7=8A=B6=E6=80=81=E8=87=AA=E5=8A=A8=E6=B5=81=E8=BD=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 抽取 auto_complete_work_plans() 可复用函数到 visits service - POST/PUT 拜访均触发自动完成(更新场景解决补录问题) - Excel 导入旧周报时也触发自动完成 - 逾期计划自动取消:plan_date < today 且无匹配拜访→已取消 - edit_log 记录区分三种来源:拜访自动完成/拜访更新自动完成/旧周报导入自动完成/逾期自动取消 Co-Authored-By: Claude --- backend/app/api/visits.py | 23 +++++++++------- backend/app/services/excel_import.py | 7 +++++ backend/app/services/scheduler.py | 24 +++++++++++++++++ backend/app/services/visits.py | 40 ++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 10 deletions(-) create mode 100644 backend/app/services/visits.py diff --git a/backend/app/api/visits.py b/backend/app/api/visits.py index d38ad12..001388c 100644 --- a/backend/app/api/visits.py +++ b/backend/app/api/visits.py @@ -161,17 +161,11 @@ async def create_visit( db.add(customer) # Auto-complete matching work plans for this customer - from app.models.work_plan import WorkPlan - plans_result = await db.execute( - select(WorkPlan).where( - WorkPlan.customer_id == data.customer_id, - WorkPlan.status == "计划中", - WorkPlan.plan_date <= parse_date(data.visit_date), - ) + from app.services.visits import auto_complete_work_plans + await auto_complete_work_plans( + db, data.customer_id, parse_date(data.visit_date), + current_user["name"], "拜访自动完成", ) - for plan in plans_result.scalars().all(): - plan.status = "已完成" - append_entry(plan, current_user["name"], [{"field": "status", "from": "计划中", "to": "已完成", "reason": "拜访自动完成"}]) # Create draft copies for companions for companion_id in data.companions: @@ -237,6 +231,15 @@ async def update_visit( await db.commit() await db.refresh(visit) + + # Auto-complete matching work plans after visit update + from app.services.visits import auto_complete_work_plans + await auto_complete_work_plans( + db, visit.customer_id, visit.visit_date, + current_user["name"], "拜访更新自动完成", + ) + await db.commit() + return await _enrich_visit(visit, db) diff --git a/backend/app/services/excel_import.py b/backend/app/services/excel_import.py index 5cad36b..646c7d5 100644 --- a/backend/app/services/excel_import.py +++ b/backend/app/services/excel_import.py @@ -203,6 +203,13 @@ async def import_from_excel(db: AsyncSession, file_bytes: bytes, manager_id: uui db.add(visit) stats["visits"] += 1 + # Auto-complete matching work plans + from app.services.visits import auto_complete_work_plans + await auto_complete_work_plans( + db, customer_id, visit_date, + mgr_name, "旧周报导入自动完成", + ) + # Update customer's last_visit_date for light board cust = await db.get(Customer, customer_id) if cust and (not cust.last_visit_date or visit_date > cust.last_visit_date): diff --git a/backend/app/services/scheduler.py b/backend/app/services/scheduler.py index 33e192c..98d8374 100644 --- a/backend/app/services/scheduler.py +++ b/backend/app/services/scheduler.py @@ -168,9 +168,33 @@ async def check_overdue_plans(db: AsyncSession) -> dict: await wecom_client.send_text_message([user.wecom_userid], content) + # ── Auto-cancel overdue plans that have no matching visit ── + from app.utils.edit_log import append_entry as append_edit_log + auto_cancelled = 0 + for plan in overdue: + has_visit = await db.execute( + select(Visit).where( + Visit.customer_id == plan.customer_id, + Visit.visit_date >= plan.plan_date, + ) + ) + if not has_visit.scalar(): + plan.status = "已取消" + append_edit_log(plan, "系统", [{ + "field": "status", + "from": "计划中", + "to": "已取消", + "reason": "逾期自动取消", + }]) + auto_cancelled += 1 + + if auto_cancelled: + await db.commit() + return { "status": "ok", "date": str(today), "overdue": len(overdue), + "auto_cancelled": auto_cancelled, "managers_affected": len(by_manager), } diff --git a/backend/app/services/visits.py b/backend/app/services/visits.py new file mode 100644 index 0000000..9808483 --- /dev/null +++ b/backend/app/services/visits.py @@ -0,0 +1,40 @@ +"""Visit service — shared logic for visit creation, update, and import.""" + +from uuid import UUID +from datetime import date +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from app.models.work_plan import WorkPlan +from app.utils.edit_log import append_entry + + +async def auto_complete_work_plans( + db: AsyncSession, + customer_id: UUID, + visit_date: date, + editor_name: str, + reason: str = "拜访自动完成", +) -> int: + """Auto-complete matching work plans when a visit is created/updated/imported. + + Matches by: same customer_id + status=="计划中" + plan_date <= visit_date. + Returns the number of plans completed. + """ + plans_result = await db.execute( + select(WorkPlan).where( + WorkPlan.customer_id == customer_id, + WorkPlan.status == "计划中", + WorkPlan.plan_date <= visit_date, + ) + ) + count = 0 + for plan in plans_result.scalars().all(): + plan.status = "已完成" + append_entry(plan, editor_name, [{ + "field": "status", + "from": "计划中", + "to": "已完成", + "reason": reason, + }]) + count += 1 + return count From 750ce8d8eb55a6f89391d901876abe281d1c5752 Mon Sep 17 00:00:00 2001 From: v6ole Date: Sun, 12 Jul 2026 16:25:35 +0800 Subject: [PATCH 02/65] =?UTF-8?q?feat:=20=E4=BA=AE=E7=81=AF=E8=A1=A8?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=AE=A2=E6=88=B7=E7=BB=8F=E7=90=86=E6=8C=87?= =?UTF-8?q?=E6=A0=87=E6=96=87=E5=AD=97=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 每个客户经理折叠栏新增一行指标摘要: 「已拜访X · 临期X · 未拜访X · 完成率X%」 数字加粗,完成率按阈值着色(>=80%绿 >=50%金 <50%红) Co-Authored-By: Claude --- frontend/src/views/desktop/LightBoard.vue | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/frontend/src/views/desktop/LightBoard.vue b/frontend/src/views/desktop/LightBoard.vue index eabf611..47e9c32 100644 --- a/frontend/src/views/desktop/LightBoard.vue +++ b/frontend/src/views/desktop/LightBoard.vue @@ -141,6 +141,12 @@ const statusLabel: Record = { green: '近30天已拜访', yellow {{ expandedManagers.has(m.manager_id) ? '▼' : '▶' }} {{ m.manager_name }} {{ m.total_customers }} 个客户 + + 已拜访{{ m.visited_this_month }} · + 临期{{ m.visited_last_month_only }} · + 未拜访{{ m.not_visited_2months }} · + 完成率{{ (m.coverage_rate * 100).toFixed(0) }}% +
{{ m.visited_this_month }} @@ -304,6 +310,8 @@ const statusLabel: Record = { green: '近30天已拜访', yellow .manager-name { font-family: var(--font-heading); font-size: 15px; color: var(--ink); letter-spacing: 0.04em; cursor: pointer; } .manager-name:hover { color: var(--gold); } .manager-count { font-family: var(--font-body); font-size: 12px; color: var(--warm-gray); } +.manager-metrics { font-family: var(--font-body); font-size: 12px; color: var(--warm-gray); white-space: nowrap; } +.manager-metrics strong { font-weight: 600; color: var(--ink); margin: 0 1px; } .manager-lights { display: flex; gap: 6px; } .light-dot { display: inline-flex; align-items: center; justify-content: center; min-width: 26px; height: 26px; border-radius: 4px; font-family: var(--font-mono); font-size: 12px; font-weight: 600; cursor: help; } .light-dot--green { background: #EDF2EC; color: var(--sage); } From be26ea5588d1241af45dd78be75243f93ab3c056 Mon Sep 17 00:00:00 2001 From: v6ole Date: Sun, 12 Jul 2026 16:35:00 +0800 Subject: [PATCH 03/65] =?UTF-8?q?fix:=20=E4=BA=AE=E7=81=AF=E8=A1=A8?= =?UTF-8?q?=E6=8C=87=E6=A0=87=E7=A7=BB=E9=99=A4=E4=B8=B4=E6=9C=9F=E8=AE=A1?= =?UTF-8?q?=E6=95=B0=EF=BC=8C=E7=B2=BE=E7=AE=80=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- frontend/src/views/desktop/LightBoard.vue | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/views/desktop/LightBoard.vue b/frontend/src/views/desktop/LightBoard.vue index 47e9c32..ac1cae6 100644 --- a/frontend/src/views/desktop/LightBoard.vue +++ b/frontend/src/views/desktop/LightBoard.vue @@ -143,7 +143,6 @@ const statusLabel: Record = { green: '近30天已拜访', yellow {{ m.total_customers }} 个客户 已拜访{{ m.visited_this_month }} · - 临期{{ m.visited_last_month_only }} · 未拜访{{ m.not_visited_2months }} · 完成率{{ (m.coverage_rate * 100).toFixed(0) }}% From 5682c12085fca24c03f7f6291025f063de7e4dcb Mon Sep 17 00:00:00 2001 From: v6ole Date: Sun, 12 Jul 2026 18:00:35 +0800 Subject: [PATCH 04/65] =?UTF-8?q?feat:=20=E6=A1=8C=E9=9D=A2=E7=AB=AF?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E9=A1=B5=E9=9D=A2=E6=94=AF=E6=8C=81=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E6=97=B6=E7=9B=B4=E6=8E=A5=E6=96=B0=E5=BB=BA=E5=AE=A2?= =?UTF-8?q?=E6=88=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WorkPlans/MiniBusiness/KeyVisits/ManagerWorkspace 四个页面新增快速新建客户功能 - 客户搜索框输入时,若无匹配结果,直接显示「+ 新建客户」按钮 - 点击即调用 POST /customers/quick-create 创建并自动选中 - 与移动端 VisitForm 保持一致体验 Co-Authored-By: Claude --- frontend/src/views/desktop/KeyVisits.vue | 31 ++++++++++++++++++- .../src/views/desktop/ManagerWorkspace.vue | 31 ++++++++++++++++++- frontend/src/views/desktop/MiniBusiness.vue | 31 ++++++++++++++++++- frontend/src/views/desktop/WorkPlans.vue | 31 ++++++++++++++++++- 4 files changed, 120 insertions(+), 4 deletions(-) diff --git a/frontend/src/views/desktop/KeyVisits.vue b/frontend/src/views/desktop/KeyVisits.vue index 3bfb9a4..23490a7 100644 --- a/frontend/src/views/desktop/KeyVisits.vue +++ b/frontend/src/views/desktop/KeyVisits.vue @@ -88,6 +88,8 @@ async function loadUsers() { } catch (_) {} } +const customerSearch = ref('') + async function loadCustomers(q?: string) { try { const params: any = { page_size: 500 } @@ -97,6 +99,28 @@ async function loadCustomers(q?: string) { } catch (_) {} } +async function handleCustomerSearch(query: string) { + customerSearch.value = query + if (query) { await loadCustomers(query) } + else { await loadCustomers() } +} + +async function handleQuickCreate() { + const name = customerSearch.value.trim() + if (!name) { ElMessage.warning('请输入单位名称'); return } + try { + const res = await api.post('/customers/quick-create', null, { params: { name } }) + const newCust = res.data + customers.value.unshift(newCust) + form.value.customer_id = newCust.id + customerSearch.value = '' + ElMessage.success(`已创建客户:${name}`) + await loadCustomers() + } catch (e: any) { + ElMessage.error('创建失败: ' + (e.response?.data?.detail || e.message)) + } +} + async function loadItems() { loading.value = true try { @@ -270,9 +294,12 @@ async function quickStatusChange(row: any, newStatus: string) { - + +
+ +
@@ -329,4 +356,6 @@ async function quickStatusChange(row: any, newStatus: string) { .filter-item { display: flex; align-items: center; gap: 8px; } .filter-label { font-size: 13px; color: var(--warm-gray); font-family: var(--font-body); white-space: nowrap; } .filter-count { font-size: 12px; color: var(--c-text-muted); font-family: var(--font-mono); margin-left: auto; } +.quick-create-btn { display: inline-flex; align-items: center; gap: 4px; background: none; border: 1px dashed var(--gold); padding: 6px 12px; color: var(--gold-dark); font-family: var(--font-body); font-size: 12px; cursor: pointer; transition: all 0.2s; } +.quick-create-btn:hover { border-color: var(--vermilion); color: var(--vermilion); background: rgba(184,71,46,0.03); } diff --git a/frontend/src/views/desktop/ManagerWorkspace.vue b/frontend/src/views/desktop/ManagerWorkspace.vue index ceba295..abe08ab 100644 --- a/frontend/src/views/desktop/ManagerWorkspace.vue +++ b/frontend/src/views/desktop/ManagerWorkspace.vue @@ -53,6 +53,8 @@ function typeLabel(t: string) { return labels[t] || '' } +const customerSearch = ref('') + async function loadCustomers(q?: string) { try { const params: any = { page_size: 100 } @@ -62,6 +64,28 @@ async function loadCustomers(q?: string) { } catch (_) {} } +async function handleCustomerSearch(query: string) { + customerSearch.value = query + if (query) { await loadCustomers(query) } + else { await loadCustomers() } +} + +async function handleQuickCreate() { + const name = customerSearch.value.trim() + if (!name) { ElMessage.warning('请输入单位名称'); return } + try { + const res = await api.post('/customers/quick-create', null, { params: { name } }) + const newCust = res.data + customers.value.unshift(newCust) + form.value.customer_id = newCust.id + customerSearch.value = '' + ElMessage.success(`已创建客户:${name}`) + await loadCustomers() + } catch (e: any) { + ElMessage.error('创建失败: ' + (e.response?.data?.detail || e.message)) + } +} + async function loadAll() { loading.value = true try { @@ -446,9 +470,12 @@ const notesByDate = computed(() => { - + +
+ +
@@ -542,4 +569,6 @@ const notesByDate = computed(() => { .date-group { margin-bottom: 20px; } .date-title { display: flex; align-items: center; margin: 12px 0 8px; font-family: var(--font-heading); font-size: 14px; color: var(--ink); letter-spacing: 0.04em; } .empty { text-align: center; color: var(--c-text-muted); padding: 40px 0; font-family: var(--font-body); } +.quick-create-btn { display: inline-flex; align-items: center; gap: 4px; background: none; border: 1px dashed var(--gold); padding: 6px 12px; color: var(--gold-dark); font-family: var(--font-body); font-size: 12px; cursor: pointer; transition: all 0.2s; } +.quick-create-btn:hover { border-color: var(--vermilion); color: var(--vermilion); background: rgba(184,71,46,0.03); } diff --git a/frontend/src/views/desktop/MiniBusiness.vue b/frontend/src/views/desktop/MiniBusiness.vue index 0f7df0a..2418741 100644 --- a/frontend/src/views/desktop/MiniBusiness.vue +++ b/frontend/src/views/desktop/MiniBusiness.vue @@ -66,6 +66,8 @@ onMounted(async () => { await Promise.all([loadItems(), loadCustomers(), loadManagerColors()]) }) +const customerSearch = ref('') + async function loadCustomers(q?: string) { try { const params: any = { page_size: 500 } @@ -75,6 +77,28 @@ async function loadCustomers(q?: string) { } catch (_) {} } +async function handleCustomerSearch(query: string) { + customerSearch.value = query + if (query) { await loadCustomers(query) } + else { await loadCustomers() } +} + +async function handleQuickCreate() { + const name = customerSearch.value.trim() + if (!name) { ElMessage.warning('请输入单位名称'); return } + try { + const res = await api.post('/customers/quick-create', null, { params: { name } }) + const newCust = res.data + customers.value.unshift(newCust) + form.value.customer_id = newCust.id + customerSearch.value = '' + ElMessage.success(`已创建客户:${name}`) + await loadCustomers() + } catch (e: any) { + ElMessage.error('创建失败: ' + (e.response?.data?.detail || e.message)) + } +} + async function loadItems() { loading.value = true try { @@ -237,9 +261,12 @@ async function quickStatusChange(row: any, newStatus: string) { - + +
+ +
@@ -284,4 +311,6 @@ async function quickStatusChange(row: any, newStatus: string) { .filter-item { display: flex; align-items: center; gap: 8px; } .filter-label { font-size: 13px; color: var(--warm-gray); font-family: var(--font-body); white-space: nowrap; } .filter-count { font-size: 12px; color: var(--c-text-muted); font-family: var(--font-mono); margin-left: auto; } +.quick-create-btn { display: inline-flex; align-items: center; gap: 4px; background: none; border: 1px dashed var(--gold); padding: 6px 12px; color: var(--gold-dark); font-family: var(--font-body); font-size: 12px; cursor: pointer; transition: all 0.2s; } +.quick-create-btn:hover { border-color: var(--vermilion); color: var(--vermilion); background: rgba(184,71,46,0.03); } diff --git a/frontend/src/views/desktop/WorkPlans.vue b/frontend/src/views/desktop/WorkPlans.vue index 55771db..1a6bc13 100644 --- a/frontend/src/views/desktop/WorkPlans.vue +++ b/frontend/src/views/desktop/WorkPlans.vue @@ -67,6 +67,8 @@ onMounted(async () => { await Promise.all([loadPlans(), loadCustomers(), loadManagerColors()]) }) +const customerSearch = ref('') + async function loadCustomers(q?: string) { try { const params: any = { page_size: 500 } @@ -76,6 +78,28 @@ async function loadCustomers(q?: string) { } catch (_) {} } +async function handleCustomerSearch(query: string) { + customerSearch.value = query + if (query) { await loadCustomers(query) } + else { await loadCustomers() } +} + +async function handleQuickCreate() { + const name = customerSearch.value.trim() + if (!name) { ElMessage.warning('请输入单位名称'); return } + try { + const res = await api.post('/customers/quick-create', null, { params: { name } }) + const newCust = res.data + customers.value.unshift(newCust) + form.value.customer_id = newCust.id + customerSearch.value = '' + ElMessage.success(`已创建客户:${name}`) + await loadCustomers() + } catch (e: any) { + ElMessage.error('创建失败: ' + (e.response?.data?.detail || e.message)) + } +} + async function loadPlans() { loading.value = true try { @@ -236,9 +260,12 @@ async function quickStatusChange(row: any, newStatus: string) { - + +
+ +
@@ -285,4 +312,6 @@ async function quickStatusChange(row: any, newStatus: string) { .filter-item { display: flex; align-items: center; gap: 8px; } .filter-label { font-size: 13px; color: var(--warm-gray); font-family: var(--font-body); white-space: nowrap; } .filter-count { font-size: 12px; color: var(--c-text-muted); font-family: var(--font-mono); margin-left: auto; } +.quick-create-btn { display: inline-flex; align-items: center; gap: 4px; background: none; border: 1px dashed var(--gold); padding: 6px 12px; color: var(--gold-dark); font-family: var(--font-body); font-size: 12px; cursor: pointer; transition: all 0.2s; } +.quick-create-btn:hover { border-color: var(--vermilion); color: var(--vermilion); background: rgba(184,71,46,0.03); } From c489a8d6fd4d30da8b0ac6715a8cfff427b0df08 Mon Sep 17 00:00:00 2001 From: v6ole Date: Sun, 12 Jul 2026 18:04:45 +0800 Subject: [PATCH 05/65] =?UTF-8?q?fix:=20=E6=90=9C=E7=B4=A2=E6=97=A0?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E6=97=B6=E6=96=B0=E5=BB=BA=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E6=94=B9=E4=B8=BAel-select=E7=9A=84empty=E6=8F=92=E6=A7=BD?= =?UTF-8?q?=EF=BC=8C=E9=81=BF=E5=85=8D=E8=A2=AB=E9=81=AE=E6=8C=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将「新建客户」按钮从 el-select 下方的独立 div 移入 #empty 插槽 - 搜索无匹配时,下拉面板直接显示「未找到xxx」+ 新建按钮 - 不再被原生「无数据」空状态遮挡 - 移动端+桌面端五个页面统一修复 Co-Authored-By: Claude --- frontend/src/views/desktop/KeyVisits.vue | 10 ++++++--- .../src/views/desktop/ManagerWorkspace.vue | 10 ++++++--- frontend/src/views/desktop/MiniBusiness.vue | 10 ++++++--- frontend/src/views/desktop/WorkPlans.vue | 10 ++++++--- frontend/src/views/mobile/VisitForm.vue | 22 +++++++++++-------- 5 files changed, 41 insertions(+), 21 deletions(-) diff --git a/frontend/src/views/desktop/KeyVisits.vue b/frontend/src/views/desktop/KeyVisits.vue index 23490a7..98ade7f 100644 --- a/frontend/src/views/desktop/KeyVisits.vue +++ b/frontend/src/views/desktop/KeyVisits.vue @@ -296,10 +296,13 @@ async function quickStatusChange(row: any, newStatus: string) { + -
- -
@@ -358,4 +361,5 @@ async function quickStatusChange(row: any, newStatus: string) { .filter-count { font-size: 12px; color: var(--c-text-muted); font-family: var(--font-mono); margin-left: auto; } .quick-create-btn { display: inline-flex; align-items: center; gap: 4px; background: none; border: 1px dashed var(--gold); padding: 6px 12px; color: var(--gold-dark); font-family: var(--font-body); font-size: 12px; cursor: pointer; transition: all 0.2s; } .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; } diff --git a/frontend/src/views/desktop/ManagerWorkspace.vue b/frontend/src/views/desktop/ManagerWorkspace.vue index abe08ab..e51b4e3 100644 --- a/frontend/src/views/desktop/ManagerWorkspace.vue +++ b/frontend/src/views/desktop/ManagerWorkspace.vue @@ -472,10 +472,13 @@ const notesByDate = computed(() => { + -
- -
@@ -571,4 +574,5 @@ const notesByDate = computed(() => { .empty { text-align: center; color: var(--c-text-muted); padding: 40px 0; font-family: var(--font-body); } .quick-create-btn { display: inline-flex; align-items: center; gap: 4px; background: none; border: 1px dashed var(--gold); padding: 6px 12px; color: var(--gold-dark); font-family: var(--font-body); font-size: 12px; cursor: pointer; transition: all 0.2s; } .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; } diff --git a/frontend/src/views/desktop/MiniBusiness.vue b/frontend/src/views/desktop/MiniBusiness.vue index 2418741..1dd849c 100644 --- a/frontend/src/views/desktop/MiniBusiness.vue +++ b/frontend/src/views/desktop/MiniBusiness.vue @@ -263,10 +263,13 @@ async function quickStatusChange(row: any, newStatus: string) { + -
- -
@@ -313,4 +316,5 @@ async function quickStatusChange(row: any, newStatus: string) { .filter-count { font-size: 12px; color: var(--c-text-muted); font-family: var(--font-mono); margin-left: auto; } .quick-create-btn { display: inline-flex; align-items: center; gap: 4px; background: none; border: 1px dashed var(--gold); padding: 6px 12px; color: var(--gold-dark); font-family: var(--font-body); font-size: 12px; cursor: pointer; transition: all 0.2s; } .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; } diff --git a/frontend/src/views/desktop/WorkPlans.vue b/frontend/src/views/desktop/WorkPlans.vue index 1a6bc13..0e326ae 100644 --- a/frontend/src/views/desktop/WorkPlans.vue +++ b/frontend/src/views/desktop/WorkPlans.vue @@ -262,10 +262,13 @@ async function quickStatusChange(row: any, newStatus: string) { + -
- -
@@ -314,4 +317,5 @@ async function quickStatusChange(row: any, newStatus: string) { .filter-count { font-size: 12px; color: var(--c-text-muted); font-family: var(--font-mono); margin-left: auto; } .quick-create-btn { display: inline-flex; align-items: center; gap: 4px; background: none; border: 1px dashed var(--gold); padding: 6px 12px; color: var(--gold-dark); font-family: var(--font-body); font-size: 12px; cursor: pointer; transition: all 0.2s; } .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; } diff --git a/frontend/src/views/mobile/VisitForm.vue b/frontend/src/views/mobile/VisitForm.vue index d93f0b6..d8f025e 100644 --- a/frontend/src/views/mobile/VisitForm.vue +++ b/frontend/src/views/mobile/VisitForm.vue @@ -235,16 +235,19 @@ async function handleDelete() { style="width:100%" > + -
- -
@@ -494,6 +497,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 { From 4453ee2ca04dfb078c47c1929fce58a8e455c942 Mon Sep 17 00:00:00 2001 From: v6ole Date: Sun, 12 Jul 2026 18:19:05 +0800 Subject: [PATCH 06/65] =?UTF-8?q?feat:=20=E6=94=AF=E5=B1=80=E9=95=BF/?= =?UTF-8?q?=E5=88=86=E7=AE=A1=E9=A2=86=E5=AF=BC=E5=8F=AF=E4=B8=BA=E4=BB=BB?= =?UTF-8?q?=E6=84=8F=E5=AE=A2=E6=88=B7=E7=BB=8F=E7=90=86=E4=BB=A3=E5=A1=AB?= =?UTF-8?q?=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - 5个模块的 Create/Update schema 新增可选 manager_id 字段 - POST 端点: director/leader 可指定 manager_id,manager 角色自动用自身 - PUT 端点: director/leader 可修改 manager_id,manager 角色禁止修改 前端(移动端+桌面端共10个页面): - 支局长/领导可见「客户经理」下拉选择框 - 创建和编辑时均可指定记录归属的客户经理 - 涵盖今日拜访、今日纪要、工作计划、商机跟单、要客拜访五个模块 Co-Authored-By: Claude --- backend/app/api/daily_notes.py | 4 +++- backend/app/api/key_visits.py | 4 +++- backend/app/api/mini_business.py | 4 +++- backend/app/api/visits.py | 4 +++- backend/app/api/work_plans.py | 4 +++- backend/app/schemas/daily_note.py | 2 ++ backend/app/schemas/key_visit.py | 2 ++ backend/app/schemas/mini_business.py | 2 ++ backend/app/schemas/visit.py | 2 ++ backend/app/schemas/work_plan.py | 2 ++ frontend/src/views/desktop/KeyVisits.vue | 11 +++++++++- .../src/views/desktop/ManagerWorkspace.vue | 21 ++++++++++++++----- frontend/src/views/desktop/MiniBusiness.vue | 11 +++++++++- frontend/src/views/desktop/WorkPlans.vue | 11 +++++++++- frontend/src/views/mobile/DailyNoteForm.vue | 15 +++++++++++++ frontend/src/views/mobile/KeyVisitForm.vue | 14 +++++++++++++ .../src/views/mobile/MiniBusinessForm.vue | 19 ++++++++++++++++- frontend/src/views/mobile/VisitForm.vue | 9 ++++++++ frontend/src/views/mobile/WorkPlanForm.vue | 19 ++++++++++++++++- 19 files changed, 145 insertions(+), 15 deletions(-) diff --git a/backend/app/api/daily_notes.py b/backend/app/api/daily_notes.py index bf5f227..4f836d1 100644 --- a/backend/app/api/daily_notes.py +++ b/backend/app/api/daily_notes.py @@ -80,7 +80,7 @@ async def create_note( db: AsyncSession = Depends(get_db), ): note = DailyNote( - manager_id=uuid.UUID(current_user["user_id"]), + manager_id=data.manager_id if (current_user["role"] in ("director", "leader") and data.manager_id) else uuid.UUID(current_user["user_id"]), note_date=parse_date(data.note_date), category=data.category, content=data.content, @@ -108,6 +108,8 @@ async def update_note( old_snapshot = {"note_date": str(note.note_date), "category": note.category, "content": note.content, "time_range": note.time_range} update_data = data.model_dump(exclude_unset=True) + if current_user["role"] not in ("director", "leader"): + update_data.pop("manager_id", None) if "note_date" in update_data and update_data["note_date"]: update_data["note_date"] = parse_date(update_data["note_date"]) for k, v in update_data.items(): diff --git a/backend/app/api/key_visits.py b/backend/app/api/key_visits.py index 2544343..8a98a76 100644 --- a/backend/app/api/key_visits.py +++ b/backend/app/api/key_visits.py @@ -63,7 +63,7 @@ async def create_key_visit( planned_date=data.planned_date, planned_visitor=data.planned_visitor, visit_target=data.visit_target, - manager_id=uuid.UUID(current_user["user_id"]), + manager_id=data.manager_id if (current_user["role"] in ("director", "leader") and data.manager_id) else uuid.UUID(current_user["user_id"]), ) init_entry(k, current_user["name"]) db.add(k) @@ -87,6 +87,8 @@ async def update_key_visit( old_snapshot = {"customer_id": str(k.customer_id), "urgency_level": k.urgency_level, "description": k.description, "progress_status": k.progress_status, "planned_date": k.planned_date, "planned_visitor": k.planned_visitor, "visit_target": k.visit_target} update_data = data.model_dump(exclude_unset=True) + if current_user["role"] not in ("director", "leader"): + update_data.pop("manager_id", None) for key, v in update_data.items(): setattr(k, key, v) new_snapshot = {"customer_id": str(k.customer_id), "urgency_level": k.urgency_level, "description": k.description, "progress_status": k.progress_status, "planned_date": k.planned_date, "planned_visitor": k.planned_visitor, "visit_target": k.visit_target} diff --git a/backend/app/api/mini_business.py b/backend/app/api/mini_business.py index 7cb9fc5..390ac04 100644 --- a/backend/app/api/mini_business.py +++ b/backend/app/api/mini_business.py @@ -60,7 +60,7 @@ async def create_mini_business( amount=data.amount, follow_up_detail=data.follow_up_detail, status=data.status, - manager_id=uuid.UUID(current_user["user_id"]), + manager_id=data.manager_id if (current_user["role"] in ("director", "leader") and data.manager_id) else uuid.UUID(current_user["user_id"]), expected_revenue_date=data.expected_revenue_date, ) init_entry(m, current_user["name"]) @@ -85,6 +85,8 @@ async def update_mini_business( old_snapshot = {"customer_id": str(m.customer_id), "product_type": m.product_type, "amount": m.amount, "follow_up_detail": m.follow_up_detail, "status": m.status, "expected_revenue_date": m.expected_revenue_date} update_data = data.model_dump(exclude_unset=True) + if current_user["role"] not in ("director", "leader"): + update_data.pop("manager_id", None) for k, v in update_data.items(): setattr(m, k, v) new_snapshot = {"customer_id": str(m.customer_id), "product_type": m.product_type, "amount": m.amount, "follow_up_detail": m.follow_up_detail, "status": m.status, "expected_revenue_date": m.expected_revenue_date} diff --git a/backend/app/api/visits.py b/backend/app/api/visits.py index 001388c..f02ab82 100644 --- a/backend/app/api/visits.py +++ b/backend/app/api/visits.py @@ -147,7 +147,7 @@ async def create_visit( companions=data.companions, companion_names=data.companion_names, photos=data.photos, - manager_id=uuid.UUID(current_user["user_id"]), + manager_id=data.manager_id if (current_user["role"] in ("director", "leader") and data.manager_id) else uuid.UUID(current_user["user_id"]), ) init_entry(visit, current_user["name"]) db.add(visit) @@ -212,6 +212,8 @@ async def update_visit( } update_data = data.model_dump(exclude_unset=True) + if current_user["role"] not in ("director", "leader"): + update_data.pop("manager_id", None) if "visit_date" in update_data and update_data["visit_date"]: update_data["visit_date"] = parse_date(update_data["visit_date"]) diff --git a/backend/app/api/work_plans.py b/backend/app/api/work_plans.py index 269e42a..b8075e3 100644 --- a/backend/app/api/work_plans.py +++ b/backend/app/api/work_plans.py @@ -58,7 +58,7 @@ async def create_work_plan( customer_id=data.customer_id, plan_content=data.plan_content, plan_date=parse_date(data.plan_date), - manager_id=uuid.UUID(current_user["user_id"]), + manager_id=data.manager_id if (current_user["role"] in ("director", "leader") and data.manager_id) else uuid.UUID(current_user["user_id"]), status=data.status, ) init_entry(wp, current_user["name"]) @@ -83,6 +83,8 @@ async def update_work_plan( old_snapshot = {"customer_id": str(wp.customer_id), "plan_content": wp.plan_content, "plan_date": str(wp.plan_date), "status": wp.status} update_data = data.model_dump(exclude_unset=True) + if current_user["role"] not in ("director", "leader"): + update_data.pop("manager_id", None) if "plan_date" in update_data and update_data["plan_date"]: update_data["plan_date"] = parse_date(update_data["plan_date"]) for k, v in update_data.items(): diff --git a/backend/app/schemas/daily_note.py b/backend/app/schemas/daily_note.py index 1664ada..3ce960b 100644 --- a/backend/app/schemas/daily_note.py +++ b/backend/app/schemas/daily_note.py @@ -9,6 +9,7 @@ class DailyNoteCreate(BaseModel): category: str = "其他" content: str = "" time_range: str = "" + manager_id: Optional[uuid.UUID] = None class DailyNoteUpdate(BaseModel): @@ -16,6 +17,7 @@ class DailyNoteUpdate(BaseModel): category: Optional[str] = None content: Optional[str] = None time_range: Optional[str] = None + manager_id: Optional[uuid.UUID] = None class DailyNoteOut(BaseModel): diff --git a/backend/app/schemas/key_visit.py b/backend/app/schemas/key_visit.py index c7d7454..91063cc 100644 --- a/backend/app/schemas/key_visit.py +++ b/backend/app/schemas/key_visit.py @@ -11,6 +11,7 @@ class KeyVisitCreate(BaseModel): planned_date: str = "" planned_visitor: str = "" visit_target: str = "" + manager_id: Optional[uuid.UUID] = None class KeyVisitUpdate(BaseModel): @@ -21,6 +22,7 @@ class KeyVisitUpdate(BaseModel): planned_date: Optional[str] = None planned_visitor: Optional[str] = None visit_target: Optional[str] = None + manager_id: Optional[uuid.UUID] = None class KeyVisitOut(BaseModel): diff --git a/backend/app/schemas/mini_business.py b/backend/app/schemas/mini_business.py index a0719aa..0da30e7 100644 --- a/backend/app/schemas/mini_business.py +++ b/backend/app/schemas/mini_business.py @@ -10,6 +10,7 @@ class MiniBusinessCreate(BaseModel): follow_up_detail: str = "" status: str = "跟进中" expected_revenue_date: str = "" + manager_id: Optional[uuid.UUID] = None class MiniBusinessUpdate(BaseModel): @@ -19,6 +20,7 @@ class MiniBusinessUpdate(BaseModel): follow_up_detail: Optional[str] = None status: Optional[str] = None expected_revenue_date: Optional[str] = None + manager_id: Optional[uuid.UUID] = None class MiniBusinessOut(BaseModel): diff --git a/backend/app/schemas/visit.py b/backend/app/schemas/visit.py index 7984e37..3db7d1b 100644 --- a/backend/app/schemas/visit.py +++ b/backend/app/schemas/visit.py @@ -16,6 +16,7 @@ class VisitCreate(BaseModel): companions: list[uuid.UUID] = [] companion_names: list[str] = [] photos: list[str] = [] + manager_id: Optional[uuid.UUID] = None # director/leader can specify class VisitUpdate(BaseModel): @@ -28,6 +29,7 @@ class VisitUpdate(BaseModel): communication_content: Optional[str] = None customer_demand: Optional[str] = None companions: Optional[list[uuid.UUID]] = None + manager_id: Optional[uuid.UUID] = None companion_names: Optional[list[str]] = None photos: Optional[list[str]] = None diff --git a/backend/app/schemas/work_plan.py b/backend/app/schemas/work_plan.py index 1816798..5b10799 100644 --- a/backend/app/schemas/work_plan.py +++ b/backend/app/schemas/work_plan.py @@ -9,6 +9,7 @@ class WorkPlanCreate(BaseModel): plan_content: str = "" plan_date: str # "YYYY-MM-DD" status: str = "计划中" + manager_id: Optional[uuid.UUID] = None class WorkPlanUpdate(BaseModel): @@ -16,6 +17,7 @@ class WorkPlanUpdate(BaseModel): plan_content: Optional[str] = None plan_date: Optional[str] = None status: Optional[str] = None + manager_id: Optional[uuid.UUID] = None class WorkPlanOut(BaseModel): diff --git a/frontend/src/views/desktop/KeyVisits.vue b/frontend/src/views/desktop/KeyVisits.vue index 98ade7f..914a179 100644 --- a/frontend/src/views/desktop/KeyVisits.vue +++ b/frontend/src/views/desktop/KeyVisits.vue @@ -15,6 +15,7 @@ const loading = ref(false) const isLight = computed(() => themeStore.currentTheme === 'light') const keyVisits = ref([]) const customers = ref([]) +const allManagers = ref([]) const shotRef = ref(null) const { capturing, captureEl } = useScreenshot() const allUsers = ref([]) @@ -79,6 +80,9 @@ const managerSummary = computed(() => { onMounted(async () => { await Promise.all([loadItems(), loadCustomers(), loadUsers(), loadManagerColors()]) + if (auth.isDirector || auth.isLeader) { + api.get('/users/', { params: { role: 'manager' } }).then(r => { allManagers.value = r.data }).catch(() => {}) + } }) async function loadUsers() { @@ -132,7 +136,7 @@ async function loadItems() { function openCreate() { dialogMode.value = 'create' - form.value = { customer_id: '', urgency_level: '一般', description: '', progress_status: '未开始', planned_date: '', planned_visitor: '', visit_target: '' } + form.value = { customer_id: '', urgency_level: '一般', description: '', progress_status: '未开始', planned_date: '', planned_visitor: '', visit_target: '', manager_id: auth.userId || '' } plannedVisitors.value = [] dialogVisible.value = true } @@ -304,6 +308,11 @@ async function quickStatusChange(row: any, newStatus: string) { + + + + +
([]) const visits = ref([]) const workPlans = ref([]) @@ -39,6 +42,9 @@ const keyStatuses = ['未开始', '进行中', '已完成'] onMounted(async () => { await Promise.all([loadAll(), loadCustomers(), loadUsers()]) + if (auth.isDirector || auth.isLeader) { + api.get('/users/', { params: { role: 'manager' } }).then(r => { allManagers.value = r.data }).catch(() => {}) + } }) async function loadUsers() { @@ -117,11 +123,11 @@ async function loadAll() { function openCreate(type: string) { dialogMode.value = 'create'; dialogType.value = type dialogTimeRange.value = null - if (type === 'visit') form.value = { customer_id: '', visit_date: todayStr(), visit_method: '上门', time_range: '', visitor_name: '', visitor_phone: '', communication_content: '', customer_demand: '', companions: [], companion_names: [] } - else if (type === 'note') form.value = { note_date: todayStr(), category: '其他', content: '', time_range: '' } - else if (type === 'plan') form.value = { customer_id: '', plan_content: '', plan_date: todayStr(), status: '计划中' } - else if (type === 'mini') form.value = { customer_id: '', product_type: '', amount: '', follow_up_detail: '', status: '跟进中', expected_revenue_date: '' } - else if (type === 'key') { form.value = { customer_id: '', urgency_level: '一般', description: '', progress_status: '未开始', planned_date: '', planned_visitor: '', visit_target: '' }; plannedVisitors.value = [] } + if (type === 'visit') form.value = { customer_id: '', visit_date: todayStr(), visit_method: '上门', time_range: '', visitor_name: '', visitor_phone: '', communication_content: '', customer_demand: '', companions: [], companion_names: [], manager_id: auth.userId || '' } + else if (type === 'note') form.value = { note_date: todayStr(), category: '其他', content: '', time_range: '', manager_id: auth.userId || '' } + else if (type === 'plan') form.value = { customer_id: '', plan_content: '', plan_date: todayStr(), status: '计划中', manager_id: auth.userId || '' } + else if (type === 'mini') form.value = { customer_id: '', product_type: '', amount: '', follow_up_detail: '', status: '跟进中', expected_revenue_date: '', manager_id: auth.userId || '' } + else if (type === 'key') { form.value = { customer_id: '', urgency_level: '一般', description: '', progress_status: '未开始', planned_date: '', planned_visitor: '', visit_target: '', manager_id: auth.userId || '' }; plannedVisitors.value = [] } dialogVisible.value = true } @@ -480,6 +486,11 @@ const notesByDate = computed(() => { + + + + + diff --git a/frontend/src/views/desktop/MiniBusiness.vue b/frontend/src/views/desktop/MiniBusiness.vue index 1dd849c..fbd7364 100644 --- a/frontend/src/views/desktop/MiniBusiness.vue +++ b/frontend/src/views/desktop/MiniBusiness.vue @@ -15,6 +15,7 @@ const loading = ref(false) const isLight = computed(() => themeStore.currentTheme === 'light') const miniBusiness = ref([]) const customers = ref([]) +const allManagers = ref([]) const shotRef = ref(null) const { capturing, captureEl } = useScreenshot() @@ -64,6 +65,9 @@ const managerSummary = computed(() => { onMounted(async () => { await Promise.all([loadItems(), loadCustomers(), loadManagerColors()]) + if (auth.isDirector || auth.isLeader) { + api.get('/users/', { params: { role: 'manager' } }).then(r => { allManagers.value = r.data }).catch(() => {}) + } }) const customerSearch = ref('') @@ -110,7 +114,7 @@ async function loadItems() { function openCreate() { dialogMode.value = 'create' - form.value = { customer_id: '', product_type: '', amount: '', follow_up_detail: '', status: '跟进中', expected_revenue_date: '' } + form.value = { customer_id: '', product_type: '', amount: '', follow_up_detail: '', status: '跟进中', expected_revenue_date: '', manager_id: auth.userId || '' } dialogVisible.value = true } @@ -271,6 +275,11 @@ async function quickStatusChange(row: any, newStatus: string) { + + + + + diff --git a/frontend/src/views/desktop/WorkPlans.vue b/frontend/src/views/desktop/WorkPlans.vue index 0e326ae..cae02ff 100644 --- a/frontend/src/views/desktop/WorkPlans.vue +++ b/frontend/src/views/desktop/WorkPlans.vue @@ -14,6 +14,7 @@ const themeStore = useThemeStore() const loading = ref(false) const workPlans = ref([]) const customers = ref([]) +const allManagers = ref([]) const shotRef = ref(null) const { capturing, captureEl } = useScreenshot() @@ -65,6 +66,9 @@ const managerSummary = computed(() => { onMounted(async () => { await Promise.all([loadPlans(), loadCustomers(), loadManagerColors()]) + if (auth.isDirector || auth.isLeader) { + api.get('/users/', { params: { role: 'manager' } }).then(r => { allManagers.value = r.data }).catch(() => {}) + } }) const customerSearch = ref('') @@ -111,7 +115,7 @@ async function loadPlans() { function openCreate() { dialogMode.value = 'create' - form.value = { customer_id: '', plan_content: '', plan_date: todayStr(), status: '计划中' } + form.value = { customer_id: '', plan_content: '', plan_date: todayStr(), status: '计划中', manager_id: auth.userId || '' } dialogVisible.value = true } @@ -270,6 +274,11 @@ async function quickStatusChange(row: any, newStatus: string) { + + + + + diff --git a/frontend/src/views/mobile/DailyNoteForm.vue b/frontend/src/views/mobile/DailyNoteForm.vue index 6346515..e4c9b9f 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.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 (_) {} @@ -105,6 +113,13 @@ async function handleDelete() { + + + + + + +