diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..927c6c4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,182 @@ +# CLAUDE.md — 企迹 (qiji) 政企周报管理系统 + +## 项目概述 + +面向中国电信政企客户经理团队,替代 Excel 的周报管理系统。覆盖每日拜访记录、下周工作计划、小微业务商机跟单、要客拜访计划四大模块。支持 PC 端(支局长/分管领导汇总查看)和移动端(客户经理外勤填报 + 企微入口)。 + +## 技术栈 + +| 层 | 技术 | +|---|------| +| 后端 | FastAPI + SQLAlchemy 2.0 (async) + Alembic | +| 数据库 | PostgreSQL (已有,复用) | +| 文件存储 | MinIO (已有,复用) | +| 认证 | Casdoor OIDC (已有) + 企业微信 OAuth | +| 前端 | Vue 3 + Vite + TypeScript + Element Plus | +| 状态管理 | Pinia | +| 定时任务 | APScheduler | + +## 项目结构 + +``` +qiji/ +├── docker-compose.yml # PostgreSQL + MinIO + backend +├── 政企周报管理系统-设计文档.md # 完整设计文档 +├── backend/ +│ ├── app/ +│ │ ├── main.py # FastAPI 入口 + lifespan + 路由挂载 +│ │ ├── config.py # pydantic-settings 配置 +│ │ ├── database.py # async engine + session +│ │ ├── models/ # 8 张表 (见下方数据模型) +│ │ ├── schemas/ # Pydantic 请求/响应 schema +│ │ ├── api/ # REST 路由 (12 个模块) +│ │ ├── services/ # 业务逻辑层 +│ │ ├── middleware/auth.py # JWT 鉴权 + 角色权限 +│ │ └── utils/security.py # JWT 签发/验证 +│ ├── alembic/ # 数据库迁移 +│ └── requirements.txt +├── frontend/ +│ ├── src/ +│ │ ├── router/index.ts # 路由 (mobile + desktop 两套布局) +│ │ ├── stores/auth.ts # Pinia 认证 store +│ │ ├── api/ # 9 个 Axios API 模块 +│ │ ├── views/ +│ │ │ ├── mobile/ # 移动端页面 (Home, VisitForm, ...) +│ │ │ └── desktop/ # PC 端页面 (Dashboard, WeeklyReport, ...) +│ │ └── components/ # MobileLayout + DesktopLayout +│ ├── nginx.conf # 生产 nginx 配置 +│ └── Dockerfile +``` + +## 数据模型 (8 张 PostgreSQL 表) + +| 表 | 核心字段 | +|----|---------| +| `users` | id, casdoor_id, name, role(manager/director/leader), wecom_userid | +| `customers` | id, name, industry, address, in_use_services, monthly_fee, remarks, created_by | +| `customer_contacts` | id, customer_id, name, phone, role_desc | +| `customer_assignments` | id, customer_id, manager_id, role(primary/assistant), assigned_by | +| `visits` | id, customer_id, visit_date, visit_method, time_range, communication_content, customer_demand, companions(UUID[]), photos(TEXT[]), manager_id | +| `work_plans` | id, customer_id, plan_content, plan_date, manager_id, status | +| `mini_business` | id, customer_id, product_type, amount, follow_up_detail, status, manager_id | +| `key_visits` | id, customer_id, urgency_level, description, progress_status, planned_date, planned_visitor, visit_target, manager_id | + +## 当前进度 + +### 已完成 ✅ + +- [x] **Phase 1: 项目骨架** — 完整目录结构,FastAPI + Vue 3 均可启动/构建 +- [x] **Phase 2: 数据模型** — 8 个 SQLAlchemy 模型 + Alembic 迁移配置 +- [x] **Phase 3: 认证系统** — JWT + Casdoor OIDC + 企微静默登录 + 角色中间件 +- [x] **Phase 4: 客户档案** — CRUD 全链路 API + PC 端管理页 + 归属分配/批量转移 +- [x] **Phase 5: 拜访记录** — CRUD API + MinIO 预签名直传 + 移动端填报表单 + 移动端首页 +- [x] **Phase 6: 计划/商机/要客** — 三个模块的 CRUD API + 移动端表单 +- [x] **Phase 7: 仪表盘与汇总** — 四卡统计 + 填报进度 + 四 Tab 周报 + 按人/客户筛选 + 时间轴 +- [x] **Phase 8: Excel 导入导出** — 导出四 sheet xlsx + 导入预览→确认→去重 +- [x] **Phase 9: 企业微信** — 静默登录 + 催办/公告推送 + 每日 18:00 定时检查 +- [x] **Phase 10: 部署** — Dockerfile × 2 + nginx.conf + docker-compose.yml +- [x] **Casdoor 对接** — 已配置 Casdoor OIDC,前后端 Client ID 统一,登录/回调正常 +- [x] **MinIO 对接** — 已配置预签名上传/下载,bucket 就绪 +- [x] **PostgreSQL 对接** — 8 张表自动创建,数据持久化正常 +- [x] **用户管理** — 支局长可在 PC 端「用户管理」页面修改任意用户角色 +- [x] **客户档案增强** — 详情含客户经理、点击名称查看、备注字段、收支费用(金额+单位)、联系人管理(新增/删除)、多选批量转移 +- [x] **客户导入导出** — 导出含全部字段 + 模板下载 + Excel 批量导入(自动去重匹配经理) +- [x] **路由修复** — Hash 改 HTML5 History 模式,Casdoor 回调正常 +- [x] **网络部署** — 后端监听 0.0.0.0:8002,前端监听 0.0.0.0:5173,防火墙已开放 + +### 待完善 + +- [ ] 实际对接企业微信(需填写 WECOM_CORP_ID/AGENT_ID/SECRET 等) +- [ ] 前端移动端/桌面端自动适配(目前路由分两套,user-agent 判断待完善) +- [ ] 客户查重前端集成(API 已有 `/customers/check-duplicate/{name}`) +- [ ] 缩略图生成策略(MinIO 端配置) +- [ ] 企微聊天侧边栏(设计文档标注为"后续扩展,首期不做") + +## 验证状态 + +| 检查项 | 结果 | +|--------|------| +| `import app.main` | ✅ 成功 | +| `vite build` | ✅ 构建成功,输出到 `dist/` | +| 后端运行 | ✅ `http://10.10.10.14:8002` | +| 前端运行 | ✅ `http://10.10.10.14:5173` | +| PostgreSQL | ✅ `10.10.10.14:5432/qiji` | +| MinIO | ✅ `10.10.10.13:17051`,bucket `qiji-photos` | +| Casdoor | ✅ `10.10.10.14:18000`,登录/回调正常 | +| 企微推送 | ⚠️ 待填写有效 Token/AESKey 后测试 | +| `docker-compose up` | ⚠️ 需要本地 PostgreSQL + MinIO 实例 | + +## 启动命令 + +### 第一步:配置环境变量 + +```bash +# 后端 +cd backend +cp .env.example .env +# 编辑 .env,填入 Casdoor/PostgreSQL/MinIO/企微 的真实连接信息 + +# 前端(Casdoor 登录用) +cd frontend +cat > .env << 'EOF' +VITE_CASDOOR_ENDPOINT=http://your-casdoor:18000 +VITE_CASDOOR_CLIENT_ID=your-client-id +EOF +``` + +配置项说明: + +| 配置块 | 说明 | 必须? | +|--------|------|--------| +| `DATABASE_URL` | PostgreSQL 连接串,已有基础设施填真实地址 | ✅ 必须 | +| `CASDOOR_*` | Casdoor OIDC 认证服务的地址、Client ID/Secret | ✅ 登录必须 | +| `MINIO_*` | MinIO 对象存储地址和密钥 | ✅ 照片上传必须 | +| `WECOM_*` | 企微自建应用的 Corp ID / Agent ID / Secret | ⚠️ 企微功能需要 | +| `SECRET_KEY` | JWT 签发密钥,生产环境务必修改 | ✅ 必须 | +| `CORS_ORIGINS` | 前端地址白名单 | ✅ 必须 | +| `VITE_CASDOOR_*` | 前端登录跳转 Casdoor 所需,必须与后端一致 | ✅ 必须 | + +### 第二步:启动 PostgreSQL + MinIO + +如果已有可用的 PostgreSQL 和 MinIO 实例,跳过此步。否则用 docker-compose 启动: + +```bash +# 在项目根目录执行 +docker-compose up -d postgres minio +``` + +### 第三步:启动后端 + +```bash +cd backend +uvicorn app.main:app --reload --port 8002 --host 0.0.0.0 +# 首次启动会自动创建数据库表(lifespan 中执行 Base.metadata.create_all) +# API 文档 → http://localhost:8002/docs +``` + +### 第四步:启动前端 + +```bash +cd frontend +npm run dev -- --host 0.0.0.0 +# 页面 → http://localhost:5173 +# vite proxy 自动将 /api/* 请求转发到 localhost:8002 +``` + +### 一键启动(生产模式) + +```bash +# 项目根目录 +docker-compose up -d +# 后端 :8000 + PostgreSQL :5432 + MinIO :9000 + :9001(console) +# 前端需单独部署到 nginx,见 frontend/Dockerfile + nginx.conf +``` + +## 关键设计决策 + +1. **双布局**: `/m/*` 移动端(底部 TabBar),`/*` PC 端(侧边栏菜单) +2. **照片上传**: 前端从 `/api/upload/presigned-url` 拿 PUT URL → 直传 MinIO → 表单只传 object key +3. **同访人员**: 选同访人后,后端自动创建一条内容为空的状态副本 +4. **权限**: JWT payload 含 `role`,`RoleChecker` 做接口级鉴权,前端 Pinia store 做 UI 级控制 +5. **企微静默登录**: wecom code → userid → 查 users 表 → 找到签 JWT / 未找到引导 Casdoor 绑定 +6. **前后端分离开发**: `vite.config.ts` proxy 转发 `/api` → `localhost:8000` diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..d9ca1c2 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,41 @@ +# ============================================ +# 企迹 (qiji) — 环境变量配置文件 +# 复制此文件为 .env 并填入真实值 +# ============================================ + +# ── 应用 ── +APP_NAME=企迹-政企周报管理系统 +DEBUG=true +SECRET_KEY=change-me-to-a-random-string-in-production + +# ── PostgreSQL (已有基础设施,填写实际连接信息) ── +DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/qiji + +# ── JWT ── +JWT_ALGORITHM=HS256 +JWT_EXPIRE_MINUTES=480 + +# ── Casdoor (已有,填写实际部署地址和密钥) ── +CASDOOR_ENDPOINT=http://your-casdoor-server:8000 +CASDOOR_CLIENT_ID=your-client-id +CASDOOR_CLIENT_SECRET=your-client-secret +# CASDOOR_CERTIFICATE= # 如果 Casdoor 使用自签名证书,填证书内容 +CASDOOR_ORG_NAME=qiji +CASDOOR_APPLICATION=qiji-weekly-report + +# ── MinIO (已有,填写实际部署地址和密钥) ── +MINIO_ENDPOINT=localhost:9000 +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin +MINIO_BUCKET=qiji-photos +MINIO_SECURE=false + +# ── 企业微信 (需在企微管理后台创建自建应用后获取) ── +WECOM_CORP_ID=your-corp-id +WECOM_AGENT_ID=your-agent-id +WECOM_SECRET=your-app-secret +WECOM_TOKEN=your-token +WECOM_ENCODING_AES_KEY=your-encoding-aes-key + +# ── CORS (前端地址) ── +CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"] diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..385ccd3 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..b18f0e5 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,36 @@ +[alembic] +script_location = alembic +sqlalchemy.url = postgresql://postgres:postgres@localhost:5432/qiji + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..100dd2d --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,57 @@ +import asyncio +from logging.config import fileConfig +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config +from alembic import context + +from app.database import Base +from app.models import * # noqa: import all models +from app.config import settings + +config = context.config +config.set_main_option("sqlalchemy.url", settings.DATABASE_URL) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + await connectable.dispose() + + +def run_migrations_online() -> None: + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..af9233c --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1,3 @@ +from fastapi import APIRouter + +router = APIRouter() diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py new file mode 100644 index 0000000..208636d --- /dev/null +++ b/backend/app/api/auth.py @@ -0,0 +1,93 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from app.database import get_db +from app.schemas.user import TokenResponse, WecomLoginRequest, CasdoorLoginRequest, WecomBindRequest +from app.services.auth import ( + exchange_casdoor_code, get_or_create_user_from_casdoor, + get_user_by_wecom_id, bind_wecom_user, build_token_for_user, +) +from app.services.wecom import wecom_client +from app.config import settings + +router = APIRouter(prefix="/auth", tags=["Authentication"]) + + +@router.post("/casdoor-login", response_model=TokenResponse) +async def casdoor_login(req: CasdoorLoginRequest, db: AsyncSession = Depends(get_db)): + """Standard Casdoor OIDC login — exchange code for userinfo, get or create user, return JWT.""" + userinfo = await exchange_casdoor_code(req.code) + if not userinfo: + raise HTTPException(status_code=400, detail="Failed to exchange code with Casdoor") + + casdoor_id = userinfo.get("sub") or userinfo.get("id") + if not casdoor_id: + raise HTTPException(status_code=400, detail="Invalid userinfo from Casdoor") + + name = userinfo.get("name") or userinfo.get("preferred_username") or casdoor_id + role = userinfo.get("role", "manager") + + user = await get_or_create_user_from_casdoor(db, casdoor_id, name, role) + token = build_token_for_user(user) + + return TokenResponse( + access_token=token, + user_id=str(user.id), + name=user.name, + role=user.role, + ) + + +@router.post("/wecom-login") +async def wecom_login(req: WecomLoginRequest, db: AsyncSession = Depends(get_db)): + """WeChat Work silent login — exchange wecom code for userid, find bound user, return JWT.""" + userinfo = await wecom_client.get_userinfo_by_code(req.code) + if not userinfo: + raise HTTPException(status_code=400, detail="Failed to exchange wecom code") + + wecom_userid = userinfo.get("UserId") or userinfo.get("userid") + if not wecom_userid: + raise HTTPException(status_code=400, detail="Could not get userid from wecom") + + user = await get_user_by_wecom_id(db, wecom_userid) + if user: + token = build_token_for_user(user) + return TokenResponse( + access_token=token, + user_id=str(user.id), + name=user.name, + role=user.role, + ) + + # Not bound yet — return a redirect URL to Casdoor for binding + casdoor_auth_url = ( + f"{settings.CASDOOR_ENDPOINT}/login/oauth/authorize" + f"?client_id={settings.CASDOOR_CLIENT_ID}" + f"&response_type=code" + f"&redirect_uri={settings.CORS_ORIGINS[0]}/bind-wecom" + f"&scope=openid+profile" + f"&state={wecom_userid}" + ) + return {"need_bind": True, "casdoor_url": casdoor_auth_url, "wecom_userid": wecom_userid} + + +@router.post("/bind-wecom") +async def bind_wecom(req: WecomBindRequest, db: AsyncSession = Depends(get_db)): + """Bind Casdoor account with WeChat Work userid after OIDC redirect.""" + userinfo = await exchange_casdoor_code(req.casdoor_code) + if not userinfo: + raise HTTPException(status_code=400, detail="Failed to exchange casdoor code") + + casdoor_id = userinfo.get("sub") or userinfo.get("id") + wecom_userid = req.wecom_userid or userinfo.get("state", "") + + user = await bind_wecom_user(db, casdoor_id, wecom_userid) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + token = build_token_for_user(user) + return TokenResponse( + access_token=token, + user_id=str(user.id), + name=user.name, + role=user.role, + ) diff --git a/backend/app/api/customers.py b/backend/app/api/customers.py new file mode 100644 index 0000000..768f2f4 --- /dev/null +++ b/backend/app/api/customers.py @@ -0,0 +1,507 @@ +import io +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.orm import selectinload +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.user import User +from app.schemas.customer import ( + CustomerCreate, CustomerUpdate, CustomerOut, CustomerListOut, CustomerListResponse, + ContactCreate, ContactOut, AssignmentCreate, AssignmentOut, BatchAssignRequest, +) + +router = APIRouter(prefix="/customers", tags=["Customers"]) + + +def _split_fee(fee: str) -> tuple[str, str]: + """Split '5000元/月' into ('5000', '元/月').""" + if not fee: + return ("", "") + for u in ["元/月", "元/年"]: + if fee.endswith(u): + return (fee[:-len(u)].strip(), u) + # Custom unit: separate trailing non-digit+non-space chars + m = __import__('re').match(r'^(.+?)\s*([^\d\s]+)$', fee) + if m: + return (m.group(1).strip(), m.group(2).strip()) + return (fee, "") + + +# ══════ Fixed-path routes (must come before /{customer_id}) ══════ + +@router.get("/", response_model=CustomerListResponse) +async def list_customers( + search: Optional[str] = Query(None), + industry: Optional[str] = Query(None), + service: Optional[str] = Query(None), + manager_id: Optional[str] = Query(None), + page: int = Query(1, ge=1), + page_size: int = Query(25, ge=1, le=100), + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """List customers with filters and pagination. Managers only see their assigned.""" + 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}%")) + if service: + base_query = base_query.where(Customer.in_use_services.ilike(f"%{service}%")) + if manager_id: + assign_subq = select(CustomerAssignment.customer_id).where( + CustomerAssignment.manager_id == manager_id + ) + base_query = base_query.where(Customer.id.in_(assign_subq)) + if search: + contact_subq = select(CustomerContact.customer_id).where( + or_( + CustomerContact.name.ilike(f"%{search}%"), + CustomerContact.phone.ilike(f"%{search}%"), + ) + ) + base_query = base_query.where(or_( + Customer.name.ilike(f"%{search}%"), + Customer.industry.ilike(f"%{search}%"), + Customer.address.ilike(f"%{search}%"), + Customer.id.in_(contact_subq), + )) + + # Count total + count_query = select(func.count()).select_from(base_query.subquery()) + total = (await db.execute(count_query)).scalar() or 0 + + # Paginate + offset = (page - 1) * page_size + query = base_query.order_by(Customer.name).offset(offset).limit(page_size) + result = await db.execute(query) + + return CustomerListResponse( + items=result.scalars().all(), + total=total, + page=page, + page_size=page_size, + ) + + +@router.get("/export") +async def export_customers( + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Export all customers (name, industry, address, services, fee, contacts) as Excel.""" + from openpyxl import Workbook + result = await db.execute(select(Customer).options(selectinload(Customer.contacts))) + customers = result.scalars().all() + + # Build manager lookup + mgr_result = await db.execute( + select(CustomerAssignment.customer_id, User.name) + .join(User, CustomerAssignment.manager_id == User.id) + .where(CustomerAssignment.role == "primary") + ) + mgr_map = {str(cid): name for cid, name in mgr_result.all()} + + wb = Workbook() + ws = wb.active + ws.title = "客户档案" + ws.append(["单位名称", "所属行业", "单位地址", "在用业务", "收支费用-金额", "收支费用-单位", "客户经理", "备注", "联系人姓名", "联系人电话", "联系人角色"]) + for c in customers: + mgr_name = mgr_map.get(str(c.id), "") + amt, unit = _split_fee(c.monthly_fee) + if c.contacts: + for ct in c.contacts: + ws.append([c.name, c.industry, c.address, c.in_use_services, amt, unit, mgr_name, c.remarks or "", ct.name, ct.phone, ct.role_desc]) + else: + ws.append([c.name, c.industry, c.address, c.in_use_services, amt, unit, mgr_name, c.remarks or "", "", "", ""]) + for col_cells in ws.columns: + ws.column_dimensions[col_cells[0].column_letter].width = 22 + + output = io.BytesIO() + wb.save(output) + output.seek(0) + return StreamingResponse( + output, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": "attachment; filename=customers.xlsx"}, + ) + + +@router.get("/template") +async def download_import_template(): + """Download a blank customer import template (public, no auth required for download).""" + from openpyxl import Workbook + wb = Workbook() + ws = wb.active + ws.title = "客户档案导入模板" + ws.append(["单位名称*", "所属行业", "单位地址", "在用业务", "收支费用-金额", "收支费用-单位", "客户经理", "备注", "联系人姓名", "联系人电话", "联系人角色"]) + ws.append(["XX科技有限公司", "信息技术", "XX市XX路100号", "云桌面、专线", "5000", "元/月", "韦柳柏", "重点客户,季度回访", "张三", "13800000000", "技术负责人"]) + for col_cells in ws.columns: + ws.column_dimensions[col_cells[0].column_letter].width = 22 + + output = io.BytesIO() + wb.save(output) + output.seek(0) + return StreamingResponse( + output, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": "attachment; filename=customer_import_template.xlsx"}, + ) + + +@router.post("/import") +async def import_customers( + file: UploadFile = File(...), + current_user: dict = Depends(require_director), + db: AsyncSession = Depends(get_db), +): + """Import customers from Excel file. Director only.""" + import uuid as uuid_mod + content = await file.read() + try: + wb = openpyxl.load_workbook(io.BytesIO(content), data_only=True) + except Exception as e: + raise HTTPException(status_code=400, detail=f"Excel 解析失败: {str(e)}") + + ws = wb.active + created, skipped = 0, 0 + errors = [] + + # Build user name → id lookup + user_rows = await db.execute(select(User.name, User.id).where(User.role == "manager")) + user_map = {name: uid for name, uid in user_rows.all()} + default_user_id = uuid_mod.UUID(current_user["user_id"]) + + for row_idx, row in enumerate(ws.iter_rows(min_row=2, values_only=True), start=2): + if not row or not row[0]: + continue + name = str(row[0]).strip() if row[0] else "" + if not name: + continue + industry = str(row[1]).strip() if len(row) > 1 and row[1] else "" + address = str(row[2]).strip() if len(row) > 2 and row[2] else "" + services = str(row[3]).strip() if len(row) > 3 and row[3] else "" + fee_amt = str(row[4]).strip() if len(row) > 4 and row[4] else "" + fee_unit = str(row[5]).strip() if len(row) > 5 and row[5] else "" + fee = (fee_amt + fee_unit).strip() if fee_amt else "" + mgr_name = str(row[6]).strip() if len(row) > 6 and row[6] else "" + remarks = str(row[7]).strip() if len(row) > 7 and row[7] else "" + contact_name = str(row[8]).strip() if len(row) > 8 and row[8] else "" + contact_phone = str(row[9]).strip() if len(row) > 9 and row[9] else "" + contact_role = str(row[10]).strip() if len(row) > 10 and row[10] else "" + + existing = await db.execute(select(Customer).where(Customer.name == name)) + if existing.scalar_one_or_none(): + skipped += 1 + continue + + # Resolve manager: by name from template, or fallback to current user + assignee_id = user_map.get(mgr_name, default_user_id) + + try: + customer = Customer( + name=name, industry=industry, address=address, + in_use_services=services, monthly_fee=fee, + remarks=remarks, + created_by=default_user_id, + ) + db.add(customer) + await db.flush() + + if contact_name: + db.add(CustomerContact(customer_id=customer.id, name=contact_name, phone=contact_phone, role_desc=contact_role)) + + db.add(CustomerAssignment( + customer_id=customer.id, manager_id=assignee_id, + role="primary", assigned_by=default_user_id, + )) + created += 1 + except Exception as e: + errors.append(f"第{row_idx}行: {str(e)}") + + await db.commit() + return {"created": created, "skipped": skipped, "errors": errors} + + +@router.get("/check-duplicate/{name}") +async def check_duplicate( + name: str, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Check for duplicate customer names before creating.""" + result = await db.execute( + select(Customer.id, Customer.name, Customer.industry) + .where(Customer.name.ilike(f"%{name}%")) + .limit(10) + ) + matches = [{"id": str(r.id), "name": r.name, "industry": r.industry} for r in result.all()] + return {"matches": matches} + + +@router.post("/batch-assign") +async def batch_assign( + data: BatchAssignRequest, + current_user: dict = Depends(require_director), + db: AsyncSession = Depends(get_db), +): + """Batch transfer customers to a new manager.""" + import uuid as uuid_mod + for cid in data.customer_ids: + result = await db.execute( + select(CustomerAssignment).where( + CustomerAssignment.customer_id == cid, + CustomerAssignment.role == "primary", + ) + ) + all_rows = result.all() + if all_rows: + # Update the first one, delete any duplicates + first = all_rows[0][0] + first.manager_id = data.manager_id + first.assigned_by = uuid_mod.UUID(current_user["user_id"]) + for dup in all_rows[1:]: + await db.delete(dup[0]) + else: + db.add(CustomerAssignment( + customer_id=cid, manager_id=data.manager_id, + role="primary", assigned_by=uuid_mod.UUID(current_user["user_id"]), + )) + await db.commit() + return {"detail": f"Assigned {len(data.customer_ids)} customers"} + + +@router.post("/quick-create") +async def quick_create_customer( + name: str = Query(...), + current_user: dict = Depends(require_any_role), + db: AsyncSession = Depends(get_db), +): + """Quick-create a customer with just a name. Returns the new customer.""" + import uuid + customer = Customer(name=name, created_by=uuid.UUID(current_user["user_id"])) + db.add(customer) + await db.flush() + db.add(CustomerAssignment( + customer_id=customer.id, manager_id=uuid.UUID(current_user["user_id"]), + role="primary", assigned_by=uuid.UUID(current_user["user_id"]), + )) + await db.commit() + return {"id": str(customer.id), "name": customer.name} + + +# ══════ Parameterized routes (/{customer_id}) ══════ + +@router.get("/{customer_id}", response_model=CustomerOut) +async def get_customer( + customer_id: str, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(Customer).where(Customer.id == customer_id).options(selectinload(Customer.contacts)) + ) + customer = result.scalar_one_or_none() + if not customer: + raise HTTPException(status_code=404, detail="Customer not found") + + # Load primary manager name + from sqlalchemy.orm import selectinload as sl + assign_result = await db.execute( + select(CustomerAssignment, User.name).join(User, CustomerAssignment.manager_id == User.id) + .where(CustomerAssignment.customer_id == customer_id, CustomerAssignment.role == "primary") + ) + row = assign_result.first() + manager_name = row[1] if row else None + + # Attach to response via a dict + out = { + "id": customer.id, "name": customer.name, "industry": customer.industry, + "address": customer.address, "in_use_services": customer.in_use_services, + "monthly_fee": customer.monthly_fee, "remarks": customer.remarks or "", + "created_by": customer.created_by, + "created_at": customer.created_at, "updated_at": customer.updated_at, + "contacts": customer.contacts, "primary_manager_name": manager_name, + } + return out + + +@router.post("/", response_model=CustomerOut) +async def create_customer( + data: CustomerCreate, + current_user: dict = Depends(require_any_role), + db: AsyncSession = Depends(get_db), +): + """Create a new customer with optional contacts and manager assignment.""" + import uuid + customer = Customer( + name=data.name, industry=data.industry, address=data.address, + in_use_services=data.in_use_services, monthly_fee=data.monthly_fee, + remarks=data.remarks, + created_by=uuid.UUID(current_user["user_id"]), + ) + db.add(customer) + await db.flush() + + for contact_data in data.contacts: + if not contact_data.name.strip(): + continue + db.add(CustomerContact( + customer_id=customer.id, + name=contact_data.name.strip(), + phone=contact_data.phone.strip(), + role_desc=contact_data.role_desc.strip(), + )) + + assignee_id = data.assignee_id or uuid.UUID(current_user["user_id"]) + db.add(CustomerAssignment( + customer_id=customer.id, manager_id=assignee_id, + role="primary", assigned_by=uuid.UUID(current_user["user_id"]), + )) + await db.commit() + + result = await db.execute( + select(Customer).where(Customer.id == customer.id).options(selectinload(Customer.contacts)) + ) + return result.scalar_one() + + +@router.put("/{customer_id}", response_model=CustomerOut) +async def update_customer( + customer_id: str, data: CustomerUpdate, + current_user: dict = Depends(require_any_role), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(Customer).where(Customer.id == customer_id).options(selectinload(Customer.contacts)) + ) + customer = result.scalar_one_or_none() + if not customer: + raise HTTPException(status_code=404, detail="Customer not found") + + update_data = data.model_dump(exclude_unset=True) + assignee_id = update_data.pop("assignee_id", None) # Handle separately + + for key, value in update_data.items(): + setattr(customer, key, value) + + # Update primary manager assignment if changed (director only) + 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"]) + else: + db.add(CustomerAssignment( + customer_id=customer.id, manager_id=assignee_id, + role="primary", assigned_by=uuid_mod.UUID(current_user["user_id"]), + )) + + await db.commit() + + result = await db.execute( + select(Customer).where(Customer.id == customer.id).options(selectinload(Customer.contacts)) + ) + return result.scalar_one() + + +@router.delete("/{customer_id}") +async def delete_customer( + customer_id: str, + current_user: dict = Depends(require_director), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(Customer).where(Customer.id == customer_id)) + customer = result.scalar_one_or_none() + if not customer: + raise HTTPException(status_code=404, detail="Customer not found") + await db.delete(customer) + await db.commit() + return {"detail": "deleted"} + + +# ── Contacts ── + +@router.post("/{customer_id}/contacts", response_model=ContactOut) +async def add_contact( + customer_id: str, data: ContactCreate, + current_user: dict = Depends(require_any_role), + db: AsyncSession = Depends(get_db), +): + contact = CustomerContact(customer_id=customer_id, name=data.name, phone=data.phone, role_desc=data.role_desc) + db.add(contact) + await db.commit() + await db.refresh(contact) + return contact + + +@router.delete("/{customer_id}/contacts/{contact_id}") +async def delete_contact( + customer_id: str, contact_id: str, + current_user: dict = Depends(require_any_role), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(CustomerContact).where(CustomerContact.id == contact_id, CustomerContact.customer_id == customer_id) + ) + contact = result.scalar_one_or_none() + if not contact: + raise HTTPException(status_code=404, detail="Contact not found") + await db.delete(contact) + await db.commit() + return {"detail": "deleted"} + + +# ── Assignments ── + +@router.get("/{customer_id}/assignments", response_model=list[AssignmentOut]) +async def list_assignments( + customer_id: str, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(CustomerAssignment).where(CustomerAssignment.customer_id == customer_id)) + return result.scalars().all() + + +@router.post("/{customer_id}/assignments", response_model=AssignmentOut) +async def assign_manager( + customer_id: str, data: AssignmentCreate, + current_user: dict = Depends(require_director), + db: AsyncSession = Depends(get_db), +): + assignment = CustomerAssignment( + customer_id=customer_id, manager_id=data.manager_id, + role=data.role, assigned_by=current_user["user_id"], + ) + db.add(assignment) + await db.commit() + await db.refresh(assignment) + return assignment diff --git a/backend/app/api/daily_notes.py b/backend/app/api/daily_notes.py new file mode 100644 index 0000000..2f5df98 --- /dev/null +++ b/backend/app/api/daily_notes.py @@ -0,0 +1,131 @@ +import uuid +from datetime import date +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from app.database import get_db +from app.middleware.auth import get_current_user, require_any_role +from app.models.daily_note import DailyNote +from app.models.user import User +from app.schemas.daily_note import DailyNoteCreate, DailyNoteUpdate, DailyNoteOut +from app.utils.timezone import today_cst, parse_date + +router = APIRouter(prefix="/daily-notes", tags=["DailyNotes"]) + + +async def _enrich(note: DailyNote, db: AsyncSession) -> dict: + mgr = await db.execute(select(User.name).where(User.id == note.manager_id)) + return { + "id": note.id, "manager_id": note.manager_id, + "note_date": note.note_date, "category": note.category, + "content": note.content, "time_range": note.time_range, + "created_at": note.created_at, "updated_at": note.updated_at, + "manager_name": mgr.scalar_one_or_none(), + } + + +@router.get("/") +async def list_notes( + date_from: Optional[str] = Query(None), + date_to: Optional[str] = Query(None), + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + query = select(DailyNote) + if current_user["role"] == "manager": + query = query.where(DailyNote.manager_id == uuid.UUID(current_user["user_id"])) + if date_from: + query = query.where(DailyNote.note_date >= parse_date(date_from)) + if date_to: + query = query.where(DailyNote.note_date <= parse_date(date_to)) + query = query.order_by(DailyNote.note_date.desc(), DailyNote.created_at.desc()).limit(100) + result = await db.execute(query) + return [await _enrich(n, db) for n in result.scalars().all()] + + +@router.get("/today") +async def list_today_notes( + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + query = select(DailyNote).where(DailyNote.note_date == today_cst()) + if current_user["role"] == "manager": + query = query.where(DailyNote.manager_id == uuid.UUID(current_user["user_id"])) + result = await db.execute(query) + notes = [await _enrich(n, db) for n in result.scalars().all()] + return {"count": len(notes), "notes": notes} + + +@router.get("/{note_id}") +async def get_note( + note_id: str, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(DailyNote).where(DailyNote.id == note_id)) + note = result.scalar_one_or_none() + if not note: + raise HTTPException(status_code=404, detail="Not found") + if current_user["role"] == "manager" and str(note.manager_id) != current_user["user_id"]: + raise HTTPException(status_code=403, detail="Access denied") + return await _enrich(note, db) + + +@router.post("/") +async def create_note( + data: DailyNoteCreate, + current_user: dict = Depends(require_any_role), + db: AsyncSession = Depends(get_db), +): + note = DailyNote( + manager_id=uuid.UUID(current_user["user_id"]), + note_date=parse_date(data.note_date), + category=data.category, + content=data.content, + time_range=data.time_range, + ) + db.add(note) + await db.commit() + await db.refresh(note) + return await _enrich(note, db) + + +@router.put("/{note_id}") +async def update_note( + note_id: str, data: DailyNoteUpdate, + current_user: dict = Depends(require_any_role), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(DailyNote).where(DailyNote.id == note_id)) + note = result.scalar_one_or_none() + if not note: + raise HTTPException(status_code=404, detail="Not found") + if current_user["role"] == "manager" and str(note.manager_id) != current_user["user_id"]: + raise HTTPException(status_code=403, detail="Access denied") + + update_data = data.model_dump(exclude_unset=True) + if "note_date" in update_data and update_data["note_date"]: + update_data["note_date"] = parse_date(update_data["note_date"]) + for k, v in update_data.items(): + setattr(note, k, v) + await db.commit() + await db.refresh(note) + return await _enrich(note, db) + + +@router.delete("/{note_id}") +async def delete_note( + note_id: str, + current_user: dict = Depends(require_any_role), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(DailyNote).where(DailyNote.id == note_id)) + note = result.scalar_one_or_none() + if not note: + raise HTTPException(status_code=404, detail="Not found") + if current_user["role"] == "manager" and str(note.manager_id) != current_user["user_id"]: + raise HTTPException(status_code=403, detail="Access denied") + await db.delete(note) + await db.commit() + return {"detail": "deleted"} diff --git a/backend/app/api/dashboard.py b/backend/app/api/dashboard.py new file mode 100644 index 0000000..bff1ec7 --- /dev/null +++ b/backend/app/api/dashboard.py @@ -0,0 +1,45 @@ +import uuid +from typing import Optional +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession +from app.database import get_db +from app.middleware.auth import get_current_user +from app.services.dashboard import get_dashboard_stats, get_reporting_progress, get_weekly_report + +router = APIRouter(prefix="/dashboard", tags=["Dashboard"]) + + +@router.get("/stats") +async def dashboard_stats( + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Get dashboard card statistics for the current week.""" + stats = await get_dashboard_stats(db) + return stats + + +@router.get("/progress") +async def reporting_progress( + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Get per-manager reporting progress.""" + return await get_reporting_progress(db) + + +@router.get("/weekly-report") +async def weekly_report( + manager_id: Optional[str] = Query(None), + customer_id: Optional[str] = Query(None), + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Get full weekly report data (four modules).""" + return await get_weekly_report( + db=db, + user_id=uuid.UUID(current_user["user_id"]), + role=current_user["role"], + filter_manager_id=uuid.UUID(manager_id) if manager_id else None, + filter_customer_id=uuid.UUID(customer_id) if customer_id else None, + ) diff --git a/backend/app/api/export.py b/backend/app/api/export.py new file mode 100644 index 0000000..35ef63c --- /dev/null +++ b/backend/app/api/export.py @@ -0,0 +1,22 @@ +from fastapi import APIRouter, Depends +from fastapi.responses import StreamingResponse +from sqlalchemy.ext.asyncio import AsyncSession +from app.database import get_db +from app.middleware.auth import get_current_user, require_director_or_leader +from app.services.excel_export import export_weekly_report + +router = APIRouter(prefix="/export", tags=["Export"]) + + +@router.get("/weekly-report") +async def download_weekly_report( + current_user: dict = Depends(require_director_or_leader), + db: AsyncSession = Depends(get_db), +): + """Export this week's report as a 4-sheet .xlsx file.""" + excel_bytes = await export_weekly_report(db) + return StreamingResponse( + excel_bytes, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": "attachment; filename=weekly_report.xlsx"}, + ) diff --git a/backend/app/api/import_data.py b/backend/app/api/import_data.py new file mode 100644 index 0000000..e0ff7ad --- /dev/null +++ b/backend/app/api/import_data.py @@ -0,0 +1,43 @@ +import uuid +from fastapi import APIRouter, Depends, File, UploadFile, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from app.database import get_db +from app.middleware.auth import get_current_user, require_director +from app.services.excel_import import import_from_excel + +router = APIRouter(prefix="/import", tags=["Import"]) + + +@router.post("/weekly-report") +async def import_weekly_report( + file: UploadFile = File(...), + current_user: dict = Depends(require_director), + db: AsyncSession = Depends(get_db), +): + """Upload old weekly report Excel and import data. Only director can do this.""" + if not file.filename or not file.filename.endswith(('.xlsx', '.xls')): + raise HTTPException(status_code=400, detail="Only .xlsx and .xls files are supported") + + content = await file.read() + + # Preview first: parse headers + try: + import openpyxl + wb = openpyxl.load_workbook(io.BytesIO(content), data_only=True) + preview = {} + for sheet_name in wb.sheetnames: + ws = wb[sheet_name] + headers = [str(cell.value) for cell in ws[1]] + row_count = ws.max_row - 1 # minus header + preview[sheet_name] = {"headers": headers, "row_count": row_count} + except Exception as e: + raise HTTPException(status_code=400, detail=f"Failed to parse Excel: {str(e)}") + + # Import data + stats = await import_from_excel(db, content, uuid.UUID(current_user["user_id"])) + stats["preview"] = preview + + return stats + + +import io diff --git a/backend/app/api/key_visits.py b/backend/app/api/key_visits.py new file mode 100644 index 0000000..2fed5d3 --- /dev/null +++ b/backend/app/api/key_visits.py @@ -0,0 +1,107 @@ +import uuid +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from app.database import get_db +from app.middleware.auth import get_current_user, require_any_role +from app.models.key_visit import KeyVisit +from app.models.customer import Customer +from app.models.user import User +from app.schemas.key_visit import KeyVisitCreate, KeyVisitUpdate, KeyVisitOut + +router = APIRouter(prefix="/key-visits", tags=["KeyVisits"]) + + +async def _enrich(k: KeyVisit, db: AsyncSession) -> dict: + cust = await db.execute(select(Customer.name).where(Customer.id == k.customer_id)) + mgr = await db.execute(select(User.name).where(User.id == k.manager_id)) + return { + "id": str(k.id), + "customer_id": str(k.customer_id), + "customer_name": cust.scalar_one_or_none(), + "urgency_level": k.urgency_level, + "description": k.description, + "progress_status": k.progress_status, + "planned_date": k.planned_date, + "planned_visitor": k.planned_visitor, + "visit_target": k.visit_target, + "manager_id": str(k.manager_id), + "manager_name": mgr.scalar_one_or_none(), + } + + +@router.get("/") +async def list_key_visits( + customer_id: Optional[str] = Query(None), + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + query = select(KeyVisit) + if current_user["role"] == "manager": + query = query.where(KeyVisit.manager_id == uuid.UUID(current_user["user_id"])) + if customer_id: + query = query.where(KeyVisit.customer_id == uuid.UUID(customer_id)) + query = query.order_by(KeyVisit.planned_date.desc()).limit(200) + result = await db.execute(query) + return [await _enrich(k, db) for k in result.scalars().all()] + + +@router.post("/") +async def create_key_visit( + data: KeyVisitCreate, + current_user: dict = Depends(require_any_role), + db: AsyncSession = Depends(get_db), +): + k = KeyVisit( + customer_id=data.customer_id, + urgency_level=data.urgency_level, + description=data.description, + progress_status=data.progress_status, + planned_date=data.planned_date, + planned_visitor=data.planned_visitor, + visit_target=data.visit_target, + manager_id=uuid.UUID(current_user["user_id"]), + ) + db.add(k) + await db.commit() + await db.refresh(k) + return await _enrich(k, db) + + +@router.put("/{item_id}") +async def update_key_visit( + item_id: str, data: KeyVisitUpdate, + current_user: dict = Depends(require_any_role), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(KeyVisit).where(KeyVisit.id == item_id)) + k = result.scalar_one_or_none() + if not k: + raise HTTPException(status_code=404, detail="Not found") + if current_user["role"] == "manager" and str(k.manager_id) != current_user["user_id"]: + raise HTTPException(status_code=403, detail="Access denied") + + update_data = data.model_dump(exclude_unset=True) + for key, v in update_data.items(): + setattr(k, key, v) + await db.commit() + await db.refresh(k) + return await _enrich(k, db) + + +@router.delete("/{item_id}") +async def delete_key_visit( + item_id: str, + current_user: dict = Depends(require_any_role), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(KeyVisit).where(KeyVisit.id == item_id)) + k = result.scalar_one_or_none() + if not k: + raise HTTPException(status_code=404, detail="Not found") + if current_user["role"] == "manager" and str(k.manager_id) != current_user["user_id"]: + raise HTTPException(status_code=403, detail="Access denied") + await db.delete(k) + await db.commit() + return {"detail": "deleted"} diff --git a/backend/app/api/mini_business.py b/backend/app/api/mini_business.py new file mode 100644 index 0000000..95c9c37 --- /dev/null +++ b/backend/app/api/mini_business.py @@ -0,0 +1,105 @@ +import uuid +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from app.database import get_db +from app.middleware.auth import get_current_user, require_any_role +from app.models.mini_business import MiniBusiness +from app.models.customer import Customer +from app.models.user import User +from app.schemas.mini_business import MiniBusinessCreate, MiniBusinessUpdate, MiniBusinessOut + +router = APIRouter(prefix="/mini-business", tags=["MiniBusiness"]) + + +async def _enrich(m: MiniBusiness, db: AsyncSession) -> dict: + cust = await db.execute(select(Customer.name).where(Customer.id == m.customer_id)) + mgr = await db.execute(select(User.name).where(User.id == m.manager_id)) + return { + "id": str(m.id), + "customer_id": str(m.customer_id), + "customer_name": cust.scalar_one_or_none(), + "product_type": m.product_type, + "amount": m.amount, + "follow_up_detail": m.follow_up_detail, + "status": m.status, + "manager_id": str(m.manager_id), + "manager_name": mgr.scalar_one_or_none(), + "expected_revenue_date": m.expected_revenue_date, + } + + +@router.get("/") +async def list_mini_business( + customer_id: Optional[str] = Query(None), + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + query = select(MiniBusiness) + if current_user["role"] == "manager": + query = query.where(MiniBusiness.manager_id == uuid.UUID(current_user["user_id"])) + if customer_id: + query = query.where(MiniBusiness.customer_id == uuid.UUID(customer_id)) + query = query.order_by(MiniBusiness.expected_revenue_date.desc()).limit(200) + result = await db.execute(query) + return [await _enrich(m, db) for m in result.scalars().all()] + + +@router.post("/") +async def create_mini_business( + data: MiniBusinessCreate, + current_user: dict = Depends(require_any_role), + db: AsyncSession = Depends(get_db), +): + m = MiniBusiness( + customer_id=data.customer_id, + product_type=data.product_type, + amount=data.amount, + follow_up_detail=data.follow_up_detail, + status=data.status, + manager_id=uuid.UUID(current_user["user_id"]), + expected_revenue_date=data.expected_revenue_date, + ) + db.add(m) + await db.commit() + await db.refresh(m) + return await _enrich(m, db) + + +@router.put("/{item_id}") +async def update_mini_business( + item_id: str, data: MiniBusinessUpdate, + current_user: dict = Depends(require_any_role), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(MiniBusiness).where(MiniBusiness.id == item_id)) + m = result.scalar_one_or_none() + if not m: + raise HTTPException(status_code=404, detail="Not found") + if current_user["role"] == "manager" and str(m.manager_id) != current_user["user_id"]: + raise HTTPException(status_code=403, detail="Access denied") + + update_data = data.model_dump(exclude_unset=True) + for k, v in update_data.items(): + setattr(m, k, v) + await db.commit() + await db.refresh(m) + return await _enrich(m, db) + + +@router.delete("/{item_id}") +async def delete_mini_business( + item_id: str, + current_user: dict = Depends(require_any_role), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(MiniBusiness).where(MiniBusiness.id == item_id)) + m = result.scalar_one_or_none() + if not m: + raise HTTPException(status_code=404, detail="Not found") + if current_user["role"] == "manager" and str(m.manager_id) != current_user["user_id"]: + raise HTTPException(status_code=403, detail="Access denied") + await db.delete(m) + await db.commit() + return {"detail": "deleted"} diff --git a/backend/app/api/upload.py b/backend/app/api/upload.py new file mode 100644 index 0000000..c979403 --- /dev/null +++ b/backend/app/api/upload.py @@ -0,0 +1,36 @@ +import uuid +from fastapi import APIRouter, Depends +from app.middleware.auth import get_current_user, require_any_role +from app.services.minio_client import generate_presigned_upload_url, generate_presigned_download_url + +router = APIRouter(prefix="/upload", tags=["Upload"]) + + +@router.post("/presigned-url") +async def get_presigned_upload_url( + filename: str, + content_type: str = "image/jpeg", + current_user: dict = Depends(require_any_role), +): + """Get a presigned PUT URL for direct MinIO upload.""" + import datetime + today = datetime.date.today().isoformat() + user_id = current_user["user_id"][:8] + object_key = f"{today}/{user_id}/{uuid.uuid4()}.jpg" + + url = generate_presigned_upload_url(object_key) + + return { + "upload_url": url, + "object_key": object_key, + } + + +@router.get("/download-url") +async def get_presigned_download_url( + object_key: str, + current_user: dict = Depends(get_current_user), +): + """Get a presigned GET URL for viewing a photo (1 hour validity).""" + url = generate_presigned_download_url(object_key) + return {"download_url": url} diff --git a/backend/app/api/users.py b/backend/app/api/users.py new file mode 100644 index 0000000..346c97d --- /dev/null +++ b/backend/app/api/users.py @@ -0,0 +1,82 @@ +import uuid +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from app.database import get_db +from app.middleware.auth import get_current_user, require_director +from app.models.user import User +from app.schemas.customer import UserOut + +router = APIRouter(prefix="/users", tags=["Users"]) + + +class UpdateUserRoleRequest(BaseModel): + role: str # manager / director / leader + department: str = "" + + +@router.get("/", response_model=list[UserOut]) +async def list_users( + role: Optional[str] = Query(None), + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """List all users. Used for companion selection, assignment, etc.""" + query = select(User) + if role: + query = query.where(User.role == role) + query = query.order_by(User.name) + result = await db.execute(query) + return result.scalars().all() + + +@router.get("/me") +async def get_current_user_info( + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Get current user's full profile.""" + result = await db.execute(select(User).where(User.id == uuid.UUID(current_user["user_id"]))) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + return { + "id": str(user.id), + "casdoor_id": user.casdoor_id, + "name": user.name, + "role": user.role, + "department": user.department, + "wecom_userid": user.wecom_userid, + } + + +@router.put("/{user_id}/role") +async def update_user_role( + user_id: str, + data: UpdateUserRoleRequest, + current_user: dict = Depends(require_director), + db: AsyncSession = Depends(get_db), +): + """Director updates a user's role. Only director can do this.""" + if data.role not in ("manager", "director", "leader"): + raise HTTPException(status_code=400, detail="Invalid role. Must be manager/director/leader") + + result = await db.execute(select(User).where(User.id == uuid.UUID(user_id))) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + user.role = data.role + if data.department: + user.department = data.department + await db.commit() + await db.refresh(user) + + return { + "id": str(user.id), + "name": user.name, + "role": user.role, + "department": user.department, + } diff --git a/backend/app/api/visits.py b/backend/app/api/visits.py new file mode 100644 index 0000000..5f09e10 --- /dev/null +++ b/backend/app/api/visits.py @@ -0,0 +1,209 @@ +import uuid +from datetime import date, datetime +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +from app.database import get_db +from app.middleware.auth import get_current_user, require_any_role +from app.models.visit import Visit +from app.models.customer import Customer +from app.models.user import User +from app.schemas.visit import VisitCreate, VisitUpdate, VisitOut, VisitListOut +from app.utils.timezone import today_cst, parse_date +from app.services.minio_client import delete_objects + +router = APIRouter(prefix="/visits", tags=["Visits"]) + + +async def _enrich_visit(visit: Visit, db: AsyncSession) -> dict: + """Enrich a visit record with customer/manager names.""" + customer_name = None + manager_name = None + if visit.customer_id: + cust_result = await db.execute(select(Customer.name).where(Customer.id == visit.customer_id)) + customer_name = cust_result.scalar_one_or_none() + if visit.manager_id: + mgr_result = await db.execute(select(User.name).where(User.id == visit.manager_id)) + manager_name = mgr_result.scalar_one_or_none() + + return { + "id": str(visit.id), + "customer_id": str(visit.customer_id), + "customer_name": customer_name, + "visit_date": visit.visit_date, + "visit_method": visit.visit_method, + "time_range": visit.time_range, + "communication_content": visit.communication_content, + "customer_demand": visit.customer_demand, + "companions": visit.companions, + "photos": visit.photos, + "manager_id": str(visit.manager_id), + "manager_name": manager_name, + "created_at": str(visit.created_at), + "updated_at": str(visit.updated_at), + } + + +@router.get("/") +async def list_visits( + date_from: Optional[str] = Query(None), + date_to: Optional[str] = Query(None), + customer_id: Optional[str] = Query(None), + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """List visits. Managers see only their own, directors/leaders see all.""" + query = select(Visit) + + if current_user["role"] == "manager": + query = query.where(Visit.manager_id == uuid.UUID(current_user["user_id"])) + + if date_from: + query = query.where(Visit.visit_date >= parse_date(date_from)) + if date_to: + query = query.where(Visit.visit_date <= parse_date(date_to)) + if customer_id: + query = query.where(Visit.customer_id == uuid.UUID(customer_id)) + + query = query.order_by(Visit.visit_date.desc(), Visit.created_at.desc()).limit(200) + result = await db.execute(query) + visits = result.scalars().all() + + # Enrich + enriched = [] + for v in visits: + enriched.append(await _enrich_visit(v, db)) + return enriched + + +@router.get("/today") +async def list_today_visits( + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Get today's visits for the current user's mobile home screen.""" + query = select(Visit).where(Visit.visit_date == today_cst()) + + if current_user["role"] == "manager": + query = query.where(Visit.manager_id == uuid.UUID(current_user["user_id"])) + + query = query.order_by(Visit.created_at.desc()) + result = await db.execute(query) + visits = result.scalars().all() + + enriched = [] + for v in visits: + enriched.append(await _enrich_visit(v, db)) + + count = len(enriched) + return {"count": count, "visits": enriched} + + +@router.get("/{visit_id}") +async def get_visit( + visit_id: str, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(Visit).where(Visit.id == visit_id)) + visit = result.scalar_one_or_none() + if not visit: + raise HTTPException(status_code=404, detail="Visit not found") + + # Permission check + if current_user["role"] == "manager" and str(visit.manager_id) != current_user["user_id"]: + raise HTTPException(status_code=403, detail="Access denied") + + return await _enrich_visit(visit, db) + + +@router.post("/") +async def create_visit( + data: VisitCreate, + current_user: dict = Depends(require_any_role), + db: AsyncSession = Depends(get_db), +): + """Create a visit record. If companions are selected, creates draft copies for them.""" + visit = Visit( + customer_id=data.customer_id, + visit_date=parse_date(data.visit_date), + visit_method=data.visit_method, + time_range=data.time_range, + communication_content=data.communication_content, + customer_demand=data.customer_demand, + companions=data.companions, + photos=data.photos, + manager_id=uuid.UUID(current_user["user_id"]), + ) + db.add(visit) + + # Create draft copies for companions + for companion_id in data.companions: + if companion_id != uuid.UUID(current_user["user_id"]): + draft = Visit( + customer_id=data.customer_id, + visit_date=parse_date(data.visit_date), + visit_method=data.visit_method, + time_range=data.time_range, + communication_content="", # Leave blank for companion to fill + customer_demand="", + companions=[], + photos=[], + manager_id=companion_id, + ) + db.add(draft) + + await db.commit() + await db.refresh(visit) + return await _enrich_visit(visit, db) + + +@router.put("/{visit_id}") +async def update_visit( + visit_id: str, + data: VisitUpdate, + current_user: dict = Depends(require_any_role), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(Visit).where(Visit.id == visit_id)) + visit = result.scalar_one_or_none() + if not visit: + raise HTTPException(status_code=404, detail="Visit not found") + + if current_user["role"] == "manager" and str(visit.manager_id) != current_user["user_id"]: + raise HTTPException(status_code=403, detail="Access denied") + + update_data = data.model_dump(exclude_unset=True) + if "visit_date" in update_data and update_data["visit_date"]: + update_data["visit_date"] = parse_date(update_data["visit_date"]) + + for key, value in update_data.items(): + setattr(visit, key, value) + + await db.commit() + await db.refresh(visit) + return await _enrich_visit(visit, db) + + +@router.delete("/{visit_id}") +async def delete_visit( + visit_id: str, + current_user: dict = Depends(require_any_role), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(Visit).where(Visit.id == visit_id)) + visit = result.scalar_one_or_none() + if not visit: + raise HTTPException(status_code=404, detail="Visit not found") + + if current_user["role"] == "manager" and str(visit.manager_id) != current_user["user_id"]: + raise HTTPException(status_code=403, detail="Access denied") + + # Clean up photos in MinIO + if visit.photos: + delete_objects(visit.photos) + + await db.delete(visit) + await db.commit() + return {"detail": "deleted"} diff --git a/backend/app/api/wecom.py b/backend/app/api/wecom.py new file mode 100644 index 0000000..5f03204 --- /dev/null +++ b/backend/app/api/wecom.py @@ -0,0 +1,68 @@ +import uuid +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from app.database import get_db +from app.middleware.auth import get_current_user, require_director +from app.models.user import User +from app.services.wecom import wecom_client +from app.services.scheduler import check_daily_reporting + +router = APIRouter(prefix="/wecom", tags=["WeChatWork"]) + + +class RemindRequest(BaseModel): + user_ids: list[str] + message: Optional[str] = None + + +class AnnouncementRequest(BaseModel): + content: str + + +@router.post("/remind") +async def send_reminder( + data: RemindRequest, + current_user: dict = Depends(require_director), + db: AsyncSession = Depends(get_db), +): + """Director manually sends reminder to specific managers.""" + # Get wecom_userids for the selected users + result = await db.execute( + select(User.wecom_userid).where(User.id.in_([uuid.UUID(uid) for uid in data.user_ids])) + ) + wecom_ids = [r[0] for r in result.all() if r[0]] + + content = data.message or "📋 请及时完成今日拜访记录填报。" + success = await wecom_client.send_text_message(wecom_ids, content) + + return {"success": success, "sent_to": len(wecom_ids)} + + +@router.post("/announcement") +async def send_announcement( + data: AnnouncementRequest, + current_user: dict = Depends(require_director), + db: AsyncSession = Depends(get_db), +): + """Director sends an announcement to all team members.""" + # Get all wecom_userids in the department + result = await db.execute(select(User.wecom_userid).where(User.wecom_userid.isnot(None))) + wecom_ids = [r[0] for r in result.all()] + + content = f"📢 支局长公告\n\n{data.content}" + success = await wecom_client.send_text_message(wecom_ids, content) + + return {"success": success, "sent_to": len(wecom_ids)} + + +@router.post("/trigger-daily-check") +async def trigger_daily_check( + current_user: dict = Depends(require_director), + db: AsyncSession = Depends(get_db), +): + """Manually trigger the daily reporting check (for testing or manual use).""" + result = await check_daily_reporting(db) + return result diff --git a/backend/app/api/work_plans.py b/backend/app/api/work_plans.py new file mode 100644 index 0000000..386b0ea --- /dev/null +++ b/backend/app/api/work_plans.py @@ -0,0 +1,105 @@ +import uuid +from datetime import date +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from app.database import get_db +from app.middleware.auth import get_current_user, require_any_role +from app.utils.timezone import parse_date +from app.models.work_plan import WorkPlan +from app.models.customer import Customer +from app.models.user import User +from app.schemas.work_plan import WorkPlanCreate, WorkPlanUpdate, WorkPlanOut + +router = APIRouter(prefix="/work-plans", tags=["WorkPlans"]) + + +async def _enrich(wp: WorkPlan, db: AsyncSession) -> dict: + cust = await db.execute(select(Customer.name).where(Customer.id == wp.customer_id)) + mgr = await db.execute(select(User.name).where(User.id == wp.manager_id)) + return { + "id": str(wp.id), + "customer_id": str(wp.customer_id), + "customer_name": cust.scalar_one_or_none(), + "plan_content": wp.plan_content, + "plan_date": wp.plan_date, + "manager_id": str(wp.manager_id), + "manager_name": mgr.scalar_one_or_none(), + "status": wp.status, + } + + +@router.get("/") +async def list_work_plans( + customer_id: Optional[str] = Query(None), + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + query = select(WorkPlan) + if current_user["role"] == "manager": + query = query.where(WorkPlan.manager_id == uuid.UUID(current_user["user_id"])) + if customer_id: + query = query.where(WorkPlan.customer_id == uuid.UUID(customer_id)) + query = query.order_by(WorkPlan.plan_date.desc()).limit(200) + result = await db.execute(query) + return [await _enrich(w, db) for w in result.scalars().all()] + + +@router.post("/") +async def create_work_plan( + data: WorkPlanCreate, + current_user: dict = Depends(require_any_role), + db: AsyncSession = Depends(get_db), +): + wp = WorkPlan( + customer_id=data.customer_id, + plan_content=data.plan_content, + plan_date=parse_date(data.plan_date), + manager_id=uuid.UUID(current_user["user_id"]), + status=data.status, + ) + db.add(wp) + await db.commit() + await db.refresh(wp) + return await _enrich(wp, db) + + +@router.put("/{plan_id}") +async def update_work_plan( + plan_id: str, data: WorkPlanUpdate, + current_user: dict = Depends(require_any_role), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(WorkPlan).where(WorkPlan.id == plan_id)) + wp = result.scalar_one_or_none() + if not wp: + raise HTTPException(status_code=404, detail="Not found") + if current_user["role"] == "manager" and str(wp.manager_id) != current_user["user_id"]: + raise HTTPException(status_code=403, detail="Access denied") + + update_data = data.model_dump(exclude_unset=True) + if "plan_date" in update_data and update_data["plan_date"]: + update_data["plan_date"] = parse_date(update_data["plan_date"]) + for k, v in update_data.items(): + setattr(wp, k, v) + await db.commit() + await db.refresh(wp) + return await _enrich(wp, db) + + +@router.delete("/{plan_id}") +async def delete_work_plan( + plan_id: str, + current_user: dict = Depends(require_any_role), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(WorkPlan).where(WorkPlan.id == plan_id)) + wp = result.scalar_one_or_none() + if not wp: + raise HTTPException(status_code=404, detail="Not found") + if current_user["role"] == "manager" and str(wp.manager_id) != current_user["user_id"]: + raise HTTPException(status_code=403, detail="Access denied") + await db.delete(wp) + await db.commit() + return {"detail": "deleted"} diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..76b7398 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,48 @@ +from pydantic_settings import BaseSettings +from typing import Optional + + +class Settings(BaseSettings): + # App + APP_NAME: str = "企迹-政企周报管理系统" + DEBUG: bool = True + SECRET_KEY: str = "change-me-in-production" + + # Database + DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/qiji" + + # JWT + JWT_ALGORITHM: str = "HS256" + JWT_EXPIRE_MINUTES: int = 480 + + # Casdoor + CASDOOR_ENDPOINT: str = "http://localhost:8001" + CASDOOR_CLIENT_ID: str = "" + CASDOOR_CLIENT_SECRET: str = "" + CASDOOR_CERTIFICATE: Optional[str] = None + CASDOOR_ORG_NAME: str = "qiji" + CASDOOR_APPLICATION: str = "qiji-weekly-report" + + # MinIO + MINIO_ENDPOINT: str = "localhost:9000" + MINIO_ACCESS_KEY: str = "minioadmin" + MINIO_SECRET_KEY: str = "minioadmin" + MINIO_BUCKET: str = "qiji-photos" + MINIO_SECURE: bool = False + + # WeChat Work + WECOM_CORP_ID: str = "" + WECOM_AGENT_ID: str = "" + WECOM_SECRET: str = "" + WECOM_TOKEN: str = "" + WECOM_ENCODING_AES_KEY: str = "" + + # CORS + CORS_ORIGINS: list[str] = ["http://localhost:5173", "http://localhost:3000"] + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + + +settings = Settings() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..086add2 --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,19 @@ +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession +from sqlalchemy.orm import DeclarativeBase +from app.config import settings + +engine = create_async_engine(settings.DATABASE_URL, echo=settings.DEBUG) + +async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +class Base(DeclarativeBase): + pass + + +async def get_db() -> AsyncSession: + async with async_session() as session: + try: + yield session + finally: + await session.close() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..72527bd --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,58 @@ +from contextlib import asynccontextmanager +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from app.config import settings +from app.database import engine, Base +from app.api import router as api_router +from app.api import auth, users, customers, visits, work_plans, mini_business, key_visits +from app.api import dashboard, upload, export, import_data, wecom, daily_notes + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup: create tables if not exists (for dev convenience) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + # Add columns that may be missing from older tables + await conn.run_sync(lambda c: c.exec_driver_sql( + "ALTER TABLE customers ADD COLUMN IF NOT EXISTS remarks TEXT DEFAULT ''" + )) + yield + # Shutdown + await engine.dispose() + + +app = FastAPI( + title=settings.APP_NAME, + version="0.1.0", + lifespan=lifespan, +) + +# CORS +app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Mount all routers +app.include_router(auth.router, prefix="/api") +app.include_router(users.router, prefix="/api") +app.include_router(customers.router, prefix="/api") +app.include_router(visits.router, prefix="/api") +app.include_router(work_plans.router, prefix="/api") +app.include_router(mini_business.router, prefix="/api") +app.include_router(key_visits.router, prefix="/api") +app.include_router(dashboard.router, prefix="/api") +app.include_router(upload.router, prefix="/api") +app.include_router(export.router, prefix="/api") +app.include_router(import_data.router, prefix="/api") +app.include_router(wecom.router, prefix="/api") +app.include_router(daily_notes.router, prefix="/api") + + +@app.get("/health") +async def health_check(): + return {"status": "ok", "app": settings.APP_NAME} diff --git a/backend/app/middleware/__init__.py b/backend/app/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/middleware/auth.py b/backend/app/middleware/auth.py new file mode 100644 index 0000000..9be0dbf --- /dev/null +++ b/backend/app/middleware/auth.py @@ -0,0 +1,31 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from app.utils.security import decode_token + +bearer_scheme = HTTPBearer() + + +async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme)) -> dict: + payload = decode_token(credentials.credentials) + if payload is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token") + return payload + + +class RoleChecker: + def __init__(self, allowed_roles: list[str]): + self.allowed_roles = allowed_roles + + async def __call__(self, current_user: dict = Depends(get_current_user)) -> dict: + role = current_user.get("role", "") + if role not in self.allowed_roles: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions") + return current_user + + +# Pre-built checkers +require_manager = RoleChecker(["manager"]) +require_director = RoleChecker(["director"]) +require_leader = RoleChecker(["leader"]) +require_director_or_leader = RoleChecker(["director", "leader"]) +require_any_role = RoleChecker(["manager", "director", "leader"]) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..d5632f1 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,21 @@ +from app.models.user import User +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 + +__all__ = [ + "User", + "Customer", + "CustomerContact", + "CustomerAssignment", + "Visit", + "WorkPlan", + "MiniBusiness", + "KeyVisit", + "DailyNote", +] diff --git a/backend/app/models/customer.py b/backend/app/models/customer.py new file mode 100644 index 0000000..16880d3 --- /dev/null +++ b/backend/app/models/customer.py @@ -0,0 +1,24 @@ +import uuid +from datetime import datetime +from sqlalchemy import String, Text, DateTime, func, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.dialects.postgresql import UUID +from app.database import Base + + +class Customer(Base): + __tablename__ = "customers" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name: Mapped[str] = mapped_column(String(200), index=True) + industry: Mapped[str] = mapped_column(String(100), default="") + address: Mapped[str] = mapped_column(String(500), default="") + in_use_services: Mapped[str] = mapped_column(Text, default="") + monthly_fee: Mapped[str] = mapped_column(String(100), default="") + remarks: Mapped[str] = mapped_column(Text, default="") + created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id")) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + contacts: Mapped[list["CustomerContact"]] = relationship("CustomerContact", back_populates="customer", cascade="all, delete-orphan") + assignments: Mapped[list["CustomerAssignment"]] = relationship("CustomerAssignment", back_populates="customer", cascade="all, delete-orphan") diff --git a/backend/app/models/customer_assignment.py b/backend/app/models/customer_assignment.py new file mode 100644 index 0000000..33cda2a --- /dev/null +++ b/backend/app/models/customer_assignment.py @@ -0,0 +1,20 @@ +import uuid +from datetime import datetime +from sqlalchemy import String, DateTime, ForeignKey, func +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.dialects.postgresql import UUID +from app.database import Base + + +class CustomerAssignment(Base): + __tablename__ = "customer_assignments" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + customer_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("customers.id"), index=True) + manager_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id")) + role: Mapped[str] = mapped_column(String(20), default="primary") # 'primary' / 'assistant' + assigned_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + assigned_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id")) + + customer: Mapped["Customer"] = relationship("Customer", back_populates="assignments") + manager: Mapped["User"] = relationship("User", foreign_keys=[manager_id]) diff --git a/backend/app/models/customer_contact.py b/backend/app/models/customer_contact.py new file mode 100644 index 0000000..382094f --- /dev/null +++ b/backend/app/models/customer_contact.py @@ -0,0 +1,17 @@ +import uuid +from sqlalchemy import String, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.dialects.postgresql import UUID +from app.database import Base + + +class CustomerContact(Base): + __tablename__ = "customer_contacts" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + customer_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("customers.id"), index=True) + name: Mapped[str] = mapped_column(String(50)) + phone: Mapped[str] = mapped_column(String(20), default="") + role_desc: Mapped[str] = mapped_column(String(100), default="") + + customer: Mapped["Customer"] = relationship("Customer", back_populates="contacts") diff --git a/backend/app/models/daily_note.py b/backend/app/models/daily_note.py new file mode 100644 index 0000000..96a1ca7 --- /dev/null +++ b/backend/app/models/daily_note.py @@ -0,0 +1,19 @@ +import uuid +from datetime import date, datetime +from sqlalchemy import String, Text, Date, DateTime, ForeignKey, func +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.dialects.postgresql import UUID +from app.database import Base + + +class DailyNote(Base): + __tablename__ = "daily_notes" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + manager_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), index=True) + note_date: Mapped[date] = mapped_column(Date, index=True) + category: Mapped[str] = mapped_column(String(20), default="其他") # 行政事务/合同整理/发票处理/内部会议/培训学习/其他 + content: Mapped[str] = mapped_column(Text, default="") + time_range: Mapped[str] = mapped_column(String(30), default="") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) diff --git a/backend/app/models/key_visit.py b/backend/app/models/key_visit.py new file mode 100644 index 0000000..b864aef --- /dev/null +++ b/backend/app/models/key_visit.py @@ -0,0 +1,19 @@ +import uuid +from sqlalchemy import String, Text, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.dialects.postgresql import UUID +from app.database import Base + + +class KeyVisit(Base): + __tablename__ = "key_visits" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + customer_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("customers.id"), index=True) + urgency_level: Mapped[str] = mapped_column(String(10), default="一般") # 重要/一般/紧急 + description: Mapped[str] = mapped_column(Text, default="") + progress_status: Mapped[str] = mapped_column(String(20), default="未开始") + planned_date: Mapped[str] = mapped_column(String(50), default="") + planned_visitor: Mapped[str] = mapped_column(String(100), default="") + visit_target: Mapped[str] = mapped_column(String(100), default="") + manager_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), index=True) diff --git a/backend/app/models/mini_business.py b/backend/app/models/mini_business.py new file mode 100644 index 0000000..d9e6b8a --- /dev/null +++ b/backend/app/models/mini_business.py @@ -0,0 +1,18 @@ +import uuid +from sqlalchemy import String, Text, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.dialects.postgresql import UUID +from app.database import Base + + +class MiniBusiness(Base): + __tablename__ = "mini_business" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + customer_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("customers.id"), index=True) + product_type: Mapped[str] = mapped_column(String(200), default="") + amount: Mapped[str] = mapped_column(String(100), default="") + follow_up_detail: Mapped[str] = mapped_column(Text, default="") + status: Mapped[str] = mapped_column(String(50), default="跟进中") + manager_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), index=True) + expected_revenue_date: Mapped[str] = mapped_column(String(50), default="") diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..8cafad7 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,18 @@ +import uuid +from datetime import datetime +from sqlalchemy import String, DateTime, func +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.dialects.postgresql import UUID +from app.database import Base + + +class User(Base): + __tablename__ = "users" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + casdoor_id: Mapped[str] = mapped_column(String(100), unique=True, index=True) + name: Mapped[str] = mapped_column(String(50)) + role: Mapped[str] = mapped_column(String(30)) # manager / director / leader + department: Mapped[str] = mapped_column(String(100), default="") + wecom_userid: Mapped[str | None] = mapped_column(String(100), unique=True, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/models/visit.py b/backend/app/models/visit.py new file mode 100644 index 0000000..d0af6e8 --- /dev/null +++ b/backend/app/models/visit.py @@ -0,0 +1,23 @@ +import uuid +from datetime import datetime, date +from sqlalchemy import String, Text, Date, DateTime, ForeignKey, func +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.dialects.postgresql import UUID, ARRAY +from app.database import Base + + +class Visit(Base): + __tablename__ = "visits" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + customer_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("customers.id"), index=True) + visit_date: Mapped[date] = mapped_column(Date, index=True) + visit_method: Mapped[str] = mapped_column(String(20), default="上门") # 上门/电话/微信/出差 + time_range: Mapped[str] = mapped_column(String(30), default="") + communication_content: Mapped[str] = mapped_column(Text, default="") + customer_demand: Mapped[str] = mapped_column(Text, default="") + companions: Mapped[list | None] = mapped_column(ARRAY(UUID(as_uuid=True)), nullable=True) + photos: Mapped[list | None] = mapped_column(ARRAY(Text), nullable=True) + manager_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) diff --git a/backend/app/models/work_plan.py b/backend/app/models/work_plan.py new file mode 100644 index 0000000..883f2ba --- /dev/null +++ b/backend/app/models/work_plan.py @@ -0,0 +1,17 @@ +import uuid +from datetime import date +from sqlalchemy import String, Text, Date, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.dialects.postgresql import UUID +from app.database import Base + + +class WorkPlan(Base): + __tablename__ = "work_plans" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + customer_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("customers.id"), index=True) + plan_content: Mapped[str] = mapped_column(Text, default="") + plan_date: Mapped[date] = mapped_column(Date, index=True) + manager_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), index=True) + status: Mapped[str] = mapped_column(String(20), default="计划中") # 计划中/已完成/已取消 diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..bd71473 --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1,10 @@ +from app.schemas.customer import ( + CustomerCreate, CustomerUpdate, CustomerOut, CustomerListOut, + ContactCreate, ContactOut, AssignmentCreate, AssignmentOut, BatchAssignRequest, +) +from app.schemas.visit import VisitCreate, VisitUpdate, VisitOut, VisitListOut +from app.schemas.work_plan import WorkPlanCreate, WorkPlanUpdate, WorkPlanOut +from app.schemas.mini_business import MiniBusinessCreate, MiniBusinessUpdate, MiniBusinessOut +from app.schemas.key_visit import KeyVisitCreate, KeyVisitUpdate, KeyVisitOut +from app.schemas.user import TokenResponse, WecomLoginRequest, CasdoorLoginRequest, WecomBindRequest +from app.schemas.customer import UserCreate, UserOut diff --git a/backend/app/schemas/customer.py b/backend/app/schemas/customer.py new file mode 100644 index 0000000..2f096ef --- /dev/null +++ b/backend/app/schemas/customer.py @@ -0,0 +1,118 @@ +from pydantic import BaseModel, Field +from typing import Optional +import uuid +from datetime import date, datetime + + +# ── User ── +class UserCreate(BaseModel): + name: str + role: str = "manager" + department: str = "" + wecom_userid: Optional[str] = None + + +class UserOut(BaseModel): + id: uuid.UUID + casdoor_id: str + name: str + role: str + department: str + wecom_userid: Optional[str] = None + + model_config = {"from_attributes": True} + + +# ── CustomerContact ── +class ContactCreate(BaseModel): + name: str + phone: str = "" + role_desc: str = "" + + +class ContactOut(BaseModel): + id: uuid.UUID + customer_id: uuid.UUID + name: str + phone: str + role_desc: str + + model_config = {"from_attributes": True} + + +# ── Customer ── +class CustomerCreate(BaseModel): + name: str + industry: str = "" + address: str = "" + in_use_services: str = "" + monthly_fee: str = "" + remarks: str = "" + contacts: list[ContactCreate] = [] + assignee_id: Optional[uuid.UUID] = None + + +class CustomerUpdate(BaseModel): + name: Optional[str] = None + industry: Optional[str] = None + address: Optional[str] = None + in_use_services: Optional[str] = None + monthly_fee: Optional[str] = None + remarks: Optional[str] = None + assignee_id: Optional[uuid.UUID] = None + + +class CustomerOut(BaseModel): + id: uuid.UUID + name: str + industry: str + address: str + in_use_services: str + monthly_fee: str + remarks: str = "" + created_by: uuid.UUID + created_at: datetime + updated_at: datetime + contacts: list[ContactOut] = [] + primary_manager_name: Optional[str] = None + + model_config = {"from_attributes": True} + + +class CustomerListOut(BaseModel): + id: uuid.UUID + name: str + industry: str + in_use_services: str + + model_config = {"from_attributes": True} + + +class CustomerListResponse(BaseModel): + items: list[CustomerListOut] + total: int + page: int + page_size: int + + +# ── CustomerAssignment ── +class AssignmentCreate(BaseModel): + customer_id: uuid.UUID + manager_id: uuid.UUID + role: str = "primary" + + +class AssignmentOut(BaseModel): + id: uuid.UUID + customer_id: uuid.UUID + manager_id: uuid.UUID + role: str + assigned_at: datetime + assigned_by: uuid.UUID + + model_config = {"from_attributes": True} + + +class BatchAssignRequest(BaseModel): + customer_ids: list[uuid.UUID] + manager_id: uuid.UUID diff --git a/backend/app/schemas/daily_note.py b/backend/app/schemas/daily_note.py new file mode 100644 index 0000000..1664ada --- /dev/null +++ b/backend/app/schemas/daily_note.py @@ -0,0 +1,32 @@ +from pydantic import BaseModel +from typing import Optional +import uuid +from datetime import date, datetime + + +class DailyNoteCreate(BaseModel): + note_date: str # YYYY-MM-DD + category: str = "其他" + content: str = "" + time_range: str = "" + + +class DailyNoteUpdate(BaseModel): + note_date: Optional[str] = None + category: Optional[str] = None + content: Optional[str] = None + time_range: Optional[str] = None + + +class DailyNoteOut(BaseModel): + id: uuid.UUID + manager_id: uuid.UUID + note_date: date + category: str + content: str + time_range: str + created_at: datetime + updated_at: datetime + manager_name: Optional[str] = None + + model_config = {"from_attributes": True} diff --git a/backend/app/schemas/key_visit.py b/backend/app/schemas/key_visit.py new file mode 100644 index 0000000..c7d7454 --- /dev/null +++ b/backend/app/schemas/key_visit.py @@ -0,0 +1,39 @@ +from pydantic import BaseModel +from typing import Optional +import uuid + + +class KeyVisitCreate(BaseModel): + customer_id: uuid.UUID + urgency_level: str = "一般" # 重要/一般/紧急 + description: str = "" + progress_status: str = "未开始" + planned_date: str = "" + planned_visitor: str = "" + visit_target: str = "" + + +class KeyVisitUpdate(BaseModel): + customer_id: Optional[uuid.UUID] = None + urgency_level: Optional[str] = None + description: Optional[str] = None + progress_status: Optional[str] = None + planned_date: Optional[str] = None + planned_visitor: Optional[str] = None + visit_target: Optional[str] = None + + +class KeyVisitOut(BaseModel): + id: uuid.UUID + customer_id: uuid.UUID + urgency_level: str + description: str + progress_status: str + planned_date: str + planned_visitor: str + visit_target: str + manager_id: uuid.UUID + customer_name: Optional[str] = None + manager_name: Optional[str] = None + + model_config = {"from_attributes": True} diff --git a/backend/app/schemas/mini_business.py b/backend/app/schemas/mini_business.py new file mode 100644 index 0000000..a0719aa --- /dev/null +++ b/backend/app/schemas/mini_business.py @@ -0,0 +1,36 @@ +from pydantic import BaseModel +from typing import Optional +import uuid + + +class MiniBusinessCreate(BaseModel): + customer_id: uuid.UUID + product_type: str = "" + amount: str = "" + follow_up_detail: str = "" + status: str = "跟进中" + expected_revenue_date: str = "" + + +class MiniBusinessUpdate(BaseModel): + customer_id: Optional[uuid.UUID] = None + product_type: Optional[str] = None + amount: Optional[str] = None + follow_up_detail: Optional[str] = None + status: Optional[str] = None + expected_revenue_date: Optional[str] = None + + +class MiniBusinessOut(BaseModel): + id: uuid.UUID + customer_id: uuid.UUID + product_type: str + amount: str + follow_up_detail: str + status: str + manager_id: uuid.UUID + expected_revenue_date: str + customer_name: Optional[str] = None + manager_name: Optional[str] = None + + model_config = {"from_attributes": True} diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..0803bd8 --- /dev/null +++ b/backend/app/schemas/user.py @@ -0,0 +1,25 @@ +from pydantic import BaseModel +from typing import Optional +import uuid + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + user_id: str + name: str + role: str + + +class WecomLoginRequest(BaseModel): + code: str + + +class CasdoorLoginRequest(BaseModel): + code: str + state: str + + +class WecomBindRequest(BaseModel): + casdoor_code: str + wecom_userid: Optional[str] = None diff --git a/backend/app/schemas/visit.py b/backend/app/schemas/visit.py new file mode 100644 index 0000000..dd18d89 --- /dev/null +++ b/backend/app/schemas/visit.py @@ -0,0 +1,60 @@ +from pydantic import BaseModel, Field +from typing import Optional +import uuid +from datetime import date, datetime + + +class VisitCreate(BaseModel): + customer_id: uuid.UUID + visit_date: str # "YYYY-MM-DD" + visit_method: str = "上门" + time_range: str = "" + communication_content: str = "" + customer_demand: str = "" + companions: list[uuid.UUID] = [] + photos: list[str] = [] + + +class VisitUpdate(BaseModel): + customer_id: Optional[uuid.UUID] = None + visit_date: Optional[str] = None + visit_method: Optional[str] = None + time_range: Optional[str] = None + communication_content: Optional[str] = None + customer_demand: Optional[str] = None + companions: Optional[list[uuid.UUID]] = None + photos: Optional[list[str]] = None + + +class VisitOut(BaseModel): + id: uuid.UUID + customer_id: uuid.UUID + visit_date: date + visit_method: str + time_range: str + communication_content: str + customer_demand: str + companions: Optional[list[uuid.UUID]] = None + photos: Optional[list[str]] = None + manager_id: uuid.UUID + created_at: datetime + updated_at: datetime + # Joined fields + customer_name: Optional[str] = None + manager_name: Optional[str] = None + + model_config = {"from_attributes": True} + + +class VisitListOut(BaseModel): + id: uuid.UUID + customer_id: uuid.UUID + visit_date: date + visit_method: str + time_range: str + manager_id: uuid.UUID + customer_name: Optional[str] = None + manager_name: Optional[str] = None + has_photos: bool = False + + model_config = {"from_attributes": True} diff --git a/backend/app/schemas/work_plan.py b/backend/app/schemas/work_plan.py new file mode 100644 index 0000000..1816798 --- /dev/null +++ b/backend/app/schemas/work_plan.py @@ -0,0 +1,31 @@ +from pydantic import BaseModel +from typing import Optional +import uuid +from datetime import date + + +class WorkPlanCreate(BaseModel): + customer_id: uuid.UUID + plan_content: str = "" + plan_date: str # "YYYY-MM-DD" + status: str = "计划中" + + +class WorkPlanUpdate(BaseModel): + customer_id: Optional[uuid.UUID] = None + plan_content: Optional[str] = None + plan_date: Optional[str] = None + status: Optional[str] = None + + +class WorkPlanOut(BaseModel): + id: uuid.UUID + customer_id: uuid.UUID + plan_content: str + plan_date: date + manager_id: uuid.UUID + status: str + customer_name: Optional[str] = None + manager_name: Optional[str] = None + + model_config = {"from_attributes": True} diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py new file mode 100644 index 0000000..f0372e1 --- /dev/null +++ b/backend/app/services/auth.py @@ -0,0 +1,106 @@ +from uuid import uuid4 +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +import httpx +from app.config import settings +from app.models.user import User +from app.utils.security import create_access_token + + +async def get_or_create_user_from_casdoor( + db: AsyncSession, casdoor_id: str, name: str, + role: str = "manager", department: str = "" +) -> User: + """Find existing user by casdoor_id, or create a new one.""" + result = await db.execute(select(User).where(User.casdoor_id == casdoor_id)) + user = result.scalar_one_or_none() + if user is None: + user = User( + casdoor_id=casdoor_id, + name=name, + role=role, + department=department, + ) + db.add(user) + await db.commit() + await db.refresh(user) + else: + # Update name/department if changed + if user.name != name or user.department != department: + user.name = name + user.department = department + await db.commit() + await db.refresh(user) + return user + + +async def exchange_casdoor_code(code: str) -> dict | None: + """Exchange Casdoor OIDC authorization code for user info.""" + import logging + logger = logging.getLogger("uvicorn") + + token_url = f"{settings.CASDOOR_ENDPOINT}/api/login/oauth/access_token" + async with httpx.AsyncClient() as client: + try: + resp = await client.post(token_url, data={ + "grant_type": "authorization_code", + "client_id": settings.CASDOOR_CLIENT_ID, + "client_secret": settings.CASDOOR_CLIENT_SECRET, + "code": code, + }, timeout=10) + if resp.status_code != 200: + logger.error(f"[Casdoor] token exchange failed: status={resp.status_code}, body={resp.text[:500]}") + return None + token_data = resp.json() + access_token = token_data.get("access_token", "") + if not access_token: + logger.error(f"[Casdoor] no access_token in response: {token_data}") + return None + except Exception as e: + logger.error(f"[Casdoor] token exchange exception: {e}") + return None + + userinfo_url = f"{settings.CASDOOR_ENDPOINT}/api/userinfo" + async with httpx.AsyncClient() as client: + try: + resp = await client.get(userinfo_url, headers={ + "Authorization": f"Bearer {access_token}" + }, timeout=10) + if resp.status_code != 200: + logger.error(f"[Casdoor] userinfo failed: status={resp.status_code}, body={resp.text[:500]}") + return None + return resp.json() + except Exception as e: + logger.error(f"[Casdoor] userinfo exception: {e}") + return None + return None + + +async def bind_wecom_user(db: AsyncSession, casdoor_id: str, wecom_userid: str) -> User | None: + """Bind a WeChat Work userid to a Casdoor user.""" + result = await db.execute(select(User).where(User.casdoor_id == casdoor_id)) + user = result.scalar_one_or_none() + if user is None: + return None + user.wecom_userid = wecom_userid + await db.commit() + await db.refresh(user) + return user + + +async def get_user_by_wecom_id(db: AsyncSession, wecom_userid: str) -> User | None: + """Find user by wecom_userid.""" + result = await db.execute(select(User).where(User.wecom_userid == wecom_userid)) + return result.scalar_one_or_none() + + +def build_token_for_user(user: User) -> str: + """Build a JWT token for the given user.""" + return create_access_token(data={ + "sub": str(user.id), + "user_id": str(user.id), + "casdoor_id": user.casdoor_id, + "name": user.name, + "role": user.role, + "department": user.department, + }) diff --git a/backend/app/services/dashboard.py b/backend/app/services/dashboard.py new file mode 100644 index 0000000..aa69bf1 --- /dev/null +++ b/backend/app/services/dashboard.py @@ -0,0 +1,253 @@ +from datetime import date, timedelta +from uuid import UUID +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import aliased +from app.models.customer import Customer +from app.models.user import User +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.utils.timezone import today_cst + + +def get_week_range(reference_date: date | None = None): + today = reference_date or date.today() + monday = today - timedelta(days=today.weekday()) + sunday = monday + timedelta(days=6) + return monday, sunday + + +async def get_dashboard_stats(db: AsyncSession) -> dict: + """Get dashboard statistics for the current week.""" + monday, sunday = get_week_range() + today = date.today() + + visits_count = (await db.execute( + select(func.count(Visit.id)).where(Visit.visit_date >= monday, Visit.visit_date <= sunday) + )).scalar() or 0 + + plans_count = (await db.execute( + select(func.count(WorkPlan.id)) + )).scalar() or 0 + + mini_biz_count = (await db.execute( + select(func.count(MiniBusiness.id)) + )).scalar() or 0 + + key_visit_count = (await db.execute( + select(func.count(KeyVisit.id)) + )).scalar() or 0 + + return { + "week_visits": visits_count, + "work_plans": plans_count, + "mini_business": mini_biz_count, + "key_visits": key_visit_count, + "week_start": str(monday), + "week_end": str(sunday), + } + + +async def get_reporting_progress(db: AsyncSession) -> list[dict]: + """Get per-manager reporting progress for the current week.""" + monday, sunday = get_week_range() + + # Get all managers + managers_result = await db.execute(select(User).where(User.role == "manager")) + managers = managers_result.scalars().all() + + # Get visit counts per manager this week + visits_result = await db.execute( + select(Visit.manager_id, func.count(Visit.id)) + .where(Visit.visit_date >= monday, Visit.visit_date <= sunday) + .group_by(Visit.manager_id) + ) + visit_map = {str(uid): cnt for uid, cnt in visits_result.all()} + + progress = [] + for m in managers: + count = visit_map.get(str(m.id), 0) + # Calculate expected working days (Mon-Fri) + days_passed = min((date.today() - monday).days + 1, 5) + expected = days_passed # At least 1 visit per working day + progress.append({ + "manager_id": str(m.id), + "manager_name": m.name, + "department": m.department, + "visit_count": count, + "expected": expected, + "completed": count >= expected, + "has_reported_today": False, # Will be set below + }) + + # Check today's reporting — visits OR daily notes + today = today_cst() + today_visits = await db.execute( + select(Visit.manager_id).where(Visit.visit_date == today) + ) + today_notes = await db.execute( + select(DailyNote.manager_id).where(DailyNote.note_date == today) + ) + reported_today = {str(uid) for uid, in today_visits.all()} | {str(uid) for uid, in today_notes.all()} + for p in progress: + p["has_reported_today"] = p["manager_id"] in reported_today + + return progress + + +async def get_weekly_report( + db: AsyncSession, user_id: UUID, role: str, + filter_manager_id: UUID | None = None, + filter_customer_id: UUID | None = None, +) -> dict: + """Get full weekly report data organized by module.""" + monday, sunday = get_week_range() + + # Base filters respecting role visibility + customer_map = {} + user_map = {} + + customers_result = await db.execute(select(Customer.id, Customer.name)) + customer_map = {c.id: c.name for c in customers_result.all()} + users_result = await db.execute(select(User.id, User.name)) + user_map = {u.id: u.name for u in users_result.all()} + + def build_manager_filter(existing_filter=None): + """If role is manager, only see own data. Otherwise optionally filter by manager_id.""" + if role == "manager": + return str(user_id) + return str(filter_manager_id) if filter_manager_id else None + + # ── Visits ── + visit_query = select(Visit).where(Visit.visit_date >= monday, Visit.visit_date <= sunday) + if role == "manager": + visit_query = visit_query.where(Visit.manager_id == user_id) + elif filter_manager_id: + visit_query = visit_query.where(Visit.manager_id == filter_manager_id) + if filter_customer_id: + visit_query = visit_query.where(Visit.customer_id == filter_customer_id) + visit_query = visit_query.order_by(Visit.visit_date.desc()) + visits_result = await db.execute(visit_query) + visits = visits_result.scalars().all() + + visits_data = [] + for v in visits: + visits_data.append({ + "id": str(v.id), + "customer_id": str(v.customer_id), + "customer_name": customer_map.get(v.customer_id, ""), + "visit_date": str(v.visit_date), + "visit_method": v.visit_method, + "time_range": v.time_range, + "communication_content": v.communication_content, + "customer_demand": v.customer_demand, + "companions": [str(c) for c in (v.companions or [])], + "photos": v.photos or [], + "manager_id": str(v.manager_id), + "manager_name": user_map.get(v.manager_id, ""), + "created_at": str(v.created_at), + }) + + # ── Work Plans ── + wp_query = select(WorkPlan) + if role == "manager": + wp_query = wp_query.where(WorkPlan.manager_id == user_id) + elif filter_manager_id: + wp_query = wp_query.where(WorkPlan.manager_id == filter_manager_id) + if filter_customer_id: + wp_query = wp_query.where(WorkPlan.customer_id == filter_customer_id) + wp_result = await db.execute(wp_query) + work_plans_data = [] + for w in wp_result.scalars(): + work_plans_data.append({ + "id": str(w.id), + "customer_id": str(w.customer_id), + "customer_name": customer_map.get(w.customer_id, ""), + "plan_content": w.plan_content, + "plan_date": str(w.plan_date), + "manager_id": str(w.manager_id), + "manager_name": user_map.get(w.manager_id, ""), + "status": w.status, + }) + + # ── Mini Business ── + mb_query = select(MiniBusiness) + if role == "manager": + mb_query = mb_query.where(MiniBusiness.manager_id == user_id) + elif filter_manager_id: + mb_query = mb_query.where(MiniBusiness.manager_id == filter_manager_id) + if filter_customer_id: + mb_query = mb_query.where(MiniBusiness.customer_id == filter_customer_id) + mb_result = await db.execute(mb_query) + mini_biz_data = [] + for m in mb_result.scalars(): + mini_biz_data.append({ + "id": str(m.id), + "customer_id": str(m.customer_id), + "customer_name": customer_map.get(m.customer_id, ""), + "product_type": m.product_type, + "amount": m.amount, + "follow_up_detail": m.follow_up_detail, + "status": m.status, + "manager_id": str(m.manager_id), + "manager_name": user_map.get(m.manager_id, ""), + "expected_revenue_date": m.expected_revenue_date, + }) + + # ── Key Visits ── + kv_query = select(KeyVisit) + if role == "manager": + kv_query = kv_query.where(KeyVisit.manager_id == user_id) + elif filter_manager_id: + kv_query = kv_query.where(KeyVisit.manager_id == filter_manager_id) + if filter_customer_id: + kv_query = kv_query.where(KeyVisit.customer_id == filter_customer_id) + kv_result = await db.execute(kv_query) + key_visits_data = [] + for k in kv_result.scalars(): + key_visits_data.append({ + "id": str(k.id), + "customer_id": str(k.customer_id), + "customer_name": customer_map.get(k.customer_id, ""), + "urgency_level": k.urgency_level, + "description": k.description, + "progress_status": k.progress_status, + "planned_date": k.planned_date, + "planned_visitor": k.planned_visitor, + "visit_target": k.visit_target, + "manager_id": str(k.manager_id), + "manager_name": user_map.get(k.manager_id, ""), + }) + + # ── Daily Notes ── + dn_query = select(DailyNote).where(DailyNote.note_date >= monday, DailyNote.note_date <= sunday) + if role == "manager": + dn_query = dn_query.where(DailyNote.manager_id == user_id) + elif filter_manager_id: + dn_query = dn_query.where(DailyNote.manager_id == filter_manager_id) + dn_query = dn_query.order_by(DailyNote.note_date.desc()) + dn_result = await db.execute(dn_query) + daily_notes_data = [] + for d in dn_result.scalars(): + daily_notes_data.append({ + "id": str(d.id), + "note_date": str(d.note_date), + "category": d.category, + "content": d.content, + "time_range": d.time_range, + "manager_id": str(d.manager_id), + "manager_name": user_map.get(d.manager_id, ""), + }) + + return { + "week_start": str(monday), + "week_end": str(sunday), + "visits": visits_data, + "daily_notes": daily_notes_data, + "work_plans": work_plans_data, + "mini_business": mini_biz_data, + "key_visits": key_visits_data, + } diff --git a/backend/app/services/excel_export.py b/backend/app/services/excel_export.py new file mode 100644 index 0000000..37ef246 --- /dev/null +++ b/backend/app/services/excel_export.py @@ -0,0 +1,137 @@ +import io +from datetime import date, timedelta +from openpyxl import Workbook +from openpyxl.styles import Font, Alignment, Border, Side +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +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.customer import Customer +from app.models.user import User + + +def get_week_range(reference_date: date | None = None): + """Get the Monday and Sunday of the week containing reference_date (defaults to today).""" + today = reference_date or date.today() + monday = today - timedelta(days=today.weekday()) + sunday = monday + timedelta(days=6) + return monday, sunday + + +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.""" + monday, sunday = get_week_range(reference_date) + wb = Workbook() + + # Pre-fetch lookups + customers_result = await db.execute(select(Customer.id, Customer.name)) + customer_map = {str(c.id): c.name for c in customers_result.all()} + users_result = await db.execute(select(User.id, User.name)) + user_map = {str(u.id): u.name for u in users_result.all()} + + thin_border = Border( + left=Side(style='thin'), right=Side(style='thin'), + top=Side(style='thin'), bottom=Side(style='thin') + ) + header_font = Font(bold=True) + + # ── Sheet 1: 每日拜访记录 ── + ws1 = wb.active + ws1.title = "每日拜访记录" + headers1 = ["客户单位", "拜访日期", "拜访方式", "时间范围", "沟通内容", "客户需求", "同访人员", "客户经理"] + ws1.append(headers1) + for col in range(1, len(headers1) + 1): + cell = ws1.cell(row=1, column=col) + cell.font = header_font + cell.border = thin_border + + visits_result = await db.execute( + select(Visit).where(Visit.visit_date >= monday, Visit.visit_date <= sunday) + ) + for v in visits_result.scalars(): + companions_names = [user_map.get(str(cid), str(cid)) for cid in (v.companions or [])] + ws1.append([ + customer_map.get(str(v.customer_id), ""), + str(v.visit_date), + v.visit_method, + v.time_range, + v.communication_content, + v.customer_demand, + ", ".join(companions_names), + user_map.get(str(v.manager_id), ""), + ]) + + # ── Sheet 2: 下周工作计划 ── + ws2 = wb.create_sheet("下周工作计划") + headers2 = ["客户单位", "工作计划", "计划拜访时间", "客户经理", "状态"] + ws2.append(headers2) + for col in range(1, len(headers2) + 1): + cell = ws2.cell(row=1, column=col) + cell.font = header_font + cell.border = thin_border + + plans_result = await db.execute(select(WorkPlan)) + for p in plans_result.scalars(): + ws2.append([ + customer_map.get(str(p.customer_id), ""), + p.plan_content, + str(p.plan_date), + user_map.get(str(p.manager_id), ""), + p.status, + ]) + + # ── Sheet 3: 小微业务商机 ── + ws3 = wb.create_sheet("小微业务商机") + headers3 = ["客户单位", "产品类型", "金额", "跟进内容具体情况", "跟进状态", "客户经理", "预计列收时间"] + ws3.append(headers3) + for col in range(1, len(headers3) + 1): + cell = ws3.cell(row=1, column=col) + cell.font = header_font + cell.border = thin_border + + mb_result = await db.execute(select(MiniBusiness)) + for m in mb_result.scalars(): + ws3.append([ + customer_map.get(str(m.customer_id), ""), + m.product_type, + m.amount, + m.follow_up_detail, + m.status, + user_map.get(str(m.manager_id), ""), + m.expected_revenue_date, + ]) + + # ── Sheet 4: 要客拜访计划 ── + ws4 = wb.create_sheet("要客拜访计划") + headers4 = ["客户单位", "紧急重要度", "内容描述", "进展状态", "计划拜访时间", "计划拜访人", "拜访对象", "客户经理"] + ws4.append(headers4) + for col in range(1, len(headers4) + 1): + cell = ws4.cell(row=1, column=col) + cell.font = header_font + cell.border = thin_border + + kv_result = await db.execute(select(KeyVisit)) + for k in kv_result.scalars(): + ws4.append([ + customer_map.get(str(k.customer_id), ""), + k.urgency_level, + k.description, + k.progress_status, + k.planned_date, + k.planned_visitor, + k.visit_target, + user_map.get(str(k.manager_id), ""), + ]) + + # Adjust column widths + for ws in [ws1, ws2, ws3, ws4]: + 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) + + output = io.BytesIO() + wb.save(output) + output.seek(0) + return output diff --git a/backend/app/services/excel_import.py b/backend/app/services/excel_import.py new file mode 100644 index 0000000..875a71d --- /dev/null +++ b/backend/app/services/excel_import.py @@ -0,0 +1,78 @@ +import uuid +from datetime import datetime, date +from typing import Any +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +import openpyxl +from app.models.customer import Customer +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.user import User + + +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} + + # Resolve customer name -> id cache + customers_result = await db.execute(select(Customer.id, Customer.name)) + customer_map = {c.name: c.id for c in customers_result.all()} + + # ── Parse Sheet 1: 每日拜访记录 ── + if "每日拜访记录" in wb.sheetnames: + ws = wb["每日拜访记录"] + for row in ws.iter_rows(min_row=2, values_only=True): + if not row[0]: + continue + try: + cust_name, visit_date_str, visit_method, time_range, 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 "" + + customer_id = customer_map.get(cust_name) + if not customer_id: + stats["skipped"] += 1 + continue + + # Parse date + try: + visit_date = datetime.strptime(visit_date_str[:10], "%Y-%m-%d").date() + except ValueError: + visit_date = date.today() + + # Check for duplicates (same day, same person, same customer) + existing = await db.execute( + select(Visit).where( + Visit.visit_date == visit_date, + Visit.manager_id == manager_id, + Visit.customer_id == customer_id, + ) + ) + if existing.scalar_one_or_none(): + stats["skipped"] += 1 + continue + + visit = Visit( + customer_id=customer_id, + visit_date=visit_date, + visit_method=visit_method if visit_method in ["上门", "电话", "微信", "出差"] else "上门", + time_range=time_range, + communication_content=content, + customer_demand=demand, + manager_id=manager_id, + ) + db.add(visit) + stats["visits"] += 1 + except Exception: + stats["skipped"] += 1 + + await db.commit() + return stats + + +import io diff --git a/backend/app/services/minio_client.py b/backend/app/services/minio_client.py new file mode 100644 index 0000000..4f01872 --- /dev/null +++ b/backend/app/services/minio_client.py @@ -0,0 +1,43 @@ +from datetime import timedelta +from minio import Minio +from app.config import settings + +_client: Minio | None = None + + +def get_minio_client() -> Minio: + global _client + if _client is None: + _client = Minio( + settings.MINIO_ENDPOINT, + access_key=settings.MINIO_ACCESS_KEY, + secret_key=settings.MINIO_SECRET_KEY, + secure=settings.MINIO_SECURE, + ) + # Ensure bucket exists + if not _client.bucket_exists(settings.MINIO_BUCKET): + _client.make_bucket(settings.MINIO_BUCKET) + return _client + + +def generate_presigned_upload_url(object_key: str, expires: int = 600) -> str: + """Generate a presigned PUT URL for direct upload to MinIO.""" + client = get_minio_client() + return client.presigned_put_object(settings.MINIO_BUCKET, object_key, expires=timedelta(seconds=expires)) + + +def generate_presigned_download_url(object_key: str, expires: int = 3600) -> str: + """Generate a presigned GET URL for viewing/downloading an object.""" + client = get_minio_client() + return client.presigned_get_object(settings.MINIO_BUCKET, object_key, expires=timedelta(seconds=expires)) + + +def delete_objects(object_keys: list[str]) -> None: + """Delete multiple objects from MinIO.""" + if not object_keys: + return + client = get_minio_client() + from minio.deleteobjects import DeleteObject + errors = client.remove_objects(settings.MINIO_BUCKET, [DeleteObject(k) for k in object_keys]) + for err in errors: + pass # Log errors in production diff --git a/backend/app/services/scheduler.py b/backend/app/services/scheduler.py new file mode 100644 index 0000000..a70f2c5 --- /dev/null +++ b/backend/app/services/scheduler.py @@ -0,0 +1,54 @@ +from datetime import date, datetime +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession +from app.models.visit import Visit +from app.models.daily_note import DailyNote +from app.models.user import User +from app.services.wecom import wecom_client +from app.utils.timezone import today_cst + + +async def check_daily_reporting(db: AsyncSession) -> dict: + """Check today's reporting progress and send wecom reminders to managers who haven't reported.""" + today = today_cst() + weekday = today.weekday() + if weekday >= 5: # Skip weekends + return {"status": "weekend", "date": str(today)} + + # Get all managers + result = await db.execute(select(User).where(User.role == "manager")) + managers = result.scalars().all() + + # Get managers who have reported today + reported_visits = await db.execute( + select(Visit.manager_id).where(Visit.visit_date == today) + ) + reported_notes = await db.execute( + select(DailyNote.manager_id).where(DailyNote.note_date == today) + ) + reported_map = {str(uid): True for uid, in reported_visits.all()} + for uid, in reported_notes.all(): + reported_map[str(uid)] = True + + not_reported = [] + for m in managers: + if str(m.id) not in reported_map: + not_reported.append(m) + + if not_reported and managers: + content = f"📋 今日填报提醒({today})\n\n以下同事尚未提交今日拜访记录:\n" + for m in not_reported: + content += f"• {m.name}\n" + content += "\n请尽快完成今日拜访填报 🙏" + + user_ids = [m.wecom_userid for m in not_reported if m.wecom_userid] + if user_ids: + await wecom_client.send_text_message(user_ids, content) + + return { + "status": "ok", + "date": str(today), + "total_managers": len(managers), + "reported": len(reported_map), + "not_reported": len(not_reported), + } diff --git a/backend/app/services/wecom.py b/backend/app/services/wecom.py new file mode 100644 index 0000000..9f51b38 --- /dev/null +++ b/backend/app/services/wecom.py @@ -0,0 +1,55 @@ +import httpx +from app.config import settings + + +class WecomClient: + """Minimal WeChat Work API client for sending app messages.""" + + def __init__(self): + self.corp_id = settings.WECOM_CORP_ID + self.agent_id = settings.WECOM_AGENT_ID + self.secret = settings.WECOM_SECRET + self._access_token: str | None = None + + async def _get_token(self) -> str: + if self._access_token: + return self._access_token + url = f"https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={self.corp_id}&corpsecret={self.secret}" + async with httpx.AsyncClient() as client: + resp = await client.get(url, timeout=10) + data = resp.json() + if data.get("errcode") == 0: + self._access_token = data["access_token"] + return self._access_token + raise Exception(f"Failed to get wecom token: {data}") + + async def get_userinfo_by_code(self, code: str) -> dict | None: + """Exchange OAuth2 code for userid (used in silent login).""" + token = await self._get_token() + url = f"https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo?access_token={token}&code={code}" + async with httpx.AsyncClient() as client: + resp = await client.get(url, timeout=10) + data = resp.json() + if data.get("errcode") == 0: + return data + return None + + async def send_text_message(self, user_ids: list[str], content: str) -> bool: + """Send a text app message to specified users.""" + if not settings.WECOM_AGENT_ID: + return False # Not configured, skip silently in dev + token = await self._get_token() + url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={token}" + body = { + "touser": "|".join(user_ids), + "msgtype": "text", + "agentid": int(settings.WECOM_AGENT_ID), + "text": {"content": content}, + } + async with httpx.AsyncClient() as client: + resp = await client.post(url, json=body, timeout=10) + data = resp.json() + return data.get("errcode") == 0 + + +wecom_client = WecomClient() diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/utils/security.py b/backend/app/utils/security.py new file mode 100644 index 0000000..bae475a --- /dev/null +++ b/backend/app/utils/security.py @@ -0,0 +1,19 @@ +from datetime import datetime, timedelta, timezone +from typing import Optional +from jose import jwt, JWTError +from app.config import settings + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + to_encode = data.copy() + expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=settings.JWT_EXPIRE_MINUTES)) + to_encode.update({"exp": expire}) + return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM) + + +def decode_token(token: str) -> Optional[dict]: + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.JWT_ALGORITHM]) + return payload + except JWTError: + return None diff --git a/backend/app/utils/timezone.py b/backend/app/utils/timezone.py new file mode 100644 index 0000000..3ae4de2 --- /dev/null +++ b/backend/app/utils/timezone.py @@ -0,0 +1,13 @@ +from datetime import date, datetime, timezone, timedelta + +CST = timezone(timedelta(hours=8)) # China Standard Time + + +def today_cst() -> date: + """Get today's date in Asia/Shanghai timezone.""" + return datetime.now(timezone.utc).astimezone(CST).date() + + +def parse_date(s: str) -> date: + """Parse a date string that may be YYYY-MM-DD or a full ISO datetime.""" + return date.fromisoformat(s[:10]) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..e830a11 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,14 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +sqlalchemy[asyncio]==2.0.36 +asyncpg==0.30.0 +alembic==1.14.0 +pydantic==2.10.3 +pydantic-settings==2.7.0 +python-jose[cryptography]==3.3.0 +httpx==0.28.1 +python-multipart==0.0.18 +minio==7.2.10 +openpyxl==3.1.5 +apscheduler==3.11.0 +python-dotenv==1.0.1 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..29f89dc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,46 @@ +version: "3.8" + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: qiji + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + + minio: + image: minio/minio:latest + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + ports: + - "9000:9000" + - "9001:9001" + volumes: + - miniodata:/data + + backend: + build: ./backend + ports: + - "8000:8000" + environment: + DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/qiji + MINIO_ENDPOINT: minio:9000 + MINIO_ACCESS_KEY: minioadmin + MINIO_SECRET_KEY: minioadmin + MINIO_BUCKET: qiji-photos + depends_on: + - postgres + - minio + volumes: + - ./backend:/app + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + +volumes: + pgdata: + miniodata: diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..b9352e7 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,10 @@ +# ============================================ +# 企迹 (qiji) — 前端环境变量 +# 复制此文件为 .env 并填入真实值 +# ============================================ + +# Casdoor 认证服务地址(与后端 CASDOOR_ENDPOINT 一致) +VITE_CASDOOR_ENDPOINT=http://localhost:8001 + +# Casdoor 应用 Client ID(与后端 CASDOOR_CLIENT_ID 一致) +VITE_CASDOOR_CLIENT_ID=e715d85bf5b2dc8988ac diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..92324c9 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,12 @@ +FROM node:20-alpine AS build +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..de2e77b --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + 企迹 - 政企周报管理系统 + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..3ea7914 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,26 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Vue app + location / { + try_files $uri $uri/ /index.html; + } + + # API proxy + location /api/ { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /health { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..b135bf2 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2096 @@ +{ + "name": "qiji-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "qiji-frontend", + "version": "0.1.0", + "dependencies": { + "axios": "^1.7.9", + "element-plus": "^2.9.1", + "pinia": "^2.3.0", + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "typescript": "~5.6.0", + "vite": "^6.0.5", + "vue-tsc": "^2.2.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.38.tgz", + "integrity": "sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.38", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.38.tgz", + "integrity": "sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.38", + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.38.tgz", + "integrity": "sha512-DuA2GiZawSEW442iw/9+Fkol8hTgb4Ke5KkhmSry65QA7YuyMbIdy8p0XZRMvNwJdgRz307W8g1CSzdvS4nuNg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.38", + "@vue/compiler-dom": "3.5.38", + "@vue/compiler-ssr": "3.5.38", + "@vue/shared": "3.5.38", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.15", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.38.tgz", + "integrity": "sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.38", + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmmirror.com/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.38.tgz", + "integrity": "sha512-pG6LV/NDNRbKizcUjFFLAfjaL8mcv4DmR9avNcUw2gDHBzZneuS2TWCmp633ynzxz9YYKNeEPK2I8Wraqy2HUQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.38.tgz", + "integrity": "sha512-iyW8WVfF1CpCXxncZY5Ei6rSd6oZr5DgEom//fUjRBRl56AXPD+s9ATvukRt77ZFTuYlnVA1bxY+dJB94tWVYw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.38", + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.38.tgz", + "integrity": "sha512-apX2wt9sdfDshS+a2xueFZLVpt0GkRJZSoPmrW/SA4yzXTznhfcMVW59gr7h4YQeY0vJhdJkk2rsIDwgfFgC5A==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.38", + "@vue/runtime-core": "3.5.38", + "@vue/shared": "3.5.38", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.38.tgz", + "integrity": "sha512-vue8vbf2QlV4quHqzwmJy6dWfmRhP1J8l4wtZg60CL6VoKqcPY2oe7may3+1d9qfpedjK5PRLFqd5k3Isj9mUw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.38", + "@vue/shared": "3.5.38" + }, + "peerDependencies": { + "vue": "3.5.38" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.38.tgz", + "integrity": "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "14.3.0", + "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-14.3.0.tgz", + "integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.3.0", + "@vueuse/shared": "14.3.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/metadata": { + "version": "14.3.0", + "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-14.3.0.tgz", + "integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "14.3.0", + "resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-14.3.0.tgz", + "integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.18.0", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.18.0.tgz", + "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "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", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/element-plus": { + "version": "2.14.2", + "resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.14.2.tgz", + "integrity": "sha512-eNH9uP3wQoNqieEIHXiNvIVv+zO5sZDU0CAZq5b0zqSN06DD0/V9xIq1R/qm3rw5k3nBTM1JvpxhCfRbaFLzDQ==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.7.6", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8", + "@types/lodash": "^4.17.24", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "14.3.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.20", + "lodash": "^4.18.1", + "lodash-es": "^4.18.1", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.3.3" + }, + "peerDependencies": { + "vue": "^3.3.7" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "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/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.38.tgz", + "integrity": "sha512-vAMKHfImQlYSy0C+PBue4s3ERZ2xGKfgZg5GXAsLInq1dyh2H78ILVP5sK0KPFPVW4kv+OGCIvBEondcjpZp7A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.38", + "@vue/compiler-sfc": "3.5.38", + "@vue/runtime-dom": "3.5.38", + "@vue/server-renderer": "3.5.38", + "@vue/shared": "3.5.38" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.3.5", + "resolved": "https://registry.npmmirror.com/vue-component-type-helpers/-/vue-component-type-helpers-3.3.5.tgz", + "integrity": "sha512-Fe1jyPJoUGpJOYKOri44jduR7My4yYINOMJISuMAbmrs+L5LbIDUc8NTWZYY3EJLK0yPLuCmcd5zoCsE4k2/KA==", + "license": "MIT" + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..7b2f91f --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,24 @@ +{ + "name": "qiji-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.7.9", + "element-plus": "^2.9.1", + "pinia": "^2.3.0", + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "typescript": "~5.6.0", + "vite": "^6.0.5", + "vue-tsc": "^2.2.0" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..37c7100 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,24 @@ + + + + + diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 0000000..a789144 --- /dev/null +++ b/frontend/src/api/auth.ts @@ -0,0 +1,13 @@ +import api from './index' + +export const authApi = { + casdoorLogin(code: string, state: string) { + return api.post('/auth/casdoor-login', { code, state }) + }, + wecomLogin(code: string) { + return api.post('/auth/wecom-login', { code }) + }, + bindWecom(casdoorCode: string, wecomUserid: string) { + return api.post('/auth/bind-wecom', { casdoor_code: casdoorCode, wecom_userid: wecomUserid }) + }, +} diff --git a/frontend/src/api/customers.ts b/frontend/src/api/customers.ts new file mode 100644 index 0000000..4589156 --- /dev/null +++ b/frontend/src/api/customers.ts @@ -0,0 +1,32 @@ +import api from './index' + +export const customersApi = { + list(params?: any) { + return api.get('/customers/', { params }) + }, + get(id: string) { + return api.get(`/customers/${id}`) + }, + create(data: any) { + return api.post('/customers/', data) + }, + update(id: string, data: any) { + return api.put(`/customers/${id}`, data) + }, + delete(id: string) { + return api.delete(`/customers/${id}`) + }, + checkDuplicate(name: string) { + return api.get(`/customers/check-duplicate/${encodeURIComponent(name)}`) + }, + // Assignments + listAssignments(customerId: string) { + return api.get(`/customers/${customerId}/assignments`) + }, + assignManager(customerId: string, data: any) { + return api.post(`/customers/${customerId}/assignments`, data) + }, + batchAssign(data: any) { + return api.post('/customers/batch-assign', data) + }, +} diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts new file mode 100644 index 0000000..71c43cf --- /dev/null +++ b/frontend/src/api/dashboard.ts @@ -0,0 +1,13 @@ +import api from './index' + +export const dashboardApi = { + getStats() { + return api.get('/dashboard/stats') + }, + getProgress() { + return api.get('/dashboard/progress') + }, + getWeeklyReport(params?: any) { + return api.get('/dashboard/weekly-report', { params }) + }, +} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts new file mode 100644 index 0000000..31db891 --- /dev/null +++ b/frontend/src/api/index.ts @@ -0,0 +1,30 @@ +import axios from 'axios' +import { useAuthStore } from '@/stores/auth' +import router from '@/router' + +const api = axios.create({ + baseURL: '/api', + timeout: 30000, +}) + +api.interceptors.request.use((config) => { + const auth = useAuthStore() + if (auth.token) { + config.headers.Authorization = `Bearer ${auth.token}` + } + return config +}) + +api.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + const auth = useAuthStore() + auth.logout() + router.push('/login') + } + return Promise.reject(error) + } +) + +export default api diff --git a/frontend/src/api/keyVisits.ts b/frontend/src/api/keyVisits.ts new file mode 100644 index 0000000..0da6e6e --- /dev/null +++ b/frontend/src/api/keyVisits.ts @@ -0,0 +1,16 @@ +import api from './index' + +export const keyVisitsApi = { + list(params?: any) { + return api.get('/key-visits/', { params }) + }, + create(data: any) { + return api.post('/key-visits/', data) + }, + update(id: string, data: any) { + return api.put(`/key-visits/${id}`, data) + }, + delete(id: string) { + return api.delete(`/key-visits/${id}`) + }, +} diff --git a/frontend/src/api/miniBusiness.ts b/frontend/src/api/miniBusiness.ts new file mode 100644 index 0000000..7c12684 --- /dev/null +++ b/frontend/src/api/miniBusiness.ts @@ -0,0 +1,16 @@ +import api from './index' + +export const miniBusinessApi = { + list(params?: any) { + return api.get('/mini-business/', { params }) + }, + create(data: any) { + return api.post('/mini-business/', data) + }, + update(id: string, data: any) { + return api.put(`/mini-business/${id}`, data) + }, + delete(id: string) { + return api.delete(`/mini-business/${id}`) + }, +} diff --git a/frontend/src/api/upload.ts b/frontend/src/api/upload.ts new file mode 100644 index 0000000..c119de2 --- /dev/null +++ b/frontend/src/api/upload.ts @@ -0,0 +1,20 @@ +import api from './index' +import axios from 'axios' + +export const uploadApi = { + async getPresignedUrl(filename: string, contentType: string = 'image/jpeg') { + return api.post('/upload/presigned-url', null, { + params: { filename, content_type: contentType }, + }) + }, + getDownloadUrl(objectKey: string) { + return api.get('/upload/download-url', { params: { object_key: objectKey } }) + }, + // Direct upload to MinIO + async uploadFile(uploadUrl: string, file: File) { + return axios.put(uploadUrl, file, { + headers: { 'Content-Type': file.type }, + timeout: 60000, + }) + }, +} diff --git a/frontend/src/api/visits.ts b/frontend/src/api/visits.ts new file mode 100644 index 0000000..ac82520 --- /dev/null +++ b/frontend/src/api/visits.ts @@ -0,0 +1,22 @@ +import api from './index' + +export const visitsApi = { + list(params?: any) { + return api.get('/visits/', { params }) + }, + getToday() { + return api.get('/visits/today') + }, + get(id: string) { + return api.get(`/visits/${id}`) + }, + create(data: any) { + return api.post('/visits/', data) + }, + update(id: string, data: any) { + return api.put(`/visits/${id}`, data) + }, + delete(id: string) { + return api.delete(`/visits/${id}`) + }, +} diff --git a/frontend/src/api/workPlans.ts b/frontend/src/api/workPlans.ts new file mode 100644 index 0000000..005039b --- /dev/null +++ b/frontend/src/api/workPlans.ts @@ -0,0 +1,16 @@ +import api from './index' + +export const workPlansApi = { + list(params?: any) { + return api.get('/work-plans/', { params }) + }, + create(data: any) { + return api.post('/work-plans/', data) + }, + update(id: string, data: any) { + return api.put(`/work-plans/${id}`, data) + }, + delete(id: string) { + return api.delete(`/work-plans/${id}`) + }, +} diff --git a/frontend/src/components/DesktopLayout.vue b/frontend/src/components/DesktopLayout.vue new file mode 100644 index 0000000..fe4e0c4 --- /dev/null +++ b/frontend/src/components/DesktopLayout.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/frontend/src/components/MobileLayout.vue b/frontend/src/components/MobileLayout.vue new file mode 100644 index 0000000..42addf8 --- /dev/null +++ b/frontend/src/components/MobileLayout.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/frontend/src/env.d.ts b/frontend/src/env.d.ts new file mode 100644 index 0000000..949bbc4 --- /dev/null +++ b/frontend/src/env.d.ts @@ -0,0 +1,21 @@ +/// + +interface ImportMetaEnv { + readonly VITE_CASDOOR_ENDPOINT: string + readonly VITE_CASDOOR_CLIENT_ID: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} + +declare module 'element-plus/dist/locale/zh-cn.mjs' { + const zhCn: Record + export default zhCn +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..a917c40 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,13 @@ +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 App from './App.vue' +import router from './router' + +const app = createApp(App) +app.use(createPinia()) +app.use(router) +app.use(ElementPlus, { locale: zhCn as any }) +app.mount('#app') diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 0000000..2455642 --- /dev/null +++ b/frontend/src/router/index.ts @@ -0,0 +1,63 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { useAuthStore } from '@/stores/auth' + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { + path: '/login', + name: 'Login', + component: () => import('@/views/Login.vue'), + meta: { public: true }, + }, + { + path: '/bind-wecom', + name: 'BindWecom', + component: () => import('@/views/BindWecom.vue'), + meta: { public: true }, + }, + // Mobile routes (manager-facing) + { + path: '/m', + component: () => import('@/components/MobileLayout.vue'), + children: [ + { path: '', name: 'MobileHome', component: () => import('@/views/mobile/Home.vue') }, + { path: 'visit/new', name: 'VisitForm', component: () => import('@/views/mobile/VisitForm.vue') }, + { path: 'visit/:id/edit', name: 'VisitEdit', component: () => import('@/views/mobile/VisitForm.vue') }, + { path: 'work-plan/new', name: 'WorkPlanForm', component: () => import('@/views/mobile/WorkPlanForm.vue') }, + { path: 'mini-biz/new', name: 'MiniBusinessForm', component: () => import('@/views/mobile/MiniBusinessForm.vue') }, + { path: 'key-visit/new', name: 'KeyVisitForm', component: () => import('@/views/mobile/KeyVisitForm.vue') }, + { path: 'note/new', name: 'DailyNoteForm', component: () => import('@/views/mobile/DailyNoteForm.vue') }, + { path: 'note/:id/edit', name: 'DailyNoteEdit', component: () => import('@/views/mobile/DailyNoteForm.vue') }, + ], + }, + // Desktop routes (director/leader-facing) + { + path: '/', + component: () => import('@/components/DesktopLayout.vue'), + children: [ + { path: '', name: 'Dashboard', component: () => import('@/views/desktop/Dashboard.vue') }, + { path: 'weekly-report', name: 'WeeklyReport', component: () => import('@/views/desktop/WeeklyReport.vue') }, + { path: 'workspace', name: 'ManagerWorkspace', component: () => import('@/views/desktop/ManagerWorkspace.vue') }, + { path: 'customers', name: 'CustomerManage', component: () => import('@/views/desktop/CustomerManage.vue') }, + { path: 'users', name: 'UserManage', component: () => import('@/views/desktop/UserManage.vue') }, + { path: 'settings', name: 'Settings', component: () => import('@/views/desktop/Settings.vue') }, + ], + }, + ], +}) + +router.beforeEach((to, _from, next) => { + const auth = useAuthStore() + if (to.meta.public) { + next() + return + } + if (!auth.isLoggedIn) { + next('/login') + return + } + next() +}) + +export default router diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts new file mode 100644 index 0000000..e81c7df --- /dev/null +++ b/frontend/src/stores/auth.ts @@ -0,0 +1,56 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { authApi } from '@/api/auth' + +export const useAuthStore = defineStore('auth', () => { + const token = ref(localStorage.getItem('token') || '') + const userId = ref(localStorage.getItem('userId') || '') + const name = ref(localStorage.getItem('userName') || '') + const role = ref(localStorage.getItem('userRole') || '') + + const isLoggedIn = computed(() => !!token.value) + const isManager = computed(() => role.value === 'manager') + const isDirector = computed(() => role.value === 'director') + const isLeader = computed(() => role.value === 'leader') + + function saveLogin(data: { access_token: string; user_id: string; name: string; role: string }) { + token.value = data.access_token + userId.value = data.user_id + name.value = data.name + role.value = data.role + localStorage.setItem('token', data.access_token) + localStorage.setItem('userId', data.user_id) + localStorage.setItem('userName', data.name) + localStorage.setItem('userRole', data.role) + } + + function logout() { + token.value = '' + userId.value = '' + name.value = '' + role.value = '' + localStorage.clear() + } + + async function casdoorLogin(code: string, state: string) { + const res = await authApi.casdoorLogin(code, state) + saveLogin(res.data) + return res.data + } + + async function wecomLogin(code: string) { + return await authApi.wecomLogin(code) + } + + async function bindWecom(casdoorCode: string, wecomUserid: string) { + const res = await authApi.bindWecom(casdoorCode, wecomUserid) + saveLogin(res.data) + return res.data + } + + return { + token, userId, name, role, + isLoggedIn, isManager, isDirector, isLeader, + saveLogin, logout, casdoorLogin, wecomLogin, bindWecom, + } +}) diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts new file mode 100644 index 0000000..4778a14 --- /dev/null +++ b/frontend/src/utils/index.ts @@ -0,0 +1,5 @@ +/** Get today's date in YYYY-MM-DD format (local timezone). */ +export function todayStr(): string { + const d = new Date() + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` +} diff --git a/frontend/src/views/BindWecom.vue b/frontend/src/views/BindWecom.vue new file mode 100644 index 0000000..b645d62 --- /dev/null +++ b/frontend/src/views/BindWecom.vue @@ -0,0 +1,52 @@ + + + + + diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue new file mode 100644 index 0000000..2e3f92f --- /dev/null +++ b/frontend/src/views/Login.vue @@ -0,0 +1,124 @@ + + + + + diff --git a/frontend/src/views/desktop/CustomerManage.vue b/frontend/src/views/desktop/CustomerManage.vue new file mode 100644 index 0000000..f30cd9b --- /dev/null +++ b/frontend/src/views/desktop/CustomerManage.vue @@ -0,0 +1,424 @@ + + + + + diff --git a/frontend/src/views/desktop/Dashboard.vue b/frontend/src/views/desktop/Dashboard.vue new file mode 100644 index 0000000..7c26156 --- /dev/null +++ b/frontend/src/views/desktop/Dashboard.vue @@ -0,0 +1,111 @@ + + + + + diff --git a/frontend/src/views/desktop/ManagerWorkspace.vue b/frontend/src/views/desktop/ManagerWorkspace.vue new file mode 100644 index 0000000..2fde565 --- /dev/null +++ b/frontend/src/views/desktop/ManagerWorkspace.vue @@ -0,0 +1,319 @@ + + + + + diff --git a/frontend/src/views/desktop/Settings.vue b/frontend/src/views/desktop/Settings.vue new file mode 100644 index 0000000..5cd6a0d --- /dev/null +++ b/frontend/src/views/desktop/Settings.vue @@ -0,0 +1,172 @@ + + + + + diff --git a/frontend/src/views/desktop/UserManage.vue b/frontend/src/views/desktop/UserManage.vue new file mode 100644 index 0000000..2076ae6 --- /dev/null +++ b/frontend/src/views/desktop/UserManage.vue @@ -0,0 +1,126 @@ + + + + + diff --git a/frontend/src/views/desktop/WeeklyReport.vue b/frontend/src/views/desktop/WeeklyReport.vue new file mode 100644 index 0000000..fab8d72 --- /dev/null +++ b/frontend/src/views/desktop/WeeklyReport.vue @@ -0,0 +1,257 @@ + + + + + diff --git a/frontend/src/views/mobile/DailyNoteForm.vue b/frontend/src/views/mobile/DailyNoteForm.vue new file mode 100644 index 0000000..71725e8 --- /dev/null +++ b/frontend/src/views/mobile/DailyNoteForm.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/frontend/src/views/mobile/Home.vue b/frontend/src/views/mobile/Home.vue new file mode 100644 index 0000000..f5510cb --- /dev/null +++ b/frontend/src/views/mobile/Home.vue @@ -0,0 +1,136 @@ + + + + + diff --git a/frontend/src/views/mobile/KeyVisitForm.vue b/frontend/src/views/mobile/KeyVisitForm.vue new file mode 100644 index 0000000..c16414a --- /dev/null +++ b/frontend/src/views/mobile/KeyVisitForm.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/frontend/src/views/mobile/MiniBusinessForm.vue b/frontend/src/views/mobile/MiniBusinessForm.vue new file mode 100644 index 0000000..563fdf2 --- /dev/null +++ b/frontend/src/views/mobile/MiniBusinessForm.vue @@ -0,0 +1,86 @@ + + + + + diff --git a/frontend/src/views/mobile/VisitForm.vue b/frontend/src/views/mobile/VisitForm.vue new file mode 100644 index 0000000..e2b9564 --- /dev/null +++ b/frontend/src/views/mobile/VisitForm.vue @@ -0,0 +1,269 @@ + + + + + diff --git a/frontend/src/views/mobile/WorkPlanForm.vue b/frontend/src/views/mobile/WorkPlanForm.vue new file mode 100644 index 0000000..100084e --- /dev/null +++ b/frontend/src/views/mobile/WorkPlanForm.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..268253e --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "preserve", + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true, + "paths": { + "@/*": ["./src/*"] + }, + "baseUrl": "." + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..5ce2b57 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { resolve } from 'path' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': resolve(__dirname, 'src'), + }, + }, + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:8002', + changeOrigin: true, + }, + '/health': { + target: 'http://localhost:8002', + changeOrigin: true, + }, + }, + }, +}) diff --git a/政企周报管理系统-设计文档.md b/政企周报管理系统-设计文档.md new file mode 100755 index 0000000..7e80196 --- /dev/null +++ b/政企周报管理系统-设计文档.md @@ -0,0 +1,268 @@ + # 企迹 (qiji) — 政企周报管理系统 设计文档 + +## 项目背景 + +中国电信政企客户经理团队目前使用 Excel 进行周报管理,包含四个 Sheet:每日拜访记录、下周工作计划、小微业务商机跟单、要客拜访计划。现有方式存在填报效率低、无法挂载照片、汇总查看不便、考核靠人工点名扣分、客户数据无法沉淀等问题。 + +本系统以 FastAPI + Vue 3 构建,部署在本地服务器 + 云服务器的混合拓扑上,对接已有的 Casdoor(认证)、PostgreSQL(数据库)、MinIO(文件存储)、企业微信(消息推送与快捷入口)。 + +## 用户规模与角色 + +小型支局,约 5-8 人:3-5 名客户经理 + 1 名支局长 + 偶尔分管领导查看。 + +### 角色权限矩阵 + +| 角色 | 数据可见性 | 操作权限 | +|------|-----------|---------| +| 客户经理 | 只看自己的拜访记录、工作计划、商机、要客拜访 | 填/改/删自己的记录,上传照片,新建客户 | +| 支局长 | 全支局所有人的所有数据 | 增删改所有人记录,导出汇总,查看填报统计,手动触发催办/推送公告,管理客户档案与归属分配 | +| 分管领导 | 同支局长全量视图 | 仅查看和导出,不可编辑 | + +## 技术架构 + +``` +本地服务器(内网) +├── FastAPI (uvicorn, port 8000) +├── PostgreSQL(已有,复用) +└── MinIO(已有,复用) + +云服务器 +└── Vue 3 前端 (nginx, port 443, 域名) + ↕ API 请求走 HTTPS → 本地服务器公网映射端口 + +Casdoor(已有部署,OIDC) +企业微信(管理后台创建自建应用) +``` + +开发阶段前后端均在本地:`localhost:8000` + `localhost:5173` 联调。 + +### 后端:FastAPI + +- 单体应用,FastAPI + SQLAlchemy (async) + Alembic 做数据迁移 +- JWT 鉴权,token 签发与验证对接 Casdoor OIDC +- 文件上传:客户端获取 MinIO 预签名 URL 直传,API 只记录 object key +- 定时任务:APScheduler,每日 18:00 检查填报情况触发企微提醒 + +### 前端:Vue 3 + +- Vue 3 + Vite + TypeScript +- 组件库:Element Plus(中文生态好,表格/表单组件成熟) +- 两套适配布局:PC Web 端(支局长/分管领导汇总查看)+ 移动端(客户经理外勤快速填报,也是企微应用落地页) + +### 存储:MinIO + +- 组织方式:`{date}/{manager}/{uuid}.jpg` +- 单张限制 10MB,一次拜访最多 9 张 +- 上传走预签名 URL,绕过 API 中转 +- 查看走预签名下载 URL(有效期 1 小时) +- 缩略图由 MinIO 策略自动生成 +- 删除拜访记录时同步清理关联照片 + +### 认证:Casdoor + +- FastAPI 作为 OIDC Relying Party +- 用户在 Casdoor 已分配角色(客户经理 / 支局长 / 分管领导),JWT 携带角色信息 +- 企业微信用户绑定 Casdoor 账号后,可实现企微内静默登录 + +## 数据模型 + +共 7 张核心表,全部使用 PostgreSQL。 + +### customers — 客户档案 + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | UUID | 主键 | +| name | VARCHAR(200) | 单位名称 | +| industry | VARCHAR(100) | 所属行业 | +| address | VARCHAR(500) | 单位地址 | +| in_use_services | TEXT | 在用业务(枚举文本) | +| monthly_fee | VARCHAR(100) | 月使用费 | +| created_by | UUID → users.id | 创建人 | +| created_at | TIMESTAMP | | +| updated_at | TIMESTAMP | | + +### customer_contacts — 客户联系人 + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | UUID | 主键 | +| customer_id | UUID → customers.id | 所属客户 | +| name | VARCHAR(50) | 联系人姓名 | +| phone | VARCHAR(20) | 联系方式 | +| role_desc | VARCHAR(100) | 角色描述(如"技术对接人") | + +### customer_assignments — 客户经理归属 + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | UUID | 主键 | +| customer_id | UUID → customers.id | | +| manager_id | UUID → users.id | | +| role | VARCHAR(20) | 'primary' / 'assistant' | +| assigned_at | TIMESTAMP | 分配时间 | +| assigned_by | UUID → users.id | 操作人(支局长) | + +支持归属转移:支局长修改 `manager_id` 即可,历史拜访记录保留在 `visits.manager_id` 原值不变。 + +### visits — 每日拜访记录(对应"周报"sheet) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | UUID | 主键 | +| customer_id | UUID → customers.id | 走访单位 | +| visit_date | DATE | 拜访日期 | +| visit_method | VARCHAR(20) | 上门/电话/微信/出差 | +| time_range | VARCHAR(30) | 如 "9:00-10:00" | +| communication_content | TEXT | 沟通内容 | +| customer_demand | TEXT | 客户需求 | +| companions | UUID[] | 同访人员 ID 数组 | +| photos | TEXT[] | MinIO object key 数组 | +| manager_id | UUID → users.id | 客户经理 | +| created_at | TIMESTAMP | | +| updated_at | TIMESTAMP | | + +### work_plans — 下周工作计划 + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | UUID | 主键 | +| customer_id | UUID → customers.id | 计划走访单位 | +| plan_content | TEXT | 工作计划 | +| plan_date | DATE | 计划拜访时间 | +| manager_id | UUID → users.id | | +| status | VARCHAR(20) | 计划中/已完成/已取消 | + +### mini_business — 小微业务商机(对应"小微业务"sheet) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | UUID | 主键 | +| customer_id | UUID → customers.id | 单位名称 | +| product_type | VARCHAR(200) | 产品类型 | +| amount | VARCHAR(100) | 金额 | +| follow_up_detail | TEXT | 跟进内容具体情况 | +| status | VARCHAR(50) | 跟进状态 | +| manager_id | UUID → users.id | | +| expected_revenue_date | VARCHAR(50) | 预计列收时间 | + +### key_visits — 要客拜访计划 + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | UUID | 主键 | +| customer_id | UUID → customers.id | 单位 | +| urgency_level | VARCHAR(10) | 紧急重要度:重要/一般/紧急 | +| description | TEXT | 内容描述 | +| progress_status | VARCHAR(20) | 进展状态 | +| planned_date | VARCHAR(50) | 计划拜访时间 | +| planned_visitor | VARCHAR(100) | 计划拜访人 | +| visit_target | VARCHAR(100) | 拜访对象 | +| manager_id | UUID → users.id | | + +### users — 用户(对接 Casdoor) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | UUID | 主键 | +| casdoor_id | VARCHAR(100) | Casdoor 用户 ID | +| name | VARCHAR(50) | 姓名 | +| role | VARCHAR(30) | manager / director / leader | +| department | VARCHAR(100) | 部门 | +| wecom_userid | VARCHAR(100) | 企业微信 userid | + +## 核心功能设计 + +### 一、客户经理填报 + +**入口**:移动端「今日拜访」按钮 + 企微工作台菜单「今日拜访」。 + +**表单字段**: + +- 客户单位:下拉选择(仅显示自己负责的客户 + 自己新建的客户),支持搜索,支持快速新建客户 +- 选择客户后自动带出联系人信息 +- 拜访日期:默认今天 +- 拜访方式:单选(上门 / 电话 / 微信 / 出差) +- 时间范围:两个时间选择器 +- 同访人员:多选,选了谁自动在对方账号下创建一条预填草稿(沟通内容留空让对方补) +- 沟通内容:自由文本 +- 客户需求:自由文本(用于汇总时的关键字提取) +- 拜访照片:最多 9 张,拍照或从相册选,走 MinIO 预签名直传 + +**首页状态**:显示「今天已录入 N 条」,下方为当天已填记录的摘要卡片列表。 + +### 二、支局长汇总视图 + +**仪表盘首页**: + +- 四张数字卡片:本周拜访次数、工作计划条数、商机跟单条数、要客拜访条数 +- 填报进度条:每个客户经理的填报情况(已填/未填),未填人员红色标记 ⚠ +- 快捷入口:本周周报详情、导出 Excel、历史周报 + +**周报详情页**:四个 Tab 对应四个业务模块 + +- 支持按人筛选、按客户筛选 +- 时间轴模式按天分组 +- 照片缩略图,点击放大查看 +- 一键导出 Excel(格式与现有模板一致) + +**考核与催办**: + +- 每日 18:00 自动检查,企微推送提醒未填报的客户经理 +- 支局长可手动点击「催办」按钮,选择人员发送应用消息 +- 支局长可编辑一条自定义公告,推送到全员企微 +- 支局长可设置"当日最低拜访条数"阈值,低于阈值也预警 + +### 三、分管领导视图 + +与支局长相同的全量数据视图,所有编辑按钮灰色禁用。核心目标是随时打开看,首屏加载 1 秒以内。 + +### 四、客户档案管理 + +- 独立客户档案,包含单位基本信息、在用业务、月使用费、关键联系人 +- 客户经理—客户归属关系(主负责 / 协办),支局长统一管理分配 +- 支持批量导入:从存量收入清单 Excel 导入客户和归属关系 +- 客户查重:建客户时自动提示疑似重复,支局长定期合并 +- **归属转移**:客户经理离职时,支局长将客户批量转移给新同事,历史拜访记录完整保留在原客户经理名下 + +### 五、数据导入导出 + +- **导入**:上传旧周报 Excel → 系统解析 sheet → 预览匹配字段 → 确认导入;同一天同一人同一客户自动跳过不重复 +- **导出**:支局长点击「导出本周周报」,生成与原 Excel 模板格式完全一致的四 sheet .xlsx 文件 + +## 企业微信集成 + +### 工作台菜单 + +| 菜单名称 | 跳转目标 | +|---------|---------| +| 今日拜访 | 移动端填报页 | +| 本周周报 | 支局长汇总视图 | +| 客户查询 | 客户档案搜索页 | + +### 登录流程 + +1. 用户从企微菜单进入,前端拿企微 `code` 换取 `userid` +2. 服务端用 `userid` 查找已绑定的 Casdoor 账号 +3. 已绑定:直接签发 JWT,用户无感进入系统 +4. 未绑定:引导去 Casdoor 完成一次账号绑定,之后永久生效 + +### 消息推送 + +| 场景 | 方式 | 触发 | +|------|------|------| +| 每日填报提醒 | 应用消息 → 未填人员 | 每日 18:00 自动 | +| 手动催办 | 支局长选人 → 发送应用消息 | 手动 | +| 支局长公告 | 编辑公告 → 发应用消息 → 全员 | 手动 | + +### 后续扩展(首期不做) + +- 企微聊天侧边栏:在跟客户的聊天窗口右侧弹出拜访记录入口,自动带入客户信息 + +## 部署说明 + +1. FastAPI 后端部署于本地服务器,通过内网访问 PostgreSQL 和 MinIO +2. API 端口通过公网映射暴露给云服务器上的前端 +3. Vue 前端部署于云服务器 nginx,HTTPS 访问 +4. 开发阶段前后端均在本地运行,联调结构与生产一致 +5. Casdoor、PostgreSQL、MinIO、企业微信应用均为已有基础设施,本系统作为新接入方