chore: 保存进行中的未提交改动(企微定向推送+搜索筛选UI等)
保存合并前工作树中遗留的进行中改动,避免分支合并时丢失: - scheduler: 填报汇总改为定向推送给支局长/领导(而非广播@all) - router: JWT token 校验修复,bind token(UUID)不被误剥离 - 工作计划/计划列表: 搜索+筛选+分页 UI - 各列表 API 增加 search 参数支持 - docker-compose.backend.yml + backend/.dockerignore 纳入版本管理 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.egg-info
|
||||
.eggs
|
||||
dist
|
||||
build
|
||||
.venv
|
||||
venv
|
||||
env
|
||||
.pytest_cache
|
||||
tests
|
||||
htmlcov
|
||||
.coverage
|
||||
node_modules
|
||||
.vite
|
||||
.git
|
||||
.gitignore
|
||||
.vscode
|
||||
.idea
|
||||
.env
|
||||
.env.*
|
||||
.ruff_cache
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -1,5 +1,8 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV TZ=Asia/Shanghai
|
||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
|
||||
@@ -19,7 +19,7 @@ async def dashboard_stats(
|
||||
):
|
||||
"""Get dashboard card statistics. Pass reference_date (YYYY-MM-DD) for historical weeks."""
|
||||
ref = date.fromisoformat(reference_date) if reference_date else None
|
||||
stats = await get_dashboard_stats(db, ref)
|
||||
stats = await get_dashboard_stats(db, ref, current_user["user_id"], current_user["role"])
|
||||
return stats
|
||||
|
||||
|
||||
|
||||
@@ -103,6 +103,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 "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():
|
||||
setattr(k, key, v)
|
||||
new_snapshot = {"customer_id": str(k.customer_id), "urgency_level": k.urgency_level, "description": k.description, "progress_status": k.progress_status, "planned_date": k.planned_date, "planned_visitor": k.planned_visitor, "visit_target": k.visit_target}
|
||||
|
||||
@@ -101,6 +101,8 @@ async def update_mini_business(
|
||||
|
||||
old_snapshot = {"customer_id": str(m.customer_id), "product_type": m.product_type, "amount": m.amount, "follow_up_detail": m.follow_up_detail, "status": m.status, "expected_revenue_date": m.expected_revenue_date}
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
if "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)
|
||||
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}
|
||||
|
||||
@@ -247,11 +247,14 @@ async def update_visit(
|
||||
"visit_method": visit.visit_method, "time_range": visit.time_range,
|
||||
"visitor_name": visit.visitor_name or "", "visitor_phone": visit.visitor_phone or "",
|
||||
"communication_content": visit.communication_content, "customer_demand": visit.customer_demand,
|
||||
"manager_id": str(visit.manager_id) if visit.manager_id else "",
|
||||
}
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
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"]:
|
||||
update_data["manager_id"] = uuid.UUID(update_data["manager_id"])
|
||||
|
||||
for key, value in update_data.items():
|
||||
setattr(visit, key, value)
|
||||
@@ -262,6 +265,7 @@ async def update_visit(
|
||||
"visit_method": visit.visit_method, "time_range": visit.time_range,
|
||||
"visitor_name": visit.visitor_name or "", "visitor_phone": visit.visitor_phone or "",
|
||||
"communication_content": visit.communication_content, "customer_demand": visit.customer_demand,
|
||||
"manager_id": str(visit.manager_id) if visit.manager_id else "",
|
||||
}
|
||||
changes = compute_diff(old_snapshot, new_snapshot)
|
||||
if changes:
|
||||
@@ -282,7 +286,7 @@ async def update_visit(
|
||||
|
||||
# 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 ''} ({visit.visit_date})", "update", current_user["user_id"], current_user["name"], ", ".join([c["field"] for c in changes]) if changes else "")
|
||||
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)
|
||||
return await _enrich_visit(visit, db)
|
||||
|
||||
@@ -332,8 +332,9 @@ async def oauth_callback(
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
# Not bound — redirect to bind page
|
||||
bind_url = f"https://qj.dhdx.fun/wecom-bind?wecom_userid={wecom_userid}"
|
||||
# Not bound — generate a one-time bind token and redirect to bind page
|
||||
bind_token = await store_bind_token(db, wecom_userid)
|
||||
bind_url = f"https://qj.dhdx.fun/wecom-bind?token={bind_token}&wecom_userid={wecom_userid}"
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse(url=bind_url)
|
||||
|
||||
|
||||
@@ -117,6 +117,8 @@ async def update_work_plan(
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
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}
|
||||
|
||||
@@ -21,6 +21,7 @@ class KeyVisitUpdate(BaseModel):
|
||||
planned_date: Optional[str] = None
|
||||
planned_visitor: Optional[str] = None
|
||||
visit_target: Optional[str] = None
|
||||
manager_id: Optional[str] = None
|
||||
|
||||
|
||||
class KeyVisitOut(BaseModel):
|
||||
|
||||
@@ -19,6 +19,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
|
||||
|
||||
|
||||
class MiniBusinessOut(BaseModel):
|
||||
|
||||
@@ -30,6 +30,7 @@ class VisitUpdate(BaseModel):
|
||||
companions: Optional[list[uuid.UUID]] = None
|
||||
companion_names: Optional[list[str]] = None
|
||||
photos: Optional[list[str]] = None
|
||||
manager_id: Optional[str] = None
|
||||
|
||||
|
||||
class VisitOut(BaseModel):
|
||||
|
||||
@@ -16,6 +16,7 @@ class WorkPlanUpdate(BaseModel):
|
||||
plan_content: Optional[str] = None
|
||||
plan_date: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
manager_id: Optional[str] = None
|
||||
|
||||
|
||||
class WorkPlanOut(BaseModel):
|
||||
|
||||
@@ -12,7 +12,7 @@ from app.models.key_visit import KeyVisit
|
||||
from app.models.daily_note import DailyNote
|
||||
from app.models.leave import Leave
|
||||
from app.services.holidays import load_holiday_sets
|
||||
from app.utils.timezone import today_cst, is_working_day
|
||||
from app.utils.timezone import today_cst, is_working_day, parse_date
|
||||
|
||||
|
||||
def get_week_range(reference_date: date | None = None):
|
||||
@@ -22,7 +22,7 @@ def get_week_range(reference_date: date | None = None):
|
||||
return monday, sunday
|
||||
|
||||
|
||||
async def get_dashboard_stats(db: AsyncSession, reference_date: date | None = None) -> dict:
|
||||
async def get_dashboard_stats(db: AsyncSession, reference_date: date | None = None, user_id: str = "", role: str = "") -> dict:
|
||||
"""Get dashboard statistics for a given week (defaults to current)."""
|
||||
monday, sunday = get_week_range(reference_date)
|
||||
today = date.today()
|
||||
@@ -51,12 +51,22 @@ async def get_dashboard_stats(db: AsyncSession, reference_date: date | None = No
|
||||
)
|
||||
)).scalar() or 0
|
||||
|
||||
# Overdue plans (status=计划中, plan_date < today). Managers see only own.
|
||||
overdue_q = select(func.count(WorkPlan.id)).where(
|
||||
WorkPlan.status == "计划中",
|
||||
WorkPlan.plan_date < today,
|
||||
)
|
||||
if role == "manager":
|
||||
overdue_q = overdue_q.where(WorkPlan.manager_id == UUID(user_id))
|
||||
overdue_plans = (await db.execute(overdue_q)).scalar() or 0
|
||||
|
||||
return {
|
||||
"week_visits": visits_count,
|
||||
"work_plans": plans_count,
|
||||
"mini_business": mini_biz_count,
|
||||
"key_visits": key_visit_count,
|
||||
"week_leaves": leaves_count,
|
||||
"overdue_plans": overdue_plans,
|
||||
"week_start": str(monday),
|
||||
"week_end": str(sunday),
|
||||
}
|
||||
@@ -180,8 +190,18 @@ async def get_weekly_report(
|
||||
|
||||
visits_data = []
|
||||
for gid, gvisits in groups.items():
|
||||
# Determine the "primary" — first record in group (the creator's)
|
||||
primary = gvisits[0]
|
||||
# Determine the "primary" — the creator's record (not a companion copy)
|
||||
def _is_companion_copy(v) -> bool:
|
||||
content = (v.communication_content or "").strip()
|
||||
# New-style: has "(协同XXX)" suffix
|
||||
if content.endswith(")") and "(协同" in content:
|
||||
return True
|
||||
# Old-style: empty draft created for companion
|
||||
if not content:
|
||||
return True
|
||||
return False
|
||||
creator_records = [v for v in gvisits if not _is_companion_copy(v)]
|
||||
primary = creator_records[0] if creator_records else gvisits[0]
|
||||
companion_names_resolved = [user_map.get(c, str(c)) for c in (primary.companions or [])]
|
||||
companion_names_resolved.extend(primary.companion_names or [])
|
||||
|
||||
|
||||
@@ -80,12 +80,12 @@ async def check_daily_reporting(db: AsyncSession) -> dict:
|
||||
f"📋 今日填报提醒\n\n{desc}\n未填报:{names_text}\n\n请尽快完成填报 🙏\nhttps://qj.dhdx.fun/m",
|
||||
)
|
||||
|
||||
# 2. Send summary to director
|
||||
# 2. Send summary to directors/leaders only (not broadcast to @all)
|
||||
directors = await db.execute(
|
||||
select(User).where(User.role.in_(["director", "leader"]), User.wecom_userid.isnot(None))
|
||||
)
|
||||
for d in directors.scalars().all():
|
||||
if managers:
|
||||
director_ids = [d.wecom_userid for d in directors.scalars().all()]
|
||||
if managers and director_ids:
|
||||
effective_total = len(managers) - len(on_leave_names)
|
||||
if effective_total > 0:
|
||||
pct = len(reported_map) / effective_total * 100
|
||||
@@ -106,7 +106,7 @@ async def check_daily_reporting(db: AsyncSession) -> dict:
|
||||
summary += "**未填报:**\n" + "".join(f"- {m.name}\n" for m in not_reported)
|
||||
else:
|
||||
summary += "✅ 全体已完成今日填报"
|
||||
await wecom_client.send_markdown_message(summary) # broadcast for director visibility
|
||||
await wecom_client.send_markdown_message(summary, user_ids=director_ids)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
|
||||
@@ -9,7 +9,7 @@ def get_scheduler() -> AsyncIOScheduler:
|
||||
"""Lazy-init and return the global scheduler."""
|
||||
global _scheduler
|
||||
if _scheduler is None:
|
||||
_scheduler = AsyncIOScheduler()
|
||||
_scheduler = AsyncIOScheduler(timezone="Asia/Shanghai")
|
||||
return _scheduler
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ async def reschedule_daily_check(hour: int, minute: int):
|
||||
async with async_session() as db:
|
||||
await check_daily_reporting(db)
|
||||
|
||||
sched.add_job(_wrapper, "cron", hour=hour, minute=minute, id="daily_check")
|
||||
sched.add_job(_wrapper, "cron", hour=hour, minute=minute, id="daily_check", timezone="Asia/Shanghai")
|
||||
|
||||
|
||||
def start_scheduler(notification_hour: int = 17, notification_minute: int = 30):
|
||||
@@ -46,8 +46,8 @@ def start_scheduler(notification_hour: int = 17, notification_minute: int = 30):
|
||||
async with async_session() as db:
|
||||
await check_overdue_plans(db)
|
||||
|
||||
sched.add_job(_daily, "cron", hour=notification_hour, minute=notification_minute, id="daily_check")
|
||||
sched.add_job(_overdue, "cron", hour=9, minute=0, id="overdue_check")
|
||||
sched.add_job(_daily, "cron", hour=notification_hour, minute=notification_minute, id="daily_check", timezone="Asia/Shanghai")
|
||||
sched.add_job(_overdue, "cron", hour=9, minute=0, id="overdue_check", timezone="Asia/Shanghai")
|
||||
sched.start()
|
||||
|
||||
|
||||
|
||||
@@ -139,13 +139,14 @@ class WecomClient:
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def send_markdown_message(self, content: str) -> bool:
|
||||
"""Send a markdown message to all users in the app (broadcast)."""
|
||||
async def send_markdown_message(self, content: str, user_ids: list[str] | None = None) -> bool:
|
||||
"""Send a markdown message. If user_ids is provided, send to those users; otherwise broadcast to @all."""
|
||||
if not settings.WECOM_AGENT_ID:
|
||||
return False
|
||||
try:
|
||||
touser = "|".join(user_ids) if user_ids else "@all"
|
||||
body = {
|
||||
"touser": "@all",
|
||||
"touser": touser,
|
||||
"msgtype": "markdown",
|
||||
"agentid": int(settings.WECOM_AGENT_ID),
|
||||
"markdown": {"content": content},
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# 企迹 (qiji) — 后端生产部署
|
||||
# PostgreSQL / MinIO / Casdoor / 前端 均为外部服务,仅容器化运行 FastAPI 后端
|
||||
# 用法: docker compose -f docker-compose.backend.yml up -d
|
||||
#
|
||||
# 使用 host 网络模式:
|
||||
# - 可直接访问 10.10.10.x 外部服务 (DB/MinIO/Casdoor)
|
||||
# - FRP frpc 同为 host 网络,127.0.0.1:8002 直达
|
||||
|
||||
services:
|
||||
backend:
|
||||
build: ./backend
|
||||
container_name: qiji-backend
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
command: sh -c "alembic upgrade head && exec uvicorn app.main:app --host 0.0.0.0 --port 8002"
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
@@ -82,8 +82,10 @@ router.beforeEach((to, _from, next) => {
|
||||
const auth = useAuthStore()
|
||||
|
||||
// Handle WeChat Work OAuth silent login callback: ?token=JWT
|
||||
// Only strip the token if it's a valid JWT — bind tokens (UUID hex) must
|
||||
// survive so pages like WecomBind can read them.
|
||||
const tokenParam = to.query.token as string
|
||||
if (tokenParam) {
|
||||
if (tokenParam && tokenParam.split('.').length === 3) {
|
||||
// Save token to auth store (the JWT contains user_id, name, role, theme)
|
||||
try {
|
||||
const payload = JSON.parse(atob(tokenParam.split('.')[1]))
|
||||
@@ -94,12 +96,12 @@ router.beforeEach((to, _from, next) => {
|
||||
role: payload.role,
|
||||
theme: payload.theme,
|
||||
})
|
||||
} catch (_) { /* invalid token, ignore */ }
|
||||
// Remove token from URL
|
||||
// Remove token from URL only on successful JWT decode
|
||||
const cleanQuery = { ...to.query }
|
||||
delete cleanQuery.token
|
||||
next({ path: to.path, query: cleanQuery, replace: true })
|
||||
return
|
||||
} catch (_) { /* invalid JWT — leave token for page to handle */ }
|
||||
}
|
||||
|
||||
if (to.meta.public) {
|
||||
|
||||
@@ -86,14 +86,16 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
// ── WeChat Work silent login: redirect to OAuth (no user interaction needed) ──
|
||||
// Skip OAuth when user is on the bind flow — OAuth for unbound users redirects
|
||||
// right back to /wecom-bind without logging them in, creating an infinite loop.
|
||||
if (isWecom()) {
|
||||
const targetPath = (route.query.redirect as string) || '/m'
|
||||
const endpoint = import.meta.env.VITE_CASDOOR_ENDPOINT || ''
|
||||
// In production, the backend builds the OAuth URL; use the API
|
||||
if (!targetPath.startsWith('/wecom-bind')) {
|
||||
const apiBase = window.location.origin
|
||||
window.location.href = `${apiBase}/api/wecom/oauth-url?redirect=${encodeURIComponent(targetPath)}`
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auto-redirect to Casdoor (no button needed) ──
|
||||
loading.value = true
|
||||
|
||||
@@ -9,7 +9,7 @@ import api from '@/api/index'
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const loading = ref(false)
|
||||
const stats = ref({ week_visits: 0, work_plans: 0, mini_business: 0, key_visits: 0, week_leaves: 0, week_start: '', week_end: '' })
|
||||
const stats = ref({ week_visits: 0, work_plans: 0, mini_business: 0, key_visits: 0, week_leaves: 0, overdue_plans: 0, week_start: '', week_end: '' })
|
||||
const progress = ref<any[]>([])
|
||||
const weekOffset = ref(0) // 0 = current week, -1 = last week, etc.
|
||||
|
||||
@@ -145,8 +145,8 @@ function rowState(p: any): 'full' | 'catching' | 'missing' | 'on_leave' | 'rest_
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<span class="stat-num">{{ stats.work_plans }}</span>
|
||||
<span class="stat-label">工作计划</span>
|
||||
<span class="stat-num">{{ stats.work_plans }}<span v-if="stats.overdue_plans" class="stat-overdue">/{{ stats.overdue_plans }}</span></span>
|
||||
<span class="stat-label">工作计划<template v-if="stats.overdue_plans"> · <span style="color:var(--vermilion)">{{ stats.overdue_plans }}条过期</span></template></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-card--clickable" @click="router.push('/mini-business')">
|
||||
@@ -296,6 +296,7 @@ function rowState(p: any): 'full' | 'catching' | 'missing' | 'on_leave' | 'rest_
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px; color: var(--warm-gray); letter-spacing: 0.04em;
|
||||
}
|
||||
.stat-overdue { font-size: 16px; color: var(--vermilion); font-family: var(--font-mono); }
|
||||
|
||||
/* ═══ Progress Grid — responsive 2-column ═══ */
|
||||
.progress-grid {
|
||||
|
||||
@@ -111,6 +111,10 @@ function openEdit(type: string, item: any) {
|
||||
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 })
|
||||
}
|
||||
// Ensure assigned manager is in the select options (avoid showing raw UUID)
|
||||
if (item.manager_id && item.manager_name && !allUsers.value.find((u: any) => u.id === item.manager_id)) {
|
||||
allUsers.value.unshift({ id: item.manager_id, name: item.manager_name })
|
||||
}
|
||||
dialogTimeRange.value = null
|
||||
if ((type === 'visit' || type === 'note') && item.time_range && item.time_range.includes('-')) {
|
||||
const parts = item.time_range.split('-')
|
||||
@@ -479,6 +483,16 @@ const notesByDate = computed(() => {
|
||||
<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>
|
||||
<el-form-item v-if="dialogType === 'note'" label="日期"><el-date-picker v-model="form.note_date" type="date" value-format="YYYY-MM-DD" style="width:100%" /></el-form-item>
|
||||
<el-form-item v-if="dialogType === 'plan'" 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 v-if="dialogType === 'visit' && (auth.isDirector || auth.isLeader)" label="客户经理">
|
||||
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
|
||||
<el-option v-for="u in allUsers.filter((u: any) => u.role === 'manager')" :key="u.id" :label="u.name" :value="u.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="(dialogType === 'plan' || dialogType === 'mini' || dialogType === 'key') && (auth.isDirector || auth.isLeader)" label="客户经理">
|
||||
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
|
||||
<el-option v-for="u in allUsers.filter((u: any) => u.role === 'manager')" :key="u.id" :label="u.name" :value="u.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<template v-if="dialogType === 'visit'">
|
||||
<el-form-item label="拜访方式">
|
||||
<div class="method-grid">
|
||||
|
||||
@@ -20,119 +20,142 @@ const { capturing, captureEl } = useScreenshot()
|
||||
const dialogVisible = ref(false)
|
||||
const dialogMode = ref<'create' | 'edit'>('create')
|
||||
const form = ref<any>({})
|
||||
const statusPick = ref<Record<string, string>>({})
|
||||
const planStatuses = ['计划中', '已完成', '已取消']
|
||||
const statusPick = ref<Record<string, string>>({})
|
||||
|
||||
// Quick reschedule dialog
|
||||
const rescheduleVisible = ref(false)
|
||||
const rescheduleRow = ref<any>(null)
|
||||
const rescheduleDate = ref('')
|
||||
|
||||
function openReschedule(row: any) {
|
||||
rescheduleRow.value = row
|
||||
rescheduleDate.value = row.plan_date
|
||||
rescheduleVisible.value = true
|
||||
}
|
||||
async function confirmReschedule() {
|
||||
if (!rescheduleDate.value || !rescheduleRow.value) return
|
||||
try {
|
||||
await api.put(`/work-plans/${rescheduleRow.value.id}`, { plan_date: rescheduleDate.value })
|
||||
ElMessage.success('计划日期已更新')
|
||||
rescheduleVisible.value = false
|
||||
await loadPlans()
|
||||
} catch (e: any) { ElMessage.error('更新失败: ' + (e.response?.data?.detail || e.message)) }
|
||||
}
|
||||
|
||||
// ── Filters ──
|
||||
const filterManager = ref('')
|
||||
const filterStatus = ref('')
|
||||
const expandedSections = ref<Record<string, boolean>>({
|
||||
thisWeek: true, nextWeek: true, thisMonth: false, later: false, archived: false,
|
||||
})
|
||||
|
||||
const isLight = computed(() => themeStore.currentTheme === 'light')
|
||||
|
||||
const managerOptions = computed(() => {
|
||||
const seen = new Set<string>()
|
||||
return workPlans.value
|
||||
.map((w: any) => w.manager_name || '未知')
|
||||
.filter((n: string) => { if (seen.has(n)) return false; seen.add(n); return true })
|
||||
.sort()
|
||||
})
|
||||
const statusColors: Record<string, string> = {
|
||||
'计划中': '#4A6741',
|
||||
'已完成': '#5B7FA5',
|
||||
'已取消': '#909399',
|
||||
}
|
||||
|
||||
const filteredPlans = computed(() => {
|
||||
// ── Week boundaries ──
|
||||
const today = new Date()
|
||||
const weekStart = (d: Date) => { const s = new Date(d); s.setDate(s.getDate() - s.getDay() + 1); s.setHours(0,0,0,0); return s }
|
||||
const weekEnd = (d: Date) => { const e = weekStart(d); e.setDate(e.getDate() + 6); return e }
|
||||
const thisWeekStart = weekStart(today)
|
||||
const nextWeekStart = new Date(thisWeekStart); nextWeekStart.setDate(nextWeekStart.getDate() + 7)
|
||||
const thisMonthEnd = new Date(today.getFullYear(), today.getMonth() + 1, 0)
|
||||
|
||||
function classifyPlan(planDate: string, status: string): string {
|
||||
if (status !== '计划中') return 'archived'
|
||||
const d = new Date(planDate)
|
||||
if (d < thisWeekStart) return 'overdue' // falls into thisWeek section with overdue flag
|
||||
if (d <= weekEnd(today)) return 'thisWeek'
|
||||
if (d <= weekEnd(nextWeekStart)) return 'nextWeek'
|
||||
if (d <= thisMonthEnd) return 'thisMonth'
|
||||
return 'later'
|
||||
}
|
||||
|
||||
function sectionLabel(key: string): string {
|
||||
const labels: Record<string, string> = { overdue: '已过期', thisWeek: '本周', nextWeek: '下周', thisMonth: '本月', later: '更远', archived: '已归档' }
|
||||
return labels[key] || key
|
||||
}
|
||||
|
||||
function isOverdue(row: any): boolean {
|
||||
return row.status === '计划中' && row.plan_date < todayStr()
|
||||
}
|
||||
|
||||
function overdueDays(row: any): number {
|
||||
return Math.floor((today.getTime() - new Date(row.plan_date).getTime()) / 86400000)
|
||||
}
|
||||
|
||||
// ── Group plans ──
|
||||
const groupedPlans = computed(() => {
|
||||
const groups: Record<string, any[]> = { thisWeek: [], nextWeek: [], thisMonth: [], later: [], archived: [] }
|
||||
let list = workPlans.value
|
||||
if (filterManager.value) list = list.filter((w: any) => (w.manager_name || '未知') === filterManager.value)
|
||||
if (filterStatus.value) list = list.filter((w: any) => w.status === filterStatus.value)
|
||||
return list
|
||||
|
||||
for (const p of list) {
|
||||
const key = classifyPlan(p.plan_date, p.status)
|
||||
const g = key === 'overdue' ? 'thisWeek' : key
|
||||
if (!groups[g]) groups[g] = []
|
||||
groups[g].push(p)
|
||||
}
|
||||
|
||||
// Sort each group by plan_date asc
|
||||
for (const g of Object.values(groups)) {
|
||||
g.sort((a: any, b: any) => a.plan_date.localeCompare(b.plan_date))
|
||||
}
|
||||
|
||||
return Object.entries(groups)
|
||||
.filter(([_, items]) => items.length > 0)
|
||||
.map(([key, items]) => ({ key, label: sectionLabel(key), items }))
|
||||
})
|
||||
|
||||
function resetFilters() {
|
||||
filterManager.value = ''
|
||||
filterStatus.value = ''
|
||||
}
|
||||
const allOverdue = computed(() => workPlans.value.filter((w: any) => isOverdue(w)))
|
||||
|
||||
async function handleScreenshot() {
|
||||
const d = new Date().toISOString().slice(0, 10)
|
||||
await captureEl(shotRef.value, `工作计划_${d}.png`)
|
||||
}
|
||||
const managerOptions = computed(() => {
|
||||
const seen = new Set<string>()
|
||||
return workPlans.value.map((w: any) => w.manager_name || '未知').filter((n: string) => { if (seen.has(n)) return false; seen.add(n); return true }).sort()
|
||||
})
|
||||
|
||||
const managerSummary = computed(() => {
|
||||
const map: Record<string, number> = {}
|
||||
workPlans.value.forEach((w: any) => {
|
||||
const n = w.manager_name || '未知'
|
||||
map[n] = (map[n] || 0) + 1
|
||||
})
|
||||
workPlans.value.forEach((w: any) => { const n = w.manager_name || '未知'; map[n] = (map[n] || 0) + 1 })
|
||||
return Object.entries(map).sort((a, b) => b[1] - a[1])
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadPlans(), loadCustomers(), loadManagerColors()])
|
||||
})
|
||||
function resetFilters() { filterManager.value = ''; filterStatus.value = '' }
|
||||
|
||||
onMounted(async () => { await Promise.all([loadPlans(), loadCustomers(), loadManagerColors()]) })
|
||||
|
||||
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 (_) {}
|
||||
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 } }
|
||||
|
||||
async function loadPlans() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.get('/work-plans/')
|
||||
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: '计划中' }
|
||||
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
|
||||
}
|
||||
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 handleSave() {
|
||||
try {
|
||||
if (dialogMode.value === 'create') {
|
||||
await api.post('/work-plans/', form.value)
|
||||
} else {
|
||||
await api.put(`/work-plans/${form.value.id}`, form.value)
|
||||
}
|
||||
ElMessage.success(dialogMode.value === 'create' ? '已创建' : '已更新')
|
||||
dialogVisible.value = false
|
||||
await loadPlans()
|
||||
if (dialogMode.value === 'create') await api.post('/work-plans/', form.value)
|
||||
else await api.put(`/work-plans/${form.value.id}`, form.value)
|
||||
ElMessage.success(dialogMode.value === 'create' ? '已创建' : '已更新'); dialogVisible.value = false; await loadPlans()
|
||||
} catch (e: any) { ElMessage.error('保存失败: ' + (e.response?.data?.detail || e.message)) }
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' })
|
||||
await api.delete(`/work-plans/${id}`)
|
||||
ElMessage.success('已删除')
|
||||
await loadPlans()
|
||||
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
|
||||
}
|
||||
async function handleDelete(id: string) { try { await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' }); await api.delete(`/work-plans/${id}`); ElMessage.success('已删除'); await loadPlans() } 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(`/work-plans/${row.id}`, { status: newStatus })
|
||||
ElMessage.success('状态已更新')
|
||||
await loadPlans()
|
||||
} catch (e: any) {
|
||||
if (e !== 'cancel') ElMessage.error('更新失败: ' + (e.response?.data?.detail || e.message))
|
||||
delete statusPick.value[row.id]
|
||||
}
|
||||
try { await ElMessageBox.confirm(`确定将状态改为「${newStatus}」?`, '确认', { type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' }); await api.put(`/work-plans/${row.id}`, { status: newStatus }); ElMessage.success('状态已更新'); await loadPlans() } catch (e: any) { if (e !== 'cancel') ElMessage.error('更新失败') }
|
||||
}
|
||||
|
||||
async function quickReschedule(row: any) { openReschedule(row) }
|
||||
async function quickCancel(row: any) { await quickStatusChange(row, '已取消') }
|
||||
|
||||
async function handleScreenshot() { const d = new Date().toISOString().slice(0, 10); await captureEl(shotRef.value, `工作计划_${d}.png`) }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -142,26 +165,22 @@ async function quickStatusChange(row: any, newStatus: string) {
|
||||
<div class="page-head-row">
|
||||
<div>
|
||||
<h2 class="page-title">工作计划</h2>
|
||||
<p class="page-desc">面向未来的工作计划安排,支持状态流转跟踪。</p>
|
||||
<p class="page-desc">按周分组展示,过期计划高亮提醒。💡 拜访客户后计划自动完成,无需手动改状态。</p>
|
||||
</div>
|
||||
<div class="page-head-actions">
|
||||
<el-button type="primary" @click="openCreate" class="screenshot-hide">
|
||||
<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:4px">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
<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:4px"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
|
||||
新建计划
|
||||
</el-button>
|
||||
<el-button type="warning" :loading="capturing" @click="handleScreenshot" class="screenshot-hide">
|
||||
<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:4px">
|
||||
<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>
|
||||
截图导出
|
||||
</el-button>
|
||||
<el-button type="warning" :loading="capturing" @click="handleScreenshot" class="screenshot-hide">截图导出</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-rule"></div>
|
||||
<!-- Overdue alert banner -->
|
||||
<div v-if="allOverdue.length" class="overdue-banner">
|
||||
<svg 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="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
|
||||
<span>有 <strong>{{ allOverdue.length }}</strong> 条计划已过期,请及时改期或取消</span>
|
||||
</div>
|
||||
<div v-if="managerSummary.length" class="summary-bar">
|
||||
<span class="summary-label">客户经理汇总</span>
|
||||
<span v-for="[name, count] in managerSummary" :key="name" class="summary-chip" :class="{ 'summary-chip--active': filterManager === name }" :style="getMgrStyle(name, isLight)" @click="filterManager = filterManager === name ? '' : name">{{ name }} · {{ count }}</span>
|
||||
@@ -171,94 +190,79 @@ async function quickStatusChange(row: any, newStatus: string) {
|
||||
<!-- 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"><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">{{ filteredPlans.length }} / {{ workPlans.length }} 条</span>
|
||||
<span class="filter-count">{{ workPlans.length }} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card>
|
||||
<el-table :data="filteredPlans" stripe size="small" v-if="filteredPlans.length" v-column-resize>
|
||||
<el-table-column type="index" label="序号" width="50" />
|
||||
<!-- Grouped sections -->
|
||||
<div v-if="!workPlans.length" class="empty">暂无工作计划</div>
|
||||
<div v-for="group in groupedPlans" :key="group.key" class="plan-section">
|
||||
<div class="section-header" @click="expandedSections[group.key] = !expandedSections[group.key]">
|
||||
<span class="section-arrow">{{ expandedSections[group.key] ? '▼' : '▶' }}</span>
|
||||
<span class="section-title">{{ group.label }}</span>
|
||||
<span class="section-count">{{ group.items.length }} 条</span>
|
||||
</div>
|
||||
<div v-show="expandedSections[group.key]">
|
||||
<el-table :data="group.items" stripe size="small" v-column-resize>
|
||||
<el-table-column type="index" label="#" width="45" />
|
||||
<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-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>
|
||||
</el-tooltip>
|
||||
<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">🕐</span></el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="客户经理" width="100">
|
||||
<template #default="{ row }">
|
||||
<span class="mgr-tag" :style="getMgrStyle(row.manager_name, isLight)">{{ row.manager_name || '-' }}</span>
|
||||
</template>
|
||||
<template #default="{ row }"><span class="mgr-tag" :style="getMgrStyle(row.manager_name, isLight)">{{ row.manager_name || '-' }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="plan_content" label="工作计划" min-width="280" show-overflow-tooltip />
|
||||
<el-table-column prop="plan_content" label="工作计划" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="plan_date" label="计划时间" width="110" />
|
||||
<el-table-column label="状态" width="130">
|
||||
<el-table-column label="状态" width="170">
|
||||
<template #default="{ row }">
|
||||
<el-select
|
||||
v-model="statusPick[row.id]"
|
||||
size="small"
|
||||
style="width:100%"
|
||||
:placeholder="row.status"
|
||||
@change="(v: string) => quickStatusChange(row, v)"
|
||||
@visible-change="(v: boolean) => { if (v) statusPick[row.id] = row.status }"
|
||||
>
|
||||
<div style="display:flex;align-items:center;gap:6px">
|
||||
<el-select v-model="statusPick[row.id]" size="small" style="width:110px" :placeholder="row.status" @change="(v: string) => quickStatusChange(row, v)" @visible-change="(v: boolean) => { if (v) statusPick[row.id] = row.status }">
|
||||
<el-option v-for="s in planStatuses" :key="s" :label="s" :value="s" />
|
||||
</el-select>
|
||||
<span v-if="isOverdue(row)" class="overdue-badge">过期{{ overdueDays(row) }}天</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" fixed="right" class-name="screenshot-hide">
|
||||
<el-table-column label="操作" width="140" fixed="right" class-name="screenshot-hide">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
|
||||
<template v-if="isOverdue(row)">
|
||||
<el-button size="small" type="warning" @click="quickReschedule(row)">改期</el-button>
|
||||
<el-button size="small" @click="quickCancel(row)">取消</el-button>
|
||||
</template>
|
||||
<el-button v-else size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="!workPlans.length" class="empty">暂无工作计划</div>
|
||||
<div v-else-if="workPlans.length && !filteredPlans.length" class="empty">无匹配结果</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /shotRef -->
|
||||
|
||||
<!-- 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="(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>
|
||||
<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>
|
||||
<template #footer><el-button @click="dialogVisible = false">取消</el-button><el-button type="primary" @click="handleSave">保存</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Quick reschedule dialog -->
|
||||
<el-dialog v-model="rescheduleVisible" title="修改计划日期" width="360px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="新日期">
|
||||
<el-date-picker v-model="rescheduleDate" type="date" value-format="YYYY-MM-DD" style="width:100%" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="rescheduleVisible = false">取消</el-button><el-button type="primary" @click="confirmReschedule">确认改期</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -271,6 +275,12 @@ async function quickStatusChange(row: any, newStatus: string) {
|
||||
.page-title { margin: 0; font-family: var(--font-heading); font-size: 22px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; }
|
||||
.page-desc { margin: 4px 0 0; font-size: 13px; color: var(--c-text-muted); font-family: var(--font-body); }
|
||||
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; }
|
||||
|
||||
/* ── Overdue Banner ── */
|
||||
.overdue-banner { display: flex; align-items: center; gap: 8px; margin-top: 12px; padding: 10px 16px; background: rgba(184,71,46,0.06); border-left: 3px solid var(--vermilion); font-family: var(--font-body); font-size: 13px; color: var(--ink); }
|
||||
.overdue-banner strong { color: var(--vermilion); }
|
||||
|
||||
/* ── Summary Bar ── */
|
||||
.summary-bar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 14px; padding: 10px 14px; background: var(--c-bg-light, #faf9f6); border-radius: 6px; border: 1px solid var(--c-border, #e8e5df); }
|
||||
.summary-label { font-size: 12px; color: var(--c-text-muted); font-family: var(--font-body); margin-right: 4px; }
|
||||
.summary-chip { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 12px; color: var(--mgr-chip-text); white-space: nowrap; cursor: pointer; transition: opacity 0.2s, transform 0.15s; }
|
||||
@@ -285,4 +295,15 @@ 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; }
|
||||
|
||||
/* ── Sections ── */
|
||||
.plan-section { margin-bottom: 16px; border: 1px solid var(--warm-border); background: var(--surface); }
|
||||
.section-header { display: flex; align-items: center; gap: 10px; padding: 14px 18px; cursor: pointer; user-select: none; border-bottom: 1px solid var(--warm-border); transition: background 0.2s; }
|
||||
.section-header:hover { background: var(--c-bg-light, #faf9f6); }
|
||||
.section-arrow { font-size: 10px; color: var(--gold); width: 14px; transition: transform 0.2s; }
|
||||
.section-title { font-family: var(--font-heading); font-size: 15px; color: var(--ink); letter-spacing: 0.04em; }
|
||||
.section-count { font-family: var(--font-mono); font-size: 12px; color: var(--warm-gray); }
|
||||
|
||||
/* ── 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; }
|
||||
</style>
|
||||
|
||||
@@ -16,6 +16,7 @@ const previewImageUrl = ref('')
|
||||
const previewDialogVisible = ref(false)
|
||||
const photoUrls = ref<Record<string, string>>({})
|
||||
const loading = ref(false)
|
||||
const overduePlans = ref(0)
|
||||
|
||||
const todayTotal = computed(() => todayVisitCount.value + todayNoteCount.value)
|
||||
|
||||
@@ -57,7 +58,10 @@ async function loadToday() {
|
||||
|
||||
function previewPhoto(url: string) { previewImageUrl.value = url; previewDialogVisible.value = true }
|
||||
|
||||
onMounted(loadToday)
|
||||
async function loadOverdue() {
|
||||
try { const res = await api.get('/dashboard/stats'); overduePlans.value = res.data.overdue_plans || 0 } catch (_) {}
|
||||
}
|
||||
onMounted(() => { loadToday(); loadOverdue() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -81,6 +85,13 @@ onMounted(loadToday)
|
||||
<div class="stat-ornament"></div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Overdue Plans Alert ═══ -->
|
||||
<div v-if="overduePlans" class="overdue-alert" @click="router.push('/m/plans')">
|
||||
<svg 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="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
|
||||
<span>{{ overduePlans }} 条工作计划已过期,点击处理</span>
|
||||
<span class="overdue-arrow">→</span>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Primary Quick Actions ═══ -->
|
||||
<div class="quick-actions">
|
||||
<button class="action-btn action-btn--primary" @click="router.push('/m/visit/new')">
|
||||
@@ -271,6 +282,9 @@ onMounted(loadToday)
|
||||
background: linear-gradient(135deg, transparent 50%, rgba(196,147,74,0.08) 50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.overdue-arrow { margin-left: auto; color: var(--vermilion); }
|
||||
.overdue-alert:hover { background: rgba(184,71,46,0.1); }
|
||||
.overdue-alert { display: flex; align-items: center; gap: 8px; padding: 12px 16px; margin-bottom: 16px; background: rgba(184,71,46,0.06); border-left: 3px solid var(--vermilion); font-family: var(--font-body); font-size: 13px; color: var(--ink); cursor: pointer; transition: background 0.2s; }
|
||||
|
||||
/* ═══ Quick Actions ═══ */
|
||||
.quick-actions {
|
||||
|
||||
@@ -1,40 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import api from '@/api/index'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const searchText = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(25)
|
||||
const pageSize = ref(50)
|
||||
const total = ref(0)
|
||||
const items = ref<any[]>([])
|
||||
|
||||
const today = new Date()
|
||||
const weekStart = (d: Date) => { const s = new Date(d); s.setDate(s.getDate() - s.getDay() + 1); s.setHours(0,0,0,0); return s }
|
||||
const weekEnd = (d: Date) => { const e = weekStart(d); e.setDate(e.getDate() + 6); return e }
|
||||
const thisWeekStart = weekStart(today)
|
||||
const nextWeekStart = new Date(thisWeekStart); nextWeekStart.setDate(nextWeekStart.getDate() + 7)
|
||||
const thisMonthEnd = new Date(today.getFullYear(), today.getMonth() + 1, 0)
|
||||
const todayStr = computed(() => today.toISOString().slice(0, 10))
|
||||
|
||||
function classifyPlan(planDate: string, status: string): string {
|
||||
if (status !== '计划中') return 'archived'
|
||||
const d = new Date(planDate)
|
||||
if (d < thisWeekStart) return 'thisWeek'
|
||||
if (d <= weekEnd(today)) return 'thisWeek'
|
||||
if (d <= weekEnd(nextWeekStart)) return 'nextWeek'
|
||||
if (d <= thisMonthEnd) return 'thisMonth'
|
||||
return 'later'
|
||||
}
|
||||
|
||||
function isOverdue(row: any): boolean {
|
||||
return row.status === '计划中' && row.plan_date < todayStr.value
|
||||
}
|
||||
|
||||
function overdueDays(row: any): number {
|
||||
return Math.floor((today.getTime() - new Date(row.plan_date).getTime()) / 86400000)
|
||||
}
|
||||
|
||||
const sectionLabels: Record<string, string> = {
|
||||
thisWeek: '本周', nextWeek: '下周', thisMonth: '本月', later: '更远', archived: '已归档',
|
||||
}
|
||||
|
||||
const groupedPlans = computed(() => {
|
||||
const groups: Record<string, any[]> = { thisWeek: [], nextWeek: [], thisMonth: [], later: [], archived: [] }
|
||||
for (const p of items.value) {
|
||||
const key = classifyPlan(p.plan_date, p.status)
|
||||
if (!groups[key]) groups[key] = []
|
||||
groups[key].push(p)
|
||||
}
|
||||
for (const g of Object.values(groups)) g.sort((a: any, b: any) => a.plan_date.localeCompare(b.plan_date))
|
||||
return Object.entries(groups).filter(([_, list]) => list.length > 0).map(([key, list]) => ({ key, label: sectionLabels[key] || key, list }))
|
||||
})
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
'计划中': '#4A6741', '已完成': '#5B7FA5', '已取消': '#909399',
|
||||
}
|
||||
|
||||
onMounted(loadItems)
|
||||
|
||||
async function loadItems() {
|
||||
loading.value = true
|
||||
try {
|
||||
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 res = await api.get('/work-plans/', { params: { page: page.value, page_size: pageSize.value } })
|
||||
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('删除失败') }
|
||||
}
|
||||
|
||||
async function quickCancel(item: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' })
|
||||
await api.delete(`/work-plans/${id}`)
|
||||
ElMessage.success('已删除')
|
||||
await loadItems()
|
||||
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
|
||||
await ElMessageBox.confirm('确定将此计划标记为「已取消」?', '确认取消', { type: 'warning', confirmButtonText: '确定', cancelButtonText: '返回' })
|
||||
await api.put(`/work-plans/${item.id}`, { status: '已取消' }); ElMessage.success('已取消'); await loadItems()
|
||||
} catch (e: any) { if (e !== 'cancel') ElMessage.error('操作失败') }
|
||||
}
|
||||
|
||||
const rescheduleVisible = ref(false)
|
||||
const rescheduleItem = ref<any>(null)
|
||||
const rescheduleDate = ref('')
|
||||
function openReschedule(item: any) { rescheduleItem.value = item; rescheduleDate.value = item.plan_date; rescheduleVisible.value = true }
|
||||
async function confirmReschedule() {
|
||||
if (!rescheduleDate.value || !rescheduleItem.value) return
|
||||
try { await api.put(`/work-plans/${rescheduleItem.value.id}`, { plan_date: rescheduleDate.value }); ElMessage.success('日期已更新'); rescheduleVisible.value = false; await loadItems() }
|
||||
catch (e: any) { ElMessage.error('更新失败') }
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -50,30 +102,50 @@ async function handleDelete(id: string) {
|
||||
|
||||
<button class="new-btn" @click="router.push('/m/work-plan/new')">+ 新建计划</button>
|
||||
|
||||
<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 class="auto-hint">💡 拜访客户后计划会自动完成,无需手动改状态</div>
|
||||
|
||||
<div class="cards">
|
||||
<div v-if="items.length === 0 && !loading" class="empty-state"><div class="empty-glyph">—</div><p class="empty-text">暂无工作计划</p></div>
|
||||
<article v-for="item in items" :key="item.id" class="card" @click="router.push(`/m/work-plan/${item.id}/edit`)">
|
||||
<div class="card-accent" style="background:#4A6741"></div>
|
||||
|
||||
<div v-for="group in groupedPlans" :key="group.key" class="section">
|
||||
<div class="section-header">
|
||||
<span class="section-title">{{ group.label }}</span>
|
||||
<span class="section-count">{{ group.list.length }} 条</span>
|
||||
</div>
|
||||
<div class="cards">
|
||||
<article v-for="item in group.list" :key="item.id" class="card" :class="{ 'card--overdue': isOverdue(item) }" @click="router.push(`/m/work-plan/${item.id}/edit`)">
|
||||
<div class="card-accent" :style="{ background: isOverdue(item) ? '#B8472E' : statusColors[item.status] || '#909399' }"></div>
|
||||
<div class="card-body">
|
||||
<div class="card-header">
|
||||
<span class="card-status" :style="{ color: item.status === '已完成' ? '#4A6741' : item.status === '已取消' ? '#B8472E' : '#C4934A' }">{{ item.status }}</span>
|
||||
<span class="card-status" :style="{ color: statusColors[item.status] || '#909399' }">{{ item.status }}</span>
|
||||
<span v-if="isOverdue(item)" class="overdue-badge">过期{{ overdueDays(item) }}天</span>
|
||||
<span class="card-date">{{ item.plan_date }}</span>
|
||||
</div>
|
||||
<div class="card-content">{{ item.plan_content }}</div>
|
||||
<div class="card-footer">
|
||||
<span class="card-customer">{{ item.customer_name }}</span>
|
||||
<div v-if="isOverdue(item)" style="display:flex;gap:6px">
|
||||
<button class="quick-reschedule-btn" @click.stop="openReschedule(item)">改期</button>
|
||||
<button class="quick-cancel-btn" @click.stop="quickCancel(item)">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="total > pageSize" style="display:flex;justify-content:center;margin-top:14px">
|
||||
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev, pager, next" small @current-change="loadItems" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick reschedule dialog -->
|
||||
<el-dialog v-model="rescheduleVisible" title="修改计划日期" width="90%">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="新日期">
|
||||
<el-date-picker v-model="rescheduleDate" type="date" value-format="YYYY-MM-DD" style="width:100%" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="rescheduleVisible = false">取消</el-button><el-button type="primary" @click="confirmReschedule">确认改期</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -90,18 +162,36 @@ async function handleDelete(id: string) {
|
||||
.new-btn { width: 100%; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 14px; background: var(--ink); color: #fff; border: none; font-family: var(--font-heading); font-size: 15px; letter-spacing: 0.06em; cursor: pointer; transition: all 0.25s; margin-bottom: 18px; }
|
||||
.new-btn:hover { background: var(--ink-light); }
|
||||
.new-btn:active { transform: scale(0.98); }
|
||||
|
||||
.auto-hint { padding: 10px 14px; margin-bottom: 14px; background: rgba(74,103,65,0.06); border-left: 3px solid #4A6741; font-family: var(--font-body); font-size: 12px; color: var(--ink); line-height: 1.5; }
|
||||
|
||||
/* ── Sections ── */
|
||||
.section { margin-bottom: 20px; }
|
||||
.section-header { display: flex; align-items: baseline; gap: 8px; padding: 8px 0 10px; border-bottom: 1px solid var(--warm-border); margin-bottom: 10px; }
|
||||
.section-title { font-family: var(--font-heading); font-size: 14px; color: var(--ink); letter-spacing: 0.04em; }
|
||||
.section-count { font-family: var(--font-mono); font-size: 11px; color: var(--warm-gray); }
|
||||
|
||||
.cards { display: flex; flex-direction: column; gap: 10px; }
|
||||
.card { background: var(--surface); border: 1px solid var(--warm-border); display: flex; cursor: pointer; transition: transform 0.2s, box-shadow 0.2s; }
|
||||
.card--overdue { border-left: 3px solid var(--vermilion); background: rgba(184,71,46,0.03); }
|
||||
.card:active { transform: scale(0.99); }
|
||||
.card:hover { box-shadow: 0 4px 12px rgba(28,55,56,0.06); }
|
||||
.card-accent { width: 4px; flex-shrink: 0; }
|
||||
.card-body { flex: 1; padding: 14px 16px; min-width: 0; }
|
||||
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
.card-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||
.card-status { font-family: var(--font-heading); font-size: 13px; }
|
||||
.card-date { font-family: var(--font-mono); font-size: 12px; color: var(--warm-gray); }
|
||||
.card-date { font-family: var(--font-mono); font-size: 12px; color: var(--warm-gray); margin-left: auto; }
|
||||
.card-content { font-family: var(--font-body); font-size: 14px; color: var(--ink); margin-bottom: 6px; line-height: 1.5; }
|
||||
.card-footer { display: flex; align-items: center; justify-content: space-between; }
|
||||
.card-customer { font-size: 12px; color: var(--warm-gray); }
|
||||
|
||||
/* ── Overdue ── */
|
||||
.overdue-badge { padding: 1px 6px; border-radius: 6px; background: var(--vermilion); color: #fff; font-family: var(--font-mono); font-size: 10px; white-space: nowrap; }
|
||||
.quick-reschedule-btn { padding: 4px 10px; background: none; border: 1px solid var(--gold); color: var(--gold-dark); font-family: var(--font-body); font-size: 11px; cursor: pointer; transition: all 0.2s; }
|
||||
.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; }
|
||||
|
||||
@@ -55,10 +55,13 @@ onMounted(async () => {
|
||||
try {
|
||||
const res = await visitsApi.get(route.params.id as string)
|
||||
const v = res.data
|
||||
// 确保当前客户在 select 选项列表中,避免显示 UUID
|
||||
// 确保当前客户/客户经理在 select 选项列表中,避免显示 UUID
|
||||
if (v.customer_id && v.customer_name && !customers.value.find(c => c.id === v.customer_id)) {
|
||||
customers.value.unshift({ id: v.customer_id, name: v.customer_name })
|
||||
}
|
||||
if (v.manager_id && v.manager_name && !managers.value.find((m: any) => m.id === v.manager_id)) {
|
||||
managers.value.unshift({ id: v.manager_id, name: v.manager_name })
|
||||
}
|
||||
form.value = {
|
||||
customer_id: v.customer_id,
|
||||
visit_date: v.visit_date,
|
||||
|
||||
Reference in New Issue
Block a user