feat: 信息架构重组 + 变更追踪 + 图片预览增强
- 信息架构重组: 周报精简为拜访+纪要两个Tab,工作计划/商机/要客独立为侧边栏「工作」分组下的独立页面 - 侧边栏分组: 汇总/工作/管理三层分组,仪表盘四卡可点击跳转 - 变更追踪(edit_log): 5张表新增JSONB edit_log列,POST创建/PUT diff自动记录,编辑弹窗变更时间轴,表格🕐编辑标记 - 图片预览增强: ImagePreview统一组件,支持适应页面/缩放/拖拽平移/滚轮缩放/键盘快捷键 - 修复客户导入500错误(errors变量未初始化) - 移除工作计划/商机/要客页面冗余编辑按钮 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -56,11 +56,11 @@ qiji/
|
|||||||
| `customers` | id, name, industry, address, in_use_services, monthly_fee, remarks, created_by |
|
| `customers` | id, name, industry, address, in_use_services, monthly_fee, remarks, created_by |
|
||||||
| `customer_contacts` | id, customer_id, name, phone, role_desc |
|
| `customer_contacts` | id, customer_id, name, phone, role_desc |
|
||||||
| `customer_assignments` | id, customer_id, manager_id, role(primary/assistant), assigned_by |
|
| `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 |
|
| `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) |
|
||||||
| `daily_notes` | id, manager_id, note_date, category, content, time_range |
|
| `daily_notes` | id, manager_id, note_date, category, content, time_range, edit_log(JSONB) |
|
||||||
| `work_plans` | id, customer_id, plan_content, plan_date, manager_id, status |
|
| `work_plans` | id, customer_id, plan_content, plan_date, manager_id, status, edit_log(JSONB) |
|
||||||
| `mini_business` | id, customer_id, product_type, amount, follow_up_detail, status, manager_id |
|
| `mini_business` | id, customer_id, product_type, amount, follow_up_detail, status, manager_id, expected_revenue_date, edit_log(JSONB) |
|
||||||
| `key_visits` | id, customer_id, urgency_level, description, progress_status, planned_date, planned_visitor, visit_target, manager_id |
|
| `key_visits` | id, customer_id, urgency_level, description, progress_status, planned_date, planned_visitor, visit_target, manager_id, edit_log(JSONB) |
|
||||||
|
|
||||||
## 当前进度
|
## 当前进度
|
||||||
|
|
||||||
@@ -105,6 +105,13 @@ qiji/
|
|||||||
- [x] **填报进度权限** — 经理只看到自己,支局长/领导看全员
|
- [x] **填报进度权限** — 经理只看到自己,支局长/领导看全员
|
||||||
- [x] **客户经理列** — 暖灰色hash标签,每人唯一颜色
|
- [x] **客户经理列** — 暖灰色hash标签,每人唯一颜色
|
||||||
- [x] **列表序号** — 客户管理+用户管理添加序号列
|
- [x] **列表序号** — 客户管理+用户管理添加序号列
|
||||||
|
- [x] **客户导入修复** — 修复 import_customers 500 错误(errors 变量未初始化)
|
||||||
|
- [x] **图片预览增强** — 适应页面/缩放/拖拽平移 + 底部工具栏(PC端+移动端统一 ImagePreview 组件)
|
||||||
|
- [x] **信息架构重组** — 周报精简为拜访+纪要两个Tab;工作计划/商机/要客独立为侧边栏「工作」分组下的独立页面;仪表盘四卡可点击跳转
|
||||||
|
- [x] **侧边栏分组** — 侧边栏分为「汇总」「工作」「管理」三个分组,JetBrains Mono 标签
|
||||||
|
- [x] **变更追踪 (edit_log)** — 5 张表新增 JSONB edit_log 列;POST 初始化创建记录;PUT 自动对比新旧值追加 diff;编辑弹窗底部展示变更时间轴(EditLogPanel.vue);表格被修改过的记录显示 🕐 时钟图标
|
||||||
|
- [x] **导入模板增强** — 客户导入失败原因明细显示
|
||||||
|
- [x] **操作精简** — 工作计划/商机/要客页面移除冗余"编辑"按钮(点击客户名已可编辑)
|
||||||
|
|
||||||
### 待完善
|
### 待完善
|
||||||
|
|
||||||
@@ -209,3 +216,9 @@ docker-compose up -d
|
|||||||
11. **列表操作**: 点击客户名→详情弹窗含编辑/删除按钮,移除表格操作栏
|
11. **列表操作**: 点击客户名→详情弹窗含编辑/删除按钮,移除表格操作栏
|
||||||
12. **侧边栏**: ink 深色渐变 + SVG 内联图标 + 可折叠(64px) + 金色装饰线
|
12. **侧边栏**: ink 深色渐变 + SVG 内联图标 + 可折叠(64px) + 金色装饰线
|
||||||
13. **客户经理列**: hash 配色标签,每人唯一颜色(8 色暖灰调)
|
13. **客户经理列**: hash 配色标签,每人唯一颜色(8 色暖灰调)
|
||||||
|
14. **侧边栏分组**: 汇总(仪表盘/周报)、工作(工作计划/商机跟单/要客拜访)、管理(客户/用户/系统设置)三层分组,JetBrains Mono 标签
|
||||||
|
15. **周报精简**: 只保留每日拜访记录+今日纪要两个 Tab,工作计划/商机/要客独立为侧边栏项目
|
||||||
|
16. **独立工作页**: 工作计划/商机/要客各自拥有独立页面 + CRUD 弹窗 + 状态快速切换
|
||||||
|
17. **变更追踪 (edit_log)**: 5 张业务表均含 JSONB edit_log 列;POST 自动插入创建记录;PUT 自动 diff 新旧值追加变更条目(编辑人+时间+字段级 diff+原因);编辑弹窗底部折叠时间轴面板;表格 🕐 图标标记被修改过的记录
|
||||||
|
18. **图片预览**: ImagePreview.vue 统一组件,支持适应页面/缩放(0.25x~5x)/拖拽平移/滚轮缩放/键盘快捷键,PC+移动端一致体验
|
||||||
|
19. **仪表盘跳转**: 四张统计卡片可点击跳转到对应的周报/工作计划/商机/要客页面
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ async def import_customers(
|
|||||||
ws = wb.active
|
ws = wb.active
|
||||||
created, updated, skipped = 0, 0, 0
|
created, updated, skipped = 0, 0, 0
|
||||||
reasons = []
|
reasons = []
|
||||||
|
errors = []
|
||||||
|
|
||||||
# Build user name → id lookup (all users, not just managers)
|
# Build user name → id lookup (all users, not just managers)
|
||||||
user_rows = await db.execute(select(User.name, User.id))
|
user_rows = await db.execute(select(User.name, User.id))
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from app.models.daily_note import DailyNote
|
|||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.daily_note import DailyNoteCreate, DailyNoteUpdate, DailyNoteOut
|
from app.schemas.daily_note import DailyNoteCreate, DailyNoteUpdate, DailyNoteOut
|
||||||
from app.utils.timezone import today_cst, parse_date
|
from app.utils.timezone import today_cst, parse_date
|
||||||
|
from app.utils.edit_log import compute_diff, append_entry, init_entry
|
||||||
|
|
||||||
router = APIRouter(prefix="/daily-notes", tags=["DailyNotes"])
|
router = APIRouter(prefix="/daily-notes", tags=["DailyNotes"])
|
||||||
|
|
||||||
@@ -20,8 +21,8 @@ async def _enrich(note: DailyNote, db: AsyncSession) -> dict:
|
|||||||
"id": note.id, "manager_id": note.manager_id,
|
"id": note.id, "manager_id": note.manager_id,
|
||||||
"note_date": note.note_date, "category": note.category,
|
"note_date": note.note_date, "category": note.category,
|
||||||
"content": note.content, "time_range": note.time_range,
|
"content": note.content, "time_range": note.time_range,
|
||||||
"created_at": note.created_at, "updated_at": note.updated_at,
|
"edit_log": note.edit_log or [], "created_at": note.created_at,
|
||||||
"manager_name": mgr.scalar_one_or_none(),
|
"updated_at": note.updated_at, "manager_name": mgr.scalar_one_or_none(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -85,6 +86,7 @@ async def create_note(
|
|||||||
content=data.content,
|
content=data.content,
|
||||||
time_range=data.time_range,
|
time_range=data.time_range,
|
||||||
)
|
)
|
||||||
|
init_entry(note, current_user["name"])
|
||||||
db.add(note)
|
db.add(note)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(note)
|
await db.refresh(note)
|
||||||
@@ -104,11 +106,16 @@ async def update_note(
|
|||||||
if current_user["role"] == "manager" and str(note.manager_id) != current_user["user_id"]:
|
if current_user["role"] == "manager" and str(note.manager_id) != current_user["user_id"]:
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
|
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)
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
if "note_date" in update_data and update_data["note_date"]:
|
if "note_date" in update_data and update_data["note_date"]:
|
||||||
update_data["note_date"] = parse_date(update_data["note_date"])
|
update_data["note_date"] = parse_date(update_data["note_date"])
|
||||||
for k, v in update_data.items():
|
for k, v in update_data.items():
|
||||||
setattr(note, k, v)
|
setattr(note, k, v)
|
||||||
|
new_snapshot = {"note_date": str(note.note_date), "category": note.category, "content": note.content, "time_range": note.time_range}
|
||||||
|
changes = compute_diff(old_snapshot, new_snapshot)
|
||||||
|
if changes:
|
||||||
|
append_entry(note, current_user["name"], changes, getattr(data, "edit_reason", None))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(note)
|
await db.refresh(note)
|
||||||
return await _enrich(note, db)
|
return await _enrich(note, db)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from app.models.key_visit import KeyVisit
|
|||||||
from app.models.customer import Customer
|
from app.models.customer import Customer
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.key_visit import KeyVisitCreate, KeyVisitUpdate, KeyVisitOut
|
from app.schemas.key_visit import KeyVisitCreate, KeyVisitUpdate, KeyVisitOut
|
||||||
|
from app.utils.edit_log import compute_diff, append_entry, init_entry
|
||||||
|
|
||||||
router = APIRouter(prefix="/key-visits", tags=["KeyVisits"])
|
router = APIRouter(prefix="/key-visits", tags=["KeyVisits"])
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ async def _enrich(k: KeyVisit, db: AsyncSession) -> dict:
|
|||||||
"planned_visitor": k.planned_visitor,
|
"planned_visitor": k.planned_visitor,
|
||||||
"visit_target": k.visit_target,
|
"visit_target": k.visit_target,
|
||||||
"manager_id": str(k.manager_id),
|
"manager_id": str(k.manager_id),
|
||||||
|
"edit_log": k.edit_log or [],
|
||||||
"manager_name": mgr.scalar_one_or_none(),
|
"manager_name": mgr.scalar_one_or_none(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +65,7 @@ async def create_key_visit(
|
|||||||
visit_target=data.visit_target,
|
visit_target=data.visit_target,
|
||||||
manager_id=uuid.UUID(current_user["user_id"]),
|
manager_id=uuid.UUID(current_user["user_id"]),
|
||||||
)
|
)
|
||||||
|
init_entry(k, current_user["name"])
|
||||||
db.add(k)
|
db.add(k)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(k)
|
await db.refresh(k)
|
||||||
@@ -82,9 +85,14 @@ async def update_key_visit(
|
|||||||
if current_user["role"] == "manager" and str(k.manager_id) != current_user["user_id"]:
|
if current_user["role"] == "manager" and str(k.manager_id) != current_user["user_id"]:
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
|
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)
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
for key, v in update_data.items():
|
for key, v in update_data.items():
|
||||||
setattr(k, key, v)
|
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}
|
||||||
|
changes = compute_diff(old_snapshot, new_snapshot)
|
||||||
|
if changes:
|
||||||
|
append_entry(k, current_user["name"], changes, getattr(data, "edit_reason", None))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(k)
|
await db.refresh(k)
|
||||||
return await _enrich(k, db)
|
return await _enrich(k, db)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from app.models.mini_business import MiniBusiness
|
|||||||
from app.models.customer import Customer
|
from app.models.customer import Customer
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.mini_business import MiniBusinessCreate, MiniBusinessUpdate, MiniBusinessOut
|
from app.schemas.mini_business import MiniBusinessCreate, MiniBusinessUpdate, MiniBusinessOut
|
||||||
|
from app.utils.edit_log import compute_diff, append_entry, init_entry
|
||||||
|
|
||||||
router = APIRouter(prefix="/mini-business", tags=["MiniBusiness"])
|
router = APIRouter(prefix="/mini-business", tags=["MiniBusiness"])
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ async def _enrich(m: MiniBusiness, db: AsyncSession) -> dict:
|
|||||||
"status": m.status,
|
"status": m.status,
|
||||||
"manager_id": str(m.manager_id),
|
"manager_id": str(m.manager_id),
|
||||||
"manager_name": mgr.scalar_one_or_none(),
|
"manager_name": mgr.scalar_one_or_none(),
|
||||||
|
"edit_log": m.edit_log or [],
|
||||||
"expected_revenue_date": m.expected_revenue_date,
|
"expected_revenue_date": m.expected_revenue_date,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +63,7 @@ async def create_mini_business(
|
|||||||
manager_id=uuid.UUID(current_user["user_id"]),
|
manager_id=uuid.UUID(current_user["user_id"]),
|
||||||
expected_revenue_date=data.expected_revenue_date,
|
expected_revenue_date=data.expected_revenue_date,
|
||||||
)
|
)
|
||||||
|
init_entry(m, current_user["name"])
|
||||||
db.add(m)
|
db.add(m)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(m)
|
await db.refresh(m)
|
||||||
@@ -80,9 +83,14 @@ async def update_mini_business(
|
|||||||
if current_user["role"] == "manager" and str(m.manager_id) != current_user["user_id"]:
|
if current_user["role"] == "manager" and str(m.manager_id) != current_user["user_id"]:
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
|
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)
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
for k, v in update_data.items():
|
for k, v in update_data.items():
|
||||||
setattr(m, k, v)
|
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}
|
||||||
|
changes = compute_diff(old_snapshot, new_snapshot)
|
||||||
|
if changes:
|
||||||
|
append_entry(m, current_user["name"], changes, getattr(data, "edit_reason", None))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(m)
|
await db.refresh(m)
|
||||||
return await _enrich(m, db)
|
return await _enrich(m, db)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from app.models.user import User
|
|||||||
from app.schemas.visit import VisitCreate, VisitUpdate, VisitOut, VisitListOut
|
from app.schemas.visit import VisitCreate, VisitUpdate, VisitOut, VisitListOut
|
||||||
from app.utils.timezone import today_cst, parse_date
|
from app.utils.timezone import today_cst, parse_date
|
||||||
from app.services.minio_client import delete_objects
|
from app.services.minio_client import delete_objects
|
||||||
|
from app.utils.edit_log import compute_diff, append_entry, init_entry
|
||||||
|
|
||||||
router = APIRouter(prefix="/visits", tags=["Visits"])
|
router = APIRouter(prefix="/visits", tags=["Visits"])
|
||||||
|
|
||||||
@@ -42,6 +43,7 @@ async def _enrich_visit(visit: Visit, db: AsyncSession) -> dict:
|
|||||||
"photos": visit.photos,
|
"photos": visit.photos,
|
||||||
"manager_id": str(visit.manager_id),
|
"manager_id": str(visit.manager_id),
|
||||||
"manager_name": manager_name,
|
"manager_name": manager_name,
|
||||||
|
"edit_log": visit.edit_log or [],
|
||||||
"created_at": str(visit.created_at),
|
"created_at": str(visit.created_at),
|
||||||
"updated_at": str(visit.updated_at),
|
"updated_at": str(visit.updated_at),
|
||||||
}
|
}
|
||||||
@@ -138,6 +140,7 @@ async def create_visit(
|
|||||||
photos=data.photos,
|
photos=data.photos,
|
||||||
manager_id=uuid.UUID(current_user["user_id"]),
|
manager_id=uuid.UUID(current_user["user_id"]),
|
||||||
)
|
)
|
||||||
|
init_entry(visit, current_user["name"])
|
||||||
db.add(visit)
|
db.add(visit)
|
||||||
|
|
||||||
# Create draft copies for companions
|
# Create draft copies for companions
|
||||||
@@ -176,6 +179,14 @@ async def update_visit(
|
|||||||
if current_user["role"] == "manager" and str(visit.manager_id) != current_user["user_id"]:
|
if current_user["role"] == "manager" and str(visit.manager_id) != current_user["user_id"]:
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
|
# Snapshot old values for diff
|
||||||
|
old_snapshot = {
|
||||||
|
"customer_id": str(visit.customer_id), "visit_date": str(visit.visit_date),
|
||||||
|
"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,
|
||||||
|
}
|
||||||
|
|
||||||
update_data = data.model_dump(exclude_unset=True)
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
if "visit_date" in update_data and update_data["visit_date"]:
|
if "visit_date" in update_data and update_data["visit_date"]:
|
||||||
update_data["visit_date"] = parse_date(update_data["visit_date"])
|
update_data["visit_date"] = parse_date(update_data["visit_date"])
|
||||||
@@ -183,6 +194,17 @@ async def update_visit(
|
|||||||
for key, value in update_data.items():
|
for key, value in update_data.items():
|
||||||
setattr(visit, key, value)
|
setattr(visit, key, value)
|
||||||
|
|
||||||
|
# Compute diff and append to edit_log
|
||||||
|
new_snapshot = {
|
||||||
|
"customer_id": str(visit.customer_id), "visit_date": str(visit.visit_date),
|
||||||
|
"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,
|
||||||
|
}
|
||||||
|
changes = compute_diff(old_snapshot, new_snapshot)
|
||||||
|
if changes:
|
||||||
|
append_entry(visit, current_user["name"], changes, getattr(data, "edit_reason", None))
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(visit)
|
await db.refresh(visit)
|
||||||
return await _enrich_visit(visit, db)
|
return await _enrich_visit(visit, db)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from sqlalchemy import select
|
|||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.middleware.auth import get_current_user, require_any_role
|
from app.middleware.auth import get_current_user, require_any_role
|
||||||
from app.utils.timezone import parse_date
|
from app.utils.timezone import parse_date
|
||||||
|
from app.utils.edit_log import compute_diff, append_entry, init_entry
|
||||||
from app.models.work_plan import WorkPlan
|
from app.models.work_plan import WorkPlan
|
||||||
from app.models.customer import Customer
|
from app.models.customer import Customer
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
@@ -27,6 +28,7 @@ async def _enrich(wp: WorkPlan, db: AsyncSession) -> dict:
|
|||||||
"manager_id": str(wp.manager_id),
|
"manager_id": str(wp.manager_id),
|
||||||
"manager_name": mgr.scalar_one_or_none(),
|
"manager_name": mgr.scalar_one_or_none(),
|
||||||
"status": wp.status,
|
"status": wp.status,
|
||||||
|
"edit_log": wp.edit_log or [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -59,6 +61,7 @@ async def create_work_plan(
|
|||||||
manager_id=uuid.UUID(current_user["user_id"]),
|
manager_id=uuid.UUID(current_user["user_id"]),
|
||||||
status=data.status,
|
status=data.status,
|
||||||
)
|
)
|
||||||
|
init_entry(wp, current_user["name"])
|
||||||
db.add(wp)
|
db.add(wp)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(wp)
|
await db.refresh(wp)
|
||||||
@@ -78,11 +81,16 @@ async def update_work_plan(
|
|||||||
if current_user["role"] == "manager" and str(wp.manager_id) != current_user["user_id"]:
|
if current_user["role"] == "manager" and str(wp.manager_id) != current_user["user_id"]:
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
|
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)
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
if "plan_date" in update_data and update_data["plan_date"]:
|
if "plan_date" in update_data and update_data["plan_date"]:
|
||||||
update_data["plan_date"] = parse_date(update_data["plan_date"])
|
update_data["plan_date"] = parse_date(update_data["plan_date"])
|
||||||
for k, v in update_data.items():
|
for k, v in update_data.items():
|
||||||
setattr(wp, k, v)
|
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}
|
||||||
|
changes = compute_diff(old_snapshot, new_snapshot)
|
||||||
|
if changes:
|
||||||
|
append_entry(wp, current_user["name"], changes, getattr(data, "edit_reason", None))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(wp)
|
await db.refresh(wp)
|
||||||
return await _enrich(wp, db)
|
return await _enrich(wp, db)
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ async def lifespan(app: FastAPI):
|
|||||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||||
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS visitor_phone VARCHAR(20) DEFAULT ''"
|
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS visitor_phone VARCHAR(20) DEFAULT ''"
|
||||||
))
|
))
|
||||||
|
# edit_log columns for change tracking
|
||||||
|
for tbl in ["visits", "daily_notes", "work_plans", "mini_business", "key_visits"]:
|
||||||
|
await conn.run_sync(lambda c, t=tbl: c.exec_driver_sql(
|
||||||
|
f"ALTER TABLE {t} ADD COLUMN IF NOT EXISTS edit_log JSONB DEFAULT '[]'"
|
||||||
|
))
|
||||||
yield
|
yield
|
||||||
# Shutdown
|
# Shutdown
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import uuid
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from sqlalchemy import String, Text, Date, DateTime, ForeignKey, func
|
from sqlalchemy import String, Text, Date, DateTime, ForeignKey, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
@@ -15,5 +15,6 @@ class DailyNote(Base):
|
|||||||
category: Mapped[str] = mapped_column(String(20), default="其他") # 行政事务/合同整理/发票处理/内部会议/培训学习/其他
|
category: Mapped[str] = mapped_column(String(20), default="其他") # 行政事务/合同整理/发票处理/内部会议/培训学习/其他
|
||||||
content: Mapped[str] = mapped_column(Text, default="")
|
content: Mapped[str] = mapped_column(Text, default="")
|
||||||
time_range: Mapped[str] = mapped_column(String(30), default="")
|
time_range: Mapped[str] = mapped_column(String(30), default="")
|
||||||
|
edit_log: Mapped[list] = mapped_column(JSONB, default=list)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from sqlalchemy import String, Text, ForeignKey
|
from sqlalchemy import String, Text, ForeignKey
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
@@ -17,3 +17,4 @@ class KeyVisit(Base):
|
|||||||
planned_visitor: Mapped[str] = mapped_column(String(100), default="")
|
planned_visitor: Mapped[str] = mapped_column(String(100), default="")
|
||||||
visit_target: Mapped[str] = mapped_column(String(100), default="")
|
visit_target: Mapped[str] = mapped_column(String(100), default="")
|
||||||
manager_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), index=True)
|
manager_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), index=True)
|
||||||
|
edit_log: Mapped[list] = mapped_column(JSONB, default=list)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from sqlalchemy import String, Text, ForeignKey
|
from sqlalchemy import String, Text, ForeignKey
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
@@ -16,3 +16,4 @@ class MiniBusiness(Base):
|
|||||||
status: Mapped[str] = mapped_column(String(50), default="跟进中")
|
status: Mapped[str] = mapped_column(String(50), default="跟进中")
|
||||||
manager_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), index=True)
|
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="")
|
expected_revenue_date: Mapped[str] = mapped_column(String(50), default="")
|
||||||
|
edit_log: Mapped[list] = mapped_column(JSONB, default=list)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import uuid
|
|||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
from sqlalchemy import String, Text, Date, DateTime, ForeignKey, func
|
from sqlalchemy import String, Text, Date, DateTime, ForeignKey, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from sqlalchemy.dialects.postgresql import UUID, ARRAY
|
from sqlalchemy.dialects.postgresql import UUID, ARRAY, JSONB
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
@@ -21,5 +21,6 @@ class Visit(Base):
|
|||||||
companions: Mapped[list | None] = mapped_column(ARRAY(UUID(as_uuid=True)), nullable=True)
|
companions: Mapped[list | None] = mapped_column(ARRAY(UUID(as_uuid=True)), nullable=True)
|
||||||
photos: Mapped[list | None] = mapped_column(ARRAY(Text), nullable=True)
|
photos: Mapped[list | None] = mapped_column(ARRAY(Text), nullable=True)
|
||||||
manager_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), index=True)
|
manager_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), index=True)
|
||||||
|
edit_log: Mapped[list] = mapped_column(JSONB, default=list)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import uuid
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
from sqlalchemy import String, Text, Date, ForeignKey
|
from sqlalchemy import String, Text, Date, ForeignKey
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
@@ -15,3 +15,4 @@ class WorkPlan(Base):
|
|||||||
plan_date: Mapped[date] = mapped_column(Date, index=True)
|
plan_date: Mapped[date] = mapped_column(Date, index=True)
|
||||||
manager_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), index=True)
|
manager_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), index=True)
|
||||||
status: Mapped[str] = mapped_column(String(20), default="计划中") # 计划中/已完成/已取消
|
status: Mapped[str] = mapped_column(String(20), default="计划中") # 计划中/已完成/已取消
|
||||||
|
edit_log: Mapped[list] = mapped_column(JSONB, default=list)
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Edit log helpers for change tracking."""
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
|
def compute_diff(old: dict, new: dict, exclude: set = None) -> dict:
|
||||||
|
"""Compare old and new values, return {field: [old, new]} for changed fields."""
|
||||||
|
if exclude is None:
|
||||||
|
exclude = {"edit_log", "updated_at", "created_at", "id"}
|
||||||
|
changes = {}
|
||||||
|
for key, new_val in new.items():
|
||||||
|
if key in exclude:
|
||||||
|
continue
|
||||||
|
old_val = old.get(key)
|
||||||
|
# Normalize for comparison
|
||||||
|
old_str = str(old_val) if old_val is not None else ""
|
||||||
|
new_str = str(new_val) if new_val is not None else ""
|
||||||
|
if old_str != new_str:
|
||||||
|
changes[key] = [old_str, new_str]
|
||||||
|
return changes
|
||||||
|
|
||||||
|
|
||||||
|
def append_entry(record, editor_name: str, changes: dict, reason: str = None):
|
||||||
|
"""Append an edit log entry to a record's edit_log JSON list."""
|
||||||
|
log = list(record.edit_log) if record.edit_log else []
|
||||||
|
entry = {
|
||||||
|
"editor": editor_name,
|
||||||
|
"time": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
"changes": changes,
|
||||||
|
}
|
||||||
|
if reason:
|
||||||
|
entry["reason"] = reason
|
||||||
|
log.append(entry)
|
||||||
|
record.edit_log = log
|
||||||
|
|
||||||
|
|
||||||
|
def init_entry(record, creator_name: str):
|
||||||
|
"""Initialize edit_log with a creation entry."""
|
||||||
|
record.edit_log = [{
|
||||||
|
"editor": creator_name,
|
||||||
|
"time": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
"changes": {},
|
||||||
|
}]
|
||||||
@@ -8,20 +8,47 @@ const router = useRouter()
|
|||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const collapsed = ref(false)
|
const collapsed = ref(false)
|
||||||
|
|
||||||
const menuItems = computed(() => {
|
interface MenuGroup { label?: string; items: { path: string; label: string; icon: string }[] }
|
||||||
const items: { path: string; label: string; icon: string }[] = [
|
|
||||||
{ path: '/', label: '仪表盘', icon: '<polyline points="4 7 12 3 20 7"></polyline><polyline points="20 7 20 21 4 21 4 7"></polyline><line x1="8" y1="21" x2="8" y2="12"></line><line x1="16" y1="21" x2="16" y2="12"></line>' },
|
const menuGroups = computed<MenuGroup[]>(() => {
|
||||||
{ path: '/weekly-report', label: '周报详情', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line>' },
|
const groups: MenuGroup[] = [
|
||||||
|
{
|
||||||
|
label: '汇总',
|
||||||
|
items: [
|
||||||
|
{ path: '/', label: '仪表盘', icon: '<polyline points="4 7 12 3 20 7"></polyline><polyline points="20 7 20 21 4 21 4 7"></polyline><line x1="8" y1="21" x2="8" y2="12"></line><line x1="16" y1="21" x2="16" y2="12"></line>' },
|
||||||
|
{ path: '/weekly-report', label: '周报', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line>' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '工作',
|
||||||
|
items: [
|
||||||
|
{ path: '/work-plans', label: '工作计划', icon: '<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line><polyline points="8 14 12 18 16 14"></polyline>' },
|
||||||
|
{ path: '/mini-business', label: '商机跟单', icon: '<line x1="12" y1="1" x2="12" y2="23"></line><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path>' },
|
||||||
|
{ path: '/key-visits', label: '要客拜访', icon: '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>' },
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
if (auth.isManager || auth.isDirector) {
|
if (auth.isManager || auth.isDirector) {
|
||||||
items.push({ path: '/workspace', label: '我的数据', icon: '<line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line>' })
|
groups.push({
|
||||||
|
label: undefined,
|
||||||
|
items: [
|
||||||
|
{ path: '/workspace', label: '我的数据', icon: '<line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line>' },
|
||||||
|
],
|
||||||
|
})
|
||||||
}
|
}
|
||||||
items.push({ 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>' })
|
groups.push({
|
||||||
|
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>' },
|
||||||
|
],
|
||||||
|
})
|
||||||
if (auth.isDirector) {
|
if (auth.isDirector) {
|
||||||
items.push({ 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(
|
||||||
items.push({ 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>' })
|
{ 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 items
|
return groups
|
||||||
})
|
})
|
||||||
|
|
||||||
const roleLabel = computed(() => {
|
const roleLabel = computed(() => {
|
||||||
@@ -42,16 +69,19 @@ const roleLabel = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="sidebar-nav">
|
<nav class="sidebar-nav">
|
||||||
<router-link
|
<template v-for="group in menuGroups" :key="group.label || 'nogroup'">
|
||||||
v-for="item in menuItems"
|
<div v-if="group.label" class="nav-group-label">{{ group.label }}</div>
|
||||||
:key="item.path"
|
<router-link
|
||||||
:to="item.path"
|
v-for="item in group.items"
|
||||||
class="nav-item"
|
:key="item.path"
|
||||||
:class="{ 'nav-item--active': route.path === item.path }"
|
:to="item.path"
|
||||||
>
|
class="nav-item"
|
||||||
<svg class="nav-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" v-html="item.icon"></svg>
|
:class="{ 'nav-item--active': route.path === item.path }"
|
||||||
<span class="nav-label">{{ item.label }}</span>
|
>
|
||||||
</router-link>
|
<svg class="nav-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" v-html="item.icon"></svg>
|
||||||
|
<span class="nav-label">{{ item.label }}</span>
|
||||||
|
</router-link>
|
||||||
|
</template>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
@@ -162,8 +192,20 @@ const roleLabel = computed(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-group-label {
|
||||||
|
font-family: 'JetBrains Mono', 'SF Mono', monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
color: rgba(255,255,255,0.25);
|
||||||
|
padding: 12px 14px 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar--collapsed .nav-group-label { display: none; }
|
||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
editLog: Array<{ editor: string; time: string; changes: Record<string, [string, string]>; reason?: string }>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const fieldLabels: Record<string, string> = {
|
||||||
|
customer_id: '客户单位',
|
||||||
|
customer_name: '客户名称',
|
||||||
|
visit_date: '拜访日期',
|
||||||
|
note_date: '日期',
|
||||||
|
visit_method: '拜访方式',
|
||||||
|
time_range: '时间范围',
|
||||||
|
visitor_name: '拜访人',
|
||||||
|
visitor_phone: '电话',
|
||||||
|
communication_content: '沟通内容',
|
||||||
|
customer_demand: '客户需求',
|
||||||
|
category: '分类',
|
||||||
|
content: '工作内容',
|
||||||
|
plan_content: '计划内容',
|
||||||
|
plan_date: '计划时间',
|
||||||
|
status: '状态',
|
||||||
|
product_type: '产品类型',
|
||||||
|
amount: '金额',
|
||||||
|
follow_up_detail: '跟进内容',
|
||||||
|
expected_revenue_date: '预计列收',
|
||||||
|
urgency_level: '紧急度',
|
||||||
|
description: '描述',
|
||||||
|
progress_status: '进展',
|
||||||
|
planned_date: '计划日期',
|
||||||
|
planned_visitor: '拜访人',
|
||||||
|
visit_target: '拜访对象',
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasHistory = computed(() => (props.editLog || []).length > 0)
|
||||||
|
|
||||||
|
function formatTime(t: string) {
|
||||||
|
try {
|
||||||
|
const d = new Date(t)
|
||||||
|
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||||
|
} catch { return t }
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelFor(field: string) {
|
||||||
|
return fieldLabels[field] || field
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-collapse v-if="hasHistory" style="margin-top:16px">
|
||||||
|
<el-collapse-item title="变更记录" name="1">
|
||||||
|
<div class="log-timeline">
|
||||||
|
<div v-for="(entry, idx) in editLog" :key="idx" class="log-entry"
|
||||||
|
:class="{ 'log-entry--create': !entry.changes || Object.keys(entry.changes).length === 0 }">
|
||||||
|
<div class="log-dot"></div>
|
||||||
|
<div class="log-body">
|
||||||
|
<div class="log-meta">
|
||||||
|
<span class="log-editor">{{ entry.editor }}</span>
|
||||||
|
<span class="log-time">{{ formatTime(entry.time) }}</span>
|
||||||
|
<el-tag v-if="!entry.changes || Object.keys(entry.changes).length === 0" size="small" type="info" effect="plain">创建</el-tag>
|
||||||
|
</div>
|
||||||
|
<div v-if="entry.changes && Object.keys(entry.changes).length > 0" class="log-changes">
|
||||||
|
<div v-for="(vals, field) in entry.changes" :key="field" class="log-change">
|
||||||
|
<span class="change-label">{{ labelFor(field) }}</span>
|
||||||
|
<span class="change-old">{{ vals[0] || '(空)' }}</span>
|
||||||
|
<span class="change-arrow">→</span>
|
||||||
|
<span class="change-new">{{ vals[1] || '(空)' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="entry.reason" class="log-reason">
|
||||||
|
<span class="reason-label">原因:</span>{{ entry.reason }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-collapse-item>
|
||||||
|
</el-collapse>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.log-timeline {
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-left: 2px solid var(--warm-border);
|
||||||
|
margin-left: 6px;
|
||||||
|
padding-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry:first-child {
|
||||||
|
border-left-color: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry--create {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-dot {
|
||||||
|
width: 8px; height: 8px;
|
||||||
|
background: var(--warm-border);
|
||||||
|
margin-left: -21px;
|
||||||
|
margin-top: 5px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry:first-child .log-dot {
|
||||||
|
background: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-editor {
|
||||||
|
font-family: 'ZCOOL XiaoWei', STSong, serif;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-time {
|
||||||
|
font-family: 'JetBrains Mono', 'SF Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--c-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-changes {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-change {
|
||||||
|
font-size: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.change-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink);
|
||||||
|
min-width: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.change-old {
|
||||||
|
color: var(--vermilion);
|
||||||
|
text-decoration: line-through;
|
||||||
|
text-decoration-color: var(--vermilion);
|
||||||
|
text-decoration-thickness: 1px;
|
||||||
|
max-width: 120px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.change-arrow {
|
||||||
|
color: var(--gold);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.change-new {
|
||||||
|
color: var(--sage);
|
||||||
|
max-width: 120px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-reason {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--c-text-secondary);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reason-label {
|
||||||
|
color: var(--warm-gray);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
imageUrl: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [v: boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const scale = ref(1)
|
||||||
|
const fitToPage = ref(true)
|
||||||
|
const panX = ref(0)
|
||||||
|
const panY = ref(0)
|
||||||
|
const isDragging = ref(false)
|
||||||
|
const dragStartX = ref(0)
|
||||||
|
const dragStartY = ref(0)
|
||||||
|
const panStartX = ref(0)
|
||||||
|
const panStartY = ref(0)
|
||||||
|
const imageEl = ref<HTMLImageElement | null>(null)
|
||||||
|
const overlayEl = ref<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
scale.value = 1
|
||||||
|
fitToPage.value = true
|
||||||
|
panX.value = 0
|
||||||
|
panY.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.imageUrl, reset)
|
||||||
|
watch(() => props.modelValue, (v) => { if (v) reset() })
|
||||||
|
|
||||||
|
function zoomIn() {
|
||||||
|
fitToPage.value = false
|
||||||
|
scale.value = Math.min(5, +(scale.value + 0.25).toFixed(2))
|
||||||
|
clampPan()
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomOut() {
|
||||||
|
fitToPage.value = false
|
||||||
|
const next = +(scale.value - 0.25).toFixed(2)
|
||||||
|
if (next < 0.25) {
|
||||||
|
scale.value = 1
|
||||||
|
fitToPage.value = true
|
||||||
|
panX.value = 0
|
||||||
|
panY.value = 0
|
||||||
|
} else {
|
||||||
|
scale.value = next
|
||||||
|
clampPan()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFit() {
|
||||||
|
if (fitToPage.value) {
|
||||||
|
fitToPage.value = false
|
||||||
|
scale.value = 1
|
||||||
|
} else {
|
||||||
|
fitToPage.value = true
|
||||||
|
scale.value = 1
|
||||||
|
panX.value = 0
|
||||||
|
panY.value = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampPan() {
|
||||||
|
if (!imageEl.value || !overlayEl.value) return
|
||||||
|
const img = imageEl.value
|
||||||
|
const naturalRatio = img.naturalWidth / img.naturalHeight
|
||||||
|
const viewW = overlayEl.value.clientWidth
|
||||||
|
const viewH = overlayEl.value.clientHeight
|
||||||
|
const displayW = naturalRatio > viewW / viewH ? viewW : viewH * naturalRatio
|
||||||
|
const displayH = naturalRatio > viewW / viewH ? viewW / naturalRatio : viewH
|
||||||
|
const scaledW = displayW * scale.value
|
||||||
|
const scaledH = displayH * scale.value
|
||||||
|
const maxX = Math.max(0, (scaledW - viewW) / 2)
|
||||||
|
const maxY = Math.max(0, (scaledH - viewH) / 2)
|
||||||
|
panX.value = Math.max(-maxX, Math.min(maxX, panX.value))
|
||||||
|
panY.value = Math.max(-maxY, Math.min(maxY, panY.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWheel(e: WheelEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (e.deltaY < 0) zoomIn()
|
||||||
|
else zoomOut()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDblClickImg(e: MouseEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
toggleFit()
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Drag to pan ── */
|
||||||
|
function onPointerDown(e: PointerEvent) {
|
||||||
|
if (fitToPage.value) return
|
||||||
|
isDragging.value = true
|
||||||
|
dragStartX.value = e.clientX
|
||||||
|
dragStartY.value = e.clientY
|
||||||
|
panStartX.value = panX.value
|
||||||
|
panStartY.value = panY.value
|
||||||
|
;(e.target as HTMLElement).setPointerCapture(e.pointerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(e: PointerEvent) {
|
||||||
|
if (!isDragging.value) return
|
||||||
|
panX.value = panStartX.value + (e.clientX - dragStartX.value)
|
||||||
|
panY.value = panStartY.value + (e.clientY - dragStartY.value)
|
||||||
|
clampPan()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp(_e: PointerEvent) {
|
||||||
|
isDragging.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOverlayClick(e: MouseEvent) {
|
||||||
|
if (e.target === overlayEl.value) close()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') close()
|
||||||
|
if (e.key === '+') zoomIn()
|
||||||
|
if (e.key === '-') zoomOut()
|
||||||
|
if (e.key === '0') reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
const scalePercent = computed(() => Math.round(scale.value * 100))
|
||||||
|
|
||||||
|
const imgStyle = computed(() => {
|
||||||
|
if (fitToPage.value) {
|
||||||
|
return {
|
||||||
|
maxWidth: '95vw',
|
||||||
|
maxHeight: '90vh',
|
||||||
|
objectFit: 'contain' as const,
|
||||||
|
transform: 'none',
|
||||||
|
cursor: 'default',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
maxWidth: 'none',
|
||||||
|
maxHeight: 'none',
|
||||||
|
objectFit: 'none' as const,
|
||||||
|
transform: `translate(${panX.value}px, ${panY.value}px) scale(${scale.value})`,
|
||||||
|
transformOrigin: 'center center',
|
||||||
|
cursor: isDragging.value ? 'grabbing' : (scale.value > 1 ? 'grab' : 'default'),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="modelValue"
|
||||||
|
ref="overlayEl"
|
||||||
|
class="image-preview-overlay"
|
||||||
|
@click="onOverlayClick"
|
||||||
|
@wheel="onWheel"
|
||||||
|
@keydown="onKeydown"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<!-- Image wrapper for centering -->
|
||||||
|
<div class="preview-stage" @dblclick="onDblClickImg">
|
||||||
|
<img
|
||||||
|
ref="imageEl"
|
||||||
|
:src="imageUrl"
|
||||||
|
:style="imgStyle"
|
||||||
|
class="preview-img"
|
||||||
|
@pointerdown="onPointerDown"
|
||||||
|
@pointermove="onPointerMove"
|
||||||
|
@pointerup="onPointerUp"
|
||||||
|
@pointerleave="onPointerUp"
|
||||||
|
@dblclick.stop="onDblClickImg"
|
||||||
|
@dragstart.prevent
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom toolbar -->
|
||||||
|
<div class="preview-toolbar">
|
||||||
|
<button class="toolbar-btn" title="缩小" @click="zoomOut">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="toolbar-btn toolbar-btn--fit" :class="{ active: fitToPage }" title="适应页面" @click="toggleFit">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/>
|
||||||
|
<line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/>
|
||||||
|
</svg>
|
||||||
|
<span v-if="fitToPage" class="fit-label">适应</span>
|
||||||
|
<span v-else class="fit-label">{{ scalePercent }}%</span>
|
||||||
|
</button>
|
||||||
|
<button class="toolbar-btn" title="放大" @click="zoomIn">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="toolbar-divider"></div>
|
||||||
|
<button class="toolbar-btn toolbar-btn--close" title="关闭 (Esc)" @click="close">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ═══ Overlay ═══ */
|
||||||
|
.image-preview-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
background: rgba(0, 0, 0, 0.9);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
outline: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══ Stage (image container) ═══ */
|
||||||
|
.preview-stage {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══ Image ═══ */
|
||||||
|
.preview-img {
|
||||||
|
display: block;
|
||||||
|
transition: transform 0.08s ease-out;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══ Bottom Toolbar ═══ */
|
||||||
|
.preview-toolbar {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 24px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
background: rgba(28, 55, 56, 0.85);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: 'Noto Serif SC', STSong, serif;
|
||||||
|
font-size: 12px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn.active {
|
||||||
|
background: rgba(196, 147, 74, 0.3);
|
||||||
|
color: var(--gold-light, #D4AD6E);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn--fit {
|
||||||
|
min-width: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fit-label {
|
||||||
|
font-family: 'JetBrains Mono', 'SF Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 20px;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn--close:hover {
|
||||||
|
background: rgba(184, 71, 46, 0.5);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mobile tweaks ── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.preview-toolbar {
|
||||||
|
bottom: 20px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
gap: 4px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.toolbar-btn {
|
||||||
|
min-width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.toolbar-btn--fit {
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -38,6 +38,9 @@ const router = createRouter({
|
|||||||
children: [
|
children: [
|
||||||
{ path: '', name: 'Dashboard', component: () => import('@/views/desktop/Dashboard.vue') },
|
{ path: '', name: 'Dashboard', component: () => import('@/views/desktop/Dashboard.vue') },
|
||||||
{ path: 'weekly-report', name: 'WeeklyReport', component: () => import('@/views/desktop/WeeklyReport.vue') },
|
{ path: 'weekly-report', name: 'WeeklyReport', component: () => import('@/views/desktop/WeeklyReport.vue') },
|
||||||
|
{ path: 'work-plans', name: 'WorkPlans', component: () => import('@/views/desktop/WorkPlans.vue') },
|
||||||
|
{ path: 'mini-business', name: 'MiniBusiness', component: () => import('@/views/desktop/MiniBusiness.vue') },
|
||||||
|
{ path: 'key-visits', name: 'KeyVisits', component: () => import('@/views/desktop/KeyVisits.vue') },
|
||||||
{ path: 'workspace', name: 'ManagerWorkspace', component: () => import('@/views/desktop/ManagerWorkspace.vue') },
|
{ path: 'workspace', name: 'ManagerWorkspace', component: () => import('@/views/desktop/ManagerWorkspace.vue') },
|
||||||
{ path: 'customers', name: 'CustomerManage', component: () => import('@/views/desktop/CustomerManage.vue') },
|
{ path: 'customers', name: 'CustomerManage', component: () => import('@/views/desktop/CustomerManage.vue') },
|
||||||
{ path: 'users', name: 'UserManage', component: () => import('@/views/desktop/UserManage.vue') },
|
{ path: 'users', name: 'UserManage', component: () => import('@/views/desktop/UserManage.vue') },
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ function rowState(p: any): 'full' | 'catching' | 'missing' {
|
|||||||
|
|
||||||
<!-- ═══ Stats Cards ═══ -->
|
<!-- ═══ Stats Cards ═══ -->
|
||||||
<div class="stat-grid">
|
<div class="stat-grid">
|
||||||
<div class="stat-card">
|
<div class="stat-card stat-card--clickable" @click="router.push('/weekly-report')">
|
||||||
<div class="stat-glyph stat-glyph--ink">
|
<div class="stat-glyph stat-glyph--ink">
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
|
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
|
||||||
@@ -94,7 +94,7 @@ function rowState(p: any): 'full' | 'catching' | 'missing' {
|
|||||||
<span class="stat-label">本周拜访</span>
|
<span class="stat-label">本周拜访</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card stat-card--clickable" @click="router.push('/work-plans')">
|
||||||
<div class="stat-glyph stat-glyph--sage">
|
<div class="stat-glyph stat-glyph--sage">
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
||||||
@@ -108,7 +108,7 @@ function rowState(p: any): 'full' | 'catching' | 'missing' {
|
|||||||
<span class="stat-label">工作计划</span>
|
<span class="stat-label">工作计划</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card stat-card--clickable" @click="router.push('/mini-business')">
|
||||||
<div class="stat-glyph stat-glyph--gold">
|
<div class="stat-glyph stat-glyph--gold">
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<line x1="12" y1="1" x2="12" y2="23"></line>
|
<line x1="12" y1="1" x2="12" y2="23"></line>
|
||||||
@@ -120,7 +120,7 @@ function rowState(p: any): 'full' | 'catching' | 'missing' {
|
|||||||
<span class="stat-label">商机跟单</span>
|
<span class="stat-label">商机跟单</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card stat-card--clickable" @click="router.push('/key-visits')">
|
||||||
<div class="stat-glyph stat-glyph--vermilion">
|
<div class="stat-glyph stat-glyph--vermilion">
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
|
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
|
||||||
@@ -241,6 +241,8 @@ function rowState(p: any): 'full' | 'catching' | 'missing' {
|
|||||||
transition: all var(--transition);
|
transition: all var(--transition);
|
||||||
}
|
}
|
||||||
.stat-card:hover { box-shadow: var(--shadow-md); }
|
.stat-card:hover { box-shadow: var(--shadow-md); }
|
||||||
|
.stat-card--clickable { cursor: pointer; }
|
||||||
|
.stat-card--clickable:hover { border-color: var(--gold); }
|
||||||
|
|
||||||
.stat-glyph {
|
.stat-glyph {
|
||||||
width: 48px; height: 48px;
|
width: 48px; height: 48px;
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { todayStr } from '@/utils'
|
||||||
|
import api from '@/api/index'
|
||||||
|
import EditLogPanel from '@/components/EditLogPanel.vue'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const loading = ref(false)
|
||||||
|
const keyVisits = ref<any[]>([])
|
||||||
|
const customers = ref<any[]>([])
|
||||||
|
const allUsers = ref<any[]>([])
|
||||||
|
const plannedVisitors = ref<string[]>([])
|
||||||
|
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const dialogMode = ref<'create' | 'edit'>('create')
|
||||||
|
const form = ref<any>({})
|
||||||
|
const statusPick = ref<Record<string, string>>({})
|
||||||
|
const keyStatuses = ['未开始', '进行中', '已完成']
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([loadItems(), loadCustomers(), loadUsers()])
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/users/')
|
||||||
|
allUsers.value = res.data || []
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCustomers(q?: string) {
|
||||||
|
try {
|
||||||
|
const params: any = { page_size: 100 }
|
||||||
|
if (q) params.search = q
|
||||||
|
const res = await api.get('/customers/', { params })
|
||||||
|
customers.value = res.data.items || []
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadItems() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await api.get('/key-visits/')
|
||||||
|
keyVisits.value = res.data
|
||||||
|
} catch (e: any) { ElMessage.error('加载失败') }
|
||||||
|
finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
dialogMode.value = 'create'
|
||||||
|
form.value = { customer_id: '', urgency_level: '一般', description: '', progress_status: '未开始', planned_date: '', planned_visitor: '', visit_target: '' }
|
||||||
|
plannedVisitors.value = []
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(item: any) {
|
||||||
|
dialogMode.value = 'edit'
|
||||||
|
form.value = { ...item }
|
||||||
|
plannedVisitors.value = item.planned_visitor ? item.planned_visitor.split('、') : []
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onVisitorsChange(val: string[]) {
|
||||||
|
form.value.planned_visitor = val.join('、')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
try {
|
||||||
|
if (dialogMode.value === 'create') {
|
||||||
|
await api.post('/key-visits/', form.value)
|
||||||
|
} else {
|
||||||
|
await api.put(`/key-visits/${form.value.id}`, form.value)
|
||||||
|
}
|
||||||
|
ElMessage.success(dialogMode.value === 'create' ? '已创建' : '已更新')
|
||||||
|
dialogVisible.value = false
|
||||||
|
await loadItems()
|
||||||
|
} 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(`/key-visits/${id}`)
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
await loadItems()
|
||||||
|
} 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(`/key-visits/${row.id}`, { progress_status: newStatus })
|
||||||
|
ElMessage.success('状态已更新')
|
||||||
|
await loadItems()
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e !== 'cancel') ElMessage.error('更新失败: ' + (e.response?.data?.detail || e.message))
|
||||||
|
delete statusPick.value[row.id]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="key-visits-page" v-loading="loading">
|
||||||
|
<div class="page-head">
|
||||||
|
<div class="page-head-row">
|
||||||
|
<div>
|
||||||
|
<h2 class="page-title">要客拜访</h2>
|
||||||
|
<p class="page-desc">重要客户拜访计划,按紧急程度排序跟踪。</p>
|
||||||
|
</div>
|
||||||
|
<el-button type="primary" @click="openCreate">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div class="page-rule"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-card>
|
||||||
|
<el-table :data="keyVisits" stripe size="small" v-if="keyVisits.length">
|
||||||
|
<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-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>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="urgency_level" label="重要度" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" :type="row.urgency_level==='紧急'?'danger':row.urgency_level==='重要'?'warning':''">{{ row.urgency_level }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="description" label="描述" min-width="250" show-overflow-tooltip />
|
||||||
|
<el-table-column label="进展" width="130">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-select
|
||||||
|
v-model="statusPick[row.id]"
|
||||||
|
size="small"
|
||||||
|
style="width:100%"
|
||||||
|
:placeholder="row.progress_status"
|
||||||
|
@change="(v: string) => quickStatusChange(row, v)"
|
||||||
|
@visible-change="(v: boolean) => { if (v) statusPick[row.id] = row.progress_status }"
|
||||||
|
>
|
||||||
|
<el-option v-for="s in keyStatuses" :key="s" :label="s" :value="s" />
|
||||||
|
</el-select>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="planned_date" label="计划时间" width="110" />
|
||||||
|
<el-table-column prop="planned_visitor" label="拜访人" width="80" />
|
||||||
|
<el-table-column prop="visit_target" label="拜访对象" width="110" />
|
||||||
|
<el-table-column label="操作" width="80" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button text size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div v-else class="empty">暂无需重点关注的客户</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 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-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="紧急重要度">
|
||||||
|
<div class="method-grid">
|
||||||
|
<el-button v-for="u in ['一般','重要','紧急']" :key="u" size="small"
|
||||||
|
:type="form.urgency_level===u ? (u==='紧急'?'danger':u==='重要'?'warning':'') : ''"
|
||||||
|
@click="form.urgency_level=u">{{ u }}</el-button>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="内容描述"><el-input v-model="form.description" type="textarea" :rows="4" placeholder="请输入要客拜访描述" /></el-form-item>
|
||||||
|
<el-form-item label="进展状态">
|
||||||
|
<el-select v-model="form.progress_status">
|
||||||
|
<el-option label="未开始" value="未开始" />
|
||||||
|
<el-option label="进行中" value="进行中" />
|
||||||
|
<el-option label="已完成" value="已完成" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="计划拜访时间">
|
||||||
|
<el-date-picker v-model="form.planned_date" type="date" placeholder="选择日期" style="width:100%" value-format="YYYY-MM-DD" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="计划拜访人(可多选)">
|
||||||
|
<el-select v-model="plannedVisitors" multiple filterable allow-create default-first-option placeholder="选择或输入姓名(可多选)" style="width:100%" @change="onVisitorsChange">
|
||||||
|
<el-option v-for="u in allUsers" :key="u.id" :label="u.name + (u.role==='director'?' (支局长)':u.role==='leader'?' (分管领导)':'')" :value="u.name" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="拜访对象"><el-input v-model="form.visit_target" placeholder="对方联系人/部门" /></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>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.edit-indicator { font-size: 12px; margin-left: 3px; opacity: 0.5; cursor: help; }
|
||||||
|
.page-head { margin-bottom: 20px; }
|
||||||
|
.page-head-row { display: flex; justify-content: space-between; align-items: flex-start; }
|
||||||
|
.page-title { margin: 0; font-family: 'ZCOOL XiaoWei', STSong, serif; 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: 'Noto Serif SC', STSong, serif; }
|
||||||
|
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; }
|
||||||
|
.method-grid { display: flex; gap: 8px; }
|
||||||
|
.empty { text-align: center; color: var(--c-text-muted); padding: 48px 0; font-family: 'Noto Serif SC', STSong, serif; }
|
||||||
|
</style>
|
||||||
@@ -3,6 +3,8 @@ import { ref, onMounted, computed } from 'vue'
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { todayStr } from '@/utils'
|
import { todayStr } from '@/utils'
|
||||||
import api from '@/api/index'
|
import api from '@/api/index'
|
||||||
|
import ImagePreview from '@/components/ImagePreview.vue'
|
||||||
|
import EditLogPanel from '@/components/EditLogPanel.vue'
|
||||||
|
|
||||||
const activeTab = ref('visits')
|
const activeTab = ref('visits')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -496,19 +498,14 @@ const notesByDate = computed(() => {
|
|||||||
<el-form-item label="拜访对象"><el-input v-model="form.visit_target" /></el-form-item>
|
<el-form-item label="拜访对象"><el-input v-model="form.visit_target" /></el-form-item>
|
||||||
</template>
|
</template>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
<EditLogPanel v-if="dialogMode === 'edit'" :edit-log="form.edit_log || []" />
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="dialogVisible = false">取消</el-button>
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
<el-button type="primary" @click="handleSave">保存</el-button>
|
<el-button type="primary" @click="handleSave">保存</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<!-- Image Preview -->
|
<ImagePreview v-model="previewDialogVisible" :image-url="previewImageUrl" />
|
||||||
<teleport to="body">
|
|
||||||
<div v-if="previewDialogVisible" class="image-preview-overlay" @click="previewDialogVisible = false">
|
|
||||||
<img :src="previewImageUrl" class="image-preview-full" @click.stop />
|
|
||||||
<button class="image-preview-close" @click="previewDialogVisible = false">✕</button>
|
|
||||||
</div>
|
|
||||||
</teleport>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { todayStr } from '@/utils'
|
||||||
|
import api from '@/api/index'
|
||||||
|
import EditLogPanel from '@/components/EditLogPanel.vue'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const loading = ref(false)
|
||||||
|
const miniBusiness = ref<any[]>([])
|
||||||
|
const customers = ref<any[]>([])
|
||||||
|
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const dialogMode = ref<'create' | 'edit'>('create')
|
||||||
|
const form = ref<any>({})
|
||||||
|
const statusPick = ref<Record<string, string>>({})
|
||||||
|
const miniStatuses = ['跟进中', '已签约', '已流失']
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([loadItems(), loadCustomers()])
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadCustomers(q?: string) {
|
||||||
|
try {
|
||||||
|
const params: any = { page_size: 100 }
|
||||||
|
if (q) params.search = q
|
||||||
|
const res = await api.get('/customers/', { params })
|
||||||
|
customers.value = res.data.items || []
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadItems() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await api.get('/mini-business/')
|
||||||
|
miniBusiness.value = res.data
|
||||||
|
} catch (e: any) { ElMessage.error('加载失败') }
|
||||||
|
finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
dialogMode.value = 'create'
|
||||||
|
form.value = { customer_id: '', product_type: '', amount: '', follow_up_detail: '', status: '跟进中', expected_revenue_date: '' }
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(item: any) {
|
||||||
|
dialogMode.value = 'edit'
|
||||||
|
form.value = { ...item }
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
try {
|
||||||
|
if (dialogMode.value === 'create') {
|
||||||
|
await api.post('/mini-business/', form.value)
|
||||||
|
} else {
|
||||||
|
await api.put(`/mini-business/${form.value.id}`, form.value)
|
||||||
|
}
|
||||||
|
ElMessage.success(dialogMode.value === 'create' ? '已创建' : '已更新')
|
||||||
|
dialogVisible.value = false
|
||||||
|
await loadItems()
|
||||||
|
} 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(`/mini-business/${id}`)
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
await loadItems()
|
||||||
|
} 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 })
|
||||||
|
ElMessage.success('状态已更新')
|
||||||
|
await loadItems()
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e !== 'cancel') ElMessage.error('更新失败: ' + (e.response?.data?.detail || e.message))
|
||||||
|
delete statusPick.value[row.id]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mini-biz-page" v-loading="loading">
|
||||||
|
<div class="page-head">
|
||||||
|
<div class="page-head-row">
|
||||||
|
<div>
|
||||||
|
<h2 class="page-title">商机跟单</h2>
|
||||||
|
<p class="page-desc">小微业务商机管道,按状态跟踪签约进展。</p>
|
||||||
|
</div>
|
||||||
|
<el-button type="primary" @click="openCreate">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div class="page-rule"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-card>
|
||||||
|
<el-table :data="miniBusiness" stripe size="small" v-if="miniBusiness.length">
|
||||||
|
<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-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>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="product_type" label="产品类型" width="130" />
|
||||||
|
<el-table-column prop="amount" label="金额" width="100" />
|
||||||
|
<el-table-column prop="follow_up_detail" label="跟进内容" min-width="250" show-overflow-tooltip />
|
||||||
|
<el-table-column label="状态" width="130">
|
||||||
|
<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 }"
|
||||||
|
>
|
||||||
|
<el-option v-for="s in miniStatuses" :key="s" :label="s" :value="s" />
|
||||||
|
</el-select>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="expected_revenue_date" label="预计列收" width="110" />
|
||||||
|
<el-table-column label="操作" width="80" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button text size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div v-else class="empty">暂无商机记录</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 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-input v-model="form.product_type" placeholder="如:云专线、SD-WAN" /></el-form-item>
|
||||||
|
<el-form-item label="金额"><el-input v-model="form.amount" placeholder="如:50000元/年" /></el-form-item>
|
||||||
|
<el-form-item label="跟进内容"><el-input v-model="form.follow_up_detail" 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-date-picker v-model="form.expected_revenue_date" type="month" placeholder="选择月份" style="width:100%" value-format="YYYY-MM" />
|
||||||
|
</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>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.edit-indicator { font-size: 12px; margin-left: 3px; opacity: 0.5; cursor: help; }
|
||||||
|
.page-head { margin-bottom: 20px; }
|
||||||
|
.page-head-row { display: flex; justify-content: space-between; align-items: flex-start; }
|
||||||
|
.page-title { margin: 0; font-family: 'ZCOOL XiaoWei', STSong, serif; 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: 'Noto Serif SC', STSong, serif; }
|
||||||
|
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; }
|
||||||
|
.empty { text-align: center; color: var(--c-text-muted); padding: 48px 0; font-family: 'Noto Serif SC', STSong, serif; }
|
||||||
|
</style>
|
||||||
@@ -5,6 +5,7 @@ import { useAuthStore } from '@/stores/auth'
|
|||||||
import { dashboardApi } from '@/api/dashboard'
|
import { dashboardApi } from '@/api/dashboard'
|
||||||
import { uploadApi } from '@/api/upload'
|
import { uploadApi } from '@/api/upload'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
import ImagePreview from '@/components/ImagePreview.vue'
|
||||||
import api from '@/api/index'
|
import api from '@/api/index'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
@@ -22,9 +23,6 @@ const report = ref({
|
|||||||
week_start: '', week_end: '',
|
week_start: '', week_end: '',
|
||||||
visits: [] as any[],
|
visits: [] as any[],
|
||||||
daily_notes: [] as any[],
|
daily_notes: [] as any[],
|
||||||
work_plans: [] as any[],
|
|
||||||
mini_business: [] as any[],
|
|
||||||
key_visits: [] as any[],
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const photoUrls = ref<Record<string, string>>({})
|
const photoUrls = ref<Record<string, string>>({})
|
||||||
@@ -120,7 +118,7 @@ const notesByDate = computed(() => {
|
|||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<div class="page-head-row">
|
<div class="page-head-row">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="page-title">周报详情</h2>
|
<h2 class="page-title">周报</h2>
|
||||||
<div class="week-nav">
|
<div class="week-nav">
|
||||||
<button class="week-nav-btn" @click="changeWeek(-1)">◀</button>
|
<button class="week-nav-btn" @click="changeWeek(-1)">◀</button>
|
||||||
<span class="week-label">{{ report.week_start }} — {{ report.week_end }}</span>
|
<span class="week-label">{{ report.week_start }} — {{ report.week_end }}</span>
|
||||||
@@ -178,7 +176,15 @@ const notesByDate = computed(() => {
|
|||||||
<el-table-column prop="time_range" label="时间" width="100" />
|
<el-table-column prop="time_range" label="时间" width="100" />
|
||||||
<el-table-column prop="communication_content" label="沟通内容" min-width="200" show-overflow-tooltip />
|
<el-table-column prop="communication_content" label="沟通内容" min-width="200" show-overflow-tooltip />
|
||||||
<el-table-column prop="customer_demand" label="客户需求" min-width="150" show-overflow-tooltip />
|
<el-table-column prop="customer_demand" label="客户需求" min-width="150" show-overflow-tooltip />
|
||||||
<el-table-column prop="manager_name" label="客户经理" width="80" />
|
<el-table-column label="客户经理" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span>{{ row.manager_name }}</span>
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="照片" width="100">
|
<el-table-column label="照片" width="100">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="photo-cell" v-if="row.photos?.length">
|
<div class="photo-cell" v-if="row.photos?.length">
|
||||||
@@ -193,40 +199,6 @@ const notesByDate = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<el-tab-pane label="工作计划" name="work_plans">
|
|
||||||
<el-table :data="report.work_plans" stripe>
|
|
||||||
<el-table-column prop="customer_name" label="客户" width="120" />
|
|
||||||
<el-table-column prop="plan_content" label="工作计划" min-width="250" />
|
|
||||||
<el-table-column prop="plan_date" label="计划时间" width="120" />
|
|
||||||
<el-table-column prop="manager_name" label="客户经理" width="80" />
|
|
||||||
<el-table-column prop="status" label="状态" width="100">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-tag :type="row.status === '已完成' ? 'success' : row.status === '已取消' ? 'info' : ''">
|
|
||||||
{{ row.status }}
|
|
||||||
</el-tag>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
</el-tab-pane>
|
|
||||||
|
|
||||||
<el-tab-pane label="小微业务商机" name="mini_business">
|
|
||||||
<el-table :data="report.mini_business" stripe>
|
|
||||||
<el-table-column prop="customer_name" label="客户" width="120" />
|
|
||||||
<el-table-column prop="product_type" label="产品类型" width="120" />
|
|
||||||
<el-table-column prop="amount" label="金额" width="100" />
|
|
||||||
<el-table-column prop="follow_up_detail" label="跟进内容" min-width="200" />
|
|
||||||
<el-table-column prop="status" label="状态" width="100">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-tag :type="row.status === '已签约' ? 'success' : row.status === '已流失' ? 'danger' : ''">
|
|
||||||
{{ row.status }}
|
|
||||||
</el-tag>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column prop="manager_name" label="客户经理" width="80" />
|
|
||||||
<el-table-column prop="expected_revenue_date" label="预计列收" width="120" />
|
|
||||||
</el-table>
|
|
||||||
</el-tab-pane>
|
|
||||||
|
|
||||||
<el-tab-pane label="今日纪要" name="daily_notes">
|
<el-tab-pane label="今日纪要" name="daily_notes">
|
||||||
<div v-if="report.daily_notes.length === 0" class="empty">暂无数据</div>
|
<div v-if="report.daily_notes.length === 0" class="empty">暂无数据</div>
|
||||||
<div v-for="[date, items] in notesByDate" :key="date" class="date-group">
|
<div v-for="[date, items] in notesByDate" :key="date" class="date-group">
|
||||||
@@ -251,32 +223,10 @@ const notesByDate = computed(() => {
|
|||||||
</el-table>
|
</el-table>
|
||||||
</div>
|
</div>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<el-tab-pane label="要客拜访计划" name="key_visits">
|
|
||||||
<el-table :data="report.key_visits" stripe>
|
|
||||||
<el-table-column prop="customer_name" label="客户" width="120" />
|
|
||||||
<el-table-column prop="urgency_level" label="重要度" width="80">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-tag :type="row.urgency_level === '紧急' ? 'danger' : row.urgency_level === '重要' ? 'warning' : ''">
|
|
||||||
{{ row.urgency_level }}
|
|
||||||
</el-tag>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column prop="description" label="描述" min-width="200" />
|
|
||||||
<el-table-column prop="progress_status" label="进展" width="80" />
|
|
||||||
<el-table-column prop="planned_date" label="计划时间" width="120" />
|
|
||||||
<el-table-column prop="planned_visitor" label="拜访人" width="80" />
|
|
||||||
<el-table-column prop="visit_target" label="拜访对象" width="100" />
|
|
||||||
<el-table-column prop="manager_name" label="客户经理" width="80" />
|
|
||||||
</el-table>
|
|
||||||
</el-tab-pane>
|
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<!-- Photo Viewer -->
|
<ImagePreview v-model="photoDialogVisible" :image-url="currentPhotoUrl" />
|
||||||
<el-dialog v-model="photoDialogVisible" title="照片查看" width="80%">
|
|
||||||
<img :src="currentPhotoUrl" style="width:100%;" />
|
|
||||||
</el-dialog>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -310,5 +260,6 @@ const notesByDate = computed(() => {
|
|||||||
}
|
}
|
||||||
.photo-cell { display: flex; gap: 4px; }
|
.photo-cell { display: flex; gap: 4px; }
|
||||||
.mini-thumb { width: 36px; height: 36px; object-fit: cover; cursor: pointer; }
|
.mini-thumb { width: 36px; height: 36px; object-fit: cover; cursor: pointer; }
|
||||||
|
.edit-indicator { font-size: 12px; margin-left: 3px; opacity: 0.5; cursor: help; }
|
||||||
.empty { text-align: center; color: var(--c-text-muted); padding: 40px 0; font-family: 'Noto Serif SC', STSong, serif; }
|
.empty { text-align: center; color: var(--c-text-muted); padding: 40px 0; font-family: 'Noto Serif SC', STSong, serif; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { todayStr } from '@/utils'
|
||||||
|
import api from '@/api/index'
|
||||||
|
import EditLogPanel from '@/components/EditLogPanel.vue'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const loading = ref(false)
|
||||||
|
const workPlans = ref<any[]>([])
|
||||||
|
const customers = ref<any[]>([])
|
||||||
|
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const dialogMode = ref<'create' | 'edit'>('create')
|
||||||
|
const form = ref<any>({})
|
||||||
|
const statusPick = ref<Record<string, string>>({})
|
||||||
|
const planStatuses = ['计划中', '已完成', '已取消']
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([loadPlans(), loadCustomers()])
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadCustomers(q?: string) {
|
||||||
|
try {
|
||||||
|
const params: any = { page_size: 100 }
|
||||||
|
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 (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 }
|
||||||
|
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()
|
||||||
|
} 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 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]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="work-plans-page" v-loading="loading">
|
||||||
|
<div class="page-head">
|
||||||
|
<div class="page-head-row">
|
||||||
|
<div>
|
||||||
|
<h2 class="page-title">工作计划</h2>
|
||||||
|
<p class="page-desc">面向未来的工作计划安排,支持状态流转跟踪。</p>
|
||||||
|
</div>
|
||||||
|
<el-button type="primary" @click="openCreate">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div class="page-rule"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-card>
|
||||||
|
<el-table :data="workPlans" stripe size="small" v-if="workPlans.length">
|
||||||
|
<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-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>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="plan_content" label="工作计划" min-width="280" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="plan_date" label="计划时间" width="110" />
|
||||||
|
<el-table-column label="状态" width="130">
|
||||||
|
<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 }"
|
||||||
|
>
|
||||||
|
<el-option v-for="s in planStatuses" :key="s" :label="s" :value="s" />
|
||||||
|
</el-select>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="80" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button text size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div v-else class="empty">暂无工作计划</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 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" 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>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.edit-indicator { font-size: 12px; margin-left: 3px; opacity: 0.5; cursor: help; }
|
||||||
|
.page-head { margin-bottom: 20px; }
|
||||||
|
.page-head-row { display: flex; justify-content: space-between; align-items: flex-start; }
|
||||||
|
.page-title { margin: 0; font-family: 'ZCOOL XiaoWei', STSong, serif; 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: 'Noto Serif SC', STSong, serif; }
|
||||||
|
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; }
|
||||||
|
.empty { text-align: center; color: var(--c-text-muted); padding: 48px 0; font-family: 'Noto Serif SC', STSong, serif; }
|
||||||
|
</style>
|
||||||
@@ -4,6 +4,7 @@ import { useRouter, useRoute } from 'vue-router'
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { todayStr } from '@/utils'
|
import { todayStr } from '@/utils'
|
||||||
import api from '@/api/index'
|
import api from '@/api/index'
|
||||||
|
import EditLogPanel from '@/components/EditLogPanel.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -160,6 +161,7 @@ async function handleDelete() {
|
|||||||
删除
|
删除
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<EditLogPanel v-if="isEdit" :edit-log="form.edit_log || []" />
|
||||||
</el-form>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ref, onMounted, computed } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { visitsApi } from '@/api/visits'
|
import { visitsApi } from '@/api/visits'
|
||||||
import { uploadApi } from '@/api/upload'
|
import { uploadApi } from '@/api/upload'
|
||||||
|
import ImagePreview from '@/components/ImagePreview.vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import api from '@/api/index'
|
import api from '@/api/index'
|
||||||
|
|
||||||
@@ -195,12 +196,7 @@ onMounted(loadToday)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<teleport to="body">
|
<ImagePreview v-model="previewDialogVisible" :image-url="previewImageUrl" />
|
||||||
<div v-if="previewDialogVisible" class="image-preview-overlay" @click="previewDialogVisible = false">
|
|
||||||
<img :src="previewImageUrl" class="image-preview-full" @click.stop />
|
|
||||||
<button class="image-preview-close" @click="previewDialogVisible = false">✕</button>
|
|
||||||
</div>
|
|
||||||
</teleport>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { visitsApi } from '@/api/visits'
|
|||||||
import { customersApi } from '@/api/customers'
|
import { customersApi } from '@/api/customers'
|
||||||
import { uploadApi } from '@/api/upload'
|
import { uploadApi } from '@/api/upload'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import ImagePreview from '@/components/ImagePreview.vue'
|
||||||
|
import EditLogPanel from '@/components/EditLogPanel.vue'
|
||||||
import api from '@/api/index'
|
import api from '@/api/index'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -344,15 +346,10 @@ async function handleDelete() {
|
|||||||
删除
|
删除
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<EditLogPanel v-if="isEdit" :edit-log="form.edit_log || []" />
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<!-- Image Preview Dialog -->
|
<ImagePreview v-model="previewDialogVisible" :image-url="previewImageUrl" />
|
||||||
<teleport to="body">
|
|
||||||
<div v-if="previewDialogVisible" class="image-preview-overlay" @click="previewDialogVisible = false">
|
|
||||||
<img :src="previewImageUrl" class="image-preview-full" @click.stop />
|
|
||||||
<button class="image-preview-close" @click="previewDialogVisible = false">✕</button>
|
|
||||||
</div>
|
|
||||||
</teleport>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user