Merge branch 'main' into develop
# Conflicts: # backend/app/api/key_visits.py # backend/app/api/mini_business.py # backend/app/api/visits.py # backend/app/api/work_plans.py # backend/app/main.py # backend/app/models/__init__.py # backend/app/schemas/key_visit.py # backend/app/schemas/mini_business.py # backend/app/schemas/work_plan.py # backend/app/services/light_board.py # frontend/src/components/DesktopLayout.vue # frontend/src/stores/theme.ts # frontend/src/views/desktop/ManagerWorkspace.vue # frontend/src/views/desktop/WorkPlans.vue # frontend/src/views/mobile/KeyVisitForm.vue # frontend/src/views/mobile/LeaveForm.vue # frontend/src/views/mobile/PlansList.vue # frontend/src/views/mobile/VisitForm.vue # frontend/src/views/mobile/WorkPlanForm.vue
This commit is contained in:
@@ -53,7 +53,7 @@ qiji/
|
||||
| 表 | 核心字段 |
|
||||
|----|---------|
|
||||
| `users` | id, casdoor_id, name, role(manager/director/leader), wecom_userid |
|
||||
| `customers` | id, name, industry, address, in_use_services, monthly_fee, remarks, created_by |
|
||||
| `customers` | id, name, customer_type(unit/individual), industry, address, in_use_services, monthly_fee, remarks, created_by |
|
||||
| `customer_contacts` | id, customer_id, name, phone, role_desc |
|
||||
| `customer_assignments` | id, customer_id, manager_id, role(primary/assistant), assigned_by |
|
||||
| `visits` | id, customer_id, visit_date, visit_method, time_range, visitor_name, visitor_phone, communication_content, customer_demand, companions(UUID[]), photos(TEXT[]), manager_id, edit_log(JSONB) |
|
||||
@@ -120,6 +120,10 @@ qiji/
|
||||
- [x] **工作页客户经理列+汇总栏** — 工作计划/商机跟单/要客拜访三个页面新增"客户经理"列(哈希色标签)+ 页面顶部汇总栏(按经理分组统计条数)
|
||||
- [x] **云端部署** — FRP 隧道 `qiji-backend` (本机 8002→远程 18061),前端 Docker 容器 `qiji-frontend` (远程 18063),OpenResty 反代 `qj.dhdx.fun`,生产模式运行(去掉 --reload),SECRET_KEY 已加固,CORS 已更新
|
||||
- [x] **请假管理** — `leaves` 表 + 完整 CRUD API + 填报进度免考核(on_leave 第四态) + 催办豁免 + AI 摘要注入 + PC 端独立管理页 + 移动端列表/表单
|
||||
- [x] **商机跟单优化** — 新增跟进记录子表 `mini_business_logs`,时间线展示+跟进方式chip。拜访创建时自动联动关联商机追加跟进。状态流转优化(默认筛选跟进中、已流失置底自动清空列收)
|
||||
- [x] **个人用户** — `customers.customer_type` 列区分 unit/individual。单位客户和个人用户双Tab分离管理。个人用户Tab内嵌4Tab聚合视图(拜访/计划/商机/要客),显示所有个人用户的关联数据。个人用户通过业务模块快速新建产生,客户搜索下拉中置顶
|
||||
- [x] **移动端导航重构** — 底部TabBar从3项扩展为4项(首页/工作/拜访/纪要)。新增工作中心页面(6个模块卡片入口)。新增5个历史列表页(拜访/纪要/计划/商机/要客),支持搜索+分页。3个表单支持新增+编辑双模式
|
||||
- [x] **我的数据页面优化** — 日期筛选改为灵活daterange选择器,支持搜索+分页(25/50/100条每页)。每个Tab独立维护分页状态。日期选择器+搜索框移至标题行
|
||||
|
||||
### 待完善
|
||||
|
||||
@@ -266,3 +270,6 @@ docker-compose up -d
|
||||
21. **AI 周报摘要**: 接入 DeepSeek V4 (模型 `deepseek-v4-flash`,端点 `/v1/chat/completions`,OpenAI 兼容协议)。`build_summary_prompt()` 注入拜访总览+各经理明细+客户需求汇总+纪要分类+亮灯表覆盖数据,生成四段式 Markdown。WeeklyReport.vue 集成:按钮(闪电图标)→loading 脉冲→渲染→一键复制(安全上下文检测降级)。权限:director/leader 可见按钮
|
||||
22. **云端部署**: FRP 隧道 (8002→18061) + Docker 前端容器 (18063) + OpenResty 反代。frpc 由 1Panel Docker 容器管理,配置在 `/opt/1panel/apps/frpc/frpc/data/frpc.toml`。前端生产构建用 `.env.production` 指向 Casdoor 公网地址。nginx.conf 只保留 SPA fallback,API 路由由 OpenResty 边缘处理。SSH 端口 7072,前端容器仅绑定 127.0.0.1:18063(不直接暴露公网)
|
||||
23. **请假管理**: `leaves` 表存储请假记录,按天粒度(start_date~end_date),五种类型(年假/事假/病假/调休/其他),提交即生效无审批。权限:经理看自己、支局长/领导看全员。填报进度新增 `on_leave` 第四态(蓝「假」印章),催办自动过滤请假人员,AI 摘要注入本周请假数据。PC 端侧边栏「工作」分组下独立页面,移动端首页快捷入口 + 列表页 + 表单页
|
||||
24. **个人用户**: `customers` 表 `customer_type` 列区分 unit/individual。单位客户管理页支持双Tab(单位客户/个人用户)切换。个人用户Tab 内嵌4Tab聚合视图(拜访/计划/商机/要客),显示所有个人用户的关联数据。个人用户通过业务模块快速新建产生,客户搜索下拉中置顶显示。单位客户列表默认只显示单位类型
|
||||
25. **移动端信息架构**: 底部TabBar 4项(首页/工作/拜访/纪要)。工作中心页面6个模块卡片(历史拜访/历史纪要/工作计划/商机跟单/要客拜访/请假管理)。各模块列表页支持搜索+25条/页分页。表单复用新增/编辑双模式(通过 route.params.id 判断)
|
||||
26. **我的数据优化**: 日期范围用 el-date-picker daterange 灵活选择。搜索框+日期选择器移至标题行同行。每个Tab独立维护分页状态(25/50/100)。5个后端列表API均支持 search 参数模糊匹配
|
||||
|
||||
@@ -51,6 +51,7 @@ async def list_customers(
|
||||
industry: Optional[str] = Query(None),
|
||||
service: Optional[str] = Query(None),
|
||||
manager_id: Optional[str] = Query(None),
|
||||
customer_type: Optional[str] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(100, ge=1, le=1000),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
@@ -61,6 +62,8 @@ async def list_customers(
|
||||
|
||||
base_query = select(Customer)
|
||||
|
||||
if customer_type:
|
||||
base_query = base_query.where(Customer.customer_type == customer_type)
|
||||
if industry:
|
||||
base_query = base_query.where(Customer.industry.ilike(f"%{industry}%"))
|
||||
if service:
|
||||
@@ -90,7 +93,7 @@ async def list_customers(
|
||||
|
||||
# Paginate
|
||||
offset = (page - 1) * page_size
|
||||
query = base_query.order_by(Customer.name).offset(offset).limit(page_size)
|
||||
query = base_query.order_by(Customer.customer_type, Customer.name).offset(offset).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
items = result.scalars().all()
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ async def _enrich(note: DailyNote, db: AsyncSession) -> dict:
|
||||
async def list_notes(
|
||||
date_from: Optional[str] = Query(None),
|
||||
date_to: Optional[str] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
@@ -41,6 +42,8 @@ async def list_notes(
|
||||
query = query.where(DailyNote.note_date >= parse_date(date_from))
|
||||
if date_to:
|
||||
query = query.where(DailyNote.note_date <= parse_date(date_to))
|
||||
if search:
|
||||
query = query.where(DailyNote.content.ilike(f"%{search}%"))
|
||||
query = query.order_by(DailyNote.note_date.desc(), DailyNote.created_at.desc()).limit(100)
|
||||
result = await db.execute(query)
|
||||
return [await _enrich(n, db) for n in result.scalars().all()]
|
||||
@@ -81,7 +84,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,
|
||||
@@ -112,6 +115,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():
|
||||
|
||||
@@ -2,7 +2,7 @@ import uuid
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, or_
|
||||
from app.database import get_db
|
||||
from app.middleware.auth import get_current_user, require_any_role
|
||||
from app.models.key_visit import KeyVisit
|
||||
@@ -37,6 +37,8 @@ async def _enrich(k: KeyVisit, db: AsyncSession) -> dict:
|
||||
@router.get("/")
|
||||
async def list_key_visits(
|
||||
customer_id: Optional[str] = Query(None),
|
||||
customer_type: Optional[str] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
@@ -45,11 +47,32 @@ async def list_key_visits(
|
||||
query = query.where(KeyVisit.manager_id == uuid.UUID(current_user["user_id"]))
|
||||
if customer_id:
|
||||
query = query.where(KeyVisit.customer_id == uuid.UUID(customer_id))
|
||||
if customer_type:
|
||||
query = query.join(Customer, KeyVisit.customer_id == Customer.id).where(Customer.customer_type == customer_type)
|
||||
if search:
|
||||
query = query.outerjoin(Customer, KeyVisit.customer_id == Customer.id).where(
|
||||
or_(KeyVisit.description.ilike(f"%{search}%"), KeyVisit.planned_visitor.ilike(f"%{search}%"), Customer.name.ilike(f"%{search}%"))
|
||||
)
|
||||
query = query.order_by(KeyVisit.planned_date.desc()).limit(200)
|
||||
result = await db.execute(query)
|
||||
return [await _enrich(k, db) for k in result.scalars().all()]
|
||||
|
||||
|
||||
@router.get("/{item_id}")
|
||||
async def get_key_visit(
|
||||
item_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(KeyVisit).where(KeyVisit.id == item_id))
|
||||
k = result.scalar_one_or_none()
|
||||
if not k:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
if current_user["role"] == "manager" and str(k.manager_id) != current_user["user_id"]:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
return await _enrich(k, db)
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def create_key_visit(
|
||||
data: KeyVisitCreate,
|
||||
@@ -64,7 +87,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)
|
||||
@@ -103,6 +126,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)
|
||||
if "manager_id" in update_data and update_data["manager_id"]:
|
||||
update_data["manager_id"] = uuid.UUID(update_data["manager_id"])
|
||||
for key, v in update_data.items():
|
||||
|
||||
@@ -2,7 +2,7 @@ import uuid
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, or_
|
||||
from app.database import get_db
|
||||
from app.middleware.auth import get_current_user, require_any_role
|
||||
from app.models.mini_business import MiniBusiness
|
||||
@@ -36,6 +36,8 @@ async def _enrich(m: MiniBusiness, db: AsyncSession) -> dict:
|
||||
@router.get("/")
|
||||
async def list_mini_business(
|
||||
customer_id: Optional[str] = Query(None),
|
||||
customer_type: Optional[str] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
@@ -44,11 +46,32 @@ async def list_mini_business(
|
||||
query = query.where(MiniBusiness.manager_id == uuid.UUID(current_user["user_id"]))
|
||||
if customer_id:
|
||||
query = query.where(MiniBusiness.customer_id == uuid.UUID(customer_id))
|
||||
if customer_type:
|
||||
query = query.join(Customer, MiniBusiness.customer_id == Customer.id).where(Customer.customer_type == customer_type)
|
||||
if search:
|
||||
query = query.outerjoin(Customer, MiniBusiness.customer_id == Customer.id).where(
|
||||
or_(MiniBusiness.product_type.ilike(f"%{search}%"), MiniBusiness.follow_up_detail.ilike(f"%{search}%"), Customer.name.ilike(f"%{search}%"))
|
||||
)
|
||||
query = query.order_by(MiniBusiness.expected_revenue_date.desc()).limit(200)
|
||||
result = await db.execute(query)
|
||||
return [await _enrich(m, db) for m in result.scalars().all()]
|
||||
|
||||
|
||||
@router.get("/{item_id}")
|
||||
async def get_mini_business(
|
||||
item_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(MiniBusiness).where(MiniBusiness.id == item_id))
|
||||
m = result.scalar_one_or_none()
|
||||
if not m:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
if current_user["role"] == "manager" and str(m.manager_id) != current_user["user_id"]:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
return await _enrich(m, db)
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def create_mini_business(
|
||||
data: MiniBusinessCreate,
|
||||
@@ -61,7 +84,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"])
|
||||
@@ -101,10 +124,15 @@ 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)
|
||||
if "manager_id" in update_data and update_data["manager_id"]:
|
||||
update_data["manager_id"] = uuid.UUID(update_data["manager_id"])
|
||||
for k, v in update_data.items():
|
||||
setattr(m, k, v)
|
||||
# Auto-clear expected revenue date when status becomes 已流失
|
||||
if m.status == "已流失" and m.expected_revenue_date:
|
||||
m.expected_revenue_date = ""
|
||||
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}
|
||||
changes = compute_diff(old_snapshot, new_snapshot)
|
||||
if changes:
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""MiniBusinessLog CRUD API — follow-up log entries for mini business opportunities."""
|
||||
|
||||
import uuid
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database import get_db
|
||||
from app.middleware.auth import get_current_user, require_any_role
|
||||
from app.models.mini_business import MiniBusiness
|
||||
from app.models.mini_business_log import MiniBusinessLog
|
||||
from app.models.user import User
|
||||
from app.schemas.mini_business_log import (
|
||||
MiniBusinessLogCreate, MiniBusinessLogUpdate, MiniBusinessLogOut,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/mini-business", tags=["MiniBusiness Logs"])
|
||||
|
||||
|
||||
async def _get_log(db: AsyncSession, log_id: str) -> MiniBusinessLog:
|
||||
result = await db.execute(select(MiniBusinessLog).where(MiniBusinessLog.id == log_id))
|
||||
log = result.scalar_one_or_none()
|
||||
if not log:
|
||||
raise HTTPException(status_code=404, detail="跟进记录不存在")
|
||||
return log
|
||||
|
||||
|
||||
@router.get("/{business_id}/logs")
|
||||
async def list_logs(
|
||||
business_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List follow-up logs for a mini business, newest first."""
|
||||
# Verify business exists and user has access
|
||||
mb = await db.get(MiniBusiness, business_id)
|
||||
if not mb:
|
||||
raise HTTPException(status_code=404, detail="商机不存在")
|
||||
if current_user["role"] == "manager" and str(mb.manager_id) != current_user["user_id"]:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
result = await db.execute(
|
||||
select(MiniBusinessLog)
|
||||
.where(MiniBusinessLog.business_id == business_id)
|
||||
.order_by(MiniBusinessLog.log_date.desc(), MiniBusinessLog.created_at.desc())
|
||||
)
|
||||
logs = result.scalars().all()
|
||||
|
||||
# Resolve creator names
|
||||
user_ids = {log.created_by for log in logs}
|
||||
users = (await db.execute(select(User).where(User.id.in_(user_ids)))).scalars().all()
|
||||
user_map = {u.id: u.name for u in users}
|
||||
|
||||
return [
|
||||
{
|
||||
"id": log.id,
|
||||
"business_id": log.business_id,
|
||||
"log_date": str(log.log_date),
|
||||
"method": log.method,
|
||||
"content": log.content,
|
||||
"created_by": log.created_by,
|
||||
"created_by_name": user_map.get(log.created_by, ""),
|
||||
"created_at": str(log.created_at) if log.created_at else None,
|
||||
}
|
||||
for log in logs
|
||||
]
|
||||
|
||||
|
||||
@router.post("/{business_id}/logs")
|
||||
async def create_log(
|
||||
business_id: str,
|
||||
data: MiniBusinessLogCreate,
|
||||
current_user: dict = Depends(require_any_role),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Add a follow-up log entry."""
|
||||
mb = await db.get(MiniBusiness, business_id)
|
||||
if not mb:
|
||||
raise HTTPException(status_code=404, detail="商机不存在")
|
||||
if current_user["role"] == "manager" and str(mb.manager_id) != current_user["user_id"]:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
log = MiniBusinessLog(
|
||||
business_id=uuid.UUID(business_id),
|
||||
log_date=data.log_date,
|
||||
method=data.method,
|
||||
content=data.content,
|
||||
created_by=uuid.UUID(current_user["user_id"]),
|
||||
)
|
||||
db.add(log)
|
||||
await db.commit()
|
||||
await db.refresh(log)
|
||||
|
||||
creator = await db.get(User, log.created_by)
|
||||
return {
|
||||
"id": log.id,
|
||||
"business_id": log.business_id,
|
||||
"log_date": str(log.log_date),
|
||||
"method": log.method,
|
||||
"content": log.content,
|
||||
"created_by": log.created_by,
|
||||
"created_by_name": creator.name if creator else "",
|
||||
"created_at": str(log.created_at) if log.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/logs/{log_id}")
|
||||
async def update_log(
|
||||
log_id: str,
|
||||
data: MiniBusinessLogUpdate,
|
||||
current_user: dict = Depends(require_any_role),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update a follow-up log entry."""
|
||||
log = await _get_log(db, log_id)
|
||||
|
||||
# Managers can only edit their own logs; directors/leaders can edit any
|
||||
if current_user["role"] == "manager" and str(log.created_by) != current_user["user_id"]:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for k, v in update_data.items():
|
||||
setattr(log, k, v)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(log)
|
||||
return {"id": log.id, "log_date": str(log.log_date), "method": log.method, "content": log.content}
|
||||
|
||||
|
||||
@router.delete("/logs/{log_id}")
|
||||
async def delete_log(
|
||||
log_id: str,
|
||||
current_user: dict = Depends(require_any_role),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete a follow-up log entry."""
|
||||
log = await _get_log(db, log_id)
|
||||
|
||||
if current_user["role"] == "manager" and str(log.created_by) != current_user["user_id"]:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
await db.delete(log)
|
||||
await db.commit()
|
||||
return {"status": "deleted"}
|
||||
+53
-12
@@ -3,7 +3,7 @@ from datetime import date, datetime
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy import select, func, or_
|
||||
from app.database import get_db
|
||||
from app.middleware.auth import get_current_user, require_any_role
|
||||
from app.models.visit import Visit
|
||||
@@ -74,6 +74,8 @@ async def list_visits(
|
||||
date_from: Optional[str] = Query(None),
|
||||
date_to: Optional[str] = Query(None),
|
||||
customer_id: Optional[str] = Query(None),
|
||||
customer_type: Optional[str] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
@@ -89,6 +91,12 @@ async def list_visits(
|
||||
query = query.where(Visit.visit_date <= parse_date(date_to))
|
||||
if customer_id:
|
||||
query = query.where(Visit.customer_id == uuid.UUID(customer_id))
|
||||
if customer_type:
|
||||
query = query.join(Customer, Visit.customer_id == Customer.id).where(Customer.customer_type == customer_type)
|
||||
if search:
|
||||
query = query.outerjoin(Customer, Visit.customer_id == Customer.id).where(
|
||||
or_(Visit.communication_content.ilike(f"%{search}%"), Visit.customer_demand.ilike(f"%{search}%"), Visit.visitor_name.ilike(f"%{search}%"), Customer.name.ilike(f"%{search}%"))
|
||||
)
|
||||
|
||||
query = query.order_by(Visit.visit_date.desc(), Visit.created_at.desc()).limit(200)
|
||||
result = await db.execute(query)
|
||||
@@ -161,7 +169,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"]),
|
||||
visit_group_id=group_id,
|
||||
)
|
||||
init_entry(visit, current_user["name"])
|
||||
@@ -176,18 +184,22 @@ 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": "拜访自动完成"}])
|
||||
|
||||
# Auto-create follow-up logs for active mini business opportunities
|
||||
from app.models.mini_business import MiniBusiness
|
||||
from app.models.mini_business_log import MiniBusinessLog
|
||||
method_map = {"上门": "上门", "电话": "电话", "微信": "微信", "出差": "其他"}
|
||||
active_biz = await db.execute(
|
||||
select(MiniBusiness).where(
|
||||
MiniBusiness.customer_id == data.customer_id,
|
||||
MiniBusiness.status == "跟进中",
|
||||
)
|
||||
)
|
||||
# Create full copies for companions (not blank drafts)
|
||||
creator_name = current_user["name"]
|
||||
for companion_id in data.companions:
|
||||
@@ -215,6 +227,24 @@ async def create_visit(
|
||||
init_entry(draft, current_user["name"])
|
||||
db.add(draft)
|
||||
|
||||
# Auto-create follow-up logs for active mini business opportunities
|
||||
for biz in active_biz.scalars().all():
|
||||
parts = []
|
||||
if data.communication_content and data.communication_content.strip():
|
||||
parts.append(f"沟通内容:{data.communication_content.strip()}")
|
||||
if data.customer_demand and data.customer_demand.strip():
|
||||
parts.append(f"客户需求:{data.customer_demand.strip()}")
|
||||
log_content = "\n".join(parts) if parts else "拜访跟进"
|
||||
follow_method = method_map.get(data.visit_method, "其他")
|
||||
log = MiniBusinessLog(
|
||||
business_id=biz.id,
|
||||
log_date=parse_date(data.visit_date),
|
||||
method=follow_method,
|
||||
content=log_content,
|
||||
created_by=uuid.UUID(current_user["user_id"]),
|
||||
)
|
||||
db.add(log)
|
||||
|
||||
# Audit log (before commit — part of same transaction)
|
||||
cust = await db.execute(select(Customer.name).where(Customer.id == visit.customer_id))
|
||||
await log_audit(db, "visit", visit.id, f"{cust.scalar_one_or_none() or ''} ({data.visit_date})", "create", current_user["user_id"], current_user["name"])
|
||||
@@ -251,6 +281,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"])
|
||||
if "manager_id" in update_data and update_data["manager_id"]:
|
||||
@@ -289,6 +321,15 @@ async def update_visit(
|
||||
await log_audit(db, "visit", visit.id, f"{cust.scalar_one_or_none() or ''} ({visit.visit_date})", "update", current_user["user_id"], current_user["name"], ", ".join(changes.keys()) if changes else "")
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -3,12 +3,11 @@ from datetime import date
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, or_
|
||||
from app.database import get_db
|
||||
from app.middleware.auth import get_current_user, require_any_role
|
||||
from app.utils.timezone import parse_date
|
||||
from app.utils.edit_log import compute_diff, append_entry, init_entry
|
||||
from app.utils.audit import log_audit
|
||||
from app.models.work_plan import WorkPlan
|
||||
from app.models.customer import Customer
|
||||
from app.models.user import User
|
||||
@@ -36,6 +35,8 @@ async def _enrich(wp: WorkPlan, db: AsyncSession) -> dict:
|
||||
@router.get("/")
|
||||
async def list_work_plans(
|
||||
customer_id: Optional[str] = Query(None),
|
||||
customer_type: Optional[str] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
@@ -44,59 +45,49 @@ async def list_work_plans(
|
||||
query = query.where(WorkPlan.manager_id == uuid.UUID(current_user["user_id"]))
|
||||
if customer_id:
|
||||
query = query.where(WorkPlan.customer_id == uuid.UUID(customer_id))
|
||||
if customer_type:
|
||||
query = query.join(Customer, WorkPlan.customer_id == Customer.id).where(Customer.customer_type == customer_type)
|
||||
if search:
|
||||
query = query.outerjoin(Customer, WorkPlan.customer_id == Customer.id).where(
|
||||
or_(WorkPlan.plan_content.ilike(f"%{search}%"), Customer.name.ilike(f"%{search}%"))
|
||||
)
|
||||
query = query.order_by(WorkPlan.plan_date.desc()).limit(200)
|
||||
result = await db.execute(query)
|
||||
return [await _enrich(w, db) for w in result.scalars().all()]
|
||||
|
||||
|
||||
@router.get("/{item_id}")
|
||||
async def get_work_plan(
|
||||
item_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(WorkPlan).where(WorkPlan.id == item_id))
|
||||
wp = result.scalar_one_or_none()
|
||||
if not wp:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
if current_user["role"] == "manager" and str(wp.manager_id) != current_user["user_id"]:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
return await _enrich(wp, db)
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def create_work_plan(
|
||||
data: WorkPlanCreate,
|
||||
current_user: dict = Depends(require_any_role),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
plan_date = parse_date(data.plan_date)
|
||||
|
||||
# Auto-complete if a visit already exists for this customer on/after the plan date
|
||||
from app.models.visit import Visit
|
||||
if data.status == "计划中":
|
||||
existing_visit = await db.execute(
|
||||
select(Visit.id).where(
|
||||
Visit.customer_id == data.customer_id,
|
||||
Visit.visit_date >= plan_date,
|
||||
).limit(1)
|
||||
)
|
||||
if existing_visit.scalar_one_or_none():
|
||||
data.status = "已完成"
|
||||
|
||||
wp = WorkPlan(
|
||||
customer_id=data.customer_id,
|
||||
plan_content=data.plan_content,
|
||||
plan_date=plan_date,
|
||||
manager_id=uuid.UUID(current_user["user_id"]),
|
||||
plan_date=parse_date(data.plan_date),
|
||||
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"])
|
||||
if data.status == "已完成":
|
||||
append_entry(wp, current_user["name"], [{"field": "status", "from": "计划中", "to": "已完成", "reason": "已有拜访记录,自动完成"}])
|
||||
db.add(wp)
|
||||
await db.commit()
|
||||
await db.refresh(wp)
|
||||
# Audit log
|
||||
cust = await db.execute(select(Customer.name).where(Customer.id == wp.customer_id))
|
||||
await log_audit(db, "work_plan", wp.id, f"{cust.scalar_one_or_none() or ''} ({wp.plan_date})", "create", current_user["user_id"], current_user["name"])
|
||||
await db.commit()
|
||||
return await _enrich(wp, db)
|
||||
|
||||
|
||||
@router.get("/{plan_id}")
|
||||
async def get_work_plan(plan_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user)):
|
||||
result = await db.execute(select(WorkPlan).where(WorkPlan.id == plan_id))
|
||||
wp = result.scalar_one_or_none()
|
||||
if not wp:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
if current_user["role"] == "manager" and str(wp.manager_id) != current_user["user_id"]:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
return await _enrich(wp, db)
|
||||
|
||||
|
||||
@@ -115,10 +106,10 @@ 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"])
|
||||
if "manager_id" in update_data and update_data["manager_id"]:
|
||||
update_data["manager_id"] = uuid.UUID(update_data["manager_id"])
|
||||
for k, v in update_data.items():
|
||||
setattr(wp, k, v)
|
||||
new_snapshot = {"customer_id": str(wp.customer_id), "plan_content": wp.plan_content, "plan_date": str(wp.plan_date), "status": wp.status}
|
||||
@@ -127,10 +118,6 @@ async def update_work_plan(
|
||||
append_entry(wp, current_user["name"], changes, getattr(data, "edit_reason", None))
|
||||
await db.commit()
|
||||
await db.refresh(wp)
|
||||
# Audit log
|
||||
cust = await db.execute(select(Customer.name).where(Customer.id == wp.customer_id))
|
||||
await log_audit(db, "work_plan", wp.id, f"{cust.scalar_one_or_none() or ''} ({wp.plan_date})", "update", current_user["user_id"], current_user["name"])
|
||||
await db.commit()
|
||||
return await _enrich(wp, db)
|
||||
|
||||
|
||||
@@ -146,11 +133,6 @@ async def delete_work_plan(
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
if current_user["role"] == "manager" and str(wp.manager_id) != current_user["user_id"]:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
cust = await db.execute(select(Customer.name).where(Customer.id == wp.customer_id))
|
||||
entity_name = f"{cust.scalar_one_or_none() or ''} ({wp.plan_date})"
|
||||
await db.delete(wp)
|
||||
await db.commit()
|
||||
# Audit log
|
||||
await log_audit(db, "work_plan", uuid.UUID(plan_id), entity_name, "delete", current_user["user_id"], current_user["name"])
|
||||
await db.commit()
|
||||
return {"detail": "deleted"}
|
||||
|
||||
+2
-1
@@ -5,7 +5,7 @@ from sqlalchemy import select
|
||||
from app.config import settings, validate_security_settings
|
||||
from app.database import engine, async_session
|
||||
from app.api import router as api_router
|
||||
from app.api import auth, users, customers, visits, work_plans, mini_business, key_visits
|
||||
from app.api import auth, users, customers, visits, work_plans, mini_business, mini_business_logs, key_visits
|
||||
from app.api import dashboard, upload, export, import_data, wecom, daily_notes, ai_summary, system_config, leaves, audit
|
||||
from app.models.system_config import SystemConfig
|
||||
from app.services.holidays import refresh_holidays
|
||||
@@ -63,6 +63,7 @@ app.include_router(customers.router, prefix="/api")
|
||||
app.include_router(visits.router, prefix="/api")
|
||||
app.include_router(work_plans.router, prefix="/api")
|
||||
app.include_router(mini_business.router, prefix="/api")
|
||||
app.include_router(mini_business_logs.router, prefix="/api")
|
||||
app.include_router(key_visits.router, prefix="/api")
|
||||
app.include_router(dashboard.router, prefix="/api")
|
||||
app.include_router(leaves.router, prefix="/api")
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.models.ai_summary import AISummary
|
||||
from app.models.system_config import SystemConfig
|
||||
from app.models.leave import Leave
|
||||
from app.models.wecom_bind_token import WecomBindToken
|
||||
from app.models.mini_business_log import MiniBusinessLog
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@@ -20,6 +21,7 @@ __all__ = [
|
||||
"Visit",
|
||||
"WorkPlan",
|
||||
"MiniBusiness",
|
||||
"MiniBusinessLog",
|
||||
"KeyVisit",
|
||||
"DailyNote",
|
||||
"AISummary",
|
||||
|
||||
@@ -11,6 +11,7 @@ class Customer(Base):
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
name: Mapped[str] = mapped_column(String(200), index=True)
|
||||
customer_type: Mapped[str] = mapped_column(String(20), default="unit")
|
||||
industry: Mapped[str] = mapped_column(String(100), default="")
|
||||
address: Mapped[str] = mapped_column(String(500), default="")
|
||||
in_use_services: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
from sqlalchemy import String, Text, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from app.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.mini_business_log import MiniBusinessLog
|
||||
|
||||
|
||||
class MiniBusiness(Base):
|
||||
__tablename__ = "mini_business"
|
||||
@@ -17,3 +21,5 @@ class MiniBusiness(Base):
|
||||
manager_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), index=True)
|
||||
expected_revenue_date: Mapped[str] = mapped_column(String(50), default="")
|
||||
edit_log: Mapped[list] = mapped_column(JSONB, default=list)
|
||||
|
||||
logs: Mapped[list["MiniBusinessLog"]] = relationship(back_populates="business", cascade="all, delete-orphan")
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""MiniBusinessLog — follow-up log entries for mini business opportunities."""
|
||||
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from sqlalchemy import String, Date, DateTime, ForeignKey, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class MiniBusinessLog(Base):
|
||||
__tablename__ = "mini_business_logs"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
business_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("mini_business.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
log_date: Mapped[date] = mapped_column(Date)
|
||||
method: Mapped[str] = mapped_column(String(20), default="电话")
|
||||
content: Mapped[str] = mapped_column(Text, default="")
|
||||
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()
|
||||
)
|
||||
|
||||
business: Mapped["MiniBusiness"] = relationship(back_populates="logs")
|
||||
@@ -45,6 +45,7 @@ class ContactOut(BaseModel):
|
||||
# ── Customer ──
|
||||
class CustomerCreate(BaseModel):
|
||||
name: str
|
||||
customer_type: str = "unit"
|
||||
industry: str = ""
|
||||
address: str = ""
|
||||
in_use_services: str = ""
|
||||
@@ -56,6 +57,7 @@ class CustomerCreate(BaseModel):
|
||||
|
||||
class CustomerUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
customer_type: Optional[str] = None
|
||||
industry: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
in_use_services: Optional[str] = None
|
||||
@@ -67,6 +69,7 @@ class CustomerUpdate(BaseModel):
|
||||
class CustomerOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
customer_type: str = "unit"
|
||||
industry: str
|
||||
address: str
|
||||
in_use_services: str
|
||||
@@ -84,6 +87,7 @@ class CustomerOut(BaseModel):
|
||||
class CustomerListOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
customer_type: str = "unit"
|
||||
industry: str
|
||||
in_use_services: str
|
||||
primary_manager_name: Optional[str] = None
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,7 +22,7 @@ class KeyVisitUpdate(BaseModel):
|
||||
planned_date: Optional[str] = None
|
||||
planned_visitor: Optional[str] = None
|
||||
visit_target: Optional[str] = None
|
||||
manager_id: Optional[str] = None
|
||||
manager_id: Optional[uuid.UUID] = None
|
||||
|
||||
|
||||
class KeyVisitOut(BaseModel):
|
||||
|
||||
@@ -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,7 +20,7 @@ class MiniBusinessUpdate(BaseModel):
|
||||
follow_up_detail: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
expected_revenue_date: Optional[str] = None
|
||||
manager_id: Optional[str] = None
|
||||
manager_id: Optional[uuid.UUID] = None
|
||||
|
||||
|
||||
class MiniBusinessOut(BaseModel):
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""MiniBusinessLog Pydantic schemas."""
|
||||
|
||||
from datetime import date, datetime
|
||||
from uuid import UUID
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
FOLLOW_UP_METHODS = ["电话", "微信", "上门", "邮件", "其他"]
|
||||
|
||||
|
||||
class MiniBusinessLogCreate(BaseModel):
|
||||
log_date: date
|
||||
method: str = Field(default="电话", pattern="^(电话|微信|上门|邮件|其他)$")
|
||||
content: str = ""
|
||||
|
||||
|
||||
class MiniBusinessLogUpdate(BaseModel):
|
||||
log_date: Optional[date] = None
|
||||
method: Optional[str] = Field(default=None, pattern="^(电话|微信|上门|邮件|其他)$")
|
||||
content: Optional[str] = None
|
||||
|
||||
|
||||
class MiniBusinessLogOut(BaseModel):
|
||||
id: UUID
|
||||
business_id: UUID
|
||||
log_date: date
|
||||
method: str
|
||||
content: str
|
||||
created_by: UUID
|
||||
created_by_name: str = ""
|
||||
created_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -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
|
||||
manager_id: Optional[str] = None
|
||||
|
||||
@@ -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,7 +17,7 @@ class WorkPlanUpdate(BaseModel):
|
||||
plan_content: Optional[str] = None
|
||||
plan_date: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
manager_id: Optional[str] = None
|
||||
manager_id: Optional[uuid.UUID] = None
|
||||
|
||||
|
||||
class WorkPlanOut(BaseModel):
|
||||
|
||||
@@ -132,10 +132,14 @@ async def get_reporting_progress(db: AsyncSession, reference_date: date | None =
|
||||
} if leave else None,
|
||||
})
|
||||
|
||||
# Check today's reporting — visits OR daily notes
|
||||
today_visits = await db.execute(
|
||||
select(Visit.manager_id).where(Visit.visit_date == today)
|
||||
# Check today's reporting — visits (direct + companions) OR daily notes
|
||||
from sqlalchemy import union_all
|
||||
today_direct = select(Visit.manager_id).where(Visit.visit_date == today)
|
||||
today_companion = select(func.unnest(Visit.companions).label("manager_id")).where(
|
||||
Visit.visit_date == today, Visit.companions.isnot(None)
|
||||
)
|
||||
today_combined = union_all(today_direct, today_companion).subquery()
|
||||
today_visits = await db.execute(select(today_combined.c.manager_id))
|
||||
today_notes = await db.execute(
|
||||
select(DailyNote.manager_id).where(DailyNote.note_date == today)
|
||||
)
|
||||
@@ -173,9 +177,13 @@ async def get_weekly_report(
|
||||
# ── Visits ──
|
||||
visit_query = select(Visit).where(Visit.visit_date >= monday, Visit.visit_date <= sunday)
|
||||
if role == "manager":
|
||||
visit_query = visit_query.where(Visit.manager_id == user_id)
|
||||
visit_query = visit_query.where(
|
||||
(Visit.manager_id == user_id) | (Visit.companions.any(user_id))
|
||||
)
|
||||
elif filter_manager_id:
|
||||
visit_query = visit_query.where(Visit.manager_id == filter_manager_id)
|
||||
visit_query = visit_query.where(
|
||||
(Visit.manager_id == filter_manager_id) | (Visit.companions.any(filter_manager_id))
|
||||
)
|
||||
if filter_customer_id:
|
||||
visit_query = visit_query.where(Visit.customer_id == filter_customer_id)
|
||||
visit_query = visit_query.order_by(Visit.visit_date.desc())
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -9,7 +9,7 @@ Status (rolling 30-day window):
|
||||
|
||||
from datetime import date, timedelta
|
||||
from uuid import UUID
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy import select, func, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.user import User
|
||||
from app.models.customer import Customer
|
||||
@@ -85,29 +85,34 @@ async def get_light_board(
|
||||
for m in visible_managers
|
||||
}
|
||||
|
||||
# ── All customers with their last visit per manager ──
|
||||
# Subquery: latest visit (date + method) per (manager_id, customer_id).
|
||||
# DISTINCT ON keeps exactly one row per pair even when multiple visits
|
||||
# share the same latest date (e.g. companion/collaborative visits),
|
||||
# preventing fan-out that would duplicate customer cards on the board.
|
||||
# ── All customers with their last visit per manager (including companions) ──
|
||||
# UNION direct + companions (unnested), then DISTINCT ON to get the latest per (manager, customer)
|
||||
from sqlalchemy import union_all, distinct
|
||||
direct = select(
|
||||
Visit.manager_id, Visit.customer_id, Visit.visit_date, Visit.visit_method,
|
||||
)
|
||||
companion = select(
|
||||
func.unnest(Visit.companions).label("manager_id"),
|
||||
Visit.customer_id,
|
||||
Visit.visit_date,
|
||||
Visit.visit_method,
|
||||
).where(Visit.companions.isnot(None))
|
||||
combined = union_all(direct, companion).subquery()
|
||||
|
||||
# Get latest visit (date + method) per (manager_id, customer_id) using DISTINCT ON
|
||||
latest_visit = (
|
||||
select(
|
||||
Visit.manager_id,
|
||||
Visit.customer_id,
|
||||
Visit.visit_date.label("last_visit_date"),
|
||||
Visit.visit_method.label("last_visit_method"),
|
||||
)
|
||||
.distinct(Visit.manager_id, Visit.customer_id)
|
||||
.order_by(
|
||||
Visit.manager_id,
|
||||
Visit.customer_id,
|
||||
Visit.visit_date.desc(),
|
||||
Visit.id.desc(),
|
||||
combined.c.manager_id,
|
||||
combined.c.customer_id,
|
||||
combined.c.visit_date.label("last_visit_date"),
|
||||
combined.c.visit_method,
|
||||
)
|
||||
.distinct(combined.c.manager_id, combined.c.customer_id)
|
||||
.order_by(combined.c.manager_id, combined.c.customer_id, combined.c.visit_date.desc())
|
||||
.subquery()
|
||||
)
|
||||
|
||||
# Join: assignments → customers → latest_visit (date + method)
|
||||
# Join: assignments → customers → latest_visit
|
||||
rows = await db.execute(
|
||||
select(
|
||||
CustomerAssignment.manager_id,
|
||||
@@ -118,7 +123,7 @@ async def get_light_board(
|
||||
Customer.in_use_services,
|
||||
Customer.monthly_fee,
|
||||
latest_visit.c.last_visit_date,
|
||||
latest_visit.c.last_visit_method,
|
||||
latest_visit.c.visit_method,
|
||||
)
|
||||
.join(Customer, Customer.id == CustomerAssignment.customer_id)
|
||||
.outerjoin(latest_visit, and_(
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,454 @@
|
||||
# 暗黑模式 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** 为企迹系统新增双选暗黑模式:用户在设置页分别选择亮色/暗色偏好主题,顶部栏一键切换亮↔暗。
|
||||
|
||||
**Architecture:** 扩展 theme store 支持 mode(light/dark) + lightTheme + darkTheme 三字段,新增 minimal-dark/tech-dark 两套 CSS 变量,Element Plus 通过 `html.dark` class 适配暗色。
|
||||
|
||||
**Tech Stack:** Vue 3 + Pinia + TypeScript + Element Plus + CSS Custom Properties
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 四大主题: editorial(已有), light(已有), minimal-dark(新增), tech-dark(新增)
|
||||
- localStorage 持久化: mode, lightTheme, darkTheme 三项
|
||||
- 暗色模式时 `<html>` 同时设置 `data-theme` 和 `class="dark"`
|
||||
- 移动端暂不加主题切换(仅 PC 端顶部栏按钮)
|
||||
- 保持现有 editorial/light 的 CSS 变量不变
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 改造 Theme Store
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/stores/theme.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `useThemeStore()` with `mode`, `lightTheme`, `darkTheme`, `effectiveTheme`, `toggleMode()`, `setLightTheme(t)`, `setDarkTheme(t)`, `applyTheme(t)`
|
||||
- Theme type: `'editorial' | 'light' | 'minimal-dark' | 'tech-dark'`
|
||||
|
||||
- [ ] **Step 1: 重写 theme store**
|
||||
|
||||
```typescript
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export type Theme = 'editorial' | 'light' | 'minimal-dark' | 'tech-dark'
|
||||
type Mode = 'light' | 'dark'
|
||||
|
||||
export const themeLabels: Record<Theme, string> = {
|
||||
editorial: '编辑风',
|
||||
light: '极简亮色',
|
||||
'minimal-dark': '极简深色',
|
||||
'tech-dark': '科技深色',
|
||||
}
|
||||
|
||||
export const lightThemes: Theme[] = ['editorial', 'light']
|
||||
export const darkThemes: Theme[] = ['minimal-dark', 'tech-dark']
|
||||
|
||||
export const useThemeStore = defineStore('theme', () => {
|
||||
const mode = ref<Mode>((localStorage.getItem('theme-mode') as Mode) || 'light')
|
||||
const lightTheme = ref<Theme>((localStorage.getItem('theme-light') as Theme) || 'editorial')
|
||||
const darkTheme = ref<Theme>((localStorage.getItem('theme-dark') as Theme) || 'tech-dark')
|
||||
|
||||
const effectiveTheme = computed<Theme>(() => {
|
||||
return mode.value === 'light' ? lightTheme.value : darkTheme.value
|
||||
})
|
||||
|
||||
function applyTheme(theme: Theme, isDark: boolean) {
|
||||
document.documentElement.setAttribute('data-theme', theme)
|
||||
if (isDark) {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
}
|
||||
|
||||
function apply() {
|
||||
applyTheme(effectiveTheme.value, mode.value === 'dark')
|
||||
}
|
||||
|
||||
function setLightTheme(theme: Theme) {
|
||||
lightTheme.value = theme
|
||||
localStorage.setItem('theme-light', theme)
|
||||
if (mode.value === 'light') apply()
|
||||
}
|
||||
|
||||
function setDarkTheme(theme: Theme) {
|
||||
darkTheme.value = theme
|
||||
localStorage.setItem('theme-dark', theme)
|
||||
if (mode.value === 'dark') apply()
|
||||
}
|
||||
|
||||
function toggleMode() {
|
||||
mode.value = mode.value === 'light' ? 'dark' : 'light'
|
||||
localStorage.setItem('theme-mode', mode.value)
|
||||
apply()
|
||||
}
|
||||
|
||||
// Apply on init
|
||||
apply()
|
||||
|
||||
return { mode, lightTheme, darkTheme, effectiveTheme, setLightTheme, setDarkTheme, toggleMode }
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/stores/theme.ts
|
||||
git commit -m "feat: 改造 theme store 支持暗黑模式双选架构"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 新增两套暗色 CSS 变量
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/App.vue` (在 `:root[data-theme="light"]` 块之后添加两个新块)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Theme type `'minimal-dark'`, `'tech-dark'` from store
|
||||
- Produces: CSS variables for both dark themes + Element Plus dark overrides
|
||||
|
||||
- [ ] **Step 1: 添加 minimal-dark 主题 CSS**
|
||||
|
||||
在 `:root[data-theme="light"]` 块结束后(第178行 `}` 之后),插入:
|
||||
|
||||
```css
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
THEME: Minimal Dark — 极简深色
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
:root[data-theme="minimal-dark"] {
|
||||
/* ── Core palette ── */
|
||||
--ink: #E5E5E5;
|
||||
--ink-light: #CCCCCC;
|
||||
--ink-dark: #FFFFFF;
|
||||
--vermilion: #F87171;
|
||||
--vermilion-light: #FCA5A5;
|
||||
--vermilion-dark: #EF4444;
|
||||
--gold: #A78BFA;
|
||||
--gold-light: #C4B5FD;
|
||||
--gold-dark: #8B5CF6;
|
||||
--paper: #1A1A1A;
|
||||
--paper-dark: #141414;
|
||||
--surface: #2D2D2D;
|
||||
--sage: #4ADE80;
|
||||
--amber: #FBBF24;
|
||||
--warm-gray: #999999;
|
||||
--warm-border: #3A3A3A;
|
||||
|
||||
/* ── Semantic tokens ── */
|
||||
--c-primary: var(--ink);
|
||||
--c-primary-light: var(--ink-light);
|
||||
--c-primary-bg: #262626;
|
||||
--c-accent: var(--vermilion);
|
||||
--c-accent-light: var(--vermilion-light);
|
||||
--c-gold: var(--gold);
|
||||
--c-success: var(--sage);
|
||||
--c-warning: var(--amber);
|
||||
--c-danger: var(--vermilion);
|
||||
--c-info: #60A5FA;
|
||||
--c-text: #E5E5E5;
|
||||
--c-text-secondary: #999999;
|
||||
--c-text-muted: #666666;
|
||||
--c-bg: var(--paper);
|
||||
--c-bg-card: var(--surface);
|
||||
--c-border: var(--warm-border);
|
||||
|
||||
/* ── Effects ── */
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,0.20);
|
||||
--shadow: 0 1px 3px rgba(0,0,0,0.30);
|
||||
--shadow-md: 0 2px 6px rgba(0,0,0,0.40);
|
||||
--shadow-lg: 0 4px 12px rgba(0,0,0,0.50);
|
||||
--radius: 0px;
|
||||
--radius-sm: 0px;
|
||||
--radius-xs: 0px;
|
||||
--transition: 0.18s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* ── Sidebar — dark minimal ── */
|
||||
--sidebar-bg: #141414;
|
||||
--sidebar-color: #E5E5E5;
|
||||
--sidebar-accent: var(--gold);
|
||||
--sidebar-nav-text: #777777;
|
||||
--sidebar-nav-text-hover: #CCCCCC;
|
||||
--sidebar-nav-text-active: #FFFFFF;
|
||||
--sidebar-nav-bg-hover: rgba(255,255,255,0.04);
|
||||
--sidebar-nav-bg-active: rgba(255,255,255,0.06);
|
||||
--sidebar-nav-border-active: var(--gold);
|
||||
--sidebar-divider: #2A2A2A;
|
||||
--sidebar-brand-color: #FFFFFF;
|
||||
--sidebar-brand-sub-color: #666666;
|
||||
--sidebar-group-label-color: #555555;
|
||||
--sidebar-footer-role-color: var(--gold);
|
||||
--sidebar-footer-name-color: #999999;
|
||||
|
||||
/* ── Mobile page accent ── */
|
||||
--page-accent-color: rgba(167,139,250,0.06);
|
||||
|
||||
/* ── Manager chip tags ── */
|
||||
--mgr-chip-text: #E5E5E5;
|
||||
|
||||
/* ── Fonts — sans-serif minimal ── */
|
||||
--font-body: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
--font-heading: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 添加 tech-dark 主题 CSS**
|
||||
|
||||
在 minimal-dark 块之后插入:
|
||||
|
||||
```css
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
THEME: Tech Dark — 科技深色 (编辑风暗色版)
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
:root[data-theme="tech-dark"] {
|
||||
/* ── Core palette ── */
|
||||
--ink: #C8D6E5;
|
||||
--ink-light: #A0B4CC;
|
||||
--ink-dark: #E8EEF4;
|
||||
--vermilion: #F26B6B;
|
||||
--vermilion-light: #F59898;
|
||||
--vermilion-dark: #E55555;
|
||||
--gold: #5B8DEF;
|
||||
--gold-light: #7BA5F5;
|
||||
--gold-dark: #4070D0;
|
||||
--paper: #0B1120;
|
||||
--paper-dark: #080C18;
|
||||
--surface: #111B2E;
|
||||
--sage: #4EC9B0;
|
||||
--amber: #E5C07B;
|
||||
--warm-gray: #6B7A8D;
|
||||
--warm-border: #1E2D45;
|
||||
|
||||
/* ── Semantic tokens ── */
|
||||
--c-primary: var(--ink);
|
||||
--c-primary-light: var(--ink-light);
|
||||
--c-primary-bg: #131D30;
|
||||
--c-accent: var(--vermilion);
|
||||
--c-accent-light: var(--vermilion-light);
|
||||
--c-gold: var(--gold);
|
||||
--c-success: var(--sage);
|
||||
--c-warning: var(--amber);
|
||||
--c-danger: var(--vermilion);
|
||||
--c-info: #5B8DEF;
|
||||
--c-text: #C8D6E5;
|
||||
--c-text-secondary: #6B7A8D;
|
||||
--c-text-muted: #455570;
|
||||
--c-bg: var(--paper);
|
||||
--c-bg-card: var(--surface);
|
||||
--c-border: var(--warm-border);
|
||||
|
||||
/* ── Effects ── */
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,0.25);
|
||||
--shadow: 0 2px 6px rgba(0,0,0,0.35);
|
||||
--shadow-md: 0 6px 18px rgba(0,0,0,0.45);
|
||||
--shadow-lg: 0 12px 36px rgba(0,0,0,0.55);
|
||||
--radius: 2px;
|
||||
--radius-sm: 2px;
|
||||
--radius-xs: 2px;
|
||||
--transition: 0.22s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* ── Sidebar — dark tech ── */
|
||||
--sidebar-bg: #070D1A;
|
||||
--sidebar-color: #C8D6E5;
|
||||
--sidebar-accent: var(--gold);
|
||||
--sidebar-nav-text: rgba(200,214,229,0.45);
|
||||
--sidebar-nav-text-hover: rgba(200,214,229,0.80);
|
||||
--sidebar-nav-text-active: #FFFFFF;
|
||||
--sidebar-nav-bg-hover: rgba(91,141,239,0.06);
|
||||
--sidebar-nav-bg-active: rgba(91,141,239,0.10);
|
||||
--sidebar-nav-border-active: var(--gold);
|
||||
--sidebar-divider: rgba(200,214,229,0.06);
|
||||
--sidebar-brand-color: #FFFFFF;
|
||||
--sidebar-brand-sub-color: rgba(200,214,229,0.35);
|
||||
--sidebar-group-label-color: rgba(200,214,229,0.20);
|
||||
--sidebar-footer-role-color: var(--gold);
|
||||
--sidebar-footer-name-color: rgba(200,214,229,0.65);
|
||||
|
||||
/* ── Mobile page accent ── */
|
||||
--page-accent-color: rgba(91,141,239,0.06);
|
||||
|
||||
/* ── Manager chip tags ── */
|
||||
--mgr-chip-text: #C8D6E5;
|
||||
|
||||
/* ── Fonts — editorial Chinese ── */
|
||||
--font-body: 'Noto Serif SC', STSong, Songti SC, '宋体', serif;
|
||||
--font-heading: 'ZCOOL XiaoWei', STSong, Songti SC, serif;
|
||||
--font-mono: 'JetBrains Mono', 'SF Mono', monospace;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/App.vue
|
||||
git commit -m "feat: 新增 minimal-dark 和 tech-dark 两套暗色 CSS 变量"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 改造 DesktopLayout 顶部栏按钮
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/DesktopLayout.vue` (script 和 template 中的主题切换按钮)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `useThemeStore()` — `mode`, `toggleMode()`
|
||||
|
||||
- [ ] **Step 1: 替换主题按钮为亮/暗切换**
|
||||
|
||||
将 `cycleTheme` 函数替换为 `toggleMode`,按钮改为 sun/moon 图标切换:
|
||||
|
||||
```typescript
|
||||
// 替换 cycleTheme 函数:
|
||||
function toggleDarkMode() {
|
||||
themeStore.toggleMode()
|
||||
}
|
||||
```
|
||||
|
||||
Template 中替换按钮:
|
||||
|
||||
```html
|
||||
<button
|
||||
class="topbar-btn"
|
||||
@click="toggleDarkMode"
|
||||
:title="themeStore.mode === 'light' ? '切换至暗色模式' : '切换至亮色模式'"
|
||||
>
|
||||
<!-- Sun icon (light mode) -->
|
||||
<svg v-if="themeStore.mode === 'light'" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="5"></circle>
|
||||
<line x1="12" y1="1" x2="12" y2="3"></line>
|
||||
<line x1="12" y1="21" x2="12" y2="23"></line>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
|
||||
<line x1="1" y1="12" x2="3" y2="12"></line>
|
||||
<line x1="21" y1="12" x2="23" y2="12"></line>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
|
||||
</svg>
|
||||
<!-- Moon icon (dark mode) -->
|
||||
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 触发构建验证**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build -- --emptyOutDir
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/DesktopLayout.vue
|
||||
git commit -m "feat: 桌面端顶部栏改为亮/暗模式切换按钮"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 改造设置页面主题面板
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/desktop/Settings.vue` (替换 Theme 卡片为双选下拉)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `useThemeStore()` — `lightTheme`, `darkTheme`, `mode`, `setLightTheme()`, `setDarkTheme()`
|
||||
|
||||
- [ ] **Step 1: 替换主题选择区域**
|
||||
|
||||
Replace the el-card template section (lines 158-173) and the `themeLabels` import:
|
||||
|
||||
**Script 部分修改 import:**
|
||||
```typescript
|
||||
import { useThemeStore, themeLabels, lightThemes, darkThemes, type Theme } from '@/stores/theme'
|
||||
```
|
||||
|
||||
**Template 部分替换主题卡片:**
|
||||
```html
|
||||
<!-- Theme -->
|
||||
<el-card class="setting-card">
|
||||
<template #header>
|
||||
<div class="card-header-title">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:6px; color: var(--gold)">
|
||||
<circle cx="12" cy="12" r="5"></circle><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"></path>
|
||||
</svg>
|
||||
界面主题
|
||||
</div>
|
||||
</template>
|
||||
<div class="theme-config">
|
||||
<div class="theme-row">
|
||||
<span class="theme-label">☀️ 亮色模式主题</span>
|
||||
<el-select :model-value="themeStore.lightTheme" @update:model-value="themeStore.setLightTheme($event as Theme)" style="width:200px">
|
||||
<el-option v-for="t in lightThemes" :key="t" :label="themeLabels[t]" :value="t" />
|
||||
</el-select>
|
||||
<span v-if="themeStore.mode === 'light'" class="theme-badge theme-badge--active">当前</span>
|
||||
</div>
|
||||
<div class="theme-row">
|
||||
<span class="theme-label">🌙 暗色模式主题</span>
|
||||
<el-select :model-value="themeStore.darkTheme" @update:model-value="themeStore.setDarkTheme($event as Theme)" style="width:200px">
|
||||
<el-option v-for="t in darkThemes" :key="t" :label="themeLabels[t]" :value="t" />
|
||||
</el-select>
|
||||
<span v-if="themeStore.mode === 'dark'" class="theme-badge theme-badge--active">当前</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
```
|
||||
|
||||
**Style 部分替换 `.theme-options` 样式:**
|
||||
```css
|
||||
/* ── Theme Config ── */
|
||||
.theme-config { display: flex; flex-direction: column; gap: 12px; }
|
||||
.theme-row { display: flex; align-items: center; gap: 12px; }
|
||||
.theme-label { font-family: var(--font-body); font-size: 14px; color: var(--c-text); min-width: 140px; }
|
||||
.theme-badge { font-size: 11px; padding: 2px 8px; border-radius: 2px; font-family: var(--font-mono); }
|
||||
.theme-badge--active { background: var(--c-primary); color: #fff; }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 触发构建验证**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build -- --emptyOutDir
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/views/desktop/Settings.vue
|
||||
git commit -m "feat: 设置页改为亮/暗双选主题配置面板"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 验证与收尾
|
||||
|
||||
- [ ] **Step 1: 完整构建**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 检查 localStorage 兼容性**
|
||||
|
||||
验证旧 `localStorage.setItem('theme', ...)` 数据不会导致新系统崩溃。在 theme store 的 `apply()` 中已有兜底逻辑。
|
||||
|
||||
- [ ] **Step 3: 快速手动测试检查清单**
|
||||
- [ ] 默认加载为 editorial 亮色
|
||||
- [ ] 点击顶部栏 ☀️→🌙 切换到 tech-dark
|
||||
- [ ] 设置页切换亮色主题为 light,暗色主题为 minimal-dark
|
||||
- [ ] 点击顶部栏按钮在两主题间切换
|
||||
- [ ] 刷新页面偏好保持
|
||||
- [ ] Element Plus 组件(表格/对话框/按钮)在暗色下正常
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore: 暗黑模式最终验证通过"
|
||||
```
|
||||
@@ -0,0 +1,130 @@
|
||||
# 商机跟单优化 — 跟进记录子表 — 设计文档
|
||||
|
||||
> 日期:2026-07-12 | 状态:已确认
|
||||
|
||||
## 一、需求概述
|
||||
|
||||
当前商机跟单只有一条静态记录(产品+金额+跟进内容文本+状态),缺少"持续跟进"的时间线感。新增跟进记录子表,将每个商机从"快照"变为"持续跟单"。
|
||||
|
||||
## 二、核心决策
|
||||
|
||||
| 决策项 | 结论 |
|
||||
|--------|------|
|
||||
| 方案 | 新增 `mini_business_logs` 子表 |
|
||||
| 跟进方式 | 电话/微信/上门/邮件/其他(chip 按钮选择) |
|
||||
| 原 follow_up_detail | 保留,含义改为"商机概述/背景" |
|
||||
| 状态 | 不变(跟进中/已签约/已流失) |
|
||||
|
||||
## 三、新增表 `mini_business_logs`
|
||||
|
||||
```sql
|
||||
CREATE TABLE mini_business_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
business_id UUID NOT NULL REFERENCES mini_business(id) ON DELETE CASCADE,
|
||||
log_date DATE NOT NULL,
|
||||
method VARCHAR(20) NOT NULL DEFAULT '电话',
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
created_by UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
CREATE INDEX idx_mbl_business_id ON mini_business_logs(business_id);
|
||||
CREATE INDEX idx_mbl_log_date ON mini_business_logs(log_date);
|
||||
```
|
||||
|
||||
### SQLAlchemy 模型
|
||||
|
||||
```python
|
||||
class MiniBusinessLog(Base):
|
||||
__tablename__ = "mini_business_logs"
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
business_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("mini_business.id", ondelete="CASCADE"), index=True)
|
||||
log_date: Mapped[date] = mapped_column(Date)
|
||||
method: Mapped[str] = mapped_column(String(20), default="电话")
|
||||
content: Mapped[str] = mapped_column(Text, default="")
|
||||
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())
|
||||
```
|
||||
|
||||
### MiniBusiness 模型添加 relationship
|
||||
|
||||
```python
|
||||
logs: Mapped[list["MiniBusinessLog"]] = relationship(back_populates="business", cascade="all, delete-orphan")
|
||||
```
|
||||
|
||||
## 四、商机主体字段调整
|
||||
|
||||
| 字段 | 变更 | 前端 label |
|
||||
|------|------|-----------|
|
||||
| `follow_up_detail` | 保留,含义改为商机概述 | "商机概述" |
|
||||
| 其他字段 | 不变 | |
|
||||
|
||||
## 五、API
|
||||
|
||||
### 子路由
|
||||
|
||||
| 方法 | 路径 | 说明 | 权限 |
|
||||
|------|------|------|------|
|
||||
| `GET` | `/mini-business/{id}/logs` | 获取跟进记录列表(date DESC) | 经理仅看自己 |
|
||||
| `POST` | `/mini-business/{id}/logs` | 新增跟进记录 | 经理本人或支局长/领导 |
|
||||
| `PUT` | `/mini-business/logs/{log_id}` | 编辑跟进记录 | 创建人或支局长/领导 |
|
||||
| `DELETE` | `/mini-business/logs/{log_id}` | 删除跟进记录 | 创建人或支局长/领导 |
|
||||
|
||||
### 请求体 (POST/PUT)
|
||||
|
||||
```json
|
||||
{
|
||||
"log_date": "2026-07-12",
|
||||
"method": "电话",
|
||||
"content": "客户反馈价格偏高,需进一步沟通方案"
|
||||
}
|
||||
```
|
||||
|
||||
### 跟进方式枚举
|
||||
|
||||
`["电话", "微信", "上门", "邮件", "其他"]`
|
||||
|
||||
## 六、前端
|
||||
|
||||
### PC 端 — 商机列表页增强
|
||||
|
||||
- 表格新增「最近跟进」列(最近一次跟进日期 + 跟进次数 badge)
|
||||
- 点击客户名打开详情弹窗(替代直接编辑)
|
||||
- 详情弹窗:上部=商机基本信息(客户/产品/金额/状态/概述)+ 编辑按钮,下部=跟进时间线 + 新增跟进入口
|
||||
|
||||
### PC 端 — 跟进时间线组件
|
||||
|
||||
```
|
||||
📞 07-10 电话 — 客户反馈价格偏高,需进一步沟通 [编辑] [删除]
|
||||
🏢 07-05 上门 — 现场演示产品功能,客户初步认可 [编辑] [删除]
|
||||
📧 07-01 邮件 — 发送报价方案 [编辑] [删除]
|
||||
```
|
||||
|
||||
- 按日期倒序,最新在上
|
||||
- 每条显示:方式图标 + 日期 + 内容 + 操作按钮
|
||||
- 底部「新增跟进」按钮展开内联表单(日期+方式 chip+内容 textarea)
|
||||
|
||||
### 移动端 — 商机表单页改为详情页
|
||||
|
||||
- 现有新建表单保留
|
||||
- 新增商机详情页(路由 `/m/mini-biz/:id`)
|
||||
- 上部:商机信息卡片(可点击编辑)
|
||||
- 下部:跟进时间线 + 底部固定「新增跟进」按钮
|
||||
|
||||
### 移动端 — 首页入口
|
||||
|
||||
- Home.vue 的「商机跟单」chip 改为跳转商机列表(需新增 `/m/mini-biz` 路由)
|
||||
|
||||
## 七、文件变更
|
||||
|
||||
| 类型 | 文件 | 说明 |
|
||||
|------|------|------|
|
||||
| 新增 | `models/mini_business_log.py` | Log 模型 |
|
||||
| 新增 | `schemas/mini_business_log.py` | Log Schema |
|
||||
| 新增 | `api/mini_business_logs.py` | Log CRUD API |
|
||||
| 修改 | `models/mini_business.py` | 添加 logs relationship |
|
||||
| 修改 | `models/__init__.py` | 注册新模型 |
|
||||
| 修改 | `main.py` | lifespan 自动建表 |
|
||||
| 修改 | `MiniBusiness.vue` (PC) | 详情弹窗 + 时间线 |
|
||||
| 修改 | `MiniBusinessForm.vue` (mobile) | 改为详情页模式 |
|
||||
| 新增 | `frontend/src/api/miniBusiness.ts` | 追加 logs API |
|
||||
| 新增 | 移动端商机列表页 | `/m/mini-biz` |
|
||||
@@ -0,0 +1,122 @@
|
||||
# 工作计划状态自动流转 — 设计文档
|
||||
|
||||
> 日期:2026-07-12 | 状态:已确认
|
||||
|
||||
## 一、需求概述
|
||||
|
||||
工作计划目前有三种状态(计划中/已完成/已取消),完全依赖手动切换。利用已有的拜访记录数据,实现状态的自动流转,减少客户经理手动操作。
|
||||
|
||||
## 二、核心决策
|
||||
|
||||
| 决策项 | 结论 |
|
||||
|--------|------|
|
||||
| 匹配精度 | 仅匹配 `customer_id`(任何人拜访该客户即触发,与现有逻辑一致) |
|
||||
| 增强范围 | 创建/更新触发完成 + 逾期自动取消 + Excel 导入联动 |
|
||||
| 删除回退 | 不做(边缘场景少,复杂度高) |
|
||||
|
||||
## 三、现状
|
||||
|
||||
- `POST /visits/` 已有自动完成逻辑:同一 `customer_id` + `plan_date <= visit_date` → 标记"已完成"
|
||||
- 该逻辑嵌在 API 层,不可复用
|
||||
- 更新拜访、Excel 导入均不触发自动完成
|
||||
- 逾期计划(`plan_date < today`)只发企微提醒,不自动取消
|
||||
|
||||
## 四、设计
|
||||
|
||||
### 4.1 抽取可复用函数
|
||||
|
||||
**文件:** `backend/app/services/visits.py`(如不存在则新建,如已有则追加)
|
||||
|
||||
```python
|
||||
async def auto_complete_work_plans(
|
||||
db: AsyncSession,
|
||||
customer_id: UUID,
|
||||
visit_date: date,
|
||||
editor_name: str,
|
||||
reason: str = "拜访自动完成",
|
||||
) -> int:
|
||||
"""将匹配的工作计划自动标记为已完成。返回完成数量。"""
|
||||
from app.models.work_plan import WorkPlan
|
||||
from app.utils.edit_log import append_entry as append_edit_log
|
||||
|
||||
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_edit_log(plan, editor_name, [{
|
||||
"field": "status", "from": "计划中", "to": "已完成",
|
||||
"reason": reason,
|
||||
}])
|
||||
count += 1
|
||||
return count
|
||||
```
|
||||
|
||||
### 4.2 三处调用点
|
||||
|
||||
| 调用点 | 文件 | 触发时机 | reason 参数 |
|
||||
|--------|------|---------|------------|
|
||||
| POST /visits/ | `api/visits.py` | 创建拜访后 | `"拜访自动完成"` |
|
||||
| PUT /visits/{id} | `api/visits.py` | 更新拜访后 | `"拜访更新自动完成"` |
|
||||
| excel_import.py | `services/excel_import.py` | 导入每条拜访后 | `"旧周报导入自动完成"` |
|
||||
|
||||
### 4.3 逾期计划自动取消
|
||||
|
||||
**文件:** `backend/app/services/scheduler.py`,`check_overdue_plans` 函数
|
||||
|
||||
在现有「发送企微提醒」逻辑后新增:
|
||||
|
||||
```python
|
||||
# 自动取消:逾期且无匹配拜访记录的计划
|
||||
for plan in overdue:
|
||||
has_visit = await db.execute(
|
||||
select(Visit.id).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": "逾期自动取消",
|
||||
}])
|
||||
```
|
||||
|
||||
每日 09:00 执行,与现有逾期检查共用一个调度任务。
|
||||
|
||||
### 4.4 状态流转总图
|
||||
|
||||
```
|
||||
计划中 ──┬── 拜访创建/更新/导入(customer_id + plan_date <= visit_date)──→ 已完成
|
||||
│
|
||||
└── 每日 09:00 调度(plan_date < today 且无匹配拜访)───────────→ 已取消
|
||||
```
|
||||
|
||||
## 五、文件变更
|
||||
|
||||
### 后端新增/修改
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `backend/app/services/visits.py` | 新增 `auto_complete_work_plans()` 函数 |
|
||||
| `backend/app/api/visits.py` | POST 重构为调用 service 函数;PUT 新增调用 |
|
||||
| `backend/app/services/excel_import.py` | 导入拜访后调用自动完成 |
|
||||
| `backend/app/services/scheduler.py` | `check_overdue_plans` 新增自动取消逻辑 |
|
||||
|
||||
### 前端
|
||||
|
||||
无需改动。
|
||||
|
||||
## 六、验证要点
|
||||
|
||||
1. 创建拜访 → 匹配的计划自动变为"已完成",edit_log 有记录
|
||||
2. 更新拜访日期 → 匹配的计划自动完成
|
||||
3. 导入旧周报 → 导入的拜访触发计划自动完成
|
||||
4. 逾期且无拜访的计划 → 每日 09:00 自动变为"已取消"
|
||||
5. 逾期但有拜访的计划 → 保持"计划中"(已有拜访覆盖,不算僵尸)
|
||||
@@ -6,6 +6,14 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>企迹 - 政企周报管理系统</title>
|
||||
|
||||
<!-- Apply saved theme before page render to prevent flash -->
|
||||
<script>
|
||||
(function() {
|
||||
var theme = localStorage.getItem('theme') || 'editorial';
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Preconnect to Google Fonts origins for faster font loading -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
|
||||
@@ -177,6 +177,215 @@ const isMobile = computed(() => {
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
THEME: Minimal Dark — 极简深色
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
:root[data-theme="minimal-dark"] {
|
||||
/* ── Core palette ── */
|
||||
--ink: #E5E5E5;
|
||||
--ink-light: #CCCCCC;
|
||||
--ink-dark: #FFFFFF;
|
||||
--vermilion: #F87171;
|
||||
--vermilion-light: #FCA5A5;
|
||||
--vermilion-dark: #EF4444;
|
||||
--gold: #A78BFA;
|
||||
--gold-light: #C4B5FD;
|
||||
--gold-dark: #8B5CF6;
|
||||
--paper: #141414;
|
||||
--paper-dark: #0D0D0D;
|
||||
--surface: #2D2D2D;
|
||||
--sage: #4ADE80;
|
||||
--amber: #FBBF24;
|
||||
--warm-gray: #999999;
|
||||
--warm-border: #3A3A3A;
|
||||
|
||||
/* ── Semantic tokens ── */
|
||||
--c-primary: var(--ink);
|
||||
--c-primary-light: var(--ink-light);
|
||||
--c-primary-bg: #262626;
|
||||
--c-accent: var(--vermilion);
|
||||
--c-accent-light: var(--vermilion-light);
|
||||
--c-gold: var(--gold);
|
||||
--c-success: var(--sage);
|
||||
--c-warning: var(--amber);
|
||||
--c-danger: var(--vermilion);
|
||||
--c-info: #60A5FA;
|
||||
--c-text: #E5E5E5;
|
||||
--c-text-secondary: #999999;
|
||||
--c-text-muted: #666666;
|
||||
--c-bg: var(--paper);
|
||||
--c-bg-card: var(--surface);
|
||||
--c-border: var(--warm-border);
|
||||
|
||||
/* ── Effects ── */
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,0.20);
|
||||
--shadow: 0 1px 3px rgba(0,0,0,0.30);
|
||||
--shadow-md: 0 2px 6px rgba(0,0,0,0.40);
|
||||
--shadow-lg: 0 4px 12px rgba(0,0,0,0.50);
|
||||
--radius: 0px;
|
||||
--radius-sm: 0px;
|
||||
--radius-xs: 0px;
|
||||
--transition: 0.18s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* ── Misc dark tokens ── */
|
||||
--c-bg-light: #1E1E1E;
|
||||
|
||||
/* ── Sidebar — dark minimal ── */
|
||||
--sidebar-bg: #1A1A1A;
|
||||
--sidebar-color: #E5E5E5;
|
||||
--sidebar-accent: var(--gold);
|
||||
--sidebar-nav-text: #777777;
|
||||
--sidebar-nav-text-hover: #CCCCCC;
|
||||
--sidebar-nav-text-active: #FFFFFF;
|
||||
--sidebar-nav-bg-hover: rgba(255,255,255,0.04);
|
||||
--sidebar-nav-bg-active: rgba(255,255,255,0.06);
|
||||
--sidebar-nav-border-active: var(--gold);
|
||||
--sidebar-divider: #2A2A2A;
|
||||
--sidebar-brand-color: #FFFFFF;
|
||||
--sidebar-brand-sub-color: #666666;
|
||||
--sidebar-group-label-color: #555555;
|
||||
--sidebar-footer-role-color: var(--gold);
|
||||
--sidebar-footer-name-color: #999999;
|
||||
|
||||
/* ── Mobile page accent ── */
|
||||
--page-accent-color: rgba(167,139,250,0.06);
|
||||
|
||||
/* ── Manager chip tags ── */
|
||||
--mgr-chip-text: #E5E5E5;
|
||||
|
||||
/* ── Fonts — sans-serif minimal ── */
|
||||
--font-body: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
--font-heading: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
THEME: Tech Dark — 科技深色 (编辑风暗色版)
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
:root[data-theme="tech-dark"] {
|
||||
/* ── Core palette ── */
|
||||
--ink: #C8D6E5;
|
||||
--ink-light: #A0B4CC;
|
||||
--ink-dark: #E8EEF4;
|
||||
--vermilion: #F26B6B;
|
||||
--vermilion-light: #F59898;
|
||||
--vermilion-dark: #E55555;
|
||||
--gold: #5B8DEF;
|
||||
--gold-light: #7BA5F5;
|
||||
--gold-dark: #4070D0;
|
||||
--paper: #070D1A;
|
||||
--paper-dark: #050910;
|
||||
--surface: #111B2E;
|
||||
--sage: #4EC9B0;
|
||||
--amber: #E5C07B;
|
||||
--warm-gray: #6B7A8D;
|
||||
--warm-border: #1E2D45;
|
||||
|
||||
/* ── Semantic tokens ── */
|
||||
--c-primary: var(--ink);
|
||||
--c-primary-light: var(--ink-light);
|
||||
--c-primary-bg: #131D30;
|
||||
--c-accent: var(--vermilion);
|
||||
--c-accent-light: var(--vermilion-light);
|
||||
--c-gold: var(--gold);
|
||||
--c-success: var(--sage);
|
||||
--c-warning: var(--amber);
|
||||
--c-danger: var(--vermilion);
|
||||
--c-info: #5B8DEF;
|
||||
--c-text: #C8D6E5;
|
||||
--c-text-secondary: #6B7A8D;
|
||||
--c-text-muted: #455570;
|
||||
--c-bg: var(--paper);
|
||||
--c-bg-card: var(--surface);
|
||||
--c-border: var(--warm-border);
|
||||
|
||||
/* ── Effects ── */
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,0.25);
|
||||
--shadow: 0 2px 6px rgba(0,0,0,0.35);
|
||||
--shadow-md: 0 6px 18px rgba(0,0,0,0.45);
|
||||
--shadow-lg: 0 12px 36px rgba(0,0,0,0.55);
|
||||
--radius: 2px;
|
||||
--radius-sm: 2px;
|
||||
--radius-xs: 2px;
|
||||
--transition: 0.22s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* ── Misc dark tokens ── */
|
||||
--c-bg-light: #101A28;
|
||||
|
||||
/* ── Sidebar — dark tech ── */
|
||||
--sidebar-bg: #0B1120;
|
||||
--sidebar-color: #C8D6E5;
|
||||
--sidebar-accent: var(--gold);
|
||||
--sidebar-nav-text: rgba(200,214,229,0.45);
|
||||
--sidebar-nav-text-hover: rgba(200,214,229,0.80);
|
||||
--sidebar-nav-text-active: #FFFFFF;
|
||||
--sidebar-nav-bg-hover: rgba(91,141,239,0.06);
|
||||
--sidebar-nav-bg-active: rgba(91,141,239,0.10);
|
||||
--sidebar-nav-border-active: var(--gold);
|
||||
--sidebar-divider: rgba(200,214,229,0.06);
|
||||
--sidebar-brand-color: #FFFFFF;
|
||||
--sidebar-brand-sub-color: rgba(200,214,229,0.35);
|
||||
--sidebar-group-label-color: rgba(200,214,229,0.20);
|
||||
--sidebar-footer-role-color: var(--gold);
|
||||
--sidebar-footer-name-color: rgba(200,214,229,0.65);
|
||||
|
||||
/* ── Mobile page accent ── */
|
||||
--page-accent-color: rgba(91,141,239,0.06);
|
||||
|
||||
/* ── Manager chip tags ── */
|
||||
--mgr-chip-text: #C8D6E5;
|
||||
|
||||
/* ── Fonts — editorial Chinese ── */
|
||||
--font-body: 'Noto Serif SC', STSong, Songti SC, '宋体', serif;
|
||||
--font-heading: 'ZCOOL XiaoWei', STSong, Songti SC, serif;
|
||||
--font-mono: 'JetBrains Mono', 'SF Mono', monospace;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
Dark Mode — Element Plus CSS Variable Overrides
|
||||
Override --el-* variables so ALL components render dark
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
html.dark {
|
||||
--el-color-white: var(--surface);
|
||||
--el-color-black: var(--c-text);
|
||||
--el-color-primary: var(--c-gold);
|
||||
--el-color-primary-light-3: var(--gold-light);
|
||||
--el-color-primary-light-5: var(--gold-light);
|
||||
--el-color-primary-light-7: var(--c-primary-bg);
|
||||
--el-color-primary-light-8: var(--c-primary-bg);
|
||||
--el-color-primary-light-9: var(--paper-dark);
|
||||
--el-color-primary-dark-2: var(--gold-dark);
|
||||
--el-color-success: var(--c-success);
|
||||
--el-color-warning: var(--c-warning);
|
||||
--el-color-danger: var(--c-danger);
|
||||
--el-color-info: var(--c-info);
|
||||
--el-bg-color: var(--paper);
|
||||
--el-bg-color-page: var(--paper);
|
||||
--el-bg-color-overlay: var(--surface);
|
||||
--el-text-color-primary: var(--c-text);
|
||||
--el-text-color-regular: var(--c-text);
|
||||
--el-text-color-secondary: var(--c-text-secondary);
|
||||
--el-text-color-placeholder: var(--c-text-muted);
|
||||
--el-text-color-disabled: var(--c-text-muted);
|
||||
--el-border-color: var(--warm-border);
|
||||
--el-border-color-light: var(--warm-border);
|
||||
--el-border-color-lighter: var(--warm-border);
|
||||
--el-border-color-extra-light: var(--warm-border);
|
||||
--el-border-color-dark: var(--c-text-muted);
|
||||
--el-fill-color: var(--c-primary-bg);
|
||||
--el-fill-color-light: var(--c-primary-bg);
|
||||
--el-fill-color-lighter: var(--paper-dark);
|
||||
--el-fill-color-blank: var(--surface);
|
||||
--el-disabled-bg-color: var(--paper-dark);
|
||||
--el-disabled-text-color: var(--c-text-muted);
|
||||
--el-disabled-border-color: var(--warm-border);
|
||||
--el-mask-color: rgba(0,0,0,0.6);
|
||||
--el-mask-color-extra-light: rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
/* ── Reset & Base ── */
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
@@ -466,4 +675,211 @@ body.col-resizing {
|
||||
cursor: col-resize !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
Dark Mode — Element Plus Global Overrides
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
html.dark .el-input__wrapper {
|
||||
background: var(--surface) !important;
|
||||
box-shadow: 0 0 0 1px var(--warm-border) inset !important;
|
||||
}
|
||||
html.dark .el-input__inner {
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
html.dark .el-input__wrapper:hover {
|
||||
box-shadow: 0 0 0 1px var(--c-text-muted) inset !important;
|
||||
}
|
||||
html.dark .el-input__wrapper.is-focus {
|
||||
box-shadow: 0 0 0 2px var(--gold) inset !important;
|
||||
}
|
||||
|
||||
/* Select dropdown */
|
||||
html.dark .el-select-dropdown {
|
||||
background: var(--surface) !important;
|
||||
border-color: var(--warm-border) !important;
|
||||
}
|
||||
html.dark .el-select-dropdown__item {
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
html.dark .el-select-dropdown__item:hover {
|
||||
background: var(--c-primary-bg) !important;
|
||||
}
|
||||
html.dark .el-select-dropdown__item.selected {
|
||||
color: var(--gold) !important;
|
||||
}
|
||||
|
||||
/* Table */
|
||||
html.dark .el-table {
|
||||
--el-table-bg-color: var(--surface) !important;
|
||||
--el-table-tr-bg-color: var(--surface) !important;
|
||||
--el-table-header-bg-color: var(--c-primary-bg) !important;
|
||||
--el-table-row-hover-bg-color: var(--c-primary-bg) !important;
|
||||
--el-table-border-color: var(--warm-border) !important;
|
||||
--el-table-text-color: var(--c-text) !important;
|
||||
--el-table-header-text-color: var(--c-text) !important;
|
||||
}
|
||||
html.dark .el-table th.el-table__cell {
|
||||
background: var(--c-primary-bg) !important;
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
html.dark .el-table td.el-table__cell {
|
||||
background: var(--surface) !important;
|
||||
}
|
||||
html.dark .el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell {
|
||||
background: var(--c-primary-bg) !important;
|
||||
}
|
||||
html.dark .el-table__body tr:hover > td.el-table__cell {
|
||||
background: var(--c-primary-bg) !important;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
html.dark .el-pagination button,
|
||||
html.dark .el-pager li {
|
||||
background: var(--surface) !important;
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
html.dark .el-pager li.is-active {
|
||||
background: var(--ink) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
html.dark .el-pagination button:hover,
|
||||
html.dark .el-pager li:hover {
|
||||
color: var(--gold) !important;
|
||||
}
|
||||
|
||||
/* Button — default / text variant */
|
||||
html.dark .el-button--default {
|
||||
--el-button-bg-color: var(--surface);
|
||||
--el-button-border-color: var(--warm-border);
|
||||
--el-button-text-color: var(--c-text);
|
||||
--el-button-hover-bg-color: var(--c-primary-bg);
|
||||
--el-button-hover-border-color: var(--c-text-muted);
|
||||
--el-button-hover-text-color: var(--c-text);
|
||||
}
|
||||
|
||||
/* Dialog */
|
||||
html.dark .el-dialog {
|
||||
--el-dialog-bg-color: var(--surface);
|
||||
}
|
||||
html.dark .el-dialog__title {
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
html.dark .el-dialog__header {
|
||||
border-bottom: 1px solid var(--warm-border);
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
html.dark .el-tabs__item {
|
||||
color: var(--c-text-secondary) !important;
|
||||
}
|
||||
html.dark .el-tabs__item.is-active {
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
html.dark .el-tabs__header {
|
||||
border-bottom-color: var(--warm-border);
|
||||
}
|
||||
|
||||
/* Tag */
|
||||
html.dark .el-tag {
|
||||
--el-tag-bg-color: var(--c-primary-bg);
|
||||
--el-tag-border-color: var(--warm-border);
|
||||
--el-tag-text-color: var(--c-text);
|
||||
}
|
||||
|
||||
/* Form label */
|
||||
html.dark .el-form-item__label {
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
|
||||
/* Progress */
|
||||
html.dark .el-progress-bar__outer {
|
||||
background: var(--paper-dark) !important;
|
||||
}
|
||||
|
||||
/* Date picker panel */
|
||||
html.dark .el-picker-panel {
|
||||
background: var(--surface) !important;
|
||||
border-color: var(--warm-border) !important;
|
||||
}
|
||||
html.dark .el-date-picker__header-label {
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
html.dark .el-date-table td {
|
||||
color: var(--c-text);
|
||||
}
|
||||
html.dark .el-date-table td.current:not(.disabled) .el-date-table-cell__text {
|
||||
background: var(--gold) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
html.dark .el-date-table td.today .el-date-table-cell__text {
|
||||
color: var(--gold) !important;
|
||||
}
|
||||
|
||||
/* Time picker panel */
|
||||
html.dark .el-time-panel {
|
||||
background: var(--surface) !important;
|
||||
border-color: var(--warm-border) !important;
|
||||
}
|
||||
html.dark .el-time-spinner__item {
|
||||
color: var(--c-text-muted) !important;
|
||||
}
|
||||
html.dark .el-time-spinner__item.is-active {
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
|
||||
/* Popover / tooltip */
|
||||
html.dark .el-popover,
|
||||
html.dark .el-tooltip__popper {
|
||||
background: var(--surface) !important;
|
||||
border-color: var(--warm-border) !important;
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
|
||||
/* Message box confirm */
|
||||
html.dark .el-message-box {
|
||||
--el-messagebox-bg-color: var(--surface);
|
||||
--el-messagebox-title-color: var(--c-text);
|
||||
--el-messagebox-content-color: var(--c-text-secondary);
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
html.dark .el-empty__description p {
|
||||
color: var(--c-text-muted) !important;
|
||||
}
|
||||
|
||||
/* Radio / Checkbox label */
|
||||
html.dark .el-radio__label,
|
||||
html.dark .el-checkbox__label {
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
|
||||
/* Cascader / Tree select */
|
||||
html.dark .el-cascader-node__label {
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
html.dark .el-cascader-menu {
|
||||
background: var(--surface) !important;
|
||||
border-color: var(--warm-border) !important;
|
||||
}
|
||||
|
||||
/* Alert */
|
||||
html.dark .el-alert {
|
||||
--el-alert-bg-color: var(--c-primary-bg);
|
||||
}
|
||||
|
||||
/* Drawer */
|
||||
html.dark .el-drawer {
|
||||
background: var(--surface) !important;
|
||||
}
|
||||
|
||||
/* Divider */
|
||||
html.dark .el-divider__text {
|
||||
background: var(--surface) !important;
|
||||
}
|
||||
|
||||
/* Loading mask */
|
||||
html.dark .el-loading-mask {
|
||||
background-color: rgba(0,0,0,0.5) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -13,4 +13,17 @@ export const miniBusinessApi = {
|
||||
delete(id: string) {
|
||||
return api.delete(`/mini-business/${id}`)
|
||||
},
|
||||
// Follow-up logs
|
||||
getLogs(businessId: string) {
|
||||
return api.get(`/mini-business/${businessId}/logs`)
|
||||
},
|
||||
createLog(businessId: string, data: any) {
|
||||
return api.post(`/mini-business/${businessId}/logs`, data)
|
||||
},
|
||||
updateLog(logId: string, data: any) {
|
||||
return api.put(`/mini-business/logs/${logId}`, data)
|
||||
},
|
||||
deleteLog(logId: string) {
|
||||
return api.delete(`/mini-business/logs/${logId}`)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useThemeStore, themeLabels } from '@/stores/theme'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -44,13 +44,15 @@ const menuGroups = computed<MenuGroup[]>(() => {
|
||||
label: '管理',
|
||||
items: [
|
||||
{ path: '/customers', label: '客户管理', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle>' },
|
||||
{ path: '/settings', label: '系统设置', icon: '<circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>' },
|
||||
],
|
||||
})
|
||||
if (auth.isDirector) {
|
||||
groups[groups.length - 1].items.splice(1, 0,
|
||||
{ path: '/users', label: '用户管理', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path>' },
|
||||
)
|
||||
groups[groups.length - 1].items.push(
|
||||
{ path: '/audit-log', label: '操作日志', icon: '<polyline points="9 11 12 14 22 4"></polyline><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>' },
|
||||
{ path: '/users', label: '用户管理', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path>' },
|
||||
{ path: '/settings', label: '系统设置', icon: '<circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>' },
|
||||
)
|
||||
}
|
||||
return groups
|
||||
@@ -63,8 +65,7 @@ const roleLabel = computed(() => {
|
||||
})
|
||||
|
||||
function cycleTheme() {
|
||||
const next = themeStore.currentTheme === 'editorial' ? 'light' : 'editorial'
|
||||
themeStore.setTheme(next)
|
||||
themeStore.toggleMode()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -109,11 +110,11 @@ function cycleTheme() {
|
||||
</svg>
|
||||
</button>
|
||||
<div class="topbar-actions">
|
||||
<button class="topbar-btn" @click="cycleTheme" :title="'切换至' + themeLabels[themeStore.currentTheme === 'editorial' ? 'light' : 'editorial']">
|
||||
<button class="topbar-btn" @click="cycleTheme" :title="'切换至' + (themeStore.mode === 'light' ? '深色模式' : '亮色模式')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle v-if="themeStore.currentTheme === 'editorial'" cx="12" cy="12" r="5"></circle>
|
||||
<path v-if="themeStore.currentTheme === 'editorial'" d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"></path>
|
||||
<path v-if="themeStore.currentTheme === 'light'" d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
||||
<path v-if="themeStore.mode === 'light'" d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
||||
<circle v-if="themeStore.mode === 'dark'" cx="12" cy="12" r="5"></circle>
|
||||
<path v-if="themeStore.mode === 'dark'" d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="topbar-btn" @click="router.push('/m')" title="移动端">
|
||||
|
||||
@@ -32,10 +32,14 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
userId.value = ''
|
||||
name.value = ''
|
||||
role.value = ''
|
||||
// Preserve theme preference across logout
|
||||
const savedTheme = localStorage.getItem('theme')
|
||||
// Preserve theme preferences across logout
|
||||
const savedMode = localStorage.getItem('theme-mode')
|
||||
const savedLight = localStorage.getItem('theme-light')
|
||||
const savedDark = localStorage.getItem('theme-dark')
|
||||
localStorage.clear()
|
||||
if (savedTheme) localStorage.setItem('theme', savedTheme)
|
||||
if (savedMode) localStorage.setItem('theme-mode', savedMode)
|
||||
if (savedLight) localStorage.setItem('theme-light', savedLight)
|
||||
if (savedDark) localStorage.setItem('theme-dark', savedDark)
|
||||
}
|
||||
|
||||
async function casdoorLogin(code: string, state: string) {
|
||||
|
||||
@@ -1,35 +1,61 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export type Theme = 'editorial' | 'light'
|
||||
export type Theme = 'editorial' | 'light' | 'minimal-dark' | 'tech-dark'
|
||||
type Mode = 'light' | 'dark'
|
||||
|
||||
export const themeLabels: Record<Theme, string> = {
|
||||
editorial: '编辑风',
|
||||
light: '极简亮色',
|
||||
'minimal-dark': '极简深色',
|
||||
'tech-dark': '科技深色',
|
||||
}
|
||||
|
||||
export const useThemeStore = defineStore('theme', () => {
|
||||
const currentTheme = ref<Theme>(
|
||||
(localStorage.getItem('theme') as Theme) || 'editorial'
|
||||
)
|
||||
export const lightThemes: Theme[] = ['editorial', 'light']
|
||||
export const darkThemes: Theme[] = ['minimal-dark', 'tech-dark']
|
||||
|
||||
function applyTheme(theme: Theme) {
|
||||
export const useThemeStore = defineStore('theme', () => {
|
||||
const mode = ref<Mode>((localStorage.getItem('theme-mode') as Mode) || 'light')
|
||||
const lightTheme = ref<Theme>((localStorage.getItem('theme-light') as Theme) || 'editorial')
|
||||
const darkTheme = ref<Theme>((localStorage.getItem('theme-dark') as Theme) || 'tech-dark')
|
||||
|
||||
const effectiveTheme = computed<Theme>(() => {
|
||||
return mode.value === 'light' ? lightTheme.value : darkTheme.value
|
||||
})
|
||||
|
||||
function applyTheme(theme: Theme, isDark: boolean) {
|
||||
document.documentElement.setAttribute('data-theme', theme)
|
||||
if (isDark) {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
}
|
||||
|
||||
async function setTheme(theme: Theme) {
|
||||
currentTheme.value = theme
|
||||
localStorage.setItem('theme', theme)
|
||||
applyTheme(theme)
|
||||
// Sync to backend (fire-and-forget)
|
||||
try {
|
||||
const { default: api } = await import('@/api/index')
|
||||
await api.put('/users/me/theme', { theme })
|
||||
} catch (_) { /* non-critical */ }
|
||||
function apply() {
|
||||
applyTheme(effectiveTheme.value, mode.value === 'dark')
|
||||
}
|
||||
|
||||
function setLightTheme(theme: Theme) {
|
||||
lightTheme.value = theme
|
||||
localStorage.setItem('theme-light', theme)
|
||||
if (mode.value === 'light') apply()
|
||||
}
|
||||
|
||||
function setDarkTheme(theme: Theme) {
|
||||
darkTheme.value = theme
|
||||
localStorage.setItem('theme-dark', theme)
|
||||
if (mode.value === 'dark') apply()
|
||||
}
|
||||
|
||||
function toggleMode() {
|
||||
mode.value = mode.value === 'light' ? 'dark' : 'light'
|
||||
localStorage.setItem('theme-mode', mode.value)
|
||||
apply()
|
||||
}
|
||||
|
||||
// Apply on init
|
||||
applyTheme(currentTheme.value)
|
||||
apply()
|
||||
|
||||
return { currentTheme, setTheme }
|
||||
return { mode, lightTheme, darkTheme, effectiveTheme, setLightTheme, setDarkTheme, toggleMode }
|
||||
})
|
||||
|
||||
@@ -5,17 +5,124 @@ import { useThemeStore } from '@/stores/theme'
|
||||
import { customersApi } from '@/api/customers'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getManagerColor as mgrColorFn, getMgrTextColor, loadManagerColors } from '@/utils/managerColor'
|
||||
import { compressImage } from '@/utils/image'
|
||||
import api from '@/api/index'
|
||||
import axios from 'axios'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const themeStore = useThemeStore()
|
||||
const isLight = computed(() => themeStore.currentTheme === 'light')
|
||||
const isLight = computed(() => themeStore.mode === 'light')
|
||||
const loading = ref(false)
|
||||
const search = ref('')
|
||||
const filterIndustry = ref('')
|
||||
const filterService = ref('')
|
||||
const filterManagerId = ref('')
|
||||
const customerType = ref('unit')
|
||||
const customers = ref<any[]>([])
|
||||
|
||||
// Individual view tab data
|
||||
const indivTab = ref('visits')
|
||||
const indivVisits = ref<any[]>([])
|
||||
const indivPlans = ref<any[]>([])
|
||||
const indivMini = ref<any[]>([])
|
||||
const indivKeyVisits = ref<any[]>([])
|
||||
const indivLoading = ref(false)
|
||||
|
||||
// Individual record edit
|
||||
const indivEditVisible = ref(false)
|
||||
const indivEditType = ref('')
|
||||
const indivEditForm = ref<any>({})
|
||||
const indivEditSaving = ref(false)
|
||||
const indivPhotoUrls = ref<Record<string, string>>({})
|
||||
|
||||
async function openIndivEdit(type: string, row: any) {
|
||||
indivEditType.value = type
|
||||
indivEditForm.value = { ...row }
|
||||
indivEditVisible.value = true
|
||||
// Load photo URLs for visit editing
|
||||
if (type === 'visit' && row.photos?.length) {
|
||||
for (const key of row.photos) {
|
||||
if (!indivPhotoUrls.value[key]) {
|
||||
try {
|
||||
const urlRes = await api.get('/upload/download-url', { params: { object_key: key } })
|
||||
indivPhotoUrls.value[key] = urlRes.data.download_url
|
||||
} catch (_) { indivPhotoUrls.value[key] = '' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleIndivPhotoUpload(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
if (!target.files?.length) return
|
||||
const form = indivEditForm.value
|
||||
if (!form.photos) form.photos = []
|
||||
if (form.photos.length >= 9) { ElMessage.warning('最多9张照片'); return }
|
||||
for (const file of Array.from(target.files)) {
|
||||
if (form.photos.length >= 9) break
|
||||
try {
|
||||
const compressed = await compressImage(file, { maxPixels: 1920, quality: 0.8 })
|
||||
const presignRes = await api.post('/upload/presigned-url', null, { params: { filename: compressed.name, content_type: compressed.type || 'image/jpeg' } })
|
||||
await axios.put(presignRes.data.upload_url, compressed, { headers: { 'Content-Type': compressed.type || 'image/jpeg' } })
|
||||
form.photos.push(presignRes.data.object_key)
|
||||
indivPhotoUrls.value[presignRes.data.object_key] = URL.createObjectURL(compressed)
|
||||
} catch (_) { ElMessage.error('上传失败') }
|
||||
}
|
||||
target.value = ''
|
||||
}
|
||||
|
||||
function removeIndivPhoto(idx: number) {
|
||||
const form = indivEditForm.value
|
||||
const key = form.photos[idx]
|
||||
delete indivPhotoUrls.value[key]
|
||||
form.photos.splice(idx, 1)
|
||||
}
|
||||
|
||||
async function handleIndivEditSave() {
|
||||
indivEditSaving.value = true
|
||||
try {
|
||||
const type = indivEditType.value
|
||||
const f = indivEditForm.value
|
||||
if (type === 'visit') {
|
||||
await api.put(`/visits/${f.id}`, {
|
||||
communication_content: f.communication_content,
|
||||
customer_demand: f.customer_demand || '',
|
||||
visitor_name: f.visitor_name || '',
|
||||
visitor_phone: f.visitor_phone || '',
|
||||
visit_method: f.visit_method,
|
||||
time_range: f.time_range || '',
|
||||
visit_date: f.visit_date,
|
||||
manager_id: f.manager_id,
|
||||
photos: f.photos || [],
|
||||
})
|
||||
} else if (type === 'plan') {
|
||||
await api.put(`/work-plans/${f.id}`, { plan_content: f.plan_content, status: f.status, plan_date: f.plan_date, manager_id: f.manager_id })
|
||||
} else if (type === 'mini') {
|
||||
await api.put(`/mini-business/${f.id}`, { product_type: f.product_type, amount: f.amount, follow_up_detail: f.follow_up_detail, status: f.status, expected_revenue_date: f.expected_revenue_date, manager_id: f.manager_id })
|
||||
} else if (type === 'key') {
|
||||
await api.put(`/key-visits/${f.id}`, { description: f.description, urgency_level: f.urgency_level, progress_status: f.progress_status, planned_date: f.planned_date, planned_visitor: f.planned_visitor, visit_target: f.visit_target, manager_id: f.manager_id })
|
||||
}
|
||||
ElMessage.success('已更新')
|
||||
indivEditVisible.value = false
|
||||
loadIndividualData()
|
||||
} catch (e: any) { ElMessage.error('保存失败') }
|
||||
finally { indivEditSaving.value = false }
|
||||
}
|
||||
|
||||
async function handleIndivEditDelete() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' })
|
||||
const type = indivEditType.value
|
||||
const id = indivEditForm.value.id
|
||||
if (type === 'visit') await api.delete(`/visits/${id}`)
|
||||
else if (type === 'plan') await api.delete(`/work-plans/${id}`)
|
||||
else if (type === 'mini') await api.delete(`/mini-business/${id}`)
|
||||
else if (type === 'key') await api.delete(`/key-visits/${id}`)
|
||||
ElMessage.success('已删除')
|
||||
indivEditVisible.value = false
|
||||
loadIndividualData()
|
||||
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
|
||||
}
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(25)
|
||||
const total = ref(0)
|
||||
@@ -36,6 +143,12 @@ const feeCustomUnit = ref('')
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const detailCustomer = ref<any>(null)
|
||||
const detailActiveTab = ref('visits')
|
||||
const detailVisits = ref<any[]>([])
|
||||
const detailPlans = ref<any[]>([])
|
||||
const detailMini = ref<any[]>([])
|
||||
const detailKeyVisits = ref<any[]>([])
|
||||
const detailLoading = ref(false)
|
||||
|
||||
const managers = ref<any[]>([])
|
||||
const selectedIds = ref<string[]>([])
|
||||
@@ -62,6 +175,7 @@ async function loadCustomers() {
|
||||
if (filterIndustry.value) params.industry = filterIndustry.value
|
||||
if (filterService.value) params.service = filterService.value
|
||||
if (filterManagerId.value) params.manager_id = filterManagerId.value
|
||||
if (customerType.value) params.customer_type = customerType.value
|
||||
const res = await customersApi.list(params)
|
||||
customers.value = res.data.items
|
||||
total.value = res.data.total
|
||||
@@ -73,6 +187,32 @@ function onPageChange(page: number) { currentPage.value = page; loadCustomers()
|
||||
function onPageSizeChange(size: number) { pageSize.value = size; currentPage.value = 1; loadCustomers() }
|
||||
function onFilterChange() { currentPage.value = 1; loadCustomers() }
|
||||
|
||||
function switchCustomerType(type: string) {
|
||||
customerType.value = type
|
||||
if (type === 'unit') {
|
||||
onFilterChange()
|
||||
} else {
|
||||
loadIndividualData()
|
||||
}
|
||||
}
|
||||
|
||||
async function loadIndividualData() {
|
||||
indivLoading.value = true
|
||||
try {
|
||||
const [visits, plans, mini, keyVisits] = await Promise.all([
|
||||
api.get('/visits/', { params: { customer_type: 'individual' } }),
|
||||
api.get('/work-plans/', { params: { customer_type: 'individual' } }),
|
||||
api.get('/mini-business/', { params: { customer_type: 'individual' } }),
|
||||
api.get('/key-visits/', { params: { customer_type: 'individual' } }),
|
||||
])
|
||||
indivVisits.value = Array.isArray(visits.data) ? visits.data : (visits.data.items || [])
|
||||
indivPlans.value = Array.isArray(plans.data) ? plans.data : (plans.data.items || [])
|
||||
indivMini.value = Array.isArray(mini.data) ? mini.data : (mini.data.items || [])
|
||||
indivKeyVisits.value = Array.isArray(keyVisits.data) ? keyVisits.data : (keyVisits.data.items || [])
|
||||
} catch (_) {}
|
||||
finally { indivLoading.value = false }
|
||||
}
|
||||
|
||||
function buildMonthlyFee(): string {
|
||||
const amt = form.value.fee_amount.trim()
|
||||
if (!amt) return ''
|
||||
@@ -205,7 +345,30 @@ function mgrTextColor(name: string) {
|
||||
}
|
||||
|
||||
async function openDetail(customer: any) {
|
||||
try { const res = await customersApi.get(customer.id); detailCustomer.value = res.data; detailVisible.value = true } catch (_) {}
|
||||
try {
|
||||
const res = await customersApi.get(customer.id)
|
||||
detailCustomer.value = res.data
|
||||
detailActiveTab.value = 'visits'
|
||||
detailVisible.value = true
|
||||
loadDetailData(customer.id)
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function loadDetailData(customerId: string) {
|
||||
detailLoading.value = true
|
||||
try {
|
||||
const [visits, plans, mini, keyVisits] = await Promise.all([
|
||||
api.get('/visits/', { params: { customer_id: customerId } }),
|
||||
api.get('/work-plans/', { params: { customer_id: customerId } }),
|
||||
api.get('/mini-business/', { params: { customer_id: customerId } }),
|
||||
api.get('/key-visits/', { params: { customer_id: customerId } }),
|
||||
])
|
||||
detailVisits.value = Array.isArray(visits.data) ? visits.data : (visits.data.items || [])
|
||||
detailPlans.value = Array.isArray(plans.data) ? plans.data : (plans.data.items || [])
|
||||
detailMini.value = Array.isArray(mini.data) ? mini.data : (mini.data.items || [])
|
||||
detailKeyVisits.value = Array.isArray(keyVisits.data) ? keyVisits.data : (keyVisits.data.items || [])
|
||||
} catch (_) {}
|
||||
finally { detailLoading.value = false }
|
||||
}
|
||||
|
||||
async function handleDelete(customer: any) {
|
||||
@@ -294,12 +457,72 @@ async function handleImport() {
|
||||
</svg>
|
||||
导出
|
||||
</el-button>
|
||||
<el-button v-if="auth.isDirector" type="primary" @click="openCreate">+ 新建客户</el-button>
|
||||
<el-button v-if="auth.isDirector" type="primary" @click="openCreate" :disabled="customerType === 'individual'">+ 新建客户</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-rule"></div>
|
||||
</div>
|
||||
|
||||
<!-- Type Toggle -->
|
||||
<div style="margin-bottom:14px">
|
||||
<div class="type-tabs">
|
||||
<button :class="['type-tab', { 'type-tab--active': customerType === 'unit' }]" @click="switchCustomerType('unit')">单位客户</button>
|
||||
<button :class="['type-tab', { 'type-tab--active': customerType === 'individual' }]" @click="switchCustomerType('individual')">个人用户</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Individual View: 4 Tabs -->
|
||||
<div v-if="customerType === 'individual'" v-loading="indivLoading">
|
||||
<el-tabs v-model="indivTab">
|
||||
<el-tab-pane label="拜访记录" name="visits">
|
||||
<el-table :data="indivVisits" size="small" max-height="400" v-if="indivVisits.length">
|
||||
<el-table-column prop="visit_date" label="日期" width="110" />
|
||||
<el-table-column prop="visit_method" label="方式" width="80" />
|
||||
<el-table-column prop="communication_content" label="沟通内容" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="visitor_name" label="拜访人" width="80" />
|
||||
<el-table-column prop="manager_name" label="客户经理" width="80" />
|
||||
<el-table-column prop="customer_name" label="客户" width="100" />
|
||||
<el-table-column label="操作" width="60" fixed="right"><template #default="{ row }"><el-button size="small" @click="openIndivEdit('visit', row)">编辑</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<div v-else class="empty-tab">暂无拜访记录</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="工作计划" name="plans">
|
||||
<el-table :data="indivPlans" size="small" max-height="400" v-if="indivPlans.length">
|
||||
<el-table-column prop="plan_date" label="计划时间" width="110" />
|
||||
<el-table-column prop="plan_content" label="工作计划" min-width="250" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="80" />
|
||||
<el-table-column prop="customer_name" label="客户" width="120" />
|
||||
<el-table-column label="操作" width="60" fixed="right"><template #default="{ row }"><el-button size="small" @click="openIndivEdit('plan', row)">编辑</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<div v-else class="empty-tab">暂无工作计划</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="商机跟单" name="mini">
|
||||
<el-table :data="indivMini" size="small" max-height="400" v-if="indivMini.length">
|
||||
<el-table-column prop="product_type" label="产品类型" width="110" />
|
||||
<el-table-column prop="amount" label="金额" width="100" />
|
||||
<el-table-column prop="status" label="状态" width="80" />
|
||||
<el-table-column prop="follow_up_detail" label="跟进内容" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="customer_name" label="客户" width="120" />
|
||||
<el-table-column label="操作" width="60" fixed="right"><template #default="{ row }"><el-button size="small" @click="openIndivEdit('mini', row)">编辑</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<div v-else class="empty-tab">暂无商机记录</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="要客拜访" name="keyVisits">
|
||||
<el-table :data="indivKeyVisits" size="small" max-height="400" v-if="indivKeyVisits.length">
|
||||
<el-table-column prop="planned_date" label="计划时间" width="110" />
|
||||
<el-table-column prop="description" label="内容" min-width="250" show-overflow-tooltip />
|
||||
<el-table-column prop="urgency_level" label="紧急度" width="80" />
|
||||
<el-table-column prop="customer_name" label="客户" width="120" />
|
||||
<el-table-column label="操作" width="60" fixed="right"><template #default="{ row }"><el-button size="small" @click="openIndivEdit('key', row)">编辑</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<div v-else class="empty-tab">暂不要客记录</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
|
||||
<!-- Unit View: Search + Table -->
|
||||
<template v-if="customerType === 'unit'">
|
||||
|
||||
<!-- Search & Filter -->
|
||||
<el-card style="margin-bottom: 16px;">
|
||||
<el-row :gutter="12" align="middle">
|
||||
@@ -403,25 +626,76 @@ async function handleImport() {
|
||||
</el-dialog>
|
||||
|
||||
<!-- Detail Dialog -->
|
||||
<el-dialog v-model="detailVisible" title="客户详情" width="500px">
|
||||
<template v-if="detailCustomer">
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="单位名称">{{ detailCustomer.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="行业">{{ detailCustomer.industry || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="地址">{{ detailCustomer.address || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="在用业务">{{ detailCustomer.in_use_services || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="收支费用">{{ detailCustomer.monthly_fee || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注">{{ detailCustomer.remarks || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="客户经理">{{ detailCustomer.primary_manager_name || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<h4 style="margin-top:16px; font-family: var(--font-heading); color: var(--ink)">联系人</h4>
|
||||
<div v-if="detailCustomer.contacts?.length">
|
||||
<el-tag v-for="c in detailCustomer.contacts" :key="c.id" style="margin:4px">
|
||||
{{ c.name }}{{ c.phone ? ' · '+c.phone : '' }}{{ c.role_desc ? ' ('+c.role_desc+')' : '' }}
|
||||
</el-tag>
|
||||
<el-dialog v-model="detailVisible" :title="detailCustomer?.name || '客户详情'" width="800px" v-if="detailCustomer">
|
||||
<!-- Basic Info Card -->
|
||||
<div class="cust-info-card">
|
||||
<div class="cust-info-name">{{ detailCustomer.name }}</div>
|
||||
<div class="cust-info-bar">
|
||||
<span class="cust-info-item">行业:{{ detailCustomer.industry || '-' }}</span>
|
||||
<span class="cust-info-sep">|</span>
|
||||
<span class="cust-info-item">地址:{{ detailCustomer.address || '-' }}</span>
|
||||
<span class="cust-info-sep">|</span>
|
||||
<span class="cust-info-item">费用:{{ detailCustomer.monthly_fee || '-' }}</span>
|
||||
<span class="cust-info-sep">|</span>
|
||||
<span class="cust-info-item">客户经理:{{ detailCustomer.primary_manager_name || '-' }}</span>
|
||||
</div>
|
||||
<div v-else style="color: var(--c-text-muted); text-align:center; padding:12px">暂无联系人</div>
|
||||
</template>
|
||||
<div v-if="detailCustomer.in_use_services" class="cust-info-bar">
|
||||
<span class="cust-info-item">在用业务:{{ detailCustomer.in_use_services }}</span>
|
||||
</div>
|
||||
<div v-if="detailCustomer.contacts?.length" class="cust-info-bar">
|
||||
<span class="cust-info-item">联系人:{{ detailCustomer.contacts.map((c:any) => c.name + (c.phone ? ' '+c.phone : '')).join('、') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<el-tabs v-model="detailActiveTab" style="margin-top:16px">
|
||||
<el-tab-pane label="拜访记录" name="visits">
|
||||
<div v-loading="detailLoading">
|
||||
<el-table :data="detailVisits" size="small" max-height="300" v-if="detailVisits.length">
|
||||
<el-table-column prop="visit_date" label="日期" width="110" />
|
||||
<el-table-column prop="visit_method" label="方式" width="80" />
|
||||
<el-table-column prop="communication_content" label="沟通内容" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="manager_name" label="客户经理" width="90" />
|
||||
</el-table>
|
||||
<div v-else class="empty-tab">暂无拜访记录</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="工作计划" name="plans">
|
||||
<div v-loading="detailLoading">
|
||||
<el-table :data="detailPlans" size="small" max-height="300" v-if="detailPlans.length">
|
||||
<el-table-column prop="plan_date" label="计划时间" width="110" />
|
||||
<el-table-column prop="plan_content" label="工作计划" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="90" />
|
||||
<el-table-column prop="manager_name" label="客户经理" width="90" />
|
||||
</el-table>
|
||||
<div v-else class="empty-tab">暂无工作计划</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="商机跟单" name="mini">
|
||||
<div v-loading="detailLoading">
|
||||
<el-table :data="detailMini" size="small" max-height="300" v-if="detailMini.length">
|
||||
<el-table-column prop="product_type" label="产品类型" width="110" />
|
||||
<el-table-column prop="amount" label="金额" width="100" />
|
||||
<el-table-column prop="status" label="状态" width="80" />
|
||||
<el-table-column prop="follow_up_detail" label="跟进内容" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="manager_name" label="客户经理" width="90" />
|
||||
</el-table>
|
||||
<div v-else class="empty-tab">暂无商机记录</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="要客拜访" name="keyVisits">
|
||||
<div v-loading="detailLoading">
|
||||
<el-table :data="detailKeyVisits" size="small" max-height="300" v-if="detailKeyVisits.length">
|
||||
<el-table-column prop="planned_date" label="计划时间" width="110" />
|
||||
<el-table-column prop="description" label="内容" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="urgency_level" label="紧急度" width="80" />
|
||||
<el-table-column prop="manager_name" label="客户经理" width="90" />
|
||||
</el-table>
|
||||
<div v-else class="empty-tab">暂不要客记录</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<template #footer>
|
||||
<el-button v-if="!auth.isLeader" type="primary" @click="detailVisible=false; openEdit(detailCustomer)">编辑</el-button>
|
||||
<el-button v-if="auth.isDirector" type="danger" @click="detailVisible=false; handleDelete(detailCustomer)">删除</el-button>
|
||||
@@ -454,6 +728,92 @@ async function handleImport() {
|
||||
<el-button type="primary" :loading="importLoading" @click="handleImport" :disabled="!importFile">确认导入</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<!-- Individual Record Edit Dialog -->
|
||||
<el-dialog v-model="indivEditVisible" :title="'编辑' + ({visit:'拜访',plan:'计划',mini:'商机',key:'要客'}[indivEditType]||'')" width="550px">
|
||||
<el-form label-position="top" v-if="indivEditForm">
|
||||
<template v-if="indivEditType === 'visit'">
|
||||
<el-form-item label="客户经理">
|
||||
<el-select v-model="indivEditForm.manager_id" filterable style="width:100%">
|
||||
<el-option v-for="m in managers" :key="m.id" :label="m.name" :value="m.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="日期"><el-date-picker v-model="indivEditForm.visit_date" type="date" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="拜访方式">
|
||||
<div style="display:flex;gap:8px">
|
||||
<el-button v-for="m in ['上门','电话','微信','出差']" :key="m" size="small" :type="indivEditForm.visit_method === m ? 'primary' : ''" @click="indivEditForm.visit_method = m">{{ m }}</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="时间范围"><el-input v-model="indivEditForm.time_range" placeholder="如: 10:00-11:30" /></el-form-item>
|
||||
<el-form-item label="拜访人姓名"><el-input v-model="indivEditForm.visitor_name" /></el-form-item>
|
||||
<el-form-item label="拜访人电话"><el-input v-model="indivEditForm.visitor_phone" /></el-form-item>
|
||||
<el-form-item label="相关人员"><el-input v-model="indivEditForm.companion_names_str" placeholder="多个姓名用、分隔" /></el-form-item>
|
||||
<el-form-item label="沟通内容"><el-input v-model="indivEditForm.communication_content" type="textarea" :rows="4" /></el-form-item>
|
||||
<el-form-item label="客户需求"><el-input v-model="indivEditForm.customer_demand" type="textarea" :rows="2" /></el-form-item>
|
||||
<el-form-item v-if="indivEditForm.photos?.length" label="照片">
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<div v-for="(key, idx) in indivEditForm.photos" :key="key" style="position:relative">
|
||||
<img :src="indivPhotoUrls[key]" style="width:80px;height:80px;object-fit:cover;border:1px solid var(--warm-border)" v-if="indivPhotoUrls[key]" />
|
||||
<span v-else style="width:80px;height:80px;display:flex;align-items:center;justify-content:center;background:var(--paper);border:1px solid var(--warm-border);font-size:12px;color:var(--warm-gray)">加载中</span>
|
||||
<button type="button" style="position:absolute;top:-6px;right:-6px;background:var(--vermilion);color:#fff;border:none;width:18px;height:18px;cursor:pointer;font-size:12px;line-height:1" @click="removeIndivPhoto(idx)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="添加照片">
|
||||
<label style="cursor:pointer;display:inline-flex;align-items:center;gap:6px;padding:8px 14px;border:1px dashed var(--warm-border);color:var(--warm-gray);font-size:13px">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><polyline points="21 15 16 10 5 21"></polyline></svg>
|
||||
上传照片(最多9张)
|
||||
<input type="file" accept="image/*" multiple style="display:none" @change="handleIndivPhotoUpload" />
|
||||
</label>
|
||||
</el-form-item>
|
||||
</template>
|
||||
<template v-else-if="indivEditType === 'plan'">
|
||||
<el-form-item label="客户经理">
|
||||
<el-select v-model="indivEditForm.manager_id" filterable style="width:100%">
|
||||
<el-option v-for="m in managers" :key="m.id" :label="m.name" :value="m.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="计划日期"><el-date-picker v-model="indivEditForm.plan_date" type="date" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="工作计划"><el-input v-model="indivEditForm.plan_content" type="textarea" :rows="4" /></el-form-item>
|
||||
<el-form-item label="状态"><el-select v-model="indivEditForm.status" style="width:100%"><el-option v-for="s in ['计划中','已完成','已取消']" :key="s" :label="s" :value="s" /></el-select></el-form-item>
|
||||
</template>
|
||||
<template v-else-if="indivEditType === 'mini'">
|
||||
<el-form-item label="客户经理">
|
||||
<el-select v-model="indivEditForm.manager_id" filterable style="width:100%">
|
||||
<el-option v-for="m in managers" :key="m.id" :label="m.name" :value="m.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="产品类型"><el-input v-model="indivEditForm.product_type" /></el-form-item>
|
||||
<el-form-item label="金额"><el-input v-model="indivEditForm.amount" /></el-form-item>
|
||||
<el-form-item label="跟进内容"><el-input v-model="indivEditForm.follow_up_detail" type="textarea" :rows="4" /></el-form-item>
|
||||
<el-form-item label="状态"><el-select v-model="indivEditForm.status" style="width:100%"><el-option v-for="s in ['跟进中','已签约','已流失']" :key="s" :label="s" :value="s" /></el-select></el-form-item>
|
||||
<el-form-item label="预计列收时间"><el-date-picker v-model="indivEditForm.expected_revenue_date" type="month" value-format="YYYY-MM" placeholder="选择月份" style="width:100%" /></el-form-item>
|
||||
</template>
|
||||
<template v-else-if="indivEditType === 'key'">
|
||||
<el-form-item label="客户经理">
|
||||
<el-select v-model="indivEditForm.manager_id" filterable style="width:100%">
|
||||
<el-option v-for="m in managers" :key="m.id" :label="m.name" :value="m.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="紧急重要度">
|
||||
<div style="display:flex;gap:8px">
|
||||
<el-button v-for="u in ['一般','重要','紧急']" :key="u" size="small" :type="indivEditForm.urgency_level === u ? (u==='紧急'?'danger':u==='重要'?'warning':'') : ''" @click="indivEditForm.urgency_level = u">{{ u }}</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="内容描述"><el-input v-model="indivEditForm.description" type="textarea" :rows="4" /></el-form-item>
|
||||
<el-form-item label="进展状态"><el-select v-model="indivEditForm.progress_status" style="width:100%"><el-option v-for="s in ['未开始','进行中','已完成']" :key="s" :label="s" :value="s" /></el-select></el-form-item>
|
||||
<el-form-item label="计划拜访时间"><el-date-picker v-model="indivEditForm.planned_date" type="date" value-format="YYYY-MM-DD" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="计划拜访人"><el-input v-model="indivEditForm.planned_visitor" placeholder="多个用、分隔" /></el-form-item>
|
||||
<el-form-item label="拜访对象"><el-input v-model="indivEditForm.visit_target" /></el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button type="danger" @click="handleIndivEditDelete" style="float:left">删除</el-button>
|
||||
<el-button @click="indivEditVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="indivEditSaving" @click="handleIndivEditSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Merge confirmation dialog -->
|
||||
<el-dialog v-model="mergeDialogVisible" title="合并客户" width="520px" :close-on-click-modal="false">
|
||||
@@ -499,4 +859,16 @@ async function handleImport() {
|
||||
}
|
||||
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 8px; }
|
||||
.header-btns { display: flex; gap: 8px; }
|
||||
.cust-info-bar { display: flex; align-items: center; gap: 4px; font-size: 13px; color: var(--ink); padding: 2px 0; }
|
||||
.cust-info-item { white-space: nowrap; }
|
||||
.cust-info-sep { color: var(--warm-border); margin: 0 6px; }
|
||||
.empty-tab { text-align: center; color: var(--c-text-muted); padding: 24px 0 12px; font-size: 13px; }
|
||||
.type-tabs { display: inline-flex; border: 1px solid var(--warm-border); }
|
||||
.type-tab { padding: 8px 22px; border: none; background: var(--surface); font-family: var(--font-heading); font-size: 14px; color: var(--warm-gray); cursor: pointer; transition: all 0.2s; letter-spacing: 0.04em; }
|
||||
.type-tab:not(:last-child) { border-right: 1px solid var(--warm-border); }
|
||||
.type-tab:hover { color: var(--ink); }
|
||||
.type-tab--active { background: var(--ink); color: #fff; }
|
||||
.type-tab--active:hover { color: #fff; }
|
||||
.cust-info-card { background: var(--c-bg-light, #faf9f6); border: 1px solid var(--warm-border); border-radius: 6px; padding: 14px 18px; margin-bottom: 4px; }
|
||||
.cust-info-name { font-family: var(--font-heading); font-size: 16px; color: var(--ink); letter-spacing: 0.04em; margin-bottom: 6px; }
|
||||
</style>
|
||||
|
||||
@@ -427,4 +427,17 @@ function rowState(p: any): 'full' | 'catching' | 'missing' | 'on_leave' | 'rest_
|
||||
.leave-text { font-size: 13px; color: #5B7FA5; font-family: var(--font-body); }
|
||||
.rest-text { font-size: 13px; color: #9CA3AF; font-family: var(--font-body); }
|
||||
.empty { text-align: center; color: var(--c-text-muted); padding: 40px 0; font-family: var(--font-body); }
|
||||
|
||||
/* ═══ Dark Mode — Dashboard ═══ */
|
||||
html.dark .stat-glyph--ink,
|
||||
html.dark .stat-glyph--sage,
|
||||
html.dark .stat-glyph--gold,
|
||||
html.dark .stat-glyph--vermilion {
|
||||
background: transparent;
|
||||
}
|
||||
html.dark .progress-row--missing { background: rgba(248,113,113,0.06); }
|
||||
html.dark .progress-row--catching { background: rgba(251,191,36,0.05); }
|
||||
html.dark .progress-row--full { background: rgba(74,222,128,0.05); }
|
||||
html.dark .progress-row--on_leave { background: rgba(96,165,250,0.05); }
|
||||
html.dark .progress-row--rest_day { background: rgba(156,163,175,0.03); }
|
||||
</style>
|
||||
|
||||
@@ -12,9 +12,10 @@ import EditLogPanel from '@/components/EditLogPanel.vue'
|
||||
const auth = useAuthStore()
|
||||
const themeStore = useThemeStore()
|
||||
const loading = ref(false)
|
||||
const isLight = computed(() => themeStore.currentTheme === 'light')
|
||||
const isLight = computed(() => themeStore.mode === 'light')
|
||||
const keyVisits = ref<any[]>([])
|
||||
const customers = ref<any[]>([])
|
||||
const allManagers = ref<any[]>([])
|
||||
const shotRef = ref<HTMLElement | null>(null)
|
||||
const { capturing, captureEl } = useScreenshot()
|
||||
const allUsers = ref<any[]>([])
|
||||
@@ -29,6 +30,8 @@ const keyStatuses = ['未开始', '进行中', '已完成']
|
||||
// ── Filters ──
|
||||
const filterManager = ref('')
|
||||
const filterStatus = ref('')
|
||||
const searchText = ref('')
|
||||
function onSearch() { loadItems() }
|
||||
|
||||
const managerOptions = computed(() => {
|
||||
const seen = new Set<string>()
|
||||
@@ -79,6 +82,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() {
|
||||
@@ -88,6 +94,8 @@ async function loadUsers() {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const customerSearch = ref('')
|
||||
|
||||
async function loadCustomers(q?: string) {
|
||||
try {
|
||||
const params: any = { page_size: 500 }
|
||||
@@ -97,10 +105,34 @@ 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 {
|
||||
const res = await api.get('/key-visits/')
|
||||
const params: any = {}
|
||||
if (searchText.value) params.search = searchText.value
|
||||
const res = await api.get('/key-visits/', { params })
|
||||
keyVisits.value = res.data
|
||||
} catch (e: any) { ElMessage.error('加载失败') }
|
||||
finally { loading.value = false }
|
||||
@@ -108,7 +140,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.isDirector || auth.isLeader) ? '' : (auth.userId || '') }
|
||||
plannedVisitors.value = []
|
||||
dialogVisible.value = true
|
||||
}
|
||||
@@ -128,6 +160,7 @@ function onVisitorsChange(val: string[]) {
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.value.customer_id) { ElMessage.warning('请选择客户单位'); return }
|
||||
try {
|
||||
if (dialogMode.value === 'create') {
|
||||
await api.post('/key-visits/', form.value)
|
||||
@@ -198,6 +231,9 @@ async function quickStatusChange(row: any, newStatus: string) {
|
||||
<!-- Filter Bar -->
|
||||
<div class="filter-bar">
|
||||
<div class="filter-row">
|
||||
<div class="filter-item">
|
||||
<el-input v-model="searchText" placeholder="搜索..." clearable size="small" style="width:180px" @keyup.enter="onSearch" @clear="onSearch" />
|
||||
</div>
|
||||
<div class="filter-item">
|
||||
<label class="filter-label">客户经理</label>
|
||||
<el-select v-model="filterManager" clearable placeholder="全部" size="small" style="width:150px">
|
||||
@@ -269,9 +305,20 @@ async function quickStatusChange(row: any, newStatus: string) {
|
||||
<!-- Dialog -->
|
||||
<el-dialog v-model="dialogVisible" :title="(dialogMode === 'create' ? '新建' : '编辑') + ' 要客拜访'" width="520px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="客户单位">
|
||||
<el-select v-model="form.customer_id" filterable remote :remote-method="(q: string) => loadCustomers(q)" placeholder="搜索选择客户" style="width:100%">
|
||||
<el-form-item label="客户单位 *">
|
||||
<el-select v-model="form.customer_id" filterable remote :remote-method="handleCustomerSearch" placeholder="搜索选择客户" style="width:100%">
|
||||
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
|
||||
<template #empty>
|
||||
<div v-if="customerSearch" class="select-empty-create">
|
||||
<p style="color:var(--warm-gray);font-size:13px;margin:0 0 8px">未找到「{{ customerSearch }}」</p>
|
||||
<button type="button" class="quick-create-btn" @click.stop="handleQuickCreate">+ 新建客户「{{ customerSearch }}」</button>
|
||||
</div>
|
||||
</template>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="auth.isDirector || auth.isLeader" label="客户经理">
|
||||
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
|
||||
<el-option v-for="m in allManagers" :key="m.id" :label="m.name" :value="m.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="紧急重要度">
|
||||
@@ -329,4 +376,8 @@ 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); }
|
||||
.select-empty-create { padding: 8px 12px; text-align: center; }
|
||||
.required-star { color: var(--vermilion); font-weight: 700; }
|
||||
</style>
|
||||
|
||||
@@ -27,7 +27,7 @@ const leaveTypeColors: Record<string, string> = {
|
||||
|
||||
const filterManager = ref('')
|
||||
const filterStatus = ref('')
|
||||
const isLight = computed(() => themeStore.currentTheme === 'light')
|
||||
const isLight = computed(() => themeStore.mode === 'light')
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
|
||||
@@ -138,7 +138,9 @@ async function handleSave() {
|
||||
dialogVisible.value = false
|
||||
await loadLeaves()
|
||||
} catch (e: any) {
|
||||
ElMessage.error('保存失败: ' + (e.response?.data?.detail || e.message))
|
||||
const detail = e.response?.data?.detail
|
||||
const msg = Array.isArray(detail) ? detail.map((d: any) => d.msg).join('; ') : (detail || e.message)
|
||||
ElMessage.error('保存失败: ' + msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,12 +278,14 @@ function onStatusChange(val: string) {
|
||||
type="date"
|
||||
placeholder="开始日期"
|
||||
style="width:100%; margin-bottom: 8px"
|
||||
value-format="YYYY-MM-DD"
|
||||
/>
|
||||
<el-date-picker
|
||||
v-model="form.end_date"
|
||||
type="date"
|
||||
placeholder="结束日期"
|
||||
style="width:100%"
|
||||
value-format="YYYY-MM-DD"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="原因(选填)">
|
||||
@@ -329,4 +333,5 @@ function onStatusChange(val: string) {
|
||||
border-left: 3px solid #5B7FA5 !important;
|
||||
background: rgba(91, 127, 165, 0.04) !important;
|
||||
}
|
||||
.required-star { color: var(--vermilion); font-weight: 700; }
|
||||
</style>
|
||||
|
||||
@@ -141,6 +141,11 @@ const statusLabel: Record<string, string> = { green: '近30天已拜访', yellow
|
||||
<span class="manager-expand">{{ expandedManagers.has(m.manager_id) ? '▼' : '▶' }}</span>
|
||||
<span class="manager-name" @click.stop="goManagerReport(m.manager_id)">{{ m.manager_name }}</span>
|
||||
<span class="manager-count">{{ m.total_customers }} 个客户</span>
|
||||
<span class="manager-metrics">
|
||||
已拜访<strong>{{ m.visited_this_month }}</strong> ·
|
||||
未拜访<strong>{{ m.not_visited_2months }}</strong> ·
|
||||
完成率<strong :style="{ color: coverageColor(m.coverage_rate) }">{{ (m.coverage_rate * 100).toFixed(0) }}%</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div class="manager-lights">
|
||||
<span class="light-dot light-dot--green">{{ m.visited_this_month }}</span>
|
||||
@@ -304,6 +309,8 @@ const statusLabel: Record<string, string> = { 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); }
|
||||
@@ -382,4 +389,17 @@ const statusLabel: Record<string, string> = { green: '近30天已拜访', yellow
|
||||
.dlg-plan-item.dlg-plan-overdue { background: #FBF1EE; margin: 2px -4px; padding: 4px; border-radius: 4px; }
|
||||
.dlg-plan-form { background: var(--c-bg-light, #faf9f6); padding: 10px 12px; border-radius: 6px; }
|
||||
.plan-form-row { display: flex; gap: 8px; align-items: center; }
|
||||
|
||||
/* ═══ Dark Mode — light card text stays dark ═══ */
|
||||
html.dark .customer-card .card-name { color: #1A1A1A; }
|
||||
html.dark .customer-card .card-industry,
|
||||
html.dark .customer-card .card-services,
|
||||
html.dark .customer-card .card-last-visit { color: #555; }
|
||||
html.dark .customer-card .card-method { color: #555; border-color: #999; }
|
||||
html.dark .customer-card .card-last-visit--never { color: #B8472E; }
|
||||
html.dark .plan-badge { background: #2D2D2D; color: var(--sage); }
|
||||
html.dark .plan-badge.plan-overdue { background: #3D2020; }
|
||||
html.dark .light-dot--green { background: #1E2F1E; }
|
||||
html.dark .light-dot--yellow { background: #2F2818; }
|
||||
html.dark .light-dot--red { background: #2F1E1E; }
|
||||
</style>
|
||||
|
||||
@@ -13,12 +13,29 @@ const auth = useAuthStore()
|
||||
|
||||
const activeTab = ref('visits')
|
||||
const loading = ref(false)
|
||||
const allManagers = ref<any[]>([])
|
||||
|
||||
// Date range filter
|
||||
const dateRange = ref<any>(null)
|
||||
const searchText = ref('')
|
||||
const pageSize = ref(25)
|
||||
|
||||
const visits = ref<any[]>([])
|
||||
const workPlans = ref<any[]>([])
|
||||
const miniBusiness = ref<any[]>([])
|
||||
const dailyNotes = ref<any[]>([])
|
||||
const keyVisits = ref<any[]>([])
|
||||
|
||||
// Pagination per tab
|
||||
const page = ref<Record<string, number>>({ visits: 1, notes: 1, plans: 1, mini: 1, key: 1 })
|
||||
const total = ref<Record<string, number>>({ visits: 0, notes: 0, plans: 0, mini: 0, key: 0 })
|
||||
|
||||
function getDateRange() {
|
||||
if (dateRange.value && Array.isArray(dateRange.value) && dateRange.value.length === 2) {
|
||||
return { from: dateRange.value[0], to: dateRange.value[1] }
|
||||
}
|
||||
return { from: '', to: '' }
|
||||
}
|
||||
const customers = ref<any[]>([])
|
||||
const allUsers = ref<any[]>([])
|
||||
const plannedVisitors = ref<string[]>([])
|
||||
@@ -43,6 +60,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() {
|
||||
@@ -57,6 +77,8 @@ function typeLabel(t: string) {
|
||||
return labels[t] || ''
|
||||
}
|
||||
|
||||
const customerSearch = ref('')
|
||||
|
||||
async function loadCustomers(q?: string) {
|
||||
try {
|
||||
const params: any = { page_size: 100 }
|
||||
@@ -66,18 +88,61 @@ 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))
|
||||
}
|
||||
}
|
||||
|
||||
function buildParams(tabKey: string) {
|
||||
const { from, to } = getDateRange()
|
||||
const params: any = { page: page.value[tabKey] || 1, page_size: pageSize.value }
|
||||
if (from) { params.date_from = from; params.date_to = to }
|
||||
if (searchText.value) params.search = searchText.value
|
||||
return params
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [v, w, m, d, k] = await Promise.all([
|
||||
api.get('/visits/'), api.get('/work-plans/'),
|
||||
api.get('/mini-business/'), api.get('/daily-notes/'),
|
||||
api.get('/key-visits/'),
|
||||
const [v, d] = await Promise.all([
|
||||
api.get('/visits/', { params: buildParams('visits') }),
|
||||
api.get('/daily-notes/', { params: buildParams('notes') }),
|
||||
])
|
||||
visits.value = v.data; workPlans.value = w.data
|
||||
miniBusiness.value = m.data; dailyNotes.value = d.data
|
||||
keyVisits.value = k.data
|
||||
// Load photo previews
|
||||
visits.value = Array.isArray(v.data) ? v.data : (v.data.items || [])
|
||||
dailyNotes.value = Array.isArray(d.data) ? d.data : (d.data.items || [])
|
||||
total.value.visits = v.data.total || visits.value.length
|
||||
total.value.notes = d.data.total || dailyNotes.value.length
|
||||
|
||||
const [w, m, k] = await Promise.all([
|
||||
api.get('/work-plans/', { params: buildParams('plans') }),
|
||||
api.get('/mini-business/', { params: buildParams('mini') }),
|
||||
api.get('/key-visits/', { params: buildParams('key') }),
|
||||
])
|
||||
workPlans.value = Array.isArray(w.data) ? w.data : (w.data.items || [])
|
||||
miniBusiness.value = Array.isArray(m.data) ? m.data : (m.data.items || [])
|
||||
keyVisits.value = Array.isArray(k.data) ? k.data : (k.data.items || [])
|
||||
total.value.plans = w.data.total || workPlans.value.length
|
||||
total.value.mini = m.data.total || miniBusiness.value.length
|
||||
total.value.key = k.data.total || keyVisits.value.length
|
||||
|
||||
// Load photo previews for visits
|
||||
for (const visit of visits.value) {
|
||||
if (visit.photos?.length) {
|
||||
for (const key of visit.photos) {
|
||||
@@ -94,14 +159,19 @@ async function loadAll() {
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
function onTabChange(tab: string) { activeTab.value = tab; loadAll() }
|
||||
function onFilterChange() { Object.keys(page.value).forEach(k => page.value[k] = 1); loadAll() }
|
||||
function onPageChange(tabKey: string, p: number) { page.value[tabKey] = p; loadAll() }
|
||||
function onSizeChange() { Object.keys(page.value).forEach(k => page.value[k] = 1); 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.isDirector || auth.isLeader) ? '' : (auth.userId || '') }
|
||||
else if (type === 'note') form.value = { note_date: todayStr(), category: '其他', content: '', time_range: '', manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || '') }
|
||||
else if (type === 'plan') form.value = { customer_id: '', plan_content: '', plan_date: todayStr(), status: '计划中', manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || '') }
|
||||
else if (type === 'mini') form.value = { customer_id: '', product_type: '', amount: '', follow_up_detail: '', status: '跟进中', expected_revenue_date: '', manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || '') }
|
||||
else if (type === 'key') { form.value = { customer_id: '', urgency_level: '一般', description: '', progress_status: '未开始', planned_date: '', planned_visitor: '', visit_target: '', manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || '') }; plannedVisitors.value = [] }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -294,16 +364,24 @@ const notesByDate = computed(() => {
|
||||
<template>
|
||||
<div class="workspace" v-loading="loading">
|
||||
<div class="page-head">
|
||||
<div class="page-head-row">
|
||||
<h2 class="page-title">我的工作数据</h2>
|
||||
<div class="page-head-controls">
|
||||
<el-date-picker v-model="dateRange" type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" size="small" style="width:240px" value-format="YYYY-MM-DD" @change="onFilterChange" />
|
||||
<el-button v-if="dateRange" size="small" @click="dateRange=null;onFilterChange()">清除</el-button>
|
||||
<el-input v-model="searchText" placeholder="搜索..." clearable size="small" style="width:240px" @keyup.enter="onFilterChange" @clear="onFilterChange">
|
||||
<template #append><el-button size="small" @click="onFilterChange">搜索</el-button></template>
|
||||
</el-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-rule"></div>
|
||||
</div>
|
||||
|
||||
<el-card>
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tabs v-model="activeTab" @tab-change="onTabChange">
|
||||
<!-- ═══ 拜访记录 ═══ -->
|
||||
<el-tab-pane label="拜访记录" name="visits">
|
||||
<div style="margin-bottom:12px"><el-button type="primary" size="small" @click="openCreate('visit')">+ 新建拜访</el-button></div>
|
||||
<div v-for="[date, items] in visitsByDate" :key="date" class="date-group">
|
||||
<div v-for="[date, items] in visitsByDate" :key="date" class="date-group">
|
||||
<h4 class="date-title">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px; color: var(--gold)">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line>
|
||||
@@ -340,7 +418,6 @@ const notesByDate = computed(() => {
|
||||
|
||||
<!-- ═══ 今日纪要 ═══ -->
|
||||
<el-tab-pane label="今日纪要" name="daily_notes">
|
||||
<div style="margin-bottom:12px"><el-button type="primary" size="small" @click="openCreate('note')">+ 新建纪要</el-button></div>
|
||||
<div v-for="[date, items] in notesByDate" :key="date" class="date-group">
|
||||
<h4 class="date-title">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px; color: var(--gold)">
|
||||
@@ -371,7 +448,6 @@ const notesByDate = computed(() => {
|
||||
|
||||
<!-- ═══ 工作计划 ═══ -->
|
||||
<el-tab-pane label="工作计划" name="work_plans">
|
||||
<div style="margin-bottom:12px"><el-button type="primary" size="small" @click="openCreate('plan')">+ 新建计划</el-button></div>
|
||||
<el-table :data="workPlans" stripe size="small" v-column-resize>
|
||||
<el-table-column prop="customer_name" label="客户" width="130">
|
||||
<template #default="{ row }"><el-link type="primary" :underline="false" @click="openEdit('plan', row)">{{ row.customer_name }}</el-link></template>
|
||||
@@ -403,7 +479,6 @@ const notesByDate = computed(() => {
|
||||
|
||||
<!-- ═══ 小微商机 ═══ -->
|
||||
<el-tab-pane label="小微商机" name="mini_biz">
|
||||
<div style="margin-bottom:12px"><el-button type="primary" size="small" @click="openCreate('mini')">+ 新建商机</el-button></div>
|
||||
<el-table :data="miniBusiness" stripe size="small" v-column-resize>
|
||||
<el-table-column prop="customer_name" label="客户" width="130">
|
||||
<template #default="{ row }"><el-link type="primary" :underline="false" @click="openEdit('mini', row)">{{ row.customer_name }}</el-link></template>
|
||||
@@ -437,7 +512,6 @@ const notesByDate = computed(() => {
|
||||
|
||||
<!-- ═══ 要客拜访 ═══ -->
|
||||
<el-tab-pane label="要客拜访" name="key_visits">
|
||||
<div style="margin-bottom:12px"><el-button type="primary" size="small" @click="openCreate('key')">+ 新建要客拜访</el-button></div>
|
||||
<el-table :data="keyVisits" stripe size="small" v-column-resize>
|
||||
<el-table-column prop="customer_name" label="客户" width="130">
|
||||
<template #default="{ row }"><el-link type="primary" :underline="false" @click="openEdit('key', row)">{{ row.customer_name }}</el-link></template>
|
||||
@@ -469,15 +543,38 @@ const notesByDate = computed(() => {
|
||||
</el-table>
|
||||
<div v-if="!keyVisits.length" class="empty">暂无数据</div>
|
||||
</el-tab-pane>
|
||||
<div style="margin-top:16px;display:flex;align-items:center;gap:12px">
|
||||
<el-pagination
|
||||
v-model:current-page="page[activeTab === 'notes' ? 'notes' : activeTab === 'plans' ? 'plans' : activeTab === 'mini' ? 'mini' : activeTab === 'key' ? 'key' : 'visits']"
|
||||
:page-size="pageSize"
|
||||
:total="total[activeTab === 'notes' ? 'notes' : activeTab === 'plans' ? 'plans' : activeTab === 'mini' ? 'mini' : activeTab === 'key' ? 'key' : 'visits']"
|
||||
:page-sizes="[25, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
small
|
||||
@current-change="(p: number) => onPageChange(activeTab === 'notes' ? 'notes' : activeTab === 'plans' ? 'plans' : activeTab === 'mini' ? 'mini' : activeTab === 'key' ? 'key' : 'visits', p)"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
|
||||
<!-- Create/Edit Dialog -->
|
||||
<el-dialog v-model="dialogVisible" :title="(dialogMode === 'create' ? '新建' : '编辑') + ' ' + typeLabel(dialogType)" width="520px">
|
||||
<el-form label-position="top" v-if="form">
|
||||
<el-form-item v-if="['visit','plan','mini','key'].includes(dialogType)" label="客户单位">
|
||||
<el-select v-model="form.customer_id" filterable remote :remote-method="(q: string) => loadCustomers(q)" placeholder="搜索选择客户" style="width:100%" @change="onDialogCustomerChange">
|
||||
<el-form-item v-if="['visit','plan','mini','key'].includes(dialogType)" label="客户单位 *">
|
||||
<el-select v-model="form.customer_id" filterable remote :remote-method="handleCustomerSearch" placeholder="搜索选择客户" style="width:100%" @change="onDialogCustomerChange">
|
||||
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
|
||||
<template #empty>
|
||||
<div v-if="customerSearch" class="select-empty-create">
|
||||
<p style="color:var(--warm-gray);font-size:13px;margin:0 0 8px">未找到「{{ customerSearch }}」</p>
|
||||
<button type="button" class="quick-create-btn" @click.stop="handleQuickCreate">+ 新建客户「{{ customerSearch }}」</button>
|
||||
</div>
|
||||
</template>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="(auth.isDirector || auth.isLeader) && ['visit','plan','mini','key','note'].includes(dialogType)" label="客户经理">
|
||||
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
|
||||
<el-option v-for="m in allManagers" :key="m.id" :label="m.name" :value="m.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="dialogType === 'visit'" label="日期"><el-date-picker v-model="form.visit_date" type="date" value-format="YYYY-MM-DD" style="width:100%" /></el-form-item>
|
||||
@@ -577,9 +674,15 @@ const notesByDate = computed(() => {
|
||||
|
||||
<style scoped>
|
||||
.page-head { margin-bottom: 20px; }
|
||||
.page-title { margin: 0; font-family: var(--font-heading); font-size: 22px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; }
|
||||
.page-head-row { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; }
|
||||
.page-head-controls { display: flex; align-items: center; gap: 8px; }
|
||||
.page-title { margin: 0; font-family: var(--font-heading); font-size: 22px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; white-space: nowrap; }
|
||||
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; }
|
||||
.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); }
|
||||
.select-empty-create { padding: 8px 12px; text-align: center; }
|
||||
.required-star { color: var(--vermilion); font-weight: 700; }
|
||||
</style>
|
||||
|
||||
@@ -7,14 +7,16 @@ import { todayStr } from '@/utils'
|
||||
import { getMgrStyle, loadManagerColors } from '@/utils/managerColor'
|
||||
import { useScreenshot } from '@/utils/screenshot'
|
||||
import api from '@/api/index'
|
||||
import { miniBusinessApi } from '@/api/miniBusiness'
|
||||
import EditLogPanel from '@/components/EditLogPanel.vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const themeStore = useThemeStore()
|
||||
const loading = ref(false)
|
||||
const isLight = computed(() => themeStore.currentTheme === 'light')
|
||||
const isLight = computed(() => themeStore.mode === 'light')
|
||||
const miniBusiness = ref<any[]>([])
|
||||
const customers = ref<any[]>([])
|
||||
const allManagers = ref<any[]>([])
|
||||
const shotRef = ref<HTMLElement | null>(null)
|
||||
const { capturing, captureEl } = useScreenshot()
|
||||
|
||||
@@ -24,9 +26,22 @@ const form = ref<any>({})
|
||||
const statusPick = ref<Record<string, string>>({})
|
||||
const miniStatuses = ['跟进中', '已签约', '已流失']
|
||||
|
||||
// ── Detail + Follow-up logs ──
|
||||
const detailVisible = ref(false)
|
||||
const selectedBusiness = ref<any>(null)
|
||||
const logs = ref<any[]>([])
|
||||
const logForm = ref({ log_date: '', method: '电话', content: '' })
|
||||
const logMethods = ['电话', '微信', '上门', '邮件', '其他']
|
||||
const logMethodIcons: Record<string, string> = { '电话': '📞', '微信': '💬', '上门': '🏢', '邮件': '📧', '其他': '📋' }
|
||||
const logMethodColors: Record<string, string> = { '电话': '#5B7FA5', '微信': '#22c55e', '上门': '#4A6741', '邮件': '#C4934A', '其他': '#7B7568' }
|
||||
const logLoading = ref(false)
|
||||
const logSaving = ref(false)
|
||||
|
||||
// ── Filters ──
|
||||
const filterManager = ref('')
|
||||
const filterStatus = ref('')
|
||||
const filterStatus = ref('跟进中')
|
||||
const searchText = ref('')
|
||||
function onSearch() { loadItems() }
|
||||
|
||||
const managerOptions = computed(() => {
|
||||
const seen = new Set<string>()
|
||||
@@ -36,10 +51,12 @@ const managerOptions = computed(() => {
|
||||
.sort()
|
||||
})
|
||||
|
||||
const statusOrder: Record<string, number> = { '跟进中': 0, '已签约': 1, '已流失': 2 }
|
||||
const filteredItems = computed(() => {
|
||||
let list = miniBusiness.value
|
||||
if (filterManager.value) list = list.filter((m: any) => (m.manager_name || '未知') === filterManager.value)
|
||||
if (filterStatus.value) list = list.filter((m: any) => m.status === filterStatus.value)
|
||||
list.sort((a: any, b: any) => (statusOrder[a.status] ?? 9) - (statusOrder[b.status] ?? 9))
|
||||
return list
|
||||
})
|
||||
|
||||
@@ -64,8 +81,13 @@ 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('')
|
||||
|
||||
async function loadCustomers(q?: string) {
|
||||
try {
|
||||
const params: any = { page_size: 500 }
|
||||
@@ -75,10 +97,34 @@ 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 {
|
||||
const res = await api.get('/mini-business/')
|
||||
const params: any = {}
|
||||
if (searchText.value) params.search = searchText.value
|
||||
const res = await api.get('/mini-business/', { params })
|
||||
miniBusiness.value = res.data
|
||||
} catch (e: any) { ElMessage.error('加载失败') }
|
||||
finally { loading.value = false }
|
||||
@@ -86,7 +132,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.isDirector || auth.isLeader) ? '' : (auth.userId || '') }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -100,7 +146,9 @@ function openEdit(item: any) {
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.value.customer_id) { ElMessage.warning('请选择客户单位'); return }
|
||||
try {
|
||||
if (form.value.status === '已流失') form.value.expected_revenue_date = ''
|
||||
if (dialogMode.value === 'create') {
|
||||
await api.post('/mini-business/', form.value)
|
||||
} else {
|
||||
@@ -121,10 +169,50 @@ async function handleDelete(id: string) {
|
||||
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
|
||||
}
|
||||
|
||||
// ── Detail + Follow-up logs ──
|
||||
async function openDetail(item: any) {
|
||||
selectedBusiness.value = item
|
||||
detailVisible.value = true
|
||||
logForm.value = { log_date: new Date().toISOString().slice(0, 10), method: '电话', content: '' }
|
||||
await loadLogs()
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
logLoading.value = true
|
||||
try {
|
||||
const res = await miniBusinessApi.getLogs(selectedBusiness.value.id)
|
||||
logs.value = res.data || []
|
||||
} catch (_) { logs.value = [] }
|
||||
finally { logLoading.value = false }
|
||||
}
|
||||
|
||||
async function handleCreateLog() {
|
||||
if (!logForm.value.content.trim()) { ElMessage.warning('请输入跟进内容'); return }
|
||||
logSaving.value = true
|
||||
try {
|
||||
await miniBusinessApi.createLog(selectedBusiness.value.id, logForm.value)
|
||||
ElMessage.success('跟进记录已添加')
|
||||
logForm.value = { log_date: new Date().toISOString().slice(0, 10), method: '电话', content: '' }
|
||||
await loadLogs()
|
||||
} catch (e: any) { ElMessage.error('添加失败: ' + (e.response?.data?.detail || e.message)) }
|
||||
finally { logSaving.value = false }
|
||||
}
|
||||
|
||||
async function handleDeleteLog(logId: string) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除该跟进记录?', '确认', { type: 'warning' })
|
||||
await miniBusinessApi.deleteLog(logId)
|
||||
ElMessage.success('已删除')
|
||||
await loadLogs()
|
||||
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
|
||||
}
|
||||
|
||||
async function quickStatusChange(row: any, newStatus: string) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定将状态改为「${newStatus}」?`, '确认', { type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' })
|
||||
await api.put(`/mini-business/${row.id}`, { status: newStatus })
|
||||
const payload: any = { status: newStatus }
|
||||
if (newStatus === '已流失') payload.expected_revenue_date = ''
|
||||
await api.put(`/mini-business/${row.id}`, payload)
|
||||
ElMessage.success('状态已更新')
|
||||
await loadItems()
|
||||
} catch (e: any) {
|
||||
@@ -170,6 +258,9 @@ async function quickStatusChange(row: any, newStatus: string) {
|
||||
<!-- Filter Bar -->
|
||||
<div class="filter-bar">
|
||||
<div class="filter-row">
|
||||
<div class="filter-item">
|
||||
<el-input v-model="searchText" placeholder="搜索..." clearable size="small" style="width:180px" @keyup.enter="onSearch" @clear="onSearch" />
|
||||
</div>
|
||||
<div class="filter-item">
|
||||
<label class="filter-label">客户经理</label>
|
||||
<el-select v-model="filterManager" clearable placeholder="全部" size="small" style="width:150px">
|
||||
@@ -192,7 +283,7 @@ async function quickStatusChange(row: any, newStatus: string) {
|
||||
<el-table-column type="index" label="序号" width="50" />
|
||||
<el-table-column prop="customer_name" label="客户" width="160">
|
||||
<template #default="{ row }">
|
||||
<el-link type="primary" :underline="false" @click="openEdit(row)">{{ row.customer_name }}</el-link>
|
||||
<el-link type="primary" :underline="false" @click="openDetail(row)">{{ row.customer_name }}</el-link>
|
||||
<el-tooltip v-if="row.edit_log?.length > 1" placement="top">
|
||||
<template #content>最后编辑:{{ row.edit_log[row.edit_log.length-1].editor }} · {{ row.edit_log.length-1 }}次修改</template>
|
||||
<span class="edit-indicator" title="有过修改">🕐</span>
|
||||
@@ -236,9 +327,20 @@ async function quickStatusChange(row: any, newStatus: string) {
|
||||
<!-- Dialog -->
|
||||
<el-dialog v-model="dialogVisible" :title="(dialogMode === 'create' ? '新建' : '编辑') + ' 小微商机'" width="500px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="客户单位">
|
||||
<el-select v-model="form.customer_id" filterable remote :remote-method="(q: string) => loadCustomers(q)" placeholder="搜索选择客户" style="width:100%">
|
||||
<el-form-item label="客户单位 *">
|
||||
<el-select v-model="form.customer_id" filterable remote :remote-method="handleCustomerSearch" placeholder="搜索选择客户" style="width:100%">
|
||||
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
|
||||
<template #empty>
|
||||
<div v-if="customerSearch" class="select-empty-create">
|
||||
<p style="color:var(--warm-gray);font-size:13px;margin:0 0 8px">未找到「{{ customerSearch }}」</p>
|
||||
<button type="button" class="quick-create-btn" @click.stop="handleQuickCreate">+ 新建客户「{{ customerSearch }}」</button>
|
||||
</div>
|
||||
</template>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="auth.isDirector || auth.isLeader" label="客户经理">
|
||||
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
|
||||
<el-option v-for="m in allManagers" :key="m.id" :label="m.name" :value="m.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="产品类型"><el-input v-model="form.product_type" placeholder="如:云专线、SD-WAN" /></el-form-item>
|
||||
@@ -261,6 +363,59 @@ async function quickStatusChange(row: any, newStatus: string) {
|
||||
<el-button type="primary" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Detail Dialog with Follow-up Timeline -->
|
||||
<el-dialog v-model="detailVisible" :title="selectedBusiness?.customer_name + ' — 商机跟单'" width="650px" v-if="selectedBusiness">
|
||||
<!-- Business Info -->
|
||||
<div class="detail-info">
|
||||
<div class="detail-row"><span class="detail-label">产品类型</span><span>{{ selectedBusiness.product_type }}</span></div>
|
||||
<div class="detail-row"><span class="detail-label">金额</span><span>{{ selectedBusiness.amount }}</span></div>
|
||||
<div class="detail-row"><span class="detail-label">状态</span><span :style="{ color: selectedBusiness.status === '已签约' ? '#4A6741' : selectedBusiness.status === '已流失' ? '#B8472E' : '#C4934A' }">{{ selectedBusiness.status }}</span></div>
|
||||
<div class="detail-row"><span class="detail-label">预计列收</span><span>{{ selectedBusiness.expected_revenue_date }}</span></div>
|
||||
<div class="detail-row"><span class="detail-label">客户经理</span><span class="mgr-tag" :style="getMgrStyle(selectedBusiness.manager_name, isLight)">{{ selectedBusiness.manager_name }}</span></div>
|
||||
<div class="detail-row" v-if="selectedBusiness.follow_up_detail"><span class="detail-label">商机概述</span><span>{{ selectedBusiness.follow_up_detail }}</span></div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:12px">
|
||||
<el-button size="small" @click="detailVisible = false; openEdit(selectedBusiness)">编辑商机</el-button>
|
||||
</div>
|
||||
|
||||
<!-- Follow-up Timeline -->
|
||||
<div class="log-section">
|
||||
<h4 class="log-title">跟进记录</h4>
|
||||
<div v-loading="logLoading" class="log-list">
|
||||
<div v-if="logs.length === 0 && !logLoading" class="log-empty">暂无跟进记录</div>
|
||||
<div v-for="l in logs" :key="l.id" class="log-item">
|
||||
<span class="log-icon">{{ logMethodIcons[l.method] || '📋' }}</span>
|
||||
<div class="log-body">
|
||||
<div class="log-header">
|
||||
<span class="log-date">{{ l.log_date }}</span>
|
||||
<span class="log-method-chip" :style="{ background: logMethodColors[l.method] || '#7B7568', color: '#fff', padding: '1px 8px', fontSize: '11px' }">{{ l.method }}</span>
|
||||
<span class="log-author">{{ l.created_by_name }}</span>
|
||||
</div>
|
||||
<div class="log-content">{{ l.content }}</div>
|
||||
</div>
|
||||
<el-button size="small" type="danger" text @click="handleDeleteLog(l.id)" class="log-del">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Log Form -->
|
||||
<div class="log-add">
|
||||
<div class="log-add-row">
|
||||
<el-date-picker v-model="logForm.log_date" type="date" value-format="YYYY-MM-DD" size="small" style="width:140px" />
|
||||
<div class="log-method-chips">
|
||||
<button v-for="m in logMethods" :key="m" type="button" class="log-chip" :class="{ 'log-chip--active': logForm.method === m }" :style="logForm.method === m ? { background: logMethodColors[m], borderColor: logMethodColors[m], color: '#fff' } : {}" @click="logForm.method = m">{{ m }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<el-input v-model="logForm.content" type="textarea" :rows="2" placeholder="输入跟进内容..." size="small" style="margin-top:6px" />
|
||||
<el-button type="primary" size="small" :loading="logSaving" @click="handleCreateLog" style="margin-top:6px">添加跟进</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="detailVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -284,4 +439,37 @@ 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); }
|
||||
.select-empty-create { padding: 8px 12px; text-align: center; }
|
||||
|
||||
/* ── Detail Dialog ── */
|
||||
.detail-info { background: var(--c-bg-light, #faf9f6); border-radius: 6px; padding: 14px 18px; }
|
||||
.detail-row { display: flex; gap: 12px; padding: 4px 0; font-size: 13px; }
|
||||
.detail-label { color: var(--warm-gray); min-width: 70px; flex-shrink: 0; }
|
||||
|
||||
/* ── Log Timeline ── */
|
||||
.log-section { margin-top: 18px; border-top: 1px solid var(--warm-border); padding-top: 14px; }
|
||||
.log-title { margin: 0 0 12px; font-family: var(--font-heading); font-size: 16px; color: var(--ink); }
|
||||
.log-list { display: flex; flex-direction: column; gap: 10px; max-height: 300px; overflow-y: auto; margin-bottom: 14px; }
|
||||
.log-empty { text-align: center; color: var(--warm-gray); font-size: 13px; padding: 20px 0; }
|
||||
.log-item { display: flex; gap: 10px; align-items: flex-start; padding: 10px 12px; background: var(--surface); border: 1px solid var(--warm-border); }
|
||||
.log-icon { font-size: 18px; flex-shrink: 0; margin-top: 2px; }
|
||||
.log-body { flex: 1; min-width: 0; }
|
||||
.log-header { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
|
||||
.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.6; }
|
||||
.log-del { flex-shrink: 0; opacity: 0.5; }
|
||||
.log-del:hover { opacity: 1; }
|
||||
.log-method-chip { border-radius: 3px; white-space: nowrap; }
|
||||
|
||||
/* ── Add Log Form ── */
|
||||
.log-add { border-top: 1px solid var(--warm-border); padding-top: 12px; }
|
||||
.log-add-row { display: flex; gap: 8px; align-items: center; }
|
||||
.log-method-chips { display: flex; gap: 4px; }
|
||||
.log-chip { padding: 4px 10px; border: 1px solid var(--warm-border); background: var(--surface); font-size: 12px; cursor: pointer; transition: all 0.2s; }
|
||||
.log-chip:hover { border-color: var(--ink); }
|
||||
.log-chip--active { font-weight: 600; }
|
||||
.required-star { color: var(--vermilion); font-weight: 700; }
|
||||
</style>
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import api from '@/api/index'
|
||||
import { useThemeStore, themeLabels, type Theme } from '@/stores/theme'
|
||||
import { useThemeStore, themeLabels, type Theme, lightThemes, darkThemes } from '@/stores/theme'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const managers = ref<any[]>([])
|
||||
const selectedUserIds = ref<string[]>([])
|
||||
@@ -165,13 +167,28 @@ async function handleImportPreview() {
|
||||
</div>
|
||||
</template>
|
||||
<div class="theme-options">
|
||||
<label v-for="t in (['editorial', 'light'] as Theme[])" :key="t" class="theme-opt" :class="{ 'theme-opt--active': themeStore.currentTheme === t }" @click="themeStore.setTheme(t)">
|
||||
<span class="theme-opt-radio">{{ themeStore.currentTheme === t ? '●' : '○' }}</span>
|
||||
<div class="theme-mode-row">
|
||||
<span class="theme-mode-label">模式</span>
|
||||
<button class="theme-mode-btn" @click="themeStore.toggleMode()">
|
||||
{{ themeStore.mode === 'light' ? '☀️ 亮色模式' : '🌙 深色模式' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="theme-sub-options">
|
||||
<label
|
||||
v-for="t in (themeStore.mode === 'light' ? lightThemes : darkThemes)"
|
||||
:key="t"
|
||||
class="theme-opt"
|
||||
:class="{ 'theme-opt--active': (themeStore.mode === 'light' ? themeStore.lightTheme : themeStore.darkTheme) === t }"
|
||||
@click="themeStore.mode === 'light' ? themeStore.setLightTheme(t) : themeStore.setDarkTheme(t)"
|
||||
>
|
||||
<span class="theme-opt-radio">{{ (themeStore.mode === 'light' ? themeStore.lightTheme : themeStore.darkTheme) === t ? '●' : '○' }}</span>
|
||||
<span class="theme-opt-label">{{ themeLabels[t] }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<template v-if="auth.isDirector">
|
||||
<!-- Manual Remind -->
|
||||
<el-card class="setting-card">
|
||||
<template #header>
|
||||
@@ -371,6 +388,7 @@ async function handleImportPreview() {
|
||||
</el-alert>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -395,7 +413,12 @@ async function handleImportPreview() {
|
||||
.setting-label { font-family: var(--font-body); font-size: 13px; color: var(--ink); }
|
||||
|
||||
/* ── Theme Selector ── */
|
||||
.theme-options { display: flex; gap: 16px; }
|
||||
.theme-options { display: flex; flex-direction: column; gap: 12px; }
|
||||
.theme-mode-row { display: flex; align-items: center; gap: 12px; padding-bottom: 8px; border-bottom: 1px solid var(--warm-border); margin-bottom: 4px; }
|
||||
.theme-mode-label { font-family: var(--font-heading); font-size: 13px; color: var(--warm-gray); }
|
||||
.theme-mode-btn { font-family: var(--font-body); font-size: 14px; cursor: pointer; padding: 6px 16px; border: 1px solid var(--warm-border); border-radius: 6px; background: var(--c-primary-bg); color: var(--c-text); transition: all var(--transition); }
|
||||
.theme-mode-btn:hover { border-color: var(--gold); }
|
||||
.theme-sub-options { display: flex; gap: 12px; padding-left: 4px; }
|
||||
.theme-opt { display: flex; align-items: center; gap: 8px; cursor: pointer; padding: 8px 16px; border: 1px solid var(--warm-border); transition: all var(--transition); }
|
||||
.theme-opt:hover { border-color: var(--ink); }
|
||||
.theme-opt--active { border-color: var(--ink); background: var(--c-primary-bg); }
|
||||
|
||||
@@ -14,6 +14,7 @@ const themeStore = useThemeStore()
|
||||
const loading = ref(false)
|
||||
const workPlans = ref<any[]>([])
|
||||
const customers = ref<any[]>([])
|
||||
const allManagers = ref<any[]>([])
|
||||
const shotRef = ref<HTMLElement | null>(null)
|
||||
const { capturing, captureEl } = useScreenshot()
|
||||
|
||||
@@ -49,8 +50,11 @@ const filterStatus = ref('')
|
||||
const expandedSections = ref<Record<string, boolean>>({
|
||||
thisWeek: true, nextWeek: true, thisMonth: false, later: false, archived: false,
|
||||
})
|
||||
const searchText = ref('')
|
||||
|
||||
const isLight = computed(() => themeStore.currentTheme === 'light')
|
||||
function onSearch() { loadPlans() }
|
||||
|
||||
const isLight = computed(() => themeStore.mode === 'light')
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
'计划中': '#4A6741',
|
||||
@@ -128,17 +132,69 @@ const managerSummary = computed(() => {
|
||||
|
||||
function resetFilters() { filterManager.value = ''; filterStatus.value = '' }
|
||||
|
||||
onMounted(async () => { await Promise.all([loadPlans(), loadCustomers(), loadManagerColors()]) })
|
||||
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('')
|
||||
|
||||
async function loadCustomers(q?: string) {
|
||||
try { const params: any = { page_size: 500 }; if (q) params.search = q; const res = await api.get('/customers/', { params }); customers.value = res.data.items || [] } catch (_) {}
|
||||
}
|
||||
async function loadPlans() { loading.value = true; try { const res = await api.get('/work-plans/'); workPlans.value = res.data } catch (_) {} finally { loading.value = false } }
|
||||
|
||||
function openCreate() { dialogMode.value = 'create'; form.value = { customer_id: '', plan_content: '', plan_date: todayStr(), status: '计划中' }; dialogVisible.value = true }
|
||||
function openEdit(item: any) { dialogMode.value = 'edit'; form.value = { ...item }; if (item.customer_id && item.customer_name && !customers.value.find((c: any) => c.id === item.customer_id)) { customers.value.unshift({ id: item.customer_id, name: item.customer_name }) }; dialogVisible.value = true }
|
||||
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 {
|
||||
const params: any = {}
|
||||
if (searchText.value) params.search = searchText.value
|
||||
const res = await api.get('/work-plans/', { params })
|
||||
workPlans.value = res.data
|
||||
} catch (e: any) { ElMessage.error('加载失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
dialogMode.value = 'create'
|
||||
form.value = { customer_id: '', plan_content: '', plan_date: todayStr(), status: '计划中', manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || '') }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(item: any) {
|
||||
dialogMode.value = 'edit'
|
||||
form.value = { ...item }
|
||||
if (item.customer_id && item.customer_name && !customers.value.find((c: any) => c.id === item.customer_id)) {
|
||||
customers.value.unshift({ id: item.customer_id, name: item.customer_name })
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.value.customer_id) { ElMessage.warning('请选择客户单位'); return }
|
||||
try {
|
||||
if (dialogMode.value === 'create') await api.post('/work-plans/', form.value)
|
||||
else await api.put(`/work-plans/${form.value.id}`, form.value)
|
||||
@@ -190,8 +246,21 @@ async function handleScreenshot() { const d = new Date().toISOString().slice(0,
|
||||
<!-- Filter Bar -->
|
||||
<div class="filter-bar">
|
||||
<div class="filter-row">
|
||||
<div class="filter-item"><label class="filter-label">客户经理</label><el-select v-model="filterManager" clearable placeholder="全部" size="small" style="width:150px"><el-option v-for="m in managerOptions" :key="m" :label="m" :value="m" /></el-select></div>
|
||||
<div class="filter-item"><label class="filter-label">状态</label><el-select v-model="filterStatus" clearable placeholder="全部" size="small" style="width:120px"><el-option v-for="s in planStatuses" :key="s" :label="s" :value="s" /></el-select></div>
|
||||
<div class="filter-item">
|
||||
<el-input v-model="searchText" placeholder="搜索..." clearable size="small" style="width:180px" @keyup.enter="onSearch" @clear="onSearch" />
|
||||
</div>
|
||||
<div class="filter-item">
|
||||
<label class="filter-label">客户经理</label>
|
||||
<el-select v-model="filterManager" clearable placeholder="全部" size="small" style="width:150px">
|
||||
<el-option v-for="m in managerOptions" :key="m" :label="m" :value="m" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="filter-item">
|
||||
<label class="filter-label">状态</label>
|
||||
<el-select v-model="filterStatus" clearable placeholder="全部" size="small" style="width:120px">
|
||||
<el-option v-for="s in planStatuses" :key="s" :label="s" :value="s" />
|
||||
</el-select>
|
||||
</div>
|
||||
<el-button v-if="filterManager || filterStatus" size="small" plain @click="resetFilters">重置</el-button>
|
||||
<span class="filter-count">{{ workPlans.length }} 条</span>
|
||||
</div>
|
||||
@@ -246,10 +315,35 @@ async function handleScreenshot() { const d = new Date().toISOString().slice(0,
|
||||
<!-- Dialog -->
|
||||
<el-dialog v-model="dialogVisible" :title="(dialogMode === 'create' ? '新建' : '编辑') + ' 工作计划'" width="500px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="客户单位"><el-select v-model="form.customer_id" filterable remote :remote-method="(q: string) => loadCustomers(q)" placeholder="搜索选择客户" style="width:100%"><el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" /></el-select></el-form-item>
|
||||
<el-form-item label="计划拜访时间"><el-date-picker v-model="form.plan_date" type="date" value-format="YYYY-MM-DD" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="工作计划"><el-input v-model="form.plan_content" type="textarea" :rows="4" placeholder="请输入计划内容" /></el-form-item>
|
||||
<el-form-item label="状态"><el-select v-model="form.status"><el-option label="计划中" value="计划中" /><el-option label="已完成" value="已完成" /><el-option label="已取消" value="已取消" /></el-select></el-form-item>
|
||||
<el-form-item label="客户单位 *">
|
||||
<el-select v-model="form.customer_id" filterable remote :remote-method="handleCustomerSearch" placeholder="搜索选择客户" style="width:100%">
|
||||
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
|
||||
<template #empty>
|
||||
<div v-if="customerSearch" class="select-empty-create">
|
||||
<p style="color:var(--warm-gray);font-size:13px;margin:0 0 8px">未找到「{{ customerSearch }}」</p>
|
||||
<button type="button" class="quick-create-btn" @click.stop="handleQuickCreate">+ 新建客户「{{ customerSearch }}」</button>
|
||||
</div>
|
||||
</template>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="auth.isDirector || auth.isLeader" label="客户经理">
|
||||
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
|
||||
<el-option v-for="m in allManagers" :key="m.id" :label="m.name" :value="m.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="计划拜访时间">
|
||||
<el-date-picker v-model="form.plan_date" type="date" value-format="YYYY-MM-DD" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="工作计划">
|
||||
<el-input v-model="form.plan_content" type="textarea" :rows="4" placeholder="请输入计划内容" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="form.status">
|
||||
<el-option label="计划中" value="计划中" />
|
||||
<el-option label="已完成" value="已完成" />
|
||||
<el-option label="已取消" value="已取消" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<EditLogPanel v-if="dialogMode === 'edit'" :edit-log="form.edit_log || []" />
|
||||
<template #footer><el-button @click="dialogVisible = false">取消</el-button><el-button type="primary" @click="handleSave">保存</el-button></template>
|
||||
@@ -306,4 +400,9 @@ async function handleScreenshot() { const d = new Date().toISOString().slice(0,
|
||||
|
||||
/* ── Overdue Badge ── */
|
||||
.overdue-badge { display: inline-block; padding: 1px 7px; border-radius: 8px; background: var(--vermilion); color: #fff; font-family: var(--font-mono); font-size: 10px; white-space: nowrap; }
|
||||
|
||||
.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; }
|
||||
.required-star { color: var(--vermilion); font-weight: 700; }
|
||||
</style>
|
||||
|
||||
@@ -121,6 +121,13 @@ async function handleDelete() {
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="auth.isDirector || auth.isLeader">
|
||||
<template #label><span class="form-label">客户经理</span></template>
|
||||
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
|
||||
<el-option v-for="m in allManagers" :key="m.id" :label="m.name" :value="m.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<template #label>
|
||||
<span class="form-label">分类</span>
|
||||
|
||||
@@ -70,7 +70,9 @@ async function handleSubmit() {
|
||||
}
|
||||
router.push('/m/leaves')
|
||||
} catch (e: any) {
|
||||
ElMessage.error((isEdit ? '更新' : '提交') + '失败: ' + (e.response?.data?.detail || e.message))
|
||||
const detail = e.response?.data?.detail
|
||||
const msg = Array.isArray(detail) ? detail.map((d: any) => d.msg).join('; ') : (detail || e.message)
|
||||
ElMessage.error((isEdit ? '更新' : '提交') + '失败: ' + msg)
|
||||
} finally { submitLoading.value = false }
|
||||
}
|
||||
|
||||
@@ -123,13 +125,13 @@ async function handleDelete() {
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<template #label><span class="form-label">开始日期</span></template>
|
||||
<el-date-picker v-model="form.start_date" type="date" value-format="YYYY-MM-DD" placeholder="选择开始日期" style="width:100%" />
|
||||
<template #label><span class="form-label">开始日期 <span class="required-star">*</span></span></template>
|
||||
<el-date-picker v-model="form.start_date" type="date" placeholder="选择开始日期" style="width:100%" value-format="YYYY-MM-DD" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<template #label><span class="form-label">结束日期</span></template>
|
||||
<el-date-picker v-model="form.end_date" type="date" value-format="YYYY-MM-DD" placeholder="选择结束日期" style="width:100%" />
|
||||
<template #label><span class="form-label">结束日期 <span class="required-star">*</span></span></template>
|
||||
<el-date-picker v-model="form.end_date" type="date" placeholder="选择结束日期" style="width:100%" value-format="YYYY-MM-DD" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
@@ -198,4 +200,5 @@ async function handleDelete() {
|
||||
.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; }
|
||||
</style>
|
||||
|
||||
@@ -6,8 +6,9 @@ import api from '@/api/index'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const searchText = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(50)
|
||||
const pageSize = ref(25)
|
||||
const total = ref(0)
|
||||
const items = ref<any[]>([])
|
||||
|
||||
@@ -55,18 +56,22 @@ const groupedPlans = computed(() => {
|
||||
const statusColors: Record<string, string> = {
|
||||
'计划中': '#4A6741', '已完成': '#5B7FA5', '已取消': '#909399',
|
||||
}
|
||||
|
||||
onMounted(loadItems)
|
||||
|
||||
async function loadItems() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.get('/work-plans/', { params: { page: page.value, page_size: pageSize.value } })
|
||||
const params: any = { page: page.value, page_size: pageSize.value }
|
||||
if (searchText.value) params.search = searchText.value
|
||||
const res = await api.get('/work-plans/', { params })
|
||||
const raw = Array.isArray(res.data) ? res.data : (res.data.items || [])
|
||||
items.value = raw; total.value = res.data.total || raw.length
|
||||
} catch (_) {} finally { loading.value = false }
|
||||
} catch (_) {}
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
function onSearch() { page.value = 1; loadItems() }
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
try { await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' }); await api.delete(`/work-plans/${id}`); ElMessage.success('已删除'); await loadItems() }
|
||||
catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
|
||||
@@ -104,6 +109,11 @@ async function confirmReschedule() {
|
||||
|
||||
<div class="auto-hint">💡 拜访客户后计划会自动完成,无需手动改状态</div>
|
||||
|
||||
<div style="margin-bottom:12px;display:flex;gap:8px">
|
||||
<el-input v-model="searchText" placeholder="搜索..." clearable @keyup.enter="onSearch" @clear="onSearch" />
|
||||
<el-button @click="onSearch">搜索</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="items.length === 0 && !loading" class="empty-state"><div class="empty-glyph">—</div><p class="empty-text">暂无工作计划</p></div>
|
||||
|
||||
<div v-for="group in groupedPlans" :key="group.key" class="section">
|
||||
@@ -191,7 +201,6 @@ async function confirmReschedule() {
|
||||
.quick-reschedule-btn:hover { background: var(--gold); color: #fff; }
|
||||
.quick-cancel-btn { padding: 4px 10px; background: none; border: 1px solid var(--vermilion); color: var(--vermilion); font-family: var(--font-body); font-size: 11px; cursor: pointer; transition: all 0.2s; }
|
||||
.quick-cancel-btn:hover { background: var(--vermilion); color: #fff; }
|
||||
|
||||
.empty-state { text-align: center; padding: 48px 0; }
|
||||
.empty-glyph { font-family: var(--font-heading); font-size: 40px; color: var(--gold); opacity: 0.4; margin-bottom: 8px; }
|
||||
.empty-text { font-family: var(--font-body); font-size: 15px; color: var(--warm-gray); margin: 0; }
|
||||
|
||||
Reference in New Issue
Block a user