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 }} 条
+ ,自动创建客户 {{ importResult.customers_created }} 个
+ ,外部同访人 {{ importResult.external_companions }} 人
+ ,跳过 {{ importResult.skipped }} 条
+
+
+
+
+
+ 🆕 新建客户:{{ importResult.customers_created_names.join('、') }}
+
+
• {{ r }}
diff --git a/frontend/src/views/desktop/WeeklyReport.vue b/frontend/src/views/desktop/WeeklyReport.vue
index be4eea3..b7595c1 100644
--- a/frontend/src/views/desktop/WeeklyReport.vue
+++ b/frontend/src/views/desktop/WeeklyReport.vue
@@ -38,29 +38,40 @@ const photoUrls = ref
>({})
const photoDialogVisible = ref(false)
const currentPhotoUrl = ref('')
-onMounted(async () => {
+onMounted(() => {
if (route.query.manager_id) filterManagerId.value = route.query.manager_id as string
if (route.query.customer_id) filterCustomerId.value = route.query.customer_id as string
- await loadReport()
- try {
- const [mRes, cRes] = await Promise.all([
- api.get('/users/', { params: { role: 'manager' } }),
- customersApi.list({ page_size: 500 }),
- ])
- managers.value = mRes.data
- customers.value = cRes.data.items || cRes.data
- } catch (_) {}
- // Auto-load cached AI summary
- if (auth.isDirector || auth.isLeader) {
+
+ // Kick off report load immediately (includes photo URL fetching)
+ const reportPromise = loadReport()
+
+ // Dropdown data loads in parallel with report
+ const dropdownsPromise = (async () => {
try {
- const cached = await aiApi.getSummary({ reference_date: getRefDate(), period: 'week' })
- if (cached.data?.summary) {
- aiSummary.value = cached.data.summary
- aiCached.value = !!cached.data.cached
- aiCreatedAt.value = cached.data.created_at || ''
- }
+ const [mRes, cRes] = await Promise.all([
+ api.get('/users/', { params: { role: 'manager' } }),
+ customersApi.list({ page_size: 500 }),
+ ])
+ managers.value = mRes.data
+ customers.value = cRes.data.items || cRes.data
} catch (_) {}
- }
+ })()
+
+ // AI summary loads in parallel too
+ const aiPromise = (async () => {
+ if (auth.isDirector || auth.isLeader) {
+ try {
+ const cached = await aiApi.getSummary({ reference_date: getRefDate(), period: 'week' })
+ if (cached.data?.summary) {
+ aiSummary.value = cached.data.summary
+ aiCached.value = !!cached.data.cached
+ aiCreatedAt.value = cached.data.created_at || ''
+ }
+ } catch (_) {}
+ }
+ })()
+
+ Promise.all([reportPromise, dropdownsPromise, aiPromise])
})
function changeWeek(delta: number) { weekOffset.value += delta; loadReport() }
@@ -81,17 +92,19 @@ async function loadReport() {
params.reference_date = getRefDate()
const res = await dashboardApi.getWeeklyReport(params)
report.value = res.data
+ // Collect all unique photo keys first, then fetch in parallel
+ const photoKeys = new Set()
for (const v of report.value.visits) {
- if (v.photos?.length) {
- for (const key of v.photos) {
- if (!photoUrls.value[key]) {
- try {
- const urlRes = await uploadApi.getDownloadUrl(key)
- photoUrls.value[key] = urlRes.data.download_url
- } catch (_) {}
- }
- }
- }
+ for (const key of (v.photos || [])) photoKeys.add(key)
+ }
+ const newKeys = [...photoKeys].filter(k => !photoUrls.value[k])
+ if (newKeys.length > 0) {
+ const results = await Promise.allSettled(
+ newKeys.map(k => uploadApi.getDownloadUrl(k))
+ )
+ results.forEach((r, i) => {
+ if (r.status === 'fulfilled') photoUrls.value[newKeys[i]] = r.value.data.download_url
+ })
}
} catch (e: any) {
ElMessage.error('加载周报失败')
@@ -316,9 +329,12 @@ const notesByDate = computed(() => {
-
+
- {{ row.manager_name }}
+
+ {{ row.manager_name }}
+ , {{ n }}
+
最后编辑:{{ row.edit_log[row.edit_log.length-1].editor }} · {{ row.edit_log.length-1 }}次修改
🕐
@@ -359,7 +375,7 @@ const notesByDate = computed(() => {
-
+
diff --git a/frontend/src/views/mobile/VisitForm.vue b/frontend/src/views/mobile/VisitForm.vue
index e6ec849..0c169b7 100644
--- a/frontend/src/views/mobile/VisitForm.vue
+++ b/frontend/src/views/mobile/VisitForm.vue
@@ -6,6 +6,7 @@ import { todayStr } from '@/utils'
import { visitsApi } from '@/api/visits'
import { customersApi } from '@/api/customers'
import { uploadApi } from '@/api/upload'
+import { compressImage } from '@/utils/image'
import { useAuthStore } from '@/stores/auth'
import ImagePreview from '@/components/ImagePreview.vue'
import EditLogPanel from '@/components/EditLogPanel.vue'
@@ -121,8 +122,10 @@ async function handlePhotoUpload(event: Event) {
const previewUrl = URL.createObjectURL(file)
photoPreviews.value.push(previewUrl)
try {
- const res = await uploadApi.getPresignedUrl(file.name, file.type || 'image/jpeg')
- await uploadApi.uploadFile(res.data.upload_url, file)
+ // Compress before upload to reduce storage & transfer
+ const compressed = await compressImage(file, { maxPixels: 1920, quality: 0.8 })
+ const res = await uploadApi.getPresignedUrl(compressed.name, compressed.type || 'image/jpeg')
+ await uploadApi.uploadFile(res.data.upload_url, compressed)
uploadedPhotos.value.push(res.data.object_key)
form.value.photos = [...uploadedPhotos.value]
} catch (e: any) {
@@ -154,15 +157,28 @@ function removePhoto(index: number) {
form.value.photos = [...uploadedPhotos.value]
}
+function splitCompanions(values: string[]) {
+ // UUIDs → companions (system users), non-UUID strings → companion_names (external)
+ 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 handleSubmit() {
if (!form.value.customer_id) { ElMessage.warning('请选择客户'); return }
submitLoading.value = true
try {
+ const { companions, companion_names } = splitCompanions(form.value.companions || [])
+ const body = { ...form.value, companions, companion_names }
if (isEdit.value) {
- await visitsApi.update(route.params.id as string, form.value)
+ await visitsApi.update(route.params.id as string, body)
ElMessage.success('记录已更新')
} else {
- await visitsApi.create(form.value)
+ await visitsApi.create(body)
ElMessage.success('拜访记录已提交')
}
router.push('/m')
@@ -269,9 +285,9 @@ async function handleDelete() {
- 同访人员
+ 同访人员 可输入外部人员
-
+
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index fe0bbec..0ff986d 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -1,9 +1,21 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
+import Components from 'unplugin-vue-components/vite'
+import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
+import ElementPlus from 'unplugin-element-plus/vite'
export default defineConfig({
- plugins: [vue()],
+ plugins: [
+ vue(),
+ // Auto-import Element Plus components used in templates
+ Components({
+ resolvers: [ElementPlusResolver()],
+ dts: 'src/components.d.ts',
+ }),
+ // Auto-import styles for explicitly imported Element Plus APIs (ElMessage, ElMessageBox, etc.)
+ ElementPlus({}),
+ ],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
@@ -13,6 +25,7 @@ export default defineConfig({
rollupOptions: {
output: {
manualChunks: {
+ // After tree-shaking, this chunk only contains the components actually used
'element-plus': ['element-plus'],
'vue-vendor': ['vue', 'vue-router', 'pinia'],
},