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:
2026-08-17 09:31:12 +08:00
46 changed files with 2682 additions and 213 deletions
+8 -1
View File
@@ -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 fallbackAPI 路由由 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 参数模糊匹配
+4 -1
View File
@@ -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()
+6 -1
View File
@@ -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():
+27 -2
View File
@@ -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():
+30 -2
View File
@@ -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:
+143
View File
@@ -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
View File
@@ -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)
+28 -46
View File
@@ -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
View File
@@ -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")
+2
View File
@@ -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",
+1
View File
@@ -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="")
+7 -1
View File
@@ -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")
+30
View File
@@ -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")
+4
View File
@@ -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
+2
View File
@@ -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):
+2 -1
View File
@@ -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):
+2 -1
View File
@@ -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):
+33
View File
@@ -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}
+2
View File
@@ -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
+2 -1
View File
@@ -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):
+13 -5
View File
@@ -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())
+7
View File
@@ -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):
+24 -19
View File
@@ -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_(
+24
View File
@@ -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),
}
+40
View File
@@ -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. 逾期但有拜访的计划 → 保持"计划中"(已有拜访覆盖,不算僵尸)
+8
View File
@@ -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>
+416
View File
@@ -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
View File
@@ -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}`)
},
}
+10 -9
View File
@@ -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="移动端">
+7 -3
View File
@@ -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) {
+43 -17
View File
@@ -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 lightThemes: Theme[] = ['editorial', 'light']
export const darkThemes: Theme[] = ['minimal-dark', 'tech-dark']
export const useThemeStore = defineStore('theme', () => {
const currentTheme = ref<Theme>(
(localStorage.getItem('theme') as Theme) || 'editorial'
)
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')
function applyTheme(theme: Theme) {
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 }
})
+393 -21
View File
@@ -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>
+13
View File
@@ -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>
+56 -5
View File
@@ -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>
+7 -2
View File
@@ -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>
+20
View File
@@ -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>
+125 -22
View File
@@ -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,15 +364,23 @@ 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">
<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)">
@@ -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>
+196 -8
View File
@@ -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>
+27 -4
View File
@@ -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); }
+110 -11
View File
@@ -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>
+8 -5
View File
@@ -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>
+14 -5
View File
@@ -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; }