feat: 三项改进 — require_report + 拜访联动 + 解绑移到弹窗

1. 用户管理页 UX 优化:
   - 表格内「解绑」按钮移入编辑弹窗(clearable 输入框)
   - 新增「需要填写周报」el-switch 开关
   - 新增「填报」列显示是否计入统计

2. require_report 字段 (DB + API + 联动):
   - users 表新增 require_report BOOLEAN DEFAULT TRUE
   - 调度器/仪表盘进度/立即检查 均排除 require_report=false
   - UserOut schema + UpdateUserRoleRequest 同步更新

3. 拜访联动客户 last_visit:
   - customers 表新增 last_visit_date / last_visit_manager_id
   - 创建拜访记录时自动更新对应客户的最后拜访信息
   - 亮灯表可据此精确判断客户拜访状态

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-26 16:36:27 +08:00
parent 989c41da8c
commit a580eefd14
9 changed files with 54 additions and 26 deletions
+3
View File
@@ -15,6 +15,7 @@ router = APIRouter(prefix="/users", tags=["Users"])
class UpdateUserRoleRequest(BaseModel): class UpdateUserRoleRequest(BaseModel):
role: str # manager / director / leader role: str # manager / director / leader
department: str = "" department: str = ""
require_report: bool = True
@router.get("/", response_model=list[UserOut]) @router.get("/", response_model=list[UserOut])
@@ -71,6 +72,7 @@ async def update_user_role(
user.role = data.role user.role = data.role
if data.department: if data.department:
user.department = data.department user.department = data.department
user.require_report = data.require_report
await db.commit() await db.commit()
await db.refresh(user) await db.refresh(user)
@@ -79,6 +81,7 @@ async def update_user_role(
"name": user.name, "name": user.name,
"role": user.role, "role": user.role,
"department": user.department, "department": user.department,
"require_report": user.require_report,
} }
+8
View File
@@ -143,6 +143,14 @@ async def create_visit(
init_entry(visit, current_user["name"]) init_entry(visit, current_user["name"])
db.add(visit) db.add(visit)
# Update customer's last visit tracking
cust_result = await db.execute(select(Customer).where(Customer.id == data.customer_id))
customer = cust_result.scalar_one_or_none()
if customer:
customer.last_visit_date = parse_date(data.visit_date)
customer.last_visit_manager_id = uuid.UUID(current_user["user_id"])
db.add(customer)
# Create draft copies for companions # Create draft copies for companions
for companion_id in data.companions: for companion_id in data.companions:
if companion_id != uuid.UUID(current_user["user_id"]): if companion_id != uuid.UUID(current_user["user_id"]):
+10
View File
@@ -38,6 +38,16 @@ async def lifespan(app: FastAPI):
await conn.run_sync(lambda c, t=tbl: c.exec_driver_sql( await conn.run_sync(lambda c, t=tbl: c.exec_driver_sql(
f"ALTER TABLE {t} ADD COLUMN IF NOT EXISTS edit_log JSONB DEFAULT '[]'" f"ALTER TABLE {t} ADD COLUMN IF NOT EXISTS edit_log JSONB DEFAULT '[]'"
)) ))
# New columns for v0.3
await conn.run_sync(lambda c: c.exec_driver_sql(
"ALTER TABLE users ADD COLUMN IF NOT EXISTS require_report BOOLEAN DEFAULT TRUE"
))
await conn.run_sync(lambda c: c.exec_driver_sql(
"ALTER TABLE customers ADD COLUMN IF NOT EXISTS last_visit_date DATE"
))
await conn.run_sync(lambda c: c.exec_driver_sql(
"ALTER TABLE customers ADD COLUMN IF NOT EXISTS last_visit_manager_id UUID"
))
# Start daily reporting scheduler (17:30 CST = 09:30 UTC) # Start daily reporting scheduler (17:30 CST = 09:30 UTC)
_scheduler.add_job(_scheduled_check, "cron", hour=17, minute=30, id="daily_check") _scheduler.add_job(_scheduled_check, "cron", hour=17, minute=30, id="daily_check")
+4 -2
View File
@@ -1,6 +1,6 @@
import uuid import uuid
from datetime import datetime from datetime import date, datetime
from sqlalchemy import String, Text, DateTime, func, ForeignKey from sqlalchemy import String, Text, Date, DateTime, Boolean, func, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from app.database import Base from app.database import Base
@@ -19,6 +19,8 @@ class Customer(Base):
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id")) created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
last_visit_date: Mapped[date | None] = mapped_column(Date, nullable=True)
last_visit_manager_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
contacts: Mapped[list["CustomerContact"]] = relationship("CustomerContact", back_populates="customer", cascade="all, delete-orphan") contacts: Mapped[list["CustomerContact"]] = relationship("CustomerContact", back_populates="customer", cascade="all, delete-orphan")
assignments: Mapped[list["CustomerAssignment"]] = relationship("CustomerAssignment", back_populates="customer", cascade="all, delete-orphan") assignments: Mapped[list["CustomerAssignment"]] = relationship("CustomerAssignment", back_populates="customer", cascade="all, delete-orphan")
+1
View File
@@ -15,4 +15,5 @@ class User(Base):
role: Mapped[str] = mapped_column(String(30)) # manager / director / leader role: Mapped[str] = mapped_column(String(30)) # manager / director / leader
department: Mapped[str] = mapped_column(String(100), default="") department: Mapped[str] = mapped_column(String(100), default="")
wecom_userid: Mapped[str | None] = mapped_column(String(100), unique=True, nullable=True) wecom_userid: Mapped[str | None] = mapped_column(String(100), unique=True, nullable=True)
require_report: Mapped[bool] = mapped_column(default=True, server_default="true")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+1
View File
@@ -19,6 +19,7 @@ class UserOut(BaseModel):
role: str role: str
department: str department: str
wecom_userid: Optional[str] = None wecom_userid: Optional[str] = None
require_report: bool = True
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
+1 -1
View File
@@ -55,7 +55,7 @@ async def get_reporting_progress(db: AsyncSession, reference_date: date | None =
"""Get per-manager reporting progress. Managers only see themselves.""" """Get per-manager reporting progress. Managers only see themselves."""
monday, sunday = get_week_range(reference_date) monday, sunday = get_week_range(reference_date)
managers_result = await db.execute(select(User).where(User.role == "manager")) managers_result = await db.execute(select(User).where(User.role == "manager", User.require_report == True))
all_managers = managers_result.scalars().all() all_managers = managers_result.scalars().all()
# Filter: managers only see themselves # Filter: managers only see themselves
managers = all_managers if role in ("director", "leader") else [m for m in all_managers if str(m.id) == user_id] managers = all_managers if role in ("director", "leader") else [m for m in all_managers if str(m.id) == user_id]
+2 -2
View File
@@ -15,9 +15,9 @@ async def check_daily_reporting(db: AsyncSession) -> dict:
if weekday >= 5: # Skip weekends if weekday >= 5: # Skip weekends
return {"status": "weekend", "date": str(today)} return {"status": "weekend", "date": str(today)}
# Get all managers (stats include everyone) # Get all managers who need to report
result = await db.execute( result = await db.execute(
select(User).where(User.role == "manager") select(User).where(User.role == "manager", User.require_report == True)
) )
managers = result.scalars().all() managers = result.scalars().all()
+24 -21
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage } from 'element-plus'
import api from '@/api/index' import api from '@/api/index'
const loading = ref(false) const loading = ref(false)
@@ -10,6 +10,7 @@ const editUser = ref<any>(null)
const editRole = ref('') const editRole = ref('')
const editDepartment = ref('') const editDepartment = ref('')
const editWecomId = ref('') const editWecomId = ref('')
const editRequireReport = ref(true)
const roleOptions = [ const roleOptions = [
{ label: '客户经理', value: 'manager' }, { label: '客户经理', value: 'manager' },
@@ -40,13 +41,15 @@ function openEdit(user: any) {
editRole.value = user.role editRole.value = user.role
editDepartment.value = user.department || '' editDepartment.value = user.department || ''
editWecomId.value = user.wecom_userid || '' editWecomId.value = user.wecom_userid || ''
editRequireReport.value = user.require_report !== false // default true
editDialogVisible.value = true editDialogVisible.value = true
} }
async function handleSave() { async function handleSave() {
if (!editUser.value) return if (!editUser.value) return
try { try {
await api.put(`/users/${editUser.value.id}/role`, { role: editRole.value, department: editDepartment.value }) const payload: any = { role: editRole.value, department: editDepartment.value, require_report: editRequireReport.value }
await api.put(`/users/${editUser.value.id}/role`, payload)
const newWecomId = editWecomId.value.trim() const newWecomId = editWecomId.value.trim()
if (newWecomId !== (editUser.value.wecom_userid || '')) { if (newWecomId !== (editUser.value.wecom_userid || '')) {
await api.put(`/users/${editUser.value.id}/wecom`, { wecom_userid: newWecomId || null }) await api.put(`/users/${editUser.value.id}/wecom`, { wecom_userid: newWecomId || null })
@@ -56,16 +59,6 @@ async function handleSave() {
await loadUsers() await loadUsers()
} catch (e: any) { ElMessage.error('更新失败: ' + (e.response?.data?.detail || e.message)) } } catch (e: any) { ElMessage.error('更新失败: ' + (e.response?.data?.detail || e.message)) }
} }
async function handleUnbind(row: any) {
if (!row.wecom_userid) return
try {
await ElMessageBox.confirm(`确定解除「${row.name}」的企微绑定?`, '确认解绑', { type: 'warning' })
await api.put(`/users/${row.id}/wecom`, { wecom_userid: null })
ElMessage.success('已解绑')
await loadUsers()
} catch (e: any) { if (e !== 'cancel') ElMessage.error('操作失败') }
}
</script> </script>
<template> <template>
@@ -83,16 +76,20 @@ async function handleUnbind(row: any) {
<el-tag :type="roleTagType[row.role]">{{ roleLabel[row.role] || row.role }}</el-tag> <el-tag :type="roleTagType[row.role]">{{ roleLabel[row.role] || row.role }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="wecom_userid" label="企微 UserID" min-width="140"> <el-table-column label="填报" width="70">
<template #default="{ row }"> <template #default="{ row }">
<template v-if="row.wecom_userid"> <span :class="row.require_report !== false ? 'tag-yes' : 'tag-no'">
<span class="wecom-id">{{ row.wecom_userid }}</span> {{ row.require_report !== false ? '✓ 是' : '— 否' }}
<el-button text size="small" type="danger" @click="handleUnbind(row)" style="margin-left:6px">解绑</el-button> </span>
</template>
<span v-else style="color:var(--c-text-muted)">未绑定</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="100" fixed="right"> <el-table-column prop="wecom_userid" label="企微 UserID" min-width="140">
<template #default="{ row }">
<span v-if="row.wecom_userid" class="wecom-id">{{ row.wecom_userid }}</span>
<span v-else style="color:var(--c-text-muted);font-size:12px">未绑定</span>
</template>
</el-table-column>
<el-table-column label="操作" width="80" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button text size="small" type="primary" @click="openEdit(row)">编辑</el-button> <el-button text size="small" type="primary" @click="openEdit(row)">编辑</el-button>
</template> </template>
@@ -109,10 +106,14 @@ async function handleUnbind(row: any) {
<el-option v-for="opt in roleOptions" :key="opt.value" :label="opt.label" :value="opt.value" /> <el-option v-for="opt in roleOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="部门(可选)"><el-input v-model="editDepartment" placeholder="如:XX支局" /></el-form-item> <el-form-item label="部门"><el-input v-model="editDepartment" placeholder="如:XX支局" /></el-form-item>
<el-form-item label="企业微信 UserID"> <el-form-item label="企业微信 UserID">
<el-input v-model="editWecomId" placeholder="从企微后台通讯录获取,留空则解绑" clearable /> <el-input v-model="editWecomId" placeholder="从企微后台通讯录获取,留空则解绑" clearable />
<div class="form-hint">企微后台 通讯录 成员详情 账号 中查看</div> <div class="form-hint">企微后台 通讯录 成员详情 账号</div>
</el-form-item>
<el-form-item label="需要填写周报">
<el-switch v-model="editRequireReport" active-text="是" inactive-text="否" />
<div class="form-hint">关闭后该用户不计入填报统计不接收催办提醒</div>
</el-form-item> </el-form-item>
</el-form> </el-form>
</template> </template>
@@ -135,4 +136,6 @@ async function handleUnbind(row: any) {
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; } .page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; }
.wecom-id { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--sage); background: var(--c-bg-light, #f0ede5); padding: 2px 8px; border-radius: 4px; } .wecom-id { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--sage); background: var(--c-bg-light, #f0ede5); padding: 2px 8px; border-radius: 4px; }
.form-hint { font-size: 12px; color: var(--c-text-muted); margin-top: 4px; } .form-hint { font-size: 12px; color: var(--c-text-muted); margin-top: 4px; }
.tag-yes { font-size: 12px; color: var(--sage); }
.tag-no { font-size: 12px; color: var(--c-text-muted); }
</style> </style>