From 3285e22142bc0dcb85b4a353e440e24bc5c4ace3 Mon Sep 17 00:00:00 2001 From: v6ole Date: Mon, 29 Jun 2026 22:25:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AF=BC=E5=85=A5=E5=A2=9E=E5=BC=BA=20?= =?UTF-8?q?+=20=E5=AE=A2=E6=88=B7=E5=90=88=E5=B9=B6=20+=20=E5=90=8C?= =?UTF-8?q?=E4=BC=B4=E8=87=AA=E5=AE=9A=E4=B9=89=20+=20=E6=80=A7=E8=83=BD?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit === 导入系统全面增强 === - 5 Sheet 完整导入:拜访/计划/商机/要客/纪要 - 同访人智能解析:中英文逗号/顿号/分号 → 系统用户UUID + 外部人员TEXT - 客户自动创建:Excel中不存在的客户自动入库 - 模糊名称匹配:去空格 + 包含关系纠错 - manager_id 修正:导入时用客户分配的经理(非Excel列/非导入人) - 模板更新:Sheet5「客户经理」→「填报人」,同访人示例含逗号分隔 === 客户合并功能 === - PUT 改名碰撞检测 → 409 + 合并预览 - GET merge-preview / POST merge 端点 - 事务级迁移:Visit/WorkPlan/MiniBusiness/KeyVisit/联系人/分配 - 去重逻辑:联系人(name+phone)、分配(manager+role) - last_visit_date 取最大值 === 拜访记录完善 === - visits 新增 companion_names TEXT[] 列 - 同访人支持自定义输入(外部人员),el-select allow-create - 移动端 VisitForm + PC端 ManagerWorkspace 统一 - 周报「客户经理」→「相关人员」(创建人+同访人) - 纪要「客户经理」→「填报人」 - 删除拜访后重新计算 customer.last_visit_date === 客户选择放开 === - 客户经理可看到全部客户(不再限自己分配的) - 拜访时客户下拉返回全量 === 前端性能优化 === - Element Plus 按需加载 (unplugin-vue-components + unplugin-element-plus) - 周报照片URL并行请求 (Promise.all) - onMounted 三路并行 (loadReport + dropdowns + AI) - nginx Cache-Control: immutable for /assets/ - Google Fonts preconnect hints - 图片上传前 Canvas 压缩 (max 1920px, quality 0.8) Co-Authored-By: Claude --- backend/app/api/customers.py | 219 +++++- backend/app/api/import_data.py | 11 +- backend/app/api/visits.py | 38 + backend/app/main.py | 4 + backend/app/models/visit.py | 1 + backend/app/schemas/visit.py | 3 + backend/app/services/excel_export.py | 27 +- backend/app/services/excel_import.py | 346 ++++++++- frontend/index.html | 4 + frontend/nginx.conf | 6 + frontend/package-lock.json | 655 ++++++++++++++++++ frontend/package.json | 2 + frontend/src/App.vue | 5 +- frontend/src/components.d.ts | 52 ++ frontend/src/main.ts | 4 - frontend/src/utils/image.ts | 101 +++ frontend/src/views/desktop/CustomerManage.vue | 70 +- frontend/src/views/desktop/LightBoard.vue | 84 +-- .../src/views/desktop/ManagerWorkspace.vue | 31 +- frontend/src/views/desktop/Settings.vue | 15 +- frontend/src/views/desktop/WeeklyReport.vue | 80 ++- frontend/src/views/mobile/VisitForm.vue | 28 +- frontend/vite.config.ts | 15 +- 23 files changed, 1629 insertions(+), 172 deletions(-) create mode 100644 frontend/src/components.d.ts create mode 100644 frontend/src/utils/image.ts diff --git a/backend/app/api/customers.py b/backend/app/api/customers.py index 03ecf4a..d516300 100644 --- a/backend/app/api/customers.py +++ b/backend/app/api/customers.py @@ -1,17 +1,24 @@ import io +import uuid as uuid_mod from uuid import uuid4 from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File from fastapi.responses import StreamingResponse from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, or_ +from sqlalchemy import select, or_, update from sqlalchemy.orm import selectinload +from pydantic import BaseModel import openpyxl from app.database import get_db from app.middleware.auth import get_current_user, require_director, require_any_role from app.models.customer import Customer from app.models.customer_contact import CustomerContact from app.models.customer_assignment import CustomerAssignment +from app.models.visit import Visit +from app.models.work_plan import WorkPlan +from app.models.mini_business import MiniBusiness +from app.models.key_visit import KeyVisit +from app.models.daily_note import DailyNote from app.models.user import User from app.schemas.customer import ( CustomerCreate, CustomerUpdate, CustomerOut, CustomerListOut, CustomerListResponse, @@ -48,19 +55,10 @@ async def list_customers( current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - """List customers with filters and pagination. Managers only see their assigned.""" + """List customers with filters and pagination. All roles see all customers.""" from sqlalchemy import func base_query = select(Customer) - if current_user["role"] == "manager": - base_query = base_query.where(or_( - Customer.id.in_( - select(CustomerAssignment.customer_id).where( - CustomerAssignment.manager_id == current_user["user_id"] - ) - ), - Customer.created_by == current_user["user_id"], - )) if industry: base_query = base_query.where(Customer.industry.ilike(f"%{industry}%")) @@ -425,6 +423,28 @@ async def update_customer( update_data = data.model_dump(exclude_unset=True) assignee_id = update_data.pop("assignee_id", None) # Handle separately + # ── Name collision detection (for merge) ── + new_name = update_data.get("name") + if new_name and new_name.strip() != customer.name: + existing = await db.execute( + select(Customer).where( + Customer.name == new_name.strip(), + Customer.id != customer.id, + ) + ) + target = existing.scalar_one_or_none() + if target: + # Return 409 with merge preview — frontend should show merge dialog + preview = await _build_merge_preview(db, str(customer.id), str(target.id)) + raise HTTPException(status_code=409, detail={ + "message": "名称冲突 — 同名客户已存在", + "source_id": str(customer.id), + "source_name": customer.name, + "target_id": str(target.id), + "target_name": target.name, + "preview": preview, + }) + for key, value in update_data.items(): setattr(customer, key, value) @@ -432,17 +452,16 @@ async def update_customer( if assignee_id: if current_user["role"] != "director": raise HTTPException(status_code=403, detail="Only director can change manager assignment") - import uuid as uuid_mod assign_result = await db.execute( select(CustomerAssignment).where( CustomerAssignment.customer_id == customer.id, CustomerAssignment.role == "primary", ) ) - existing = assign_result.scalar_one_or_none() - if existing: - existing.manager_id = assignee_id - existing.assigned_by = uuid_mod.UUID(current_user["user_id"]) + existing_a = assign_result.scalar_one_or_none() + if existing_a: + existing_a.manager_id = assignee_id + existing_a.assigned_by = uuid_mod.UUID(current_user["user_id"]) else: db.add(CustomerAssignment( customer_id=customer.id, manager_id=assignee_id, @@ -472,7 +491,173 @@ async def delete_customer( return {"detail": "deleted"} -# ── Contacts ── +# ── Customer Merge ── + + +class MergePreviewOut(BaseModel): + source_id: str + source_name: str + target_id: str + target_name: str + visits: int = 0 + work_plans: int = 0 + mini_business: int = 0 + key_visits: int = 0 + contacts: int = 0 + assignments: int = 0 + note: str = "" + + +class MergeRequest(BaseModel): + target_id: str + + +async def _build_merge_preview(db: AsyncSession, source_id: str, target_id: str) -> dict: + """Count all records that would be migrated from source to target.""" + sid = uuid_mod.UUID(source_id) + tid = uuid_mod.UUID(target_id) + + visits = (await db.execute( + select(select(Visit).where(Visit.customer_id == sid).subquery()).with_only_columns( + __import__('sqlalchemy').func.count() + ) + )).scalar() or 0 + # Simpler: count directly + from sqlalchemy import func as sa_func + visits = (await db.execute(select(sa_func.count()).select_from(Visit).where(Visit.customer_id == sid))).scalar() or 0 + plans = (await db.execute(select(sa_func.count()).select_from(WorkPlan).where(WorkPlan.customer_id == sid))).scalar() or 0 + mini = (await db.execute(select(sa_func.count()).select_from(MiniBusiness).where(MiniBusiness.customer_id == sid))).scalar() or 0 + kv = (await db.execute(select(sa_func.count()).select_from(KeyVisit).where(KeyVisit.customer_id == sid))).scalar() or 0 + contacts = (await db.execute(select(sa_func.count()).select_from(CustomerContact).where(CustomerContact.customer_id == sid))).scalar() or 0 + assignments = (await db.execute(select(sa_func.count()).select_from(CustomerAssignment).where(CustomerAssignment.customer_id == sid))).scalar() or 0 + + # Check for assignment conflicts + target_assigns = await db.execute( + select(CustomerAssignment.manager_id).where(CustomerAssignment.customer_id == tid) + ) + target_managers = {str(row[0]) for row in target_assigns.all()} + source_assigns = await db.execute( + select(CustomerAssignment).where(CustomerAssignment.customer_id == sid) + ) + conflict_note = "" + for sa in source_assigns.scalars().all(): + if str(sa.manager_id) in target_managers: + conflict_note = f"目标客户已有相同经理的分配,将去重" + + return { + "source_id": source_id, + "source_name": "", + "target_id": target_id, + "target_name": "", + "visits": visits, + "work_plans": plans, + "mini_business": mini, + "key_visits": kv, + "contacts": contacts, + "assignments": assignments, + "note": conflict_note, + } + + +@router.get("/{customer_id}/merge-preview") +async def get_merge_preview( + customer_id: str, + target_id: str = Query(...), + current_user: dict = Depends(require_director), + db: AsyncSession = Depends(get_db), +): + """Preview merge: show what data would be moved from source to target.""" + source = (await db.execute(select(Customer).where(Customer.id == customer_id))).scalar_one_or_none() + target = (await db.execute(select(Customer).where(Customer.id == target_id))).scalar_one_or_none() + if not source or not target: + raise HTTPException(status_code=404, detail="客户不存在") + if customer_id == target_id: + raise HTTPException(status_code=400, detail="不能合并到自身") + + preview = await _build_merge_preview(db, customer_id, target_id) + preview["source_name"] = source.name + preview["target_name"] = target.name + return preview + + +@router.post("/{customer_id}/merge") +async def execute_merge( + customer_id: str, + body: MergeRequest, + current_user: dict = Depends(require_director), + db: AsyncSession = Depends(get_db), +): + """Merge source customer into target. All related data is migrated, source is deleted.""" + source_id = uuid_mod.UUID(customer_id) + target_id = uuid_mod.UUID(body.target_id) + + if customer_id == body.target_id: + raise HTTPException(status_code=400, detail="不能合并到自身") + + source = (await db.execute(select(Customer).where(Customer.id == source_id))).scalar_one_or_none() + target = (await db.execute(select(Customer).where(Customer.id == target_id))).scalar_one_or_none() + if not source or not target: + raise HTTPException(status_code=404, detail="客户不存在") + + # Build preview for response + preview = await _build_merge_preview(db, customer_id, body.target_id) + + # ── Transaction: migrate all FK references ── + for model, fk_col in [ + (Visit, Visit.customer_id), + (WorkPlan, WorkPlan.customer_id), + (MiniBusiness, MiniBusiness.customer_id), + (KeyVisit, KeyVisit.customer_id), + ]: + await db.execute( + update(model).where(fk_col == source_id).values(customer_id=target_id) + ) + + # Contacts: migrate, skip duplicates + source_contacts = (await db.execute( + select(CustomerContact).where(CustomerContact.customer_id == source_id) + )).scalars().all() + target_contacts = (await db.execute( + select(CustomerContact).where(CustomerContact.customer_id == target_id) + )).scalars().all() + existing_contact_keys = {(c.name, c.phone) for c in target_contacts} + for c in source_contacts: + if (c.name, c.phone) in existing_contact_keys: + await db.delete(c) # Skip duplicate + else: + c.customer_id = target_id + db.add(c) + + # Assignments: migrate, skip same (manager_id, role) pairs + target_assigns = (await db.execute( + select(CustomerAssignment).where(CustomerAssignment.customer_id == target_id) + )).scalars().all() + existing_assign_keys = {(str(a.manager_id), a.role) for a in target_assigns} + source_assigns = (await db.execute( + select(CustomerAssignment).where(CustomerAssignment.customer_id == source_id) + )).scalars().all() + for a in source_assigns: + if (str(a.manager_id), a.role) in existing_assign_keys: + await db.delete(a) # Skip duplicate + else: + a.customer_id = target_id + db.add(a) + + # Update target's last_visit_date to the max of both + if source.last_visit_date: + if not target.last_visit_date or source.last_visit_date > target.last_visit_date: + target.last_visit_date = source.last_visit_date + target.last_visit_manager_id = source.last_visit_manager_id + + # Delete source customer + source_name = source.name + target_name = target.name + await db.delete(source) + await db.commit() + + preview["result"] = f"已将「{source_name}」合并到「{target_name}」" + return preview + @router.post("/{customer_id}/contacts", response_model=ContactOut) async def add_contact( diff --git a/backend/app/api/import_data.py b/backend/app/api/import_data.py index 4843670..e8f7821 100644 --- a/backend/app/api/import_data.py +++ b/backend/app/api/import_data.py @@ -12,7 +12,7 @@ router = APIRouter(prefix="/import", tags=["Import"]) @router.get("/template") async def download_weekly_report_template(): - """Download a 4-sheet weekly report import template.""" + """Download a 5-sheet weekly report import template.""" from openpyxl import Workbook from openpyxl.styles import Font @@ -24,7 +24,7 @@ async def download_weekly_report_template(): ws1.title = "每日拜访记录" ws1.append(["客户单位", "拜访日期", "拜访方式", "时间范围", "拜访人姓名", "拜访人电话", "沟通内容", "客户需求", "同访人员", "客户经理"]) for c in ws1[1]: c.font = header_font - ws1.append(["XX科技有限公司", "2026-06-23", "上门", "9:00-10:00", "韦柳柏", "13800000000", "沟通了解云桌面需求", "希望扩容", "", "韦矍森"]) + ws1.append(["XX科技有限公司", "2026-06-23", "上门", "9:00-10:00", "韦柳柏", "13800000000", "沟通了解云桌面需求", "希望扩容", "韦柳柏, 张科长", "韦矍森"]) ws1.column_dimensions['A'].width = 20; ws1.column_dimensions['E'].width = 30; ws1.column_dimensions['F'].width = 20 # Sheet 2: 下周工作计划 @@ -48,6 +48,13 @@ async def download_weekly_report_template(): ws4.append(["XX科技有限公司", "重要", "拜访技术负责人确认方案", "未开始", "2026-07-01", "韦柳柏", "王局长", "韦矍森"]) ws4.column_dimensions['A'].width = 20; ws4.column_dimensions['C'].width = 30 + # Sheet 5: 今日纪要 + ws5 = wb.create_sheet("今日纪要") + ws5.append(["日期", "分类", "内容", "时间范围", "填报人"]) + for c in ws5[1]: c.font = header_font + ws5.append(["2026-06-23", "内部会议", "参加云桌面项目方案讨论会", "14:00-16:00", "韦矍森"]) + ws5.column_dimensions['A'].width = 15; ws5.column_dimensions['B'].width = 12; ws5.column_dimensions['C'].width = 40 + output = io.BytesIO() wb.save(output) output.seek(0) diff --git a/backend/app/api/visits.py b/backend/app/api/visits.py index 53c0f37..d38ad12 100644 --- a/backend/app/api/visits.py +++ b/backend/app/api/visits.py @@ -28,6 +28,12 @@ async def _enrich_visit(visit: Visit, db: AsyncSession) -> dict: mgr_result = await db.execute(select(User.name).where(User.id == visit.manager_id)) manager_name = mgr_result.scalar_one_or_none() + # Resolve companion UUIDs to names + companion_names_resolved = list(visit.companion_names or []) + if visit.companions: + comp_result = await db.execute(select(User.name).where(User.id.in_(visit.companions))) + companion_names_resolved = [n for n, in comp_result.all()] + companion_names_resolved + return { "id": str(visit.id), "customer_id": str(visit.customer_id), @@ -40,6 +46,8 @@ async def _enrich_visit(visit: Visit, db: AsyncSession) -> dict: "communication_content": visit.communication_content, "customer_demand": visit.customer_demand, "companions": visit.companions, + "companion_names": visit.companion_names or [], + "companion_names_resolved": companion_names_resolved, "photos": visit.photos, "manager_id": str(visit.manager_id), "manager_name": manager_name, @@ -137,6 +145,7 @@ async def create_visit( communication_content=data.communication_content, customer_demand=data.customer_demand, companions=data.companions, + companion_names=data.companion_names, photos=data.photos, manager_id=uuid.UUID(current_user["user_id"]), ) @@ -245,10 +254,39 @@ async def delete_visit( if current_user["role"] == "manager" and str(visit.manager_id) != current_user["user_id"]: raise HTTPException(status_code=403, detail="Access denied") + customer_id = visit.customer_id + manager_id = visit.manager_id + # Clean up photos in MinIO if visit.photos: delete_objects(visit.photos) await db.delete(visit) + + # Recalculate customer's last_visit_date from remaining visits + latest = await db.execute( + select(func.max(Visit.visit_date)).where( + Visit.customer_id == customer_id, + ) + ) + new_latest = latest.scalar() + cust = await db.get(Customer, customer_id) + if cust: + if new_latest: + cust.last_visit_date = new_latest + # Keep the existing manager if date unchanged, or find who made the latest visit + latest_visit = await db.execute( + select(Visit).where( + Visit.customer_id == customer_id, + Visit.visit_date == new_latest, + ).order_by(Visit.created_at.desc()).limit(1) + ) + lv = latest_visit.scalar_one_or_none() + if lv: + cust.last_visit_manager_id = lv.manager_id + else: + cust.last_visit_date = None + cust.last_visit_manager_id = None + await db.commit() return {"detail": "deleted"} diff --git a/backend/app/main.py b/backend/app/main.py index ee8aff6..a33303f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -54,6 +54,10 @@ async def lifespan(app: FastAPI): await conn.run_sync(lambda c: c.exec_driver_sql( "ALTER TABLE customers ADD COLUMN IF NOT EXISTS last_visit_manager_id UUID" )) + # New columns for v0.4 + await conn.run_sync(lambda c: c.exec_driver_sql( + "ALTER TABLE visits ADD COLUMN IF NOT EXISTS companion_names TEXT[] DEFAULT '{}'" + )) # Start daily reporting scheduler (17:30 CST = 09:30 UTC) _scheduler.add_job(_scheduled_check, "cron", hour=17, minute=30, id="daily_check") diff --git a/backend/app/models/visit.py b/backend/app/models/visit.py index af1b59f..580c278 100644 --- a/backend/app/models/visit.py +++ b/backend/app/models/visit.py @@ -19,6 +19,7 @@ class Visit(Base): visitor_name: Mapped[str] = mapped_column(String(50), default="") visitor_phone: Mapped[str] = mapped_column(String(20), default="") companions: Mapped[list | None] = mapped_column(ARRAY(UUID(as_uuid=True)), nullable=True) + companion_names: Mapped[list] = mapped_column(ARRAY(Text), default=list) 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) edit_log: Mapped[list] = mapped_column(JSONB, default=list) diff --git a/backend/app/schemas/visit.py b/backend/app/schemas/visit.py index 8d4d14f..7984e37 100644 --- a/backend/app/schemas/visit.py +++ b/backend/app/schemas/visit.py @@ -14,6 +14,7 @@ class VisitCreate(BaseModel): communication_content: str = "" customer_demand: str = "" companions: list[uuid.UUID] = [] + companion_names: list[str] = [] photos: list[str] = [] @@ -27,6 +28,7 @@ class VisitUpdate(BaseModel): communication_content: Optional[str] = None customer_demand: Optional[str] = None companions: Optional[list[uuid.UUID]] = None + companion_names: Optional[list[str]] = None photos: Optional[list[str]] = None @@ -41,6 +43,7 @@ class VisitOut(BaseModel): communication_content: str customer_demand: str companions: Optional[list[uuid.UUID]] = None + companion_names: list[str] = [] photos: Optional[list[str]] = None manager_id: uuid.UUID created_at: datetime diff --git a/backend/app/services/excel_export.py b/backend/app/services/excel_export.py index ea6d965..d1ce2cd 100644 --- a/backend/app/services/excel_export.py +++ b/backend/app/services/excel_export.py @@ -8,6 +8,7 @@ from app.models.visit import Visit from app.models.work_plan import WorkPlan from app.models.mini_business import MiniBusiness from app.models.key_visit import KeyVisit +from app.models.daily_note import DailyNote from app.models.customer import Customer from app.models.user import User @@ -21,7 +22,7 @@ def get_week_range(reference_date: date | None = None): async def export_weekly_report(db: AsyncSession, reference_date: date | None = None) -> io.BytesIO: - """Generate a 4-sheet xlsx matching the existing weekly report template.""" + """Generate a 5-sheet xlsx matching the existing weekly report template.""" monday, sunday = get_week_range(reference_date) wb = Workbook() @@ -52,6 +53,7 @@ async def export_weekly_report(db: AsyncSession, reference_date: date | None = N ) for v in visits_result.scalars(): companions_names = [user_map.get(str(cid), str(cid)) for cid in (v.companions or [])] + companions_names.extend(v.companion_names or []) ws1.append([ customer_map.get(str(v.customer_id), ""), str(v.visit_date), @@ -127,8 +129,29 @@ async def export_weekly_report(db: AsyncSession, reference_date: date | None = N user_map.get(str(k.manager_id), ""), ]) + # ── Sheet 5: 今日纪要 ── + ws5 = wb.create_sheet("今日纪要") + headers5 = ["日期", "分类", "内容", "时间范围", "填报人"] + ws5.append(headers5) + for col in range(1, len(headers5) + 1): + cell = ws5.cell(row=1, column=col) + cell.font = header_font + cell.border = thin_border + + notes_result = await db.execute( + select(DailyNote).where(DailyNote.note_date >= monday, DailyNote.note_date <= sunday) + ) + for n in notes_result.scalars(): + ws5.append([ + str(n.note_date), + n.category, + n.content, + n.time_range, + user_map.get(str(n.manager_id), ""), + ]) + # Adjust column widths - for ws in [ws1, ws2, ws3, ws4]: + for ws in [ws1, ws2, ws3, ws4, ws5]: for col_cells in ws.columns: max_length = max((len(str(cell.value or "")) for cell in col_cells), default=10) ws.column_dimensions[col_cells[0].column_letter].width = min(max_length + 4, 50) diff --git a/backend/app/services/excel_import.py b/backend/app/services/excel_import.py index b09ae6a..5cad36b 100644 --- a/backend/app/services/excel_import.py +++ b/backend/app/services/excel_import.py @@ -1,26 +1,142 @@ +import io import uuid +import re from datetime import datetime, date from typing import Any from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select -import openpyxl +from sqlalchemy import select, update from app.models.customer import Customer +from app.models.customer_assignment import CustomerAssignment from app.models.visit import Visit from app.models.work_plan import WorkPlan from app.models.mini_business import MiniBusiness from app.models.key_visit import KeyVisit +from app.models.daily_note import DailyNote from app.models.user import User +import openpyxl + +# Companion name separators: Chinese comma, English comma, Chinese semicolon, dun-hao +_COMPANION_SEP = re.compile(r'[,,;、;]') + + +def _parse_companions(raw: str, user_map: dict[str, uuid.UUID]) -> tuple[list[uuid.UUID], list[str]]: + """Split a companion string into system user IDs and external names. + + Returns (system_ids, external_names). + """ + if not raw or not raw.strip(): + return [], [] + + parts = [p.strip() for p in _COMPANION_SEP.split(raw) if p.strip()] + system_ids: list[uuid.UUID] = [] + external_names: list[str] = [] + + for name in parts: + uid = user_map.get(name) + if uid: + system_ids.append(uid) + else: + external_names.append(name) + + return system_ids, external_names + + +def _fuzzy_match_customer( + cust_name: str, + customer_map: dict[str, uuid.UUID], +) -> uuid.UUID | None: + """Try to match a customer name with fuzzy rules. + + Rules (in order): + 1. Exact match (already handled before calling this) + 2. Normalize whitespace → exact match + 3. One name fully contains the other + """ + norm = cust_name.replace(' ', '').replace(' ', '') + # Rule 2: whitespace-normalized match + for existing_name, cid in customer_map.items(): + existing_norm = existing_name.replace(' ', '').replace(' ', '') + if norm == existing_norm: + return cid + + # Rule 3: containment (longer name contains shorter) + for existing_name, cid in customer_map.items(): + if len(norm) >= 4 and len(existing_name.replace(' ', '').replace(' ', '')) >= 4: + if norm in existing_name.replace(' ', '').replace(' ', '') or \ + existing_name.replace(' ', '').replace(' ', '') in norm: + return cid + + return None + async def import_from_excel(db: AsyncSession, file_bytes: bytes, manager_id: uuid.UUID) -> dict: - """Parse old weekly report Excel and import data. Returns summary stats.""" - wb = openpyxl.load_workbook(io.BytesIO(file_bytes), data_only=True) - stats = {"visits": 0, "work_plans": 0, "mini_business": 0, "key_visits": 0, "skipped": 0, "skip_reasons": []} + """Parse old weekly report Excel and import data. Returns summary stats. - # Resolve customer name -> id cache + Features: + - Companion parsing: split by comma/dun-hao → system users + external names + - Auto-create customers: if customer not found, create from Excel data + - Fuzzy name matching: whitespace normalization + containment + - Daily notes import (Sheet 5) + """ + wb = openpyxl.load_workbook(io.BytesIO(file_bytes), data_only=True) + stats = { + "visits": 0, "work_plans": 0, "mini_business": 0, "key_visits": 0, + "daily_notes": 0, "skipped": 0, "skip_reasons": [], + "customers_created": 0, "customers_created_names": [], + "external_companions": 0, "name_corrections": [], + } + + # ── Pre-fetch lookups ── customers_result = await db.execute(select(Customer.id, Customer.name)) customer_map = {c.name: c.id for c in customers_result.all()} + users_result = await db.execute(select(User.id, User.name)) + user_map = {u.name: u.id for u in users_result.all()} + + # Customer → assigned primary manager lookup (not from Excel) + assign_result = await db.execute( + select(CustomerAssignment.customer_id, CustomerAssignment.manager_id) + .where(CustomerAssignment.role == "primary") + ) + customer_manager_map: dict[uuid.UUID, uuid.UUID] = {} + for cid, mid in assign_result.all(): + if cid not in customer_manager_map: # first primary wins + customer_manager_map[cid] = mid + + def get_assigned_manager(customer_id: uuid.UUID) -> uuid.UUID: + """Return the customer's assigned primary manager, or the importer as fallback.""" + return customer_manager_map.get(customer_id, manager_id) + + # Helper: resolve or create customer + async def resolve_customer(cust_name: str, mgr_name: str = "") -> tuple[uuid.UUID | None, str]: + """Resolve customer by name. Auto-creates if not found. Returns (id, note).""" + if not cust_name or not cust_name.strip(): + return None, "" + + cust_name = cust_name.strip() + cid = customer_map.get(cust_name) + if cid: + return cid, "" + + # Fuzzy match + fuzzy_id = _fuzzy_match_customer(cust_name, customer_map) + if fuzzy_id: + real_name = next((n for n, i in customer_map.items() if i == fuzzy_id), cust_name) + stats["name_corrections"].append(f"「{cust_name}」→「{real_name}」") + customer_map[cust_name] = fuzzy_id # cache for future rows + return fuzzy_id, "" + + # Auto-create customer (no manager assignment — leave unassigned) + customer = Customer(name=cust_name, created_by=manager_id) + db.add(customer) + await db.flush() + customer_map[cust_name] = customer.id + stats["customers_created"] += 1 + stats["customers_created_names"].append(cust_name) + + return customer.id, "" + # ── Parse Sheet 1: 每日拜访记录 ── if "每日拜访记录" in wb.sheetnames: ws = wb["每日拜访记录"] @@ -28,28 +144,36 @@ async def import_from_excel(db: AsyncSession, file_bytes: bytes, manager_id: uui if not row[0]: continue try: - cust_name, visit_date_str, visit_method, time_range, visitor_name, visitor_phone, content, demand, companions_str, mgr_str = \ - row[0], str(row[1]) if row[1] else str(date.today()), \ - str(row[2]) if row[2] else "上门", str(row[3]) if row[3] else "", \ - str(row[4]) if row[4] else "", str(row[5]) if row[5] else "", \ - str(row[6]) if row[6] else "", str(row[7]) if row[7] else "", \ - str(row[8]) if row[8] else "", str(row[9]) if row[9] else "" + cust_name, visit_date_str, visit_method, time_range, visitor_name, visitor_phone, \ + content, demand, companions_str, mgr_str = ( + row[0], str(row[1]) if row[1] else str(date.today()), + str(row[2]) if row[2] else "上门", str(row[3]) if row[3] else "", + str(row[4]) if row[4] else "", str(row[5]) if row[5] else "", + str(row[6]) if row[6] else "", str(row[7]) if row[7] else "", + str(row[8]) if row[8] else "", str(row[9]) if row[9] else "", + ) - customer_id = customer_map.get(cust_name) + # Resolve customer (auto-create if needed) + customer_id, _ = await resolve_customer(cust_name, str(row[9]) if row[9] else "") if not customer_id: stats["skipped"] += 1 - stats["skip_reasons"].append(f"客户「{cust_name}」不存在,跳过") + stats["skip_reasons"].append(f"客户名称为空,跳过") continue + # Parse visit date try: visit_date = datetime.strptime(visit_date_str[:10], "%Y-%m-%d").date() except ValueError: visit_date = date.today() + # Use customer's assigned primary manager (not Excel column) + visit_manager_id = get_assigned_manager(customer_id) + + # Check duplicate: same date + same manager + same customer existing = await db.execute( select(Visit).where( Visit.visit_date == visit_date, - Visit.manager_id == manager_id, + Visit.manager_id == visit_manager_id, Visit.customer_id == customer_id, ) ) @@ -58,6 +182,11 @@ async def import_from_excel(db: AsyncSession, file_bytes: bytes, manager_id: uui stats["skip_reasons"].append(f"重复:{cust_name} {visit_date} 已存在") continue + # Parse companions into system users + external names + sys_companions, ext_names = _parse_companions(str(companions_str), user_map) + if ext_names: + stats["external_companions"] += len(ext_names) + visit = Visit( customer_id=customer_id, visit_date=visit_date, @@ -67,15 +196,194 @@ async def import_from_excel(db: AsyncSession, file_bytes: bytes, manager_id: uui visitor_phone=visitor_phone, communication_content=content, customer_demand=demand, - manager_id=manager_id, + companions=sys_companions, + companion_names=ext_names, + manager_id=visit_manager_id, ) db.add(visit) stats["visits"] += 1 + + # Update customer's last_visit_date for light board + cust = await db.get(Customer, customer_id) + if cust and (not cust.last_visit_date or visit_date > cust.last_visit_date): + cust.last_visit_date = visit_date + cust.last_visit_manager_id = visit_manager_id + except Exception as e: + stats["skipped"] += 1 + stats["skip_reasons"].append(f"拜访解析异常:{repr(e)[:120]}") + + # ── Parse Sheet 2: 下周工作计划 ── + if "下周工作计划" in wb.sheetnames: + ws2 = wb["下周工作计划"] + for row in ws2.iter_rows(min_row=2, values_only=True): + if not row[0]: + continue + try: + cust_name = str(row[0]).strip() if row[0] else "" + plan_content = str(row[1]) if row[1] else "" + plan_date_str = str(row[2]) if row[2] else str(date.today()) + mgr_name = str(row[3]).strip() if row[3] else "" + status = str(row[4]) if row[4] else "计划中" + + customer_id, _ = await resolve_customer(cust_name, mgr_name) + if not customer_id: + stats["skipped"] += 1 + stats["skip_reasons"].append(f"工作计划:客户「{cust_name}」无法解析") + continue + + try: + plan_date = datetime.strptime(plan_date_str[:10], "%Y-%m-%d").date() + except ValueError: + plan_date = date.today() + + plan_manager_id = get_assigned_manager(customer_id) + + work_plan = WorkPlan( + customer_id=customer_id, + plan_content=plan_content, + plan_date=plan_date, + manager_id=plan_manager_id, + status=status if status in ["计划中", "已完成", "已取消"] else "计划中", + ) + db.add(work_plan) + stats["work_plans"] += 1 + except Exception: + stats["skipped"] += 1 + + # ── Parse Sheet 3: 小微业务商机 ── + if "小微业务商机" in wb.sheetnames: + ws3 = wb["小微业务商机"] + for row in ws3.iter_rows(min_row=2, values_only=True): + if not row[0]: + continue + try: + cust_name = str(row[0]).strip() if row[0] else "" + product_type = str(row[1]) if row[1] else "" + amount = str(row[2]) if row[2] else "" + follow_up = str(row[3]) if row[3] else "" + status = str(row[4]) if row[4] else "跟进中" + mgr_name = str(row[5]).strip() if row[5] else "" + expected_revenue = str(row[6]) if row[6] else "" + + customer_id, _ = await resolve_customer(cust_name, mgr_name) + if not customer_id: + stats["skipped"] += 1 + stats["skip_reasons"].append(f"商机:客户「{cust_name}」无法解析") + continue + + biz_manager_id = get_assigned_manager(customer_id) + + mini = MiniBusiness( + customer_id=customer_id, + product_type=product_type, + amount=amount, + follow_up_detail=follow_up, + status=status if status in ["跟进中", "已成交", "已流失"] else "跟进中", + manager_id=biz_manager_id, + expected_revenue_date=expected_revenue, + ) + db.add(mini) + stats["mini_business"] += 1 + except Exception: + stats["skipped"] += 1 + + # ── Parse Sheet 4: 要客拜访计划 ── + if "要客拜访计划" in wb.sheetnames: + ws4 = wb["要客拜访计划"] + for row in ws4.iter_rows(min_row=2, values_only=True): + if not row[0]: + continue + try: + cust_name = str(row[0]).strip() if row[0] else "" + urgency = str(row[1]) if row[1] else "一般" + description = str(row[2]) if row[2] else "" + progress = str(row[3]) if row[3] else "未开始" + planned_date = str(row[4]) if row[4] else "" + planned_visitor = str(row[5]) if row[5] else "" + visit_target = str(row[6]) if row[6] else "" + mgr_name = str(row[7]).strip() if row[7] else "" + + customer_id, _ = await resolve_customer(cust_name, mgr_name) + if not customer_id: + stats["skipped"] += 1 + stats["skip_reasons"].append(f"要客:客户「{cust_name}」无法解析") + continue + + kv_manager_id = get_assigned_manager(customer_id) + valid_urgency = urgency if urgency in ["一般", "重要", "紧急"] else "一般" + valid_progress = progress if progress in ["未开始", "进行中", "已完成"] else "未开始" + + key_visit = KeyVisit( + customer_id=customer_id, + urgency_level=valid_urgency, + description=description, + progress_status=valid_progress, + planned_date=planned_date, + planned_visitor=planned_visitor, + visit_target=visit_target, + manager_id=kv_manager_id, + ) + db.add(key_visit) + stats["key_visits"] += 1 + except Exception: + stats["skipped"] += 1 + + # ── Parse Sheet 5: 今日纪要 ── + if "今日纪要" in wb.sheetnames: + ws5 = wb["今日纪要"] + valid_categories = ["行政事务", "合同整理", "发票处理", "内部会议", "培训学习", "其他"] + for row in ws5.iter_rows(min_row=2, values_only=True): + if not row[0]: + continue + try: + note_date_str = str(row[0]) if row[0] else str(date.today()) + category = str(row[1]) if row[1] else "其他" + content = str(row[2]) if row[2] else "" + time_range = str(row[3]) if row[3] else "" + mgr_name = str(row[4]).strip() if row[4] else "" + + # Validate category + if category not in valid_categories: + category = "其他" + + # Resolve manager by name + note_manager_id = manager_id # default to importer + if mgr_name: + mgr_id = user_map.get(mgr_name) + if mgr_id: + note_manager_id = mgr_id + else: + stats["skip_reasons"].append(f"纪要:客户经理「{mgr_name}」不存在,使用导入人") + + try: + note_date = datetime.strptime(note_date_str[:10], "%Y-%m-%d").date() + except ValueError: + note_date = date.today() + + # Skip duplicates: same manager, same date, same category + existing = await db.execute( + select(DailyNote).where( + DailyNote.note_date == note_date, + DailyNote.manager_id == note_manager_id, + DailyNote.category == category, + ) + ) + if existing.scalar_one_or_none(): + stats["skipped"] += 1 + stats["skip_reasons"].append(f"重复:{note_date} {category} 纪要已存在") + continue + + daily_note = DailyNote( + manager_id=note_manager_id, + note_date=note_date, + category=category, + content=content, + time_range=time_range, + ) + db.add(daily_note) + stats["daily_notes"] += 1 except Exception: stats["skipped"] += 1 await db.commit() return stats - - -import io diff --git a/frontend/index.html b/frontend/index.html index 678ca3d..15d80dd 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,6 +5,10 @@ 企迹 - 政企周报管理系统 + + + +
diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 8e3b95c..f923d4b 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -5,6 +5,12 @@ server { root /usr/share/nginx/html; index index.html; + # Immutable hashed static assets (Vite content-hashed filenames) + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + # SPA fallback location / { try_files $uri $uri/ /index.html; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f7086c9..2dfa454 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -21,6 +21,8 @@ "postcss": "^8.5.15", "tailwindcss": "^3.4.19", "typescript": "~5.6.0", + "unplugin-element-plus": "^0.11.2", + "unplugin-vue-components": "^32.1.0", "vite": "^6.0.5", "vue-tsc": "^2.2.0" } @@ -580,6 +582,17 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -645,6 +658,48 @@ "node": ">= 8" } }, + "node_modules/@nuxt/kit": { + "version": "4.4.8", + "resolved": "https://registry.npmmirror.com/@nuxt/kit/-/kit-4.4.8.tgz", + "integrity": "sha512-ZUlZ5iYfyfJFDPluhn6ZxFWcsuxWbLnZBc8w3MAROcQ4lYfZ+qFpALBLSNlpc0zhOa++33EE+5PEbOAdVIY+dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "c12": "^3.3.4", + "consola": "^3.4.2", + "defu": "^6.1.7", + "destr": "^2.0.5", + "errx": "^0.1.0", + "exsolve": "^1.0.8", + "ignore": "^7.0.5", + "jiti": "^2.7.0", + "klona": "^2.0.6", + "mlly": "^1.8.2", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "pkg-types": "^2.3.1", + "rc9": "^3.0.1", + "scule": "^1.3.0", + "semver": "^7.8.1", + "tinyglobby": "^0.2.17", + "ufo": "^1.6.4", + "unctx": "^2.5.0", + "untyped": "^2.0.0" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/@nuxt/kit/node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/@popperjs/core": { "name": "@sxzz/popperjs-es", "version": "2.11.8", @@ -1270,6 +1325,19 @@ "vue": "^3.5.0" } }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz", @@ -1481,6 +1549,75 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/c12": { + "version": "3.3.4", + "resolved": "https://registry.npmmirror.com/c12/-/c12-3.3.4.tgz", + "integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "confbox": "^0.2.4", + "defu": "^6.1.6", + "dotenv": "^17.3.1", + "exsolve": "^1.0.8", + "giget": "^3.2.0", + "jiti": "^2.6.1", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^2.1.0", + "pkg-types": "^2.3.0", + "rc9": "^3.0.1" + }, + "peerDependencies": { + "magicast": "*" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/c12/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/c12/node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/c12/node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -1563,6 +1700,16 @@ "node": ">= 6" } }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmmirror.com/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", @@ -1585,6 +1732,23 @@ "node": ">= 6" } }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmmirror.com/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz", @@ -1634,6 +1798,13 @@ } } }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmmirror.com/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "dev": true, + "license": "MIT" + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1643,6 +1814,13 @@ "node": ">=0.4.0" } }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "dev": true, + "license": "MIT" + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmmirror.com/didyoumean/-/didyoumean-1.2.2.tgz", @@ -1657,6 +1835,19 @@ "dev": true, "license": "MIT" }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1716,6 +1907,13 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/errx": { + "version": "0.1.0", + "resolved": "https://registry.npmmirror.com/errx/-/errx-0.1.0.tgz", + "integrity": "sha512-fZmsRiDNv07K6s2KkKFTiD2aIvECa7++PKyD5NC32tpRw46qZA3sOz+aM+/V9V0GDHxVTKLziveV4JhzBHDp9Q==", + "dev": true, + "license": "MIT" + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1734,6 +1932,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-2.2.0.tgz", + "integrity": "sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -1813,12 +2018,32 @@ "node": ">=6" } }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "license": "MIT" }, + "node_modules/exsolve": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/exsolve/-/exsolve-1.1.0.tgz", + "integrity": "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz", @@ -2001,6 +2226,16 @@ "node": ">= 0.4" } }, + "node_modules/giget": { + "version": "3.3.0", + "resolved": "https://registry.npmmirror.com/giget/-/giget-3.3.0.tgz", + "integrity": "sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw==", + "dev": true, + "license": "MIT", + "bin": { + "giget": "dist/cli.mjs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2088,6 +2323,16 @@ "node": ">= 6" } }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmmirror.com/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -2160,6 +2405,23 @@ "jiti": "bin/jiti.js" } }, + "node_modules/klona": { + "version": "2.0.6", + "resolved": "https://registry.npmmirror.com/klona/-/klona-2.0.6.tgz", + "integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/knitwork": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/knitwork/-/knitwork-1.3.0.tgz", + "integrity": "sha512-4LqMNoONzR43B1W0ek0fhXMsDNW/zxa1NdFAVMY+k28pgZLovR4G3PB5MrpTxCy1QaZCqNoiaKPr5w5qZHfSNw==", + "dev": true, + "license": "MIT" + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.3.tgz", @@ -2180,6 +2442,24 @@ "dev": true, "license": "MIT" }, + "node_modules/local-pkg": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-1.2.1.tgz", + "integrity": "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", @@ -2301,6 +2581,38 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmmirror.com/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mlly/node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", @@ -2390,6 +2702,27 @@ "node": ">= 6" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmmirror.com/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "dev": true, + "license": "MIT" + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", @@ -2404,6 +2737,20 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", @@ -2465,6 +2812,18 @@ "node": ">= 6" } }, + "node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz", @@ -2650,6 +3009,23 @@ "node": ">=10" } }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmmirror.com/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -2671,6 +3047,17 @@ ], "license": "MIT" }, + "node_modules/rc9": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/rc9/-/rc9-3.0.1.tgz", + "integrity": "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.6", + "destr": "^2.0.5" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmmirror.com/read-cache/-/read-cache-1.0.0.tgz", @@ -2740,6 +3127,22 @@ "node": ">=0.10.0" } }, + "node_modules/rolldown-string": { + "version": "0.2.1", + "resolved": "https://registry.npmmirror.com/rolldown-string/-/rolldown-string-0.2.1.tgz", + "integrity": "sha512-7H8oH5A8+L96pbBTPCt/rZrwayEhZY5/ejhdk9nRODH32H1v7+bfkaCr+kS15DcGQ7VC1HcWdQVNABFYgrMOzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.21" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, "node_modules/rollup": { "version": "4.62.2", "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.2.tgz", @@ -2809,6 +3212,26 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/scule": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/scule/-/scule-1.3.0.tgz", + "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2980,6 +3403,231 @@ "node": ">=14.17" } }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmmirror.com/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/unctx": { + "version": "2.5.0", + "resolved": "https://registry.npmmirror.com/unctx/-/unctx-2.5.0.tgz", + "integrity": "sha512-p+Rz9x0R7X+CYDkT+Xg8/GhpcShTlU8n+cf9OtOEf7zEQsNcCZO1dPKNRDqvUTaq+P32PMMkxWHwfrxkqfqAYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.15.0", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21", + "unplugin": "^2.3.11" + } + }, + "node_modules/unctx/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/unplugin": { + "version": "2.3.11", + "resolved": "https://registry.npmmirror.com/unplugin/-/unplugin-2.3.11.tgz", + "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "acorn": "^8.15.0", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/unplugin-element-plus": { + "version": "0.11.2", + "resolved": "https://registry.npmmirror.com/unplugin-element-plus/-/unplugin-element-plus-0.11.2.tgz", + "integrity": "sha512-jr88ePpv43h8cCmVW0SqM73sTD+g1n9Rmy4uMbTh+pSmceH9ZdKteWX9f+twC4aDlP3svdZuKMqLoUNBT2V6Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nuxt/kit": "^4.2.2", + "es-module-lexer": "^2.0.0", + "escape-string-regexp": "^5.0.0", + "rolldown-string": "^0.2.1", + "unplugin": "^2.3.11" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/unplugin-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmmirror.com/unplugin-utils/-/unplugin-utils-0.3.1.tgz", + "integrity": "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/unplugin-vue-components": { + "version": "32.1.0", + "resolved": "https://registry.npmmirror.com/unplugin-vue-components/-/unplugin-vue-components-32.1.0.tgz", + "integrity": "sha512-YiUkSxuRjab18XFOrX5VsIxXzccrfmHVGsGeJgSgklb829DQmCy9E4vvDUE4tuvZZdxyFJZX0Oc4TPnnxiiMyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "local-pkg": "^1.2.0", + "magic-string": "^0.30.21", + "mlly": "^1.8.2", + "obug": "^2.1.1", + "picomatch": "^4.0.4", + "tinyglobby": "^0.2.16", + "unplugin": "^3.0.0", + "unplugin-utils": "^0.3.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@nuxt/kit": "^3.2.2 || ^4.0.0", + "vue": "^3.0.0" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + } + } + }, + "node_modules/unplugin-vue-components/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/unplugin-vue-components/node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/unplugin-vue-components/node_modules/unplugin": { + "version": "3.3.0", + "resolved": "https://registry.npmmirror.com/unplugin/-/unplugin-3.3.0.tgz", + "integrity": "sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "picomatch": "^4.0.4", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@farmfe/core": "*", + "@rspack/core": "*", + "bun-types-no-globals": "*", + "esbuild": "*", + "rolldown": "*", + "rollup": "*", + "unloader": "*", + "vite": "*", + "webpack": "*" + }, + "peerDependenciesMeta": { + "@farmfe/core": { + "optional": true + }, + "@rspack/core": { + "optional": true + }, + "bun-types-no-globals": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "rolldown": { + "optional": true + }, + "rollup": { + "optional": true + }, + "unloader": { + "optional": true + }, + "vite": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/untyped": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/untyped/-/untyped-2.0.0.tgz", + "integrity": "sha512-nwNCjxJTjNuLCgFr42fEak5OcLuB3ecca+9ksPFNvtfYSLpjf+iJqSIaSnIile6ZPbKYxI5k2AfXqeopGudK/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "defu": "^6.1.4", + "jiti": "^2.4.2", + "knitwork": "^1.2.0", + "scule": "^1.3.0" + }, + "bin": { + "untyped": "dist/cli.mjs" + } + }, + "node_modules/untyped/node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -3184,6 +3832,13 @@ "peerDependencies": { "typescript": ">=5.0.0" } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmmirror.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true, + "license": "MIT" } } } diff --git a/frontend/package.json b/frontend/package.json index 49bce70..30ac61a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,8 @@ "postcss": "^8.5.15", "tailwindcss": "^3.4.19", "typescript": "~5.6.0", + "unplugin-element-plus": "^0.11.2", + "unplugin-vue-components": "^32.1.0", "vite": "^6.0.5", "vue-tsc": "^2.2.0" } diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 2494efe..120b6ce 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,6 +1,7 @@ diff --git a/frontend/src/views/desktop/ManagerWorkspace.vue b/frontend/src/views/desktop/ManagerWorkspace.vue index 2e0b032..15916e0 100644 --- a/frontend/src/views/desktop/ManagerWorkspace.vue +++ b/frontend/src/views/desktop/ManagerWorkspace.vue @@ -3,6 +3,7 @@ import { ref, onMounted, computed } from 'vue' import { ElMessage, ElMessageBox } from 'element-plus' import { todayStr } from '@/utils' import api from '@/api/index' +import { compressImage } from '@/utils/image' import ImagePreview from '@/components/ImagePreview.vue' import EditLogPanel from '@/components/EditLogPanel.vue' @@ -92,7 +93,7 @@ async function loadAll() { function openCreate(type: string) { dialogMode.value = 'create'; dialogType.value = type dialogTimeRange.value = null - if (type === 'visit') form.value = { customer_id: '', visit_date: todayStr(), visit_method: '上门', time_range: '', visitor_name: '', visitor_phone: '', communication_content: '', customer_demand: '' } + if (type === 'visit') form.value = { customer_id: '', visit_date: todayStr(), visit_method: '上门', time_range: '', visitor_name: '', visitor_phone: '', communication_content: '', customer_demand: '', companions: [], companion_names: [] } else if (type === 'note') form.value = { note_date: todayStr(), category: '其他', content: '', time_range: '' } else if (type === 'plan') form.value = { customer_id: '', plan_content: '', plan_date: todayStr(), status: '计划中' } else if (type === 'mini') form.value = { customer_id: '', product_type: '', amount: '', follow_up_detail: '', status: '跟进中', expected_revenue_date: '' } @@ -134,11 +135,13 @@ async function handleDialogPhotoUpload(event: Event) { for (const file of Array.from(target.files)) { if ((form.value.photos || []).length >= 9) break try { + // Compress before upload to reduce storage & transfer + const compressed = await compressImage(file, { maxPixels: 1920, quality: 0.8 }) // Get presigned URL - const presignRes = await api.post('/upload/presigned-url', null, { params: { filename: file.name, content_type: file.type || 'image/jpeg' } }) + const presignRes = await api.post('/upload/presigned-url', null, { params: { filename: compressed.name, content_type: compressed.type || 'image/jpeg' } }) // Upload directly to MinIO (not through our API) const axios = (await import('axios')).default - await axios.put(presignRes.data.upload_url, file, { headers: { 'Content-Type': file.type || 'image/jpeg' } }) + await axios.put(presignRes.data.upload_url, compressed, { headers: { 'Content-Type': compressed.type || 'image/jpeg' } }) const key = presignRes.data.object_key if (!form.value.photos) form.value.photos = [] form.value.photos.push(key) @@ -167,8 +170,23 @@ function removePhotoFromEdit(idx: number) { } } +function splitCompanions(values: string[]) { + const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + const sys: string[] = []; const ext: string[] = [] + for (const v of (values || [])) { + if (uuidRe.test(v)) sys.push(v) + else if (v.trim()) ext.push(v.trim()) + } + return { companions: sys, companion_names: ext } +} + async function handleSave() { - const t = dialogType.value; const d = form.value + const t = dialogType.value; let d = { ...form.value } + // Split companions for visit type + if (t === 'visit' && d.companions) { + const { companions, companion_names } = splitCompanions(d.companions) + d = { ...d, companions, companion_names } + } try { if (dialogMode.value === 'create') { switch (t) { @@ -443,6 +461,11 @@ const notesByDate = computed(() => { + + + + + diff --git a/frontend/src/views/desktop/Settings.vue b/frontend/src/views/desktop/Settings.vue index 7cf3344..959fb52 100644 --- a/frontend/src/views/desktop/Settings.vue +++ b/frontend/src/views/desktop/Settings.vue @@ -178,7 +178,20 @@ async function handleImportPreview() {
- 导入完成:拜访 {{ importResult.visits }} 条,跳过 {{ importResult.skipped }} 条 + 导入完成:拜访 {{ importResult.visits || 0 }} 条、纪要 {{ importResult.daily_notes || 0 }} 条、计划 {{ importResult.work_plans || 0 }} 条、商机 {{ importResult.mini_business || 0 }} 条、要客 {{ importResult.key_visits || 0 }} 条 + + + + +