feat: 导入增强 + 客户合并 + 同伴自定义 + 性能优化
=== 导入系统全面增强 === - 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 <noreply@anthropic.com>
This commit is contained in:
+202
-17
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>企迹 - 政企周报管理系统</title>
|
||||
|
||||
<!-- Preconnect to Google Fonts origins for faster font loading -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Generated
+655
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { computed } from 'vue'
|
||||
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const isMobile = computed(() => {
|
||||
@@ -9,7 +10,9 @@ const isMobile = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
<el-config-provider :locale="zhCn">
|
||||
<router-view />
|
||||
</el-config-provider>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
|
||||
Vendored
+52
@@ -0,0 +1,52 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// biome-ignore lint: disable
|
||||
// oxlint-disable
|
||||
// ------
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
DesktopLayout: typeof import('./components/DesktopLayout.vue')['default']
|
||||
EditLogPanel: typeof import('./components/EditLogPanel.vue')['default']
|
||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
ElCollapse: typeof import('element-plus/es')['ElCollapse']
|
||||
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
|
||||
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
||||
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
ElForm: typeof import('element-plus/es')['ElForm']
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElLink: typeof import('element-plus/es')['ElLink']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||
ElProgress: typeof import('element-plus/es')['ElProgress']
|
||||
ElRow: typeof import('element-plus/es')['ElRow']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
ElTimePicker: typeof import('element-plus/es')['ElTimePicker']
|
||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||
ImagePreview: typeof import('./components/ImagePreview.vue')['default']
|
||||
MobileLayout: typeof import('./components/MobileLayout.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
}
|
||||
export interface GlobalDirectives {
|
||||
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
||||
import './tailwind.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
@@ -10,5 +7,4 @@ import router from './router'
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus, { locale: zhCn as any })
|
||||
app.mount('#app')
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Client-side image compression before MinIO upload.
|
||||
* Uses Canvas API to resize and re-encode images, reducing
|
||||
* storage/bandwidth costs and improving load times.
|
||||
*/
|
||||
export interface CompressOptions {
|
||||
/** Max dimension (width or height) in pixels. Default 1920. */
|
||||
maxPixels?: number
|
||||
/** JPEG quality 0–1. Default 0.8. */
|
||||
quality?: number
|
||||
/** Max file size in bytes before compression is applied. Default 200KB. */
|
||||
sizeThreshold?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Compress an image file if it exceeds the size/dimension thresholds.
|
||||
* Returns a File-like object suitable for upload, or the original file
|
||||
* if compression is not needed.
|
||||
*/
|
||||
export async function compressImage(
|
||||
file: File,
|
||||
options: CompressOptions = {},
|
||||
): Promise<File> {
|
||||
const { maxPixels = 1920, quality = 0.8, sizeThreshold = 200 * 1024 } = options
|
||||
|
||||
// Skip non-image files
|
||||
if (!file.type.startsWith('image/')) return file
|
||||
// Don't re-compress GIF/SVG
|
||||
if (file.type === 'image/gif' || file.type === 'image/svg+xml') return file
|
||||
|
||||
// Skip small files
|
||||
if (file.size <= sizeThreshold) return file
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
const url = URL.createObjectURL(file)
|
||||
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
let { width, height } = img
|
||||
|
||||
// Skip if already within dimension limits and file is small enough
|
||||
if (width <= maxPixels && height <= maxPixels && file.size <= sizeThreshold * 2) {
|
||||
return resolve(file)
|
||||
}
|
||||
|
||||
// Calculate new dimensions maintaining aspect ratio
|
||||
if (width > maxPixels || height > maxPixels) {
|
||||
if (width > height) {
|
||||
height = Math.round((height * maxPixels) / width)
|
||||
width = maxPixels
|
||||
} else {
|
||||
width = Math.round((width * maxPixels) / height)
|
||||
height = maxPixels
|
||||
}
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
if (!ctx) {
|
||||
return resolve(file) // fallback
|
||||
}
|
||||
|
||||
// Use better image smoothing
|
||||
ctx.imageSmoothingEnabled = true
|
||||
ctx.imageSmoothingQuality = 'medium'
|
||||
ctx.drawImage(img, 0, 0, width, height)
|
||||
|
||||
// Use original MIME type if supported, otherwise JPEG
|
||||
let mimeType = file.type
|
||||
if (!['image/jpeg', 'image/png', 'image/webp'].includes(mimeType)) {
|
||||
mimeType = 'image/jpeg'
|
||||
}
|
||||
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob || blob.size >= file.size) {
|
||||
// Compression didn't help or failed — use original
|
||||
return resolve(file)
|
||||
}
|
||||
const compressed = new File([blob], file.name, {
|
||||
type: mimeType,
|
||||
lastModified: Date.now(),
|
||||
})
|
||||
resolve(compressed)
|
||||
},
|
||||
mimeType,
|
||||
quality,
|
||||
)
|
||||
}
|
||||
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
resolve(file) // fallback to original on error
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -144,7 +144,44 @@ async function handleSubmit() {
|
||||
}
|
||||
dialogVisible.value = false
|
||||
await loadCustomers()
|
||||
} catch (e: any) { ElMessage.error('操作失败: ' + (e.response?.data?.detail || e.message)) }
|
||||
} catch (e: any) {
|
||||
// Handle name collision → offer merge
|
||||
if (e.response?.status === 409 && e.response?.data?.detail?.preview) {
|
||||
const d = e.response.data.detail
|
||||
mergeSourceId.value = d.source_id
|
||||
mergeSourceName.value = d.source_name
|
||||
mergeTargetId.value = d.target_id
|
||||
mergeTargetName.value = d.target_name
|
||||
mergePreview.value = d.preview
|
||||
mergeDialogVisible.value = true
|
||||
return
|
||||
}
|
||||
ElMessage.error('操作失败: ' + (typeof e.response?.data?.detail === 'object' ? e.response.data.detail.message : (e.response?.data?.detail || e.message)))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Merge ──
|
||||
const mergeDialogVisible = ref(false)
|
||||
const mergeSourceId = ref('')
|
||||
const mergeSourceName = ref('')
|
||||
const mergeTargetId = ref('')
|
||||
const mergeTargetName = ref('')
|
||||
const mergePreview = ref<any>({})
|
||||
const mergeLoading = ref(false)
|
||||
|
||||
async function handleMerge() {
|
||||
mergeLoading.value = true
|
||||
try {
|
||||
const res = await api.post(`/customers/${mergeSourceId.value}/merge`, { target_id: mergeTargetId.value })
|
||||
ElMessage.success(res.data.result || '合并完成')
|
||||
mergeDialogVisible.value = false
|
||||
dialogVisible.value = false
|
||||
await loadCustomers()
|
||||
} catch (e: any) {
|
||||
ElMessage.error('合并失败: ' + (e.response?.data?.detail || e.message))
|
||||
} finally {
|
||||
mergeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeExistingContact(contactId: string) {
|
||||
@@ -413,6 +450,37 @@ async function handleImport() {
|
||||
<el-button type="primary" :loading="importLoading" @click="handleImport" :disabled="!importFile">确认导入</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Merge confirmation dialog -->
|
||||
<el-dialog v-model="mergeDialogVisible" title="合并客户" width="520px" :close-on-click-modal="false">
|
||||
<div style="line-height:1.8">
|
||||
<el-alert type="warning" :closable="false" style="margin-bottom:16px">
|
||||
⚠ 客户「<b>{{ mergeTargetName }}</b>」已存在。是否将「<b>{{ mergeSourceName }}</b>」合并到「{{ mergeTargetName }}」?
|
||||
</el-alert>
|
||||
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="源客户">{{ mergeSourceName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="目标客户">{{ mergeTargetName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="拜访记录">{{ mergePreview.visits || 0 }} 条</el-descriptions-item>
|
||||
<el-descriptions-item label="工作计划">{{ mergePreview.work_plans || 0 }} 条</el-descriptions-item>
|
||||
<el-descriptions-item label="商机跟单">{{ mergePreview.mini_business || 0 }} 条</el-descriptions-item>
|
||||
<el-descriptions-item label="要客拜访">{{ mergePreview.key_visits || 0 }} 条</el-descriptions-item>
|
||||
<el-descriptions-item label="联系人">{{ mergePreview.contacts || 0 }} 人</el-descriptions-item>
|
||||
<el-descriptions-item label="经理分配">{{ mergePreview.assignments || 0 }} 条</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div v-if="mergePreview.note" style="margin-top:12px; color:var(--amber); font-size:13px">
|
||||
⚠ {{ mergePreview.note }}
|
||||
</div>
|
||||
<div style="margin-top:12px; color:var(--vermilion); font-size:13px">
|
||||
合并后「{{ mergeSourceName }}」将被删除,此操作不可撤销。
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="mergeDialogVisible = false">取消</el-button>
|
||||
<el-button type="danger" :loading="mergeLoading" @click="handleMerge">确认合并</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { dashboardApi } from '@/api/dashboard'
|
||||
@@ -21,51 +21,6 @@ const planDate = ref(todayStr())
|
||||
const planContent = ref('')
|
||||
const planSaving = ref(false)
|
||||
|
||||
// ── Batch selection ──
|
||||
const selectedCards = ref<Set<string>>(new Set())
|
||||
const batchDialogVisible = ref(false)
|
||||
const batchPlanDate = ref(todayStr())
|
||||
const batchPlanContent = ref('')
|
||||
const batchSaving = ref(false)
|
||||
|
||||
watch(expandedManagers, () => { selectedCards.value.clear() })
|
||||
|
||||
function toggleCardSelect(customerId: string) {
|
||||
const s = new Set(selectedCards.value)
|
||||
if (s.has(customerId)) s.delete(customerId)
|
||||
else s.add(customerId)
|
||||
selectedCards.value = s
|
||||
}
|
||||
|
||||
function openBatchDialog() {
|
||||
if (selectedCards.value.size === 0) { ElMessage.warning('请先勾选客户卡片'); return }
|
||||
batchPlanContent.value = ''
|
||||
batchPlanDate.value = todayStr()
|
||||
batchDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleBatchCreate() {
|
||||
if (!batchPlanContent.value.trim()) { ElMessage.warning('请输入计划内容'); return }
|
||||
batchSaving.value = true
|
||||
let created = 0
|
||||
for (const cid of selectedCards.value) {
|
||||
try {
|
||||
await api.post('/work-plans/', {
|
||||
customer_id: cid,
|
||||
plan_content: batchPlanContent.value.trim(),
|
||||
plan_date: batchPlanDate.value,
|
||||
status: '计划中',
|
||||
})
|
||||
created++
|
||||
} catch (_) { /* continue */ }
|
||||
}
|
||||
ElMessage.success(`已为 ${created} 个客户制定计划`)
|
||||
batchDialogVisible.value = false
|
||||
selectedCards.value.clear()
|
||||
batchSaving.value = false
|
||||
await loadData()
|
||||
}
|
||||
|
||||
const referenceMonth = computed(() => {
|
||||
const d = new Date()
|
||||
d.setMonth(d.getMonth() + monthOffset.value)
|
||||
@@ -204,14 +159,7 @@ const statusLabel: Record<string, string> = { green: '本月已拜访', yellow:
|
||||
</div>
|
||||
|
||||
<div v-if="expandedManagers.has(m.manager_id)" class="customer-grid">
|
||||
<div class="batch-actions" v-if="selectedCards.size > 0" style="width:100%;margin-bottom:8px">
|
||||
<el-button type="primary" size="small" @click="openBatchDialog">📋 批量制定计划 ({{ selectedCards.size }}个)</el-button>
|
||||
<el-button size="small" @click="selectedCards.clear()">取消选择</el-button>
|
||||
</div>
|
||||
<div v-for="cust in m.customers" :key="cust.customer_id" class="customer-card" :class="['customer-card--' + cust.status, { 'card-selected': selectedCards.has(cust.customer_id) }]" @click="openCustomerDialog(cust)">
|
||||
<div v-if="cust.status !== 'green' && cust.status !== 'gray'" class="card-check" @click.stop="toggleCardSelect(cust.customer_id)">
|
||||
<span v-if="selectedCards.has(cust.customer_id)">☑</span><span v-else>☐</span>
|
||||
</div>
|
||||
<div v-for="cust in m.customers" :key="cust.customer_id" class="customer-card" :class="`customer-card--${cust.status}`" @click="openCustomerDialog(cust)">
|
||||
<div class="card-status-stripe"></div>
|
||||
<div class="card-body">
|
||||
<div class="card-name-row">
|
||||
@@ -322,23 +270,6 @@ const statusLabel: Record<string, string> = { green: '本月已拜访', yellow:
|
||||
</el-link>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ═══ Batch Plan Dialog ═══ -->
|
||||
<el-dialog v-model="batchDialogVisible" title="批量制定拜访计划" width="480px">
|
||||
<p style="margin:0 0 12px;color:var(--c-text-muted)">将为 <strong>{{ selectedCards.size }}</strong> 个客户统一制定拜访计划:</p>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="计划时间">
|
||||
<el-date-picker v-model="batchPlanDate" type="date" style="width:100%" value-format="YYYY-MM-DD" />
|
||||
</el-form-item>
|
||||
<el-form-item label="计划内容">
|
||||
<el-input v-model="batchPlanContent" type="textarea" :rows="3" placeholder="统一的拜访计划内容" :disabled="batchSaving" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="batchDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="batchSaving" @click="handleBatchCreate">批量制定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -445,15 +376,4 @@ const statusLabel: Record<string, string> = { green: '本月已拜访', yellow:
|
||||
.dlg-plan-item.dlg-plan-overdue { background: #FBF1EE; margin: 2px -4px; padding: 4px; border-radius: 4px; }
|
||||
.dlg-plan-form { background: var(--c-bg-light, #faf9f6); padding: 10px 12px; border-radius: 6px; }
|
||||
.plan-form-row { display: flex; gap: 8px; align-items: center; }
|
||||
|
||||
/* ═══ Card Checkbox ═══ */
|
||||
.card-check {
|
||||
position: absolute; top: 4px; right: 4px;
|
||||
width: 22px; height: 22px; display: flex; align-items: center; justify-content: center;
|
||||
cursor: pointer; font-size: 14px; color: var(--warm-gray);
|
||||
border-radius: 4px; background: rgba(255,255,255,0.8);
|
||||
z-index: 2;
|
||||
}
|
||||
.card-check:hover { color: var(--ink); background: rgba(196,147,74,0.1); }
|
||||
.customer-card.card-selected { border-color: var(--gold); box-shadow: 0 0 0 2px rgba(196,147,74,0.2); }
|
||||
</style>
|
||||
|
||||
@@ -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(() => {
|
||||
</el-form-item>
|
||||
<el-form-item label="拜访人姓名"><el-input v-model="form.visitor_name" placeholder="实际拜访人姓名" /></el-form-item>
|
||||
<el-form-item label="拜访人电话"><el-input v-model="form.visitor_phone" placeholder="联系电话(可选)" /></el-form-item>
|
||||
<el-form-item label="同访人员">
|
||||
<el-select v-model="form.companions" multiple filterable allow-create default-first-option placeholder="选择或输入姓名(可多选)" style="width:100%">
|
||||
<el-option v-for="u in allUsers" :key="u.id" :label="u.name" :value="u.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="沟通内容"><el-input v-model="form.communication_content" type="textarea" :rows="3" /></el-form-item>
|
||||
<el-form-item label="客户需求"><el-input v-model="form.customer_demand" type="textarea" :rows="2" /></el-form-item>
|
||||
<el-form-item v-if="dialogMode === 'edit' && form.photos?.length" label="照片">
|
||||
|
||||
@@ -178,7 +178,20 @@ async function handleImportPreview() {
|
||||
</div>
|
||||
<div v-if="importResult" style="margin-top:12px">
|
||||
<el-alert type="success" :closable="false">
|
||||
导入完成:拜访 {{ importResult.visits }} 条,跳过 {{ importResult.skipped }} 条
|
||||
导入完成:拜访 {{ importResult.visits || 0 }} 条、纪要 {{ importResult.daily_notes || 0 }} 条、计划 {{ importResult.work_plans || 0 }} 条、商机 {{ importResult.mini_business || 0 }} 条、要客 {{ importResult.key_visits || 0 }} 条
|
||||
<template v-if="importResult.customers_created">,自动创建客户 {{ importResult.customers_created }} 个</template>
|
||||
<template v-if="importResult.external_companions">,外部同访人 {{ importResult.external_companions }} 人</template>
|
||||
<template v-if="importResult.skipped">,跳过 {{ importResult.skipped }} 条</template>
|
||||
<template v-if="importResult.name_corrections?.length">
|
||||
<div style="margin-top:6px; font-size:12px; color:var(--amber)">
|
||||
<div v-for="(r, i) in importResult.name_corrections.slice(0, 10)" :key="'nc'+i">🔧 {{ r }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="importResult.customers_created_names?.length">
|
||||
<div style="margin-top:4px; font-size:12px; color:var(--sage)">
|
||||
🆕 新建客户:{{ importResult.customers_created_names.join('、') }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="importResult.skip_reasons?.length">
|
||||
<div style="margin-top:8px; font-size:12px; max-height:200px; overflow-y:auto">
|
||||
<div v-for="(r, i) in importResult.skip_reasons.slice(0, 20)" :key="i">• {{ r }}</div>
|
||||
|
||||
@@ -38,29 +38,40 @@ const photoUrls = ref<Record<string, string>>({})
|
||||
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<string>()
|
||||
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(() => {
|
||||
<el-table-column prop="time_range" label="时间" width="100" />
|
||||
<el-table-column prop="communication_content" label="沟通内容" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="customer_demand" label="客户需求" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="客户经理" width="100">
|
||||
<el-table-column label="相关人员" width="120">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.manager_name }}</span>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:2px">
|
||||
<span>{{ row.manager_name }}</span>
|
||||
<span v-for="n in (row.companion_names_resolved || [])" :key="n" style="color:var(--warm-gray);font-size:12px">, {{ n }}</span>
|
||||
</div>
|
||||
<el-tooltip v-if="row.edit_log?.length > 1" placement="top">
|
||||
<template #content>最后编辑:{{ row.edit_log[row.edit_log.length-1].editor }} · {{ row.edit_log.length-1 }}次修改</template>
|
||||
<span class="edit-indicator" title="有过修改">🕐</span>
|
||||
@@ -359,7 +375,7 @@ const notesByDate = computed(() => {
|
||||
</el-table-column>
|
||||
<el-table-column prop="content" label="工作内容" min-width="300" show-overflow-tooltip />
|
||||
<el-table-column prop="time_range" label="时间" width="100" />
|
||||
<el-table-column prop="manager_name" label="客户经理" width="80" />
|
||||
<el-table-column prop="manager_name" label="填报人" width="80" />
|
||||
</el-table>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
<el-form-item>
|
||||
<template #label>
|
||||
<span class="form-label">同访人员</span>
|
||||
<span class="form-label">同访人员 <span class="form-label-hint">可输入外部人员</span></span>
|
||||
</template>
|
||||
<el-select v-model="form.companions" multiple filterable placeholder="选择同访人员" style="width:100%">
|
||||
<el-select v-model="form.companions" multiple filterable allow-create default-first-option placeholder="选择或输入姓名(可多选)" style="width:100%">
|
||||
<el-option v-for="m in managers" :key="m.id" :label="m.name" :value="m.id" :disabled="m.id === auth.userId" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
+14
-1
@@ -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'],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user