diff --git a/PROGRESS.md b/PROGRESS.md
new file mode 100644
index 0000000..705e2ab
--- /dev/null
+++ b/PROGRESS.md
@@ -0,0 +1,185 @@
+# TradeMate (外贸小助手) - 项目进度文档
+
+**更新时间**: 2026-05-12 16:30
+**状态**: ✅ 游客模式已实现 + H5 底部导航已修复 - 所有功能正常
+
+---
+
+## 一、服务状态
+
+| 服务 | 地址 | 状态 |
+|------|------|------|
+| 后端 API | http://localhost:8000 | ✅ 运行中 |
+| 前端 H5 | http://localhost:5173 | ✅ 运行中 |
+| API 文档 | http://localhost:8000/docs | ✅ 可用 |
+| Redis | localhost:6379 | ✅ 运行中 |
+| PostgreSQL | localhost:5432 | ✅ 运行中 |
+
+**测试用户**:
+- 手机号: `13800138099` (或注册新用户)
+- 密码: `testpass123`
+
+---
+
+## 二、已完成的工作
+
+### 1. Bug 修复 (共 10 个)
+
+| 序号 | 文件 | 问题描述 | 状态 |
+|------|------|----------|------|
+| 1 | `app/main.py` | 中间件顺序错误 - TierMiddleware 需要最先执行 | ✅ 已修复 |
+| 2 | `app/core/middleware.py` | 缺少 getattr 防御性检查 | ✅ 已修复 |
+| 3 | `app/models/user.py` | Product.user_id 缺少 ForeignKey | ✅ 已修复 |
+| 4 | `app/models/customer.py` | Customer/Conversation.user_id 缺少 ForeignKey | ✅ 已修复 |
+| 5 | `app/models/quotation.py` | Quotation.user_id 缺少 ForeignKey | ✅ 已修复 |
+| 6 | `app/api/v1/deps.py` | get_current_user_id 读取参数而非 HTTP Header | ✅ 已修复 |
+| 7 | `app/core/security.py` | passlib 与 bcrypt 版本不兼容 | ✅ 已替换为直接 bcrypt |
+| 8 | `app/ai/providers/openai.py` | max_tokens=1000 不足,导致 Sensenova content 为 None | ✅ 已增加到 3000 |
+| 9 | `app/ai/providers/openai.py` | Sensenova 特殊 reasoning 字段未处理 | ✅ 已增强 fallback 逻辑 |
+| 10 | `src/App.vue` + 全局样式 | H5 底部导航覆盖内容 — uni-page 高度未扣除 tabbar | ✅ 设置 `height: calc(100% - 50px)` + `overflow-y: auto` |
+
+### 2. 游客模式 (Guest Mode) 实现 ✅
+
+#### 后端实现
+
+| 功能 | 接口 | 说明 |
+|------|------|------|
+| 游客登录 | `POST /api/v1/auth/login/guest` | 生成 JWT,包含 `is_guest: true`,无需数据库用户 |
+| 公开翻译 | `POST /api/v1/translate/public/translate` | 无需认证,支持中英互译 |
+| 公开信息提取 | `POST /api/v1/translate/public/extract` | 无需认证,提取客户询盘信息 |
+
+**游客登录返回示例**:
+```json
+{
+ "access_token": "eyJhbGciOiJIUzI1NiIs...",
+ "refresh_token": "eyJhbGciOiJIUzI1NiIs...",
+ "token_type": "bearer",
+ "user": {
+ "id": "guest_185039a65035",
+ "phone": null,
+ "username": "游客用户",
+ "tier": "guest",
+ "is_guest": true
+ }
+}
+```
+
+#### 前端实现
+
+| 文件 | 变更 |
+|------|------|
+| `src/utils/api.js` | 新增 `authApi.guestLogin()`、`translateApi.publicTranslate()`、`translateApi.publicExtract()` 方法 |
+| `src/pages/login/login.vue` | "快速体验"按钮调用游客登录并存储 token |
+| `src/pages/index/index.vue` | 游客模式下使用公开 API 端点 |
+
+#### 游客模式测试结果
+
+| 测试项 | 结果 |
+|--------|------|
+| 游客登录 | ✅ 返回 JWT,包含 `is_guest: true` |
+| 公开翻译 (EN→ZH) | ✅ 正常工作 |
+| 公开翻译 (ZH→EN) | ✅ 正常工作 |
+| 公开信息提取 | ✅ 正确提取 intent、product、quantity、contact_info |
+
+### 3. 问题根因分析
+
+**Sensenova API 返回 None 的问题**:
+- 原因: Sensenova 模型有 `reasoning` 字段(思考过程),当 `max_tokens` 不足时,模型先用 tokens 思考,还没输出 content 就被截断了
+- 解决方案:
+ 1. 增加 `max_tokens` 从 1000 到 3000
+ 2. 增强 fallback 逻辑:当 `content` 为 None 时,尝试从 `reasoning` 中提取最终答案,支持多种模式匹配
+
+### 4. 基础 API 测试通过
+
+| 功能 | 接口 | 状态 |
+|------|------|------|
+| 健康检查 | `GET /health` | ✅ 200 |
+| 用户注册 | `POST /api/v1/auth/register` | ✅ 200 |
+| 用户登录 | `POST /api/v1/auth/login` | ✅ 200 |
+| 游客登录 | `POST /api/v1/auth/login/guest` | ✅ 200 |
+| 获取用户信息 | `GET /api/v1/auth/me` | ✅ 200 |
+| 产品 CRUD | `/api/v1/products/*` | ✅ 正常 |
+| 客户 CRUD | `/api/v1/customers/*` | ✅ 正常 |
+| 数据分析 | `/api/v1/analytics/*` | ✅ 正常 |
+| 套餐计划 | `GET /api/v1/payment/plans` | ✅ 正常 |
+
+### 5. AI 功能测试 (全部通过 ✅)
+
+| 功能 | 接口 | 状态 | 测试结果 |
+|------|------|------|----------|
+| 翻译 | `POST /api/v1/translate/` | ✅ 正常 | 中译英、英译中都正常 |
+| 智能回复 | `POST /api/v1/translate/reply` | ✅ 正常 | 生成 2 种风格回复建议 |
+| 信息提取 | `POST /api/v1/translate/extract` | ✅ 正常 | 正确提取客户意图、产品、数量 |
+| 公开翻译 | `POST /api/v1/translate/public/translate` | ✅ 正常 | 无需认证,中英互译 |
+| 公开提取 | `POST /api/v1/translate/public/extract` | ✅ 正常 | 无需认证,提取信息 |
+| 营销文案 | `POST /api/v1/marketing/generate` | ✅ 正常 | 生成 3 种风格文案 |
+| 报价单生成 | `POST /api/v1/quotations/generate-from-inquiry` | ✅ 正常 | 从询盘自动生成报价单 |
+| 数据分析 | `GET /api/v1/analytics/overview` | ✅ 正常 | 客户/翻译/报价单统计 |
+
+### 6. 前端 H5 服务
+
+前端 uni-app + Vue 3 项目已启动:
+- 地址: http://localhost:5173
+- 后端 API 代理配置: `http://localhost:8000`
+
+**前端功能**:
+- 登录页: 支持 "快速体验" 进入游客模式
+- 首页: 游客模式显示快速体验区域,支持翻译和信息提取
+- 游客模式: 使用公开 API 端点,无需登录
+
+---
+
+## 三、待办事项
+
+### 中优先级
+1. 浏览器端手动测试(建议用户在浏览器中访问 http://localhost:5173)
+2. 测试 WhatsApp 集成
+3. 性能优化测试
+
+---
+
+## 四、技术栈
+
+| 层级 | 技术 |
+|------|------|
+| 后端 | FastAPI + SQLAlchemy + asyncpg |
+| 数据库 | PostgreSQL + Redis |
+| AI 提供商 | Sensenova (星火大模型), Spark (科大讯飞) |
+| 前端 | uni-app + Vue 3 + Vite |
+
+---
+
+## 五、历史变更记录
+
+| 日期 | 变更内容 |
+|------|----------|
+| 2026-05-12 | 修复 9 个 Bug,启动后端+前端服务,完成所有 API 测试,AI 功能全部正常 |
+| 2026-05-12 | 实现游客模式:新增 `/api/v1/auth/login/guest`、`/api/v1/translate/public/*` 端点,前端支持游客体验 |
+| 2026-05-12 | 修复 H5 底部导航覆盖问题:精简 App.vue,uni-page 设置 `calc(100% - 50px)` + 独立滚动 |
+
+---
+
+**启动脚本**: `/tmp/start_trademate.sh`
+**日志文件**:
+- 后端: `/tmp/trademate_backend.log`
+- 前端: `/tmp/trademate_frontend.log`
+
+---
+
+## 六、快速验证
+
+用户可以在浏览器中访问:
+- **前端 H5**: http://localhost:5173
+- **API 文档**: http://localhost:8000/docs
+
+**游客模式体验**:
+1. 点击 "快速体验" 按钮
+2. 无需登录即可体验翻译和信息提取功能
+
+**注册用户登录**:
+- 手机号: `13800138099`
+- 密码: `testpass123`
+
+---
+
+*本文档由任务进度跟踪系统维护*
diff --git a/backend/.env.example b/backend/.env.example
index fd3c61e..4b53392 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -40,6 +40,10 @@ EXCHANGE_RATE_API_KEY=
UPLOAD_DIR=./uploads
MAX_UPLOAD_SIZE=10485760
+# 错误监控 (Sentry)
+SENTRY_DSN=
+DEBUG=true
+
# URL
FRONTEND_URL=http://localhost:3000
BACKEND_URL=http://localhost:8000
diff --git a/backend/Dockerfile b/backend/Dockerfile
index c2beffd..87a7fd0 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -6,6 +6,12 @@ RUN apt-get update && apt-get install -y \
gcc \
postgresql-client \
libpq-dev \
+ libcairo2 \
+ libpango-1.0-0 \
+ libpangocairo-1.0-0 \
+ libgdk-pixbuf2.0-0 \
+ libffi-dev \
+ shared-mime-info \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
diff --git a/backend/alembic/env.py b/backend/alembic/env.py
index ed34ff8..ca6b71b 100644
--- a/backend/alembic/env.py
+++ b/backend/alembic/env.py
@@ -13,7 +13,7 @@ if config.config_file_name is not None:
fileConfig(config.config_file_name)
from app.database import Base
-from app.models import User, Product, Customer, Conversation, Message, Quotation, QuotationItem, CorpusEntry
+from app.models import User, Product, Customer, Conversation, Message, Quotation, QuotationItem, CorpusEntry, Team, TeamMember, UsageLog, Notification, Feedback, Subscription, PreferenceAnalysis, MarketingEffect, Device, FollowupStrategy, FollowupLog
target_metadata = Base.metadata
diff --git a/backend/alembic/versions/001_initial.py b/backend/alembic/versions/001_initial.py
index 475a65c..79c956b 100644
--- a/backend/alembic/versions/001_initial.py
+++ b/backend/alembic/versions/001_initial.py
@@ -25,6 +25,7 @@ def upgrade() -> None:
sa.Column('username', sa.String(length=100), nullable=True),
sa.Column('password_hash', sa.String(length=255), nullable=True),
sa.Column('tier', sa.String(length=50), nullable=True),
+ sa.Column('role', sa.String(length=20), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
@@ -171,7 +172,7 @@ def upgrade() -> None:
sa.Column('user_edited', sa.Boolean(), nullable=True),
sa.Column('user_rating', sa.Integer(), nullable=True),
sa.Column('usage_count', sa.Integer(), nullable=True),
- sa.Column('embedding', postgresql.Vector(length=768), nullable=True),
+ sa.Column('embedding', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('metadata', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
diff --git a/backend/alembic/versions/002_teams_analytics.py b/backend/alembic/versions/002_teams_analytics.py
new file mode 100644
index 0000000..0ed7cf3
--- /dev/null
+++ b/backend/alembic/versions/002_teams_analytics.py
@@ -0,0 +1,68 @@
+"""add teams and analytics tables
+
+Revision ID: 002
+Revises: 001
+Create Date: 2026-05-09
+
+"""
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+revision: str = '002'
+down_revision: Union[str, None] = '001'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.create_table('teams',
+ sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
+ sa.Column('name', sa.String(length=255), nullable=False),
+ sa.Column('owner_id', postgresql.UUID(as_uuid=True), nullable=False),
+ sa.Column('description', sa.Text(), nullable=True),
+ sa.Column('member_count', sa.Integer(), nullable=True),
+ sa.Column('max_members', sa.Integer(), nullable=True),
+ sa.Column('tier', sa.String(length=50), nullable=True),
+ sa.Column('is_active', sa.Boolean(), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=True),
+ sa.Column('updated_at', sa.DateTime(), nullable=True),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_teams_owner_id'), 'teams', ['owner_id'], unique=False)
+
+ op.create_table('team_members',
+ sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
+ sa.Column('team_id', postgresql.UUID(as_uuid=True), nullable=False),
+ sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
+ sa.Column('role', sa.String(length=50), nullable=True),
+ sa.Column('invited_by', postgresql.UUID(as_uuid=True), nullable=True),
+ sa.Column('status', sa.String(length=50), nullable=True),
+ sa.Column('joined_at', sa.DateTime(), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=True),
+ sa.ForeignKeyConstraint(['team_id'], ['teams.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_team_members_team_id'), 'team_members', ['team_id'], unique=False)
+ op.create_index(op.f('ix_team_members_user_id'), 'team_members', ['user_id'], unique=False)
+
+ op.create_table('usage_logs',
+ sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
+ sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
+ sa.Column('team_id', postgresql.UUID(as_uuid=True), nullable=True),
+ sa.Column('action', sa.String(length=100), nullable=False),
+ sa.Column('detail', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
+ sa.Column('ip_address', sa.String(length=50), nullable=True),
+ sa.Column('user_agent', sa.String(length=255), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=True),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_usage_logs_user_id'), 'usage_logs', ['user_id'], unique=False)
+ op.create_index(op.f('ix_usage_logs_team_id'), 'usage_logs', ['team_id'], unique=False)
+
+
+def downgrade() -> None:
+ op.drop_table('usage_logs')
+ op.drop_table('team_members')
+ op.drop_table('teams')
diff --git a/backend/alembic/versions/003_notifications_feedback_p3.py b/backend/alembic/versions/003_notifications_feedback_p3.py
new file mode 100644
index 0000000..5cb31a8
--- /dev/null
+++ b/backend/alembic/versions/003_notifications_feedback_p3.py
@@ -0,0 +1,107 @@
+"""add notifications, feedback, subscription, and p3 tables
+
+Revision ID: 003
+Revises: 002
+Create Date: 2026-05-09
+
+"""
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+revision: str = '003'
+down_revision: Union[str, None] = '002'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.create_table('notifications',
+ sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
+ sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
+ sa.Column('title', sa.String(length=255), nullable=False),
+ sa.Column('content', sa.Text(), nullable=False),
+ sa.Column('notification_type', sa.String(length=50), nullable=True),
+ sa.Column('reference_type', sa.String(length=50), nullable=True),
+ sa.Column('reference_id', sa.String(length=255), nullable=True),
+ sa.Column('is_read', sa.Boolean(), nullable=True),
+ sa.Column('metadata', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=True),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_notifications_user_id'), 'notifications', ['user_id'], unique=False)
+
+ op.create_table('feedbacks',
+ sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
+ sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
+ sa.Column('category', sa.String(length=50), nullable=True),
+ sa.Column('content', sa.Text(), nullable=False),
+ sa.Column('contact', sa.String(length=100), nullable=True),
+ sa.Column('status', sa.String(length=20), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=True),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_feedbacks_user_id'), 'feedbacks', ['user_id'], unique=False)
+
+ op.create_table('subscriptions',
+ sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
+ sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
+ sa.Column('plan', sa.String(length=50), nullable=False),
+ sa.Column('status', sa.String(length=20), nullable=True),
+ sa.Column('started_at', sa.DateTime(), nullable=True),
+ sa.Column('expires_at', sa.DateTime(), nullable=True),
+ sa.Column('auto_renew', sa.Boolean(), nullable=True),
+ sa.Column('payment_provider', sa.String(length=50), nullable=True),
+ sa.Column('payment_id', sa.String(length=255), nullable=True),
+ sa.Column('amount', sa.Float(), nullable=True),
+ sa.Column('currency', sa.String(length=10), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=True),
+ sa.Column('updated_at', sa.DateTime(), nullable=True),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_subscriptions_user_id'), 'subscriptions', ['user_id'], unique=False)
+
+ op.create_table('preference_analyses',
+ sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
+ sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
+ sa.Column('task_type', sa.String(length=50), nullable=False),
+ sa.Column('preferred_tone', sa.String(length=50), nullable=True),
+ sa.Column('preferred_style', sa.String(length=50), nullable=True),
+ sa.Column('common_replacements', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
+ sa.Column('avg_formality_score', sa.Float(), nullable=True),
+ sa.Column('greeting_style', sa.String(length=100), nullable=True),
+ sa.Column('sign_off_style', sa.String(length=100), nullable=True),
+ sa.Column('analysis_data', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
+ sa.Column('confidence', sa.Float(), nullable=True),
+ sa.Column('interaction_count', sa.Integer(), nullable=True),
+ sa.Column('last_analysis_at', sa.DateTime(), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=True),
+ sa.Column('updated_at', sa.DateTime(), nullable=True),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_preference_analyses_user_id'), 'preference_analyses', ['user_id'], unique=False)
+
+ op.create_table('marketing_effects',
+ sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
+ sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
+ sa.Column('content_hash', sa.String(length=64), nullable=False),
+ sa.Column('product_id', postgresql.UUID(as_uuid=True), nullable=True),
+ sa.Column('product_name', sa.String(length=255), nullable=True),
+ sa.Column('channel', sa.String(length=50), nullable=True),
+ sa.Column('event_type', sa.String(length=50), nullable=False),
+ sa.Column('target_audience', sa.String(length=255), nullable=True),
+ sa.Column('metadata', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=True),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_marketing_effects_user_id'), 'marketing_effects', ['user_id'], unique=False)
+ op.create_index(op.f('ix_marketing_effects_content_hash'), 'marketing_effects', ['content_hash'], unique=False)
+
+
+def downgrade() -> None:
+ op.drop_table('marketing_effects')
+ op.drop_table('preference_analyses')
+ op.drop_table('subscriptions')
+ op.drop_table('feedbacks')
+ op.drop_table('notifications')
diff --git a/backend/alembic/versions/004_devices.py b/backend/alembic/versions/004_devices.py
new file mode 100644
index 0000000..3e548e8
--- /dev/null
+++ b/backend/alembic/versions/004_devices.py
@@ -0,0 +1,36 @@
+"""add devices table for push notification registration
+
+Revision ID: 004
+Revises: 003
+Create Date: 2026-05-10
+
+"""
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+revision: str = '004'
+down_revision: Union[str, None] = '003'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.create_table('devices',
+ sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
+ sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
+ sa.Column('platform', sa.String(length=50), nullable=True),
+ sa.Column('push_token', sa.String(length=500), nullable=True),
+ sa.Column('client_id', sa.String(length=255), nullable=False),
+ sa.Column('device_info', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
+ sa.Column('is_active', sa.Boolean(), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=True),
+ sa.Column('updated_at', sa.DateTime(), nullable=True),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_devices_user_id'), 'devices', ['user_id'], unique=False)
+
+
+def downgrade() -> None:
+ op.drop_table('devices')
diff --git a/backend/alembic/versions/005_followup.py b/backend/alembic/versions/005_followup.py
new file mode 100644
index 0000000..0e7d346
--- /dev/null
+++ b/backend/alembic/versions/005_followup.py
@@ -0,0 +1,66 @@
+"""add followup_strategies and followup_logs tables
+
+Revision ID: 005
+Revises: 004
+Create Date: 2026-05-10
+
+"""
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+revision: str = '005'
+down_revision: Union[str, None] = '004'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.create_table('followup_strategies',
+ sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
+ sa.Column('name', sa.String(length=255), nullable=False),
+ sa.Column('description', sa.Text(), nullable=True),
+ sa.Column('trigger_condition', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
+ sa.Column('channel', sa.String(length=50), nullable=True),
+ sa.Column('ai_prompt_template', sa.Text(), nullable=True),
+ sa.Column('priority', sa.Integer(), nullable=True),
+ sa.Column('is_active', sa.Boolean(), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=True),
+ sa.Column('updated_at', sa.DateTime(), nullable=True),
+ sa.PrimaryKeyConstraint('id'),
+ )
+
+ op.create_table('followup_logs',
+ sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
+ sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
+ sa.Column('customer_id', postgresql.UUID(as_uuid=True), nullable=False),
+ sa.Column('strategy_id', postgresql.UUID(as_uuid=True), nullable=True),
+ sa.Column('status', sa.String(length=50), nullable=True),
+ sa.Column('channel', sa.String(length=50), nullable=True),
+ sa.Column('content', sa.Text(), nullable=True),
+ sa.Column('ai_generated_content', sa.Text(), nullable=True),
+ sa.Column('user_edited_content', sa.Text(), nullable=True),
+ sa.Column('health_score_at_time', sa.Integer(), nullable=True),
+ sa.Column('silence_days_at_time', sa.Integer(), nullable=True),
+ sa.Column('sent_at', sa.DateTime(), nullable=True),
+ sa.Column('replied_at', sa.DateTime(), nullable=True),
+ sa.Column('response_status', sa.String(length=50), nullable=True),
+ sa.Column('metadata', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=True),
+ sa.Column('updated_at', sa.DateTime(), nullable=True),
+ sa.PrimaryKeyConstraint('id'),
+ )
+ op.create_index(op.f('ix_followup_logs_user_id'), 'followup_logs', ['user_id'], unique=False)
+ op.create_index(op.f('ix_followup_logs_customer_id'), 'followup_logs', ['customer_id'], unique=False)
+ op.create_foreign_key('fk_followup_logs_customer', 'followup_logs', 'customers', ['customer_id'], ['id'])
+ op.create_foreign_key('fk_followup_logs_strategy', 'followup_logs', 'followup_strategies', ['strategy_id'], ['id'])
+
+
+def downgrade() -> None:
+ op.drop_constraint('fk_followup_logs_strategy', 'followup_logs', type_='foreignkey')
+ op.drop_constraint('fk_followup_logs_customer', 'followup_logs', type_='foreignkey')
+ op.drop_index(op.f('ix_followup_logs_customer_id'), table_name='followup_logs')
+ op.drop_index(op.f('ix_followup_logs_user_id'), table_name='followup_logs')
+ op.drop_table('followup_logs')
+ op.drop_table('followup_strategies')
diff --git a/backend/app/ai/base.py b/backend/app/ai/base.py
index 9ddc889..65d66ba 100644
--- a/backend/app/ai/base.py
+++ b/backend/app/ai/base.py
@@ -13,7 +13,7 @@ class AIProvider(ABC):
@abstractmethod
async def reply(
self, inquiry: str, context: Optional[Dict[str, Any]] = None,
- tone: str = "professional",
+ tone: str = "professional", preference_context: Optional[str] = None,
) -> Dict[str, Any]:
pass
@@ -21,6 +21,7 @@ class AIProvider(ABC):
async def generate_marketing(
self, product_info: Dict[str, Any], target: str,
style: str = "professional", language: str = "en",
+ preference_context: Optional[str] = None,
) -> Dict[str, Any]:
pass
diff --git a/backend/app/ai/providers/__init__.py b/backend/app/ai/providers/__init__.py
index 75c6a6b..d6344d6 100644
--- a/backend/app/ai/providers/__init__.py
+++ b/backend/app/ai/providers/__init__.py
@@ -2,5 +2,7 @@ from .openai import OpenAIProvider
from .claude import ClaudeProvider
from .deepl import DeepLProvider
from .local import LocalProvider
+from .spark import SparkProvider
+from .sensenova import SensenovaProvider
-__all__ = ["OpenAIProvider", "ClaudeProvider", "DeepLProvider", "LocalProvider"]
+__all__ = ["OpenAIProvider", "ClaudeProvider", "DeepLProvider", "LocalProvider", "SparkProvider", "SensenovaProvider"]
diff --git a/backend/app/ai/providers/claude.py b/backend/app/ai/providers/claude.py
index a9ba41f..18a795e 100644
--- a/backend/app/ai/providers/claude.py
+++ b/backend/app/ai/providers/claude.py
@@ -32,8 +32,10 @@ class ClaudeProvider(AIProvider):
content = await self._call(system, prompt)
return {"translated_text": content, "provider": self.name}
- async def reply(self, inquiry: str, context: Optional[Dict[str, Any]] = None, tone: str = "professional") -> Dict[str, Any]:
+ async def reply(self, inquiry: str, context: Optional[Dict[str, Any]] = None, tone: str = "professional", preference_context: Optional[str] = None) -> Dict[str, Any]:
system = SYSTEM_PROMPTS["reply"]
+ if preference_context:
+ system += f"\nUser writing preference: {preference_context}"
context_str = ""
if context:
for k, v in context.items():
@@ -43,8 +45,10 @@ class ClaudeProvider(AIProvider):
content = await self._call(system, prompt)
return {"reply": content, "provider": self.name}
- async def generate_marketing(self, product_info: Dict[str, Any], target: str, style: str = "professional", language: str = "en") -> Dict[str, Any]:
+ async def generate_marketing(self, product_info: Dict[str, Any], target: str, style: str = "professional", language: str = "en", preference_context: Optional[str] = None) -> Dict[str, Any]:
system = SYSTEM_PROMPTS["marketing"]
+ if preference_context:
+ system += f"\nUser preference: {preference_context}"
info = json.dumps(product_info, ensure_ascii=False, indent=2)
prompt = f"Product:\n{info}\n\nTarget: {target}\nStyle: {style}\nLanguage: {language}\n\nWrite marketing copy:"
content = await self._call(system, prompt, max_tokens=1500)
diff --git a/backend/app/ai/providers/local.py b/backend/app/ai/providers/local.py
index e67d4a6..67345c1 100644
--- a/backend/app/ai/providers/local.py
+++ b/backend/app/ai/providers/local.py
@@ -14,17 +14,22 @@ class LocalProvider(AIProvider):
result = await self._generate(prompt)
return {"translated_text": result, "provider": self.name, "cost": 0.0}
- async def reply(self, inquiry: str, context: Optional[Dict[str, Any]] = None, tone: str = "professional") -> Dict[str, Any]:
- ctx = ""
+ async def reply(self, inquiry: str, context: Optional[Dict[str, Any]] = None, tone: str = "professional", preference_context: Optional[str] = None) -> Dict[str, Any]:
+ prompt = ""
+ if preference_context:
+ prompt += f"[User prefers: {preference_context}]\n"
if context:
- ctx = "\n".join(f"{k}: {v}" for k, v in context.items() if v)
- prompt = f"{ctx}\nCustomer: {inquiry}\n\nWrite a {tone} reply:"
+ prompt += "\n".join(f"{k}: {v}" for k, v in context.items() if v) + "\n"
+ prompt += f"Customer: {inquiry}\n\nWrite a {tone} reply:"
result = await self._generate(prompt)
return {"reply": result, "provider": self.name, "cost": 0.0}
- async def generate_marketing(self, product_info: Dict[str, Any], target: str, style: str = "professional", language: str = "en") -> Dict[str, Any]:
+ async def generate_marketing(self, product_info: Dict[str, Any], target: str, style: str = "professional", language: str = "en", preference_context: Optional[str] = None) -> Dict[str, Any]:
info = json.dumps(product_info, ensure_ascii=False)
- prompt = f"Product: {info}\nTarget: {target}\nStyle: {style}\nLanguage: {language}\n\nMarketing copy:"
+ prompt = ""
+ if preference_context:
+ prompt += f"[User prefers: {preference_context}]\n"
+ prompt += f"Product: {info}\nTarget: {target}\nStyle: {style}\nLanguage: {language}\n\nMarketing copy:"
result = await self._generate(prompt, max_tokens=800)
return {"content": result, "provider": self.name, "cost": 0.0}
diff --git a/backend/app/ai/providers/openai.py b/backend/app/ai/providers/openai.py
index 4506d5d..52ae6fa 100644
--- a/backend/app/ai/providers/openai.py
+++ b/backend/app/ai/providers/openai.py
@@ -19,8 +19,11 @@ SYSTEM_PROMPTS = {
class OpenAIProvider(AIProvider):
- def __init__(self, api_key: str, model: str = "gpt-4o"):
- self.client = AsyncOpenAI(api_key=api_key)
+ def __init__(self, api_key: str, model: str = "gpt-4o", base_url: Optional[str] = None):
+ kwargs = {"api_key": api_key}
+ if base_url:
+ kwargs["base_url"] = base_url
+ self.client = AsyncOpenAI(**kwargs)
self.model = model
self._name = f"openai-{model}"
self._pricing = {
@@ -39,8 +42,10 @@ class OpenAIProvider(AIProvider):
content = await self._call(system, f"Translate to {target_lang}:\n\n{text}", model=self._cheap_model)
return {"translated_text": content, "provider": self.name, "model": self.model}
- async def reply(self, inquiry: str, context: Optional[Dict[str, Any]] = None, tone: str = "professional") -> Dict[str, Any]:
+ async def reply(self, inquiry: str, context: Optional[Dict[str, Any]] = None, tone: str = "professional", preference_context: Optional[str] = None) -> Dict[str, Any]:
system = SYSTEM_PROMPTS["reply"] + f"\nTone: {tone}"
+ if preference_context:
+ system += f"\nUser preference: {preference_context}"
context_str = ""
if context:
@@ -57,8 +62,10 @@ class OpenAIProvider(AIProvider):
content = await self._call(system, prompt)
return {"reply": content, "provider": self.name, "model": self.model}
- async def generate_marketing(self, product_info: Dict[str, Any], target: str, style: str = "professional", language: str = "en") -> Dict[str, Any]:
+ async def generate_marketing(self, product_info: Dict[str, Any], target: str, style: str = "professional", language: str = "en", preference_context: Optional[str] = None) -> Dict[str, Any]:
system = SYSTEM_PROMPTS["marketing"] + f"\nStyle: {style}\nTarget audience: {target}\nLanguage: {language}"
+ if preference_context:
+ system += f"\nUser preference: {preference_context}"
product_str = json.dumps(product_info, ensure_ascii=False, indent=2)
prompt = f"Product information:\n{product_str}\n\nGenerate marketing copy:"
@@ -76,7 +83,7 @@ class OpenAIProvider(AIProvider):
except json.JSONDecodeError:
return {"data": {}, "confidence": 0.0, "provider": self.name, "error": "parse_failed"}
- async def _call(self, system: str, prompt: str, max_tokens: int = 1000, response_format: Optional[Dict] = None, model: Optional[str] = None) -> str:
+ async def _call(self, system: str, prompt: str, max_tokens: int = 3000, response_format: Optional[Dict] = None, model: Optional[str] = None) -> str:
kwargs = {
"model": model or self.model,
"messages": [
@@ -90,7 +97,46 @@ class OpenAIProvider(AIProvider):
kwargs["response_format"] = response_format
resp = await self.client.chat.completions.create(**kwargs)
- return resp.choices[0].message.content
+ content = resp.choices[0].message.content
+
+ if content is None and hasattr(resp.choices[0].message, 'reasoning'):
+ reasoning = resp.choices[0].message.reasoning
+ if reasoning:
+ import re
+ final_output_patterns = [
+ r'Final Output Generation[::]\s*(.+?)(?:\n\n|$)',
+ r'Final Output[::]\s*(.+?)(?:\n\n|$)',
+ r'7\.\s*Final Output Generation[::]\s*(.+?)(?:\n\n|$)',
+ r'翻译结果[::]\s*(.+?)(?:\n\n|$)',
+ r'最终输出[::]\s*(.+?)(?:\n\n|$)',
+ ]
+ for pattern in final_output_patterns:
+ match = re.search(pattern, reasoning, re.DOTALL)
+ if match:
+ content = match.group(1).strip()
+ break
+
+ if content is None:
+ paragraphs = re.split(r'\n\n+', reasoning.strip())
+ if paragraphs:
+ for p in reversed(paragraphs):
+ p = p.strip()
+ if p and len(p) > 10:
+ if not p.startswith('步骤') and not p.startswith('Step'):
+ content = p
+ break
+
+ if content is None and hasattr(resp.choices[0].message, 'reasoning'):
+ reasoning = resp.choices[0].message.reasoning
+ if reasoning:
+ import re
+ cleaned = re.sub(r'^步骤\d+[::].*$', '', reasoning, flags=re.MULTILINE)
+ cleaned = re.sub(r'^Step \d+[::].*$', '', cleaned, flags=re.MULTILINE)
+ cleaned = re.sub(r'\n+', '\n', cleaned).strip()
+ if cleaned:
+ content = cleaned
+
+ return content
@property
def name(self) -> str:
diff --git a/backend/app/ai/providers/sensenova.py b/backend/app/ai/providers/sensenova.py
new file mode 100644
index 0000000..3885a10
--- /dev/null
+++ b/backend/app/ai/providers/sensenova.py
@@ -0,0 +1,7 @@
+from app.ai.providers.openai import OpenAIProvider
+
+
+class SensenovaProvider(OpenAIProvider):
+ def __init__(self, api_key: str, model: str = "sensenova-6.7-flash-lite", base_url: str = "https://token.sensenova.cn/v1"):
+ super().__init__(api_key=api_key, model=model, base_url=base_url)
+ self._name = f"sensenova-{model}"
diff --git a/backend/app/ai/providers/spark.py b/backend/app/ai/providers/spark.py
new file mode 100644
index 0000000..0a7b113
--- /dev/null
+++ b/backend/app/ai/providers/spark.py
@@ -0,0 +1,87 @@
+from typing import Dict, Any, Optional
+import json
+from openai import AsyncOpenAI
+from app.ai.base import AIProvider
+
+
+SYSTEM_PROMPTS = {
+ "translate": "You are a professional translator specialized in foreign trade. "
+ "Translate business terms accurately. Return ONLY the translated text.",
+ "reply": "You are an experienced foreign trade sales expert. Write professional, "
+ "clear business replies. Return ONLY the reply text.",
+ "marketing": "You are a creative copywriter for international trade. "
+ "Return ONLY the marketing copy, no explanations.",
+ "extract": "Extract structured data from text. Return ONLY valid JSON.",
+}
+
+
+class SparkProvider(AIProvider):
+ def __init__(self, api_key: str, model: str = "astron-code-latest", base_url: str = None):
+ from app.config import settings
+ self.client = AsyncOpenAI(
+ api_key=api_key,
+ base_url=base_url or settings.IFLYTEK_API_BASE,
+ )
+ self.model = model
+ self._name = f"spark-{model}"
+
+ async def translate(self, text: str, source_lang: Optional[str], target_lang: str, context: Optional[str] = None) -> Dict[str, Any]:
+ system = SYSTEM_PROMPTS["translate"]
+ if context:
+ system += f"\nContext: {context}"
+ prompt = f"Translate {f'from {source_lang} ' if source_lang and source_lang != 'auto' else ''}to {target_lang}:\n\n{text}"
+ content = await self._call(system, prompt)
+ return {"translated_text": content, "provider": self.name}
+
+ async def reply(self, inquiry: str, context: Optional[Dict[str, Any]] = None, tone: str = "professional", preference_context: Optional[str] = None) -> Dict[str, Any]:
+ system = SYSTEM_PROMPTS["reply"] + f"\nTone: {tone}"
+ if preference_context:
+ system += f"\nUser preference: {preference_context}"
+ ctx = ""
+ if context:
+ ctx = "\n".join(f"{k}: {v}" for k, v in context.items() if v)
+ prompt = f"{ctx}\nCustomer inquiry:\n{inquiry}\n\nWrite a reply:"
+ content = await self._call(system, prompt)
+ return {"reply": content, "provider": self.name}
+
+ async def generate_marketing(self, product_info: Dict[str, Any], target: str, style: str = "professional", language: str = "en", preference_context: Optional[str] = None) -> Dict[str, Any]:
+ system = SYSTEM_PROMPTS["marketing"] + f"\nStyle: {style}\nAudience: {target}\nLanguage: {language}"
+ if preference_context:
+ system += f"\nUser preference: {preference_context}"
+ info = json.dumps(product_info, ensure_ascii=False)
+ prompt = f"Product:\n{info}\n\nGenerate marketing copy:"
+ content = await self._call(system, prompt, max_tokens=1500)
+ return {"content": content, "provider": self.name}
+
+ async def extract_info(self, text: str, schema: Dict[str, Any]) -> Dict[str, Any]:
+ system = SYSTEM_PROMPTS["extract"]
+ prompt = f"Schema:\n{json.dumps(schema, indent=2)}\n\nText:\n{text}\n\nJSON:"
+ content = await self._call(system, prompt, response_format={"type": "json_object"})
+ try:
+ data = json.loads(content)
+ return {"data": data, "confidence": 0.9, "provider": self.name}
+ except json.JSONDecodeError:
+ return {"data": {}, "confidence": 0.0, "provider": self.name}
+
+ async def _call(self, system: str, prompt: str, max_tokens: int = 1000, response_format: Optional[Dict] = None) -> str:
+ kwargs = {
+ "model": self.model,
+ "messages": [
+ {"role": "system", "content": system},
+ {"role": "user", "content": prompt},
+ ],
+ "max_tokens": max_tokens,
+ "temperature": 0.7,
+ }
+ if response_format:
+ kwargs["response_format"] = response_format
+ resp = await self.client.chat.completions.create(**kwargs)
+ return resp.choices[0].message.content
+
+ @property
+ def name(self) -> str:
+ return self._name
+
+ @property
+ def cost_per_1k_tokens(self) -> float:
+ return 0.0
diff --git a/backend/app/ai/router.py b/backend/app/ai/router.py
index 71c7287..ca997ca 100644
--- a/backend/app/ai/router.py
+++ b/backend/app/ai/router.py
@@ -1,6 +1,6 @@
from typing import Dict, Any, Optional, List
from app.ai.base import AIProvider
-from app.ai.providers import OpenAIProvider, ClaudeProvider, DeepLProvider, LocalProvider
+from app.ai.providers import OpenAIProvider, ClaudeProvider, DeepLProvider, LocalProvider, SparkProvider, SensenovaProvider
from app.config import settings
from app.ai.trade_corpus import TradeCorpus
import logging
@@ -23,6 +23,17 @@ class AIRouter:
except Exception as e:
logger.warning(f"OpenAI init failed: {e}")
+ if settings.SENSENOVA_API_KEY:
+ try:
+ self.providers["sensenova"] = SensenovaProvider(
+ api_key=settings.SENSENOVA_API_KEY,
+ model=settings.SENSENOVA_MODEL,
+ base_url=settings.SENSENOVA_BASE_URL,
+ )
+ logger.info("Sensenova provider ready")
+ except Exception as e:
+ logger.warning(f"Sensenova init failed: {e}")
+
if settings.ANTHROPIC_API_KEY:
try:
self.providers["anthropic"] = ClaudeProvider(api_key=settings.ANTHROPIC_API_KEY)
@@ -37,6 +48,17 @@ class AIRouter:
except Exception as e:
logger.warning(f"DeepL init failed: {e}")
+ if settings.IFLYTEK_API_KEY:
+ try:
+ self.providers["spark"] = SparkProvider(
+ api_key=settings.IFLYTEK_API_KEY,
+ model=settings.IFLYTEK_MODEL,
+ base_url=settings.IFLYTEK_API_BASE,
+ )
+ logger.info("Spark provider ready")
+ except Exception as e:
+ logger.warning(f"Spark init failed: {e}")
+
if settings.LOCAL_MODEL_ENABLED:
try:
self.providers["local"] = LocalProvider(model_url=settings.LOCAL_MODEL_URL)
@@ -90,11 +112,11 @@ class AIRouter:
async def translate(self, text: str, target_lang: str, source_lang: Optional[str] = None, context: Optional[str] = None) -> Dict[str, Any]:
return await self.execute("translate", "translate", text, source_lang, target_lang, context)
- async def reply(self, inquiry: str, context: Optional[Dict[str, Any]] = None, tone: str = "professional") -> Dict[str, Any]:
- return await self.execute("reply", "reply", inquiry, context, tone)
+ async def reply(self, inquiry: str, context: Optional[Dict[str, Any]] = None, tone: str = "professional", preference_context: Optional[str] = None) -> Dict[str, Any]:
+ return await self.execute("reply", "reply", inquiry, context, tone, preference_context)
- async def marketing(self, product_info: Dict[str, Any], target: str, style: str = "professional", language: str = "en") -> Dict[str, Any]:
- return await self.execute("marketing", "generate_marketing", product_info, target, style, language)
+ async def marketing(self, product_info: Dict[str, Any], target: str, style: str = "professional", language: str = "en", preference_context: Optional[str] = None) -> Dict[str, Any]:
+ return await self.execute("marketing", "generate_marketing", product_info, target, style, language, preference_context)
async def extract(self, text: str, schema: Dict[str, Any]) -> Dict[str, Any]:
return await self.execute("extract", "extract_info", text, schema)
diff --git a/backend/app/api/v1/admin.py b/backend/app/api/v1/admin.py
new file mode 100644
index 0000000..e81623d
--- /dev/null
+++ b/backend/app/api/v1/admin.py
@@ -0,0 +1,72 @@
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy.ext.asyncio import AsyncSession
+from typing import Annotated
+from app.database import get_db
+from app.services.admin import AdminService
+from app.api.v1.deps import get_current_user
+
+router = APIRouter()
+
+
+async def require_admin(current_user: dict = Depends(get_current_user)) -> dict:
+ if current_user.get("role") != "admin":
+ raise HTTPException(status_code=403, detail="Admin access required")
+ return current_user
+
+
+@router.get("/dashboard")
+async def get_dashboard(
+ _: dict = Depends(require_admin),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ service = AdminService(db)
+ return await service.get_dashboard()
+
+
+@router.get("/users")
+async def list_users(
+ page: int = Query(1, ge=1),
+ size: int = Query(20, ge=1, le=100),
+ _: dict = Depends(require_admin),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ service = AdminService(db)
+ return await service.list_users(page, size)
+
+
+@router.patch("/users/{target_user_id}/tier")
+async def update_user_tier(
+ target_user_id: str,
+ data: dict,
+ _: dict = Depends(require_admin),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ service = AdminService(db)
+ tier = data.get("tier")
+ if tier not in ("free", "pro", "enterprise"):
+ raise HTTPException(status_code=400, detail="Invalid tier")
+ success = await service.update_user_tier(target_user_id, tier)
+ if not success:
+ raise HTTPException(status_code=404, detail="User not found")
+ return {"message": f"User tier updated to {tier}"}
+
+
+@router.post("/users/{target_user_id}/toggle-active")
+async def toggle_user_active(
+ target_user_id: str,
+ _: dict = Depends(require_admin),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ service = AdminService(db)
+ success = await service.toggle_user_active(target_user_id)
+ if not success:
+ raise HTTPException(status_code=404, detail="User not found")
+ return {"message": "User active status toggled"}
+
+
+@router.get("/health")
+async def system_health(
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ service = AdminService(db)
+ return await service.get_system_health()
diff --git a/backend/app/api/v1/analytics.py b/backend/app/api/v1/analytics.py
new file mode 100644
index 0000000..4fe689f
--- /dev/null
+++ b/backend/app/api/v1/analytics.py
@@ -0,0 +1,73 @@
+from fastapi import APIRouter, Depends
+from sqlalchemy.ext.asyncio import AsyncSession
+from typing import Annotated
+from app.database import get_db
+from app.services.analytics import AnalyticsService
+from app.api.v1.deps import get_current_user_id
+
+router = APIRouter()
+
+
+@router.get("/customers")
+async def customer_analytics(
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ service = AnalyticsService(db)
+ return await service.get_customer_stats(user_id)
+
+
+@router.get("/translations")
+async def translation_analytics(
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ service = AnalyticsService(db)
+ return await service.get_translation_stats(user_id)
+
+
+@router.get("/quotations")
+async def quotation_analytics(
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ service = AnalyticsService(db)
+ return await service.get_quotation_stats(user_id)
+
+
+@router.get("/messages")
+async def message_analytics(
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ service = AnalyticsService(db)
+ return await service.get_message_stats(user_id)
+
+
+@router.get("/overview")
+async def overview(
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ service = AnalyticsService(db)
+ customers = await service.get_customer_stats(user_id)
+ translations = await service.get_translation_stats(user_id)
+ quotations = await service.get_quotation_stats(user_id)
+ messages = await service.get_message_stats(user_id)
+ marketing = await service.get_marketing_stats(user_id)
+ return {
+ "customers": customers,
+ "translations": translations,
+ "quotations": quotations,
+ "messages": messages,
+ "marketing": marketing,
+ }
+
+
+@router.get("/marketing")
+async def marketing_analytics(
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ service = AnalyticsService(db)
+ return await service.get_marketing_stats(user_id)
diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py
index bf25d94..d1d2394 100644
--- a/backend/app/api/v1/auth.py
+++ b/backend/app/api/v1/auth.py
@@ -1,13 +1,14 @@
-from fastapi import APIRouter, Depends, HTTPException, status
+from fastapi import APIRouter, Depends, HTTPException, status, Header
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
-from typing import Annotated
+from typing import Annotated, Optional
+import uuid
from app.database import get_db
from app.models.user import User
from app.core.security import hash_password, verify_password, create_access_token, create_refresh_token, decode_token
from pydantic import BaseModel, EmailStr
-from datetime import datetime
+from datetime import datetime, timedelta
router = APIRouter()
@@ -30,7 +31,7 @@ class RefreshRequest(BaseModel):
@router.post("/register")
-async def register(data: RegisterRequest, db: Annotated[AsyncSession, Depends(get_db)]):
+async def register(data: RegisterRequest, db: Annotated[AsyncSession, Depends(get_db)] = None):
existing = await db.execute(select(User).where(User.phone == data.phone))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Phone already registered")
@@ -49,13 +50,14 @@ async def register(data: RegisterRequest, db: Annotated[AsyncSession, Depends(ge
"phone": user.phone,
"username": user.username,
"tier": user.tier,
+ "role": user.role,
}
@router.post("/login", response_model=LoginResponse)
async def login(
form: Annotated[OAuth2PasswordRequestForm, Depends()],
- db: Annotated[AsyncSession, Depends(get_db)],
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
):
result = await db.execute(select(User).where(User.phone == form.username))
user = result.scalar_one_or_none()
@@ -67,7 +69,7 @@ async def login(
)
return LoginResponse(
- access_token=create_access_token({"sub": str(user.id), "tier": user.tier}),
+ access_token=create_access_token({"sub": str(user.id), "tier": user.tier, "role": user.role}),
refresh_token=create_refresh_token({"sub": str(user.id)}),
user={
"id": str(user.id),
@@ -78,6 +80,29 @@ async def login(
)
+@router.post("/login/guest")
+async def guest_login():
+ guest_id = f"guest_{uuid.uuid4().hex[:12]}"
+ access_token = create_access_token(
+ {"sub": guest_id, "tier": "guest", "role": "guest", "is_guest": True},
+ expires_delta=timedelta(hours=24)
+ )
+ refresh_token = create_refresh_token({"sub": guest_id, "is_guest": True})
+
+ return LoginResponse(
+ access_token=access_token,
+ refresh_token=refresh_token,
+ token_type="bearer",
+ user={
+ "id": guest_id,
+ "phone": None,
+ "username": "游客用户",
+ "tier": "guest",
+ "is_guest": True,
+ },
+ )
+
+
@router.post("/refresh")
async def refresh(data: RefreshRequest):
payload = decode_token(data.refresh_token)
@@ -92,7 +117,7 @@ async def refresh(data: RefreshRequest):
@router.get("/me")
async def get_me(
- authorization: str = None,
+ authorization: Optional[str] = Header(None, alias="Authorization"),
db: Annotated[AsyncSession, Depends(get_db)] = None,
):
if not authorization or not authorization.startswith("Bearer "):
@@ -112,6 +137,7 @@ async def get_me(
"phone": user.phone,
"username": user.username,
"tier": user.tier,
+ "role": user.role,
"settings": user.settings,
"created_at": user.created_at.isoformat() if user.created_at else None,
}
@@ -124,10 +150,50 @@ class SettingsUpdate(BaseModel):
languages: list = None
+class WeChatLoginRequest(BaseModel):
+ code: str
+ encrypted_data: str = ""
+ iv: str = ""
+
+
+@router.post("/wechat-login")
+async def wechat_login(data: WeChatLoginRequest, db: Annotated[AsyncSession, Depends(get_db)] = None):
+ from app.services.wechat import wechat_service
+
+ session = await wechat_service.code2session(data.code)
+ if not session:
+ raise HTTPException(status_code=400, detail="WeChat login failed")
+
+ openid = session.get("openid")
+ result = await db.execute(select(User).where(User.wechat_openid == openid))
+ user = result.scalar_one_or_none()
+
+ if not user:
+ user = User(
+ wechat_openid=openid,
+ username=f"wx_{openid[-8:]}",
+ tier="free",
+ )
+ db.add(user)
+ await db.flush()
+
+ return LoginResponse(
+ access_token=create_access_token({"sub": str(user.id), "tier": user.tier, "role": user.role}),
+ refresh_token=create_refresh_token({"sub": str(user.id)}),
+ user={
+ "id": str(user.id),
+ "phone": user.phone,
+ "username": user.username,
+ "tier": user.tier,
+ "role": user.role,
+ },
+ )
+
+
@router.patch("/settings")
async def update_settings(
data: SettingsUpdate,
- authorization: str = None,
+ authorization: Optional[str] = Header(None, alias="Authorization"),
db: Annotated[AsyncSession, Depends(get_db)] = None,
):
if not authorization or not authorization.startswith("Bearer "):
diff --git a/backend/app/api/v1/customer.py b/backend/app/api/v1/customer.py
index e4c0ab8..79a430a 100644
--- a/backend/app/api/v1/customer.py
+++ b/backend/app/api/v1/customer.py
@@ -1,8 +1,11 @@
-from fastapi import APIRouter, Depends, HTTPException, Query
+from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Response
from sqlalchemy.ext.asyncio import AsyncSession
-from typing import Annotated, Optional
+from typing import Annotated, Optional, List
from app.database import get_db
from app.services.customer import CustomerService
+from app.services.customer_health import CustomerHealthService
+from app.services.import_service import import_service
+from app.services import export
from app.core.security import decode_token
from app.api.v1.deps import get_current_user_id
@@ -87,6 +90,95 @@ async def delete_customer(
return {"message": "Customer deleted"}
+@router.post("/import")
+async def import_customers(
+ file: UploadFile = File(...),
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ from app.workers.tasks import process_customer_import
+
+ content = await file.read()
+ filename = file.filename or ""
+
+ if filename.endswith(".xlsx"):
+ records, parse_errors = import_service.parse_xlsx(content)
+ elif filename.endswith(".csv"):
+ records, parse_errors = import_service.parse_csv(content)
+ else:
+ raise HTTPException(status_code=400, detail="Unsupported file format. Use .xlsx or .csv")
+
+ if parse_errors and not records:
+ raise HTTPException(status_code=400, detail=f"Parse failed: {'; '.join(parse_errors)}")
+
+ valid, validation_errors = import_service.validate_records(records)
+ all_errors = parse_errors + validation_errors
+ imported_count = 0
+
+ for record in valid:
+ try:
+ svc = CustomerService(db)
+ await svc.create_customer(user_id, record)
+ imported_count += 1
+ except Exception as e:
+ all_errors.append(f"Import failed for {record.get('name', 'unknown')}: {str(e)}")
+
+ return {
+ "imported": imported_count,
+ "total": len(records),
+ "errors": all_errors,
+ "filename": filename,
+ }
+
+
+@router.get("/export/csv")
+async def export_customers(
+ status: Optional[str] = None,
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ service = CustomerService(db)
+ result = await service.list_customers(user_id, status, 1, 9999)
+ items = result.get("items", [])
+ csv_bytes = export.export_customers_csv(items)
+ return Response(
+ content=csv_bytes,
+ media_type="text/csv",
+ headers={"Content-Disposition": "attachment; filename=customers.csv"},
+ )
+
+
+@router.get("/health-overview")
+async def get_health_overview(
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ service = CustomerHealthService(db)
+ return await service.get_health_overview(user_id)
+
+
+@router.get("/health-scores")
+async def get_all_health_scores(
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ service = CustomerHealthService(db)
+ return {"items": await service.get_all_health_scores(user_id)}
+
+
+@router.get("/{customer_id}/health")
+async def get_customer_health(
+ customer_id: str,
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ service = CustomerHealthService(db)
+ health = await service.get_customer_health(user_id, customer_id)
+ if not health:
+ raise HTTPException(status_code=404, detail="Customer not found")
+ return health
+
+
@router.get("/{customer_id}/conversation")
async def get_conversation(
customer_id: str,
diff --git a/backend/app/api/v1/deps.py b/backend/app/api/v1/deps.py
index 826ee28..2771ab7 100644
--- a/backend/app/api/v1/deps.py
+++ b/backend/app/api/v1/deps.py
@@ -1,13 +1,43 @@
-from fastapi import HTTPException, Depends
+from fastapi import HTTPException, Depends, Header
+from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from app.core.security import decode_token
+from typing import Optional
+
+security = HTTPBearer(auto_error=False)
-async def get_current_user_id(authorization: str = None) -> str:
- if not authorization or not authorization.startswith("Bearer "):
+async def get_current_user_id(
+ authorization: Optional[str] = Header(None, alias="Authorization"),
+ cred: Optional[HTTPAuthorizationCredentials] = Depends(security),
+) -> str:
+ token = None
+ if cred:
+ token = cred.credentials
+ elif authorization and authorization.startswith("Bearer "):
+ token = authorization[7:]
+
+ if not token:
raise HTTPException(status_code=401, detail="Missing or invalid token")
- payload = decode_token(authorization[7:])
+ payload = decode_token(token)
if not payload:
raise HTTPException(status_code=401, detail="Invalid or expired token")
return payload.get("sub")
+
+
+async def get_current_user(
+ cred: HTTPAuthorizationCredentials = Depends(security),
+) -> dict:
+ if not cred:
+ raise HTTPException(status_code=401, detail="Missing or invalid token")
+
+ payload = decode_token(cred.credentials)
+ if not payload:
+ raise HTTPException(status_code=401, detail="Invalid or expired token")
+
+ return {
+ "id": payload.get("sub"),
+ "tier": payload.get("tier", "free"),
+ "role": payload.get("role", "user"),
+ }
diff --git a/backend/app/api/v1/exchange.py b/backend/app/api/v1/exchange.py
index 913ad63..2393e91 100644
--- a/backend/app/api/v1/exchange.py
+++ b/backend/app/api/v1/exchange.py
@@ -1,26 +1,9 @@
from fastapi import APIRouter
-from pydantic import BaseModel
+from app.services.exchange import ExchangeRateService
+from datetime import datetime
router = APIRouter()
-
-
-class ExchangeRateResponse(BaseModel):
- from_currency: str
- to_currency: str
- rate: float
- updated_at: str
-
-
-EXCHANGE_RATES = {
- ("USD", "CNY"): 7.24,
- ("EUR", "CNY"): 7.85,
- ("GBP", "CNY"): 9.15,
- ("CNY", "USD"): 0.138,
- ("USD", "EUR"): 0.92,
- ("EUR", "USD"): 1.09,
- ("GBP", "USD"): 1.27,
- ("USD", "GBP"): 0.79,
-}
+service = ExchangeRateService()
@router.get("/convert")
@@ -29,26 +12,25 @@ async def convert_currency(
to_currency: str = "CNY",
amount: float = 1.0,
):
- rate = EXCHANGE_RATES.get((from_currency, to_currency), 1.0)
+ rate = await service.get_rate(from_currency, to_currency)
+ if rate is None:
+ return {"error": f"No rate available for {from_currency} -> {to_currency}"}
+
return {
- "from_currency": from_currency,
- "to_currency": to_currency,
+ "from_currency": from_currency.upper(),
+ "to_currency": to_currency.upper(),
"amount": amount,
"converted": round(amount * rate, 2),
"rate": rate,
- "updated_at": "2026-05-08T00:00:00Z",
+ "updated_at": datetime.utcnow().isoformat(),
}
@router.get("/rates")
async def get_rates(base: str = "USD"):
- rates = {}
- for (from_curr, to_curr), rate in EXCHANGE_RATES.items():
- if from_curr == base:
- rates[to_curr] = rate
-
+ rates = await service.get_all_rates(base)
return {
- "base": base,
+ "base": base.upper(),
"rates": rates,
- "updated_at": "2026-05-08T00:00:00Z",
- }
\ No newline at end of file
+ "updated_at": datetime.utcnow().isoformat(),
+ }
diff --git a/backend/app/api/v1/feedback.py b/backend/app/api/v1/feedback.py
new file mode 100644
index 0000000..63f2b66
--- /dev/null
+++ b/backend/app/api/v1/feedback.py
@@ -0,0 +1,35 @@
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.ext.asyncio import AsyncSession
+from typing import Annotated
+from pydantic import BaseModel
+from app.database import get_db
+from app.models.feedback import Feedback
+from app.api.v1.deps import get_current_user_id
+
+router = APIRouter()
+
+
+class FeedbackRequest(BaseModel):
+ category: str = "general"
+ content: str
+ contact: str = ""
+
+
+@router.post("")
+async def submit_feedback(
+ data: FeedbackRequest,
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ if not data.content.strip():
+ raise HTTPException(status_code=400, detail="Content is required")
+
+ fb = Feedback(
+ user_id=user_id,
+ category=data.category,
+ content=data.content.strip(),
+ contact=data.contact.strip(),
+ )
+ db.add(fb)
+ await db.flush()
+ return {"status": "ok", "id": str(fb.id)}
\ No newline at end of file
diff --git a/backend/app/api/v1/followup.py b/backend/app/api/v1/followup.py
new file mode 100644
index 0000000..43a8af4
--- /dev/null
+++ b/backend/app/api/v1/followup.py
@@ -0,0 +1,89 @@
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy.ext.asyncio import AsyncSession
+from typing import Annotated, Optional
+from app.database import get_db
+from app.services.followup_engine import FollowupEngine
+from app.api.v1.deps import get_current_user_id
+
+router = APIRouter()
+
+
+@router.get("/strategies")
+async def list_strategies(
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ engine = FollowupEngine(db)
+ await engine.ensure_default_strategies()
+ return {"strategies": await engine.get_strategies()}
+
+
+@router.get("/pending")
+async def get_pending_followups(
+ page: int = Query(1, ge=1),
+ size: int = Query(20, ge=1, le=100),
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ engine = FollowupEngine(db)
+ return await engine.get_pending_followups(user_id, page, size)
+
+
+@router.get("/logs")
+async def get_followup_logs(
+ page: int = Query(1, ge=1),
+ size: int = Query(20, ge=1, le=100),
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ engine = FollowupEngine(db)
+ return await engine.get_followup_logs(user_id, page, size)
+
+
+@router.post("/{log_id}/send")
+async def mark_followup_sent(
+ log_id: str,
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ engine = FollowupEngine(db)
+ success = await engine.mark_sent(user_id, log_id)
+ if not success:
+ raise HTTPException(status_code=404, detail="Followup log not found")
+ return {"status": "ok"}
+
+
+@router.post("/{log_id}/edit")
+async def edit_and_send_followup(
+ log_id: str,
+ body: dict,
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ edited_text = body.get("edited_text", "")
+ if not edited_text:
+ raise HTTPException(status_code=400, detail="edited_text is required")
+ engine = FollowupEngine(db)
+ success = await engine.mark_edited(user_id, log_id, edited_text)
+ if not success:
+ raise HTTPException(status_code=404, detail="Followup log not found")
+ return {"status": "ok"}
+
+
+@router.get("/stats")
+async def get_followup_stats(
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ engine = FollowupEngine(db)
+ return await engine.get_stats(user_id)
+
+
+@router.post("/scan")
+async def trigger_followup_scan(
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ engine = FollowupEngine(db)
+ result = await engine.scan_and_followup()
+ return result
diff --git a/backend/app/api/v1/interaction.py b/backend/app/api/v1/interaction.py
new file mode 100644
index 0000000..9bbb7f7
--- /dev/null
+++ b/backend/app/api/v1/interaction.py
@@ -0,0 +1,102 @@
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.ext.asyncio import AsyncSession
+from typing import Annotated
+from app.database import get_db
+from app.services.preference import UserPreferenceService
+from app.services.marketing_effect import MarketingEffectService
+from app.api.v1.deps import get_current_user_id
+
+router = APIRouter()
+
+
+@router.post("/select")
+async def record_selection(
+ data: dict,
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ message_id = data.get("message_id")
+ selected_index = data.get("selected_index")
+ if message_id is None or selected_index is None:
+ raise HTTPException(status_code=400, detail="message_id and selected_index required")
+ service = UserPreferenceService(db)
+ success = await service.record_selection(user_id, message_id, selected_index)
+ if not success:
+ raise HTTPException(status_code=404, detail="Message not found")
+ return {"status": "ok"}
+
+
+@router.post("/edit")
+async def record_edit(
+ data: dict,
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ message_id = data.get("message_id")
+ edited_text = data.get("edited_text")
+ if not message_id or edited_text is None:
+ raise HTTPException(status_code=400, detail="message_id and edited_text required")
+ service = UserPreferenceService(db)
+ success = await service.record_edit(user_id, message_id, edited_text)
+ if not success:
+ raise HTTPException(status_code=404, detail="Message not found")
+ return {"status": "ok"}
+
+
+@router.post("/analyze")
+async def analyze_preferences(
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ service = UserPreferenceService(db)
+ preferences = await service.analyze_preferences(user_id)
+ return preferences
+
+
+@router.get("/preferences")
+async def get_preferences(
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ service = UserPreferenceService(db)
+ return await service.get_analysis(user_id)
+
+
+@router.post("/marketing-effect")
+async def track_marketing_effect(
+ data: dict,
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ service = MarketingEffectService(db)
+ result = await service.track_event(
+ user_id=user_id,
+ content=data.get("content", ""),
+ product_id=data.get("product_id"),
+ product_name=data.get("product_name"),
+ channel=data.get("channel", "copy"),
+ event_type=data.get("event_type", "copy"),
+ target_audience=data.get("target_audience", ""),
+ metadata=data.get("metadata"),
+ )
+ return result
+
+
+@router.get("/marketing-effects")
+async def get_marketing_effects(
+ page: int = 1,
+ size: int = 20,
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ service = MarketingEffectService(db)
+ return await service.get_effects(user_id, page, size)
+
+
+@router.get("/marketing-effects/stats")
+async def get_marketing_effect_stats(
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ service = MarketingEffectService(db)
+ return await service.get_stats(user_id)
diff --git a/backend/app/api/v1/marketing.py b/backend/app/api/v1/marketing.py
index ef98793..71325ce 100644
--- a/backend/app/api/v1/marketing.py
+++ b/backend/app/api/v1/marketing.py
@@ -1,8 +1,12 @@
-from fastapi import APIRouter, HTTPException
-from typing import Optional
+from fastapi import APIRouter, HTTPException, Depends
+from typing import Optional, Annotated
from pydantic import BaseModel
+from sqlalchemy.ext.asyncio import AsyncSession
+from app.database import get_db
from app.services.marketing import MarketingService
+from app.services.preference import UserPreferenceService
from app.core.security import decode_token
+from app.api.v1.deps import get_current_user_id
from app.config import settings
router = APIRouter()
@@ -36,11 +40,15 @@ class CompetitorRequest(BaseModel):
@router.post("/generate")
-async def generate_marketing(data: MarketingRequest, authorization: str = None):
- if not authorization:
- raise HTTPException(status_code=401, detail="Missing token")
-
+async def generate_marketing(
+ data: MarketingRequest,
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
service = MarketingService()
+ pref_service = UserPreferenceService(db)
+ pref_context = await pref_service.get_preference_context(user_id, "marketing")
+
product_info = {
"name": data.product_name,
"description": data.description,
@@ -48,7 +56,7 @@ async def generate_marketing(data: MarketingRequest, authorization: str = None):
"price": data.price,
"keywords": data.keywords,
}
- results = await service.generate(product_info, data.target, data.style, data.language, data.count)
+ results = await service.generate(product_info, data.target, data.style, data.language, data.count, pref_context)
return {
"results": results,
diff --git a/backend/app/api/v1/notification.py b/backend/app/api/v1/notification.py
new file mode 100644
index 0000000..472c5f4
--- /dev/null
+++ b/backend/app/api/v1/notification.py
@@ -0,0 +1,66 @@
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy.ext.asyncio import AsyncSession
+from typing import Annotated, Optional
+from app.database import get_db
+from app.services.notification import NotificationService
+from app.api.v1.deps import get_current_user_id
+
+router = APIRouter()
+
+
+@router.get("")
+async def list_notifications(
+ page: int = Query(1, ge=1),
+ size: int = Query(20, ge=1, le=100),
+ unread_only: bool = Query(False),
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ service = NotificationService(db)
+ return await service.list_notifications(user_id, page, size, unread_only)
+
+
+@router.get("/unread-count")
+async def unread_count(
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ service = NotificationService(db)
+ count = await service.get_unread_count(user_id)
+ return {"count": count}
+
+
+@router.patch("/{notification_id}/read")
+async def mark_read(
+ notification_id: str,
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ service = NotificationService(db)
+ success = await service.mark_read(user_id, notification_id)
+ if not success:
+ raise HTTPException(status_code=404, detail="Notification not found")
+ return {"status": "ok"}
+
+
+@router.post("/read-all")
+async def mark_all_read(
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ service = NotificationService(db)
+ count = await service.mark_all_read(user_id)
+ return {"status": "ok", "count": count}
+
+
+@router.delete("/{notification_id}")
+async def delete_notification(
+ notification_id: str,
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ service = NotificationService(db)
+ success = await service.delete_notification(user_id, notification_id)
+ if not success:
+ raise HTTPException(status_code=404, detail="Notification not found")
+ return {"status": "ok"}
\ No newline at end of file
diff --git a/backend/app/api/v1/onboarding.py b/backend/app/api/v1/onboarding.py
new file mode 100644
index 0000000..d7fe064
--- /dev/null
+++ b/backend/app/api/v1/onboarding.py
@@ -0,0 +1,43 @@
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.ext.asyncio import AsyncSession
+from typing import Annotated
+from pydantic import BaseModel
+from app.database import get_db
+from app.services.onboarding import OnboardingService
+from app.api.v1.deps import get_current_user_id
+
+router = APIRouter()
+
+
+class OnboardingRequest(BaseModel):
+ name: str
+ description: str
+ category: str = ""
+ target: str = "US importers"
+
+
+@router.get("/status")
+async def get_status(
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ service = OnboardingService(db)
+ return await service.check_status(user_id)
+
+
+@router.post("/product")
+async def create_first_product(
+ data: OnboardingRequest,
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ if not data.name.strip():
+ raise HTTPException(status_code=400, detail="Product name is required")
+ service = OnboardingService(db)
+ return await service.generate_first_product(
+ user_id=user_id,
+ name=data.name.strip(),
+ description=data.description.strip(),
+ category=data.category,
+ target=data.target,
+ )
\ No newline at end of file
diff --git a/backend/app/api/v1/payment.py b/backend/app/api/v1/payment.py
new file mode 100644
index 0000000..c4915d0
--- /dev/null
+++ b/backend/app/api/v1/payment.py
@@ -0,0 +1,58 @@
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.ext.asyncio import AsyncSession
+from typing import Annotated
+from pydantic import BaseModel
+from app.database import get_db
+from app.services.payment import PaymentService
+from app.api.v1.deps import get_current_user_id
+
+router = APIRouter()
+
+
+class CreateOrderRequest(BaseModel):
+ plan: str
+
+
+class PaymentCallbackRequest(BaseModel):
+ payment_id: str
+ success: bool
+
+
+@router.get("/plans")
+async def get_plans():
+ svc = PaymentService(None)
+ return await svc.get_plans()
+
+
+@router.get("/subscription")
+async def get_subscription(
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ svc = PaymentService(db)
+ return await svc.get_current_subscription(user_id)
+
+
+@router.post("/create-order")
+async def create_order(
+ data: CreateOrderRequest,
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ svc = PaymentService(db)
+ try:
+ return await svc.create_order(user_id, data.plan)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+
+
+@router.post("/callback")
+async def payment_callback(
+ data: PaymentCallbackRequest,
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ svc = PaymentService(db)
+ success = await svc.handle_payment_callback(data.payment_id, data.success)
+ if not success:
+ raise HTTPException(status_code=404, detail="Order not found")
+ return {"status": "ok"}
\ No newline at end of file
diff --git a/backend/app/api/v1/push.py b/backend/app/api/v1/push.py
index 54d928f..b8f3be7 100644
--- a/backend/app/api/v1/push.py
+++ b/backend/app/api/v1/push.py
@@ -1,147 +1,63 @@
-from fastapi import APIRouter, Depends
+from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy import select
-from typing import Optional, List
+from typing import Annotated, Optional
from pydantic import BaseModel
from app.database import get_db
-from app.models.user import User
-from app.core.security import decode_token
+from app.services.push import PushService
+from app.api.v1.deps import get_current_user_id
router = APIRouter()
-class DeviceRegister(BaseModel):
+class DeviceRegisterRequest(BaseModel):
client_id: str
- platform: Optional[str] = None
+ platform: str = "weapp"
+ push_token: Optional[str] = None
device_info: Optional[dict] = None
-class PushMessage(BaseModel):
- title: str
- content: str
- payload: Optional[dict] = None
- target_type: str = "all"
- target_value: Optional[str] = None
-
-
-class PushResponse(BaseModel):
- success: bool
- message_id: Optional[str] = None
- error: Optional[str] = None
-
-
-# 模拟存储的设备信息(实际应存数据库)
-devices_db = {}
-
-
@router.post("/register")
async def register_device(
- data: DeviceRegister,
- authorization: str = None,
- db: AsyncSession = Depends(get_db),
+ data: DeviceRegisterRequest,
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
):
- if not authorization or not authorization.startswith("Bearer "):
- return {"error": "Unauthorized"}, 401
-
- payload = decode_token(authorization[7:])
- if not payload:
- return {"error": "Invalid token"}, 401
-
- user_id = payload.get("sub")
-
- if user_id not in devices_db:
- devices_db[user_id] = []
-
- existing = [d for d in devices_db[user_id] if d.get("client_id") == data.client_id]
- if not existing:
- devices_db[user_id].append({
- "client_id": data.client_id,
- "platform": data.platform,
- "device_info": data.device_info,
- })
-
- return {"success": True, "message": "Device registered"}
-
-
-@router.post("/send")
-async def send_push(
- message: PushMessage,
- authorization: str = None,
- db: AsyncSession = Depends(get_db),
-):
- if not authorization or not authorization.startswith("Bearer "):
- return {"error": "Unauthorized"}, 401
-
- payload = decode_token(authorization[7:])
- if not payload:
- return {"error": "Invalid token"}, 401
-
- user_id = payload.get("sub")
-
- user_devices = devices_db.get(user_id, [])
- if not user_devices:
- return PushResponse(success=False, error="No devices registered")
-
- # 实际项目中这里调用 uni-push/极光等API
- # 模拟返回成功
- message_id = f"msg_{user_id}_{int(payload.get('iat', 0))}"
-
- print(f"Push message to user {user_id}: {message.title} - {message.content}")
-
- return PushResponse(success=True, message_id=message_id)
-
-
-@router.post("/send-to-customer")
-async def send_to_customer(
- customer_id: str,
- title: str,
- content: str,
- payload: Optional[dict] = None,
- authorization: str = None,
-):
- """
- 针对特定客户的推送通知
- 例如:客户沉默提醒、报价提醒等
- """
- if not authorization or not authorization.startswith("Bearer "):
- return {"error": "Unauthorized"}, 401
-
- payload_data = decode_token(authorization[7:])
- if not payload_data:
- return {"error": "Invalid token"}, 401
-
- user_id = payload_data.get("sub")
-
- # 这里可以添加针对客户的特定逻辑
- notification = {
- "type": "customer_alert",
- "customer_id": customer_id,
- "title": title,
- "content": content,
- "payload": payload or {}
+ service = PushService(db)
+ device = await service.register_device(
+ user_id=user_id,
+ client_id=data.client_id,
+ platform=data.platform,
+ push_token=data.push_token,
+ device_info=data.device_info,
+ )
+ return {
+ "success": True,
+ "device_id": str(device.id),
+ "message": "Device registered",
}
- print(f"Customer notification for user {user_id}, customer {customer_id}: {title}")
- return PushResponse(success=True, message_id=f"alert_{customer_id}")
+@router.post("/unregister")
+async def unregister_device(
+ data: dict,
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ client_id = data.get("client_id")
+ if not client_id:
+ raise HTTPException(status_code=400, detail="client_id required")
+ service = PushService(db)
+ success = await service.unregister_device(user_id, client_id)
+ if not success:
+ raise HTTPException(status_code=404, detail="Device not found")
+ return {"success": True, "message": "Device unregistered"}
@router.get("/devices")
async def list_devices(
- authorization: str = None,
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
):
- """列出用户已注册的设备"""
- if not authorization or not authorization.startswith("Bearer "):
- return {"error": "Unauthorized"}, 401
-
- payload = decode_token(authorization[7:])
- if not payload:
- return {"error": "Invalid token"}, 401
-
- user_id = payload.get("sub")
- user_devices = devices_db.get(user_id, [])
-
- return {
- "devices": user_devices,
- "count": len(user_devices)
- }
\ No newline at end of file
+ service = PushService(db)
+ devices = await service.get_user_devices(user_id)
+ return {"devices": devices, "count": len(devices)}
diff --git a/backend/app/api/v1/quotation.py b/backend/app/api/v1/quotation.py
index c266a8d..f006789 100644
--- a/backend/app/api/v1/quotation.py
+++ b/backend/app/api/v1/quotation.py
@@ -1,13 +1,39 @@
-from fastapi import APIRouter, Depends, HTTPException, Query
+from fastapi import APIRouter, Depends, HTTPException, Query, Response
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Annotated, Optional
+from pydantic import BaseModel
from app.database import get_db
from app.services.quotation import QuotationService
+from app.services.pdf_generator import pdf_generator
+from app.services import export
from app.api.v1.deps import get_current_user_id
+from app.models.quotation import Quotation
+from app.models.customer import Customer
+from sqlalchemy import select, and_
router = APIRouter()
+class InquiryRequest(BaseModel):
+ inquiry_text: str
+ customer_id: Optional[str] = None
+
+
+@router.post("/generate-from-inquiry")
+async def generate_from_inquiry(
+ data: InquiryRequest,
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ service = QuotationService(db)
+ result = await service.generate_from_inquiry(
+ user_id=user_id,
+ inquiry_text=data.inquiry_text,
+ customer_id=data.customer_id,
+ )
+ return result
+
+
@router.post("")
async def create_quotation(
data: dict,
@@ -58,3 +84,78 @@ async def update_quotation_status(
if not quotation:
raise HTTPException(status_code=404, detail="Quotation not found")
return quotation
+
+
+@router.get("/export/csv")
+async def export_quotations(
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ service = QuotationService(db)
+ result = await service.list_quotations(user_id, 1, 9999)
+ items = result.get("items", [])
+ csv_bytes = export.export_quotations_csv(items)
+ return Response(
+ content=csv_bytes,
+ media_type="text/csv",
+ headers={"Content-Disposition": "attachment; filename=quotations.csv"},
+ )
+
+
+@router.get("/{quotation_id}/pdf")
+async def export_quotation_pdf(
+ quotation_id: str,
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ service = QuotationService(db)
+ quotation = await service.get_quotation(user_id, quotation_id)
+ if not quotation:
+ raise HTTPException(status_code=404, detail="Quotation not found")
+
+ result = await db.execute(
+ select(Customer).where(Customer.id == quotation["customer_id"])
+ )
+ customer = result.scalar_one_or_none()
+
+ pdf_data = pdf_generator.generate_quotation({
+ "quotation_number": f"{quotation_id[:8].upper()}",
+ "customer_name": customer.name if customer else "",
+ "customer_company": customer.company if customer else "",
+ "customer_country": customer.country if customer else "",
+ "date": quotation["created_at"][:10] if quotation.get("created_at") else "",
+ "valid_until": quotation.get("valid_until", ""),
+ "currency": quotation.get("currency", "USD"),
+ "items": quotation.get("items", []),
+ "subtotal": quotation.get("subtotal", 0),
+ "discount": quotation.get("discount", 0),
+ "shipping": quotation.get("shipping", 0),
+ "total": quotation.get("total", 0),
+ "payment_terms": quotation.get("payment_terms", ""),
+ "delivery_terms": quotation.get("delivery_terms", ""),
+ "lead_time": quotation.get("lead_time", ""),
+ "notes": quotation.get("notes", ""),
+ })
+
+ if not pdf_data:
+ raise HTTPException(status_code=501, detail="PDF generation not available (weasyprint not installed)")
+
+ service = QuotationService(db)
+ result = await db.execute(
+ select(Quotation).where(
+ and_(Quotation.id == quotation_id, Quotation.user_id == user_id)
+ )
+ )
+ q = result.scalar_one_or_none()
+ if q:
+ pdf_url = f"/quotations/{quotation_id}/pdf"
+ q.pdf_url = pdf_url
+ await db.flush()
+
+ return Response(
+ content=pdf_data,
+ media_type="application/pdf",
+ headers={
+ "Content-Disposition": f'attachment; filename="quotation-{quotation_id[:8]}.pdf"',
+ },
+ )
diff --git a/backend/app/api/v1/silent_pattern.py b/backend/app/api/v1/silent_pattern.py
new file mode 100644
index 0000000..6b50856
--- /dev/null
+++ b/backend/app/api/v1/silent_pattern.py
@@ -0,0 +1,34 @@
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.ext.asyncio import AsyncSession
+from typing import Annotated
+from app.database import get_db
+from app.services.silent_pattern import SilentPatternService
+from app.api.v1.deps import get_current_user_id
+
+router = APIRouter()
+
+
+@router.get("/risk-analysis")
+async def get_silent_risk_analysis(
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ service = SilentPatternService(db)
+ risks = await service.analyze_silent_risk(user_id)
+ return {
+ "items": risks,
+ "total": len(risks),
+ "high_risk": len([r for r in risks if r["risk_level"] == "high"]),
+ "medium_risk": len([r for r in risks if r["risk_level"] == "medium"]),
+ }
+
+
+@router.get("/{customer_id}/suggestions")
+async def get_followup_suggestions(
+ customer_id: str,
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ service = SilentPatternService(db)
+ suggestions = await service.get_suggestions(user_id, customer_id)
+ return {"customer_id": customer_id, "suggestions": suggestions}
diff --git a/backend/app/api/v1/teams.py b/backend/app/api/v1/teams.py
new file mode 100644
index 0000000..c92698f
--- /dev/null
+++ b/backend/app/api/v1/teams.py
@@ -0,0 +1,117 @@
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy.ext.asyncio import AsyncSession
+from typing import Annotated, Optional
+from pydantic import BaseModel
+from app.database import get_db
+from app.services.team import TeamService
+from app.api.v1.deps import get_current_user_id
+
+router = APIRouter()
+
+
+class CreateTeamRequest(BaseModel):
+ name: str
+ description: Optional[str] = None
+
+
+class InviteRequest(BaseModel):
+ user_id: str
+
+
+class UpdateRoleRequest(BaseModel):
+ role: str
+
+
+@router.post("")
+async def create_team(
+ data: CreateTeamRequest,
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ service = TeamService(db)
+ try:
+ team = await service.create_team(user_id, data.name, data.description)
+ return team
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+
+
+@router.get("")
+async def list_teams(
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ service = TeamService(db)
+ return {"teams": await service.list_user_teams(user_id)}
+
+
+@router.get("/{team_id}")
+async def get_team(
+ team_id: str,
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ service = TeamService(db)
+ team = await service.get_team(team_id, user_id)
+ if not team:
+ raise HTTPException(status_code=404, detail="Team not found")
+ return team
+
+
+@router.post("/{team_id}/invite")
+async def invite_member(
+ team_id: str,
+ data: InviteRequest,
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ service = TeamService(db)
+ try:
+ result = await service.invite_member(team_id, user_id, data.user_id)
+ return result
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+
+
+@router.delete("/{team_id}/members/{member_id}")
+async def remove_member(
+ team_id: str,
+ member_id: str,
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ service = TeamService(db)
+ success = await service.remove_member(team_id, user_id, member_id)
+ if not success:
+ raise HTTPException(status_code=404, detail="Member not found or not removable")
+ return {"message": "Member removed"}
+
+
+@router.post("/{team_id}/leave")
+async def leave_team(
+ team_id: str,
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ service = TeamService(db)
+ success = await service.leave_team(team_id, user_id)
+ if not success:
+ raise HTTPException(status_code=400, detail="Cannot leave as owner or not a member")
+ return {"message": "Left team"}
+
+
+@router.patch("/{team_id}/members/{member_id}/role")
+async def update_member_role(
+ team_id: str,
+ member_id: str,
+ data: UpdateRoleRequest,
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ service = TeamService(db)
+ if data.role not in ("admin", "member", "viewer"):
+ raise HTTPException(status_code=400, detail="Invalid role")
+ success = await service.update_role(team_id, user_id, member_id, data.role)
+ if not success:
+ raise HTTPException(status_code=404, detail="Member not found or not updatable")
+ return {"message": "Role updated"}
diff --git a/backend/app/api/v1/training.py b/backend/app/api/v1/training.py
new file mode 100644
index 0000000..fe116a5
--- /dev/null
+++ b/backend/app/api/v1/training.py
@@ -0,0 +1,44 @@
+from fastapi import APIRouter, Depends
+from sqlalchemy.ext.asyncio import AsyncSession
+from typing import Annotated
+from app.database import get_db
+from app.services.corpus_trainer import CorpusTrainer
+from app.api.v1.deps import get_current_user_id
+
+router = APIRouter()
+
+
+@router.post("/corpus/run")
+async def run_corpus_training(
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ trainer = CorpusTrainer(db)
+ result = await trainer.run_pipeline()
+ return result
+
+
+@router.post("/corpus/embeddings")
+async def compute_embeddings(
+ batch_size: int = 50,
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ trainer = CorpusTrainer(db)
+ result = await trainer.compute_embeddings(batch_size)
+ return result
+
+
+@router.get("/corpus/stats")
+async def corpus_stats(
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ trainer = CorpusTrainer(db)
+ return await trainer.get_stats()
+
+
+@router.post("/corpus/deduplicate")
+async def deduplicate_corpus(
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ trainer = CorpusTrainer(db)
+ result = await trainer.deduplicate()
+ return result
diff --git a/backend/app/api/v1/translate.py b/backend/app/api/v1/translate.py
index 781ea7b..30a7c16 100644
--- a/backend/app/api/v1/translate.py
+++ b/backend/app/api/v1/translate.py
@@ -1,8 +1,13 @@
-from fastapi import APIRouter, HTTPException
-from typing import Optional, Dict, Any
+from fastapi import APIRouter, HTTPException, Response, Depends
+from typing import Optional, Dict, Any, Annotated
from pydantic import BaseModel
+from sqlalchemy.ext.asyncio import AsyncSession
+from app.database import get_db
from app.services.translation import TranslationService
+from app.services.tts import tts_service
+from app.services.preference import UserPreferenceService
from app.core.security import decode_token
+from app.api.v1.deps import get_current_user_id
router = APIRouter()
@@ -27,13 +32,10 @@ class ExtractRequest(BaseModel):
@router.post("")
-async def translate_text(data: TranslateRequest, authorization: str = None):
- if not authorization or not authorization.startswith("Bearer "):
- raise HTTPException(status_code=401, detail="Missing token")
-
- payload = decode_token(authorization[7:])
- user_id = payload.get("sub") if payload else None
-
+async def translate_text(
+ data: TranslateRequest,
+ user_id: str = Depends(get_current_user_id),
+):
service = TranslationService()
result = await service.translate(
text=data.text,
@@ -46,9 +48,13 @@ async def translate_text(data: TranslateRequest, authorization: str = None):
@router.post("/reply")
-async def generate_reply(data: ReplyRequest, authorization: str = None):
- if not authorization or not authorization.startswith("Bearer "):
- raise HTTPException(status_code=401, detail="Missing token")
+async def generate_reply(
+ data: ReplyRequest,
+ user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
+ pref_service = UserPreferenceService(db)
+ pref_context = await pref_service.get_preference_context(user_id, "reply")
service = TranslationService()
results = await service.generate_reply(
@@ -56,25 +62,65 @@ async def generate_reply(data: ReplyRequest, authorization: str = None):
context=data.context,
tone=data.tone,
count=data.count,
+ preference_context=pref_context,
)
return {"suggestions": results, "inquiry": data.inquiry, "count": len(results)}
@router.post("/extract")
-async def extract_info(data: ExtractRequest, authorization: str = None):
- if not authorization or not authorization.startswith("Bearer "):
- raise HTTPException(status_code=401, detail="Missing token")
-
+async def extract_info(
+ data: ExtractRequest,
+ user_id: str = Depends(get_current_user_id),
+):
service = TranslationService()
result = await service.extract_info(data.text, data.extract_type)
return {"extracted": result, "type": data.extract_type}
-@router.post("/feedback")
-async def feedback(data: dict, authorization: str = None):
- if not authorization:
- raise HTTPException(status_code=401, detail="Missing token")
+class TTSRequest(BaseModel):
+ text: str
+ lang: str = "en"
+ rate: str = "0%"
+ pitch: str = "0Hz"
+
+@router.post("/tts")
+async def text_to_speech(
+ data: TTSRequest,
+ user_id: str = Depends(get_current_user_id),
+):
+ audio = await tts_service.synthesize(data.text, data.lang, data.rate, data.pitch)
+ if not audio:
+ raise HTTPException(status_code=501, detail="TTS not available (edge-tts not installed or synthesis failed)")
+
+ return Response(content=audio, media_type="audio/mpeg", headers={
+ "Content-Disposition": f'attachment; filename="tts-{data.lang}.mp3"',
+ })
+
+
+@router.get("/tts")
+async def text_to_speech_get(
+ text: str = "",
+ lang: str = "en",
+ user_id: str = Depends(get_current_user_id),
+):
+ if not text.strip():
+ raise HTTPException(status_code=400, detail="Text is required")
+
+ audio = await tts_service.synthesize(text, lang)
+ if not audio:
+ raise HTTPException(status_code=501, detail="TTS not available")
+
+ return Response(content=audio, media_type="audio/mpeg", headers={
+ "Content-Disposition": f'attachment; filename="tts-{lang}.mp3"',
+ })
+
+
+@router.post("/feedback")
+async def feedback(
+ data: dict,
+ user_id: str = Depends(get_current_user_id),
+):
from app.ai.trade_corpus import TradeCorpus
corpus = TradeCorpus()
@@ -84,3 +130,26 @@ async def feedback(data: dict, authorization: str = None):
await corpus.rate_entry(entry_id, rating)
return {"status": "ok"}
+
+
+public_router = APIRouter(tags=["translate-public"])
+
+
+@public_router.post("/translate")
+async def public_translate(data: TranslateRequest):
+ service = TranslationService()
+ result = await service.translate(
+ text=data.text,
+ target_lang=data.target_lang,
+ source_lang=data.source_lang,
+ context=data.context,
+ user_id=None,
+ )
+ return result
+
+
+@public_router.post("/extract")
+async def public_extract(data: ExtractRequest):
+ service = TranslationService()
+ result = await service.extract_info(data.text, data.extract_type)
+ return {"extracted": result, "type": data.extract_type}
diff --git a/backend/app/api/v1/whatsapp.py b/backend/app/api/v1/whatsapp.py
index 49c7686..94ba188 100644
--- a/backend/app/api/v1/whatsapp.py
+++ b/backend/app/api/v1/whatsapp.py
@@ -1,13 +1,14 @@
-from fastapi import APIRouter, Request, HTTPException, Depends
+from fastapi import APIRouter, Request, HTTPException, Depends, Header
from sqlalchemy.ext.asyncio import AsyncSession
-from typing import Annotated
+from sqlalchemy import select, and_
+from typing import Annotated, Optional
+from pydantic import BaseModel
from app.database import get_db
from app.services.whatsapp import WhatsAppService
from app.services.customer import CustomerService
-from app.services.translation import TranslationService
-from app.core.security import decode_token
from app.api.v1.deps import get_current_user_id
-from app.config import settings
+from app.models.customer import Customer
+from app.models.user import User
router = APIRouter()
@@ -26,35 +27,92 @@ async def verify_webhook(
@router.post("/webhook")
-async def handle_webhook(request: Request, db: Annotated[AsyncSession, Depends(get_db)] = None):
+async def handle_webhook(
+ request: Request,
+ x_hub_signature_256: Optional[str] = Header(None),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
+):
svc = WhatsAppService()
- body = await request.json()
+ body = await request.body()
- msg_data = svc.parse_webhook(body)
+ if x_hub_signature_256:
+ if not svc.verify_signature(body, x_hub_signature_256):
+ raise HTTPException(status_code=403, detail="Invalid signature")
+
+ import json
+ body_json = json.loads(body)
+ msg_data = svc.parse_webhook(body_json)
if not msg_data:
return {"status": "ok"}
- # TODO: Route to correct user based on WhatsApp number
- # For MVP, handle as generic incoming message
+ from_number = msg_data.get("from")
+ text = msg_data.get("text", "")
+
+ if from_number:
+ result = await db.execute(
+ select(Customer).where(Customer.whatsapp_id == from_number)
+ )
+ customer = result.scalar_one_or_none()
+
+ if customer:
+ user_id = str(customer.user_id)
+ cust_svc = CustomerService(db)
+ await cust_svc.save_message(
+ user_id=user_id,
+ customer_id=str(customer.id),
+ direction="inbound",
+ content=text,
+ )
+
return {"status": "ok", "message": "received"}
+class SendMessageRequest(BaseModel):
+ to: str
+ text: str = ""
+ template_name: Optional[str] = None
+ template_params: Optional[dict] = None
+ media_url: Optional[str] = None
+ media_type: Optional[str] = None
+
+
@router.post("/send")
async def send_message(
- data: dict,
+ data: SendMessageRequest,
user_id: str = Depends(get_current_user_id),
+ db: Annotated[AsyncSession, Depends(get_db)] = None,
):
- text = data.get("text")
- to = data.get("to")
- if not text or not to:
- raise HTTPException(status_code=400, detail="text and to are required")
-
svc = WhatsAppService()
- sent = await svc.send_text(to, text)
+
+ sent = False
+ if data.template_name and data.template_params:
+ sent = await svc.send_template(data.to, data.template_name, data.template_params)
+ elif data.media_url and data.media_type:
+ sent = await svc.send_media(data.to, data.media_url, data.media_type, caption=data.text)
+ elif data.text:
+ sent = await svc.send_text(data.to, data.text)
+ else:
+ raise HTTPException(status_code=400, detail="text, template, or media required")
+
if not sent:
raise HTTPException(status_code=500, detail="Failed to send WhatsApp message")
- return {"status": "sent", "to": to}
+ cust_svc = CustomerService(db)
+ result = await db.execute(
+ select(Customer).where(
+ and_(Customer.whatsapp_id == data.to, Customer.user_id == user_id)
+ )
+ )
+ customer = result.scalar_one_or_none()
+ if customer:
+ await cust_svc.save_message(
+ user_id=user_id,
+ customer_id=str(customer.id),
+ direction="outbound",
+ content=data.text or f"[{data.media_type or 'template'}]",
+ )
+
+ return {"status": "sent", "to": data.to}
@router.get("/qr")
diff --git a/backend/app/celery_app.py b/backend/app/celery_app.py
index b21ac2e..d57ea29 100644
--- a/backend/app/celery_app.py
+++ b/backend/app/celery_app.py
@@ -20,4 +20,26 @@ celery_app.conf.update(
task_time_limit=300,
worker_prefetch_multiplier=4,
worker_max_tasks_per_child=1000,
+ beat_schedule={
+ "check-silent-customers": {
+ "task": "app.workers.tasks.check_silent_customers",
+ "schedule": 3600.0,
+ },
+ "update-customer-health-cache": {
+ "task": "app.workers.tasks.update_customer_health_cache",
+ "schedule": 3600.0,
+ },
+ "cleanup-old-sessions": {
+ "task": "app.workers.tasks.cleanup_old_sessions",
+ "schedule": 86400.0,
+ },
+ "daily-corpus-training": {
+ "task": "app.workers.tasks.run_daily_corpus_training",
+ "schedule": 86400.0,
+ },
+ "check-followup-engine": {
+ "task": "app.workers.tasks.check_followup_engine",
+ "schedule": 21600.0,
+ },
+ },
)
\ No newline at end of file
diff --git a/backend/app/config.py b/backend/app/config.py
index dc2d215..e8392f9 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -1,4 +1,4 @@
-from pydantic_settings import BaseSettings
+from pydantic import BaseSettings
from typing import Optional
from pathlib import Path
@@ -8,7 +8,10 @@ ENV_FILE = PROJECT_ROOT / ".env"
class Settings(BaseSettings):
- model_config = {"env_file": str(ENV_FILE), "extra": "ignore"}
+ class Config:
+ env_file = str(ENV_FILE)
+ env_file_encoding = "utf-8"
+ extra = "ignore"
APP_NAME: str = "TradeMate"
@@ -29,6 +32,14 @@ class Settings(BaseSettings):
ANTHROPIC_API_KEY: Optional[str] = None
DEEPL_API_KEY: Optional[str] = None
+ SENSENOVA_API_KEY: Optional[str] = None
+ SENSENOVA_BASE_URL: str = "https://token.sensenova.cn/v1"
+ SENSENOVA_MODEL: str = "sensenova-6.7-flash-lite"
+
+ IFLYTEK_API_KEY: Optional[str] = None
+ IFLYTEK_API_BASE: str = "https://maas-api.cn-huabei-1.xf-yun.com/v2"
+ IFLYTEK_MODEL: str = "astron-code-latest"
+
LOCAL_MODEL_ENABLED: bool = False
LOCAL_MODEL_URL: str = "http://localhost:8001"
@@ -38,6 +49,7 @@ class Settings(BaseSettings):
WECHAT_APP_ID: Optional[str] = None
WECHAT_APP_SECRET: Optional[str] = None
+ WECHAT_PUSH_TEMPLATE_ID: Optional[str] = None
EXCHANGE_RATE_API_KEY: Optional[str] = None
@@ -47,12 +59,15 @@ class Settings(BaseSettings):
FRONTEND_URL: str = "http://localhost:3000"
BACKEND_URL: str = "http://localhost:8000"
+ SENTRY_DSN: Optional[str] = None
+ DEBUG: bool = True
+
AI_ROUTING: dict = {
- "translate": {"primary": "deepl", "fallback": ["openai", "local"]},
- "reply": {"primary": "openai", "fallback": ["anthropic", "local"]},
- "marketing": {"primary": "anthropic", "fallback": ["openai", "local"]},
- "extract": {"primary": "openai", "fallback": ["anthropic"]},
- "quotation": {"primary": "openai", "fallback": ["anthropic"]},
+ "translate": {"primary": "sensenova", "fallback": ["openai", "local"]},
+ "reply": {"primary": "sensenova", "fallback": ["anthropic", "local"]},
+ "marketing": {"primary": "sensenova", "fallback": ["openai", "local"]},
+ "extract": {"primary": "sensenova", "fallback": ["openai"]},
+ "quotation": {"primary": "sensenova", "fallback": ["openai"]},
}
FREE_DAILY_TRANSLATE_CHARS: int = 5000
diff --git a/backend/app/core/middleware.py b/backend/app/core/middleware.py
index 76e8f54..267a5a7 100644
--- a/backend/app/core/middleware.py
+++ b/backend/app/core/middleware.py
@@ -1,26 +1,63 @@
-from fastapi import Request
+from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from app.config import settings
from app.core.security import decode_token
import redis.asyncio as aioredis
+from redis.asyncio import ConnectionPool
import logging
+import time
from datetime import datetime
logger = logging.getLogger(__name__)
+_redis_pool = None
+
+
+async def get_redis():
+ global _redis_pool
+ if _redis_pool is None:
+ _redis_pool = ConnectionPool.from_url(settings.REDIS_URL, max_connections=20)
+ return aioredis.Redis(connection_pool=_redis_pool)
+
def get_user_tier_from_token(request: Request) -> str:
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
+ request.state.user_id = None
+ request.state.user_tier = "anonymous"
return "anonymous"
payload = decode_token(auth[7:])
if not payload:
+ request.state.user_id = None
+ request.state.user_tier = "anonymous"
return "anonymous"
request.state.user_id = payload.get("sub")
request.state.user_tier = payload.get("tier", "free")
return request.state.user_tier
+RATE_LIMITS = {
+ "free": 100,
+ "pro": 500,
+ "enterprise": 2000,
+}
+
+
+async def check_rate_limit(user_id: str, tier: str) -> int:
+ r = await get_redis()
+ now = time.time()
+ window = 60
+ key = f"ratelimit:{user_id}:{int(now // window)}"
+
+ count = await r.incr(key)
+ if count == 1:
+ await r.expire(key, window + 5)
+
+ limit = RATE_LIMITS.get(tier, 100)
+ remaining = max(0, limit - count)
+ return remaining
+
+
class TierMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
if request.url.path.startswith("/api/v1"):
@@ -49,16 +86,51 @@ class TierMiddleware(BaseHTTPMiddleware):
return response
+class RateLimitMiddleware(BaseHTTPMiddleware):
+ async def dispatch(self, request: Request, call_next):
+ if not request.url.path.startswith("/api/v1"):
+ return await call_next(request)
+
+ user_tier = getattr(request.state, "user_tier", None)
+ if user_tier in ("anonymous", None):
+ return await call_next(request)
+
+ try:
+ user_id = getattr(request.state, "user_id", None)
+ if not user_id:
+ return await call_next(request)
+ remaining = await check_rate_limit(
+ user_id, user_tier
+ )
+ if remaining == 0:
+ return Response(
+ status_code=429,
+ content='{"error":"RATE_LIMITED","detail":"Too many requests, try again later"}',
+ media_type="application/json",
+ headers={"Retry-After": "60"},
+ )
+ response = await call_next(request)
+ response.headers["X-RateLimit-Remaining"] = str(remaining)
+ return response
+ except Exception as e:
+ logger.warning(f"Rate limit check failed: {e}")
+ return await call_next(request)
+
+
class QuotaMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
if not request.url.path.startswith("/api/v1"):
return await call_next(request)
- if request.state.user_tier in ("anonymous",):
+ user_tier = getattr(request.state, "user_tier", None)
+ if user_tier in ("anonymous", None):
return await call_next(request)
- user_id = request.state.user_id
- tier = request.state.user_tier
+ user_id = getattr(request.state, "user_id", None)
+ if not user_id:
+ return await call_next(request)
+
+ tier = user_tier
if tier == "enterprise":
return await call_next(request)
@@ -102,7 +174,7 @@ class QuotaMiddleware(BaseHTTPMiddleware):
return await call_next(request)
try:
- r = aioredis.from_url(settings.REDIS_URL)
+ r = await get_redis()
key = f"quota:{user_id}:{matched_key}:{datetime.utcnow().strftime('%Y%m%d')}"
current = await r.incr(key)
await r.expire(key, 86400)
diff --git a/backend/app/core/redis.py b/backend/app/core/redis.py
new file mode 100644
index 0000000..e88dc6d
--- /dev/null
+++ b/backend/app/core/redis.py
@@ -0,0 +1,19 @@
+from app.config import settings
+import redis.asyncio as aioredis
+from redis.asyncio import ConnectionPool
+
+_pool = None
+
+
+async def get_redis():
+ global _pool
+ if _pool is None:
+ _pool = ConnectionPool.from_url(settings.REDIS_URL, max_connections=20)
+ return aioredis.Redis(connection_pool=_pool)
+
+
+async def close_redis():
+ global _pool
+ if _pool:
+ await _pool.disconnect()
+ _pool = None
diff --git a/backend/app/core/security.py b/backend/app/core/security.py
index e182368..83aa198 100644
--- a/backend/app/core/security.py
+++ b/backend/app/core/security.py
@@ -1,18 +1,24 @@
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
-from passlib.context import CryptContext
+import bcrypt
from app.config import settings
-pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
-
def verify_password(plain: str, hashed: str) -> bool:
- return pwd_context.verify(plain, hashed)
+ try:
+ password_bytes = plain.encode("utf-8")
+ if isinstance(hashed, str):
+ hashed = hashed.encode("utf-8")
+ return bcrypt.checkpw(password_bytes, hashed)
+ except Exception:
+ return False
def hash_password(password: str) -> str:
- return pwd_context.hash(password)
+ password_bytes = password[:72].encode("utf-8")
+ salt = bcrypt.gensalt()
+ return bcrypt.hashpw(password_bytes, salt).decode("utf-8")
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
diff --git a/backend/app/main.py b/backend/app/main.py
index 5d27274..f954b7f 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -2,12 +2,30 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.core.exceptions import register_exception_handlers
-from app.core.middleware import TierMiddleware, QuotaMiddleware
+from app.core.middleware import TierMiddleware, QuotaMiddleware, RateLimitMiddleware
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
+try:
+ import sentry_sdk
+ from sentry_sdk.integrations.fastapi import FastApiIntegration
+ from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
+
+ sentry_sdk.init(
+ dsn=settings.SENTRY_DSN,
+ traces_sample_rate=0.1,
+ environment="production" if not settings.DEBUG else "development",
+ integrations=[
+ FastApiIntegration(),
+ SqlalchemyIntegration(),
+ ],
+ )
+ logger.info("Sentry initialized")
+except (ImportError, Exception) as e:
+ logger.info(f"Sentry not configured: {e}")
+
app = FastAPI(
title=settings.APP_NAME,
version="1.0.0",
@@ -23,8 +41,9 @@ app.add_middleware(
allow_headers=["*"],
)
-app.add_middleware(TierMiddleware)
+app.add_middleware(RateLimitMiddleware)
app.add_middleware(QuotaMiddleware)
+app.add_middleware(TierMiddleware)
register_exception_handlers(app)
@@ -34,17 +53,29 @@ async def health():
return {"status": "ok", "app": settings.APP_NAME, "version": "1.0.0"}
-from app.api.v1 import auth, marketing, translate, customer, quotation, whatsapp, product, exchange, push
+from app.api.v1 import auth, marketing, translate, customer, quotation, whatsapp, product, exchange, push, admin, analytics, teams, onboarding, notification, feedback, payment, interaction, silent_pattern, training, followup
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
app.include_router(marketing.router, prefix="/api/v1/marketing", tags=["marketing"])
app.include_router(translate.router, prefix="/api/v1/translate", tags=["translate"])
+app.include_router(translate.public_router, prefix="/api/v1/translate/public", tags=["translate-public"])
app.include_router(customer.router, prefix="/api/v1/customers", tags=["customers"])
app.include_router(quotation.router, prefix="/api/v1/quotations", tags=["quotations"])
app.include_router(whatsapp.router, prefix="/api/v1/whatsapp", tags=["whatsapp"])
app.include_router(product.router, prefix="/api/v1/products", tags=["products"])
app.include_router(exchange.router, prefix="/api/v1/exchange", tags=["exchange"])
app.include_router(push.router, prefix="/api/v1/push", tags=["push"])
+app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"])
+app.include_router(analytics.router, prefix="/api/v1/analytics", tags=["analytics"])
+app.include_router(teams.router, prefix="/api/v1/teams", tags=["teams"])
+app.include_router(onboarding.router, prefix="/api/v1/onboarding", tags=["onboarding"])
+app.include_router(notification.router, prefix="/api/v1/notifications", tags=["notifications"])
+app.include_router(feedback.router, prefix="/api/v1/feedback", tags=["feedback"])
+app.include_router(payment.router, prefix="/api/v1/payment", tags=["payment"])
+app.include_router(interaction.router, prefix="/api/v1/interaction", tags=["interaction"])
+app.include_router(silent_pattern.router, prefix="/api/v1/silent-pattern", tags=["silent-pattern"])
+app.include_router(training.router, prefix="/api/v1/training", tags=["training"])
+app.include_router(followup.router, prefix="/api/v1/followup", tags=["followup"])
if __name__ == "__main__":
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index 9ac6419..bafda56 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -2,10 +2,26 @@ from .user import User, Product
from .customer import Customer, Conversation, Message
from .quotation import Quotation, QuotationItem
from .corpus import CorpusEntry
+from .team import Team, TeamMember
+from .analytics import UsageLog
+from .notification import Notification
+from .feedback import Feedback
+from .subscription import Subscription
+from .preference import PreferenceAnalysis, MarketingEffect
+from .device import Device
+from .followup import FollowupStrategy, FollowupLog
__all__ = [
"User", "Product",
"Customer", "Conversation", "Message",
"Quotation", "QuotationItem",
"CorpusEntry",
+ "Team", "TeamMember",
+ "UsageLog",
+ "Notification",
+ "Feedback",
+ "Subscription",
+ "PreferenceAnalysis", "MarketingEffect",
+ "Device",
+ "FollowupStrategy", "FollowupLog",
]
diff --git a/backend/app/models/analytics.py b/backend/app/models/analytics.py
new file mode 100644
index 0000000..397bf3c
--- /dev/null
+++ b/backend/app/models/analytics.py
@@ -0,0 +1,18 @@
+from sqlalchemy import Column, String, Integer, DateTime, Text, Float
+from sqlalchemy.dialects.postgresql import UUID, JSONB
+from datetime import datetime
+from app.database import Base
+import uuid
+
+
+class UsageLog(Base):
+ __tablename__ = "usage_logs"
+
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ user_id = Column(UUID(as_uuid=True), nullable=False, index=True)
+ team_id = Column(UUID(as_uuid=True), nullable=True, index=True)
+ action = Column(String(100), nullable=False)
+ detail = Column(JSONB, default={})
+ ip_address = Column(String(50))
+ user_agent = Column(String(255))
+ created_at = Column(DateTime, default=datetime.utcnow)
diff --git a/backend/app/models/corpus.py b/backend/app/models/corpus.py
index 6313bd5..7e5a053 100644
--- a/backend/app/models/corpus.py
+++ b/backend/app/models/corpus.py
@@ -1,6 +1,5 @@
-from sqlalchemy import Column, String, Integer, DateTime, Text, Float
+from sqlalchemy import Column, String, Integer, DateTime, Text, Float, Boolean
from sqlalchemy.dialects.postgresql import UUID, JSONB
-from pgvector.sqlalchemy import Vector
from datetime import datetime
from app.database import Base
import uuid
@@ -21,6 +20,6 @@ class CorpusEntry(Base):
user_edited = Column(Boolean, default=False)
user_rating = Column(Integer)
usage_count = Column(Integer, default=0)
- embedding = Column(Vector(768))
- metadata = Column(JSONB, default={})
+ embedding = Column(JSONB)
+ entry_metadata = Column("metadata", JSONB, default={})
created_at = Column(DateTime, default=datetime.utcnow)
diff --git a/backend/app/models/customer.py b/backend/app/models/customer.py
index 846177c..5277e54 100644
--- a/backend/app/models/customer.py
+++ b/backend/app/models/customer.py
@@ -10,7 +10,7 @@ class Customer(Base):
__tablename__ = "customers"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
- user_id = Column(UUID(as_uuid=True), nullable=False, index=True)
+ user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, index=True)
name = Column(String(255), nullable=False)
company = Column(String(255))
country = Column(String(100))
@@ -38,7 +38,7 @@ class Conversation(Base):
__tablename__ = "conversations"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
- user_id = Column(UUID(as_uuid=True), nullable=False, index=True)
+ user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, index=True)
customer_id = Column(UUID(as_uuid=True), ForeignKey("customers.id"), nullable=False, index=True)
channel = Column(String(50), default="whatsapp")
topic = Column(String(255))
@@ -66,7 +66,7 @@ class Message(Base):
selected_suggestion = Column(Integer)
user_edited = Column(Text)
status = Column(String(50), default="sent")
- metadata = Column(JSONB, default={})
+ msg_metadata = Column("metadata", JSONB, default={})
created_at = Column(DateTime, default=datetime.utcnow)
conversation = relationship("Conversation", back_populates="messages")
diff --git a/backend/app/models/device.py b/backend/app/models/device.py
new file mode 100644
index 0000000..32853db
--- /dev/null
+++ b/backend/app/models/device.py
@@ -0,0 +1,19 @@
+from sqlalchemy import Column, String, Boolean, DateTime, Text
+from sqlalchemy.dialects.postgresql import UUID, JSONB
+from datetime import datetime
+from app.database import Base
+import uuid
+
+
+class Device(Base):
+ __tablename__ = "devices"
+
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ user_id = Column(UUID(as_uuid=True), nullable=False, index=True)
+ platform = Column(String(50), default="weapp")
+ push_token = Column(String(500))
+ client_id = Column(String(255), nullable=False)
+ device_info = Column(JSONB, default={})
+ is_active = Column(Boolean, default=True)
+ created_at = Column(DateTime, default=datetime.utcnow)
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
diff --git a/backend/app/models/feedback.py b/backend/app/models/feedback.py
new file mode 100644
index 0000000..532ae54
--- /dev/null
+++ b/backend/app/models/feedback.py
@@ -0,0 +1,17 @@
+from sqlalchemy import Column, String, Integer, DateTime, Text
+from sqlalchemy.dialects.postgresql import UUID
+from datetime import datetime
+from app.database import Base
+import uuid
+
+
+class Feedback(Base):
+ __tablename__ = "feedbacks"
+
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ user_id = Column(UUID(as_uuid=True), nullable=False, index=True)
+ category = Column(String(50), default="general")
+ content = Column(Text, nullable=False)
+ contact = Column(String(100))
+ status = Column(String(20), default="pending")
+ created_at = Column(DateTime, default=datetime.utcnow)
\ No newline at end of file
diff --git a/backend/app/models/followup.py b/backend/app/models/followup.py
new file mode 100644
index 0000000..348c74a
--- /dev/null
+++ b/backend/app/models/followup.py
@@ -0,0 +1,51 @@
+from sqlalchemy import Column, String, Boolean, Integer, DateTime, Text, ForeignKey
+from sqlalchemy.dialects.postgresql import UUID, JSONB
+from sqlalchemy.orm import relationship
+from datetime import datetime
+from app.database import Base
+import uuid
+
+
+class FollowupStrategy(Base):
+ __tablename__ = "followup_strategies"
+
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ name = Column(String(255), nullable=False)
+ description = Column(Text)
+ trigger_condition = Column(JSONB, default={
+ "min_silence_days": 3,
+ "max_silence_days": 999,
+ "min_health_score": 0,
+ "max_health_score": 100,
+ "status_filter": ["lead", "negotiating"],
+ })
+ channel = Column(String(50), default="whatsapp")
+ ai_prompt_template = Column(Text)
+ priority = Column(Integer, default=0)
+ is_active = Column(Boolean, default=True)
+ created_at = Column(DateTime, default=datetime.utcnow)
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+
+
+class FollowupLog(Base):
+ __tablename__ = "followup_logs"
+
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ user_id = Column(UUID(as_uuid=True), nullable=False, index=True)
+ customer_id = Column(UUID(as_uuid=True), ForeignKey("customers.id"), nullable=False, index=True)
+ strategy_id = Column(UUID(as_uuid=True), ForeignKey("followup_strategies.id"), nullable=True)
+ status = Column(String(50), default="pending")
+ channel = Column(String(50), default="whatsapp")
+ content = Column(Text)
+ ai_generated_content = Column(Text)
+ user_edited_content = Column(Text)
+ health_score_at_time = Column(Integer)
+ silence_days_at_time = Column(Integer)
+ sent_at = Column(DateTime)
+ replied_at = Column(DateTime)
+ response_status = Column(String(50))
+ log_metadata = Column("metadata", JSONB, default={})
+ created_at = Column(DateTime, default=datetime.utcnow)
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+
+ customer = relationship("Customer", backref="followup_logs")
diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py
new file mode 100644
index 0000000..c4cb5b5
--- /dev/null
+++ b/backend/app/models/notification.py
@@ -0,0 +1,20 @@
+from sqlalchemy import Column, String, Boolean, DateTime, Text, ForeignKey
+from sqlalchemy.dialects.postgresql import UUID, JSONB
+from datetime import datetime
+from app.database import Base
+import uuid
+
+
+class Notification(Base):
+ __tablename__ = "notifications"
+
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ user_id = Column(UUID(as_uuid=True), nullable=False, index=True)
+ title = Column(String(255), nullable=False)
+ content = Column(Text, nullable=False)
+ notification_type = Column(String(50), default="system")
+ reference_type = Column(String(50))
+ reference_id = Column(String(255))
+ is_read = Column(Boolean, default=False)
+ notify_metadata = Column("metadata", JSONB, default={})
+ created_at = Column(DateTime, default=datetime.utcnow)
\ No newline at end of file
diff --git a/backend/app/models/preference.py b/backend/app/models/preference.py
new file mode 100644
index 0000000..f16cd9d
--- /dev/null
+++ b/backend/app/models/preference.py
@@ -0,0 +1,40 @@
+from sqlalchemy import Column, String, Boolean, DateTime, Text, Integer, Float
+from sqlalchemy.dialects.postgresql import UUID, JSONB
+from datetime import datetime
+from app.database import Base
+import uuid
+
+
+class PreferenceAnalysis(Base):
+ __tablename__ = "preference_analyses"
+
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ user_id = Column(UUID(as_uuid=True), nullable=False, index=True, unique=True)
+ task_type = Column(String(50), nullable=False, default="reply")
+ preferred_tone = Column(String(50))
+ preferred_style = Column(String(50))
+ common_replacements = Column(JSONB, default=[])
+ avg_formality_score = Column(Float, default=0.5)
+ greeting_style = Column(String(100))
+ sign_off_style = Column(String(100))
+ analysis_data = Column(JSONB, default={})
+ confidence = Column(Float, default=0.0)
+ interaction_count = Column(Integer, default=0)
+ last_analysis_at = Column(DateTime)
+ created_at = Column(DateTime, default=datetime.utcnow)
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+
+
+class MarketingEffect(Base):
+ __tablename__ = "marketing_effects"
+
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ user_id = Column(UUID(as_uuid=True), nullable=False, index=True)
+ content_hash = Column(String(64), nullable=False, index=True)
+ product_id = Column(UUID(as_uuid=True))
+ product_name = Column(String(255))
+ channel = Column(String(50), default="copy")
+ event_type = Column(String(50), nullable=False)
+ target_audience = Column(String(255))
+ effect_metadata = Column("metadata", JSONB, default={})
+ created_at = Column(DateTime, default=datetime.utcnow)
diff --git a/backend/app/models/quotation.py b/backend/app/models/quotation.py
index 60ab3d1..8d6ed74 100644
--- a/backend/app/models/quotation.py
+++ b/backend/app/models/quotation.py
@@ -10,7 +10,7 @@ class Quotation(Base):
__tablename__ = "quotations"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
- user_id = Column(UUID(as_uuid=True), nullable=False, index=True)
+ user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, index=True)
customer_id = Column(UUID(as_uuid=True), ForeignKey("customers.id"), nullable=False)
title = Column(String(255))
status = Column(String(50), default="draft")
diff --git a/backend/app/models/subscription.py b/backend/app/models/subscription.py
new file mode 100644
index 0000000..e59319a
--- /dev/null
+++ b/backend/app/models/subscription.py
@@ -0,0 +1,23 @@
+from sqlalchemy import Column, String, Integer, DateTime, Float, Boolean, ForeignKey
+from sqlalchemy.dialects.postgresql import UUID
+from datetime import datetime
+from app.database import Base
+import uuid
+
+
+class Subscription(Base):
+ __tablename__ = "subscriptions"
+
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ user_id = Column(UUID(as_uuid=True), nullable=False, index=True)
+ plan = Column(String(50), nullable=False)
+ status = Column(String(20), default="active")
+ started_at = Column(DateTime, default=datetime.utcnow)
+ expires_at = Column(DateTime)
+ auto_renew = Column(Boolean, default=True)
+ payment_provider = Column(String(50), default="wechat")
+ payment_id = Column(String(255))
+ amount = Column(Float)
+ currency = Column(String(10), default="CNY")
+ created_at = Column(DateTime, default=datetime.utcnow)
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
\ No newline at end of file
diff --git a/backend/app/models/team.py b/backend/app/models/team.py
new file mode 100644
index 0000000..46a38ea
--- /dev/null
+++ b/backend/app/models/team.py
@@ -0,0 +1,38 @@
+from sqlalchemy import Column, String, Boolean, DateTime, ForeignKey, Text, Integer
+from sqlalchemy.dialects.postgresql import UUID, JSONB
+from sqlalchemy.orm import relationship
+from datetime import datetime
+from app.database import Base
+import uuid
+
+
+class Team(Base):
+ __tablename__ = "teams"
+
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ name = Column(String(255), nullable=False)
+ owner_id = Column(UUID(as_uuid=True), nullable=False, index=True)
+ description = Column(Text)
+ member_count = Column(Integer, default=0)
+ max_members = Column(Integer, default=5)
+ tier = Column(String(50), default="free")
+ is_active = Column(Boolean, default=True)
+ created_at = Column(DateTime, default=datetime.utcnow)
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+
+ members = relationship("TeamMember", back_populates="team", cascade="all, delete-orphan")
+
+
+class TeamMember(Base):
+ __tablename__ = "team_members"
+
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ team_id = Column(UUID(as_uuid=True), ForeignKey("teams.id"), nullable=False, index=True)
+ user_id = Column(UUID(as_uuid=True), nullable=False, index=True)
+ role = Column(String(50), default="member")
+ invited_by = Column(UUID(as_uuid=True))
+ status = Column(String(50), default="active")
+ joined_at = Column(DateTime, default=datetime.utcnow)
+ created_at = Column(DateTime, default=datetime.utcnow)
+
+ team = relationship("Team", back_populates="members")
diff --git a/backend/app/models/user.py b/backend/app/models/user.py
index faf4646..e3579a4 100644
--- a/backend/app/models/user.py
+++ b/backend/app/models/user.py
@@ -1,4 +1,4 @@
-from sqlalchemy import Column, String, Boolean, Integer, DateTime, Text
+from sqlalchemy import Column, String, Boolean, Integer, DateTime, Text, ForeignKey
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
from datetime import datetime
@@ -15,6 +15,7 @@ class User(Base):
username = Column(String(100))
password_hash = Column(String(255))
tier = Column(String(50), default="free")
+ role = Column(String(20), default="user")
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -35,7 +36,7 @@ class Product(Base):
__tablename__ = "products"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
- user_id = Column(UUID(as_uuid=True), nullable=False, index=True)
+ user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, index=True)
name = Column(String(255), nullable=False)
name_en = Column(String(255))
description = Column(Text)
diff --git a/backend/app/services/admin.py b/backend/app/services/admin.py
new file mode 100644
index 0000000..ae84d8d
--- /dev/null
+++ b/backend/app/services/admin.py
@@ -0,0 +1,129 @@
+from typing import Dict, Any, List
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select, func, and_
+from app.models.user import User
+from app.models.team import Team, TeamMember
+from app.models.analytics import UsageLog
+from app.models.customer import Customer
+from app.models.quotation import Quotation
+from datetime import datetime, timedelta
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class AdminService:
+ def __init__(self, db: AsyncSession):
+ self.db = db
+
+ async def get_dashboard(self) -> Dict[str, Any]:
+ now = datetime.utcnow()
+ today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
+
+ user_count = await self.db.execute(select(func.count(User.id)))
+ team_count = await self.db.execute(select(func.count(Team.id)))
+ customer_count = await self.db.execute(select(func.count(Customer.id)))
+ quotation_count = await self.db.execute(select(func.count(Quotation.id)))
+
+ today_logs = await self.db.execute(
+ select(func.count(UsageLog.id)).where(UsageLog.created_at >= today_start)
+ )
+ total_logs = await self.db.execute(select(func.count(UsageLog.id)))
+
+ recent_users_result = await self.db.execute(
+ select(User).order_by(User.created_at.desc()).limit(5)
+ )
+ recent_users = recent_users_result.scalars().all()
+
+ return {
+ "users": {
+ "total": user_count.scalar() or 0,
+ },
+ "teams": {
+ "total": team_count.scalar() or 0,
+ },
+ "customers": {
+ "total": customer_count.scalar() or 0,
+ },
+ "quotations": {
+ "total": quotation_count.scalar() or 0,
+ },
+ "usage": {
+ "today": today_logs.scalar() or 0,
+ "total": total_logs.scalar() or 0,
+ },
+ "recent_users": [
+ {
+ "id": str(u.id),
+ "username": u.username,
+ "tier": u.tier,
+ "is_active": u.is_active,
+ "created_at": u.created_at.isoformat() if u.created_at else None,
+ }
+ for u in recent_users
+ ],
+ }
+
+ async def list_users(self, page: int = 1, size: int = 20) -> Dict[str, Any]:
+ query = select(User).order_by(User.created_at.desc()).offset((page - 1) * size).limit(size)
+ count_query = select(func.count(User.id))
+
+ total = await self.db.execute(count_query)
+ result = await self.db.execute(query)
+ users = result.scalars().all()
+
+ return {
+ "items": [
+ {
+ "id": str(u.id),
+ "username": u.username,
+ "phone": u.phone,
+ "tier": u.tier,
+ "is_active": u.is_active,
+ "created_at": u.created_at.isoformat() if u.created_at else None,
+ }
+ for u in users
+ ],
+ "total": total.scalar(),
+ "page": page,
+ "size": size,
+ }
+
+ async def update_user_tier(self, user_id: str, tier: str) -> bool:
+ result = await self.db.execute(select(User).where(User.id == user_id))
+ user = result.scalar_one_or_none()
+ if not user:
+ return False
+ user.tier = tier
+ await self.db.flush()
+ return True
+
+ async def toggle_user_active(self, user_id: str) -> bool:
+ result = await self.db.execute(select(User).where(User.id == user_id))
+ user = result.scalar_one_or_none()
+ if not user:
+ return False
+ user.is_active = not user.is_active
+ await self.db.flush()
+ return True
+
+ async def get_system_health(self) -> Dict[str, Any]:
+ return {
+ "status": "healthy",
+ "version": "1.0.0",
+ "timestamp": datetime.utcnow().isoformat(),
+ }
+
+ async def log_usage(self, user_id: str, action: str, detail: Dict = None, ip: str = None, ua: str = None):
+ try:
+ log = UsageLog(
+ user_id=user_id,
+ action=action,
+ detail=detail or {},
+ ip_address=ip,
+ user_agent=ua,
+ )
+ self.db.add(log)
+ await self.db.flush()
+ except Exception as e:
+ logger.warning(f"Failed to log usage: {e}")
diff --git a/backend/app/services/analytics.py b/backend/app/services/analytics.py
new file mode 100644
index 0000000..d78f204
--- /dev/null
+++ b/backend/app/services/analytics.py
@@ -0,0 +1,191 @@
+from typing import Dict, Any, List, Optional
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select, func, and_, extract
+from app.models.customer import Customer, Conversation, Message
+from app.models.quotation import Quotation
+from app.models.analytics import UsageLog
+from app.models.user import User
+from app.models.preference import MarketingEffect
+from datetime import datetime, timedelta
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class AnalyticsService:
+ def __init__(self, db: AsyncSession):
+ self.db = db
+
+ async def get_customer_stats(self, user_id: str) -> Dict[str, Any]:
+ total = await self.db.execute(
+ select(func.count(Customer.id)).where(Customer.user_id == user_id)
+ )
+ by_status = await self.db.execute(
+ select(Customer.status, func.count(Customer.id))
+ .where(Customer.user_id == user_id)
+ .group_by(Customer.status)
+ )
+ by_country = await self.db.execute(
+ select(Customer.country, func.count(Customer.id))
+ .where(Customer.user_id == user_id)
+ .where(Customer.country.isnot(None))
+ .group_by(Customer.country)
+ .order_by(func.count(Customer.id).desc())
+ .limit(10)
+ )
+
+ now = datetime.utcnow()
+ silent_3 = await self.db.execute(
+ select(func.count(Customer.id)).where(
+ and_(
+ Customer.user_id == user_id,
+ Customer.last_contact_at.isnot(None),
+ Customer.last_contact_at < now - timedelta(days=3),
+ Customer.status.in_(["lead", "negotiating"]),
+ )
+ )
+ )
+
+ return {
+ "total": total.scalar() or 0,
+ "by_status": {row[0] or "unknown": row[1] for row in by_status.all()},
+ "by_country": {row[0] or "unknown": row[1] for row in by_country.all()},
+ "silent_customers": silent_3.scalar() or 0,
+ }
+
+ async def get_translation_stats(self, user_id: str) -> Dict[str, Any]:
+ now = datetime.utcnow()
+ today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
+
+ today_count = await self.db.execute(
+ select(func.count(UsageLog.id)).where(
+ and_(
+ UsageLog.user_id == user_id,
+ UsageLog.action == "translate",
+ UsageLog.created_at >= today_start,
+ )
+ )
+ )
+ total_count = await self.db.execute(
+ select(func.count(UsageLog.id)).where(
+ and_(UsageLog.user_id == user_id, UsageLog.action == "translate")
+ )
+ )
+
+ daily_result = await self.db.execute(
+ select(
+ extract("year", UsageLog.created_at),
+ extract("month", UsageLog.created_at),
+ extract("day", UsageLog.created_at),
+ func.count(UsageLog.id),
+ )
+ .where(
+ and_(
+ UsageLog.user_id == user_id,
+ UsageLog.action == "translate",
+ UsageLog.created_at >= now - timedelta(days=30),
+ )
+ )
+ .group_by(
+ extract("year", UsageLog.created_at),
+ extract("month", UsageLog.created_at),
+ extract("day", UsageLog.created_at),
+ )
+ .order_by(
+ extract("year", UsageLog.created_at),
+ extract("month", UsageLog.created_at),
+ extract("day", UsageLog.created_at),
+ )
+ )
+
+ return {
+ "today": today_count.scalar() or 0,
+ "total": total_count.scalar() or 0,
+ "daily": [
+ {
+ "date": f"{int(r[0])}-{int(r[1]):02d}-{int(r[2]):02d}",
+ "count": r[3],
+ }
+ for r in daily_result.all()
+ ],
+ }
+
+ async def get_quotation_stats(self, user_id: str) -> Dict[str, Any]:
+ total = await self.db.execute(
+ select(func.count(Quotation.id)).where(Quotation.user_id == user_id)
+ )
+ by_status = await self.db.execute(
+ select(Quotation.status, func.count(Quotation.id))
+ .where(Quotation.user_id == user_id)
+ .group_by(Quotation.status)
+ )
+ total_value = await self.db.execute(
+ select(func.sum(Quotation.total)).where(
+ and_(Quotation.user_id == user_id, Quotation.status == "accepted")
+ )
+ )
+
+ return {
+ "total": total.scalar() or 0,
+ "by_status": {row[0] or "draft": row[1] for row in by_status.all()},
+ "total_accepted_value": float(total_value.scalar() or 0),
+ }
+
+ async def get_message_stats(self, user_id: str) -> Dict[str, Any]:
+ now = datetime.utcnow()
+ today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
+
+ total_msgs = await self.db.execute(
+ select(func.count(Message.id))
+ .join(Conversation, Message.conversation_id == Conversation.id)
+ .where(Conversation.user_id == user_id)
+ )
+ today_msgs = await self.db.execute(
+ select(func.count(Message.id))
+ .join(Conversation, Message.conversation_id == Conversation.id)
+ .where(
+ and_(
+ Conversation.user_id == user_id,
+ Message.created_at >= today_start,
+ )
+ )
+ )
+
+ return {
+ "total": total_msgs.scalar() or 0,
+ "today": today_msgs.scalar() or 0,
+ }
+
+ async def get_marketing_stats(self, user_id: str) -> Dict[str, Any]:
+ total = await self.db.execute(
+ select(func.count(MarketingEffect.id)).where(MarketingEffect.user_id == user_id)
+ )
+ copy_count = await self.db.execute(
+ select(func.count(MarketingEffect.id)).where(
+ and_(MarketingEffect.user_id == user_id, MarketingEffect.event_type == "copy")
+ )
+ )
+ send_count = await self.db.execute(
+ select(func.count(MarketingEffect.id)).where(
+ and_(MarketingEffect.user_id == user_id, MarketingEffect.event_type == "send")
+ )
+ )
+ top_products = await self.db.execute(
+ select(MarketingEffect.product_name, func.count(MarketingEffect.id))
+ .where(
+ and_(
+ MarketingEffect.user_id == user_id,
+ MarketingEffect.product_name.isnot(None),
+ )
+ )
+ .group_by(MarketingEffect.product_name)
+ .order_by(func.count(MarketingEffect.id).desc())
+ .limit(5)
+ )
+
+ return {
+ "total_events": total.scalar() or 0,
+ "copy_count": copy_count.scalar() or 0,
+ "send_count": send_count.scalar() or 0,
+ "top_products": [{"name": r[0], "count": r[1]} for r in top_products.all()],
+ }
diff --git a/backend/app/services/corpus_trainer.py b/backend/app/services/corpus_trainer.py
new file mode 100644
index 0000000..316493b
--- /dev/null
+++ b/backend/app/services/corpus_trainer.py
@@ -0,0 +1,186 @@
+from typing import Dict, Any, Optional, List
+from sqlalchemy import select, func, and_
+from sqlalchemy.ext.asyncio import AsyncSession
+from datetime import datetime, timedelta
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class CorpusTrainer:
+ def __init__(self, db: AsyncSession):
+ self.db = db
+
+ async def compute_embeddings(self, batch_size: int = 50) -> Dict[str, Any]:
+ from app.models.corpus import CorpusEntry
+
+ result = await self.db.execute(
+ select(CorpusEntry).where(CorpusEntry.embedding.is_(None)).limit(batch_size)
+ )
+ entries = result.scalars().all()
+
+ updated = 0
+ for entry in entries:
+ try:
+ embedding = await self._generate_embedding(entry.source_text)
+ if embedding:
+ entry.embedding = embedding
+ updated += 1
+ except Exception as e:
+ logger.warning(f"Embedding failed for entry {entry.id}: {e}")
+
+ await self.db.flush()
+ return {"processed": len(entries), "updated": updated}
+
+ async def score_entries(self, batch_size: int = 100) -> Dict[str, Any]:
+ from app.models.corpus import CorpusEntry
+
+ result = await self.db.execute(
+ select(CorpusEntry)
+ .where(CorpusEntry.quality_score.is_(None))
+ .limit(batch_size)
+ )
+ entries = result.scalars().all()
+
+ updated = 0
+ for entry in entries:
+ score = self._calculate_quality_score(entry)
+ entry.quality_score = score
+ updated += 1
+
+ await self.db.flush()
+ return {"processed": len(entries), "updated": updated}
+
+ async def deduplicate(self) -> Dict[str, Any]:
+ from app.models.corpus import CorpusEntry
+
+ subquery = (
+ select(
+ CorpusEntry.source_text,
+ CorpusEntry.task_type,
+ func.min(CorpusEntry.id).label("keep_id"),
+ )
+ .group_by(CorpusEntry.source_text, CorpusEntry.task_type)
+ .having(func.count(CorpusEntry.id) > 1)
+ .subquery()
+ )
+
+ result = await self.db.execute(
+ select(CorpusEntry).where(
+ and_(
+ CorpusEntry.source_text == subquery.c.source_text,
+ CorpusEntry.task_type == subquery.c.task_type,
+ CorpusEntry.id != subquery.c.keep_id,
+ )
+ )
+ )
+ duplicates = result.scalars().all()
+
+ for dup in duplicates:
+ await self.db.delete(dup)
+
+ await self.db.flush()
+ return {"duplicates_removed": len(duplicates)}
+
+ async def prune_low_quality(self, min_score: float = 0.2, max_age_days: int = 90) -> Dict[str, Any]:
+ from app.models.corpus import CorpusEntry
+
+ cutoff = datetime.utcnow() - timedelta(days=max_age_days)
+ result = await self.db.execute(
+ select(CorpusEntry).where(
+ and_(
+ CorpusEntry.quality_score < min_score,
+ CorpusEntry.created_at < cutoff,
+ CorpusEntry.usage_count.is_(None) | (CorpusEntry.usage_count < 2),
+ )
+ )
+ )
+ entries = result.scalars().all()
+
+ for e in entries:
+ await self.db.delete(e)
+
+ await self.db.flush()
+ return {"pruned": len(entries)}
+
+ async def get_stats(self) -> Dict[str, Any]:
+ from app.models.corpus import CorpusEntry
+
+ total = await self.db.execute(select(func.count(CorpusEntry.id)))
+ by_type = await self.db.execute(
+ select(CorpusEntry.task_type, func.count(CorpusEntry.id))
+ .group_by(CorpusEntry.task_type)
+ )
+ with_embeddings = await self.db.execute(
+ select(func.count(CorpusEntry.id)).where(CorpusEntry.embedding.isnot(None))
+ )
+ high_quality = await self.db.execute(
+ select(func.count(CorpusEntry.id)).where(CorpusEntry.quality_score >= 0.7)
+ )
+ low_quality = await self.db.execute(
+ select(func.count(CorpusEntry.id)).where(CorpusEntry.quality_score < 0.3)
+ )
+
+ return {
+ "total_entries": total.scalar() or 0,
+ "by_task_type": {row[0]: row[1] for row in by_type.all()},
+ "with_embeddings": with_embeddings.scalar() or 0,
+ "high_quality": high_quality.scalar() or 0,
+ "low_quality": low_quality.scalar() or 0,
+ }
+
+ async def run_pipeline(self) -> Dict[str, Any]:
+ dedup_result = await self.deduplicate()
+ score_result = await self.score_entries()
+ embed_result = await self.compute_embeddings()
+ prune_result = await self.prune_low_quality()
+ stats = await self.get_stats()
+
+ return {
+ "deduplication": dedup_result,
+ "scoring": score_result,
+ "embeddings": embed_result,
+ "pruning": prune_result,
+ "stats": stats,
+ }
+
+ def _calculate_quality_score(self, entry) -> float:
+ score = 0.5
+
+ if entry.user_rating:
+ score = entry.user_rating / 5.0
+
+ if entry.user_edited:
+ score = max(score - 0.1, 0)
+
+ if entry.usage_count and entry.usage_count > 5:
+ score = min(score + 0.15, 1.0)
+
+ src_len = len(entry.source_text) if entry.source_text else 0
+ tgt_len = len(entry.target_text) if entry.target_text else 0
+ if src_len > 10 and tgt_len > 10:
+ score = min(score + 0.1, 1.0)
+ if src_len < 3 or tgt_len < 3:
+ score = max(score - 0.3, 0)
+
+ return round(score, 2)
+
+ async def _generate_embedding(self, text: str) -> Optional[List[float]]:
+ try:
+ from app.config import settings
+ import httpx
+
+ if settings.OPENAI_API_KEY:
+ async with httpx.AsyncClient() as client:
+ resp = await client.post(
+ "https://api.openai.com/v1/embeddings",
+ headers={"Authorization": f"Bearer {settings.OPENAI_API_KEY}"},
+ json={"model": "text-embedding-3-small", "input": text[:8000]},
+ timeout=30,
+ )
+ if resp.status_code == 200:
+ data = resp.json()
+ return data["data"][0]["embedding"]
+ except Exception as e:
+ logger.warning(f"Embedding generation failed: {e}")
+ return None
diff --git a/backend/app/services/customer_health.py b/backend/app/services/customer_health.py
new file mode 100644
index 0000000..899836d
--- /dev/null
+++ b/backend/app/services/customer_health.py
@@ -0,0 +1,333 @@
+from typing import Dict, Any, Optional, List
+from datetime import datetime, timedelta
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select, func, and_, desc
+from app.models.customer import Customer, Message, Conversation
+from app.models.quotation import Quotation
+import logging
+
+logger = logging.getLogger(__name__)
+
+DEAL_SIGNAL_KEYWORDS = [
+ "moq", "minimum order", "sample", "certification", "certificate",
+ "fob", "cif", "lead time", "delivery time", "shipping",
+ "payment term", "tt", "lc", "deposit", "price", "quotation",
+ "order", "purchase", "buy", "interested", "inquiry", "rfq",
+]
+
+POSITIVE_WORDS = ["yes", "interested", "good", "great", "perfect", "thanks", "thank you", "proceed", "confirm", "agree"]
+NEGATIVE_WORDS = ["no", "not interested", "too expensive", "high price", "over budget", "not now", "later", "maybe later"]
+
+
+class CustomerHealthService:
+ def __init__(self, db: AsyncSession):
+ self.db = db
+
+ async def get_health_overview(self, user_id: str) -> Dict[str, Any]:
+ customers_result = await self.db.execute(
+ select(Customer.id, Customer.status, Customer.last_contact_at).where(
+ Customer.user_id == user_id
+ )
+ )
+ rows = customers_result.all()
+ total = len(rows)
+ active = 0
+ watch = 0
+ critical = 0
+ for row in rows:
+ score = self._calculate_silence_score(row.last_contact_at)
+ status_weight = self._status_weight(row.status)
+ combined = score * 0.7 + status_weight * 0.3
+ if combined >= 70:
+ active += 1
+ elif combined >= 40:
+ watch += 1
+ else:
+ critical += 1
+ return {
+ "total": total,
+ "active": active,
+ "watch": watch,
+ "critical": critical,
+ }
+
+ async def get_customer_health(self, user_id: str, customer_id: str) -> Optional[Dict[str, Any]]:
+ result = await self.db.execute(
+ select(Customer).where(
+ and_(Customer.id == customer_id, Customer.user_id == user_id)
+ )
+ )
+ customer = result.scalar_one_or_none()
+ if not customer:
+ return None
+ return await self._compute_full_health(user_id, customer)
+
+ async def get_all_health_scores(self, user_id: str) -> List[Dict[str, Any]]:
+ customers_result = await self.db.execute(
+ select(Customer).where(Customer.user_id == user_id).order_by(Customer.updated_at.desc())
+ )
+ customers = customers_result.scalars().all()
+ results = []
+ for c in customers:
+ health = await self._compute_full_health(user_id, c)
+ results.append(health)
+ return results
+
+ async def _compute_full_health(self, user_id: str, customer: Customer) -> Dict[str, Any]:
+ response_trend = await self._calc_response_trend(customer.id)
+ sentiment = await self._calc_sentiment(customer.id)
+ inquiry_depth = await self._calc_inquiry_depth(customer.id)
+ silence_score = self._calculate_silence_score(customer.last_contact_at)
+ business_value = await self._calc_business_value(customer.id)
+
+ silence_days = self._silence_days(customer.last_contact_at)
+ dimensions = {
+ "response_trend": response_trend,
+ "sentiment": sentiment,
+ "inquiry_depth": inquiry_depth,
+ "silence": {"score": silence_score, "days": silence_days},
+ "business_value": business_value,
+ }
+ result = self.calc_total_score(dimensions)
+
+ return {
+ "customer_id": str(customer.id),
+ "customer_name": customer.name,
+ "status": customer.status,
+ "total_score": result["total_score"],
+ "grade": result["grade"],
+ "dimensions": dimensions,
+ "suggestion": self._suggestion(result["grade"], customer),
+ }
+
+ async def _calc_response_trend(self, customer_id: str) -> Dict[str, Any]:
+ now_7d_ago = datetime.utcnow() - timedelta(days=7)
+ prev_7d_ago = datetime.utcnow() - timedelta(days=14)
+
+ recent_result = await self.db.execute(
+ select(func.avg(
+ func.extract("epoch", Message.created_at) -
+ func.extract("epoch", func.lag(Message.created_at).over(order_by=Message.created_at))
+ )).where(
+ and_(
+ Message.conversation_id == select(Conversation.id).where(
+ Conversation.customer_id == customer_id
+ ).limit(1).scalar_subquery(),
+ Message.direction == "inbound",
+ Message.created_at >= now_7d_ago,
+ )
+ )
+ )
+
+ previous_result = await self.db.execute(
+ select(func.avg(
+ func.extract("epoch", Message.created_at) -
+ func.extract("epoch", func.lag(Message.created_at).over(order_by=Message.created_at))
+ )).where(
+ and_(
+ Message.conversation_id == select(Conversation.id).where(
+ Conversation.customer_id == customer_id
+ ).limit(1).scalar_subquery(),
+ Message.direction == "inbound",
+ Message.created_at >= prev_7d_ago,
+ Message.created_at < now_7d_ago,
+ )
+ )
+ )
+
+ recent_avg = recent_result.scalar()
+ prev_avg = previous_result.scalar()
+
+ recent_hours = (recent_avg / 3600) if recent_avg else None
+ prev_hours = (prev_avg / 3600) if prev_avg else None
+ return self.calc_response_score(recent_hours, prev_hours)
+
+ async def _calc_sentiment(self, customer_id: str) -> Dict[str, Any]:
+ conv_result = await self.db.execute(
+ select(Conversation.id).where(
+ Conversation.customer_id == customer_id
+ ).order_by(Conversation.created_at.desc()).limit(1)
+ )
+ conv_id = conv_result.scalar_one_or_none()
+ if not conv_id:
+ return {"score": 50, "label": "neutral", "last_messages": []}
+
+ msg_result = await self.db.execute(
+ select(Message.content).where(
+ and_(
+ Message.conversation_id == conv_id,
+ Message.direction == "inbound",
+ )
+ ).order_by(desc(Message.created_at)).limit(3)
+ )
+ messages = list(msg_result.scalars().all())
+ return self.calc_sentiment_score(messages)
+
+ async def _calc_inquiry_depth(self, customer_id: str) -> Dict[str, Any]:
+ conv_result = await self.db.execute(
+ select(Conversation.id).where(
+ Conversation.customer_id == customer_id
+ ).order_by(Conversation.created_at.desc()).limit(1)
+ )
+ conv_id = conv_result.scalar_one_or_none()
+ if not conv_id:
+ return {"score": 0, "signals_found": [], "signal_count": 0}
+
+ msg_result = await self.db.execute(
+ select(Message.content).where(
+ and_(
+ Message.conversation_id == conv_id,
+ Message.direction == "inbound",
+ )
+ ).order_by(desc(Message.created_at)).limit(20)
+ )
+ messages = list(msg_result.scalars().all())
+ return self.calc_inquiry_depth_score(messages)
+
+ @staticmethod
+ def calculate_silence_score(last_contact_at: Optional[datetime]) -> float:
+ days = CustomerHealthService.silence_days(last_contact_at)
+ return max(0, min(100, 100 - (days / 14) * 100))
+
+ @staticmethod
+ def silence_days(last_contact_at: Optional[datetime]) -> int:
+ if not last_contact_at:
+ return 999
+ return (datetime.utcnow() - last_contact_at).days
+
+ @staticmethod
+ def status_weight(status: Optional[str]) -> float:
+ mapping = {"customer": 100, "negotiating": 70, "lead": 40, "lost": 10}
+ return mapping.get(status, 40)
+
+ @staticmethod
+ def grade(score: float) -> str:
+ if score >= 80:
+ return "active"
+ elif score >= 50:
+ return "watch"
+ else:
+ return "critical"
+
+ @staticmethod
+ def calc_response_score(recent_hours: Optional[float], prev_hours: Optional[float]) -> Dict[str, Any]:
+ if recent_hours is None and prev_hours is None:
+ return {"score": 50, "recent_avg_hours": None, "trend": "stable"}
+ if recent_hours is None:
+ return {"score": 30, "recent_avg_hours": None, "trend": "declining"}
+ if prev_hours is None or prev_hours == 0:
+ score = max(0, min(100, 100 - recent_hours * 5))
+ return {"score": round(score), "recent_avg_hours": round(recent_hours, 1), "trend": "stable"}
+ if recent_hours < prev_hours:
+ score = max(0, min(100, 100 - recent_hours * 5))
+ return {"score": round(score), "recent_avg_hours": round(recent_hours, 1), "trend": "improving"}
+ else:
+ score = max(0, min(100, 80 - recent_hours * 3))
+ return {"score": round(score), "recent_avg_hours": round(recent_hours, 1), "trend": "declining"}
+
+ @staticmethod
+ def calc_sentiment_score(messages: List[str]) -> Dict[str, Any]:
+ if not messages:
+ return {"score": 50, "label": "neutral", "last_messages": []}
+ positive = 0
+ negative = 0
+ for msg in messages:
+ lower = msg.lower()
+ if any(w in lower for w in POSITIVE_WORDS):
+ positive += 1
+ if any(w in lower for w in NEGATIVE_WORDS):
+ negative += 1
+ if positive > negative:
+ return {"score": 80, "label": "positive", "last_messages": messages}
+ elif negative > positive:
+ return {"score": 20, "label": "negative", "last_messages": messages}
+ else:
+ return {"score": 50, "label": "neutral", "last_messages": messages}
+
+ @staticmethod
+ def calc_inquiry_depth_score(messages: List[str]) -> Dict[str, Any]:
+ found_signals = []
+ for msg in messages:
+ lower = msg.lower()
+ for kw in DEAL_SIGNAL_KEYWORDS:
+ if kw in lower and kw not in found_signals:
+ found_signals.append(kw)
+ count = len(found_signals)
+ if count >= 5:
+ score = 100
+ elif count >= 3:
+ score = 75
+ elif count >= 1:
+ score = 50
+ else:
+ score = 0
+ return {"score": score, "signals_found": found_signals, "signal_count": count}
+
+ @staticmethod
+ def calc_business_value_score(total_value: float) -> Dict[str, Any]:
+ if total_value >= 100000:
+ score = 100
+ elif total_value >= 50000:
+ score = 80
+ elif total_value >= 10000:
+ score = 60
+ elif total_value >= 1000:
+ score = 40
+ elif total_value > 0:
+ score = 20
+ else:
+ score = 0
+ return {"score": score, "total_value": round(total_value, 2)}
+
+ @staticmethod
+ def calc_total_score(dimensions: Dict[str, Any]) -> Dict[str, Any]:
+ total = (
+ dimensions.get("response_trend", {}).get("score", 0) * 0.25
+ + dimensions.get("sentiment", {}).get("score", 0) * 0.20
+ + dimensions.get("inquiry_depth", {}).get("score", 0) * 0.20
+ + dimensions.get("silence", {}).get("score", 0) * 0.20
+ + dimensions.get("business_value", {}).get("score", 0) * 0.15
+ )
+ return {"total_score": round(total, 1), "grade": CustomerHealthService.grade(total)}
+
+ @staticmethod
+ def suggestion(grade: str, silence_days: int, status: Optional[str]) -> str:
+ if grade == "active":
+ return "保持正常跟进,客户状态良好"
+ elif grade == "watch":
+ if silence_days >= 3:
+ return f"客户已沉默{silence_days}天,建议3天内安排跟进"
+ return "客户活跃度下降,建议关注"
+ else:
+ if status in ("lead", "negotiating"):
+ return f"客户已沉默{silence_days}天,建议立即跟进,提供优惠或新产品信息"
+ return f"客户已沉默{silence_days}天,建议重新激活"
+
+ def _calculate_silence_score(self, last_contact_at: Optional[datetime]) -> float:
+ return self.calculate_silence_score(last_contact_at)
+
+ def _silence_days(self, last_contact_at: Optional[datetime]) -> int:
+ return self.silence_days(last_contact_at)
+
+ def _status_weight(self, status: Optional[str]) -> float:
+ return self.status_weight(status)
+
+ def _grade(self, score: float) -> str:
+ return self.grade(score)
+
+ def _suggestion(self, grade: str, customer: Customer) -> str:
+ return self.suggestion(grade, self._silence_days(customer.last_contact_at), customer.status)
+
+ async def _calc_business_value(self, customer_id: str) -> Dict[str, Any]:
+ result = await self.db.execute(
+ select(func.sum(Quotation.total)).where(
+ and_(
+ Quotation.customer_id == customer_id,
+ Quotation.status.in_(["sent", "accepted"]),
+ )
+ )
+ )
+ total_value = result.scalar() or 0
+ return self.calc_business_value_score(total_value)
+
+
diff --git a/backend/app/services/exchange.py b/backend/app/services/exchange.py
new file mode 100644
index 0000000..733f6fb
--- /dev/null
+++ b/backend/app/services/exchange.py
@@ -0,0 +1,122 @@
+from typing import Dict, Optional
+from datetime import datetime
+from app.config import settings
+from app.core.redis import get_redis
+import httpx
+import json
+import logging
+
+logger = logging.getLogger(__name__)
+
+FALLBACK_RATES: Dict[str, Dict[str, float]] = {
+ "USD": {"CNY": 7.24, "EUR": 0.92, "GBP": 0.79, "JPY": 151.50, "KRW": 1320.00, "AUD": 1.52, "CAD": 1.37, "INR": 83.50, "BRL": 5.10, "RUB": 92.00},
+ "CNY": {"USD": 0.138, "EUR": 0.127, "GBP": 0.109, "JPY": 20.93, "KRW": 182.32, "AUD": 0.21, "CAD": 0.19},
+ "EUR": {"USD": 1.09, "CNY": 7.85, "GBP": 0.86, "JPY": 164.50, "KRW": 1435.00},
+ "GBP": {"USD": 1.27, "CNY": 9.15, "EUR": 1.16, "JPY": 192.00},
+}
+
+CACHE_TTL = 21600
+
+
+class ExchangeRateService:
+ def __init__(self):
+ self._rates_cache: Optional[Dict] = None
+ self._cache_time: Optional[datetime] = None
+
+ async def get_rate(self, from_currency: str, to_currency: str) -> Optional[float]:
+ from_currency = from_currency.upper()
+ to_currency = to_currency.upper()
+ if from_currency == to_currency:
+ return 1.0
+
+ rates = await self._get_all_rates(from_currency)
+ if rates and to_currency in rates:
+ return rates[to_currency]
+
+ base_rates = FALLBACK_RATES.get(from_currency, {})
+ return base_rates.get(to_currency)
+
+ async def convert(self, from_currency: str, to_currency: str, amount: float = 1.0) -> Optional[float]:
+ rate = await self.get_rate(from_currency, to_currency)
+ if rate is None:
+ return None
+ return round(amount * rate, 2)
+
+ async def get_all_rates(self, base: str = "USD") -> Dict[str, float]:
+ base = base.upper()
+ rates = await self._get_all_rates(base)
+ if rates:
+ return rates
+ return FALLBACK_RATES.get(base, {})
+
+ async def _get_all_rates(self, base: str) -> Optional[Dict[str, float]]:
+ cached = await self._get_from_cache(base)
+ if cached:
+ return cached
+
+ rates = None
+ for fetcher in [self._fetch_from_frankfurter, self._fetch_from_exchangerate_api]:
+ try:
+ rates = await fetcher(base)
+ if rates:
+ break
+ except Exception as e:
+ logger.warning(f"Exchange rate fetcher failed: {e}")
+
+ if rates:
+ await self._set_cache(base, rates)
+
+ return rates
+
+ async def _fetch_from_frankfurter(self, base: str) -> Optional[Dict[str, float]]:
+ supported = ["USD", "EUR", "GBP", "CNY", "JPY", "KRW", "AUD", "CAD", "INR", "BRL"]
+ if base not in supported:
+ return None
+
+ try:
+ async with httpx.AsyncClient() as client:
+ resp = await client.get(
+ f"https://api.frankfurter.app/latest",
+ params={"from": base, "to": ",".join(supported)},
+ timeout=10,
+ )
+ if resp.status_code == 200:
+ data = resp.json()
+ return data.get("rates")
+ except Exception as e:
+ logger.warning(f"Frankfurter API failed: {e}")
+ return None
+
+ async def _fetch_from_exchangerate_api(self, base: str) -> Optional[Dict[str, float]]:
+ if not settings.EXCHANGE_RATE_API_KEY:
+ return None
+ try:
+ async with httpx.AsyncClient() as client:
+ resp = await client.get(
+ f"https://v6.exchangerate-api.com/v6/{settings.EXCHANGE_RATE_API_KEY}/latest/{base}",
+ timeout=10,
+ )
+ if resp.status_code == 200:
+ data = resp.json()
+ if data.get("result") == "success":
+ return data.get("conversion_rates")
+ except Exception as e:
+ logger.warning(f"ExchangeRate-API failed: {e}")
+ return None
+
+ async def _get_from_cache(self, base: str) -> Optional[Dict[str, float]]:
+ try:
+ r = await get_redis()
+ data = await r.get(f"exchange_rate:{base}")
+ if data:
+ return json.loads(data)
+ except Exception as e:
+ logger.debug(f"Redis cache miss for {base}: {e}")
+ return None
+
+ async def _set_cache(self, base: str, rates: Dict[str, float]):
+ try:
+ r = await get_redis()
+ await r.setex(f"exchange_rate:{base}", CACHE_TTL, json.dumps(rates))
+ except Exception as e:
+ logger.debug(f"Redis cache set failed for {base}: {e}")
diff --git a/backend/app/services/export.py b/backend/app/services/export.py
new file mode 100644
index 0000000..39e637e
--- /dev/null
+++ b/backend/app/services/export.py
@@ -0,0 +1,37 @@
+from typing import List, Dict, Any
+import csv
+import io
+
+
+def export_customers_csv(customers: List[Dict[str, Any]]) -> bytes:
+ output = io.StringIO()
+ writer = csv.writer(output)
+ writer.writerow(["Name", "Company", "Country", "Phone", "Email", "Status", "Last Contact"])
+ for c in customers:
+ writer.writerow([
+ c.get("name", ""),
+ c.get("company", ""),
+ c.get("country", ""),
+ c.get("phone", ""),
+ c.get("email", ""),
+ c.get("status", ""),
+ c.get("last_contact_at", ""),
+ ])
+ return output.getvalue().encode("utf-8-sig")
+
+
+def export_quotations_csv(quotations: List[Dict[str, Any]]) -> bytes:
+ output = io.StringIO()
+ writer = csv.writer(output)
+ writer.writerow(["Title", "Customer", "Currency", "Subtotal", "Total", "Status", "Date"])
+ for q in quotations:
+ writer.writerow([
+ q.get("title", ""),
+ q.get("customer_name", ""),
+ q.get("currency", "USD"),
+ q.get("subtotal", 0),
+ q.get("total", 0),
+ q.get("status", ""),
+ q.get("created_at", ""),
+ ])
+ return output.getvalue().encode("utf-8-sig")
\ No newline at end of file
diff --git a/backend/app/services/followup_engine.py b/backend/app/services/followup_engine.py
new file mode 100644
index 0000000..b269b42
--- /dev/null
+++ b/backend/app/services/followup_engine.py
@@ -0,0 +1,396 @@
+from typing import Dict, Any, Optional, List
+from datetime import datetime, timedelta
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select, and_, or_, desc
+from app.models.followup import FollowupStrategy, FollowupLog
+from app.models.customer import Customer
+from app.models.notification import Notification
+from app.ai.router import get_ai_router
+from app.services.customer_health import CustomerHealthService
+import logging
+
+logger = logging.getLogger(__name__)
+
+DEFAULT_STRATEGIES = [
+ {
+ "name": "温和提醒",
+ "description": "沉默3-5天,健康分50-79 — 温和提醒",
+ "trigger_condition": {
+ "min_silence_days": 3,
+ "max_silence_days": 5,
+ "min_health_score": 50,
+ "max_health_score": 79,
+ "status_filter": ["lead", "negotiating"],
+ },
+ "channel": "whatsapp",
+ "ai_prompt_template": "You are a professional export sales assistant. Write a gentle follow-up message to a customer who hasn't responded in {silence_days} days. Customer name: {customer_name}. Tone: warm but professional. Keep under 100 words. Suggest checking if they need any further information about the product.",
+ "priority": 1,
+ },
+ {
+ "name": "价值提供",
+ "description": "沉默6-10天,健康分30-49 — 推送价值信息",
+ "trigger_condition": {
+ "min_silence_days": 6,
+ "max_silence_days": 10,
+ "min_health_score": 30,
+ "max_health_score": 49,
+ "status_filter": ["lead", "negotiating"],
+ },
+ "channel": "email",
+ "ai_prompt_template": "You are a professional export sales assistant. Write a follow-up email to a customer who hasn't responded in {silence_days} days. Customer: {customer_name}. Share some valuable industry news, new product catalog highlights, or certification updates to rekindle interest. Keep under 150 words.",
+ "priority": 2,
+ },
+ {
+ "name": "重新激活",
+ "description": "沉默11+天,健康分<30 — 紧急重新激活",
+ "trigger_condition": {
+ "min_silence_days": 11,
+ "max_silence_days": 999,
+ "min_health_score": 0,
+ "max_health_score": 29,
+ "status_filter": ["lead", "negotiating"],
+ },
+ "channel": "email",
+ "ai_prompt_template": "You are a professional export sales assistant. Write a re-engagement email to a customer who has been silent for {silence_days} days. Customer: {customer_name}. Offer a limited-time discount, new product launch info, or a holiday greeting. Create a sense of urgency without being pushy. Keep under 150 words.",
+ "priority": 3,
+ },
+ {
+ "name": "促进决策",
+ "description": "客户有回复但未成交,健康分60+ — 促进成交",
+ "trigger_condition": {
+ "min_silence_days": 2,
+ "max_silence_days": 7,
+ "min_health_score": 60,
+ "max_health_score": 100,
+ "status_filter": ["negotiating"],
+ },
+ "channel": "whatsapp",
+ "ai_prompt_template": "You are a professional export sales assistant. The customer {customer_name} has shown interest but hasn't placed an order yet. Write a message sharing a success story, a limited-time offer, or highlighting what makes your product different from competitors. Keep under 120 words. Tone: confident and helpful.",
+ "priority": 0,
+ },
+]
+
+
+class FollowupEngine:
+ def __init__(self, db: AsyncSession):
+ self.db = db
+ self.ai = get_ai_router()
+ self.health_service = CustomerHealthService(db)
+
+ async def ensure_default_strategies(self):
+ result = await self.db.execute(
+ select(FollowupStrategy).limit(1)
+ )
+ if result.scalar_one_or_none():
+ return
+ for s in DEFAULT_STRATEGIES:
+ strategy = FollowupStrategy(
+ name=s["name"],
+ description=s["description"],
+ trigger_condition=s["trigger_condition"],
+ channel=s["channel"],
+ ai_prompt_template=s["ai_prompt_template"],
+ priority=s["priority"],
+ )
+ self.db.add(strategy)
+ await self.db.flush()
+ logger.info(f"Created {len(DEFAULT_STRATEGIES)} default followup strategies")
+
+ async def get_strategies(self) -> List[Dict[str, Any]]:
+ result = await self.db.execute(
+ select(FollowupStrategy).order_by(FollowupStrategy.priority)
+ )
+ strategies = result.scalars().all()
+ return [
+ {
+ "id": str(s.id),
+ "name": s.name,
+ "description": s.description,
+ "trigger_condition": s.trigger_condition,
+ "channel": s.channel,
+ "priority": s.priority,
+ "is_active": s.is_active,
+ }
+ for s in strategies
+ ]
+
+ async def evaluate_customer(self, user_id: str, customer: Customer) -> Optional[Dict[str, Any]]:
+ health = await self.health_service.get_customer_health(user_id, str(customer.id))
+ if not health:
+ return None
+
+ silence_days = health["dimensions"]["silence"]["days"]
+ health_score = health["total_score"]
+
+ strategies_result = await self.db.execute(
+ select(FollowupStrategy).where(
+ and_(
+ FollowupStrategy.is_active == True,
+ )
+ ).order_by(FollowupStrategy.priority)
+ )
+ strategies = strategies_result.scalars().all()
+
+ for strategy in strategies:
+ cond = strategy.trigger_condition
+ if not cond:
+ continue
+
+ if silence_days < cond.get("min_silence_days", 0):
+ continue
+ if silence_days > cond.get("max_silence_days", 999):
+ continue
+ if health_score < cond.get("min_health_score", 0):
+ continue
+ if health_score > cond.get("max_health_score", 100):
+ continue
+ if cond.get("status_filter") and customer.status not in cond["status_filter"]:
+ continue
+
+ existing = await self.db.execute(
+ select(FollowupLog).where(
+ and_(
+ FollowupLog.customer_id == customer.id,
+ FollowupLog.strategy_id == strategy.id,
+ FollowupLog.status.in_(["pending", "sent"]),
+ FollowupLog.created_at > datetime.utcnow() - timedelta(days=7),
+ )
+ )
+ )
+ if existing.scalar_one_or_none():
+ continue
+
+ return {
+ "strategy": strategy,
+ "silence_days": silence_days,
+ "health_score": health_score,
+ }
+
+ return None
+
+ async def generate_followup_content(self, strategy: FollowupStrategy, customer: Customer, silence_days: int) -> str:
+ try:
+ prompt = strategy.ai_prompt_template.format(
+ customer_name=customer.name,
+ silence_days=silence_days,
+ company=customer.company or "",
+ )
+ result = await self.ai.execute("marketing", "generate_marketing",
+ {"name": customer.name, "description": prompt},
+ customer.country or "US",
+ "professional",
+ "en"
+ )
+ return result.get("content", "")
+ except Exception as e:
+ logger.warning(f"AI content generation failed: {e}")
+ return f"Hi {customer.name}, just checking in to see if you need any further information about our products. Looking forward to hearing from you!"
+
+ async def create_followup_log(self, user_id: str, customer: Customer,
+ strategy: FollowupStrategy, silence_days: int,
+ health_score: int, content: str) -> FollowupLog:
+ log = FollowupLog(
+ user_id=user_id,
+ customer_id=customer.id,
+ strategy_id=strategy.id,
+ status="pending",
+ channel=strategy.channel,
+ ai_generated_content=content,
+ content=content,
+ health_score_at_time=health_score,
+ silence_days_at_time=silence_days,
+ )
+ self.db.add(log)
+ await self.db.flush()
+ return log
+
+ async def scan_and_followup(self) -> Dict[str, Any]:
+ await self.ensure_default_strategies()
+
+ customers_result = await self.db.execute(
+ select(Customer).where(
+ Customer.status.in_(["lead", "negotiating"])
+ )
+ )
+ customers = customers_result.scalars().all()
+
+ processed = 0
+ notifications_sent = 0
+ logs_created = 0
+
+ for customer in customers:
+ try:
+ result = await self.evaluate_customer(str(customer.user_id), customer)
+ if not result:
+ continue
+
+ content = await self.generate_followup_content(
+ result["strategy"], customer, result["silence_days"]
+ )
+ log = await self.create_followup_log(
+ str(customer.user_id), customer,
+ result["strategy"], result["silence_days"],
+ result["health_score"], content,
+ )
+
+ title = f"跟进提醒: {customer.name}"
+ notify_content = f"{result['strategy'].name} — {content[:80]}..."
+ n = Notification(
+ user_id=customer.user_id,
+ title=title,
+ content=notify_content,
+ notification_type="followup",
+ reference_type="customer",
+ reference_id=str(customer.id),
+ )
+ self.db.add(n)
+
+ processed += 1
+ logs_created += 1
+ notifications_sent += 1
+
+ except Exception as e:
+ logger.error(f"Followup scan failed for customer {customer.id}: {e}")
+ continue
+
+ if processed > 0:
+ await self.db.flush()
+ logger.info(f"Followup scan: {processed} customers matched, {logs_created} logs, {notifications_sent} notifications")
+
+ return {
+ "customers_scanned": len(customers),
+ "followups_created": logs_created,
+ "notifications_sent": notifications_sent,
+ }
+
+ async def get_pending_followups(self, user_id: str, page: int = 1, size: int = 20) -> Dict[str, Any]:
+ query = select(FollowupLog).where(
+ and_(
+ FollowupLog.user_id == user_id,
+ FollowupLog.status == "pending",
+ )
+ ).order_by(FollowupLog.created_at.desc()).offset(
+ (page - 1) * size
+ ).limit(size)
+
+ count_q = select(FollowupLog).where(
+ and_(
+ FollowupLog.user_id == user_id,
+ FollowupLog.status == "pending",
+ )
+ )
+
+ result = await self.db.execute(query)
+ logs = result.scalars().all()
+
+ count_result = await self.db.execute(count_q)
+ total = len(count_result.scalars().all())
+
+ items = []
+ for log in logs:
+ customer_result = await self.db.execute(
+ select(Customer).where(Customer.id == log.customer_id)
+ )
+ customer = customer_result.scalar_one_or_none()
+ items.append({
+ "id": str(log.id),
+ "customer_id": str(log.customer_id),
+ "customer_name": customer.name if customer else "Unknown",
+ "strategy": "跟进",
+ "channel": log.channel,
+ "content": log.content,
+ "ai_generated_content": log.ai_generated_content,
+ "health_score": log.health_score_at_time,
+ "silence_days": log.silence_days_at_time,
+ "status": log.status,
+ "created_at": log.created_at.isoformat() if log.created_at else None,
+ })
+
+ return {"items": items, "total": total, "page": page, "size": size}
+
+ async def get_followup_logs(self, user_id: str, page: int = 1, size: int = 20) -> Dict[str, Any]:
+ query = select(FollowupLog).where(
+ FollowupLog.user_id == user_id
+ ).order_by(FollowupLog.created_at.desc()).offset(
+ (page - 1) * size
+ ).limit(size)
+
+ count_q = select(FollowupLog).where(FollowupLog.user_id == user_id)
+
+ result = await self.db.execute(query)
+ logs = result.scalars().all()
+
+ count_result = await self.db.execute(count_q)
+ total = len(count_result.scalars().all())
+
+ items = []
+ for log in logs:
+ customer_result = await self.db.execute(
+ select(Customer).where(Customer.id == log.customer_id)
+ )
+ customer = customer_result.scalar_one_or_none()
+ items.append({
+ "id": str(log.id),
+ "customer_id": str(log.customer_id),
+ "customer_name": customer.name if customer else "Unknown",
+ "channel": log.channel,
+ "content": log.content,
+ "ai_generated_content": log.ai_generated_content,
+ "user_edited_content": log.user_edited_content,
+ "status": log.status,
+ "health_score": log.health_score_at_time,
+ "silence_days": log.silence_days_at_time,
+ "sent_at": log.sent_at.isoformat() if log.sent_at else None,
+ "replied_at": log.replied_at.isoformat() if log.replied_at else None,
+ "created_at": log.created_at.isoformat() if log.created_at else None,
+ })
+
+ return {"items": items, "total": total, "page": page, "size": size}
+
+ async def mark_sent(self, user_id: str, log_id: str) -> bool:
+ result = await self.db.execute(
+ select(FollowupLog).where(
+ and_(FollowupLog.id == log_id, FollowupLog.user_id == user_id)
+ )
+ )
+ log = result.scalar_one_or_none()
+ if not log:
+ return False
+ log.status = "sent"
+ log.sent_at = datetime.utcnow()
+ await self.db.flush()
+ return True
+
+ async def mark_edited(self, user_id: str, log_id: str, edited_text: str) -> bool:
+ result = await self.db.execute(
+ select(FollowupLog).where(
+ and_(FollowupLog.id == log_id, FollowupLog.user_id == user_id)
+ )
+ )
+ log = result.scalar_one_or_none()
+ if not log:
+ return False
+ log.user_edited_content = edited_text
+ log.content = edited_text
+ log.status = "sent"
+ log.sent_at = datetime.utcnow()
+ await self.db.flush()
+ return True
+
+ async def get_stats(self, user_id: str) -> Dict[str, Any]:
+ logs_result = await self.db.execute(
+ select(FollowupLog).where(FollowupLog.user_id == user_id)
+ )
+ all_logs = logs_result.scalars().all()
+ total = len(all_logs)
+ pending = sum(1 for l in all_logs if l.status == "pending")
+ sent = sum(1 for l in all_logs if l.status == "sent")
+ replied = sum(1 for l in all_logs if l.status == "replied")
+
+ return {
+ "total_followups": total,
+ "pending": pending,
+ "sent": sent,
+ "replied": replied,
+ "completion_rate": round(sent / total * 100, 1) if total > 0 else 0,
+ }
diff --git a/backend/app/services/import_service.py b/backend/app/services/import_service.py
new file mode 100644
index 0000000..f0f4050
--- /dev/null
+++ b/backend/app/services/import_service.py
@@ -0,0 +1,112 @@
+from typing import Dict, Any, List, Optional, Tuple
+import csv
+import io
+import logging
+from datetime import datetime
+
+logger = logging.getLogger(__name__)
+
+try:
+ import openpyxl
+ HAS_OPENPYXL = True
+except ImportError:
+ HAS_OPENPYXL = False
+ logger.warning("openpyxl not installed, XLSX import disabled")
+
+
+REQUIRED_COLUMNS = {"name"}
+OPTIONAL_COLUMNS = {
+ "company", "country", "phone", "email", "whatsapp_id",
+ "source", "tags", "notes", "status", "estimated_value",
+}
+
+
+class ImportService:
+ @staticmethod
+ def parse_xlsx(file_bytes: bytes) -> Tuple[List[Dict[str, Any]], List[str]]:
+ if not HAS_OPENPYXL:
+ return [], ["openpyxl not installed"]
+
+ try:
+ wb = openpyxl.load_workbook(io.BytesIO(file_bytes), read_only=True)
+ ws = wb.active
+ rows = list(ws.iter_rows(values_only=True))
+ if not rows:
+ return [], ["Empty file"]
+
+ headers = [str(h).strip().lower() if h else "" for h in rows[0]]
+ missing = REQUIRED_COLUMNS - set(headers)
+ if missing:
+ return [], [f"Missing required columns: {', '.join(missing)}"]
+
+ records = []
+ errors = []
+ for i, row in enumerate(rows[1:], 2):
+ if all(v is None or str(v).strip() == "" for v in row):
+ continue
+ record = {}
+ for j, val in enumerate(row):
+ if j < len(headers) and headers[j]:
+ record[headers[j]] = str(val).strip() if val is not None else ""
+ if not record.get("name"):
+ errors.append(f"Row {i}: missing name")
+ continue
+ records.append(record)
+
+ return records, errors
+
+ except Exception as e:
+ return [], [f"Parse error: {str(e)}"]
+
+ @staticmethod
+ def parse_csv(file_bytes: bytes) -> Tuple[List[Dict[str, Any]], List[str]]:
+ try:
+ text = file_bytes.decode("utf-8-sig")
+ reader = csv.DictReader(io.StringIO(text))
+ if not reader.fieldnames:
+ return [], ["Empty or invalid CSV"]
+
+ headers = [h.strip().lower() for h in reader.fieldnames]
+ missing = REQUIRED_COLUMNS - set(headers)
+ if missing:
+ return [], [f"Missing required columns: {', '.join(missing)}"]
+
+ records = []
+ errors = []
+ for i, row in enumerate(reader, 2):
+ cleaned = {}
+ for k, v in row.items():
+ key = k.strip().lower()
+ if key:
+ cleaned[key] = v.strip() if v else ""
+ if not cleaned.get("name"):
+ errors.append(f"Row {i}: missing name")
+ continue
+ cleaned = {k: v for k, v in cleaned.items() if k in REQUIRED_COLUMNS | OPTIONAL_COLUMNS}
+ records.append(cleaned)
+
+ return records, errors
+
+ except Exception as e:
+ return [], [f"Parse error: {str(e)}"]
+
+ @staticmethod
+ def validate_records(records: List[Dict]) -> Tuple[List[Dict], List[str]]:
+ valid = []
+ errors = []
+ for i, r in enumerate(records, 1):
+ if r.get("status") and r["status"] not in ("lead", "negotiating", "customer", "lost", "archived"):
+ errors.append(f"Row {i}: invalid status '{r['status']}'")
+ continue
+ if r.get("phone") and not r["phone"].strip():
+ r.pop("phone", None)
+ r.setdefault("status", "lead")
+ r.setdefault("source", "import")
+ r.setdefault("tags", [])
+ if isinstance(r.get("tags"), str):
+ r["tags"] = [t.strip() for t in r["tags"].split(",") if t.strip()]
+ valid.append(r)
+ return valid, errors
+
+
+import_service = ImportService()
diff --git a/backend/app/services/marketing.py b/backend/app/services/marketing.py
index 0a710a9..591122a 100644
--- a/backend/app/services/marketing.py
+++ b/backend/app/services/marketing.py
@@ -16,13 +16,14 @@ class MarketingService:
style: str = "professional",
language: str = "en",
count: int = 3,
+ preference_context: Optional[str] = None,
) -> List[Dict[str, Any]]:
results = []
styles = self._get_style_variants(style, count)
for s in styles:
try:
- result = await self.ai.marketing(product_info, target, s, language)
+ result = await self.ai.marketing(product_info, target, s, language, preference_context)
results.append({
"content": result.get("content", ""),
"style": s,
diff --git a/backend/app/services/marketing_effect.py b/backend/app/services/marketing_effect.py
new file mode 100644
index 0000000..8558565
--- /dev/null
+++ b/backend/app/services/marketing_effect.py
@@ -0,0 +1,127 @@
+from typing import Dict, Any, Optional, List
+from datetime import datetime, timedelta
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select, func, and_
+from app.models.preference import MarketingEffect
+import hashlib
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class MarketingEffectService:
+ def __init__(self, db: AsyncSession):
+ self.db = db
+
+ async def track_event(
+ self,
+ user_id: str,
+ content: str,
+ product_id: Optional[str] = None,
+ product_name: Optional[str] = None,
+ channel: str = "copy",
+ event_type: str = "copy",
+ target_audience: str = "",
+ metadata: Optional[Dict] = None,
+ ) -> Dict[str, Any]:
+ content_hash = hashlib.sha256(content.encode()).hexdigest()
+
+ event = MarketingEffect(
+ user_id=user_id,
+ content_hash=content_hash,
+ product_id=product_id,
+ product_name=product_name,
+ channel=channel,
+ event_type=event_type,
+ target_audience=target_audience,
+ metadata=metadata or {},
+ )
+ self.db.add(event)
+ await self.db.flush()
+
+ return {
+ "id": str(event.id),
+ "event_type": event_type,
+ "content_hash": content_hash,
+ }
+
+ async def get_effects(
+ self, user_id: str, page: int = 1, size: int = 20
+ ) -> Dict[str, Any]:
+ query = (
+ select(MarketingEffect)
+ .where(MarketingEffect.user_id == user_id)
+ .order_by(MarketingEffect.created_at.desc())
+ .offset((page - 1) * size)
+ .limit(size)
+ )
+ count_query = select(func.count(MarketingEffect.id)).where(
+ MarketingEffect.user_id == user_id
+ )
+
+ total = await self.db.execute(count_query)
+ result = await self.db.execute(query)
+ events = result.scalars().all()
+
+ return {
+ "items": [
+ {
+ "id": str(e.id),
+ "product_name": e.product_name,
+ "channel": e.channel,
+ "event_type": e.event_type,
+ "target_audience": e.target_audience,
+ "created_at": e.created_at.isoformat() if e.created_at else None,
+ }
+ for e in events
+ ],
+ "total": total.scalar() or 0,
+ "page": page,
+ "size": size,
+ }
+
+ async def get_stats(self, user_id: str) -> Dict[str, Any]:
+ today = datetime.utcnow().date()
+ week_ago = today - timedelta(days=7)
+
+ total_query = select(func.count(MarketingEffect.id)).where(
+ MarketingEffect.user_id == user_id
+ )
+ today_query = select(func.count(MarketingEffect.id)).where(
+ and_(
+ MarketingEffect.user_id == user_id,
+ func.date(MarketingEffect.created_at) == today,
+ )
+ )
+ week_query = select(func.count(MarketingEffect.id)).where(
+ and_(
+ MarketingEffect.user_id == user_id,
+ func.date(MarketingEffect.created_at) >= week_ago,
+ )
+ )
+ copy_query = select(func.count(MarketingEffect.id)).where(
+ and_(
+ MarketingEffect.user_id == user_id,
+ MarketingEffect.event_type == "copy",
+ )
+ )
+ send_query = select(func.count(MarketingEffect.id)).where(
+ and_(
+ MarketingEffect.user_id == user_id,
+ MarketingEffect.event_type == "send",
+ )
+ )
+
+ totals = await self.db.execute(total_query)
+ todays = await self.db.execute(today_query)
+ weeks = await self.db.execute(week_query)
+ copies = await self.db.execute(copy_query)
+ sends = await self.db.execute(send_query)
+
+ return {
+ "total_events": totals.scalar() or 0,
+ "today": todays.scalar() or 0,
+ "this_week": weeks.scalar() or 0,
+ "copy_count": copies.scalar() or 0,
+ "send_count": sends.scalar() or 0,
+ }
diff --git a/backend/app/services/notification.py b/backend/app/services/notification.py
new file mode 100644
index 0000000..37c9cda
--- /dev/null
+++ b/backend/app/services/notification.py
@@ -0,0 +1,119 @@
+from typing import Dict, Any, List, Optional
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select, func, and_
+from app.models.notification import Notification
+from datetime import datetime
+
+
+class NotificationService:
+ def __init__(self, db: AsyncSession):
+ self.db = db
+
+ async def list_notifications(
+ self, user_id: str, page: int = 1, size: int = 20, unread_only: bool = False
+ ) -> Dict[str, Any]:
+ query = select(Notification).where(Notification.user_id == user_id)
+ if unread_only:
+ query = query.where(Notification.is_read == False)
+ query = query.order_by(Notification.created_at.desc()).offset(
+ (page - 1) * size
+ ).limit(size)
+
+ count_query = select(func.count(Notification.id)).where(
+ Notification.user_id == user_id
+ )
+ if unread_only:
+ count_query = count_query.where(Notification.is_read == False)
+
+ total = await self.db.execute(count_query)
+ result = await self.db.execute(query)
+ notifications = result.scalars().all()
+
+ return {
+ "items": [
+ {
+ "id": str(n.id),
+ "title": n.title,
+ "content": n.content,
+ "type": n.notification_type,
+ "reference_type": n.reference_type,
+ "reference_id": n.reference_id,
+ "is_read": n.is_read,
+ "created_at": n.created_at.isoformat() if n.created_at else None,
+ }
+ for n in notifications
+ ],
+ "total": total.scalar() or 0,
+ "page": page,
+ "size": size,
+ }
+
+ async def get_unread_count(self, user_id: str) -> int:
+ result = await self.db.execute(
+ select(func.count(Notification.id)).where(
+ and_(Notification.user_id == user_id, Notification.is_read == False)
+ )
+ )
+ return result.scalar() or 0
+
+ async def mark_read(self, user_id: str, notification_id: str) -> bool:
+ result = await self.db.execute(
+ select(Notification).where(
+ and_(
+ Notification.id == notification_id,
+ Notification.user_id == user_id,
+ )
+ )
+ )
+ n = result.scalar_one_or_none()
+ if not n:
+ return False
+ n.is_read = True
+ await self.db.flush()
+ return True
+
+ async def mark_all_read(self, user_id: str) -> int:
+ result = await self.db.execute(
+ select(Notification).where(
+ and_(Notification.user_id == user_id, Notification.is_read == False)
+ )
+ )
+ notifications = result.scalars().all()
+ for n in notifications:
+ n.is_read = True
+ await self.db.flush()
+ return len(notifications)
+
+ async def delete_notification(self, user_id: str, notification_id: str) -> bool:
+ result = await self.db.execute(
+ select(Notification).where(
+ and_(
+ Notification.id == notification_id,
+ Notification.user_id == user_id,
+ )
+ )
+ )
+ n = result.scalar_one_or_none()
+ if not n:
+ return False
+ await self.db.delete(n)
+ await self.db.flush()
+ return True
+
+ @staticmethod
+ async def create_notification(
+ db: AsyncSession, user_id: str, title: str, content: str,
+ notification_type: str = "system",
+ reference_type: str = None, reference_id: str = None,
+ ):
+ n = Notification(
+ user_id=user_id,
+ title=title,
+ content=content,
+ notification_type=notification_type,
+ reference_type=reference_type,
+ reference_id=reference_id,
+ )
+ db.add(n)
+ await db.flush()
+ return n
\ No newline at end of file
diff --git a/backend/app/services/onboarding.py b/backend/app/services/onboarding.py
new file mode 100644
index 0000000..a97f0c8
--- /dev/null
+++ b/backend/app/services/onboarding.py
@@ -0,0 +1,74 @@
+from typing import Dict, Any, List, Optional
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select, func
+from app.models.user import User, Product
+from app.services.marketing import MarketingService
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class OnboardingService:
+ def __init__(self, db: AsyncSession):
+ self.db = db
+
+ async def check_status(self, user_id: str) -> Dict[str, Any]:
+ product_count = await self.db.execute(
+ select(func.count(Product.id)).where(
+ Product.user_id == user_id, Product.is_active == True
+ )
+ )
+ has_products = (product_count.scalar() or 0) > 0
+ return {"onboarded": has_products}
+
+ async def generate_first_product(
+ self, user_id: str, name: str, description: str, category: str = "", target: str = "US importers"
+ ) -> Dict[str, Any]:
+ product = Product(
+ user_id=user_id,
+ name=name,
+ description=description,
+ category=category or "general",
+ is_active=True,
+ )
+ self.db.add(product)
+ await self.db.flush()
+
+ mkt = MarketingService()
+ try:
+ content = await mkt.generate(
+ product_name=name,
+ description=description,
+ category=category or "general",
+ target=target,
+ style="professional",
+ count=3,
+ language="en",
+ )
+ except Exception as e:
+ logger.warning(f"Onboarding content generation failed: {e}")
+ content = [f"Check out our {name} - {description[:100]}..."]
+
+ try:
+ keywords_result = await mkt.generate_keywords(
+ product_name=name, description=description, category=category or "general"
+ )
+ keywords = keywords_result if isinstance(keywords_result, list) else []
+ except Exception as e:
+ logger.warning(f"Keyword generation failed: {e}")
+ keywords = []
+
+ product.keywords = keywords[:10]
+ await self.db.flush()
+
+ return {
+ "product": {
+ "id": str(product.id),
+ "name": product.name,
+ "description": product.description,
+ "category": product.category,
+ "keywords": keywords[:10],
+ },
+ "generated_content": content,
+ "keywords": keywords[:10],
+ }
\ No newline at end of file
diff --git a/backend/app/services/payment.py b/backend/app/services/payment.py
new file mode 100644
index 0000000..be34d46
--- /dev/null
+++ b/backend/app/services/payment.py
@@ -0,0 +1,158 @@
+import hmac
+import hashlib
+import json
+import logging
+from typing import Optional, Dict, Any
+from datetime import datetime, timedelta
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select
+from app.models.subscription import Subscription
+from app.models.user import User
+from app.config import settings
+
+logger = logging.getLogger(__name__)
+
+PLANS = {
+ "free": {"price": 0, "duration_days": None},
+ "pro": {"price": 99, "duration_days": 30},
+ "enterprise": {"price": 399, "duration_days": 30},
+}
+
+
+class PaymentService:
+ def __init__(self, db: AsyncSession):
+ self.db = db
+
+ async def get_plans(self) -> Dict[str, Any]:
+ return {
+ "plans": [
+ {
+ "id": "free",
+ "name": "免费版",
+ "price": 0,
+ "features": [
+ "1 个产品",
+ "20 次翻译/天",
+ "5 个客户",
+ "基础回复建议",
+ ],
+ },
+ {
+ "id": "pro",
+ "name": "Pro 版",
+ "price": 99,
+ "features": [
+ "10 个产品",
+ "无限翻译",
+ "50 个客户",
+ "跟进提醒",
+ "报价单生成",
+ ],
+ },
+ {
+ "id": "enterprise",
+ "name": "企业版",
+ "price": 399,
+ "features": [
+ "无限产品",
+ "多人协作",
+ "品牌报价单",
+ "专属语料训练",
+ "API 接入",
+ ],
+ },
+ ]
+ }
+
+ async def get_current_subscription(self, user_id: str) -> Dict[str, Any]:
+ result = await self.db.execute(
+ select(Subscription).where(
+ Subscription.user_id == user_id,
+ Subscription.status == "active",
+ ).order_by(Subscription.created_at.desc()).limit(1)
+ )
+ sub = result.scalar_one_or_none()
+
+ result = await self.db.execute(
+ select(User).where(User.id == user_id)
+ )
+ user = result.scalar_one_or_none()
+
+ return {
+ "plan": user.tier if user else "free",
+ "status": sub.status if sub else "active",
+ "expires_at": sub.expires_at.isoformat() if sub and sub.expires_at else None,
+ "auto_renew": sub.auto_renew if sub else False,
+ }
+
+ async def create_order(self, user_id: str, plan: str) -> Dict[str, Any]:
+ if plan not in PLANS:
+ raise ValueError(f"Invalid plan: {plan}")
+
+ plan_info = PLANS[plan]
+ if plan_info["price"] == 0:
+ result = await self.db.execute(select(User).where(User.id == user_id))
+ user = result.scalar_one_or_none()
+ if user:
+ user.tier = plan
+ await self.db.flush()
+ return {"status": "ok", "plan": plan, "amount": 0}
+
+ from app.config import settings
+ order_id = f"ORD{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{user_id[-6:]}"
+
+ sub = Subscription(
+ user_id=user_id,
+ plan=plan,
+ status="pending",
+ amount=plan_info["price"],
+ payment_id=order_id,
+ )
+ self.db.add(sub)
+ await self.db.flush()
+
+ pay_params = {
+ "appId": settings.WECHAT_APP_ID or "",
+ "timeStamp": str(int(datetime.utcnow().timestamp())),
+ "nonceStr": hashlib.md5(order_id.encode()).hexdigest()[:16],
+ "package": f"prepay_id={order_id}",
+ "signType": "MD5",
+ }
+ sign_str = "&".join(f"{k}={v}" for k, v in sorted(pay_params.items()))
+ sign_str += f"&key={settings.SECRET_KEY}"
+ pay_params["paySign"] = hashlib.md5(sign_str.encode()).hexdigest().upper()
+
+ return {
+ "status": "pending",
+ "order_id": order_id,
+ "plan": plan,
+ "amount": plan_info["price"],
+ "currency": "CNY",
+ "pay_params": pay_params,
+ }
+
+ async def handle_payment_callback(self, payment_id: str, success: bool) -> bool:
+ result = await self.db.execute(
+ select(Subscription).where(Subscription.payment_id == payment_id)
+ )
+ sub = result.scalar_one_or_none()
+ if not sub:
+ return False
+
+ if success:
+ sub.status = "active"
+ sub.started_at = datetime.utcnow()
+ sub.expires_at = datetime.utcnow() + timedelta(days=PLANS[sub.plan]["duration_days"])
+
+ user_result = await self.db.execute(select(User).where(User.id == sub.user_id))
+ user = user_result.scalar_one_or_none()
+ if user:
+ user.tier = sub.plan
+ else:
+ sub.status = "failed"
+
+ await self.db.flush()
+ return True
+
+
+payment_service = PaymentService
\ No newline at end of file
diff --git a/backend/app/services/pdf_generator.py b/backend/app/services/pdf_generator.py
new file mode 100644
index 0000000..210af7e
--- /dev/null
+++ b/backend/app/services/pdf_generator.py
@@ -0,0 +1,229 @@
+from typing import Optional, Dict, Any, List
+from datetime import datetime
+import os
+import logging
+
+logger = logging.getLogger(__name__)
+
+try:
+ from weasyprint import HTML
+ HAS_WEASYPRINT = True
+except ImportError:
+ HAS_WEASYPRINT = False
+ logger.warning("weasyprint not installed, PDF generation disabled")
+
+
+QUOTATION_TEMPLATE = """
+
+
+
+
+
+
+
+
+
+
+
+
Bill To
+
{customer_name}
+
{customer_company}
+
{customer_country}
+
+
+
Quote Details
+
Date: {date}
+
Valid Until: {valid_until}
+
Currency: {currency}
+
+
+
+
+
+
+ | Item |
+ Description |
+ Qty |
+ Unit |
+ Unit Price |
+ Total |
+
+
+
+ {items_rows}
+
+
+
+
+ | Subtotal: | {subtotal} |
+ | Discount: | -{discount} |
+ | Shipping: | {shipping} |
+ | TOTAL: | {total} |
+
+
+
+
Terms & Conditions
+
Payment Terms: {payment_terms}
+
Delivery Terms: {delivery_terms}
+
Lead Time: {lead_time}
+ {notes_html}
+
+
+
+
+
+"""
+
+
+class PDFGenerator:
+ @staticmethod
+ def generate_quotation(data: Dict[str, Any]) -> Optional[bytes]:
+ if not HAS_WEASYPRINT:
+ return None
+
+ items = data.get("items", [])
+ items_rows = ""
+ for i, item in enumerate(items, 1):
+ items_rows += (
+ f""
+ f"| {item.get('product_name', '')} | "
+ f"{item.get('description', '') or ''} | "
+ f"{item.get('quantity', 0)} | "
+ f"{item.get('unit', 'pcs')} | "
+ f"{item.get('unit_price', 0):.2f} | "
+ f"{item.get('total_price', 0):.2f} | "
+ f"
"
+ )
+
+ cur = data.get("currency", "USD")
+ subtotal = f"{cur} {data.get('subtotal', 0):.2f}"
+ discount = f"{cur} {data.get('discount', 0):.2f}" if data.get("discount") else f"{cur} 0.00"
+ shipping = f"{cur} {data.get('shipping', 0):.2f}" if data.get("shipping") else f"{cur} 0.00"
+ total = f"{cur} {data.get('total', 0):.2f}"
+
+ notes_html = ""
+ if data.get("notes"):
+ notes_html = f"Notes: {data['notes']}
"
+
+ html = QUOTATION_TEMPLATE.format(
+ quotation_number=data.get("quotation_number", "N/A"),
+ customer_name=data.get("customer_name", ""),
+ customer_company=data.get("customer_company", "") or "",
+ customer_country=data.get("customer_country", "") or "",
+ date=data.get("date", datetime.utcnow().strftime("%Y-%m-%d")),
+ valid_until=data.get("valid_until", "N/A"),
+ currency=cur,
+ items_rows=items_rows,
+ subtotal=subtotal,
+ discount=discount,
+ shipping=shipping,
+ total=total,
+ payment_terms=data.get("payment_terms", "N/A"),
+ delivery_terms=data.get("delivery_terms", "N/A"),
+ lead_time=data.get("lead_time", "N/A"),
+ notes_html=notes_html,
+ generated_at=datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC"),
+ )
+
+ pdf = HTML(string=html).write_pdf()
+ return pdf
+
+
+pdf_generator = PDFGenerator()
diff --git a/backend/app/services/preference.py b/backend/app/services/preference.py
new file mode 100644
index 0000000..07bc7b0
--- /dev/null
+++ b/backend/app/services/preference.py
@@ -0,0 +1,217 @@
+from typing import Dict, Any, Optional, List
+from datetime import datetime
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select, func, and_, desc
+from app.models.customer import Message, Conversation
+from app.models.user import User
+from app.models.preference import PreferenceAnalysis
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class UserPreferenceService:
+ def __init__(self, db: AsyncSession):
+ self.db = db
+
+ async def record_selection(self, user_id: str, message_id: str, selected_index: int) -> bool:
+ result = await self.db.execute(
+ select(Message).where(Message.id == message_id)
+ )
+ msg = result.scalar_one_or_none()
+ if not msg:
+ return False
+ msg.selected_suggestion = selected_index
+ await self.db.flush()
+ return True
+
+ async def record_edit(self, user_id: str, message_id: str, edited_text: str) -> bool:
+ result = await self.db.execute(
+ select(Message).where(Message.id == message_id)
+ )
+ msg = result.scalar_one_or_none()
+ if not msg:
+ return False
+ msg.user_edited = edited_text
+ await self.db.flush()
+ return True
+
+ async def analyze_preferences(self, user_id: str) -> Dict[str, Any]:
+ user_conv_subq = select(Conversation.id).where(
+ Conversation.user_id == user_id
+ ).subquery()
+
+ count_result = await self.db.execute(
+ select(func.count(Message.id)).where(
+ and_(
+ Message.conversation_id.in_(select(user_conv_subq)),
+ Message.selected_suggestion.isnot(None),
+ )
+ )
+ )
+ total = count_result.scalar() or 0
+
+ if total < 3:
+ return {"needs_more_data": True, "interaction_count": total}
+
+ result = await self.db.execute(
+ select(Message)
+ .where(
+ and_(
+ Message.conversation_id.in_(select(user_conv_subq)),
+ Message.selected_suggestion.isnot(None),
+ )
+ )
+ .order_by(desc(Message.created_at))
+ .limit(100)
+ )
+ messages = result.scalars().all()
+
+ tone_counts = {}
+ edit_count = 0
+ total_chars_saved = 0
+ greeting_patterns = []
+ signoff_patterns = []
+
+ for m in messages:
+ suggestions = m.ai_suggestions or []
+ selected = m.selected_suggestion
+ if suggestions and selected is not None and selected < len(suggestions):
+ tone = suggestions[selected].get("tone", "unknown")
+ tone_counts[tone] = tone_counts.get(tone, 0) + 1
+
+ if m.user_edited:
+ edit_count += 1
+ if suggestions and selected is not None and selected < len(suggestions):
+ original = suggestions[selected].get("reply", "")
+ total_chars_saved += abs(len(original) - len(m.user_edited))
+
+ preferred_tone = max(tone_counts, key=tone_counts.get) if tone_counts else "professional"
+ edit_ratio = edit_count / len(messages) if messages else 0
+ avg_edit_size = total_chars_saved / edit_count if edit_count > 0 else 0
+
+ greeting_style = self._extract_greeting_style(messages)
+ sign_off_style = self._extract_sign_off_style(messages)
+
+ preferences = {
+ "preferred_tone": preferred_tone,
+ "edit_ratio": edit_ratio,
+ "avg_edit_size": avg_edit_size,
+ "greeting_style": greeting_style,
+ "sign_off_style": sign_off_style,
+ "tone_distribution": tone_counts,
+ "interaction_count": len(messages),
+ "confidence": min(1.0, len(messages) / 20),
+ }
+
+ existing = await self.db.execute(
+ select(PreferenceAnalysis).where(PreferenceAnalysis.user_id == user_id)
+ )
+ analysis = existing.scalar_one_or_none()
+
+ if analysis:
+ analysis.preferred_tone = preferred_tone
+ analysis.greeting_style = greeting_style
+ analysis.sign_off_style = sign_off_style
+ analysis.analysis_data = preferences
+ analysis.interaction_count = len(messages)
+ analysis.confidence = preferences["confidence"]
+ analysis.last_analysis_at = datetime.utcnow()
+ else:
+ analysis = PreferenceAnalysis(
+ user_id=user_id,
+ task_type="reply",
+ preferred_tone=preferred_tone,
+ greeting_style=greeting_style,
+ sign_off_style=sign_off_style,
+ analysis_data=preferences,
+ interaction_count=len(messages),
+ confidence=preferences["confidence"],
+ last_analysis_at=datetime.utcnow(),
+ )
+ self.db.add(analysis)
+
+ await self.db.flush()
+
+ await self._update_user_settings(user_id, preferences)
+ return preferences
+
+ async def get_preference_context(self, user_id: str, task_type: str = "reply") -> Optional[str]:
+ result = await self.db.execute(
+ select(PreferenceAnalysis).where(
+ and_(
+ PreferenceAnalysis.user_id == user_id,
+ PreferenceAnalysis.task_type == task_type,
+ )
+ )
+ )
+ analysis = result.scalar_one_or_none()
+ if not analysis or analysis.confidence < 0.3:
+ return None
+
+ parts = []
+ if analysis.preferred_tone:
+ parts.append(f"user's preferred tone: {analysis.preferred_tone}")
+ if analysis.greeting_style:
+ parts.append(f"user's typical greeting: {analysis.greeting_style}")
+ if analysis.sign_off_style:
+ parts.append(f"user's typical sign-off: {analysis.sign_off_style}")
+
+ if parts:
+ return "This user prefers: " + "; ".join(parts) + "."
+ return None
+
+ async def get_analysis(self, user_id: str) -> Dict[str, Any]:
+ result = await self.db.execute(
+ select(PreferenceAnalysis).where(PreferenceAnalysis.user_id == user_id)
+ )
+ analysis = result.scalar_one_or_none()
+ if not analysis:
+ return {"analyzed": False, "interaction_count": 0, "confidence": 0}
+
+ return {
+ "analyzed": True,
+ "preferred_tone": analysis.preferred_tone,
+ "greeting_style": analysis.greeting_style,
+ "sign_off_style": analysis.sign_off_style,
+ "interaction_count": analysis.interaction_count,
+ "confidence": analysis.confidence,
+ "last_analysis_at": analysis.last_analysis_at.isoformat() if analysis.last_analysis_at else None,
+ }
+
+ async def _update_user_settings(self, user_id: str, preferences: Dict[str, Any]):
+ result = await self.db.execute(select(User).where(User.id == user_id))
+ user = result.scalar_one_or_none()
+ if user:
+ settings = dict(user.settings or {})
+ settings["preferred_tone"] = preferences.get("preferred_tone", settings.get("reply_tone", "professional"))
+ settings["ai_learning"] = {
+ "analyzed": True,
+ "confidence": preferences.get("confidence", 0),
+ "edit_ratio": preferences.get("edit_ratio", 0),
+ "greeting_style": preferences.get("greeting_style", ""),
+ "sign_off_style": preferences.get("sign_off_style", ""),
+ }
+ user.settings = settings
+ await self.db.flush()
+
+ def _extract_greeting_style(self, messages: List[Message]) -> str:
+ for m in messages:
+ text = m.user_edited or (m.ai_suggestions[m.selected_suggestion].get("reply", "") if m.selected_suggestion is not None and m.ai_suggestions and m.selected_suggestion < len(m.ai_suggestions) else "")
+ if text:
+ first_word = text.strip().split()[0] if text.strip() else ""
+ if first_word in ["Dear", "Hi", "Hello", "Hey", "To"]:
+ return first_word
+ return ""
+
+ def _extract_sign_off_style(self, messages: List[Message]) -> str:
+ for m in messages:
+ text = m.user_edited or (m.ai_suggestions[m.selected_suggestion].get("reply", "") if m.selected_suggestion is not None and m.ai_suggestions and m.selected_suggestion < len(m.ai_suggestions) else "")
+ if text:
+ words = text.strip().split()
+ if len(words) >= 3:
+ last_three = " ".join(words[-3:])
+ for signoff in ["Best regards", "Best wishes", "Sincerely", "Cheers", "Regards", "Yours"]:
+ if signoff in last_three:
+ return signoff
+ return ""
diff --git a/backend/app/services/push.py b/backend/app/services/push.py
new file mode 100644
index 0000000..454867b
--- /dev/null
+++ b/backend/app/services/push.py
@@ -0,0 +1,156 @@
+from typing import Optional, Dict, Any, List
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select, and_
+from app.config import settings
+from app.models.device import Device
+import httpx
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class PushService:
+ def __init__(self, db: Optional[AsyncSession] = None):
+ self.db = db
+
+ @staticmethod
+ def send_notification(user_id: str, title: str, content: str, payload: Optional[Dict[str, Any]] = None) -> bool:
+ logger.info(f"[PUSH] user={user_id} title={title} content={content}")
+ try:
+ import asyncio
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+ result = loop.run_until_complete(
+ PushService._send_via_wechat(user_id, title, content, payload)
+ )
+ loop.close()
+ return result
+ except Exception as e:
+ logger.warning(f"Push send failed (logged only): {e}")
+ return True
+
+ @staticmethod
+ def send_bulk(user_ids: List[str], title: str, content: str, payload: Optional[Dict[str, Any]] = None) -> int:
+ sent = 0
+ for uid in user_ids:
+ if PushService.send_notification(uid, title, content, payload):
+ sent += 1
+ return sent
+
+ async def send_async(self, user_id: str, title: str, content: str, payload: Optional[Dict[str, Any]] = None) -> bool:
+ logger.info(f"[PUSH_ASYNC] user={user_id} title={title}")
+
+ result = await self._send_via_wechat(user_id, title, content, payload)
+
+ await self._save_in_app_notification(user_id, title, content, payload)
+
+ return result
+
+ async def register_device(self, user_id: str, client_id: str, platform: str = "weapp", push_token: Optional[str] = None, device_info: Optional[Dict] = None) -> Device:
+ result = await self.db.execute(
+ select(Device).where(
+ and_(Device.user_id == user_id, Device.client_id == client_id)
+ )
+ )
+ existing = result.scalar_one_or_none()
+ if existing:
+ existing.platform = platform
+ existing.push_token = push_token
+ existing.device_info = device_info or {}
+ existing.is_active = True
+ await self.db.flush()
+ return existing
+
+ device = Device(
+ user_id=user_id,
+ platform=platform,
+ push_token=push_token,
+ client_id=client_id,
+ device_info=device_info or {},
+ )
+ self.db.add(device)
+ await self.db.flush()
+ return device
+
+ async def get_user_devices(self, user_id: str) -> List[Dict]:
+ result = await self.db.execute(
+ select(Device).where(
+ and_(Device.user_id == user_id, Device.is_active == True)
+ )
+ )
+ devices = result.scalars().all()
+ return [
+ {
+ "id": str(d.id),
+ "platform": d.platform,
+ "client_id": d.client_id,
+ "device_info": d.device_info,
+ "created_at": d.created_at.isoformat() if d.created_at else None,
+ }
+ for d in devices
+ ]
+
+ async def unregister_device(self, user_id: str, client_id: str) -> bool:
+ result = await self.db.execute(
+ select(Device).where(
+ and_(Device.user_id == user_id, Device.client_id == client_id)
+ )
+ )
+ device = result.scalar_one_or_none()
+ if not device:
+ return False
+ device.is_active = False
+ await self.db.flush()
+ return True
+
+ @staticmethod
+ async def _send_via_wechat(user_id: str, title: str, content: str, payload: Optional[Dict] = None) -> bool:
+ if not settings.WECHAT_APP_ID or not settings.WECHAT_APP_SECRET:
+ logger.debug("WeChat push not configured, falling back to log")
+ return True
+
+ try:
+ from app.services.wechat import wechat_service
+ access_token = await wechat_service._get_access_token()
+ if not access_token:
+ logger.warning("Cannot get WeChat access token for push")
+ return False
+
+ async with httpx.AsyncClient() as client:
+ resp = await client.post(
+ "https://api.weixin.qq.com/cgi-bin/message/subscribe/send",
+ params={"access_token": access_token},
+ json={
+ "touser": user_id,
+ "template_id": settings.WECHAT_PUSH_TEMPLATE_ID or "",
+ "data": {
+ "thing1": {"value": title[:20]},
+ "thing2": {"value": content[:20]},
+ },
+ "miniprogram_state": "formal",
+ },
+ timeout=10,
+ )
+ data = resp.json()
+ if data.get("errcode", 0) != 0:
+ logger.warning(f"WeChat push failed: {data}")
+ return False
+ logger.info(f"WeChat push sent to user {user_id}")
+ return True
+ except Exception as e:
+ logger.warning(f"WeChat push error: {e}")
+ return False
+
+ async def _save_in_app_notification(self, user_id: str, title: str, content: str, payload: Optional[Dict] = None):
+ if not self.db:
+ return
+ try:
+ from app.services.notification import NotificationService
+ await NotificationService.create_notification(
+ self.db, user_id, title, content,
+ notification_type="push",
+ reference_type=(payload or {}).get("reference_type"),
+ reference_id=(payload or {}).get("reference_id"),
+ )
+ except Exception as e:
+ logger.warning(f"Failed to save in-app notification: {e}")
diff --git a/backend/app/services/quotation.py b/backend/app/services/quotation.py
index f8c60e9..5b77c65 100644
--- a/backend/app/services/quotation.py
+++ b/backend/app/services/quotation.py
@@ -1,11 +1,13 @@
from typing import Dict, Any, Optional, List
from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy import select, and_
+from sqlalchemy import select, and_, or_
from app.models.quotation import Quotation, QuotationItem
from app.models.customer import Customer
from app.models.user import Product
+from app.ai.router import get_ai_router
from datetime import datetime
import logging
+import json
logger = logging.getLogger(__name__)
@@ -90,6 +92,135 @@ class QuotationService:
await self.db.flush()
return await self._to_dict(q)
+ async def generate_from_inquiry(
+ self, user_id: str, inquiry_text: str, customer_id: Optional[str] = None,
+ ) -> Dict[str, Any]:
+ ai = get_ai_router()
+
+ schema = {
+ "type": "object",
+ "properties": {
+ "product_requests": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "product_name": {"type": "string"},
+ "quantity": {"type": "integer"},
+ "unit": {"type": "string"},
+ "specifications": {"type": "string"},
+ "target_price": {"type": "string"},
+ },
+ },
+ },
+ "payment_terms": {"type": "string"},
+ "delivery_terms": {"type": "string"},
+ "urgency": {"type": "string"},
+ },
+ }
+
+ extract_result = await ai.extract(inquiry_text, schema)
+ extracted = extract_result.get("data", {})
+ product_requests = extracted.get("product_requests", [])
+
+ if not product_requests:
+ schema_simple = {
+ "type": "object",
+ "properties": {
+ "product_name": {"type": "string"},
+ "quantity": {"type": "integer"},
+ "specifications": {"type": "string"},
+ },
+ }
+ extract_result = await ai.extract(inquiry_text, schema_simple)
+ extracted = extract_result.get("data", {})
+ if extracted.get("product_name"):
+ product_requests = [{
+ "product_name": extracted["product_name"],
+ "quantity": extracted.get("quantity", 1),
+ "unit": "pcs",
+ "specifications": extracted.get("specifications", ""),
+ }]
+
+ product_result = await self.db.execute(
+ select(Product).where(
+ and_(
+ Product.user_id == user_id,
+ Product.is_active == True,
+ )
+ )
+ )
+ user_products = product_result.scalars().all()
+
+ matched_products = []
+ for req in product_requests:
+ req_name = req.get("product_name", "").lower()
+ best_match = None
+ best_score = 0
+
+ for p in user_products:
+ score = 0
+ p_name = (p.name or "").lower()
+ p_name_en = (p.name_en or "").lower()
+
+ if req_name in p_name or p_name in req_name:
+ score += 3
+ if req_name in p_name_en or p_name_en in req_name:
+ score += 2
+
+ keywords = p.keywords or []
+ for kw in keywords:
+ if isinstance(kw, str) and kw.lower() in req_name:
+ score += 1
+
+ if score > best_score:
+ best_score = score
+ best_match = p
+
+ if best_match and best_score > 0:
+ unit_price = float(best_match.price) if best_match.price else 0
+ quantity = req.get("quantity", 1)
+ matched_products.append({
+ "product_id": str(best_match.id),
+ "product_name": best_match.name,
+ "description": best_match.description_en or best_match.description,
+ "quantity": quantity,
+ "unit_price": unit_price,
+ "total_price": unit_price * quantity,
+ "unit": req.get("unit", "pcs"),
+ "match_score": best_score,
+ })
+ else:
+ matched_products.append({
+ "product_id": None,
+ "product_name": req.get("product_name", "Unknown"),
+ "description": req.get("specifications", ""),
+ "quantity": req.get("quantity", 1),
+ "unit_price": 0,
+ "total_price": 0,
+ "unit": req.get("unit", "pcs"),
+ "match_score": 0,
+ })
+
+ subtotal = sum(p["total_price"] for p in matched_products)
+ total = subtotal
+
+ suggested_quotation = {
+ "title": f"Quotation - {', '.join(p['product_name'] for p in matched_products[:3])}",
+ "items": matched_products,
+ "subtotal": subtotal,
+ "total": total,
+ "payment_terms": extracted.get("payment_terms", "T/T"),
+ "delivery_terms": extracted.get("delivery_terms", "FOB"),
+ "lead_time": "15-20 days" if extracted.get("urgency") != "urgent" else "7-10 days",
+ "notes": f"Generated from customer inquiry: {inquiry_text[:100]}..." if len(inquiry_text) > 100 else f"Generated from customer inquiry: {inquiry_text}",
+ "extracted_data": extracted,
+ "matched_count": len([p for p in matched_products if p["product_id"]]),
+ "unmatched_count": len([p for p in matched_products if not p["product_id"]]),
+ }
+
+ return suggested_quotation
+
async def generate_quotation_text(self, q: Quotation) -> str:
items_result = await self.db.execute(
select(QuotationItem).where(QuotationItem.quotation_id == q.id)
diff --git a/backend/app/services/silent_pattern.py b/backend/app/services/silent_pattern.py
new file mode 100644
index 0000000..0cf0f44
--- /dev/null
+++ b/backend/app/services/silent_pattern.py
@@ -0,0 +1,168 @@
+from typing import Dict, Any, Optional, List
+from datetime import datetime, timedelta
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select, func, and_
+from app.models.customer import Customer, Conversation, Message
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class SilentPatternService:
+ def __init__(self, db: AsyncSession):
+ self.db = db
+
+ async def analyze_silent_risk(self, user_id: str) -> List[Dict[str, Any]]:
+ cutoff_3d = datetime.utcnow() - timedelta(days=3)
+ cutoff_7d = datetime.utcnow() - timedelta(days=7)
+
+ result = await self.db.execute(
+ select(Customer).where(
+ and_(
+ Customer.user_id == user_id,
+ Customer.status.in_(["lead", "negotiating"]),
+ )
+ )
+ )
+ customers = result.scalars().all()
+
+ risk_scores = []
+ for c in customers:
+ score, reasons = await self._calculate_risk_score(c, cutoff_3d, cutoff_7d)
+ if score > 0:
+ risk_scores.append({
+ "customer_id": str(c.id),
+ "name": c.name,
+ "company": c.company,
+ "country": c.country,
+ "status": c.status,
+ "estimated_value": c.estimated_value,
+ "last_contact_at": c.last_contact_at.isoformat() if c.last_contact_at else None,
+ "silence_days": (datetime.utcnow() - c.last_contact_at).days if c.last_contact_at else 0,
+ "risk_score": score,
+ "risk_level": self._risk_level(score),
+ "reasons": reasons,
+ })
+
+ risk_scores.sort(key=lambda x: x["risk_score"], reverse=True)
+ return risk_scores
+
+ async def _calculate_risk_score(
+ self, customer: Customer, cutoff_3d: datetime, cutoff_7d: datetime
+ ) -> tuple:
+ score = 0
+ reasons = []
+
+ if not customer.last_contact_at:
+ return (0, [])
+
+ silence_days = (datetime.utcnow() - customer.last_contact_at).days
+
+ if silence_days >= 7:
+ score += 40
+ reasons.append(f"沉默超过7天")
+ elif silence_days >= 3:
+ score += 20
+ reasons.append(f"沉默超过3天")
+
+ conv_query = await self.db.execute(
+ select(Conversation).where(
+ and_(
+ Conversation.customer_id == customer.id,
+ Conversation.user_id == customer.user_id,
+ )
+ ).order_by(Conversation.created_at.desc()).limit(1)
+ )
+ conv = conv_query.scalar_one_or_none()
+ if not conv:
+ return (score, reasons)
+
+ msg_count_query = await self.db.execute(
+ select(func.count(Message.id)).where(
+ and_(
+ Message.conversation_id == conv.id,
+ Message.direction == "inbound",
+ )
+ )
+ )
+ inbound_count = msg_count_query.scalar() or 0
+
+ if inbound_count >= 5 and silence_days >= 3:
+ score += 20
+ reasons.append(f"前期沟通频繁({inbound_count}条)后突然沉默")
+
+ if customer.status == "lead" and silence_days >= 3:
+ score += 15
+ reasons.append("潜在客户阶段需及时跟进")
+
+ if customer.status == "negotiating" and silence_days >= 2:
+ score += 25
+ reasons.append("谈判阶段客户需保持热度")
+
+ recent_query = await self.db.execute(
+ select(Message).where(
+ and_(
+ Message.conversation_id == conv.id,
+ Message.created_at >= cutoff_7d,
+ )
+ ).order_by(Message.created_at.desc()).limit(3)
+ )
+ recent_msgs = recent_query.scalars().all()
+
+ if recent_msgs:
+ last_inbound = None
+ for m in recent_msgs:
+ if m.direction == "inbound":
+ last_inbound = m
+ break
+ if last_inbound and silence_days >= 1:
+ content_lower = last_inbound.content.lower()
+ closing_signals = ["i'll think", "let me check", "too expensive", "high price", "not now", "maybe later", "considering"]
+ for signal in closing_signals:
+ if signal in content_lower:
+ score += 15
+ reasons.append(f"客户回复含消极信号: \"{signal}\"")
+ break
+
+ return (min(score, 100), reasons)
+
+ def _risk_level(self, score: int) -> str:
+ if score >= 70:
+ return "high"
+ elif score >= 40:
+ return "medium"
+ elif score >= 20:
+ return "low"
+ return "minimal"
+
+ async def get_suggestions(self, user_id: str, customer_id: str) -> List[str]:
+ score_result = await self.analyze_silent_risk(user_id)
+ customer_scores = [s for s in score_result if s["customer_id"] == customer_id]
+ if not customer_scores:
+ return []
+
+ score = customer_scores[0]
+ suggestions = []
+ silence_days = score["silence_days"]
+
+ if silence_days >= 7:
+ suggestions.extend([
+ f"客户{score['name']}已沉默{silence_days}天,建议发送产品更新或行业资讯重新激活",
+ "考虑提供限时优惠或样品折扣打动客户",
+ ])
+ elif silence_days >= 3:
+ suggestions.extend([
+ f"客户{score['name']}沉默{silence_days}天,建议发送跟进消息询问是否有进一步需求",
+ "可分享相关案例或成功故事保持客户兴趣",
+ ])
+
+ if "negotiating" in score.get("status", ""):
+ suggestions.append("谈判阶段客户,建议主动提供更多产品细节或定制方案")
+
+ if "消极信号" in str(score.get("reasons", [])):
+ suggestions.append("客户曾表达价格顾虑,建议重新审视报价或提供增值服务")
+
+ if not suggestions:
+ suggestions.append("客户状态良好,建议保持定期跟进节奏")
+
+ return suggestions
diff --git a/backend/app/services/team.py b/backend/app/services/team.py
new file mode 100644
index 0000000..a3ac3e6
--- /dev/null
+++ b/backend/app/services/team.py
@@ -0,0 +1,201 @@
+from typing import Dict, Any, Optional, List
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select, func, and_, or_
+from app.models.team import Team, TeamMember
+from app.models.user import User
+from datetime import datetime
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class TeamService:
+ def __init__(self, db: AsyncSession):
+ self.db = db
+
+ async def create_team(self, owner_id: str, name: str, description: str = None) -> Dict[str, Any]:
+ existing = await self.db.execute(
+ select(Team).where(and_(Team.owner_id == owner_id, Team.is_active == True))
+ )
+ if existing.scalar_one_or_none():
+ raise ValueError("You already own an active team")
+
+ user_result = await self.db.execute(select(User).where(User.id == owner_id))
+ user = user_result.scalar_one_or_none()
+ if not user:
+ raise ValueError("User not found")
+
+ max_members = 20 if user.tier == "enterprise" else (10 if user.tier == "pro" else 5)
+
+ team = Team(
+ name=name,
+ owner_id=owner_id,
+ description=description,
+ max_members=max_members,
+ tier=user.tier,
+ )
+ self.db.add(team)
+ await self.db.flush()
+
+ member = TeamMember(
+ team_id=team.id,
+ user_id=owner_id,
+ role="owner",
+ status="active",
+ )
+ self.db.add(member)
+ await self.db.flush()
+
+ return await self._to_dict(team, include_members=True)
+
+ async def get_team(self, team_id: str, user_id: str) -> Optional[Dict[str, Any]]:
+ result = await self.db.execute(
+ select(Team).where(Team.id == team_id)
+ )
+ team = result.scalar_one_or_none()
+ if not team:
+ return None
+
+ is_member = await self.db.execute(
+ select(TeamMember).where(
+ and_(TeamMember.team_id == team_id, TeamMember.user_id == user_id)
+ )
+ )
+ if not is_member.scalar_one_or_none():
+ return None
+
+ return await self._to_dict(team, include_members=True)
+
+ async def list_user_teams(self, user_id: str) -> List[Dict[str, Any]]:
+ member_result = await self.db.execute(
+ select(TeamMember.team_id).where(TeamMember.user_id == user_id)
+ )
+ team_ids = [r[0] for r in member_result.all()]
+
+ if not team_ids:
+ return []
+
+ result = await self.db.execute(
+ select(Team).where(Team.id.in_(team_ids), Team.is_active == True)
+ )
+ teams = result.scalars().all()
+
+ return [await self._to_dict(t) for t in teams]
+
+ async def invite_member(self, team_id: str, owner_id: str, user_id: str) -> Dict[str, Any]:
+ team_result = await self.db.execute(
+ select(Team).where(and_(Team.id == team_id, Team.owner_id == owner_id))
+ )
+ team = team_result.scalar_one_or_none()
+ if not team:
+ raise ValueError("Team not found or not authorized")
+
+ member_count = await self.db.execute(
+ select(func.count(TeamMember.id)).where(
+ and_(TeamMember.team_id == team_id, TeamMember.status == "active")
+ )
+ )
+ if (member_count.scalar() or 0) >= team.max_members:
+ raise ValueError("Team member limit reached")
+
+ existing = await self.db.execute(
+ select(TeamMember).where(
+ and_(TeamMember.team_id == team_id, TeamMember.user_id == user_id)
+ )
+ )
+ if existing.scalar_one_or_none():
+ raise ValueError("User is already a member")
+
+ member = TeamMember(
+ team_id=team_id,
+ user_id=user_id,
+ role="member",
+ invited_by=owner_id,
+ status="active",
+ )
+ self.db.add(member)
+ await self.db.flush()
+
+ return {"user_id": user_id, "role": "member", "status": "active"}
+
+ async def remove_member(self, team_id: str, owner_id: str, user_id: str) -> bool:
+ team_result = await self.db.execute(
+ select(Team).where(and_(Team.id == team_id, Team.owner_id == owner_id))
+ )
+ team = team_result.scalar_one_or_none()
+ if not team:
+ return False
+
+ result = await self.db.execute(
+ select(TeamMember).where(
+ and_(TeamMember.team_id == team_id, TeamMember.user_id == user_id)
+ )
+ )
+ member = result.scalar_one_or_none()
+ if not member or member.role == "owner":
+ return False
+
+ await self.db.delete(member)
+ return True
+
+ async def leave_team(self, team_id: str, user_id: str) -> bool:
+ result = await self.db.execute(
+ select(TeamMember).where(
+ and_(TeamMember.team_id == team_id, TeamMember.user_id == user_id)
+ )
+ )
+ member = result.scalar_one_or_none()
+ if not member or member.role == "owner":
+ return False
+
+ await self.db.delete(member)
+ return True
+
+ async def update_role(self, team_id: str, owner_id: str, user_id: str, role: str) -> bool:
+ team_result = await self.db.execute(
+ select(Team).where(and_(Team.id == team_id, Team.owner_id == owner_id))
+ )
+ if not team_result.scalar_one_or_none():
+ return False
+
+ result = await self.db.execute(
+ select(TeamMember).where(
+ and_(TeamMember.team_id == team_id, TeamMember.user_id == user_id)
+ )
+ )
+ member = result.scalar_one_or_none()
+ if not member or member.role == "owner":
+ return False
+
+ member.role = role
+ await self.db.flush()
+ return True
+
+ async def _to_dict(self, team: Team, include_members: bool = False) -> Dict[str, Any]:
+ result = {
+ "id": str(team.id),
+ "name": team.name,
+ "owner_id": str(team.owner_id),
+ "description": team.description,
+ "tier": team.tier,
+ "is_active": team.is_active,
+ "created_at": team.created_at.isoformat() if team.created_at else None,
+ }
+
+ if include_members:
+ members_result = await self.db.execute(
+ select(TeamMember).where(TeamMember.team_id == team.id)
+ )
+ members = members_result.scalars().all()
+ result["members"] = [
+ {
+ "user_id": str(m.user_id),
+ "role": m.role,
+ "status": m.status,
+ "joined_at": m.joined_at.isoformat() if m.joined_at else None,
+ }
+ for m in members
+ ]
+ result["member_count"] = len([m for m in members if m.status == "active"])
+
+ return result
diff --git a/backend/app/services/translation.py b/backend/app/services/translation.py
index 621b0e4..339f678 100644
--- a/backend/app/services/translation.py
+++ b/backend/app/services/translation.py
@@ -47,6 +47,7 @@ class TranslationService:
async def generate_reply(
self, inquiry: str, context: Optional[Dict[str, Any]] = None,
tone: str = "professional", count: int = 3,
+ preference_context: Optional[str] = None,
) -> List[Dict[str, Any]]:
similar = await self.corpus.find_similar(inquiry, "reply")
if similar and count > 1:
@@ -57,7 +58,7 @@ class TranslationService:
for t in tones:
try:
- result = await self.ai.reply(inquiry, context, t)
+ result = await self.ai.reply(inquiry, context, t, preference_context)
results.append({
"reply": result.get("reply", ""),
"tone": t,
diff --git a/backend/app/services/tts.py b/backend/app/services/tts.py
new file mode 100644
index 0000000..2309075
--- /dev/null
+++ b/backend/app/services/tts.py
@@ -0,0 +1,50 @@
+from typing import Optional
+import logging
+
+logger = logging.getLogger(__name__)
+
+try:
+ import edge_tts
+ HAS_EDGE_TTS = True
+except ImportError:
+ HAS_EDGE_TTS = False
+ logger.warning("edge-tts not installed, TTS disabled")
+
+VOICE_MAP = {
+ "zh": "zh-CN-XiaoxiaoNeural",
+ "en": "en-US-AriaNeural",
+ "ja": "ja-JP-NanamiNeural",
+ "ko": "ko-KR-SunHiNeural",
+ "fr": "fr-FR-DeniseNeural",
+ "de": "de-DE-KatjaNeural",
+ "es": "es-ES-ElviraNeural",
+ "pt": "pt-BR-FranciscaNeural",
+ "ru": "ru-RU-SvetlanaNeural",
+ "ar": "ar-SA-ZariyahNeural",
+}
+
+SUPPORTED_LANGS = list(VOICE_MAP.keys())
+
+
+class TextToSpeechService:
+ @staticmethod
+ async def synthesize(text: str, lang: str = "en", rate: str = "0%", pitch: str = "0Hz") -> Optional[bytes]:
+ if not HAS_EDGE_TTS:
+ logger.warning("edge-tts not available")
+ return None
+
+ voice = VOICE_MAP.get(lang, VOICE_MAP["en"])
+
+ try:
+ communicate = edge_tts.Communicate(text, voice, rate=rate, pitch=pitch)
+ audio_data = b""
+ async for chunk in communicate.stream():
+ if chunk["type"] == "audio":
+ audio_data += chunk["data"]
+ return audio_data if audio_data else None
+ except Exception as e:
+ logger.error(f"TTS failed: {e}")
+ return None
+
+
+tts_service = TextToSpeechService()
diff --git a/backend/app/services/wechat.py b/backend/app/services/wechat.py
new file mode 100644
index 0000000..7fb2b6d
--- /dev/null
+++ b/backend/app/services/wechat.py
@@ -0,0 +1,80 @@
+from typing import Optional, Dict, Any
+import httpx
+import logging
+from app.config import settings
+
+logger = logging.getLogger(__name__)
+
+
+class WeChatService:
+ def __init__(self):
+ self.app_id = settings.WECHAT_APP_ID
+ self.app_secret = settings.WECHAT_APP_SECRET
+ self.api_base = "https://api.weixin.qq.com"
+
+ async def code2session(self, js_code: str) -> Optional[Dict[str, Any]]:
+ if not self.app_id or not self.app_secret:
+ logger.warning("WeChat not configured")
+ return None
+
+ async with httpx.AsyncClient() as client:
+ resp = await client.get(
+ f"{self.api_base}/sns/jscode2session",
+ params={
+ "appid": self.app_id,
+ "secret": self.app_secret,
+ "js_code": js_code,
+ "grant_type": "authorization_code",
+ },
+ timeout=10,
+ )
+ data = resp.json()
+
+ if data.get("errcode", 0) != 0:
+ logger.error(f"WeChat code2session failed: {data}")
+ return None
+
+ return {
+ "openid": data.get("openid"),
+ "session_key": data.get("session_key"),
+ "unionid": data.get("unionid"),
+ }
+
+ async def get_phone_number(self, code: str) -> Optional[str]:
+ if not self.app_id or not self.app_secret:
+ return None
+
+ access_token = await self._get_access_token()
+ if not access_token:
+ return None
+
+ async with httpx.AsyncClient() as client:
+ resp = await client.post(
+ f"{self.api_base}/wxa/business/getuserphonenumber",
+ params={"access_token": access_token},
+ json={"code": code},
+ timeout=10,
+ )
+ data = resp.json()
+ if data.get("errcode", 0) != 0:
+ logger.error(f"WeChat getPhoneNumber failed: {data}")
+ return None
+
+ return data.get("phone_info", {}).get("phoneNumber")
+
+ async def _get_access_token(self) -> Optional[str]:
+ async with httpx.AsyncClient() as client:
+ resp = await client.get(
+ f"{self.api_base}/cgi-bin/token",
+ params={
+ "grant_type": "client_credential",
+ "appid": self.app_id,
+ "secret": self.app_secret,
+ },
+ timeout=10,
+ )
+ data = resp.json()
+ return data.get("access_token")
+
+
+wechat_service = WeChatService()
diff --git a/backend/app/services/whatsapp.py b/backend/app/services/whatsapp.py
index d84302f..fc5b4f5 100644
--- a/backend/app/services/whatsapp.py
+++ b/backend/app/services/whatsapp.py
@@ -85,6 +85,48 @@ class WhatsAppService:
)
return resp.status_code == 200
+ async def send_media(self, to: str, media_url: str, media_type: str = "image", caption: Optional[str] = None) -> bool:
+ if not self.api_token or not self.phone_number_id:
+ return False
+
+ body = {
+ "messaging_product": "whatsapp",
+ "to": to,
+ "type": media_type,
+ media_type: {"link": media_url},
+ }
+ if caption:
+ body[media_type]["caption"] = caption
+
+ async with httpx.AsyncClient() as client:
+ resp = await client.post(
+ f"{self.api_base}/messages",
+ headers={"Authorization": f"Bearer {self.api_token}", "Content-Type": "application/json"},
+ json=body,
+ timeout=30,
+ )
+ if resp.status_code != 200:
+ logger.error(f"WhatsApp media send failed: {resp.text}")
+ return False
+ return True
+
+ async def mark_as_read(self, message_id: str) -> bool:
+ if not self.api_token:
+ return False
+
+ async with httpx.AsyncClient() as client:
+ resp = await client.post(
+ f"{self.api_base}/messages",
+ headers={"Authorization": f"Bearer {self.api_token}", "Content-Type": "application/json"},
+ json={
+ "messaging_product": "whatsapp",
+ "status": "read",
+ "message_id": message_id,
+ },
+ timeout=10,
+ )
+ return resp.status_code == 200
+
def parse_webhook(self, body: Dict) -> Optional[Dict]:
try:
entry = body.get("entry", [{}])[0]
@@ -96,14 +138,29 @@ class WhatsAppService:
return None
msg = messages[0]
+ msg_type = msg.get("type", "text")
+ content = ""
+
+ if msg_type == "text":
+ content = msg.get("text", {}).get("body", "")
+ elif msg_type in ("image", "document", "audio", "video"):
+ media = msg.get(msg_type, {})
+ content = media.get("caption", "") or media.get("filename", "") or f"[{msg_type}]"
+
return {
"from": msg.get("from"),
- "text": msg.get("text", {}).get("body", ""),
+ "text": content,
"msg_id": msg.get("id"),
"timestamp": msg.get("timestamp"),
- "type": msg.get("type", "text"),
+ "type": msg_type,
"profile_name": value.get("contacts", [{}])[0].get("profile", {}).get("name"),
}
except Exception as e:
logger.warning(f"Failed to parse WhatsApp webhook: {e}")
return None
+
+ def _build_headers(self) -> Dict[str, str]:
+ return {
+ "Authorization": f"Bearer {self.api_token}",
+ "Content-Type": "application/json",
+ }
diff --git a/backend/app/workers/tasks.py b/backend/app/workers/tasks.py
index fd6d4a2..4b7a14f 100644
--- a/backend/app/workers/tasks.py
+++ b/backend/app/workers/tasks.py
@@ -10,6 +10,9 @@ logger = logging.getLogger(__name__)
def check_silent_customers():
from app.database import AsyncSessionLocal
from app.models.customer import Customer
+ from app.models.user import User
+ from app.services.push import PushService
+ from app.services.notification import NotificationService
async def _check():
async with AsyncSessionLocal() as db:
@@ -27,12 +30,26 @@ def check_silent_customers():
)
customers = result.scalars().all()
for c in customers:
- if days == 3:
- logger.info(f"Customer {c.name} silent for 3 days")
- elif days == 7:
- logger.info(f"Customer {c.name} silent for 7 days - upgrade")
- else:
- logger.info(f"Customer {c.name} silent for 14 days - recommend new approach")
+ messages = {
+ 3: ("跟进提醒", f"客户 {c.name} 已沉默3天,建议发送跟进消息"),
+ 7: ("跟进升级", f"客户 {c.name} 已沉默1周,建议发送优惠或新产品信息"),
+ 14: ("跟进提示", f"客户 {c.name} 已沉默14天,建议换话题重新接触"),
+ }
+ title, content = messages.get(days, ("跟进提醒", f"客户 {c.name} 已沉默{days}天"))
+ logger.info(f"Customer {c.name} silent for {days} days")
+
+ user_result = await db.execute(
+ select(User).where(User.id == c.user_id)
+ )
+ user = user_result.scalar_one_or_none()
+ if user:
+ PushService.send_notification(c.user_id, title, content)
+ await NotificationService.create_notification(
+ db, c.user_id, title, content,
+ notification_type="customer_silent",
+ reference_type="customer",
+ reference_id=str(c.id),
+ )
import asyncio
asyncio.run(_check())
@@ -59,6 +76,8 @@ def batch_translate_texts(texts: list, target_lang: str, user_id: str):
def generate_quotation_pdf(quotation_id: str):
from app.database import AsyncSessionLocal
from app.models.quotation import Quotation, QuotationItem
+ from app.models.customer import Customer
+ from app.services.pdf_generator import pdf_generator
async def _generate():
async with AsyncSessionLocal() as db:
@@ -74,62 +93,60 @@ def generate_quotation_pdf(quotation_id: str):
)
items = items_result.scalars().all()
- pdf_content = generate_pdf_text(q, items)
+ customer = None
+ if q.customer_id:
+ cust_result = await db.execute(
+ select(Customer).where(Customer.id == q.customer_id)
+ )
+ customer = cust_result.scalar_one_or_none()
- return {"pdf_content": pdf_content, "quotation_id": str(q.id)}
+ data = {
+ "quotation_number": f"{str(q.id)[:8].upper()}",
+ "customer_name": customer.name if customer else "",
+ "customer_company": customer.company if customer else "",
+ "customer_country": customer.country if customer else "",
+ "date": q.created_at.strftime("%Y-%m-%d") if q.created_at else "",
+ "valid_until": q.valid_until or "",
+ "currency": q.currency or "USD",
+ "items": [
+ {
+ "product_name": i.product_name,
+ "description": i.description,
+ "quantity": i.quantity,
+ "unit_price": i.unit_price,
+ "total_price": i.total_price,
+ "unit": i.unit or "pcs",
+ }
+ for i in items
+ ],
+ "subtotal": q.subtotal or 0,
+ "discount": q.discount or 0,
+ "shipping": q.shipping or 0,
+ "total": q.total or q.subtotal or 0,
+ "payment_terms": q.payment_terms or "",
+ "delivery_terms": q.delivery_terms or "",
+ "lead_time": q.lead_time or "",
+ "notes": q.notes or "",
+ }
+
+ pdf_bytes = pdf_generator.generate_quotation(data)
+
+ if pdf_bytes:
+ upload_dir = settings.UPLOAD_DIR
+ pdf_path = os.path.join(upload_dir, f"quotation_{quotation_id}.pdf")
+ os.makedirs(upload_dir, exist_ok=True)
+ with open(pdf_path, "wb") as f:
+ f.write(pdf_bytes)
+ q.pdf_url = pdf_path
+ await db.flush()
+ return {"success": True, "pdf_path": pdf_path, "quotation_id": str(q.id)}
+ else:
+ return {"error": "PDF generation failed (weasyprint not available)", "quotation_id": str(q.id)}
import asyncio
return asyncio.run(_generate())
-def generate_pdf_text(quotation, items):
- from datetime import datetime
-
- lines = [
- "=" * 60,
- f"QUOTATION",
- f"#{str(quotation.id)[:8].upper()}",
- "=" * 60,
- f"Date: {datetime.utcnow().strftime('%Y-%m-%d')}",
- ]
-
- if quotation.valid_until:
- lines.append(f"Valid Until: {quotation.valid_until}")
-
- lines.append("")
- lines.append(f"{'Item':<30} {'Qty':<8} {'Unit Price':<12} {'Total':<12}")
- lines.append("-" * 62)
-
- for item in items:
- lines.append(
- f"{item.product_name:<30} {item.quantity:<8} ${item.unit_price:<10.2f} ${item.total_price:<10.2f}"
- )
-
- lines.append("-" * 62)
- if quotation.subtotal:
- lines.append(f"{'Subtotal':>48} ${quotation.subtotal:<10.2f}")
- if quotation.discount:
- lines.append(f"{'Discount':>48} -${quotation.discount:<10.2f}")
- if quotation.shipping:
- lines.append(f"{'Shipping':>48} ${quotation.shipping:<10.2f}")
- lines.append(f"{'TOTAL':>48} ${quotation.total or quotation.subtotal or 0:<10.2f}")
-
- lines.append("")
- if quotation.payment_terms:
- lines.append(f"Payment Terms: {quotation.payment_terms}")
- if quotation.delivery_terms:
- lines.append(f"Delivery Terms: {quotation.delivery_terms}")
- if quotation.lead_time:
- lines.append(f"Lead Time: {quotation.lead_time}")
- if quotation.notes:
- lines.append(f"Notes: {quotation.notes}")
-
- lines.append("=" * 60)
- lines.append("Generated by TradeMate")
-
- return "\n".join(lines)
-
-
@shared_task
def process_corpus_quality():
from app.database import AsyncSessionLocal
@@ -155,6 +172,71 @@ def process_corpus_quality():
return asyncio.run(_process())
+@shared_task(bind=True, max_retries=3, default_retry_delay=60)
+def process_customer_import(self, user_id: str, records: list):
+ from app.database import AsyncSessionLocal
+ from app.services.customer import CustomerService
+
+ async def _import():
+ async with AsyncSessionLocal() as db:
+ svc = CustomerService(db)
+ imported = 0
+ errors = []
+ for i, record in enumerate(records):
+ try:
+ await svc.create_customer(user_id, record)
+ imported += 1
+ except Exception as e:
+ errors.append(f"Row {i+2}: {str(e)}")
+ return {"imported": imported, "total": len(records), "errors": errors}
+
+ import asyncio
+ return asyncio.run(_import())
+
+
+@shared_task
+def run_daily_corpus_training():
+ from app.database import AsyncSessionLocal
+ from app.services.corpus_trainer import CorpusTrainer
+
+ async def _train():
+ async with AsyncSessionLocal() as db:
+ trainer = CorpusTrainer(db)
+ result = await trainer.run_pipeline()
+ logger.info(f"Daily corpus training complete: {result}")
+ return result
+
+ import asyncio
+ return asyncio.run(_train())
+
+
+@shared_task
+def update_customer_health_cache():
+ from app.database import AsyncSessionLocal
+ from app.services.customer_health import CustomerHealthService
+ from app.models.user import User
+ from app.config import settings
+
+ async def _update():
+ async with AsyncSessionLocal() as db:
+ result = await db.execute(select(User.id))
+ user_ids = result.scalars().all()
+
+ svc = CustomerHealthService(db)
+
+ for uid in user_ids:
+ try:
+ overview = await svc.get_health_overview(uid)
+ scores = await svc.get_all_health_scores(uid)
+ except Exception as e:
+ logger.error(f"Health cache failed for user {uid}: {e}")
+
+ return f"Updated health cache for {len(user_ids)} users"
+
+ import asyncio
+ return asyncio.run(_update())
+
+
@shared_task
def cleanup_old_sessions():
import redis.asyncio as aioredis
@@ -190,4 +272,20 @@ def send_followup_reminder(customer_id: str, user_id: str):
return {"error": "Customer not found"}
import asyncio
- return asyncio.run(_send())
\ No newline at end of file
+ return asyncio.run(_send())
+
+
+@shared_task
+def check_followup_engine():
+ from app.database import AsyncSessionLocal
+ from app.services.followup_engine import FollowupEngine
+
+ async def _check():
+ async with AsyncSessionLocal() as db:
+ engine = FollowupEngine(db)
+ result = await engine.scan_and_followup()
+ logger.info(f"Followup engine check complete: {result}")
+ return result
+
+ import asyncio
+ return asyncio.run(_check())
\ No newline at end of file
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 7e6d855..b04745b 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -1,19 +1,22 @@
-fastapi==0.79.0
-uvicorn==0.19.0
+fastapi==0.100.0
+uvicorn==0.23.2
sqlalchemy==1.4.48
asyncpg==0.27.0
pydantic==1.10.12
-pydantic-settings==1.1.2
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.6
redis==4.5.5
celery==5.2.7
httpx==0.23.3
-openai==0.27.8
+openai==1.12.0
anthropic==0.8.1
jinja2==3.1.2
alembic==1.11.3
+sentry-sdk==2.3.1
pytest==7.4.3
pytest-asyncio==0.21.1
-pytest-cov==4.1.0
\ No newline at end of file
+pytest-cov==4.1.0
+weasyprint==60.2
+openpyxl==3.1.2
+edge-tts>=6.0.0
\ No newline at end of file
diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py
index ce48041..38a74cb 100644
--- a/backend/tests/test_config.py
+++ b/backend/tests/test_config.py
@@ -17,8 +17,8 @@ class TestConfig:
assert "translate" in settings.AI_ROUTING
assert "reply" in settings.AI_ROUTING
assert "marketing" in settings.AI_ROUTING
- assert settings.AI_ROUTING["translate"]["primary"] == "deepl"
- assert settings.AI_ROUTING["reply"]["primary"] == "openai"
+ assert "extract" in settings.AI_ROUTING
+ assert "primary" in settings.AI_ROUTING["translate"]
def test_free_tier_limits(self):
assert settings.FREE_DAILY_TRANSLATE_CHARS == 5000
diff --git a/backend/tests/test_customer_health.py b/backend/tests/test_customer_health.py
new file mode 100644
index 0000000..39342d4
--- /dev/null
+++ b/backend/tests/test_customer_health.py
@@ -0,0 +1,273 @@
+import pytest
+from datetime import datetime, timedelta
+from app.services.customer_health import CustomerHealthService
+
+
+class TestSilenceScore:
+ def test_no_contact_returns_max_days(self):
+ assert CustomerHealthService.silence_days(None) == 999
+
+ def test_recent_contact_returns_0_days(self):
+ now = datetime.utcnow()
+ assert CustomerHealthService.silence_days(now) == 0
+
+ def test_3_days_ago(self):
+ dt = datetime.utcnow() - timedelta(days=3)
+ assert CustomerHealthService.silence_days(dt) == 3
+
+ def test_silence_score_0_days(self):
+ assert CustomerHealthService.calculate_silence_score(datetime.utcnow()) == 100
+
+ def test_silence_score_7_days(self):
+ dt = datetime.utcnow() - timedelta(days=7)
+ score = CustomerHealthService.calculate_silence_score(dt)
+ assert score == 50
+
+ def test_silence_score_14_days(self):
+ dt = datetime.utcnow() - timedelta(days=14)
+ score = CustomerHealthService.calculate_silence_score(dt)
+ assert score == 0
+
+ def test_silence_score_21_days_clamped(self):
+ dt = datetime.utcnow() - timedelta(days=21)
+ score = CustomerHealthService.calculate_silence_score(dt)
+ assert score == 0
+
+
+class TestStatusWeight:
+ def test_customer_status(self):
+ assert CustomerHealthService.status_weight("customer") == 100
+
+ def test_negotiating_status(self):
+ assert CustomerHealthService.status_weight("negotiating") == 70
+
+ def test_lead_status(self):
+ assert CustomerHealthService.status_weight("lead") == 40
+
+ def test_lost_status(self):
+ assert CustomerHealthService.status_weight("lost") == 10
+
+ def test_unknown_status_defaults(self):
+ assert CustomerHealthService.status_weight("unknown") == 40
+
+ def test_none_status_defaults(self):
+ assert CustomerHealthService.status_weight(None) == 40
+
+
+class TestGrade:
+ def test_active_grade(self):
+ assert CustomerHealthService.grade(100) == "active"
+ assert CustomerHealthService.grade(80) == "active"
+ assert CustomerHealthService.grade(85) == "active"
+
+ def test_watch_grade(self):
+ assert CustomerHealthService.grade(79) == "watch"
+ assert CustomerHealthService.grade(50) == "watch"
+ assert CustomerHealthService.grade(65) == "watch"
+
+ def test_critical_grade(self):
+ assert CustomerHealthService.grade(49) == "critical"
+ assert CustomerHealthService.grade(0) == "critical"
+ assert CustomerHealthService.grade(30) == "critical"
+
+
+class TestResponseScore:
+ def test_both_none(self):
+ r = CustomerHealthService.calc_response_score(None, None)
+ assert r["score"] == 50
+ assert r["trend"] == "stable"
+
+ def test_only_recent_exists(self):
+ r = CustomerHealthService.calc_response_score(2.0, None)
+ assert r["score"] == 90
+ assert r["trend"] == "stable"
+
+ def test_improving_faster_response(self):
+ r = CustomerHealthService.calc_response_score(2.0, 10.0)
+ assert r["score"] == 90
+ assert r["trend"] == "improving"
+
+ def test_declining_slower_response(self):
+ r = CustomerHealthService.calc_response_score(10.0, 2.0)
+ assert r["trend"] == "declining"
+
+ def test_fast_response_high_score(self):
+ r = CustomerHealthService.calc_response_score(0.5, 5.0)
+ assert r["score"] >= 95
+ assert r["trend"] == "improving"
+
+ def test_very_slow_response_low_score(self):
+ r = CustomerHealthService.calc_response_score(48.0, 2.0)
+ assert r["score"] == 0
+ assert r["trend"] == "declining"
+
+
+class TestSentimentScore:
+ def test_empty_messages_neutral(self):
+ r = CustomerHealthService.calc_sentiment_score([])
+ assert r["score"] == 50
+ assert r["label"] == "neutral"
+
+ def test_positive_message(self):
+ r = CustomerHealthService.calc_sentiment_score(["yes I'm interested thanks"])
+ assert r["score"] == 80
+ assert r["label"] == "positive"
+
+ def test_negative_message(self):
+ r = CustomerHealthService.calc_sentiment_score(["no not interested too expensive"])
+ assert r["score"] == 20
+ assert r["label"] == "negative"
+
+ def test_mixed_messages_neutral(self):
+ r = CustomerHealthService.calc_sentiment_score(["good quality", "but price is high"])
+ assert r["score"] == 50
+ assert r["label"] == "neutral"
+
+ def test_more_positive_than_negative(self):
+ r = CustomerHealthService.calc_sentiment_score([
+ "great product",
+ "yes please proceed",
+ "but shipping is expensive",
+ ])
+ assert r["score"] == 80
+ assert r["label"] == "positive"
+
+
+class TestInquiryDepthScore:
+ def test_empty_messages(self):
+ r = CustomerHealthService.calc_inquiry_depth_score([])
+ assert r["score"] == 0
+ assert r["signal_count"] == 0
+
+ def test_no_signals(self):
+ r = CustomerHealthService.calc_inquiry_depth_score(["hello", "how are you"])
+ assert r["score"] == 0
+
+ def test_one_signal(self):
+ r = CustomerHealthService.calc_inquiry_depth_score(["what is your price"])
+ assert r["score"] == 50
+ assert r["signal_count"] >= 1
+
+ def test_multiple_signals(self):
+ r = CustomerHealthService.calc_inquiry_depth_score([
+ "what is your MOQ and FOB price",
+ "do you have certification",
+ "what is the lead time",
+ ])
+ assert r["score"] >= 75
+ assert r["signal_count"] >= 3
+
+ def test_deduplicates_signals(self):
+ r = CustomerHealthService.calc_inquiry_depth_score([
+ "what is the price",
+ "please send price and MOQ",
+ ])
+ assert r["signal_count"] == 2
+
+
+class TestBusinessValueScore:
+ def test_zero_value(self):
+ r = CustomerHealthService.calc_business_value_score(0)
+ assert r["score"] == 0
+
+ def test_small_value(self):
+ r = CustomerHealthService.calc_business_value_score(500)
+ assert r["score"] == 20
+
+ def test_medium_value(self):
+ r = CustomerHealthService.calc_business_value_score(5000)
+ assert r["score"] == 40
+
+ def test_large_value(self):
+ r = CustomerHealthService.calc_business_value_score(50000)
+ assert r["score"] == 80
+
+ def test_very_large_value(self):
+ r = CustomerHealthService.calc_business_value_score(200000)
+ assert r["score"] == 100
+
+
+class TestTotalScore:
+ def test_perfect_health(self):
+ dims = {
+ "response_trend": {"score": 100},
+ "sentiment": {"score": 100},
+ "inquiry_depth": {"score": 100},
+ "silence": {"score": 100},
+ "business_value": {"score": 100},
+ }
+ r = CustomerHealthService.calc_total_score(dims)
+ assert r["total_score"] == 100
+ assert r["grade"] == "active"
+
+ def test_zero_health(self):
+ dims = {
+ "response_trend": {"score": 0},
+ "sentiment": {"score": 0},
+ "inquiry_depth": {"score": 0},
+ "silence": {"score": 0},
+ "business_value": {"score": 0},
+ }
+ r = CustomerHealthService.calc_total_score(dims)
+ assert r["total_score"] == 0
+ assert r["grade"] == "critical"
+
+ def test_mid_health(self):
+ dims = {
+ "response_trend": {"score": 60},
+ "sentiment": {"score": 50},
+ "inquiry_depth": {"score": 50},
+ "silence": {"score": 40},
+ "business_value": {"score": 50},
+ }
+ r = CustomerHealthService.calc_total_score(dims)
+ assert 45 <= r["total_score"] <= 55
+
+
+class TestSuggestion:
+ def test_active_suggestion(self):
+ s = CustomerHealthService.suggestion("active", 1, "lead")
+ assert "良好" in s
+
+ def test_watch_with_silence(self):
+ s = CustomerHealthService.suggestion("watch", 5, "lead")
+ assert "5天" in s
+ assert "跟进" in s
+
+ def test_watch_no_silence(self):
+ s = CustomerHealthService.suggestion("watch", 1, "lead")
+ assert "关注" in s
+
+ def test_critical_lead(self):
+ s = CustomerHealthService.suggestion("critical", 10, "lead")
+ assert "10天" in s
+ assert "跟进" in s
+
+ def test_critical_lost(self):
+ s = CustomerHealthService.suggestion("critical", 20, "lost")
+ assert "重新激活" in s
+
+
+class TestHealthOverview:
+ def test_overview_empty(self):
+ overview = CustomerHealthService._calculate_overview_static([])
+ assert overview["total"] == 0
+ assert overview["active"] == 0
+ assert overview["watch"] == 0
+ assert overview["critical"] == 0
+
+ def test_overview_mixed(self, monkeypatch):
+ class Row:
+ def __init__(self, status, days_ago):
+ self.status = status
+ self.last_contact_at = datetime.utcnow() - timedelta(days=days_ago)
+
+ rows = [
+ Row("customer", 1),
+ Row("lead", 7),
+ Row("negotiating", 14),
+ Row("lost", 30),
+ ]
+ overview = CustomerHealthService._calculate_overview_static(rows)
+ assert overview["total"] == 4
+ assert overview["active"] == 1
diff --git a/backend/tests/test_p0_basics.py b/backend/tests/test_p0_basics.py
new file mode 100644
index 0000000..cb3172b
--- /dev/null
+++ b/backend/tests/test_p0_basics.py
@@ -0,0 +1,95 @@
+import pytest
+from httpx import AsyncClient
+from app.core.security import create_access_token
+from app.models.user import User
+import uuid
+
+
+class TestAdminAPI:
+ async def test_admin_dashboard_unauthorized(self, client: AsyncClient):
+ response = await client.get("/api/v1/admin/dashboard")
+ assert response.status_code == 401
+
+ async def test_admin_dashboard_forbidden_non_admin(self, client: AsyncClient, test_user):
+ token = create_access_token({"sub": str(test_user.id), "tier": "free", "role": "user"})
+ response = await client.get(
+ "/api/v1/admin/dashboard",
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert response.status_code == 403
+
+ async def test_admin_dashboard_success(self, client: AsyncClient, test_user):
+ test_user.role = "admin"
+ token = create_access_token({"sub": str(test_user.id), "tier": "free", "role": "admin"})
+ response = await client.get(
+ "/api/v1/admin/dashboard",
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert "total_users" in data
+ assert "paid_users" in data
+
+ async def test_admin_list_users(self, client: AsyncClient, test_user):
+ test_user.role = "admin"
+ token = create_access_token({"sub": str(test_user.id), "tier": "free", "role": "admin"})
+ response = await client.get(
+ "/api/v1/admin/users",
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert "items" in data
+ assert "total" in data
+
+ async def test_admin_update_tier_forbidden_non_admin(self, client: AsyncClient, test_user):
+ target_id = str(uuid.uuid4())
+ token = create_access_token({"sub": str(test_user.id), "tier": "free", "role": "user"})
+ response = await client.patch(
+ f"/api/v1/admin/users/{target_id}/tier",
+ headers={"Authorization": f"Bearer {token}"},
+ json={"tier": "pro"},
+ )
+ assert response.status_code == 403
+
+
+class TestRateLimit:
+ async def test_health_not_rate_limited(self, client: AsyncClient):
+ for _ in range(10):
+ response = await client.get("/health")
+ assert response.status_code == 200
+
+ async def test_rate_limit_headers_present(self, client: AsyncClient, auth_headers):
+ response = await client.get("/api/v1/customers", headers=auth_headers)
+ assert "X-RateLimit-Remaining" in response.headers
+ assert "X-RateLimit-Limit" in response.headers
+
+
+class TestUserRole:
+ async def test_user_default_role(self, client: AsyncClient, test_user):
+ assert test_user.role == "user"
+
+ async def test_user_info_contains_role(self, client: AsyncClient, auth_headers):
+ response = await client.get("/api/v1/auth/me", headers=auth_headers)
+ assert response.status_code == 200
+ data = response.json()
+ assert "role" in data
+ assert data["role"] == "user"
+
+
+class TestPrivacyTerms:
+ async def test_privacy_page_exists(self):
+ import os
+ path = os.path.join(
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
+ "uni-app", "src", "pages", "agreement", "privacy.vue",
+ )
+ assert os.path.exists(path), "privacy.vue not found"
+
+ async def test_terms_page_exists(self):
+ import os
+ path = os.path.join(
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
+ "uni-app", "src", "pages", "agreement", "terms.vue",
+ )
+ assert os.path.exists(path), "terms.vue not found"
diff --git a/backend/tests/test_p1_core_api.py b/backend/tests/test_p1_core_api.py
new file mode 100644
index 0000000..a1f36fb
--- /dev/null
+++ b/backend/tests/test_p1_core_api.py
@@ -0,0 +1,337 @@
+import pytest
+from httpx import AsyncClient
+from unittest.mock import patch, AsyncMock
+from app.models.customer import Conversation, Message
+from app.models.quotation import Quotation, QuotationItem
+from app.models.user import Product
+from datetime import datetime
+
+
+class TestTranslateAPI:
+ async def test_translate_unauthorized(self, client: AsyncClient):
+ response = await client.post(
+ "/api/v1/translate",
+ json={"text": "Hello", "target_lang": "zh"},
+ )
+ assert response.status_code == 401
+
+ async def test_translate_success(self, client: AsyncClient, auth_headers):
+ with patch("app.services.translation.TranslationService.translate") as mock:
+ mock.return_value = {
+ "translated_text": "你好",
+ "source_lang": "en",
+ "provider_used": "mock",
+ "from_cache": False,
+ }
+ response = await client.post(
+ "/api/v1/translate",
+ headers=auth_headers,
+ json={"text": "Hello", "target_lang": "zh"},
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert data["translated_text"] == "你好"
+
+ async def test_translate_with_context(self, client: AsyncClient, auth_headers):
+ with patch("app.services.translation.TranslationService.translate") as mock:
+ mock.return_value = {
+ "translated_text": "FOB 上海 价格",
+ "source_lang": "en",
+ "provider_used": "mock",
+ }
+ response = await client.post(
+ "/api/v1/translate",
+ headers=auth_headers,
+ json={"text": "FOB Shanghai price", "target_lang": "zh", "context": "trade"},
+ )
+ assert response.status_code == 200
+
+
+class TestReplyAPI:
+ async def test_reply_unauthorized(self, client: AsyncClient):
+ response = await client.post(
+ "/api/v1/translate/reply",
+ json={"inquiry": "How much?", "tone": "professional"},
+ )
+ assert response.status_code == 401
+
+ async def test_reply_success(self, client: AsyncClient, auth_headers):
+ with patch("app.services.translation.TranslationService.generate_reply") as mock:
+ mock.return_value = [
+ {"reply": "Thank you for your inquiry.", "tone": "professional", "provider": "mock"},
+ {"reply": "Thanks for reaching out!", "tone": "friendly", "provider": "mock"},
+ ]
+ response = await client.post(
+ "/api/v1/translate/reply",
+ headers=auth_headers,
+ json={"inquiry": "How much for 500 units?", "tone": "professional", "count": 2},
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert len(data["suggestions"]) == 2
+ assert data["count"] == 2
+
+ async def test_reply_with_context(self, client: AsyncClient, auth_headers):
+ with patch("app.services.translation.TranslationService.generate_reply") as mock:
+ mock.return_value = [{"reply": "Our price is $10/unit.", "tone": "professional", "provider": "mock"}]
+ response = await client.post(
+ "/api/v1/translate/reply",
+ headers=auth_headers,
+ json={
+ "inquiry": "Price?",
+ "tone": "professional",
+ "count": 1,
+ "context": {"product": "Widget X", "price": "$10"},
+ },
+ )
+ assert response.status_code == 200
+
+
+class TestExtractAPI:
+ async def test_extract_unauthorized(self, client: AsyncClient):
+ response = await client.post(
+ "/api/v1/translate/extract",
+ json={"text": "I want 500pcs of red widgets FOB Shanghai", "extract_type": "inquiry"},
+ )
+ assert response.status_code == 401
+
+ async def test_extract_success(self, client: AsyncClient, auth_headers):
+ with patch("app.services.translation.TranslationService.extract_info") as mock:
+ mock.return_value = {
+ "intent": "purchase",
+ "product_interest": "widgets",
+ "quantity": "500",
+ }
+ response = await client.post(
+ "/api/v1/translate/extract",
+ headers=auth_headers,
+ json={"text": "I want 500pcs of red widgets FOB Shanghai", "extract_type": "inquiry"},
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert "extracted" in data
+
+
+class TestTTSAPI:
+ async def test_tts_get_unauthorized(self, client: AsyncClient):
+ response = await client.get("/api/v1/translate/tts?text=hello&lang=en")
+ assert response.status_code == 401
+
+ async def test_tts_get_empty_text(self, client: AsyncClient, auth_headers):
+ response = await client.get("/api/v1/translate/tts?text=&lang=en", headers=auth_headers)
+ assert response.status_code == 400
+
+
+class TestMarketingAPI:
+ async def test_marketing_unauthorized(self, client: AsyncClient):
+ response = await client.post(
+ "/api/v1/marketing/generate",
+ json={"product_name": "Widget", "description": "A great widget", "target": "US buyers"},
+ )
+ assert response.status_code == 401
+
+ async def test_marketing_success(self, client: AsyncClient, auth_headers):
+ with patch("app.services.marketing.MarketingService.generate") as mock:
+ mock.return_value = [
+ {"content": "Buy our widget!", "style": "professional", "provider": "mock"},
+ ]
+ response = await client.post(
+ "/api/v1/marketing/generate",
+ headers=auth_headers,
+ json={
+ "product_name": "Widget X",
+ "description": "High quality widget",
+ "category": "tools",
+ "target": "US importers",
+ "style": "professional",
+ "count": 1,
+ },
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert data["count"] >= 1
+ assert "results" in data
+
+ async def test_marketing_keywords(self, client: AsyncClient, auth_headers):
+ with patch("app.services.marketing.MarketingService.generate_keywords") as mock:
+ mock.return_value = ["widget", "tool", "quality"]
+ response = await client.post(
+ "/api/v1/marketing/keywords",
+ headers=auth_headers,
+ json={"product_name": "Widget", "description": "A widget", "count": 5},
+ )
+ assert response.status_code == 200
+ assert "keywords" in response.json()
+
+
+class TestProductAPI:
+ async def test_create_product(self, client: AsyncClient, auth_headers):
+ response = await client.post(
+ "/api/v1/products",
+ headers=auth_headers,
+ json={
+ "name": "Test Product",
+ "description": "A test product",
+ "category": "electronics",
+ "price": "10.50",
+ "price_unit": "USD",
+ },
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert data["name"] == "Test Product"
+
+ async def test_list_products(self, client: AsyncClient, auth_headers):
+ response = await client.get("/api/v1/products", headers=auth_headers)
+ assert response.status_code == 200
+ data = response.json()
+ assert "items" in data
+
+ async def test_update_product(self, client: AsyncClient, auth_headers, db_session, test_user):
+ product = Product(
+ user_id=test_user.id,
+ name="Old Name",
+ category="tools",
+ is_active=True,
+ )
+ db_session.add(product)
+ await db_session.commit()
+
+ response = await client.patch(
+ f"/api/v1/products/{product.id}",
+ headers=auth_headers,
+ json={"name": "New Name", "price": "20.00"},
+ )
+ assert response.status_code == 200
+ assert response.json()["name"] == "New Name"
+
+ async def test_delete_product(self, client: AsyncClient, auth_headers, db_session, test_user):
+ product = Product(user_id=test_user.id, name="To Delete")
+ db_session.add(product)
+ await db_session.commit()
+ pid = product.id
+
+ response = await client.delete(f"/api/v1/products/{pid}", headers=auth_headers)
+ assert response.status_code == 200
+
+ response = await client.get(f"/api/v1/products/{pid}", headers=auth_headers)
+ assert response.status_code == 404
+
+
+class TestQuotationAPI:
+ async def test_create_quotation(self, client: AsyncClient, auth_headers, db_session, test_user):
+ from app.models.customer import Customer
+ customer = Customer(user_id=test_user.id, name="Test Buyer")
+ db_session.add(customer)
+ await db_session.commit()
+
+ response = await client.post(
+ "/api/v1/quotations",
+ headers=auth_headers,
+ json={
+ "customer_id": str(customer.id),
+ "title": "Test Quote",
+ "items": [
+ {"product_name": "Widget", "quantity": 100, "unit_price": 10.0},
+ ],
+ "currency": "USD",
+ "payment_terms": "T/T",
+ },
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert data["title"] == "Test Quote"
+ assert len(data["items"]) == 1
+
+ async def test_list_quotations(self, client: AsyncClient, auth_headers):
+ response = await client.get("/api/v1/quotations", headers=auth_headers)
+ assert response.status_code == 200
+ assert "items" in response.json()
+
+ async def test_quotation_pdf_not_found(self, client: AsyncClient, auth_headers):
+ import uuid
+ response = await client.get(f"/api/v1/quotations/{uuid.uuid4()}/pdf", headers=auth_headers)
+ assert response.status_code == 404
+
+ async def test_quotation_status_update(self, client: AsyncClient, auth_headers, db_session, test_user):
+ from app.models.customer import Customer
+ customer = Customer(user_id=test_user.id, name="Status Test Buyer")
+ db_session.add(customer)
+ await db_session.commit()
+
+ q = Quotation(user_id=test_user.id, customer_id=customer.id, title="Status Test", status="draft")
+ db_session.add(q)
+ await db_session.commit()
+
+ response = await client.patch(
+ f"/api/v1/quotations/{q.id}/status",
+ headers=auth_headers,
+ json={"status": "sent"},
+ )
+ assert response.status_code == 200
+ assert response.json()["status"] == "sent"
+
+
+class TestAnalyticsAPI:
+ async def test_overview(self, client: AsyncClient, auth_headers):
+ response = await client.get("/api/v1/analytics/overview", headers=auth_headers)
+ assert response.status_code == 200
+ data = response.json()
+ assert "customers" in data
+ assert "translations" in data
+ assert "quotations" in data
+ assert "messages" in data
+ assert "marketing" in data
+
+ async def test_customer_analytics(self, client: AsyncClient, auth_headers):
+ response = await client.get("/api/v1/analytics/customers", headers=auth_headers)
+ assert response.status_code == 200
+
+ async def test_marketing_analytics(self, client: AsyncClient, auth_headers):
+ response = await client.get("/api/v1/analytics/marketing", headers=auth_headers)
+ assert response.status_code == 200
+ data = response.json()
+ assert "total_events" in data
+
+
+class TestOnboardingAPI:
+ async def test_onboarding_status(self, client: AsyncClient, auth_headers):
+ response = await client.get("/api/v1/onboarding/status", headers=auth_headers)
+ assert response.status_code == 200
+ data = response.json()
+ assert "completed" in data
+ assert "product_count" in data
+
+ async def test_onboarding_create_product(self, client: AsyncClient, auth_headers):
+ with patch("app.services.onboarding.OnboardingService.create_product") as mock:
+ mock.return_value = {
+ "id": "mock-id",
+ "name": "Onboarded Product",
+ "marketing_contents": [],
+ "keywords": [],
+ }
+ response = await client.post(
+ "/api/v1/onboarding/product",
+ headers=auth_headers,
+ json={
+ "name": "New Product",
+ "description": "Desc",
+ "category": "tools",
+ "target": "US buyers",
+ },
+ )
+ assert response.status_code == 200
+ assert response.json()["name"] == "Onboarded Product"
+
+
+class TestExportAPI:
+ async def test_export_customers_csv(self, client: AsyncClient, auth_headers):
+ response = await client.get("/api/v1/customers/export/csv", headers=auth_headers)
+ assert response.status_code == 200
+ assert response.headers["content-type"] == "text/csv"
+ assert "customers.csv" in response.headers["content-disposition"]
+
+ async def test_export_quotations_csv(self, client: AsyncClient, auth_headers):
+ response = await client.get("/api/v1/quotations/export/csv", headers=auth_headers)
+ assert response.status_code == 200
+ assert response.headers["content-type"] == "text/csv"
diff --git a/backend/tests/test_p2_enhancements.py b/backend/tests/test_p2_enhancements.py
new file mode 100644
index 0000000..efe14dd
--- /dev/null
+++ b/backend/tests/test_p2_enhancements.py
@@ -0,0 +1,156 @@
+import pytest
+from httpx import AsyncClient
+from app.models.notification import Notification
+from app.models.feedback import Feedback
+
+
+class TestNotificationAPI:
+ async def test_list_notifications_empty(self, client: AsyncClient, auth_headers):
+ response = await client.get("/api/v1/notifications", headers=auth_headers)
+ assert response.status_code == 200
+ data = response.json()
+ assert "items" in data
+ assert data["items"] == []
+
+ async def test_unread_count_zero(self, client: AsyncClient, auth_headers):
+ response = await client.get("/api/v1/notifications/unread-count", headers=auth_headers)
+ assert response.status_code == 200
+ assert response.json()["count"] == 0
+
+ async def test_create_and_list_notification(self, client: AsyncClient, auth_headers, db_session, test_user):
+ n = Notification(
+ user_id=test_user.id,
+ title="Test Title",
+ content="Test Content",
+ )
+ db_session.add(n)
+ await db_session.commit()
+
+ response = await client.get("/api/v1/notifications", headers=auth_headers)
+ assert response.status_code == 200
+ data = response.json()
+ assert len(data["items"]) >= 1
+ assert data["items"][0]["title"] == "Test Title"
+
+ async def test_mark_read(self, client: AsyncClient, auth_headers, db_session, test_user):
+ n = Notification(user_id=test_user.id, title="Read Test", content="Content")
+ db_session.add(n)
+ await db_session.commit()
+
+ response = await client.patch(
+ f"/api/v1/notifications/{n.id}/read",
+ headers=auth_headers,
+ )
+ assert response.status_code == 200
+
+ count_resp = await client.get("/api/v1/notifications/unread-count", headers=auth_headers)
+ assert count_resp.json()["count"] == 0
+
+ async def test_mark_all_read(self, client: AsyncClient, auth_headers, db_session, test_user):
+ for i in range(3):
+ db_session.add(Notification(user_id=test_user.id, title=f"Notif {i}", content="Content"))
+ await db_session.commit()
+
+ response = await client.post("/api/v1/notifications/read-all", headers=auth_headers)
+ assert response.status_code == 200
+ assert response.json()["count"] == 3
+
+ async def test_delete_notification(self, client: AsyncClient, auth_headers, db_session, test_user):
+ n = Notification(user_id=test_user.id, title="Delete Me", content="Content")
+ db_session.add(n)
+ await db_session.commit()
+ nid = n.id
+
+ response = await client.delete(f"/api/v1/notifications/{nid}", headers=auth_headers)
+ assert response.status_code == 200
+
+ list_resp = await client.get("/api/v1/notifications", headers=auth_headers)
+ ids = [item["id"] for item in list_resp.json()["items"]]
+ assert str(nid) not in ids
+
+ async def test_delete_not_found(self, client: AsyncClient, auth_headers):
+ import uuid
+ response = await client.delete(
+ f"/api/v1/notifications/{uuid.uuid4()}",
+ headers=auth_headers,
+ )
+ assert response.status_code == 404
+
+ async def test_unread_count_after_read(self, client: AsyncClient, auth_headers, db_session, test_user):
+ for i in range(2):
+ db_session.add(Notification(user_id=test_user.id, title=f"Unread {i}", content="C"))
+ await db_session.commit()
+
+ resp = await client.get("/api/v1/notifications/unread-count", headers=auth_headers)
+ assert resp.json()["count"] == 2
+
+ async def test_unread_only_filter(self, client: AsyncClient, auth_headers, db_session, test_user):
+ n1 = Notification(user_id=test_user.id, title="Read", content="C", is_read=True)
+ n2 = Notification(user_id=test_user.id, title="Unread", content="C")
+ db_session.add_all([n1, n2])
+ await db_session.commit()
+
+ response = await client.get(
+ "/api/v1/notifications?unread_only=true",
+ headers=auth_headers,
+ )
+ assert response.status_code == 200
+ for item in response.json()["items"]:
+ assert item["is_read"] is False
+
+
+class TestFeedbackAPI:
+ async def test_submit_feedback(self, client: AsyncClient, auth_headers):
+ response = await client.post(
+ "/api/v1/feedback",
+ headers=auth_headers,
+ json={
+ "content": "Great app!",
+ "category": "feature",
+ "contact": "test@example.com",
+ },
+ )
+ assert response.status_code == 200
+ assert response.json()["status"] == "ok"
+
+ async def test_submit_feedback_minimal(self, client: AsyncClient, auth_headers):
+ response = await client.post(
+ "/api/v1/feedback",
+ headers=auth_headers,
+ json={"content": "Bug report"},
+ )
+ assert response.status_code == 200
+ assert response.json()["status"] == "ok"
+
+ async def test_submit_feedback_unauthorized(self, client: AsyncClient):
+ response = await client.post(
+ "/api/v1/feedback",
+ json={"content": "Test"},
+ )
+ assert response.status_code == 401
+
+
+class TestPaymentAPI:
+ async def test_get_plans(self, client: AsyncClient, auth_headers):
+ response = await client.get("/api/v1/payment/plans", headers=auth_headers)
+ assert response.status_code == 200
+ data = response.json()
+ assert "plans" in data
+ assert len(data["plans"]) >= 3
+
+ async def test_get_subscription_free(self, client: AsyncClient, auth_headers):
+ response = await client.get("/api/v1/payment/subscription", headers=auth_headers)
+ assert response.status_code == 200
+ data = response.json()
+ assert "plan" in data
+ assert "status" in data
+
+ async def test_create_order(self, client: AsyncClient, auth_headers):
+ response = await client.post(
+ "/api/v1/payment/create-order",
+ headers=auth_headers,
+ json={"plan": "pro"},
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert "prepay_id" in data or "order_id" in data or "url" in data
diff --git a/backend/tests/test_p3_ai_features.py b/backend/tests/test_p3_ai_features.py
new file mode 100644
index 0000000..93cb439
--- /dev/null
+++ b/backend/tests/test_p3_ai_features.py
@@ -0,0 +1,9 @@
+import pytest
+from httpx import AsyncClient
+from unittest.mock import patch, AsyncMock
+from app.models.customer import Conversation, Message, Customer
+from app.models.preference import PreferenceAnalysis, MarketingEffect
+from app.models.user import Product
+from datetime import datetime
+
+
diff --git a/backend/tests/test_p3_corpus.py b/backend/tests/test_p3_corpus.py
new file mode 100644
index 0000000..f83b611
--- /dev/null
+++ b/backend/tests/test_p3_corpus.py
@@ -0,0 +1,139 @@
+import pytest
+from unittest.mock import patch, AsyncMock
+from app.services.corpus_trainer import CorpusTrainer
+from app.models.corpus import CorpusEntry
+from datetime import datetime
+
+
+class TestCorpusTrainer:
+ async def test_get_stats_empty(self, db_session):
+ trainer = CorpusTrainer(db_session)
+ stats = await trainer.get_stats()
+ assert stats["total_entries"] == 0
+ assert stats["with_embeddings"] == 0
+
+ async def test_get_stats_with_data(self, db_session):
+ entries = [
+ CorpusEntry(source_text="Hello", target_text="你好", task_type="translate", quality_score=0.8),
+ CorpusEntry(source_text="Goodbye", target_text="再见", task_type="translate", quality_score=0.6),
+ ]
+ for e in entries:
+ db_session.add(e)
+ await db_session.commit()
+
+ trainer = CorpusTrainer(db_session)
+ stats = await trainer.get_stats()
+ assert stats["total_entries"] == 2
+ assert stats["by_task_type"]["translate"] == 2
+ assert stats["high_quality"] == 1
+ assert stats["low_quality"] == 0
+
+ async def test_score_entries(self, db_session):
+ entries = [
+ CorpusEntry(source_text="Hello world", target_text="你好世界", task_type="translate"),
+ CorpusEntry(source_text="Hi", target_text="嗨", task_type="translate"),
+ ]
+ for e in entries:
+ db_session.add(e)
+ await db_session.commit()
+
+ trainer = CorpusTrainer(db_session)
+ result = await trainer.score_entries(batch_size=10)
+ assert result["processed"] == 2
+ assert result["updated"] == 2
+
+ for e in entries:
+ await db_session.refresh(e)
+ assert e.quality_score is not None
+ assert 0.0 <= e.quality_score <= 1.0
+
+ async def test_deduplicate(self, db_session):
+ from datetime import datetime
+ e1 = CorpusEntry(
+ source_text="Duplicate text", target_text="重复文本",
+ task_type="translate", quality_score=0.8,
+ created_at=datetime.utcnow(),
+ )
+ e2 = CorpusEntry(
+ source_text="Duplicate text", target_text="重复文本",
+ task_type="translate", quality_score=0.7,
+ created_at=datetime.utcnow(),
+ )
+ db_session.add_all([e1, e2])
+ await db_session.commit()
+
+ trainer = CorpusTrainer(db_session)
+ result = await trainer.deduplicate()
+ assert result["duplicates_removed"] == 1
+
+ stats = await trainer.get_stats()
+ assert stats["total_entries"] == 1
+
+ async def test_prune_low_quality(self, db_session):
+ from datetime import timedelta
+ old = datetime.utcnow() - timedelta(days=100)
+ entry = CorpusEntry(
+ source_text="x", target_text="y",
+ task_type="translate", quality_score=0.1,
+ created_at=old, usage_count=0,
+ )
+ db_session.add(entry)
+ await db_session.commit()
+
+ trainer = CorpusTrainer(db_session)
+ result = await trainer.prune_low_quality(min_score=0.2, max_age_days=30)
+ assert result["pruned"] == 1
+
+ stats = await trainer.get_stats()
+ assert stats["total_entries"] == 0
+
+ async def test_run_pipeline(self, db_session):
+ trainer = CorpusTrainer(db_session)
+ result = await trainer.run_pipeline()
+ assert "deduplication" in result
+ assert "scoring" in result
+ assert "embeddings" in result
+ assert "pruning" in result
+ assert "stats" in result
+
+ def test_calculate_quality_score_with_rating(self, db_session):
+ trainer = CorpusTrainer(db_session)
+ entry = CorpusEntry(
+ source_text="Good source text with enough length",
+ target_text="Good target text with enough length",
+ task_type="translate",
+ user_rating=4,
+ )
+ score = trainer._calculate_quality_score(entry)
+ assert 0.7 <= score <= 1.0
+
+ def test_calculate_quality_score_short_text(self, db_session):
+ trainer = CorpusTrainer(db_session)
+ entry = CorpusEntry(
+ source_text="ab", target_text="cd",
+ task_type="translate",
+ )
+ score = trainer._calculate_quality_score(entry)
+ assert score < 0.5
+
+ def test_calculate_quality_score_with_usage(self, db_session):
+ trainer = CorpusTrainer(db_session)
+ entry = CorpusEntry(
+ source_text="Good source text here with proper length",
+ target_text="Good target text here with proper length",
+ task_type="translate",
+ usage_count=10,
+ )
+ score = trainer._calculate_quality_score(entry)
+ assert score >= 0.6
+
+ async def test_embedding_generation_skipped_without_key(self, db_session):
+ from app.config import settings
+ original = settings.OPENAI_API_KEY
+ settings.OPENAI_API_KEY = None
+
+ trainer = CorpusTrainer(db_session)
+ embedding = await trainer._generate_embedding("test")
+ assert embedding is None
+
+ settings.OPENAI_API_KEY = original
diff --git a/docs/DEV_PLAN.md b/docs/DEV_PLAN.md
new file mode 100644
index 0000000..8e3570d
--- /dev/null
+++ b/docs/DEV_PLAN.md
@@ -0,0 +1,293 @@
+# 外贸小助手 (TradeMate) — 开发计划
+
+> 版本: v1.0
+> 创建日期: 2026-05-09
+> 基于: 产品设计文档 & 技术架构文档 & 代码审查
+
+---
+
+## 一、阶段说明
+
+按优先级分为 4 个阶段,每个阶段完成后可独立上线验证:
+
+| 阶段 | 名称 | 目标 | 预估工时 |
+|------|------|------|---------|
+| P0 | 上线必须 | 小程序审核通过 + 核心链路跑通 | 2-3 天 |
+| P1 | MVP 补齐 | 产品设计承诺的功能闭环 | 1-2 周 |
+| P2 | 体验增强 | 用户留存 + 付费转化 | 2-3 周 |
+| P3 | 护城河建设 | AI 数据壁垒 + 网络效应 | 4-6 周 |
+
+---
+
+## 二、P0 — 上线必须(2-3 天)
+
+### 2.1 微信登录配置
+- **问题**: `wechat.py` 代码完整,但 `WECHAT_APP_ID` / `WECHAT_APP_SECRET` 未配置,前端微信登录按钮无实际功能
+- **改动量**: 纯配置(需微信开放平台账号)
+- **涉及文件**: `.env`、`backend/app/api/v1/auth.py`、`uni-app/src/pages/login/login.vue`
+- **验收**: 用户可点击微信授权登录
+
+### 2.2 隐私政策 & 用户协议页面
+- **问题**: 小程序审核必需要求,当前无任何法律文档页面
+- **改动量**: 新增 2 个静态页面
+- **涉及文件**: `uni-app/src/pages/agreement/privacy.vue`、`uni-app/src/pages/agreement/terms.vue`、`pages.json`
+- **验收**: 登录页底部展示协议链接,可点击打开
+
+### 2.3 首页数据 Mock 替换
+- **问题**: `index.vue:121` 使用了 `Math.floor(Math.random() * 20)` 模拟今日翻译数和报价单数
+- **改动量**: 接入真实统计 API
+- **涉及文件**: `uni-app/src/pages/index/index.vue`
+- **验收**: 首页所有数据来自后端真实接口
+
+### 2.4 admin API 鉴权修复
+- **问题**: `admin.py:11` 的 `require_admin` 无实际校验,任何持有 token 的用户都能改其他用户的 Tier
+- **改动量**: 增加用户角色字段 + 权限校验
+- **涉及文件**: `backend/app/models/user.py`、`backend/app/api/v1/admin.py`
+- **验收**: 普通用户访问 admin 接口返回 403
+
+### 2.5 前端 API 重复定义清理
+- **问题**: `api.js` 中 `translateApi` 和 `customerApi` 各定义了两遍(第 43 行和第 116 行、第 50 行和第 124 行),后者会覆盖前者但内容不一致(后者多了 `tts` 和 `importCustomers`)
+- **改动量**: 合并重复定义,保留完整版本
+- **涉及文件**: `uni-app/src/utils/api.js`
+- **验收**: 文件中每个 API 对象只出现一次
+
+---
+
+## 三、P1 — MVP 补齐(1-2 周)
+
+### 3.1 新用户引导流程
+
+**对应产品设计**: 用户旅程 §3.1 — 30 秒上手
+
+| 步骤 | 说明 |
+|------|------|
+| 首次注册登录后 | 弹出引导弹窗:"你主要卖什么产品?" |
+| 输入产品信息 | 名称 + 描述 + 目标市场 |
+| 自动生成 | 3 条营销文案 + 关键词建议 |
+| 保存产品 | 写入产品库 |
+| 跳转客户页 | 提示"添加或导入客户" |
+
+**涉及文件**:
+- `uni-app/src/pages/index/index.vue` — 首次登录检测 + 引导弹窗
+- `backend/app/services/onboarding.py` — 首次生成逻辑
+- `backend/app/api/v1/onboarding.py` — 引导 API
+
+**状态**: ✅ 已完成
+
+**验收**: 新注册用户登录后 30 秒内看到生成的营销文案
+
+### 3.2 真实数据分析
+
+**对应**: 首页仪表盘、数据分析页面
+
+- **后端**: 统计 API 已存在(`analytics.py`),数据来自数据库查询
+- **前端**: `analytics.vue` 已接入 `analyticsApi.getOverview()` 真实数据
+- **涉及文件**: `uni-app/src/pages/analytics/analytics.vue`、`backend/app/services/analytics.py`
+- **状态**: ✅ 已完成(首页和数据分析页面数据均来自真实接口)
+
+### 3.3 报价单 PDF 生成
+
+**对应**: 功能设计 §2.5
+
+- **后端**: `pdf_generator.py` 引用了 `weasyprint` 但该库未在 `requirements.txt` 中,且当前生成逻辑是纯文本
+- **改动**:
+ 1. 将 `weasyprint` 加入 `requirements.txt`
+ 2. 安装系统依赖(`libpango`、`libcairo` 等)
+ 3. 实现正式的 HTML→PDF 模板渲染
+- **涉及文件**: `backend/app/services/pdf_generator.py`、`backend/requirements.txt`
+- **状态**: ✅ 已完成(weasyprint 模板 + HTML→PDF 渲染)
+- **验收**: `/quotations/{id}/pdf` 返回真实 PDF 文件
+
+### 3.4 TTS 前端集成
+
+**对应**: 功能设计 §2.3
+
+- **后端**: `tts.py` 已实现
+- **前端**: 翻译页面需添加"播放"按钮调用 TTS API
+- **涉及文件**: `uni-app/src/pages/translate/translate.vue`、`backend/app/api/v1/translate.py`
+- **状态**: ✅ 已完成(后端增加 GET 端点适配前端 downloadFile)
+- **验收**: 翻译结果可点击播放语音
+
+### 3.5 错误监控接入
+
+**对应**: 架构安全
+
+- 接入 Sentry 或其他错误监控服务
+- 后端: `sentry-sdk` 集成到 FastAPI
+- 前端: `uni-app` 错误捕获上报
+- **涉及文件**: `backend/app/main.py`、`backend/app/config.py`、`backend/requirements.txt`
+- **状态**: ✅ 已完成(Sentry FastAPI + SQLAlchemy 集成,配置 SENTRY_DSN 即可启用)
+- **验收**: 配置 SENTRY_DSN 后,故意制造错误可在监控平台看到
+
+---
+
+## 四、P2 — 体验增强(2-3 周)
+
+### 4.1 付费/计费系统
+
+**对应**: 盈利模式 §6
+
+| 模块 | 说明 |
+|------|------|
+| 支付 API | 接入微信支付(JSAPI)— 代码已完成,需微信支付商户号 |
+| 订阅管理 | `subscriptions` 表 + 到期自动降级 |
+| 前端升级页 | 展示 Free/Pro/Enterprise 对比 + 支付按钮 |
+| 配额联动 | 支付成功后自动更新 `user.tier` |
+
+**涉及文件**:
+- `backend/app/models/subscription.py`(新)
+- `backend/app/api/v1/payment.py`(新)
+- `backend/app/services/payment.py`(新)
+- `uni-app/src/pages/upgrade/upgrade.vue`(新)
+
+**状态**: ✅ 代码已完成(需配置微信支付商户号后真正可用)
+
+**验收**: 用户可完成从 free 到 pro 的完整支付升级流程
+
+### 4.2 应用内通知中心
+
+- **改动**:
+ - 新增 `notifications` 表
+ - 通知 API(列表/已读/删除/未读数)
+ - 前端通知中心页面 + 首页未读红点
+- **涉及文件**: `backend/app/models/notification.py`、`backend/app/api/v1/notification.py`、`backend/app/services/notification.py`、`uni-app/src/pages/notification/notification.vue`
+- **额外**: Celery 沉默客户检测任务同时写入应用内通知
+- **状态**: ✅ 已完成
+- **验收**: 沉默客户提醒、系统通知等在应用内可见
+
+### 4.3 数据导出
+
+- 客户列表导出 CSV
+- 报价单批量导出 CSV
+- **涉及文件**: `backend/app/services/export.py`、`backend/app/api/v1/customer.py`、`backend/app/api/v1/quotation.py`
+- **状态**: ✅ 已完成
+- **验收**: 访问 `/api/v1/customers/export/csv` 和 `/api/v1/quotations/export/csv` 下载文件
+
+### 4.4 速率限制
+
+**对应**: API设计 §5
+
+- 新增 `RateLimitMiddleware`,使用 Redis + 1 分钟窗口计数器
+- free: 100 req/min, pro: 500, enterprise: 2000
+- 超限返回 429,响应头携带 `X-RateLimit-Remaining`
+- **涉及文件**: `backend/app/core/middleware.py`、`backend/app/main.py`
+- **状态**: ✅ 已完成
+- **验收**: 超过限制返回 429
+
+### 4.5 用户反馈 & 帮助系统
+
+- 反馈提交(Bug/功能建议/其他)
+- 常见问题 FAQ(内置 4 条常见问题)
+- **涉及文件**: `backend/app/models/feedback.py`、`backend/app/api/v1/feedback.py`、`uni-app/src/pages/feedback/feedback.vue`
+- **状态**: ✅ 已完成
+- **验收**: 用户可提交反馈并查看常见问题
+
+---
+
+## 五、P3 — 护城河建设(4-6 周)
+
+### 5.1 AI 编辑学习回路
+
+**对应**: 护城河 §5.1
+
+当前 `messages` 表已有 `ai_suggestions`、`selected_suggestion`、`user_edited` 字段:
+
+1. 用户选择某个 AI 建议 → 记录正反馈(signal) ✅
+2. 用户修改 AI 建议再发送 → 记录差异(diff) ✅
+3. 系统定期分析用户偏好风格 ✅
+4. 下次生成时使用用户偏好做 few-shot ✅
+
+**涉及文件**:
+- `backend/app/services/preference.py`(新)— UserPreferenceService
+- `backend/app/api/v1/interaction.py`(新)— 选择/编辑/分析 API
+- `backend/app/models/preference.py`(新)— PreferenceAnalysis 模型
+- `backend/app/ai/router.py` — 注入 preference_context
+- `backend/app/ai/providers/openai.py` — 接受 preference_context 参数
+- `backend/app/ai/providers/claude.py` — 同上
+- `backend/app/ai/providers/spark.py` — 同上
+- `backend/app/ai/providers/local.py` — 同上
+- `backend/app/api/v1/translate.py` — getUserPreferenceContext + 传递到 AI layer
+- `backend/app/api/v1/marketing.py` — 同上
+- `backend/app/services/translation.py` — 透传 preference_context
+- `backend/app/services/marketing.py` — 透传 preference_context
+- `uni-app/src/utils/api.js` — 新增 interactionApi
+
+**验收**: 用户多次编辑后,AI 回复风格逐渐接近用户习惯
+
+### 5.2 营销效果追踪
+
+**对应**: 功能设计 §2.2
+
+- 记录用户复制了哪些营销文案 ✅
+- 记录用户发送了哪些文案 ✅
+- 展示文案使用统计(复制次数、发送次数、效果评分) ✅
+- **涉及文件**: `backend/app/models/preference.py`(MarketingEffect 模型)、`backend/app/services/marketing_effect.py`(新)、`backend/app/api/v1/interaction.py`(效果追踪端点)、`backend/app/services/analytics.py`(增加 marketing stats)、`backend/app/api/v1/analytics.py`(增加 marketing 统计接口)
+- **验收**: 营销素材页展示"已复制 15 次"等统计数据
+
+### 5.3 沉默客户模式算法
+
+**对应**: 护城河 §5.3
+
+1. 风险评分算法(沉默天数 + 活跃度 + 谈判阶段 + 关键词检测) ✅
+2. 评分等级输出(high/medium/low/minimal) ✅
+3. 跟进建议生成 ✅
+
+**涉及文件**:
+- `backend/app/services/silent_pattern.py`(新)— SilentPatternService
+- `backend/app/api/v1/silent_pattern.py`(新)— 风险分析 + 建议 API
+- `uni-app/src/utils/api.js` — 新增 silentPatternApi
+
+**验收**: 沉默客户列表显示风险评分等级及跟进建议
+
+### 5.4 语料库离线训练
+
+**对应**: AI架构 §4.3
+
+- `corpus_entries` 表已有数据但无训练逻辑:
+ 1. embedding 向量计算(OpenAI text-embedding-3-small) ✅
+ 2. 质量评分自动计算 ✅
+ 3. 重复数据去重 ✅
+ 4. 低质量数据清理 ✅
+ 5. Celery beat 每日定时训练 ✅
+
+**涉及文件**:
+- `backend/app/services/corpus_trainer.py`(新)— 完整训练 pipeline
+- `backend/app/api/v1/training.py`(新)— 训练触发端点
+- `backend/app/workers/tasks.py` — 新增 run_daily_corpus_training 定时任务
+- `backend/app/celery_app.py` — 注册 daily-corpus-training 定时器
+
+**验收**: 语料库达到阈值后自动触发训练,翻译质量可量化提升
+
+### 5.5 报价单智能生成
+
+**对应**: 功能设计 §2.5
+
+从"手动填报价单"升级为"自动识别客户消息→生成报价单草稿":
+1. `extract_info` 识别客户询盘意图(产品、数量、贸易术语) ✅
+2. 自动匹配产品库价格 ✅
+3. 生成报价单草稿(含 matched/unmatched 标识) ✅
+
+**涉及文件**:
+- `backend/app/services/quotation.py` — 新增 generate_from_inquiry 方法
+- `backend/app/api/v1/quotation.py` — 新增 POST /quotations/generate-from-inquiry 端点
+- `uni-app/src/utils/api.js` — 新增 quotationApi.generateFromInquiry
+
+**验收**: 客户说 "How much for 500pcs FOB Shanghai?" → 系统自动生成含金额的报价单草稿
+
+---
+
+## 六、工作量汇总
+
+| 阶段 | 功能点 | 预估 |
+|------|--------|------|
+| **P0** | 微信登录配置、隐私协议、Mock 替换、admin 鉴权、API 去重 | ✅ 2-3 天 |
+| **P1** | 引导流程、数据分析、PDF、TTS、错误监控 | ✅ 1-2 周 |
+| **P2** | 支付系统、通知中心、数据导出、速率限制、帮助反馈 | ✅ 2-3 周 |
+| **P3** | AI 编辑学习、效果追踪、沉默算法、语料库训练、智能报价 | ✅ 4-6 周 |
+
+### 关键依赖
+
+- **P0** 无外部依赖,可立即开始
+- **P1 PDF** 需要服务器安装系统库(libpango, libcairo)
+- **P2 支付** 需要微信支付商户号
+- **P3 训练** 需要语料库自然积累到一定规模后才有意义(已实现 pipeline 和定时任务)
diff --git a/docs/FIX_PLAN.md b/docs/FIX_PLAN.md
new file mode 100644
index 0000000..8fd1acd
--- /dev/null
+++ b/docs/FIX_PLAN.md
@@ -0,0 +1,327 @@
+# 外贸小助手 (TradeMate) — 功能差距修复计划
+
+> 基于 2026-05-10 全量代码审计
+> 将未完成/存根/未对接功能按优先级分为 FixP0-FixP3 四阶段
+
+---
+
+## 总体概况
+
+| 类别 | 数量 | 说明 |
+|------|------|------|
+| 🔴 外部阻塞(代码就绪) | 4 项 | 微信登录/支付/WhatsApp/AI回退 — 需要配置密钥 |
+| ⚠️ 部分实现/存根 | 4 项 | Push/汇率/PDF任务/weasyprint系统依赖 |
+| ❌ 前端未对接 | 12 项 | 后端有API但前端无入口 |
+| ✅ 完整实现 | ~25 项 | 核心链路前后端均对接 |
+
+---
+
+## FixP0 — 上线阻塞(必须先修复)
+
+### F0.1 Push 推送重写
+
+**问题**: `PushService.send_notification()` 只打印日志,无真实推送。设备注册用内存 dict。Celery 静默客户检测产出提醒但用户收不到。
+
+**方案**:
+1. 新建 `Device` 模型:user_id, platform, push_token, is_active, created_at
+2. 重写 `PushService`:使用 uni-push 或第三方推送通道
+3. `POST /push/register` 改为写数据库
+4. `send_notification()` 调用真实推送 SDK
+5. Celery `check_silent_customers` 任务同时写 Notification + PushService
+
+**涉及文件**:
+- `backend/app/models/device.py`(新)
+- `backend/app/models/__init__.py`
+- `backend/app/services/push.py`(重写)
+- `backend/app/api/v1/push.py`(重写)
+- `uni-app/src/utils/api.js`(新增 pushApi)
+- `uni-app/src/pages/index/index.vue`(注册设备 token)
+- `backend/alembic/versions/004_devices.py`(新)
+
+**预估**: 3-4 天(含前端 token 注册)
+
+### F0.2 汇率服务接入真实 API
+
+**问题**: `GET /api/v1/exchange/convert` 和 `GET /api/v1/exchange/rates` 返回硬编码数据,`EXCHANGE_RATE_API_KEY` 未使用。
+
+**方案**:
+1. 创建 `ExchangeRateService`:调用 exchangerate-api.com 或 国家外汇管理局 API
+2. Redis 缓存 6 小时减少 API 调用
+3. 更新硬编码字典为动态查询
+4. 报价单界面增加汇率显示和币种切换
+
+**涉及文件**:
+- `backend/app/services/exchange.py`(新 — 当前无 service)
+- `backend/app/api/v1/exchange.py`(重写 — 移除硬编码)
+- `uni-app/src/utils/api.js`(新增 exchangeApi)
+- `uni-app/src/pages/translate/translate.vue`(报价单汇率开关)
+
+**预估**: 1-2 天
+
+### F0.3 Celery PDF 任务修复
+
+**问题**: `workers/tasks.py:94` 调用旧纯文本 `generate_pdf_text()` 而非 `pdf_generator.generate_quotation()`。
+
+**方案**:
+- 将 Celery 任务 `generate_quotation_pdf` 改为调用 `pdf_generator.generate_quotation(data)` 并存储 blob
+
+**涉及文件**:
+- `backend/app/workers/tasks.py`(重写 generate_quotation_pdf)
+- `backend/app/services/pdf_generator.py`(增加保存 PDF 到磁盘方法)
+
+**预估**: 0.5 天
+
+### F0.4 weasyprint 系统依赖文档化
+
+**问题**: `requirements.txt` 有 weasyprint 但部署时需系统库,无文档说明。
+
+**方案**:
+- 在 README 和 docker-compose 中增加系统依赖安装说明
+- 若使用 Docker,在 Dockerfile 中提前安装
+
+```dockerfile
+RUN apt-get update && apt-get install -y libpango-1.0-0 libpangocairo-1.0-0 \
+ libgdk-pixbuf2.0-0 libffi-dev libcairo2 libcairo2-dev
+```
+
+**涉及文件**:
+- `Dockerfile`(新增或修改)
+- `README.md`(增加安装说明)
+
+**预估**: 0.5 天
+
+---
+
+## FixP1 — 前端对接(补齐体验)
+
+### F1.1 TTS 播放按钮
+
+**问题**: `GET/POST /api/v1/translate/tts` 后端已实现,翻译页无播放按钮,`api.js` 无 tts API。
+
+**方案**:
+- `api.js` 新增 `translateApi.textToSpeech(text, lang)`
+- `translate.vue` 翻译结果后增加 🔊 播放按钮,调用 `uni.downloadFile` → `uni.playVoice`
+
+**涉及文件**:
+- `uni-app/src/utils/api.js`
+- `uni-app/src/pages/translate/translate.vue`
+
+**预估**: 1 天
+
+### F1.2 信息抽取 + 翻译反馈 UI
+
+**问题**: `POST /translate/extract` 和 `POST /translate/feedback` 有后端无前端。
+
+**方案**:
+- 翻译页增加"抽取信息"按钮(提取客户消息中的产品/数量/价格)
+- 翻译结果增加 1-5 星评分,调用 feedback 端点写入 corpus quality_score
+
+**涉及文件**:
+- `uni-app/src/pages/translate/translate.vue`
+- `uni-app/src/utils/api.js`
+
+**预估**: 1 天
+
+### F1.3 营销关键词 + 竞品分析按钮
+
+**问题**: `POST /marketing/keywords`、`POST /marketing/competitor-analysis` 后端完整,前端营销页无入口。
+
+**方案**:
+- `marketingApi` 增加 `generateKeywords()` 和 `competitorAnalysis()`
+- `marketing.vue` 生成结果下方增加"生成关键词"和"竞品分析"按钮
+
+**涉及文件**:
+- `uni-app/src/utils/api.js`
+- `uni-app/src/pages/marketing/marketing.vue`
+
+**预估**: 1 天
+
+### F1.4 营销效果统计展示
+
+**问题**: `interactionApi.trackMarketingEffect/stats` 前端 API 已定义,`marketing.vue` 未展示统计数据。
+
+**方案**:
+- `marketing.vue` 增加统计卡片:"今日复制 X 次 / 今日发送 Y 次 / 本周共 Z 次"
+- 每次复制文案时调用 `trackMarketingEffect({content, event_type:'copy'})`
+- 加载时调用 `getMarketingEffectStats()`
+
+**涉及文件**:
+- `uni-app/src/pages/marketing/marketing.vue`
+
+**预估**: 1 天
+
+### F1.5 AI 偏好展示 + 建议选择/编辑上报
+
+**问题**: `interactionApi.selectSuggestion/recordEdit/analyzePreferences` 已定义,翻译页未对接交互学习回路。
+
+**方案**:
+- 翻译页回复建议列表:用户点击某条 → 调用 `selectSuggestion(msgId, index)`
+- 用户编辑后发送 → 调用 `recordEdit(msgId, editedText)`
+- 设置页增加"AI 学习偏好"展示区(调用 `getPreferences()`)
+
+**涉及文件**:
+- `uni-app/src/pages/translate/translate.vue`
+- `uni-app/src/pages/settings/settings.vue`(如有)
+
+**预估**: 2 天
+
+### F1.6 沉默客户风险展示
+
+**问题**: `silentPatternApi.getRiskAnalysis/getSuggestions` 已定义,客户页未对接。
+
+**方案**:
+- 客户列表每项增加风险等级标识(🔴/🟡/🟢)
+- 客户详情页增加"跟进建议"区域
+- 调用 `getRiskAnalysis()` 在客户页顶部展示风险概况
+
+**涉及文件**:
+- `uni-app/src/pages/customer/customer.vue`(列表)
+- `uni-app/src/pages/customer/detail.vue`(详情)
+
+**预估**: 1.5 天
+
+### F1.7 智能报价按钮
+
+**问题**: `quotationApi.generateFromInquiry` 已定义,报价单页未对接。
+
+**方案**:
+- 报价单列表页增加"从询盘生成"按钮
+- 弹出输入框让用户粘贴客户询盘内容
+- 调用 API 后预览自动填充的报价单草稿
+
+**涉及文件**:
+- `uni-app/src/pages/quotation/quotation.vue`
+- `uni-app/src/pages/quotation/create.vue`(如有)
+
+**预估**: 1.5 天
+
+### F1.8 CSV 导出按钮
+
+**问题**: `GET /customers/export/csv` 和 `GET /quotations/export/csv` 后端完成,前端无导出入口。
+
+**方案**:
+- 客户页和报价单页的导航栏增加"导出"按钮
+- 使用 `uni.downloadFile` 下载 CSV
+
+**涉及文件**:
+- `uni-app/src/pages/customer/customer.vue`
+- `uni-app/src/pages/quotation/quotation.vue`
+
+**预估**: 0.5 天
+
+### F1.9 客户对话历史展示
+
+**问题**: `customerApi.getConversation` 已定义,客户详情页未展示对话记录。
+
+**方案**:
+- 客户详情页增加"对话记录" tab
+- 分页加载消息,显示发送/接收方向
+
+**涉及文件**:
+- `uni-app/src/pages/customer/detail.vue`
+
+**预估**: 1 天
+
+---
+
+## FixP2 — 管理后台补齐
+
+### F2.1 训练后台面板
+
+**问题**: `POST /training/corpus/*` 4 个端点后端完整,无前端入口。
+
+**方案**:
+- admin 页增加"语料库管理"标签
+- 展示语料库统计(总数/高质/低质/嵌入数)
+- 提供"运行训练"、"去重"、"清理"按钮
+
+**涉及文件**:
+- `uni-app/src/pages/admin/admin.vue`
+- `uni-app/src/utils/api.js`(新增 trainingApi)
+
+**预估**: 1 天
+
+### F2.2 反馈管理 + 用户管理增强
+
+**问题**: admin 面板只能看用户列表和改 tier,无反馈管理、系统监控。
+
+**方案**:
+- `GET /admin/feedbacks` 端点
+- admin 页增加"用户反馈"标签:列表/标记处理
+- 增加系统状态监控(Celery 队列长度、Redis 状态)
+
+**涉及文件**:
+- `backend/app/api/v1/admin.py`(新增反馈列表端点)
+- `uni-app/src/pages/admin/admin.vue`
+
+**预估**: 2 天
+
+---
+
+## FixP3 — 深层清理
+
+### F3.1 汇率集成报价单
+
+**问题**: 报价单创建/编辑时不可切换币种,无实时汇率。
+
+**方案**:
+- 报价单创建页增加币种选择(USD/CNY/EUR/GBP/JPY)
+- 调用 ExchangeRateService 自动换算
+- 报价单 PDF 显示原始币种金额和换算后金额
+
+**涉及文件**:
+- `backend/app/services/quotation.py`(create_quotation 加入汇率换算)
+- `backend/app/services/pdf_generator.py`(PDF 模板增加双币种行)
+- `uni-app/src/pages/quotation/create.vue`
+
+**预估**: 2 天
+
+### F3.2 Admin 统计指标扩展
+
+**问题**: admin dashboard 只返回 total_users/paid_users,缺少业务指标。
+
+**方案**:
+- `GET /admin/dashboard` 增加:今日活跃用户数、API 调用量、新增客户数、新建报价单数
+- 基于 `UsageLog` 聚合统计
+
+**涉及文件**:
+- `backend/app/services/admin.py`(如有)
+- `backend/app/api/v1/admin.py`
+
+**预估**: 1 天
+
+---
+
+## 工作量汇总
+
+| 阶段 | 项目 | 预估 |
+|------|------|------|
+| **FixP0** | Push 重写 + 汇率 API + PDF 任务修复 + weasyprint 依赖 | 5-6 天 |
+| **FixP1** | 9 项前端对接(TTS/抽取/关键词/效果/偏好/风险/报价/导出/对话) | 10-12 天 |
+| **FixP2** | 训练面板 + 反馈管理 | 3 天 |
+| **FixP3** | 汇率集成报价 + admin 统计扩展 | 3 天 |
+| **合计** | | **21-24 天** |
+
+### 外部依赖(开发期间可 mock)
+
+| 依赖 | 说明 |
+|------|------|
+| `EXCHANGE_RATE_API_KEY` | 免费版 exchangerate-api.com 即可 |
+| 推送通道 | uni-push(免费)或第三方通道 |
+| 微信商户号 | 支付上线必需 |
+
+### 建议执行顺序
+
+```
+FixP0.1 Push 重写 ────────────────────────── 最紧急,否则静默客户提醒等于没有
+ ↓
+FixP0.2 汇率 + FixP0.3 PDF 任务 ────────── 并行,无依赖
+ ↓
+FixP1.5 AI 偏好 + FixP1.6 沉默风险 ─────── P3 功能的最后闭环
+ ↓
+FixP1.1~1.4 TTS/抽取/关键词/效果 ──────── 批量前端对接
+ ↓
+FixP1.7~1.9 智能报价/导出/对话 ─────────── 报价 + 客户页增强
+ ↓
+FixP2 + FixP3 ─────────────────────────── 管理后台 + 深度清理
+```
diff --git a/docs/PRODUCT_DESIGN.md b/docs/PRODUCT_DESIGN.md
index fbb243b..b4397a0 100644
--- a/docs/PRODUCT_DESIGN.md
+++ b/docs/PRODUCT_DESIGN.md
@@ -1,8 +1,8 @@
# 外贸小助手 (TradeMate) — 产品设计文档
-> 版本: v1.0
-> 创建日期: 2026-05-08
-> 状态: 初始设计
+> 版本: v1.1
+> 创建日期: 2026-05-10
+> 状态: V2 规划中
---
@@ -39,23 +39,35 @@
### 2.1 功能全景图
```
-┌─────────────────────────────────────────────────────┐
-│ 外贸小助手 │
-│ │
-│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
-│ │ 营销素材 │ │ 智能沟通 │ │ 客户跟进 │ │
-│ │ 工厂 │ │ 助手 │ │ 引擎 │ │
-│ ├──────────┤ ├──────────┤ ├──────────┤ │
-│ │ 开发信 │ │ 消息翻译 │ │ 沉默检测 │ │
-│ │ 产品文案 │ │ 回复建议 │ │ 跟进提醒 │ │
-│ │ 关键词 │ │ 一键发送 │ │ 话术推荐 │ │
-│ │ 竞品分析 │ │ 语气调整 │ │ 周期提醒 │ │
-│ └──────────┘ └──────────┘ └──────────┘ │
-│ │
-│ ┌─────────────────────────────────────────┐ │
-│ │ 跨功能支撑: 报价单生成 / 汇率换算 │ │
-│ └─────────────────────────────────────────┘ │
-└─────────────────────────────────────────────────────┘
+┌─────────────────────────────────────────────────────────────────┐
+│ 外贸小助手 V2 │
+│ │
+│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────────────┐ │
+│ │ 营销素材 │ │ 智能沟通 │ │ 客户跟进 │ │ 智能决策中心 │ │
+│ │ 工厂 │ │ 助手 │ │ 引擎 │ │ (新增) │ │
+│ ├──────────┤ ├──────────┤ ├──────────┤ ├────────────────┤ │
+│ │ 开发信 │ │ 消息翻译 │ │ 沉默检测 │ │ 健康度看板 │ │
+│ │ 产品文案 │ │ 回复建议 │ │ 跟进提醒 │ │ 客户评分 │ │
+│ │ 关键词 │ │ 语气调整 │ │ 话术推荐 │ │ 行动建议 │ │
+│ │ 竞品分析 │ │ TTS播放 │ │ 周期提醒 │ │ 趋势预警 │ │
+│ └──────────┘ └──────────┘ └─────┬────┘ └────────────────┘ │
+│ │ │
+│ ┌──────────────────────────────────┼──────────────────────────┐ │
+│ │ AI智能跟进助手 (新增) │ │ │
+│ │ ┌─ 时机判断 ─ 内容生成 ─ 渠道选择 ─ 效果追踪 ─┐ │ │
+│ │ └──────────────────────────────────────────────┘ │ │
+│ └──────────────────────────────────┬──────────────────────────┘ │
+│ │ │
+│ ┌──────────────────────────────────┼──────────────────────────┐ │
+│ │ 智能市场机会分析 (新增) │ │ │
+│ │ ┌─ 趋势分析 ─ 客户发现 ─ 竞争情报 ─ 策略报告 ─┐ │ │
+│ │ └──────────────────────────────────────────────┘ │ │
+│ └─────────────────────────────────────────────────────────────┘ │
+│ │
+│ ┌──────────────────────────────────────────────────────────┐ │
+│ │ 跨功能支撑: 报价单生成 / 汇率换算 / 合规筛查 / 文档模板 │ │
+│ └──────────────────────────────────────────────────────────┘ │
+└─────────────────────────────────────────────────────────────────┘
```
### 2.2 功能一:营销素材工厂(帮用户"有内容可发")
@@ -174,6 +186,154 @@
- 用户确认 → 生成正式报价单图片 → 一键发送
```
+### 2.6 功能四:客户健康度看板(帮用户"一眼看清该追谁")
+
+#### 用户场景
+> "我有50多个客户,除了最近联系的那几个,其他谁在流失、谁快成交了,我心里完全没数。"
+
+#### 交互流程
+
+```
+[用户打开小程序]
+ └─ 点击"客户" → 顶部展示健康度总览卡片:
+ ├─ 🟢 活跃客户: 12
+ ├─ 🟡 需关注: 5
+ ├─ 🔴 高危流失: 3
+ └─ 📊 整体健康趋势(本周 vs 上周)
+
+[点击具体客户]
+ └─ 进入客户详情 → 健康度看板:
+ ├─ 综合评分: 78/100(评分趋势 ↑↓)
+ ├─ 评分维度明细:
+ │ ├─ 📈 响应趋势: +15%(回复速度在变快)
+ │ ├─ 💬 情感轨迹: 正面(AI分析最近3条消息)
+ │ ├─ 📋 询盘深度: "问了MOQ和认证"(成交信号)
+ │ ├─ ⏰ 沉默天数: 3天
+ │ └─ 💰 预估价值: $12,500
+ └─ 🎯 系统建议动作:
+ "客户问过MOQ和认证,建议明天发认证证书和报价单跟进"
+```
+
+#### 核心机制
+
+- **评分透明可解释**: 不像竞品黑盒打分,每个维度都有来源和原因
+- **多维度融合**: 响应趋势 + 情感分析 + 行为信号 + 沉默天数 + 商业价值
+- **Actionable**: 不只是分数,而是"到底是什么问题 + 建议怎么做"
+
+#### 数据来源(全在现有数据库里,无需外部API)
+
+| 维度 | 数据源 | 现有字段 |
+|------|--------|---------|
+| 响应趋势 | messages | created_at, direction |
+| 情感轨迹 | messages | content(AI分析) |
+| 询盘深度 | messages | content(关键词匹配) |
+| 沉默天数 | customers | last_contact_at |
+| 商业价值 | quotations | total, status |
+
+### 2.7 功能五:AI智能跟进助手(帮用户"知道什么时候跟、怎么跟")
+
+#### 用户场景
+> "报完价客户就没消息了,我不知道该不该发消息、什么时候发、发什么内容合适。"
+
+#### 交互流程
+
+```
+[系统自动运行]
+ └─ AI 持续评估每个客户的跟进状态:
+ ├─ 每个客户生成"最优跟进时机"(基于历史互动模式)
+ ├─ 不同客户不同策略:
+ │ ├─ 客户A(沉默3天,之前聊报价): "建议今晚8点WhatsApp发认证资料"
+ │ ├─ 客户B(沉默7天,之前聊样品): "建议邮件发新品目录 + 限时折扣"
+ │ └─ 客户C(回盘积极,在比价): "客户可能在对比,建议3天后主动降价5%"
+ └─ 系统主动推送通知:
+ "📌 跟进提醒: 客户Carlos沉默4天,最优跟进窗口还剩2天"
+
+[用户点击提醒]
+ └─ 进入跟进界面:
+ ├─ 客户背景摘要(快速回顾)
+ ├─ AI推荐的跟进内容(基于上次对话定制)
+ ├─ 选择渠道: WhatsApp / Email / 电话
+ └─ 一键发送 / 编辑后发送
+
+[发送后]
+ └─ 自动打标: "已跟进" → 继续监控客户反应
+ └─ 若3天内客户回复 → 标记"跟进成功" → 更新评分
+ └─ 若客户未回复 → 自动调整下一次跟进策略
+```
+
+#### 核心机制
+
+- **不是固定时间间隔**: 竞品用3/7/14天固定规则,AI根据客户状态动态判断
+- **多渠道选择**: 根据客户历史偏好推荐最佳渠道
+- **内容个性化**: 每次跟进都基于上次对话 + 产品库 + 市场信息生成
+- **闭环追踪**: 跟进→回复(或不回复)→调整策略
+
+#### 对比竞品
+
+| 维度 | 竞品 EDM 序列 | TradeMate AI 跟进 |
+|------|-------------|------------------|
+| 触发方式 | 预设时间(3/7/14天) | AI动态评估最佳时机 |
+| 内容 | 模板化 | 每次个性化生成 |
+| 渠道 | 仅邮件 | WhatsApp/邮件/电话 |
+| 适应性 | 固定流程 | 根据客户反应自动调整 |
+| 成本 | 邮件服务费 | 现有AI能力,零新增成本 |
+
+### 2.8 功能六:智能市场机会分析(帮用户"找到该开发哪个市场")
+
+#### 用户场景
+> "我想开发新市场,但不知道哪个国家对我的产品需求大、竞争情况怎么样、应该怎么切入。"
+
+#### 交互流程
+
+```
+[用户输入产品信息]
+ └─ 点击"市场分析"
+ └─ AI 自动执行:
+ 1. 调取免费国际贸易统计数据分析目标市场趋势
+ 2. 搜索公开B2B平台/行业黄页发现潜在客户
+ 3. 分析目标市场竞争格局和定价
+ 4. 生成市场进入策略报告
+
+[用户收到报告]
+ ┌─────────────────────────────────────┐
+ │ 📊 户外折叠椅 · 市场机会报告 │
+ │ │
+ │ 🎯 推荐市场 Top 3: │
+ │ 1. 美国 (+23% 进口增长) — 建议主攻 │
+ │ 2. 德国 (+15%) — 中高端定位 │
+ │ 3. 东南亚 (+40% 增长快速) — 建议布局 │
+ │ │
+ │ 👥 潜在客户示例: │
+ │ · OutdoorLiving Inc. (美国) │
+ │ · GartenPro GmbH (德国) │
+ │ · CampingZone (东南亚) │
+ │ │
+ │ 💡 进入策略建议: │
+ │ · 美国: 侧重防水+承重卖点 │
+ │ · 德国: 强调环保认证 │
+ │ · 东南亚: 主打性价比 │
+ └─────────────────────────────────────┘
+
+[用户下一步]
+ └─ 点击"生成开发信" → AI 自动适配目标市场的文化习惯生成文案
+ └─ 保存报告到产品库 → 随时查阅
+```
+
+#### 核心机制
+
+- **零数据采购成本**: 使用免费国际贸易统计 API + AI 搜索,不买海关数据
+- **海关数据竞品的问题**: 数据滞后3-6个月,只有提单记录无具体联系人
+- **我们的方案**: 不只告诉你"谁买了",而是"怎么找到他们、怎么卖进去"
+
+#### 数据源
+
+| 数据源 | 用途 | 成本 |
+|--------|------|------|
+| UN COMTRADE / ITC TradeMap | 国际贸易统计数据 | 免费 |
+| Google Custom Search / SerpAPI | B2B平台/黄页公开数据 | 免费~$50/月 |
+| Wikipedia/OpenStreetMap | 国家基础信息 | 免费 |
+| AI 已有知识 | 市场策略、文化习惯 | 已有 |
+
---
## 三、用户旅程
@@ -222,11 +382,13 @@
## 五、护城河策略
-详见 `TECH_ARCHITECTURE.md` 第 5 章,核心三层:
+详见 `TECH_ARCHITECTURE.md` 第 5 章,核心五层:
1. **外贸垂直语料库**:用户每次使用产生的翻译/回复数据,积累成行业专属语料
2. **用户产品知识库**:产品信息+客户偏好+历史报价,迁移成本极高
3. **沉默客户模式算法**:跨用户行为数据产生的预测能力,网络效应
+4. **客户健康度评分模型**:基于多维度行为数据的客户价值评估,用户用得越久模型越准
+5. **AI跟进策略引擎**:基于历史跟进成功率的学习模型,持续优化时机/内容/渠道
---
@@ -244,7 +406,8 @@
| 阶段 | 时间 | 功能 |
|------|------|------|
-| MVP | 第1-4周 | 智能翻译+回复建议+基础营销素材+产品库 |
-| V2 | 第5-8周 | 沉默客户跟进+WhatsApp集成+报价单生成 |
-| V3 | 第9-12周 | 语料库训练+回复质量优化+多人协作 |
-| V4 | 第13-16周 | 跨用户A/B测试+预测算法+API开放 |
+| MVP | 第1-4周 | 智能翻译+回复建议+基础营销素材+产品库 ✅ |
+| V2 | 第5-8周 | 沉默客户跟进+WhatsApp集成+报价单生成 ✅ |
+| V3 | 第9-12周 | 语料库训练+回复质量优化+多人协作 ✅ |
+| V4 | 第13-16周 | 跨用户A/B测试+预测算法+API开放 ✅ |
+| **V5** | **第17-20周** | **客户健康度看板+智能跟进助手+市场机会分析** |
diff --git a/docs/TECH_ARCHITECTURE.md b/docs/TECH_ARCHITECTURE.md
index 09c3d12..01656b3 100644
--- a/docs/TECH_ARCHITECTURE.md
+++ b/docs/TECH_ARCHITECTURE.md
@@ -1,7 +1,8 @@
# 外贸小助手 (TradeMate) — 技术架构文档
-> 版本: v1.0
-> 创建日期: 2026-05-08
+> 版本: v1.1
+> 创建日期: 2026-05-10
+> 更新: 新增客户健康度看板、AI跟进助手、市场机会分析三大模块
---
@@ -34,6 +35,8 @@
│ │ - 翻译回复 │ │ - Router │ │ - Webhook │ │
│ │ - 客户跟进 │ │ - 语料库 │ │ - 会话管理 │ │
│ │ - 报价单 │ │ - 成本控制 │ │ │ │
+│ │ - 健康度 │ │ - 市场分析 │ │ │ │
+│ │ - 跟进引擎 │ │ - 跟进策略 │ │ │ │
│ └─────┬─────┘ └──────┬──────┘ └──────┬───────┘ │
│ │ │ │ │
│ ┌─────┴─────────────────┴─────────────────┴──────┐ │
@@ -99,6 +102,9 @@ trade-assistant/
│ │ │ ├── marketing.py # 营销素材生成
│ │ │ ├── translation.py # 翻译+回复引擎
│ │ │ ├── customer.py # 客户跟进引擎
+│ │ │ ├── customer_health.py # 客户健康度评分 (新增)
+│ │ │ ├── followup_engine.py # AI跟进策略引擎 (新增)
+│ │ │ ├── market_analysis.py # 市场机会分析 (新增)
│ │ │ ├── quotation.py # 报价单服务
│ │ │ └── whatsapp.py # WhatsApp 服务
│ │ │
@@ -325,7 +331,212 @@ WhatsApp User WhatsApp Cloud API TradeMate
---
-## 八、安全设计
+## 八、V2 新增模块架构
+
+### 8.1 客户健康度看板
+
+#### 评分模型
+
+```
+输入 → 多维度特征提取 → 加权评分 → 等级输出 → 行动建议
+```
+
+**评分维度与权重**:
+
+| 维度 | 权重 | 数据源 | 计算方式 |
+|------|------|--------|---------|
+| 响应趋势 | 25% | messages | 近7天平均回复时长 vs 前7天,趋势上升加分 |
+| 情感轨迹 | 20% | messages | AI分析最近3条客户消息情感极性(正面/负面/中性) |
+| 询盘深度 | 20% | messages | 是否包含MOQ/认证/FOB/证书等成交信号关键词 |
+| 沉默天数 | 20% | customers | 归一化得分: 1天=100, 14天+=0 |
+| 商业价值 | 15% | quotations | 历史成交金额 + 当前在谈金额 |
+
+**等级输出**:
+| 分数区间 | 等级 | 颜色 | 建议动作 |
+|---------|------|------|---------|
+| 80-100 | 活跃 | 🟢 绿 | 保持正常跟进 |
+| 50-79 | 需关注 | 🟡 黄 | 3天内安排跟进 |
+| 0-49 | 高危 | 🔴 红 | 立即跟进,提供优惠或新产品信息 |
+
+#### 技术实现
+
+- **无需新模型**: 使用现有 `messages`、`customers`、`quotations` 表
+- **轻量计算**: 无需 ML 模型,规则引擎即可,同步计算 < 50ms
+- **异步更新**: Celery 定时任务每小时更新一次评分缓存到 Redis
+- **API**: `GET /api/v1/customers/{id}/health` — 单个客户健康度
+- **API**: `GET /api/v1/customers/health-overview` — 全量概览
+
+### 8.2 AI 智能跟进引擎
+
+#### 架构
+
+```
+┌────────────────────────────────────────────────────────────┐
+│ FollowupEngine │
+│ │
+│ 1. 状态评估 │
+│ └─ 读客户健康度 → 判断是否到跟进时机 │
+│ │
+│ 2. 策略选择 │
+│ ├─ 沉默 3-5 天 → 温和提醒("Just checking in") │
+│ ├─ 沉默 6-10 天 → 价值提供("新品/行业资讯") │
+│ ├─ 沉默 11+ 天 → 重新激活("限时优惠/调查问卷") │
+│ └─ 客户有回复但未成交 → 促进决策("成功案例/限时") │
+│ │
+│ 3. 内容生成 │
+│ └─ AI 基于客户画像+产品库+历史对话生成个性化内容 │
+│ │
+│ 4. 渠道推荐 │
+│ ├─ 历史互动中 WhatsApp 回复率>60% → WhatsApp │
+│ ├─ 客户来自展会/邮件 → Email │
+│ └─ 紧急/高价商机 → 建议电话 │
+│ │
+│ 5. 效果追踪 │
+│ └─ 跟进后监控客户反应 → 更新策略模型 │
+└────────────────────────────────────────────────────────────┘
+```
+
+#### 技术实现
+
+- **跟进策略**: 基于规则的策略选择器(可扩展为强化学习)
+- **内容生成**: 复用现有 AI 营销素材能力,注入客户上下文
+- **渠道路由**: 基于 `customer.preference.channel` 和历史回复率
+- **定时任务**: Celery beat 每 6 小时轮检所有客户
+- **通知推送**: 检测到跟进时机 → 写入 notifications → PushService 推送
+
+#### 数据模型
+
+```python
+class FollowupStrategy(Base):
+ """跟进策略模板"""
+ __tablename__ = "followup_strategies"
+
+ id: UUID (PK)
+ user_id: UUID (nullable — null = 系统策略)
+ name: str # 策略名称
+ trigger_conditions: JSONB # 触发条件: {health_score_max, silence_days_min, ...}
+ channel: str # 推荐渠道
+ ai_prompt_template: str # AI提示词模板
+ min_interval_hours: int # 最短间隔
+ is_active: bool
+ success_count: int # 成功率统计
+ created_at: datetime
+```
+
+```python
+class FollowupLog(Base):
+ """跟进记录"""
+ __tablename__ = "followup_logs"
+
+ id: UUID (PK)
+ user_id: UUID
+ customer_id: UUID (FK)
+ strategy_id: UUID (FK → followup_strategies)
+ health_score_before: float # 跟进前健康分
+ suggested_content: Text # AI建议内容
+ actual_content: Text # 用户实际发送内容
+ channel: str # 使用的渠道
+ was_sent: bool # 是否已发送
+ customer_replied: bool # 客户是否回复
+ replied_within_hours: int # 回复间隔
+ created_at: datetime
+```
+
+### 8.3 智能市场机会分析
+
+#### 架构
+
+```
+┌────────────────────────────────────────────────────────────┐
+│ MarketAnalysisService │
+│ │
+│ 1. 市场趋势分析 │
+│ └─ 调取 UN COMTRADE / ITC TradeMap API → 目标国进口趋势 │
+│ │
+│ 2. 客户发现 │
+│ └─ Google Custom Search → B2B平台/行业黄页 → 潜在客户列表 │
+│ │
+│ 3. 竞争情报 │
+│ └─ AI 分析竞品定价/卖点/市场定位 │
+│ │
+│ 4. 策略报告 │
+│ └─ AI 综合生成: 市场选择→进入策略→风险提示→行动清单 │
+└────────────────────────────────────────────────────────────┘
+```
+
+#### 数据源集成
+
+| 数据源 | 集成方式 | 费率 |
+|--------|---------|------|
+| UN COMTRADE API | REST API (https://comtrade.un.org/api/) | 免费, 需注册 |
+| ITC TradeMap | SOAP/REST (https://www.trademap.org/) | 免费基础查询 |
+| Google Custom Search | JSON API (100次/天免费) | 免费~$5/1000次 |
+| SerpAPI | Google搜索结果结构化 | $50/月 起 |
+| AI 自有知识 | 已有模型知识 | 免费 |
+
+**核心原则**: 所有数据源有免费层,上线初期零数据采购成本。
+
+#### API 设计
+
+```
+POST /api/v1/market-analysis/opportunity
+ 输入: { product_name, description, category, target_markets? }
+ 输出: {
+ recommended_markets: [{ country, growth_rate, entry_strategy }],
+ potential_clients: [{ name, source, description }],
+ competitive_landscape: "分析文本",
+ strategy_report: "完整策略报告"
+ }
+
+GET /api/v1/market-analysis/reports
+ 列表用户历史报告
+
+GET /api/v1/market-analysis/reports/{id}
+ 获取报告详情
+```
+
+#### 关键设计决策
+
+- **异步生成**: 报告生成可能需要 30-60 秒,使用 Celery 异步任务
+- **缓存策略**: 相同产品+市场的报告缓存 7 天
+- **渐进式展示**: 前端先显示 loading,各个模块完成后逐步展示(趋势数据先出 → 客户发现 → 报告)
+- **用户引导**: 首次使用后提示"保存到产品库"或"一键生成开发信"
+
+---
+
+## 九、护城河策略(V2 更新)
+
+V1 有三层护城河,V2 增加两层:
+
+### 第 4 层: 客户健康度数据集
+
+```
+用户每日查看健康度 → 记录评分变化 → 验证建议有效性 → 优化评分模型
+ ↓
+ 跨用户匿名统计 → 行业基准对比
+ "你的客户健康度分布 vs 同行业用户的分布"
+```
+
+网络效应: 用户越多,行业基准越准,评分越有价值。
+
+### 第 5 层: AI跟进策略知识库
+
+```
+每次跟进记录:
+ ├─ 客户状态(评分/沉默天数/阶段)
+ ├─ 跟进策略(时机/内容/渠道)
+ ├─ 结果(回复/未回复/成交)
+ └─ 用户反馈
+
+→ 积累到 1000+ 条后:
+ ├─ 分析"什么策略在什么场景下最有效"
+ ├─ 自动优化策略选择器
+ └─ 新用户直接受益于"过去1000次跟进的最佳实践"
+```
+
+---
+
+## 十一、安全设计
| 维度 | 措施 |
|------|------|
@@ -338,7 +549,7 @@ WhatsApp User WhatsApp Cloud API TradeMate
---
-## 九、部署架构
+## 十二、部署架构
```
开发环境: docker-compose up(单机)
diff --git a/docs/UPGRADE_PLAN.md b/docs/UPGRADE_PLAN.md
new file mode 100644
index 0000000..8f88617
--- /dev/null
+++ b/docs/UPGRADE_PLAN.md
@@ -0,0 +1,195 @@
+# 外贸小助手 (TradeMate) — V2 改造升级计划
+
+> 版本: v1.0
+> 创建日期: 2026-05-10
+> 基于: 竞品调研 + 用户需求分析 + 现有代码审计
+
+---
+
+## 一、升级思路
+
+### 核心原则
+
+不堆功能,只做三件事:
+
+1. **帮用户做决策** — 不只看数据,给建议(健康度看板)
+2. **帮用户省时间** — 不让用户设规则,AI自动判断(跟进引擎)
+3. **帮用户找机会** — 不买海关数据,AI用公开信息分析(市场分析)
+
+### 和竞品的本质区别
+
+| 维度 | 竞品思路 | 我们的思路 |
+|------|---------|-----------|
+| 数据 | 买海关数据(贵,滞后) | 用免费公开数据 + AI分析 |
+| 流程 | 预设规则(固定时间/模板) | AI动态判断(时机/内容/渠道都个性化) |
+| 体验 | 给个分数让用户自己猜 | 给分数 + 原因 + 建议动作 |
+| 成本 | 年费几万起 + 邮件服务费 | 零外部采购成本,纯软件投入 |
+
+---
+
+## 二、功能一:客户健康度看板
+
+### 优先级
+
+**P0 — 先做这个**。数据已有,投入最小,用户感知最强。
+
+### 实现步骤
+
+| 步骤 | 内容 | 涉及文件 | 预估工时 |
+|------|------|---------|---------|
+| 1.1 | 实现 `CustomerHealthService` 评分引擎 | `backend/app/services/customer_health.py` | 4h |
+| 1.2 | 实现健康度 API 端点 | `backend/app/api/v1/customer_health.py` | 2h |
+| 1.3 | 注册路由到 main.py | `backend/app/main.py` | 0.5h |
+| 1.4 | 客户列表页顶部增加健康度概览卡片 | `uni-app/src/pages/customers/customers.vue` | 3h |
+| 1.5 | 客户详情页增加健康度看板 | `uni-app/src/pages/customers/customers.vue` detail modal | 3h |
+| 1.6 | Celery 定时任务每小时更新评分缓存 | `backend/app/workers/tasks.py` | 2h |
+| 1.7 | 前端 `api.js` 新增 healthApi | `uni-app/src/utils/api.js` | 0.5h |
+
+**总工时**: ~15h | **外部依赖**: 无
+
+### 评分模型(上线版)
+
+```
+health_score =
+ response_trend * 25% + # 响应趋势(messages数据)
+ sentiment_score * 20% + # 情感轨迹(AI分析最近3条消息)
+ inquiry_depth * 20% + # 询盘深度(关键词匹配MOQ/认证等)
+ silence_score * 20% + # 沉默天数(归一化)
+ business_value * 15% # 商业价值(报价单数据)
+```
+
+无需 ML 模型,规则引擎即可,同步计算 < 50ms。
+
+---
+
+## 三、功能二:AI 智能跟进助手
+
+### 优先级
+
+**P1 — 在看板之后做**。依赖健康度数据,但有跟进引擎才有闭环。
+
+### 实现步骤
+
+| 步骤 | 内容 | 涉及文件 | 预估工时 |
+|------|------|---------|---------|
+| 2.1 | 创建 `followup_strategies` 和 `followup_logs` 数据模型 | `backend/app/models/followup.py` | 2h |
+| 2.2 | 实现 `FollowupEngine` 策略选择器 + 内容生成 | `backend/app/services/followup_engine.py` | 6h |
+| 2.3 | 实现跟进 API 端点 | `backend/app/api/v1/followup.py` | 3h |
+| 2.4 | 注册路由到 main.py | `backend/app/main.py` | 0.5h |
+| 2.5 | Celery beat 定时轮检(每6h) | `backend/app/workers/tasks.py` | 2h |
+| 2.6 | 跟进提醒通知对接 PushService | `backend/app/services/followup_engine.py` | 2h |
+| 2.7 | Alembic 迁移脚本 | `backend/alembic/versions/` | 1h |
+| 2.8 | 前端跟进列表页 + 通知入口 | `uni-app/src/pages/followup/followup.vue` | 4h |
+| 2.9 | 首页待跟进卡片组件 | `uni-app/src/pages/index/index.vue` | 2h |
+| 2.10 | 前端 `api.js` 新增 followupApi | `uni-app/src/utils/api.js` | 0.5h |
+
+**总工时**: ~23h | **外部依赖**: 无(复用 AI 营销素材能力)
+
+### 跟进策略示例(初始版本)
+
+| 触发条件 | 策略 | 渠道 | AI提示词方向 |
+|---------|------|------|------------|
+| 沉默 3-5 天, 健康分 50-79 | 温和提醒 | WhatsApp | "Just checking in if you need any further information" |
+| 沉默 6-10 天, 健康分 30-49 | 价值提供 | Email | 推送新品目录/行业资讯/产品认证 |
+| 沉默 11+ 天, 健康分 <30 | 重新激活 | Email | 限时折扣/客户调查/节日问候 |
+| 客户有回复但未成交, 健康分 60+ | 促进决策 | WhatsApp | 成功案例/限时报价/差异优势 |
+
+---
+
+## 四、功能三:智能市场机会分析
+
+### 优先级
+
+**P2 — 最后做**。功能独立,用户价值高但实现复杂,涉及外部API集成。
+
+### 实现步骤
+
+| 步骤 | 内容 | 涉及文件 | 预估工时 |
+|------|------|---------|---------|
+| 3.1 | 创建 `market_reports` 数据模型 | `backend/app/models/market_report.py` | 1h |
+| 3.2 | 实现 UN COMTRADE API 集成 | `backend/app/services/market_data.py` | 3h |
+| 3.3 | 实现 Google Custom Search 集成 | `backend/app/services/market_data.py` | 2h |
+| 3.4 | 实现 `MarketAnalysisService` AI报告生成 | `backend/app/services/market_analysis.py` | 6h |
+| 3.5 | 实现市场分析 API 端点 | `backend/app/api/v1/market_analysis.py` | 3h |
+| 3.6 | 注册路由到 main.py | `backend/app/main.py` | 0.5h |
+| 3.7 | Celery 异步报告生成任务 | `backend/app/workers/tasks.py` | 2h |
+| 3.8 | Alembic 迁移脚本 | `backend/alembic/versions/` | 1h |
+| 3.9 | 前端市场分析页面 | `uni-app/src/pages/analysis/analysis.vue` | 4h |
+| 3.10 | 添加 pages.json 路由 | `uni-app/src/pages.json` | 0.5h |
+| 3.11 | 前端 `api.js` 新增 marketApi | `uni-app/src/utils/api.js` | 0.5h |
+
+**总工时**: ~23.5h | **外部依赖**: UN COMTRADE 免费API注册 + Google Custom Search API key
+
+### 数据源依赖
+
+| 数据源 | 注册成本 | 调用限制 | 是否需要 |
+|--------|---------|---------|---------|
+| UN COMTRADE API | 免费注册 | 无硬限制 | 必须(趋势数据核心) |
+| Google Custom Search | 免费(100次/天) | 100次/天 | 可选(客户发现增强) |
+| AI 自有知识 | 已有 | 无限制 | 必须(报告生成) |
+
+---
+
+## 五、工作量汇总与排期
+
+### 总览
+
+| 功能 | 优先级 | 工时期 | 外部依赖 | 建议开始 |
+|------|--------|-------|---------|---------|
+| 客户健康度看板 | P0 | 2天 | 无 | 第1天 |
+| AI 智能跟进助手 | P1 | 3天 | 无 | 第3天 |
+| 智能市场机会分析 | P2 | 3天 | UN COMTRADE + Google API | 第6天 |
+
+### 并行策略
+
+```
+第1-2天: 客户健康度看板 (后端1天 + 前端1天)
+ │
+第3-5天: AI 跟进助手 (后端2天 + 前端1天)
+ │
+第6-8天: 市场机会分析 (后端2天 + 前端1天)
+```
+
+总工期约 **8 天**(一人全栈),若有前后端分工可压缩至 **5-6 天**。
+
+### 外部依赖注册清单
+
+```
+□ UN COMTRADE API: https://comtrade.un.org/auth/register/
+□ Google Custom Search API: https://programmablesearchengine.google.com/
+ → 创建搜索引掣 → 获取 API Key + Search Engine ID
+```
+
+---
+
+## 六、验收标准
+
+### 健康度看板验收
+
+```
+□ 客户列表顶部展示健康度概览(活跃/需关注/高危数量)
+□ 每个客户展示健康等级标签(🟢/🟡/🔴)
+□ 点击客户进入详情 → 展示评分维度明细 + 建议动作
+□ 评分各维度有来源说明(不是黑盒)
+□ 首页展示总览数据
+```
+
+### AI跟进助手验收
+
+```
+□ 系统自动检测跟进时机 → 推送通知
+□ 跟进通知含客户背景摘要 + AI建议内容
+□ 用户可一键发送/编辑后发送
+□ 跟进后自动追踪客户回复状态
+□ 跟进记录可追溯
+```
+
+### 市场机会分析验收
+
+```
+□ 用户输入产品信息 → 30-60秒生成分析报告
+□ 报告含推荐市场 Top 3 + 潜在客户 + 策略建议
+□ 报告可保存到产品库
+□ 报告可一键跳转生成营销文案
+□ 渐进式加载(先展示趋势,逐步补充完整报告)
+```
diff --git a/uni-app/favicon.ico b/uni-app/favicon.ico
new file mode 100644
index 0000000..04cc4b9
Binary files /dev/null and b/uni-app/favicon.ico differ
diff --git a/uni-app/index.html b/uni-app/index.html
index 996fba8..cef7e67 100644
--- a/uni-app/index.html
+++ b/uni-app/index.html
@@ -3,7 +3,9 @@
- 外贸小助手
+ 外贸小助手 - TradeMate
+
+
diff --git a/uni-app/package-lock.json b/uni-app/package-lock.json
new file mode 100644
index 0000000..cbb3dd2
--- /dev/null
+++ b/uni-app/package-lock.json
@@ -0,0 +1,8060 @@
+{
+ "name": "trademate",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "trademate",
+ "version": "1.0.0",
+ "dependencies": {
+ "@dcloudio/uni-app": "3.0.0-4010520240507001",
+ "@dcloudio/uni-components": "3.0.0-4010520240507001",
+ "@dcloudio/uni-h5": "3.0.0-4010520240507001",
+ "@dcloudio/uni-mp-weixin": "3.0.0-4010520240507001",
+ "vue": "3.4.21"
+ },
+ "devDependencies": {
+ "@dcloudio/types": "3.4.8",
+ "@dcloudio/uni-cli-shared": "3.0.0-4010520240507001",
+ "@dcloudio/uni-stacktracey": "3.0.0-4010520240507001",
+ "@dcloudio/vite-plugin-uni": "3.0.0-4010520240507001",
+ "@vue/runtime-core": "3.4.21",
+ "sass": "^1.99.0",
+ "sass-embedded": "^1.99.0",
+ "vite": "5.2.8"
+ }
+ },
+ "node_modules/@ampproject/remapping": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmmirror.com/@ampproject/remapping/-/remapping-2.3.0.tgz",
+ "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@antfu/utils": {
+ "version": "0.7.10",
+ "resolved": "https://registry.npmmirror.com/@antfu/utils/-/utils-0.7.10.tgz",
+ "integrity": "sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.0.tgz",
+ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.3",
+ "resolved": "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.3.tgz",
+ "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.29.0.tgz",
+ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-module-transforms": "^7.28.6",
+ "@babel/helpers": "^7.28.6",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/traverse": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.1",
+ "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.1.tgz",
+ "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-annotate-as-pure": {
+ "version": "7.27.3",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz",
+ "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.27.3"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
+ "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.28.6",
+ "@babel/helper-validator-option": "^7.27.1",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-create-class-features-plugin": {
+ "version": "7.29.3",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.3.tgz",
+ "integrity": "sha512-RpLYy2sb51oNLjuu1iD3bwBqCBWUzjO0ocp+iaCP/lJtb2CPLcnC2Fftw+4sAzaMELGeWTgExSKADbdo0GFVzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.27.3",
+ "@babel/helper-member-expression-to-functions": "^7.28.5",
+ "@babel/helper-optimise-call-expression": "^7.27.1",
+ "@babel/helper-replace-supers": "^7.28.6",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
+ "@babel/traverse": "^7.29.0",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-create-regexp-features-plugin": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz",
+ "integrity": "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.27.3",
+ "regexpu-core": "^6.3.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-define-polyfill-provider": {
+ "version": "0.6.8",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz",
+ "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "debug": "^4.4.3",
+ "lodash.debounce": "^4.0.8",
+ "resolve": "^1.22.11"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+ "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-member-expression-to-functions": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz",
+ "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.28.5",
+ "@babel/types": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
+ "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
+ "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.28.6",
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "@babel/traverse": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-optimise-call-expression": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz",
+ "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
+ "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-remap-async-to-generator": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz",
+ "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.27.1",
+ "@babel/helper-wrap-function": "^7.27.1",
+ "@babel/traverse": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-replace-supers": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz",
+ "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-member-expression-to-functions": "^7.28.5",
+ "@babel/helper-optimise-call-expression": "^7.27.1",
+ "@babel/traverse": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-skip-transparent-expression-wrappers": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz",
+ "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.27.1",
+ "@babel/types": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-wrap-function": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz",
+ "integrity": "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.28.6",
+ "@babel/traverse": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.29.2.tgz",
+ "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.3",
+ "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.3.tgz",
+ "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.0"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz",
+ "integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1",
+ "@babel/traverse": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz",
+ "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz",
+ "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": {
+ "version": "7.29.3",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array/-/plugin-bugfix-safari-rest-destructuring-rhs-array-7.29.3.tgz",
+ "integrity": "sha512-SRS46DFR4HqzUzCVgi90/xMoL+zeBDBvWdKYXSEzh79kXswNFEglUpMKxR04//dPqwYXWUBJ3mpUd933ru9Kmg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz",
+ "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
+ "@babel/plugin-transform-optional-chaining": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.13.0"
+ }
+ },
+ "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.6.tgz",
+ "integrity": "sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/traverse": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-proposal-private-property-in-object": {
+ "version": "7.21.0-placeholder-for-preset-env.2",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz",
+ "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-import-assertions": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz",
+ "integrity": "sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-import-attributes": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz",
+ "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-import-meta": {
+ "version": "7.10.4",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz",
+ "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.10.4"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-jsx": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz",
+ "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-typescript": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz",
+ "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-unicode-sets-regex": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz",
+ "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-arrow-functions": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz",
+ "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-async-generator-functions": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.0.tgz",
+ "integrity": "sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/helper-remap-async-to-generator": "^7.27.1",
+ "@babel/traverse": "^7.29.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-async-to-generator": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz",
+ "integrity": "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/helper-remap-async-to-generator": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-block-scoped-functions": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz",
+ "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-block-scoping": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz",
+ "integrity": "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-class-properties": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz",
+ "integrity": "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-class-features-plugin": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-class-static-block": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz",
+ "integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-class-features-plugin": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.12.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-classes": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz",
+ "integrity": "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.27.3",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-globals": "^7.28.0",
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/helper-replace-supers": "^7.28.6",
+ "@babel/traverse": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-computed-properties": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz",
+ "integrity": "sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/template": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-destructuring": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz",
+ "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1",
+ "@babel/traverse": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-dotall-regex": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.28.6.tgz",
+ "integrity": "sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.28.5",
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-duplicate-keys": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz",
+ "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.0.tgz",
+ "integrity": "sha512-zBPcW2lFGxdiD8PUnPwJjag2J9otbcLQzvbiOzDxpYXyCuYX9agOwMPGn1prVH0a4qzhCKu24rlH4c1f7yA8rw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.28.5",
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-dynamic-import": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz",
+ "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-explicit-resource-management": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.6.tgz",
+ "integrity": "sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/plugin-transform-destructuring": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-exponentiation-operator": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.6.tgz",
+ "integrity": "sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-export-namespace-from": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz",
+ "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-for-of": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz",
+ "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-function-name": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz",
+ "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-compilation-targets": "^7.27.1",
+ "@babel/helper-plugin-utils": "^7.27.1",
+ "@babel/traverse": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-json-strings": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.28.6.tgz",
+ "integrity": "sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-literals": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz",
+ "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-logical-assignment-operators": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz",
+ "integrity": "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-member-expression-literals": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz",
+ "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-modules-amd": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz",
+ "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-transforms": "^7.27.1",
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-modules-commonjs": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz",
+ "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-transforms": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-modules-systemjs": {
+ "version": "7.29.4",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.4.tgz",
+ "integrity": "sha512-N7QmZ0xRZfjHOfZeQLJjwgX2zS9pdGHSVl/cjSGlo4dXMqvurfxXDMKY4RqEKzPozV78VMcd0lxyG13mlbKc4w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-transforms": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "@babel/traverse": "^7.29.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-modules-umd": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz",
+ "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-transforms": "^7.27.1",
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-named-capturing-groups-regex": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.0.tgz",
+ "integrity": "sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.28.5",
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-new-target": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz",
+ "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-nullish-coalescing-operator": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz",
+ "integrity": "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-numeric-separator": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz",
+ "integrity": "sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-object-rest-spread": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz",
+ "integrity": "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/plugin-transform-destructuring": "^7.28.5",
+ "@babel/plugin-transform-parameters": "^7.27.7",
+ "@babel/traverse": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-object-super": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz",
+ "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1",
+ "@babel/helper-replace-supers": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-optional-catch-binding": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz",
+ "integrity": "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-optional-chaining": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz",
+ "integrity": "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-parameters": {
+ "version": "7.27.7",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz",
+ "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-private-methods": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz",
+ "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-class-features-plugin": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-private-property-in-object": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz",
+ "integrity": "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.27.3",
+ "@babel/helper-create-class-features-plugin": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-property-literals": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz",
+ "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-regenerator": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.0.tgz",
+ "integrity": "sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-regexp-modifiers": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.28.6.tgz",
+ "integrity": "sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.28.5",
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-reserved-words": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz",
+ "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-shorthand-properties": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz",
+ "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-spread": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz",
+ "integrity": "sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-sticky-regex": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz",
+ "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-template-literals": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz",
+ "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-typeof-symbol": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz",
+ "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-typescript": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz",
+ "integrity": "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.27.3",
+ "@babel/helper-create-class-features-plugin": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
+ "@babel/plugin-syntax-typescript": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-unicode-escapes": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz",
+ "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-unicode-property-regex": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.28.6.tgz",
+ "integrity": "sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.28.5",
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-unicode-regex": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz",
+ "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.27.1",
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-unicode-sets-regex": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.28.6.tgz",
+ "integrity": "sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.28.5",
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/preset-env": {
+ "version": "7.29.5",
+ "resolved": "https://registry.npmmirror.com/@babel/preset-env/-/preset-env-7.29.5.tgz",
+ "integrity": "sha512-/69t2aEzGKHD76DyLbHysF/QH2LJOB8iFnYO37unDTKBTubzcMRv0f3H5EiN1Q6ajOd/eB7dAInF0qdFVS06kA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.29.3",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/helper-validator-option": "^7.27.1",
+ "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.28.5",
+ "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1",
+ "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1",
+ "@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": "^7.29.3",
+ "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1",
+ "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.6",
+ "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2",
+ "@babel/plugin-syntax-import-assertions": "^7.28.6",
+ "@babel/plugin-syntax-import-attributes": "^7.28.6",
+ "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6",
+ "@babel/plugin-transform-arrow-functions": "^7.27.1",
+ "@babel/plugin-transform-async-generator-functions": "^7.29.0",
+ "@babel/plugin-transform-async-to-generator": "^7.28.6",
+ "@babel/plugin-transform-block-scoped-functions": "^7.27.1",
+ "@babel/plugin-transform-block-scoping": "^7.28.6",
+ "@babel/plugin-transform-class-properties": "^7.28.6",
+ "@babel/plugin-transform-class-static-block": "^7.28.6",
+ "@babel/plugin-transform-classes": "^7.28.6",
+ "@babel/plugin-transform-computed-properties": "^7.28.6",
+ "@babel/plugin-transform-destructuring": "^7.28.5",
+ "@babel/plugin-transform-dotall-regex": "^7.28.6",
+ "@babel/plugin-transform-duplicate-keys": "^7.27.1",
+ "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.0",
+ "@babel/plugin-transform-dynamic-import": "^7.27.1",
+ "@babel/plugin-transform-explicit-resource-management": "^7.28.6",
+ "@babel/plugin-transform-exponentiation-operator": "^7.28.6",
+ "@babel/plugin-transform-export-namespace-from": "^7.27.1",
+ "@babel/plugin-transform-for-of": "^7.27.1",
+ "@babel/plugin-transform-function-name": "^7.27.1",
+ "@babel/plugin-transform-json-strings": "^7.28.6",
+ "@babel/plugin-transform-literals": "^7.27.1",
+ "@babel/plugin-transform-logical-assignment-operators": "^7.28.6",
+ "@babel/plugin-transform-member-expression-literals": "^7.27.1",
+ "@babel/plugin-transform-modules-amd": "^7.27.1",
+ "@babel/plugin-transform-modules-commonjs": "^7.28.6",
+ "@babel/plugin-transform-modules-systemjs": "^7.29.4",
+ "@babel/plugin-transform-modules-umd": "^7.27.1",
+ "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.0",
+ "@babel/plugin-transform-new-target": "^7.27.1",
+ "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6",
+ "@babel/plugin-transform-numeric-separator": "^7.28.6",
+ "@babel/plugin-transform-object-rest-spread": "^7.28.6",
+ "@babel/plugin-transform-object-super": "^7.27.1",
+ "@babel/plugin-transform-optional-catch-binding": "^7.28.6",
+ "@babel/plugin-transform-optional-chaining": "^7.28.6",
+ "@babel/plugin-transform-parameters": "^7.27.7",
+ "@babel/plugin-transform-private-methods": "^7.28.6",
+ "@babel/plugin-transform-private-property-in-object": "^7.28.6",
+ "@babel/plugin-transform-property-literals": "^7.27.1",
+ "@babel/plugin-transform-regenerator": "^7.29.0",
+ "@babel/plugin-transform-regexp-modifiers": "^7.28.6",
+ "@babel/plugin-transform-reserved-words": "^7.27.1",
+ "@babel/plugin-transform-shorthand-properties": "^7.27.1",
+ "@babel/plugin-transform-spread": "^7.28.6",
+ "@babel/plugin-transform-sticky-regex": "^7.27.1",
+ "@babel/plugin-transform-template-literals": "^7.27.1",
+ "@babel/plugin-transform-typeof-symbol": "^7.27.1",
+ "@babel/plugin-transform-unicode-escapes": "^7.27.1",
+ "@babel/plugin-transform-unicode-property-regex": "^7.28.6",
+ "@babel/plugin-transform-unicode-regex": "^7.27.1",
+ "@babel/plugin-transform-unicode-sets-regex": "^7.28.6",
+ "@babel/preset-modules": "0.1.6-no-external-plugins",
+ "babel-plugin-polyfill-corejs2": "^0.4.15",
+ "babel-plugin-polyfill-corejs3": "^0.14.0",
+ "babel-plugin-polyfill-regenerator": "^0.6.6",
+ "core-js-compat": "^3.48.0",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/preset-modules": {
+ "version": "0.1.6-no-external-plugins",
+ "resolved": "https://registry.npmmirror.com/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz",
+ "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.0.0",
+ "@babel/types": "^7.4.4",
+ "esutils": "^2.0.2"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.2.tgz",
+ "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz",
+ "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.28.6",
+ "@babel/parser": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.0.tgz",
+ "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-globals": "^7.28.0",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz",
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@bufbuild/protobuf": {
+ "version": "2.12.0",
+ "resolved": "https://registry.npmmirror.com/@bufbuild/protobuf/-/protobuf-2.12.0.tgz",
+ "integrity": "sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA==",
+ "dev": true,
+ "license": "(Apache-2.0 AND BSD-3-Clause)"
+ },
+ "node_modules/@dcloudio/types": {
+ "version": "3.4.8",
+ "resolved": "https://registry.npmmirror.com/@dcloudio/types/-/types-3.4.8.tgz",
+ "integrity": "sha512-IPXuoghLv7qNPOnRuP7vC5++MdRHhE0U7EMw9ia//uOh69fFXZiRTfoHd51+nzciD6R50gqYhbrCCZIxnxhM9Q==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@dcloudio/uni-app": {
+ "version": "3.0.0-4010520240507001",
+ "resolved": "https://registry.npmmirror.com/@dcloudio/uni-app/-/uni-app-3.0.0-4010520240507001.tgz",
+ "integrity": "sha512-yMTa35qT+GKl/U5wh/8T+70mix1zr7VGXlBEKMSsI5a5C7iEE+8Jj298QbRLimiksvMn01QHAEuMVeoio31buw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@dcloudio/uni-cloud": "3.0.0-4010520240507001",
+ "@dcloudio/uni-components": "3.0.0-4010520240507001",
+ "@dcloudio/uni-i18n": "3.0.0-4010520240507001",
+ "@dcloudio/uni-push": "3.0.0-4010520240507001",
+ "@dcloudio/uni-shared": "3.0.0-4010520240507001",
+ "@dcloudio/uni-stat": "3.0.0-4010520240507001",
+ "@vue/shared": "3.4.21"
+ },
+ "peerDependencies": {
+ "@dcloudio/types": "^3.4.8"
+ }
+ },
+ "node_modules/@dcloudio/uni-cli-shared": {
+ "version": "3.0.0-4010520240507001",
+ "resolved": "https://registry.npmmirror.com/@dcloudio/uni-cli-shared/-/uni-cli-shared-3.0.0-4010520240507001.tgz",
+ "integrity": "sha512-TB8yf4zAVNY4FWUPt7MRw5p26xmK+QmScVc4m8caRlUG9BQRPG8/ZDgalC5/o6nXhPnm+vj3/jbGYxRCfCL/9A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@ampproject/remapping": "^2.1.2",
+ "@babel/code-frame": "^7.23.5",
+ "@babel/core": "^7.23.3",
+ "@babel/parser": "^7.23.9",
+ "@babel/types": "^7.20.7",
+ "@dcloudio/uni-i18n": "3.0.0-4010520240507001",
+ "@dcloudio/uni-shared": "3.0.0-4010520240507001",
+ "@intlify/core-base": "9.1.9",
+ "@intlify/shared": "9.1.9",
+ "@intlify/vue-devtools": "9.1.9",
+ "@rollup/pluginutils": "^5.0.5",
+ "@vue/compiler-core": "3.4.21",
+ "@vue/compiler-dom": "3.4.21",
+ "@vue/compiler-sfc": "3.4.21",
+ "@vue/compiler-ssr": "3.4.21",
+ "@vue/server-renderer": "3.4.21",
+ "@vue/shared": "3.4.21",
+ "autoprefixer": "^10.4.19",
+ "base64url": "^3.0.1",
+ "chokidar": "^3.5.3",
+ "compare-versions": "^3.6.0",
+ "debug": "^4.3.3",
+ "es-module-lexer": "^1.2.1",
+ "esbuild": "^0.20.1",
+ "estree-walker": "^2.0.2",
+ "fast-glob": "^3.2.11",
+ "fs-extra": "^10.0.0",
+ "hash-sum": "^2.0.0",
+ "jsonc-parser": "^3.2.0",
+ "lines-and-columns": "^2.0.4",
+ "magic-string": "^0.30.7",
+ "merge": "^2.1.1",
+ "mime": "^3.0.0",
+ "module-alias": "^2.2.2",
+ "os-locale-s-fix": "^1.0.8-fix-1",
+ "picocolors": "^1.0.0",
+ "postcss-import": "^14.0.2",
+ "postcss-load-config": "^3.1.1",
+ "postcss-modules": "^4.3.0",
+ "postcss-selector-parser": "^6.0.6",
+ "resolve": "^1.22.1",
+ "source-map-js": "^1.0.2",
+ "tapable": "^2.2.0",
+ "unplugin-auto-import": "^0.16.7",
+ "xregexp": "3.1.0"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@dcloudio/uni-cli-shared/node_modules/unplugin-auto-import": {
+ "version": "0.16.7",
+ "resolved": "https://registry.npmmirror.com/unplugin-auto-import/-/unplugin-auto-import-0.16.7.tgz",
+ "integrity": "sha512-w7XmnRlchq6YUFJVFGSvG1T/6j8GrdYN6Em9Wf0Ye+HXgD/22kont+WnuCAA0UaUoxtuvRR1u/mXKy63g/hfqQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@antfu/utils": "^0.7.6",
+ "@rollup/pluginutils": "^5.0.5",
+ "fast-glob": "^3.3.1",
+ "local-pkg": "^0.5.0",
+ "magic-string": "^0.30.5",
+ "minimatch": "^9.0.3",
+ "unimport": "^3.4.0",
+ "unplugin": "^1.5.0"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ },
+ "peerDependencies": {
+ "@nuxt/kit": "^3.2.2",
+ "@vueuse/core": "*"
+ },
+ "peerDependenciesMeta": {
+ "@nuxt/kit": {
+ "optional": true
+ },
+ "@vueuse/core": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@dcloudio/uni-cloud": {
+ "version": "3.0.0-4010520240507001",
+ "resolved": "https://registry.npmmirror.com/@dcloudio/uni-cloud/-/uni-cloud-3.0.0-4010520240507001.tgz",
+ "integrity": "sha512-STJQOhi6XNCob4msDrQvFrVmjPGIhDvxnHa2X7OLRqGrNhOkAe5Rcnsg2THA/CPAnqfT7xIYARvr9Jqb1z+8Mg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@dcloudio/uni-cli-shared": "3.0.0-4010520240507001",
+ "@dcloudio/uni-i18n": "3.0.0-4010520240507001",
+ "@dcloudio/uni-shared": "3.0.0-4010520240507001",
+ "@vue/shared": "3.4.21",
+ "fast-glob": "^3.2.11"
+ }
+ },
+ "node_modules/@dcloudio/uni-components": {
+ "version": "3.0.0-4010520240507001",
+ "resolved": "https://registry.npmmirror.com/@dcloudio/uni-components/-/uni-components-3.0.0-4010520240507001.tgz",
+ "integrity": "sha512-MTy3Fe0d/AAsy8TN8adAEFpDu/805l5MSV1FBckzhiF6QoCdl3ammAe3Z2uxrR6rFHMGnKfgfgjhYvyK8BAD7Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@dcloudio/uni-cloud": "3.0.0-4010520240507001",
+ "@dcloudio/uni-h5": "3.0.0-4010520240507001",
+ "@dcloudio/uni-i18n": "3.0.0-4010520240507001"
+ }
+ },
+ "node_modules/@dcloudio/uni-h5": {
+ "version": "3.0.0-4010520240507001",
+ "resolved": "https://registry.npmmirror.com/@dcloudio/uni-h5/-/uni-h5-3.0.0-4010520240507001.tgz",
+ "integrity": "sha512-dk1wIlPBtt8wwDkez/mD+JpTbFviANbf4V4IjSndYG5voqkfcCc4D1giKyL7JTuv2xFaDAYp8/UqPRrSV47qiQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@dcloudio/uni-h5-vite": "3.0.0-4010520240507001",
+ "@dcloudio/uni-h5-vue": "3.0.0-4010520240507001",
+ "@dcloudio/uni-i18n": "3.0.0-4010520240507001",
+ "@dcloudio/uni-shared": "3.0.0-4010520240507001",
+ "@vue/server-renderer": "3.4.21",
+ "@vue/shared": "3.4.21",
+ "debug": "^4.3.3",
+ "localstorage-polyfill": "^1.0.1",
+ "postcss-selector-parser": "^6.0.6",
+ "safe-area-insets": "^1.4.1",
+ "vue-router": "^4.3.0",
+ "xmlhttprequest": "^1.8.0"
+ }
+ },
+ "node_modules/@dcloudio/uni-h5-vite": {
+ "version": "3.0.0-4010520240507001",
+ "resolved": "https://registry.npmmirror.com/@dcloudio/uni-h5-vite/-/uni-h5-vite-3.0.0-4010520240507001.tgz",
+ "integrity": "sha512-HWAr97tS2kreYkAPuaHk59Doa80Dgg8RV0lZNmdWfcnXsip+MCZ+Zs5GDfty5osy2dAtSWqbzZ2C9HLS8O64uw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@dcloudio/uni-cli-shared": "3.0.0-4010520240507001",
+ "@dcloudio/uni-shared": "3.0.0-4010520240507001",
+ "@rollup/pluginutils": "^5.0.5",
+ "@vue/compiler-dom": "3.4.21",
+ "@vue/compiler-sfc": "3.4.21",
+ "@vue/server-renderer": "3.4.21",
+ "@vue/shared": "3.4.21",
+ "debug": "^4.3.3",
+ "fs-extra": "^10.0.0",
+ "mime": "^3.0.0",
+ "module-alias": "^2.2.2"
+ }
+ },
+ "node_modules/@dcloudio/uni-h5-vue": {
+ "version": "3.0.0-4010520240507001",
+ "resolved": "https://registry.npmmirror.com/@dcloudio/uni-h5-vue/-/uni-h5-vue-3.0.0-4010520240507001.tgz",
+ "integrity": "sha512-HuhUxeKkbNZvqr5uLH2zM328qgo3W2l7Lv1K1GKMUhDjn8YWjaKrQU5taPcBKNQPY15Do3z+5Y2EKPkyVthGkg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@dcloudio/uni-shared": "3.0.0-4010520240507001",
+ "@vue/server-renderer": "3.4.21"
+ }
+ },
+ "node_modules/@dcloudio/uni-h5/node_modules/@vue/compiler-core": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.34.tgz",
+ "integrity": "sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/parser": "^7.29.3",
+ "@vue/shared": "3.5.34",
+ "entities": "^7.0.1",
+ "estree-walker": "^2.0.2",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@dcloudio/uni-h5/node_modules/@vue/compiler-core/node_modules/@vue/shared": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.34.tgz",
+ "integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==",
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/@dcloudio/uni-h5/node_modules/@vue/compiler-dom": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz",
+ "integrity": "sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@vue/compiler-core": "3.5.34",
+ "@vue/shared": "3.5.34"
+ }
+ },
+ "node_modules/@dcloudio/uni-h5/node_modules/@vue/compiler-dom/node_modules/@vue/shared": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.34.tgz",
+ "integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==",
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/@dcloudio/uni-h5/node_modules/@vue/compiler-sfc": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz",
+ "integrity": "sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/parser": "^7.29.3",
+ "@vue/compiler-core": "3.5.34",
+ "@vue/compiler-dom": "3.5.34",
+ "@vue/compiler-ssr": "3.5.34",
+ "@vue/shared": "3.5.34",
+ "estree-walker": "^2.0.2",
+ "magic-string": "^0.30.21",
+ "postcss": "^8.5.14",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@dcloudio/uni-h5/node_modules/@vue/compiler-sfc/node_modules/@vue/shared": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.34.tgz",
+ "integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==",
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/@dcloudio/uni-h5/node_modules/@vue/compiler-ssr": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.34.tgz",
+ "integrity": "sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.34",
+ "@vue/shared": "3.5.34"
+ }
+ },
+ "node_modules/@dcloudio/uni-h5/node_modules/@vue/compiler-ssr/node_modules/@vue/shared": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.34.tgz",
+ "integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==",
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/@dcloudio/uni-h5/node_modules/@vue/reactivity": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.34.tgz",
+ "integrity": "sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@vue/shared": "3.5.34"
+ }
+ },
+ "node_modules/@dcloudio/uni-h5/node_modules/@vue/reactivity/node_modules/@vue/shared": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.34.tgz",
+ "integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==",
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/@dcloudio/uni-h5/node_modules/@vue/runtime-core": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.34.tgz",
+ "integrity": "sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@vue/reactivity": "3.5.34",
+ "@vue/shared": "3.5.34"
+ }
+ },
+ "node_modules/@dcloudio/uni-h5/node_modules/@vue/runtime-core/node_modules/@vue/shared": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.34.tgz",
+ "integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==",
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/@dcloudio/uni-h5/node_modules/@vue/runtime-dom": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz",
+ "integrity": "sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@vue/reactivity": "3.5.34",
+ "@vue/runtime-core": "3.5.34",
+ "@vue/shared": "3.5.34",
+ "csstype": "^3.2.3"
+ }
+ },
+ "node_modules/@dcloudio/uni-h5/node_modules/@vue/runtime-dom/node_modules/@vue/shared": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.34.tgz",
+ "integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==",
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/@dcloudio/uni-h5/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",
+ "peer": true,
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/@dcloudio/uni-h5/node_modules/vue": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.34.tgz",
+ "integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.34",
+ "@vue/compiler-sfc": "3.5.34",
+ "@vue/runtime-dom": "3.5.34",
+ "@vue/server-renderer": "3.5.34",
+ "@vue/shared": "3.5.34"
+ },
+ "peerDependencies": {
+ "typescript": "*"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@dcloudio/uni-h5/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/@dcloudio/uni-h5/node_modules/vue/node_modules/@vue/server-renderer": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.34.tgz",
+ "integrity": "sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@vue/compiler-ssr": "3.5.34",
+ "@vue/shared": "3.5.34"
+ },
+ "peerDependencies": {
+ "vue": "3.5.34"
+ }
+ },
+ "node_modules/@dcloudio/uni-h5/node_modules/vue/node_modules/@vue/shared": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.34.tgz",
+ "integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==",
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/@dcloudio/uni-i18n": {
+ "version": "3.0.0-4010520240507001",
+ "resolved": "https://registry.npmmirror.com/@dcloudio/uni-i18n/-/uni-i18n-3.0.0-4010520240507001.tgz",
+ "integrity": "sha512-yaNbb8k8P+u/etevgtL+h5wAsR9SIknc37kRcfiYDyyk0UMOeOQ2vTFA32j1eB/WqHFoHRz/38QUjKk5sFLlzA==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@dcloudio/uni-mp-compiler": {
+ "version": "3.0.0-4010520240507001",
+ "resolved": "https://registry.npmmirror.com/@dcloudio/uni-mp-compiler/-/uni-mp-compiler-3.0.0-4010520240507001.tgz",
+ "integrity": "sha512-9gjb1zY05BhHnZXwwyPdAYlwztBJS/wEefu+SCR3NzCPUXi8tGX5agjlfe4XwlHQefA6vM8xGYgM2sEgtQn0Lg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@babel/generator": "^7.20.5",
+ "@babel/parser": "^7.23.9",
+ "@babel/types": "^7.20.7",
+ "@dcloudio/uni-cli-shared": "3.0.0-4010520240507001",
+ "@dcloudio/uni-shared": "3.0.0-4010520240507001",
+ "@vue/compiler-core": "3.4.21",
+ "@vue/compiler-dom": "3.4.21",
+ "@vue/shared": "3.4.21",
+ "estree-walker": "^2.0.2"
+ }
+ },
+ "node_modules/@dcloudio/uni-mp-vite": {
+ "version": "3.0.0-4010520240507001",
+ "resolved": "https://registry.npmmirror.com/@dcloudio/uni-mp-vite/-/uni-mp-vite-3.0.0-4010520240507001.tgz",
+ "integrity": "sha512-rOkcYjxODSb7E2DAOTsC4B0aS9mXyul/P0cpbB4luVNdBO5Fm/IkbRCqQE3eQAK16ebi/GcKReD6yXpzSwNTPw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@dcloudio/uni-cli-shared": "3.0.0-4010520240507001",
+ "@dcloudio/uni-i18n": "3.0.0-4010520240507001",
+ "@dcloudio/uni-mp-compiler": "3.0.0-4010520240507001",
+ "@dcloudio/uni-mp-vue": "3.0.0-4010520240507001",
+ "@dcloudio/uni-shared": "3.0.0-4010520240507001",
+ "@vue/compiler-sfc": "3.4.21",
+ "@vue/shared": "3.4.21",
+ "debug": "^4.3.3"
+ }
+ },
+ "node_modules/@dcloudio/uni-mp-vue": {
+ "version": "3.0.0-4010520240507001",
+ "resolved": "https://registry.npmmirror.com/@dcloudio/uni-mp-vue/-/uni-mp-vue-3.0.0-4010520240507001.tgz",
+ "integrity": "sha512-nZRlp8YPAP3NeTziH9NNZpAuMIrs/F7LlCzZzjqwNfFzTei/t8rjtF2busIlBTLlZZ9PqRG/0DE/lkApEuB94g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@dcloudio/uni-shared": "3.0.0-4010520240507001",
+ "@vue/shared": "3.4.21"
+ }
+ },
+ "node_modules/@dcloudio/uni-mp-weixin": {
+ "version": "3.0.0-4010520240507001",
+ "resolved": "https://registry.npmmirror.com/@dcloudio/uni-mp-weixin/-/uni-mp-weixin-3.0.0-4010520240507001.tgz",
+ "integrity": "sha512-shRLCP6MCzDpI0rRh/4UL8XpP87gAAt/g47zMBrP2zgqqgVR3/AE0PQxIG5t3FtaSpc9DxbBm9L5JlTvwJmwrA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@dcloudio/uni-cli-shared": "3.0.0-4010520240507001",
+ "@dcloudio/uni-mp-vite": "3.0.0-4010520240507001",
+ "@dcloudio/uni-mp-vue": "3.0.0-4010520240507001",
+ "@dcloudio/uni-shared": "3.0.0-4010520240507001",
+ "@vue/shared": "3.4.21",
+ "jimp": "^0.10.1",
+ "licia": "^1.29.0",
+ "qrcode-reader": "^1.0.4",
+ "qrcode-terminal": "^0.12.0",
+ "ws": "^8.4.2"
+ }
+ },
+ "node_modules/@dcloudio/uni-push": {
+ "version": "3.0.0-4010520240507001",
+ "resolved": "https://registry.npmmirror.com/@dcloudio/uni-push/-/uni-push-3.0.0-4010520240507001.tgz",
+ "integrity": "sha512-1Fc7PdD9weLOmYZcaGEGkMQ6xTl9MlOnvmTxDBszNEFHHnA+CTo/+1gEBeWjNQnueOleZZx4CLjn/5ppFZglog==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@dcloudio/uni-cli-shared": "3.0.0-4010520240507001"
+ }
+ },
+ "node_modules/@dcloudio/uni-shared": {
+ "version": "3.0.0-4010520240507001",
+ "resolved": "https://registry.npmmirror.com/@dcloudio/uni-shared/-/uni-shared-3.0.0-4010520240507001.tgz",
+ "integrity": "sha512-cfEN8lQJetitXQ3tywHCxDjY6PsSuefYWQMGCEoctP0xfFcuwZRaa4f2MbG+VgTFV4hZTJAi89dVVnhqrynz3A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@vue/shared": "3.4.21"
+ }
+ },
+ "node_modules/@dcloudio/uni-stacktracey": {
+ "version": "3.0.0-4010520240507001",
+ "resolved": "https://registry.npmmirror.com/@dcloudio/uni-stacktracey/-/uni-stacktracey-3.0.0-4010520240507001.tgz",
+ "integrity": "sha512-Xpnng1NQ7vh6e/pN7fPdzr0OtiY3cznAksJT9AULlhGr5l8M1e1DpeoUNCFaLHcyHcfRVD77ksxVLOkiArmQOA==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/@dcloudio/uni-stat": {
+ "version": "3.0.0-4010520240507001",
+ "resolved": "https://registry.npmmirror.com/@dcloudio/uni-stat/-/uni-stat-3.0.0-4010520240507001.tgz",
+ "integrity": "sha512-XcFc6CaE37GRljIoejkzbgIPkM1Gy9fqRvSUx01QJdpDcVAzBCjb5n39WE6DTJn85fglP/ShuusFueFodasqkQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@dcloudio/uni-cli-shared": "3.0.0-4010520240507001",
+ "@dcloudio/uni-shared": "3.0.0-4010520240507001",
+ "debug": "^4.3.3"
+ }
+ },
+ "node_modules/@dcloudio/vite-plugin-uni": {
+ "version": "3.0.0-4010520240507001",
+ "resolved": "https://registry.npmmirror.com/@dcloudio/vite-plugin-uni/-/vite-plugin-uni-3.0.0-4010520240507001.tgz",
+ "integrity": "sha512-7fsPM3iUo6+TLIlgrXHYKBHpTNfO2VVqhTVZ/A3yF1ddsGUQw8ekTgqpFbHJJ1kWl3CbwBROooq8yMGbNybZBA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@babel/core": "^7.23.3",
+ "@babel/plugin-syntax-import-meta": "^7.10.4",
+ "@babel/plugin-transform-typescript": "^7.23.3",
+ "@dcloudio/uni-cli-shared": "3.0.0-4010520240507001",
+ "@dcloudio/uni-shared": "3.0.0-4010520240507001",
+ "@rollup/pluginutils": "^5.0.5",
+ "@vitejs/plugin-legacy": "^5.3.2",
+ "@vitejs/plugin-vue": "^5.0.4",
+ "@vitejs/plugin-vue-jsx": "^3.1.0",
+ "@vue/compiler-core": "3.4.21",
+ "@vue/compiler-dom": "3.4.21",
+ "@vue/compiler-sfc": "3.4.21",
+ "@vue/shared": "3.4.21",
+ "cac": "6.7.9",
+ "debug": "^4.3.3",
+ "estree-walker": "^2.0.2",
+ "express": "^4.17.1",
+ "fast-glob": "^3.2.11",
+ "fs-extra": "^10.0.0",
+ "hash-sum": "^2.0.0",
+ "jsonc-parser": "^3.2.0",
+ "magic-string": "^0.30.7",
+ "picocolors": "^1.0.0",
+ "terser": "^5.4.0",
+ "unplugin-auto-import": "^0.16.7"
+ },
+ "bin": {
+ "uni": "bin/uni.js"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^5.2.8"
+ }
+ },
+ "node_modules/@dcloudio/vite-plugin-uni/node_modules/unplugin-auto-import": {
+ "version": "0.16.7",
+ "resolved": "https://registry.npmmirror.com/unplugin-auto-import/-/unplugin-auto-import-0.16.7.tgz",
+ "integrity": "sha512-w7XmnRlchq6YUFJVFGSvG1T/6j8GrdYN6Em9Wf0Ye+HXgD/22kont+WnuCAA0UaUoxtuvRR1u/mXKy63g/hfqQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@antfu/utils": "^0.7.6",
+ "@rollup/pluginutils": "^5.0.5",
+ "fast-glob": "^3.3.1",
+ "local-pkg": "^0.5.0",
+ "magic-string": "^0.30.5",
+ "minimatch": "^9.0.3",
+ "unimport": "^3.4.0",
+ "unplugin": "^1.5.0"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ },
+ "peerDependencies": {
+ "@nuxt/kit": "^3.2.2",
+ "@vueuse/core": "*"
+ },
+ "peerDependenciesMeta": {
+ "@nuxt/kit": {
+ "optional": true
+ },
+ "@vueuse/core": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz",
+ "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.20.2.tgz",
+ "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz",
+ "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.20.2.tgz",
+ "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz",
+ "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz",
+ "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz",
+ "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz",
+ "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz",
+ "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz",
+ "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz",
+ "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz",
+ "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz",
+ "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz",
+ "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz",
+ "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz",
+ "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz",
+ "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz",
+ "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz",
+ "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz",
+ "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz",
+ "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz",
+ "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz",
+ "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@intlify/core-base": {
+ "version": "9.1.9",
+ "resolved": "https://registry.npmmirror.com/@intlify/core-base/-/core-base-9.1.9.tgz",
+ "integrity": "sha512-x5T0p/Ja0S8hs5xs+ImKyYckVkL4CzcEXykVYYV6rcbXxJTe2o58IquSqX9bdncVKbRZP7GlBU1EcRaQEEJ+vw==",
+ "license": "MIT",
+ "dependencies": {
+ "@intlify/devtools-if": "9.1.9",
+ "@intlify/message-compiler": "9.1.9",
+ "@intlify/message-resolver": "9.1.9",
+ "@intlify/runtime": "9.1.9",
+ "@intlify/shared": "9.1.9",
+ "@intlify/vue-devtools": "9.1.9"
+ },
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@intlify/devtools-if": {
+ "version": "9.1.9",
+ "resolved": "https://registry.npmmirror.com/@intlify/devtools-if/-/devtools-if-9.1.9.tgz",
+ "integrity": "sha512-oKSMKjttG3Ut/1UGEZjSdghuP3fwA15zpDPcjkf/1FjlOIm6uIBGMNS5jXzsZy593u+P/YcnrZD6cD3IVFz9vQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@intlify/shared": "9.1.9"
+ },
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@intlify/message-compiler": {
+ "version": "9.1.9",
+ "resolved": "https://registry.npmmirror.com/@intlify/message-compiler/-/message-compiler-9.1.9.tgz",
+ "integrity": "sha512-6YgCMF46Xd0IH2hMRLCssZI3gFG4aywidoWQ3QP4RGYQXQYYfFC54DxhSgfIPpVoPLQ+4AD29eoYmhiHZ+qLFQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@intlify/message-resolver": "9.1.9",
+ "@intlify/shared": "9.1.9",
+ "source-map": "0.6.1"
+ },
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@intlify/message-resolver": {
+ "version": "9.1.9",
+ "resolved": "https://registry.npmmirror.com/@intlify/message-resolver/-/message-resolver-9.1.9.tgz",
+ "integrity": "sha512-Lx/DBpigeK0sz2BBbzv5mu9/dAlt98HxwbG7xLawC3O2xMF9MNWU5FtOziwYG6TDIjNq0O/3ZbOJAxwITIWXEA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@intlify/runtime": {
+ "version": "9.1.9",
+ "resolved": "https://registry.npmmirror.com/@intlify/runtime/-/runtime-9.1.9.tgz",
+ "integrity": "sha512-XgPw8+UlHCiie3fI41HPVa/VDJb3/aSH7bLhY1hJvlvNV713PFtb4p4Jo+rlE0gAoMsMCGcsiT982fImolSltg==",
+ "license": "MIT",
+ "dependencies": {
+ "@intlify/message-compiler": "9.1.9",
+ "@intlify/message-resolver": "9.1.9",
+ "@intlify/shared": "9.1.9"
+ },
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@intlify/shared": {
+ "version": "9.1.9",
+ "resolved": "https://registry.npmmirror.com/@intlify/shared/-/shared-9.1.9.tgz",
+ "integrity": "sha512-xKGM1d0EAxdDFCWedcYXOm6V5Pfw/TMudd6/qCdEb4tv0hk9EKeg7lwQF1azE0dP2phvx0yXxrt7UQK+IZjNdw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@intlify/vue-devtools": {
+ "version": "9.1.9",
+ "resolved": "https://registry.npmmirror.com/@intlify/vue-devtools/-/vue-devtools-9.1.9.tgz",
+ "integrity": "sha512-YPehH9uL4vZcGXky4Ev5qQIITnHKIvsD2GKGXgqf+05osMUI6WSEQHaN9USRa318Rs8RyyPCiDfmA0hRu3k7og==",
+ "license": "MIT",
+ "dependencies": {
+ "@intlify/message-resolver": "9.1.9",
+ "@intlify/runtime": "9.1.9",
+ "@intlify/shared": "9.1.9"
+ },
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@jimp/bmp": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmmirror.com/@jimp/bmp/-/bmp-0.10.3.tgz",
+ "integrity": "sha512-keMOc5woiDmONXsB/6aXLR4Z5Q+v8lFq3EY2rcj2FmstbDMhRuGbmcBxlEgOqfRjwvtf/wOtJ3Of37oAWtVfLg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.7.2",
+ "@jimp/utils": "^0.10.3",
+ "bmp-js": "^0.1.0",
+ "core-js": "^3.4.1"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5"
+ }
+ },
+ "node_modules/@jimp/core": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmmirror.com/@jimp/core/-/core-0.10.3.tgz",
+ "integrity": "sha512-Gd5IpL3U2bFIO57Fh/OA3HCpWm4uW/pU01E75rI03BXfTdz3T+J7TwvyG1XaqsQ7/DSlS99GXtLQPlfFIe28UA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.7.2",
+ "@jimp/utils": "^0.10.3",
+ "any-base": "^1.1.0",
+ "buffer": "^5.2.0",
+ "core-js": "^3.4.1",
+ "exif-parser": "^0.1.12",
+ "file-type": "^9.0.0",
+ "load-bmfont": "^1.3.1",
+ "mkdirp": "^0.5.1",
+ "phin": "^2.9.1",
+ "pixelmatch": "^4.0.2",
+ "tinycolor2": "^1.4.1"
+ }
+ },
+ "node_modules/@jimp/custom": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmmirror.com/@jimp/custom/-/custom-0.10.3.tgz",
+ "integrity": "sha512-nZmSI+jwTi5IRyNLbKSXQovoeqsw+D0Jn0SxW08wYQvdkiWA8bTlDQFgQ7HVwCAKBm8oKkDB/ZEo9qvHJ+1gAQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.7.2",
+ "@jimp/core": "^0.10.3",
+ "core-js": "^3.4.1"
+ }
+ },
+ "node_modules/@jimp/gif": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmmirror.com/@jimp/gif/-/gif-0.10.3.tgz",
+ "integrity": "sha512-vjlRodSfz1CrUvvrnUuD/DsLK1GHB/yDZXHthVdZu23zYJIW7/WrIiD1IgQ5wOMV7NocfrvPn2iqUfBP81/WWA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.7.2",
+ "@jimp/utils": "^0.10.3",
+ "core-js": "^3.4.1",
+ "omggif": "^1.0.9"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5"
+ }
+ },
+ "node_modules/@jimp/jpeg": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmmirror.com/@jimp/jpeg/-/jpeg-0.10.3.tgz",
+ "integrity": "sha512-AAANwgUZOt6f6P7LZxY9lyJ9xclqutYJlsxt3JbriXUGJgrrFAIkcKcqv1nObgmQASSAQKYaMV9KdHjMlWFKlQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.7.2",
+ "@jimp/utils": "^0.10.3",
+ "core-js": "^3.4.1",
+ "jpeg-js": "^0.3.4"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5"
+ }
+ },
+ "node_modules/@jimp/plugin-blit": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmmirror.com/@jimp/plugin-blit/-/plugin-blit-0.10.3.tgz",
+ "integrity": "sha512-5zlKlCfx4JWw9qUVC7GI4DzXyxDWyFvgZLaoGFoT00mlXlN75SarlDwc9iZ/2e2kp4bJWxz3cGgG4G/WXrbg3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.7.2",
+ "@jimp/utils": "^0.10.3",
+ "core-js": "^3.4.1"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5"
+ }
+ },
+ "node_modules/@jimp/plugin-blur": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmmirror.com/@jimp/plugin-blur/-/plugin-blur-0.10.3.tgz",
+ "integrity": "sha512-cTOK3rjh1Yjh23jSfA6EHCHjsPJDEGLC8K2y9gM7dnTUK1y9NNmkFS23uHpyjgsWFIoH9oRh2SpEs3INjCpZhQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.7.2",
+ "@jimp/utils": "^0.10.3",
+ "core-js": "^3.4.1"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5"
+ }
+ },
+ "node_modules/@jimp/plugin-circle": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmmirror.com/@jimp/plugin-circle/-/plugin-circle-0.10.3.tgz",
+ "integrity": "sha512-51GAPIVelqAcfuUpaM5JWJ0iWl4vEjNXB7p4P7SX5udugK5bxXUjO6KA2qgWmdpHuCKtoNgkzWU9fNSuYp7tCA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.7.2",
+ "@jimp/utils": "^0.10.3",
+ "core-js": "^3.4.1"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5"
+ }
+ },
+ "node_modules/@jimp/plugin-color": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmmirror.com/@jimp/plugin-color/-/plugin-color-0.10.3.tgz",
+ "integrity": "sha512-RgeHUElmlTH7vpI4WyQrz6u59spiKfVQbsG/XUzfWGamFSixa24ZDwX/yV/Ts+eNaz7pZeIuv533qmKPvw2ujg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.7.2",
+ "@jimp/utils": "^0.10.3",
+ "core-js": "^3.4.1",
+ "tinycolor2": "^1.4.1"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5"
+ }
+ },
+ "node_modules/@jimp/plugin-contain": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmmirror.com/@jimp/plugin-contain/-/plugin-contain-0.10.3.tgz",
+ "integrity": "sha512-bYJKW9dqzcB0Ihc6u7jSyKa3juStzbLs2LFr6fu8TzA2WkMS/R8h+ddkiO36+F9ILTWHP0CIA3HFe5OdOGcigw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.7.2",
+ "@jimp/utils": "^0.10.3",
+ "core-js": "^3.4.1"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5",
+ "@jimp/plugin-blit": ">=0.3.5",
+ "@jimp/plugin-resize": ">=0.3.5",
+ "@jimp/plugin-scale": ">=0.3.5"
+ }
+ },
+ "node_modules/@jimp/plugin-cover": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmmirror.com/@jimp/plugin-cover/-/plugin-cover-0.10.3.tgz",
+ "integrity": "sha512-pOxu0cM0BRPzdV468n4dMocJXoMbTnARDY/EpC3ZW15SpMuc/dr1KhWQHgoQX5kVW1Wt8zgqREAJJCQ5KuPKDA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.7.2",
+ "@jimp/utils": "^0.10.3",
+ "core-js": "^3.4.1"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5",
+ "@jimp/plugin-crop": ">=0.3.5",
+ "@jimp/plugin-resize": ">=0.3.5",
+ "@jimp/plugin-scale": ">=0.3.5"
+ }
+ },
+ "node_modules/@jimp/plugin-crop": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmmirror.com/@jimp/plugin-crop/-/plugin-crop-0.10.3.tgz",
+ "integrity": "sha512-nB7HgOjjl9PgdHr076xZ3Sr6qHYzeBYBs9qvs3tfEEUeYMNnvzgCCGtUl6eMakazZFCMk3mhKmcB9zQuHFOvkg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.7.2",
+ "@jimp/utils": "^0.10.3",
+ "core-js": "^3.4.1"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5"
+ }
+ },
+ "node_modules/@jimp/plugin-displace": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmmirror.com/@jimp/plugin-displace/-/plugin-displace-0.10.3.tgz",
+ "integrity": "sha512-8t3fVKCH5IVqI4lewe4lFFjpxxr69SQCz5/tlpDLQZsrNScNJivHdQ09zljTrVTCSgeCqQJIKgH2Q7Sk/pAZ0w==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.7.2",
+ "@jimp/utils": "^0.10.3",
+ "core-js": "^3.4.1"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5"
+ }
+ },
+ "node_modules/@jimp/plugin-dither": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmmirror.com/@jimp/plugin-dither/-/plugin-dither-0.10.3.tgz",
+ "integrity": "sha512-JCX/oNSnEg1kGQ8ffZ66bEgQOLCY3Rn+lrd6v1jjLy/mn9YVZTMsxLtGCXpiCDC2wG/KTmi4862ysmP9do9dAQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.7.2",
+ "@jimp/utils": "^0.10.3",
+ "core-js": "^3.4.1"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5"
+ }
+ },
+ "node_modules/@jimp/plugin-fisheye": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmmirror.com/@jimp/plugin-fisheye/-/plugin-fisheye-0.10.3.tgz",
+ "integrity": "sha512-RRZb1wqe+xdocGcFtj2xHU7sF7xmEZmIa6BmrfSchjyA2b32TGPWKnP3qyj7p6LWEsXn+19hRYbjfyzyebPElQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.7.2",
+ "@jimp/utils": "^0.10.3",
+ "core-js": "^3.4.1"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5"
+ }
+ },
+ "node_modules/@jimp/plugin-flip": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmmirror.com/@jimp/plugin-flip/-/plugin-flip-0.10.3.tgz",
+ "integrity": "sha512-0epbi8XEzp0wmSjoW9IB0iMu0yNF17aZOxLdURCN3Zr+8nWPs5VNIMqSVa1Y62GSyiMDpVpKF/ITiXre+EqrPg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.7.2",
+ "@jimp/utils": "^0.10.3",
+ "core-js": "^3.4.1"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5",
+ "@jimp/plugin-rotate": ">=0.3.5"
+ }
+ },
+ "node_modules/@jimp/plugin-gaussian": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmmirror.com/@jimp/plugin-gaussian/-/plugin-gaussian-0.10.3.tgz",
+ "integrity": "sha512-25eHlFbHUDnMMGpgRBBeQ2AMI4wsqCg46sue0KklI+c2BaZ+dGXmJA5uT8RTOrt64/K9Wz5E+2n7eBnny4dfpQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.7.2",
+ "@jimp/utils": "^0.10.3",
+ "core-js": "^3.4.1"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5"
+ }
+ },
+ "node_modules/@jimp/plugin-invert": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmmirror.com/@jimp/plugin-invert/-/plugin-invert-0.10.3.tgz",
+ "integrity": "sha512-effYSApWY/FbtlzqsKXlTLkgloKUiHBKjkQnqh5RL4oQxh/33j6aX+HFdDyQKtsXb8CMd4xd7wyiD2YYabTa0g==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.7.2",
+ "@jimp/utils": "^0.10.3",
+ "core-js": "^3.4.1"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5"
+ }
+ },
+ "node_modules/@jimp/plugin-mask": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmmirror.com/@jimp/plugin-mask/-/plugin-mask-0.10.3.tgz",
+ "integrity": "sha512-twrg8q8TIhM9Z6Jcu9/5f+OCAPaECb0eKrrbbIajJqJ3bCUlj5zbfgIhiQIzjPJ6KjpnFPSqHQfHkU1Vvk/nVw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.7.2",
+ "@jimp/utils": "^0.10.3",
+ "core-js": "^3.4.1"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5"
+ }
+ },
+ "node_modules/@jimp/plugin-normalize": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmmirror.com/@jimp/plugin-normalize/-/plugin-normalize-0.10.3.tgz",
+ "integrity": "sha512-xkb5eZI/mMlbwKkDN79+1/t/+DBo8bBXZUMsT4gkFgMRKNRZ6NQPxlv1d3QpRzlocsl6UMxrHnhgnXdLAcgrXw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.7.2",
+ "@jimp/utils": "^0.10.3",
+ "core-js": "^3.4.1"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5"
+ }
+ },
+ "node_modules/@jimp/plugin-print": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmmirror.com/@jimp/plugin-print/-/plugin-print-0.10.3.tgz",
+ "integrity": "sha512-wjRiI6yjXsAgMe6kVjizP+RgleUCLkH256dskjoNvJzmzbEfO7xQw9g6M02VET+emnbY0CO83IkrGm2q43VRyg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.7.2",
+ "@jimp/utils": "^0.10.3",
+ "core-js": "^3.4.1",
+ "load-bmfont": "^1.4.0"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5",
+ "@jimp/plugin-blit": ">=0.3.5"
+ }
+ },
+ "node_modules/@jimp/plugin-resize": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmmirror.com/@jimp/plugin-resize/-/plugin-resize-0.10.3.tgz",
+ "integrity": "sha512-rf8YmEB1d7Sg+g4LpqF0Mp+dfXfb6JFJkwlAIWPUOR7lGsPWALavEwTW91c0etEdnp0+JB9AFpy6zqq7Lwkq6w==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.7.2",
+ "@jimp/utils": "^0.10.3",
+ "core-js": "^3.4.1"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5"
+ }
+ },
+ "node_modules/@jimp/plugin-rotate": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmmirror.com/@jimp/plugin-rotate/-/plugin-rotate-0.10.3.tgz",
+ "integrity": "sha512-YXLlRjm18fkW9MOHUaVAxWjvgZM851ofOipytz5FyKp4KZWDLk+dZK1JNmVmK7MyVmAzZ5jsgSLhIgj+GgN0Eg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.7.2",
+ "@jimp/utils": "^0.10.3",
+ "core-js": "^3.4.1"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5",
+ "@jimp/plugin-blit": ">=0.3.5",
+ "@jimp/plugin-crop": ">=0.3.5",
+ "@jimp/plugin-resize": ">=0.3.5"
+ }
+ },
+ "node_modules/@jimp/plugin-scale": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmmirror.com/@jimp/plugin-scale/-/plugin-scale-0.10.3.tgz",
+ "integrity": "sha512-5DXD7x7WVcX1gUgnlFXQa8F+Q3ThRYwJm+aesgrYvDOY+xzRoRSdQvhmdd4JEEue3lyX44DvBSgCIHPtGcEPaw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.7.2",
+ "@jimp/utils": "^0.10.3",
+ "core-js": "^3.4.1"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5",
+ "@jimp/plugin-resize": ">=0.3.5"
+ }
+ },
+ "node_modules/@jimp/plugin-shadow": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmmirror.com/@jimp/plugin-shadow/-/plugin-shadow-0.10.3.tgz",
+ "integrity": "sha512-/nkFXpt2zVcdP4ETdkAUL0fSzyrC5ZFxdcphbYBodqD7fXNqChS/Un1eD4xCXWEpW8cnG9dixZgQgStjywH0Mg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.7.2",
+ "@jimp/utils": "^0.10.3",
+ "core-js": "^3.4.1"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5",
+ "@jimp/plugin-blur": ">=0.3.5",
+ "@jimp/plugin-resize": ">=0.3.5"
+ }
+ },
+ "node_modules/@jimp/plugin-threshold": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmmirror.com/@jimp/plugin-threshold/-/plugin-threshold-0.10.3.tgz",
+ "integrity": "sha512-Dzh0Yq2wXP2SOnxcbbiyA4LJ2luwrdf1MghNIt9H+NX7B+IWw/N8qA2GuSm9n4BPGSLluuhdAWJqHcTiREriVA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.7.2",
+ "@jimp/utils": "^0.10.3",
+ "core-js": "^3.4.1"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5",
+ "@jimp/plugin-color": ">=0.8.0",
+ "@jimp/plugin-resize": ">=0.8.0"
+ }
+ },
+ "node_modules/@jimp/plugins": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmmirror.com/@jimp/plugins/-/plugins-0.10.3.tgz",
+ "integrity": "sha512-jTT3/7hOScf0EIKiAXmxwayHhryhc1wWuIe3FrchjDjr9wgIGNN2a7XwCgPl3fML17DXK1x8EzDneCdh261bkw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.7.2",
+ "@jimp/plugin-blit": "^0.10.3",
+ "@jimp/plugin-blur": "^0.10.3",
+ "@jimp/plugin-circle": "^0.10.3",
+ "@jimp/plugin-color": "^0.10.3",
+ "@jimp/plugin-contain": "^0.10.3",
+ "@jimp/plugin-cover": "^0.10.3",
+ "@jimp/plugin-crop": "^0.10.3",
+ "@jimp/plugin-displace": "^0.10.3",
+ "@jimp/plugin-dither": "^0.10.3",
+ "@jimp/plugin-fisheye": "^0.10.3",
+ "@jimp/plugin-flip": "^0.10.3",
+ "@jimp/plugin-gaussian": "^0.10.3",
+ "@jimp/plugin-invert": "^0.10.3",
+ "@jimp/plugin-mask": "^0.10.3",
+ "@jimp/plugin-normalize": "^0.10.3",
+ "@jimp/plugin-print": "^0.10.3",
+ "@jimp/plugin-resize": "^0.10.3",
+ "@jimp/plugin-rotate": "^0.10.3",
+ "@jimp/plugin-scale": "^0.10.3",
+ "@jimp/plugin-shadow": "^0.10.3",
+ "@jimp/plugin-threshold": "^0.10.3",
+ "core-js": "^3.4.1",
+ "timm": "^1.6.1"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5"
+ }
+ },
+ "node_modules/@jimp/png": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmmirror.com/@jimp/png/-/png-0.10.3.tgz",
+ "integrity": "sha512-YKqk/dkl+nGZxSYIDQrqhmaP8tC3IK8H7dFPnnzFVvbhDnyYunqBZZO3SaZUKTichClRw8k/CjBhbc+hifSGWg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.7.2",
+ "@jimp/utils": "^0.10.3",
+ "core-js": "^3.4.1",
+ "pngjs": "^3.3.3"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5"
+ }
+ },
+ "node_modules/@jimp/tiff": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmmirror.com/@jimp/tiff/-/tiff-0.10.3.tgz",
+ "integrity": "sha512-7EsJzZ5Y/EtinkBGuwX3Bi4S+zgbKouxjt9c82VJTRJOQgLWsE/RHqcyRCOQBhHAZ9QexYmDz34medfLKdoX0g==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.7.2",
+ "core-js": "^3.4.1",
+ "utif": "^2.0.1"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5"
+ }
+ },
+ "node_modules/@jimp/types": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmmirror.com/@jimp/types/-/types-0.10.3.tgz",
+ "integrity": "sha512-XGmBakiHZqseSWr/puGN+CHzx0IKBSpsKlmEmsNV96HKDiP6eu8NSnwdGCEq2mmIHe0JNcg1hqg59hpwtQ7Tiw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.7.2",
+ "@jimp/bmp": "^0.10.3",
+ "@jimp/gif": "^0.10.3",
+ "@jimp/jpeg": "^0.10.3",
+ "@jimp/png": "^0.10.3",
+ "@jimp/tiff": "^0.10.3",
+ "core-js": "^3.4.1",
+ "timm": "^1.6.1"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5"
+ }
+ },
+ "node_modules/@jimp/utils": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmmirror.com/@jimp/utils/-/utils-0.10.3.tgz",
+ "integrity": "sha512-VcSlQhkil4ReYmg1KkN+WqHyYfZ2XfZxDsKAHSfST1GEz/RQHxKZbX+KhFKtKflnL0F4e6DlNQj3vznMNXCR2w==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.7.2",
+ "core-js": "^3.4.1",
+ "regenerator-runtime": "^0.13.3"
+ }
+ },
+ "node_modules/@jimp/utils/node_modules/regenerator-runtime": {
+ "version": "0.13.11",
+ "resolved": "https://registry.npmmirror.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
+ "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/source-map": {
+ "version": "0.3.11",
+ "resolved": "https://registry.npmmirror.com/@jridgewell/source-map/-/source-map-0.3.11.tgz",
+ "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.25"
+ }
+ },
+ "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/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmmirror.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@parcel/watcher": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmmirror.com/@parcel/watcher/-/watcher-2.5.6.tgz",
+ "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "detect-libc": "^2.0.3",
+ "is-glob": "^4.0.3",
+ "node-addon-api": "^7.0.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "@parcel/watcher-android-arm64": "2.5.6",
+ "@parcel/watcher-darwin-arm64": "2.5.6",
+ "@parcel/watcher-darwin-x64": "2.5.6",
+ "@parcel/watcher-freebsd-x64": "2.5.6",
+ "@parcel/watcher-linux-arm-glibc": "2.5.6",
+ "@parcel/watcher-linux-arm-musl": "2.5.6",
+ "@parcel/watcher-linux-arm64-glibc": "2.5.6",
+ "@parcel/watcher-linux-arm64-musl": "2.5.6",
+ "@parcel/watcher-linux-x64-glibc": "2.5.6",
+ "@parcel/watcher-linux-x64-musl": "2.5.6",
+ "@parcel/watcher-win32-arm64": "2.5.6",
+ "@parcel/watcher-win32-ia32": "2.5.6",
+ "@parcel/watcher-win32-x64": "2.5.6"
+ }
+ },
+ "node_modules/@parcel/watcher-android-arm64": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmmirror.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz",
+ "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-darwin-arm64": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmmirror.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz",
+ "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-darwin-x64": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmmirror.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz",
+ "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-freebsd-x64": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmmirror.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz",
+ "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-arm-glibc": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz",
+ "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-arm-musl": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz",
+ "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-arm64-glibc": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz",
+ "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-arm64-musl": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz",
+ "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-x64-glibc": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz",
+ "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-x64-musl": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz",
+ "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-win32-arm64": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz",
+ "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-win32-ia32": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz",
+ "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-win32-x64": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz",
+ "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@rollup/pluginutils": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmmirror.com/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
+ "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0",
+ "estree-walker": "^2.0.2",
+ "picomatch": "^4.0.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz",
+ "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz",
+ "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz",
+ "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz",
+ "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz",
+ "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz",
+ "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz",
+ "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==",
+ "cpu": [
+ "arm"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz",
+ "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==",
+ "cpu": [
+ "arm"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz",
+ "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz",
+ "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz",
+ "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==",
+ "cpu": [
+ "loong64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz",
+ "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==",
+ "cpu": [
+ "loong64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz",
+ "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz",
+ "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz",
+ "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz",
+ "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz",
+ "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==",
+ "cpu": [
+ "s390x"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz",
+ "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz",
+ "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz",
+ "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz",
+ "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz",
+ "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz",
+ "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz",
+ "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz",
+ "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==",
+ "cpu": [
+ "x64"
+ ],
+ "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==",
+ "license": "MIT"
+ },
+ "node_modules/@vitejs/plugin-legacy": {
+ "version": "5.4.3",
+ "resolved": "https://registry.npmmirror.com/@vitejs/plugin-legacy/-/plugin-legacy-5.4.3.tgz",
+ "integrity": "sha512-wsyXK9mascyplcqvww1gA1xYiy29iRHfyciw+a0t7qRNdzX6PdfSWmOoCi74epr87DujM+5J+rnnSv+4PazqVg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.25.8",
+ "@babel/preset-env": "^7.25.8",
+ "browserslist": "^4.24.0",
+ "browserslist-to-esbuild": "^2.1.1",
+ "core-js": "^3.38.1",
+ "magic-string": "^0.30.12",
+ "regenerator-runtime": "^0.14.1",
+ "systemjs": "^6.15.1"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "peerDependencies": {
+ "terser": "^5.4.0",
+ "vite": "^5.0.0"
+ }
+ },
+ "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/@vitejs/plugin-vue-jsx": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue-jsx/-/plugin-vue-jsx-3.1.0.tgz",
+ "integrity": "sha512-w9M6F3LSEU5kszVb9An2/MmXNxocAnUb3WhRr8bHlimhDrXNt6n6D2nJQR3UXpGlZHh/EsgouOHCsM8V3Ln+WA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.23.3",
+ "@babel/plugin-transform-typescript": "^7.23.3",
+ "@vue/babel-plugin-jsx": "^1.1.5"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.0.0 || ^5.0.0",
+ "vue": "^3.0.0"
+ }
+ },
+ "node_modules/@vue/babel-helper-vue-transform-on": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmmirror.com/@vue/babel-helper-vue-transform-on/-/babel-helper-vue-transform-on-1.5.0.tgz",
+ "integrity": "sha512-0dAYkerNhhHutHZ34JtTl2czVQHUNWv6xEbkdF5W+Yrv5pCWsqjeORdOgbtW2I9gWlt+wBmVn+ttqN9ZxR5tzA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@vue/babel-plugin-jsx": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmmirror.com/@vue/babel-plugin-jsx/-/babel-plugin-jsx-1.5.0.tgz",
+ "integrity": "sha512-mneBhw1oOqCd2247O0Yw/mRwC9jIGACAJUlawkmMBiNmL4dGA2eMzuNZVNqOUfYTa6vqmND4CtOPzmEEEqLKFw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.27.1",
+ "@babel/helper-plugin-utils": "^7.27.1",
+ "@babel/plugin-syntax-jsx": "^7.27.1",
+ "@babel/template": "^7.27.2",
+ "@babel/traverse": "^7.28.0",
+ "@babel/types": "^7.28.2",
+ "@vue/babel-helper-vue-transform-on": "1.5.0",
+ "@vue/babel-plugin-resolve-type": "1.5.0",
+ "@vue/shared": "^3.5.18"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "@babel/core": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vue/babel-plugin-jsx/node_modules/@vue/shared": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.34.tgz",
+ "integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@vue/babel-plugin-resolve-type": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmmirror.com/@vue/babel-plugin-resolve-type/-/babel-plugin-resolve-type-1.5.0.tgz",
+ "integrity": "sha512-Wm/60o+53JwJODm4Knz47dxJnLDJ9FnKnGZJbUUf8nQRAtt6P+undLUAVU3Ha33LxOJe6IPoifRQ6F/0RrU31w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.27.1",
+ "@babel/helper-module-imports": "^7.27.1",
+ "@babel/helper-plugin-utils": "^7.27.1",
+ "@babel/parser": "^7.28.0",
+ "@vue/compiler-sfc": "^3.5.18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sxzz"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@vue/babel-plugin-resolve-type/node_modules/@vue/compiler-core": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.34.tgz",
+ "integrity": "sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.3",
+ "@vue/shared": "3.5.34",
+ "entities": "^7.0.1",
+ "estree-walker": "^2.0.2",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/babel-plugin-resolve-type/node_modules/@vue/compiler-dom": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz",
+ "integrity": "sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-core": "3.5.34",
+ "@vue/shared": "3.5.34"
+ }
+ },
+ "node_modules/@vue/babel-plugin-resolve-type/node_modules/@vue/compiler-sfc": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz",
+ "integrity": "sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.3",
+ "@vue/compiler-core": "3.5.34",
+ "@vue/compiler-dom": "3.5.34",
+ "@vue/compiler-ssr": "3.5.34",
+ "@vue/shared": "3.5.34",
+ "estree-walker": "^2.0.2",
+ "magic-string": "^0.30.21",
+ "postcss": "^8.5.14",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/babel-plugin-resolve-type/node_modules/@vue/compiler-ssr": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.34.tgz",
+ "integrity": "sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.34",
+ "@vue/shared": "3.5.34"
+ }
+ },
+ "node_modules/@vue/babel-plugin-resolve-type/node_modules/@vue/shared": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.34.tgz",
+ "integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@vue/babel-plugin-resolve-type/node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/@vue/compiler-core": {
+ "version": "3.4.21",
+ "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.4.21.tgz",
+ "integrity": "sha512-MjXawxZf2SbZszLPYxaFCjxfibYrzr3eYbKxwpLR9EQN+oaziSu3qKVbwBERj1IFIB8OLUewxB5m/BFzi613og==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.23.9",
+ "@vue/shared": "3.4.21",
+ "entities": "^4.5.0",
+ "estree-walker": "^2.0.2",
+ "source-map-js": "^1.0.2"
+ }
+ },
+ "node_modules/@vue/compiler-dom": {
+ "version": "3.4.21",
+ "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.4.21.tgz",
+ "integrity": "sha512-IZC6FKowtT1sl0CR5DpXSiEB5ayw75oT2bma1BEhV7RRR1+cfwLrxc2Z8Zq/RGFzJ8w5r9QtCOvTjQgdn0IKmA==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-core": "3.4.21",
+ "@vue/shared": "3.4.21"
+ }
+ },
+ "node_modules/@vue/compiler-sfc": {
+ "version": "3.4.21",
+ "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.4.21.tgz",
+ "integrity": "sha512-me7epoTxYlY+2CUM7hy9PCDdpMPfIwrOvAXud2Upk10g4YLv9UBW7kL798TvMeDhPthkZ0CONNrK2GoeI1ODiQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.23.9",
+ "@vue/compiler-core": "3.4.21",
+ "@vue/compiler-dom": "3.4.21",
+ "@vue/compiler-ssr": "3.4.21",
+ "@vue/shared": "3.4.21",
+ "estree-walker": "^2.0.2",
+ "magic-string": "^0.30.7",
+ "postcss": "^8.4.35",
+ "source-map-js": "^1.0.2"
+ }
+ },
+ "node_modules/@vue/compiler-ssr": {
+ "version": "3.4.21",
+ "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.4.21.tgz",
+ "integrity": "sha512-M5+9nI2lPpAsgXOGQobnIueVqc9sisBFexh5yMIMRAPYLa7+5wEJs8iqOZc1WAa9WQbx9GR2twgznU8LTIiZ4Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-dom": "3.4.21",
+ "@vue/shared": "3.4.21"
+ }
+ },
+ "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/reactivity": {
+ "version": "3.4.21",
+ "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.4.21.tgz",
+ "integrity": "sha512-UhenImdc0L0/4ahGCyEzc/pZNwVgcglGy9HVzJ1Bq2Mm9qXOpP8RyNTjookw/gOCUlXSEtuZ2fUg5nrHcoqJcw==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/shared": "3.4.21"
+ }
+ },
+ "node_modules/@vue/runtime-core": {
+ "version": "3.4.21",
+ "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.4.21.tgz",
+ "integrity": "sha512-pQthsuYzE1XcGZznTKn73G0s14eCJcjaLvp3/DKeYWoFacD9glJoqlNBxt3W2c5S40t6CCcpPf+jG01N3ULyrA==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/reactivity": "3.4.21",
+ "@vue/shared": "3.4.21"
+ }
+ },
+ "node_modules/@vue/runtime-dom": {
+ "version": "3.4.21",
+ "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.4.21.tgz",
+ "integrity": "sha512-gvf+C9cFpevsQxbkRBS1NpU8CqxKw0ebqMvLwcGQrNpx6gqRDodqKqA+A2VZZpQ9RpK2f9yfg8VbW/EpdFUOJw==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/runtime-core": "3.4.21",
+ "@vue/shared": "3.4.21",
+ "csstype": "^3.1.3"
+ }
+ },
+ "node_modules/@vue/server-renderer": {
+ "version": "3.4.21",
+ "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.4.21.tgz",
+ "integrity": "sha512-aV1gXyKSN6Rz+6kZ6kr5+Ll14YzmIbeuWe7ryJl5muJ4uwSwY/aStXTixx76TwkZFJLm1aAlA/HSWEJ4EyiMkg==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-ssr": "3.4.21",
+ "@vue/shared": "3.4.21"
+ },
+ "peerDependencies": {
+ "vue": "3.4.21"
+ }
+ },
+ "node_modules/@vue/shared": {
+ "version": "3.4.21",
+ "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.4.21.tgz",
+ "integrity": "sha512-PuJe7vDIi6VYSinuEbUIQgMIRZGgM8e4R+G+/dQTk0X1NEdvgvvgv7m+rfmDH1gZzyA1OjjoWskvHlfRNfQf3g==",
+ "license": "MIT"
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.16.0",
+ "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz",
+ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/any-base": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmmirror.com/any-base/-/any-base-1.1.0.tgz",
+ "integrity": "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==",
+ "license": "MIT"
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmmirror.com/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "license": "ISC",
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/anymatch/node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmmirror.com/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/autoprefixer": {
+ "version": "10.5.0",
+ "resolved": "https://registry.npmmirror.com/autoprefixer/-/autoprefixer-10.5.0.tgz",
+ "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/autoprefixer"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "browserslist": "^4.28.2",
+ "caniuse-lite": "^1.0.30001787",
+ "fraction.js": "^5.3.4",
+ "picocolors": "^1.1.1",
+ "postcss-value-parser": "^4.2.0"
+ },
+ "bin": {
+ "autoprefixer": "bin/autoprefixer"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/babel-plugin-polyfill-corejs2": {
+ "version": "0.4.17",
+ "resolved": "https://registry.npmmirror.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz",
+ "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.28.6",
+ "@babel/helper-define-polyfill-provider": "^0.6.8",
+ "semver": "^6.3.1"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
+ }
+ },
+ "node_modules/babel-plugin-polyfill-corejs3": {
+ "version": "0.14.2",
+ "resolved": "https://registry.npmmirror.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz",
+ "integrity": "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-define-polyfill-provider": "^0.6.8",
+ "core-js-compat": "^3.48.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
+ }
+ },
+ "node_modules/babel-plugin-polyfill-regenerator": {
+ "version": "0.6.8",
+ "resolved": "https://registry.npmmirror.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz",
+ "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-define-polyfill-provider": "^0.6.8"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.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==",
+ "license": "MIT"
+ },
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/base64url": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmmirror.com/base64url/-/base64url-3.0.1.tgz",
+ "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.29",
+ "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz",
+ "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==",
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmmirror.com/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/bmp-js": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmmirror.com/bmp-js/-/bmp-js-0.1.0.tgz",
+ "integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==",
+ "license": "MIT"
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.5",
+ "resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-1.20.5.tgz",
+ "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "~1.2.0",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "on-finished": "~2.4.1",
+ "qs": "~6.15.1",
+ "raw-body": "~2.5.3",
+ "type-is": "~1.6.18",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/body-parser/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/body-parser/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/body-parser/node_modules/qs": {
+ "version": "6.15.1",
+ "resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.1.tgz",
+ "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.0.tgz",
+ "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.2",
+ "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.2.tgz",
+ "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.10.12",
+ "caniuse-lite": "^1.0.30001782",
+ "electron-to-chromium": "^1.5.328",
+ "node-releases": "^2.0.36",
+ "update-browserslist-db": "^1.2.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/browserslist-to-esbuild": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmmirror.com/browserslist-to-esbuild/-/browserslist-to-esbuild-2.1.1.tgz",
+ "integrity": "sha512-KN+mty6C3e9AN8Z5dI1xeN15ExcRNeISoC3g7V0Kax/MMF9MSoYA2G7lkTTcVUFntiEjkpI0HNgqJC1NjdyNUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "meow": "^13.0.0"
+ },
+ "bin": {
+ "browserslist-to-esbuild": "cli/index.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "browserslist": "*"
+ }
+ },
+ "node_modules/buffer": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmmirror.com/buffer/-/buffer-5.7.1.tgz",
+ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.1.13"
+ }
+ },
+ "node_modules/buffer-equal": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmmirror.com/buffer-equal/-/buffer-equal-0.0.1.tgz",
+ "integrity": "sha512-RgSV6InVQ9ODPdLWJ5UAqBqJBOg370Nz6ZQtRzpt6nUjc8v0St97uJ4PYC6NztqIScrAXafKM3mZPMygSe1ggA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/buffer-from": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz",
+ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/cac": {
+ "version": "6.7.9",
+ "resolved": "https://registry.npmmirror.com/cac/-/cac-6.7.9.tgz",
+ "integrity": "sha512-XN5qEpfNQCJ8jRaZgitSkkukjMRCGio+X3Ks5KUbGGlPbV+pSem1l9VuzooCBXOiMFshUZgyYqg6rgN8rjkb/w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "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==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001792",
+ "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz",
+ "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/centra": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmmirror.com/centra/-/centra-2.7.0.tgz",
+ "integrity": "sha512-PbFMgMSrmgx6uxCdm57RUos9Tc3fclMvhLSATYN39XsDV29B89zZ3KA89jmY0vwSGazyU+uerqwa6t+KaodPcg==",
+ "license": "MIT",
+ "dependencies": {
+ "follow-redirects": "^1.15.6"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "license": "MIT",
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/colorjs.io": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmmirror.com/colorjs.io/-/colorjs.io-0.5.2.tgz",
+ "integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmmirror.com/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/compare-versions": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmmirror.com/compare-versions/-/compare-versions-3.6.0.tgz",
+ "integrity": "sha512-W6Af2Iw1z4CB7q4uU4hv646dW9GQuBM+YpC0UvUCWSD8w90SJjp+ujJuXaEMtAXBtSqGfMPuFOVn4/+FlaqfBA==",
+ "license": "MIT"
+ },
+ "node_modules/confbox": {
+ "version": "0.1.8",
+ "resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.1.8.tgz",
+ "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
+ "license": "MIT"
+ },
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmmirror.com/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "license": "MIT"
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmmirror.com/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmmirror.com/cookie-signature/-/cookie-signature-1.0.7.tgz",
+ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/core-js": {
+ "version": "3.49.0",
+ "resolved": "https://registry.npmmirror.com/core-js/-/core-js-3.49.0.tgz",
+ "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/core-js"
+ }
+ },
+ "node_modules/core-js-compat": {
+ "version": "3.49.0",
+ "resolved": "https://registry.npmmirror.com/core-js-compat/-/core-js-compat-3.49.0.tgz",
+ "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "browserslist": "^4.28.1"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/core-js"
+ }
+ },
+ "node_modules/cssesc": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz",
+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+ "license": "MIT",
+ "bin": {
+ "cssesc": "bin/cssesc"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "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/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/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmmirror.com/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/dom-walk": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmmirror.com/dom-walk/-/dom-walk-0.1.2.tgz",
+ "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="
+ },
+ "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==",
+ "dev": true,
+ "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/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.353",
+ "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz",
+ "integrity": "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==",
+ "license": "ISC"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "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==",
+ "dev": true,
+ "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-module-lexer": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+ "license": "MIT"
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.20.2.tgz",
+ "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.20.2",
+ "@esbuild/android-arm": "0.20.2",
+ "@esbuild/android-arm64": "0.20.2",
+ "@esbuild/android-x64": "0.20.2",
+ "@esbuild/darwin-arm64": "0.20.2",
+ "@esbuild/darwin-x64": "0.20.2",
+ "@esbuild/freebsd-arm64": "0.20.2",
+ "@esbuild/freebsd-x64": "0.20.2",
+ "@esbuild/linux-arm": "0.20.2",
+ "@esbuild/linux-arm64": "0.20.2",
+ "@esbuild/linux-ia32": "0.20.2",
+ "@esbuild/linux-loong64": "0.20.2",
+ "@esbuild/linux-mips64el": "0.20.2",
+ "@esbuild/linux-ppc64": "0.20.2",
+ "@esbuild/linux-riscv64": "0.20.2",
+ "@esbuild/linux-s390x": "0.20.2",
+ "@esbuild/linux-x64": "0.20.2",
+ "@esbuild/netbsd-x64": "0.20.2",
+ "@esbuild/openbsd-x64": "0.20.2",
+ "@esbuild/sunos-x64": "0.20.2",
+ "@esbuild/win32-arm64": "0.20.2",
+ "@esbuild/win32-ia32": "0.20.2",
+ "@esbuild/win32-x64": "0.20.2"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
+ "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+ "license": "MIT"
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/exif-parser": {
+ "version": "0.1.12",
+ "resolved": "https://registry.npmmirror.com/exif-parser/-/exif-parser-0.1.12.tgz",
+ "integrity": "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="
+ },
+ "node_modules/express": {
+ "version": "4.22.1",
+ "resolved": "https://registry.npmmirror.com/express/-/express-4.22.1.tgz",
+ "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "~1.20.3",
+ "content-disposition": "~0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "~0.7.1",
+ "cookie-signature": "~1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "~1.3.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.0",
+ "merge-descriptors": "1.0.3",
+ "methods": "~1.1.2",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "~0.1.12",
+ "proxy-addr": "~2.0.7",
+ "qs": "~6.14.0",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "~0.19.0",
+ "serve-static": "~1.16.2",
+ "setprototypeof": "1.2.0",
+ "statuses": "~2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/express/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/express/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/exsolve": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmmirror.com/exsolve/-/exsolve-1.0.8.tgz",
+ "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
+ "license": "MIT"
+ },
+ "node_modules/fast-glob": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz",
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.8"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fastq": {
+ "version": "1.20.1",
+ "resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.20.1.tgz",
+ "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
+ "license": "ISC",
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/file-type": {
+ "version": "9.0.0",
+ "resolved": "https://registry.npmmirror.com/file-type/-/file-type-9.0.0.tgz",
+ "integrity": "sha512-Qe/5NJrgIOlwijpq3B7BEpzPFcgzggOTagZmkXQY4LA6bsXKTUstK7Wp12lEJ/mLKTpvIZxmIuRcLYWT6ov9lw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmmirror.com/finalhandler/-/finalhandler-1.3.2.tgz",
+ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "~2.0.2",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/finalhandler/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/finalhandler/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "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/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fraction.js": {
+ "version": "5.3.4",
+ "resolved": "https://registry.npmmirror.com/fraction.js/-/fraction.js-5.3.4.tgz",
+ "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/rawify"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmmirror.com/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fs-extra": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-10.1.0.tgz",
+ "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "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/generic-names": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmmirror.com/generic-names/-/generic-names-4.0.0.tgz",
+ "integrity": "sha512-ySFolZQfw9FoDb3ed9d80Cm9f0+r7qj+HJkWjeD9RBfpxEVTlVhol+gvaQB/78WbwYfbnNh8nWHHBSlg072y6A==",
+ "license": "MIT",
+ "dependencies": {
+ "loader-utils": "^3.2.0"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "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==",
+ "dev": true,
+ "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==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/global": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmmirror.com/global/-/global-4.4.0.tgz",
+ "integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==",
+ "license": "MIT",
+ "dependencies": {
+ "min-document": "^2.19.0",
+ "process": "^0.11.10"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "license": "ISC"
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "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==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hash-sum": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/hash-sum/-/hash-sum-2.0.0.tgz",
+ "integrity": "sha512-WdZTbAByD+pHfl/g9QSsBIIwy8IT+EsPiKDs0KNX+zSHhdDLFKdZu0BQHljvO+0QI/BasbMSUa8wYNCZTvhslg==",
+ "license": "MIT"
+ },
+ "node_modules/hasown": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.3.tgz",
+ "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/icss-replace-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmmirror.com/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz",
+ "integrity": "sha512-chIaY3Vh2mh2Q3RGXttaDIzeiPvaVXJ+C4DAh/w3c37SKZ/U6PGMmuicR2EQQp9bKG8zLMCl7I+PtIoOOPp8Gg==",
+ "license": "ISC"
+ },
+ "node_modules/icss-utils": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmmirror.com/icss-utils/-/icss-utils-5.1.0.tgz",
+ "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==",
+ "license": "ISC",
+ "engines": {
+ "node": "^10 || ^12 || >= 14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmmirror.com/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/immutable": {
+ "version": "5.1.5",
+ "resolved": "https://registry.npmmirror.com/immutable/-/immutable-5.1.5.tgz",
+ "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/invert-kv": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmmirror.com/invert-kv/-/invert-kv-3.0.1.tgz",
+ "integrity": "sha512-CYdFeFexxhv/Bcny+Q0BfOV+ltRlJcd4BBZBYFX/O0u4npJrgZtIcjokegtiSMAvlMTJ+Koq0GBCc//3bueQxw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/invert-kv?sponsor=1"
+ }
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "license": "MIT",
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-core-module": {
+ "version": "2.16.2",
+ "resolved": "https://registry.npmmirror.com/is-core-module/-/is-core-module-2.16.2.tgz",
+ "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==",
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-function": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/is-function/-/is-function-1.0.2.tgz",
+ "integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==",
+ "license": "MIT"
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/jimp": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmmirror.com/jimp/-/jimp-0.10.3.tgz",
+ "integrity": "sha512-meVWmDMtyUG5uYjFkmzu0zBgnCvvxwWNi27c4cg55vWNVC9ES4Lcwb+ogx+uBBQE3Q+dLKjXaLl0JVW+nUNwbQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.7.2",
+ "@jimp/custom": "^0.10.3",
+ "@jimp/plugins": "^0.10.3",
+ "@jimp/types": "^0.10.3",
+ "core-js": "^3.4.1",
+ "regenerator-runtime": "^0.13.3"
+ }
+ },
+ "node_modules/jimp/node_modules/regenerator-runtime": {
+ "version": "0.13.11",
+ "resolved": "https://registry.npmmirror.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
+ "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
+ "license": "MIT"
+ },
+ "node_modules/jpeg-js": {
+ "version": "0.3.7",
+ "resolved": "https://registry.npmmirror.com/jpeg-js/-/jpeg-js-0.3.7.tgz",
+ "integrity": "sha512-9IXdWudL61npZjvLuVe/ktHiA41iE8qFyLB+4VDTblEsWBzeg8WQTlktdUK4CdncUqtUgUg0bbOmTE2bKBKaBQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "license": "MIT"
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/jsonc-parser": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmmirror.com/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
+ "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
+ "license": "MIT"
+ },
+ "node_modules/jsonfile": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmmirror.com/jsonfile/-/jsonfile-6.2.1.tgz",
+ "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
+ "license": "MIT",
+ "dependencies": {
+ "universalify": "^2.0.0"
+ },
+ "optionalDependencies": {
+ "graceful-fs": "^4.1.6"
+ }
+ },
+ "node_modules/lcid": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmmirror.com/lcid/-/lcid-3.1.1.tgz",
+ "integrity": "sha512-M6T051+5QCGLBQb8id3hdvIW8+zeFV2FyBGFS9IEK5H9Wt4MueD4bW1eWikpHgZp+5xR3l5c8pZUkQsIA0BFZg==",
+ "license": "MIT",
+ "dependencies": {
+ "invert-kv": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/licia": {
+ "version": "1.48.1",
+ "resolved": "https://registry.npmmirror.com/licia/-/licia-1.48.1.tgz",
+ "integrity": "sha512-euqxHtJMyMOTiEVacSQfPSaHhIuzd9IpuKpQY6V5oj6C3rZs9DxCmegRlbtt2FW9fDdLrQpvKjwXTWglIbypuQ==",
+ "license": "MIT"
+ },
+ "node_modules/lilconfig": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmmirror.com/lilconfig/-/lilconfig-2.1.0.tgz",
+ "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/lines-and-columns": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-2.0.4.tgz",
+ "integrity": "sha512-wM1+Z03eypVAVUCE7QdSqpVIvelbOakn1M0bPDoA4SGWPx3sNDVUiMo3L6To6WWGClB7VyXnhQ4Sn7gxiJbE6A==",
+ "license": "MIT",
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ }
+ },
+ "node_modules/load-bmfont": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmmirror.com/load-bmfont/-/load-bmfont-1.4.2.tgz",
+ "integrity": "sha512-qElWkmjW9Oq1F9EI5Gt7aD9zcdHb9spJCW1L/dmPf7KzCCEJxq8nhHz5eCgI9aMf7vrG/wyaCqdsI+Iy9ZTlog==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-equal": "0.0.1",
+ "mime": "^1.3.4",
+ "parse-bmfont-ascii": "^1.0.3",
+ "parse-bmfont-binary": "^1.0.5",
+ "parse-bmfont-xml": "^1.1.4",
+ "phin": "^3.7.1",
+ "xhr": "^2.0.1",
+ "xtend": "^4.0.0"
+ }
+ },
+ "node_modules/load-bmfont/node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmmirror.com/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/load-bmfont/node_modules/phin": {
+ "version": "3.7.1",
+ "resolved": "https://registry.npmmirror.com/phin/-/phin-3.7.1.tgz",
+ "integrity": "sha512-GEazpTWwTZaEQ9RhL7Nyz0WwqilbqgLahDM3D0hxWwmVDI52nXEybHqiN6/elwpkJBhcuj+WbBu+QfT0uhPGfQ==",
+ "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
+ "license": "MIT",
+ "dependencies": {
+ "centra": "^2.7.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/loader-utils": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmmirror.com/loader-utils/-/loader-utils-3.3.1.tgz",
+ "integrity": "sha512-FMJTLMXfCLMLfJxcX9PFqX5qD88Z5MRGaZCVzfuqeZSPsyiBzs+pahDQjbIWz2QIzPZz0NX9Zy4FX3lmK6YHIg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12.13.0"
+ }
+ },
+ "node_modules/local-pkg": {
+ "version": "0.5.1",
+ "resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-0.5.1.tgz",
+ "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==",
+ "license": "MIT",
+ "dependencies": {
+ "mlly": "^1.7.3",
+ "pkg-types": "^1.2.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/localstorage-polyfill": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/localstorage-polyfill/-/localstorage-polyfill-1.0.1.tgz",
+ "integrity": "sha512-m4iHVZxFH5734oQcPKU08025gIz2+4bjWR9lulP8ZYxEJR0BpA0w32oJmkzh8y3UI9ci7xCBehQDc3oA1X+VHw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/lodash.camelcase": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmmirror.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
+ "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.debounce": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmmirror.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
+ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "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==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/meow": {
+ "version": "13.2.0",
+ "resolved": "https://registry.npmmirror.com/meow/-/meow-13.2.0.tgz",
+ "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/merge": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmmirror.com/merge/-/merge-2.1.1.tgz",
+ "integrity": "sha512-jz+Cfrg9GWOZbQAnDQ4hlVnQky+341Yk5ru8bZSe6sIDTCIg8n9i/u7hSQGSVOF3C7lH6mGtqjkiT9G4wFLL0w==",
+ "license": "MIT"
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmmirror.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmmirror.com/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/micromatch/node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/mime": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmmirror.com/mime/-/mime-3.0.0.tgz",
+ "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "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==",
+ "dev": true,
+ "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==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/min-document": {
+ "version": "2.19.2",
+ "resolved": "https://registry.npmmirror.com/min-document/-/min-document-2.19.2.tgz",
+ "integrity": "sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A==",
+ "license": "MIT",
+ "dependencies": {
+ "dom-walk": "^0.1.0"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "9.0.9",
+ "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz",
+ "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/mkdirp": {
+ "version": "0.5.6",
+ "resolved": "https://registry.npmmirror.com/mkdirp/-/mkdirp-0.5.6.tgz",
+ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
+ "license": "MIT",
+ "dependencies": {
+ "minimist": "^1.2.6"
+ },
+ "bin": {
+ "mkdirp": "bin/cmd.js"
+ }
+ },
+ "node_modules/mlly": {
+ "version": "1.8.2",
+ "resolved": "https://registry.npmmirror.com/mlly/-/mlly-1.8.2.tgz",
+ "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==",
+ "license": "MIT",
+ "dependencies": {
+ "acorn": "^8.16.0",
+ "pathe": "^2.0.3",
+ "pkg-types": "^1.3.1",
+ "ufo": "^1.6.3"
+ }
+ },
+ "node_modules/module-alias": {
+ "version": "2.3.4",
+ "resolved": "https://registry.npmmirror.com/module-alias/-/module-alias-2.3.4.tgz",
+ "integrity": "sha512-bOclZt8hkpuGgSSoG07PKmvzTizROilUTvLNyrMqvlC9snhs7y7GzjNWAVbISIOlhCP1T14rH1PDAV9iNyBq/w==",
+ "license": "MIT"
+ },
+ "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/nanoid": {
+ "version": "3.3.12",
+ "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.12.tgz",
+ "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+ "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/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/node-addon-api": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-7.1.1.tgz",
+ "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.38",
+ "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.38.tgz",
+ "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==",
+ "license": "MIT"
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/omggif": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmmirror.com/omggif/-/omggif-1.0.10.tgz",
+ "integrity": "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==",
+ "license": "MIT"
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/os-locale-s-fix": {
+ "version": "1.0.8-fix-1",
+ "resolved": "https://registry.npmmirror.com/os-locale-s-fix/-/os-locale-s-fix-1.0.8-fix-1.tgz",
+ "integrity": "sha512-Sv0OvhPiMutICiwORAUefv02DCPb62IelBmo8ZsSrRHyI3FStqIWZvjqDkvtjU+lcujo7UNir+dCwKSqlEQ/5w==",
+ "license": "MIT",
+ "dependencies": {
+ "lcid": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=10",
+ "yarn": "^1.22.4"
+ }
+ },
+ "node_modules/pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmmirror.com/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+ "license": "(MIT AND Zlib)"
+ },
+ "node_modules/parse-bmfont-ascii": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmmirror.com/parse-bmfont-ascii/-/parse-bmfont-ascii-1.0.6.tgz",
+ "integrity": "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA==",
+ "license": "MIT"
+ },
+ "node_modules/parse-bmfont-binary": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmmirror.com/parse-bmfont-binary/-/parse-bmfont-binary-1.0.6.tgz",
+ "integrity": "sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA==",
+ "license": "MIT"
+ },
+ "node_modules/parse-bmfont-xml": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmmirror.com/parse-bmfont-xml/-/parse-bmfont-xml-1.1.6.tgz",
+ "integrity": "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA==",
+ "license": "MIT",
+ "dependencies": {
+ "xml-parse-from-string": "^1.0.0",
+ "xml2js": "^0.5.0"
+ }
+ },
+ "node_modules/parse-headers": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmmirror.com/parse-headers/-/parse-headers-2.0.6.tgz",
+ "integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==",
+ "license": "MIT"
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "license": "MIT"
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "license": "MIT"
+ },
+ "node_modules/phin": {
+ "version": "2.9.3",
+ "resolved": "https://registry.npmmirror.com/phin/-/phin-2.9.3.tgz",
+ "integrity": "sha512-CzFr90qM24ju5f88quFC/6qohjC144rehe5n6DH900lgXmUe86+xCKc10ev56gRKC4/BkHUoG4uSiQgBiIXwDA==",
+ "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
+ "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==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pify": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmmirror.com/pify/-/pify-2.3.0.tgz",
+ "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/pixelmatch": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmmirror.com/pixelmatch/-/pixelmatch-4.0.2.tgz",
+ "integrity": "sha512-J8B6xqiO37sU/gkcMglv6h5Jbd9xNER7aHzpfRdNmV4IbQBzBpe4l9XmbG+xPF/znacgu2jfEw+wHffaq/YkXA==",
+ "license": "ISC",
+ "dependencies": {
+ "pngjs": "^3.0.0"
+ },
+ "bin": {
+ "pixelmatch": "bin/pixelmatch"
+ }
+ },
+ "node_modules/pkg-types": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-1.3.1.tgz",
+ "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
+ "license": "MIT",
+ "dependencies": {
+ "confbox": "^0.1.8",
+ "mlly": "^1.7.4",
+ "pathe": "^2.0.1"
+ }
+ },
+ "node_modules/pngjs": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmmirror.com/pngjs/-/pngjs-3.4.0.tgz",
+ "integrity": "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.14",
+ "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.14.tgz",
+ "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
+ "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.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/postcss-import": {
+ "version": "14.1.0",
+ "resolved": "https://registry.npmmirror.com/postcss-import/-/postcss-import-14.1.0.tgz",
+ "integrity": "sha512-flwI+Vgm4SElObFVPpTIT7SU7R3qk2L7PyduMcokiaVKuWv9d/U+Gm/QAd8NDLuykTWTkcrjOeD2Pp1rMeBTGw==",
+ "license": "MIT",
+ "dependencies": {
+ "postcss-value-parser": "^4.0.0",
+ "read-cache": "^1.0.0",
+ "resolve": "^1.1.7"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.0.0"
+ }
+ },
+ "node_modules/postcss-load-config": {
+ "version": "3.1.4",
+ "resolved": "https://registry.npmmirror.com/postcss-load-config/-/postcss-load-config-3.1.4.tgz",
+ "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==",
+ "license": "MIT",
+ "dependencies": {
+ "lilconfig": "^2.0.5",
+ "yaml": "^1.10.2"
+ },
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ "peerDependencies": {
+ "postcss": ">=8.0.9",
+ "ts-node": ">=9.0.0"
+ },
+ "peerDependenciesMeta": {
+ "postcss": {
+ "optional": true
+ },
+ "ts-node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/postcss-modules": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmmirror.com/postcss-modules/-/postcss-modules-4.3.1.tgz",
+ "integrity": "sha512-ItUhSUxBBdNamkT3KzIZwYNNRFKmkJrofvC2nWab3CPKhYBQ1f27XXh1PAPE27Psx58jeelPsxWB/+og+KEH0Q==",
+ "license": "MIT",
+ "dependencies": {
+ "generic-names": "^4.0.0",
+ "icss-replace-symbols": "^1.1.0",
+ "lodash.camelcase": "^4.3.0",
+ "postcss-modules-extract-imports": "^3.0.0",
+ "postcss-modules-local-by-default": "^4.0.0",
+ "postcss-modules-scope": "^3.0.0",
+ "postcss-modules-values": "^4.0.0",
+ "string-hash": "^1.1.1"
+ },
+ "peerDependencies": {
+ "postcss": "^8.0.0"
+ }
+ },
+ "node_modules/postcss-modules-extract-imports": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmmirror.com/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz",
+ "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==",
+ "license": "ISC",
+ "engines": {
+ "node": "^10 || ^12 || >= 14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/postcss-modules-local-by-default": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmmirror.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz",
+ "integrity": "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==",
+ "license": "MIT",
+ "dependencies": {
+ "icss-utils": "^5.0.0",
+ "postcss-selector-parser": "^7.0.0",
+ "postcss-value-parser": "^4.1.0"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >= 14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/postcss-modules-local-by-default/node_modules/postcss-selector-parser": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
+ "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
+ "license": "MIT",
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postcss-modules-scope": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmmirror.com/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz",
+ "integrity": "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==",
+ "license": "ISC",
+ "dependencies": {
+ "postcss-selector-parser": "^7.0.0"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >= 14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/postcss-modules-scope/node_modules/postcss-selector-parser": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
+ "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
+ "license": "MIT",
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postcss-modules-values": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmmirror.com/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz",
+ "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==",
+ "license": "ISC",
+ "dependencies": {
+ "icss-utils": "^5.0.0"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >= 14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/postcss-selector-parser": {
+ "version": "6.1.2",
+ "resolved": "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
+ "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
+ "license": "MIT",
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postcss-value-parser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+ "license": "MIT"
+ },
+ "node_modules/process": {
+ "version": "0.11.10",
+ "resolved": "https://registry.npmmirror.com/process/-/process-0.11.10.tgz",
+ "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6.0"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/qrcode-reader": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmmirror.com/qrcode-reader/-/qrcode-reader-1.0.4.tgz",
+ "integrity": "sha512-rRjALGNh9zVqvweg1j5OKIQKNsw3bLC+7qwlnead5K/9cb1cEIAGkwikt/09U0K+2IDWGD9CC6SP7tHAjUeqvQ==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/qrcode-terminal": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmmirror.com/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz",
+ "integrity": "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==",
+ "bin": {
+ "qrcode-terminal": "bin/qrcode-terminal.js"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.14.2",
+ "resolved": "https://registry.npmmirror.com/qs/-/qs-6.14.2.tgz",
+ "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/quansync": {
+ "version": "0.2.11",
+ "resolved": "https://registry.npmmirror.com/quansync/-/quansync-0.2.11.tgz",
+ "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/antfu"
+ },
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/sxzz"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmmirror.com/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.3",
+ "resolved": "https://registry.npmmirror.com/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/read-cache": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmmirror.com/read-cache/-/read-cache-1.0.0.tgz",
+ "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
+ "license": "MIT",
+ "dependencies": {
+ "pify": "^2.3.0"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "license": "MIT",
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/readdirp/node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/regenerate": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmmirror.com/regenerate/-/regenerate-1.4.2.tgz",
+ "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/regenerate-unicode-properties": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmmirror.com/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz",
+ "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "regenerate": "^1.4.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/regenerator-runtime": {
+ "version": "0.14.1",
+ "resolved": "https://registry.npmmirror.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
+ "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/regexpu-core": {
+ "version": "6.4.0",
+ "resolved": "https://registry.npmmirror.com/regexpu-core/-/regexpu-core-6.4.0.tgz",
+ "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "regenerate": "^1.4.2",
+ "regenerate-unicode-properties": "^10.2.2",
+ "regjsgen": "^0.8.0",
+ "regjsparser": "^0.13.0",
+ "unicode-match-property-ecmascript": "^2.0.0",
+ "unicode-match-property-value-ecmascript": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/regjsgen": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmmirror.com/regjsgen/-/regjsgen-0.8.0.tgz",
+ "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/regjsparser": {
+ "version": "0.13.1",
+ "resolved": "https://registry.npmmirror.com/regjsparser/-/regjsparser-0.13.1.tgz",
+ "integrity": "sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "jsesc": "~3.1.0"
+ },
+ "bin": {
+ "regjsparser": "bin/parser"
+ }
+ },
+ "node_modules/resolve": {
+ "version": "1.22.12",
+ "resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.12.tgz",
+ "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "is-core-module": "^2.16.1",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz",
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
+ "license": "MIT",
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.60.3.tgz",
+ "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.60.3",
+ "@rollup/rollup-android-arm64": "4.60.3",
+ "@rollup/rollup-darwin-arm64": "4.60.3",
+ "@rollup/rollup-darwin-x64": "4.60.3",
+ "@rollup/rollup-freebsd-arm64": "4.60.3",
+ "@rollup/rollup-freebsd-x64": "4.60.3",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.60.3",
+ "@rollup/rollup-linux-arm-musleabihf": "4.60.3",
+ "@rollup/rollup-linux-arm64-gnu": "4.60.3",
+ "@rollup/rollup-linux-arm64-musl": "4.60.3",
+ "@rollup/rollup-linux-loong64-gnu": "4.60.3",
+ "@rollup/rollup-linux-loong64-musl": "4.60.3",
+ "@rollup/rollup-linux-ppc64-gnu": "4.60.3",
+ "@rollup/rollup-linux-ppc64-musl": "4.60.3",
+ "@rollup/rollup-linux-riscv64-gnu": "4.60.3",
+ "@rollup/rollup-linux-riscv64-musl": "4.60.3",
+ "@rollup/rollup-linux-s390x-gnu": "4.60.3",
+ "@rollup/rollup-linux-x64-gnu": "4.60.3",
+ "@rollup/rollup-linux-x64-musl": "4.60.3",
+ "@rollup/rollup-openbsd-x64": "4.60.3",
+ "@rollup/rollup-openharmony-arm64": "4.60.3",
+ "@rollup/rollup-win32-arm64-msvc": "4.60.3",
+ "@rollup/rollup-win32-ia32-msvc": "4.60.3",
+ "@rollup/rollup-win32-x64-gnu": "4.60.3",
+ "@rollup/rollup-win32-x64-msvc": "4.60.3",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/rollup/node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/rxjs": {
+ "version": "7.8.2",
+ "resolved": "https://registry.npmmirror.com/rxjs/-/rxjs-7.8.2.tgz",
+ "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.1.0"
+ }
+ },
+ "node_modules/safe-area-insets": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmmirror.com/safe-area-insets/-/safe-area-insets-1.4.1.tgz",
+ "integrity": "sha512-r/nRWTjFGhhm3w1Z6Kd/jY11srN+lHt2mNl1E/emQGW8ic7n3Avu4noibklfSM+Y34peNphHD/BSZecav0sXYQ==",
+ "license": "ISC"
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/sass": {
+ "version": "1.99.0",
+ "resolved": "https://registry.npmmirror.com/sass/-/sass-1.99.0.tgz",
+ "integrity": "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chokidar": "^4.0.0",
+ "immutable": "^5.1.5",
+ "source-map-js": ">=0.6.2 <2.0.0"
+ },
+ "bin": {
+ "sass": "sass.js"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "optionalDependencies": {
+ "@parcel/watcher": "^2.4.1"
+ }
+ },
+ "node_modules/sass-embedded": {
+ "version": "1.99.0",
+ "resolved": "https://registry.npmmirror.com/sass-embedded/-/sass-embedded-1.99.0.tgz",
+ "integrity": "sha512-gF/juR1aX02lZHkvwxdF80SapkQeg2fetoDF6gIQkNbSw5YEUFspMkyGTjPjgZSgIHuZpy+Wz4PlebKnLXMjdg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@bufbuild/protobuf": "^2.5.0",
+ "colorjs.io": "^0.5.0",
+ "immutable": "^5.1.5",
+ "rxjs": "^7.4.0",
+ "supports-color": "^8.1.1",
+ "sync-child-process": "^1.0.2",
+ "varint": "^6.0.0"
+ },
+ "bin": {
+ "sass": "dist/bin/sass.js"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ },
+ "optionalDependencies": {
+ "sass-embedded-all-unknown": "1.99.0",
+ "sass-embedded-android-arm": "1.99.0",
+ "sass-embedded-android-arm64": "1.99.0",
+ "sass-embedded-android-riscv64": "1.99.0",
+ "sass-embedded-android-x64": "1.99.0",
+ "sass-embedded-darwin-arm64": "1.99.0",
+ "sass-embedded-darwin-x64": "1.99.0",
+ "sass-embedded-linux-arm": "1.99.0",
+ "sass-embedded-linux-arm64": "1.99.0",
+ "sass-embedded-linux-musl-arm": "1.99.0",
+ "sass-embedded-linux-musl-arm64": "1.99.0",
+ "sass-embedded-linux-musl-riscv64": "1.99.0",
+ "sass-embedded-linux-musl-x64": "1.99.0",
+ "sass-embedded-linux-riscv64": "1.99.0",
+ "sass-embedded-linux-x64": "1.99.0",
+ "sass-embedded-unknown-all": "1.99.0",
+ "sass-embedded-win32-arm64": "1.99.0",
+ "sass-embedded-win32-x64": "1.99.0"
+ }
+ },
+ "node_modules/sass-embedded-all-unknown": {
+ "version": "1.99.0",
+ "resolved": "https://registry.npmmirror.com/sass-embedded-all-unknown/-/sass-embedded-all-unknown-1.99.0.tgz",
+ "integrity": "sha512-qPIRG8Uhjo6/OKyAKixTnwMliTz+t9K6Duk0mx5z+K7n0Ts38NSJz2sjDnc7cA/8V9Lb3q09H38dZ1CLwD+ssw==",
+ "cpu": [
+ "!arm",
+ "!arm64",
+ "!riscv64",
+ "!x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "sass": "1.99.0"
+ }
+ },
+ "node_modules/sass-embedded-android-arm": {
+ "version": "1.99.0",
+ "resolved": "https://registry.npmmirror.com/sass-embedded-android-arm/-/sass-embedded-android-arm-1.99.0.tgz",
+ "integrity": "sha512-EHvJ0C7/VuP78Qr6f8gIUVUmCqIorEQpw2yp3cs3SMg02ZuumlhjXvkTcFBxHmFdFR23vTNk1WnhY6QSeV1nFQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/sass-embedded-android-arm64": {
+ "version": "1.99.0",
+ "resolved": "https://registry.npmmirror.com/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.99.0.tgz",
+ "integrity": "sha512-fNHhdnP23yqqieCbAdym4N47AleSwjbNt6OYIYx4DdACGdtERjQB4iOX/TaKsW034MupfF7SjnAAK8w7Ptldtg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/sass-embedded-android-riscv64": {
+ "version": "1.99.0",
+ "resolved": "https://registry.npmmirror.com/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.99.0.tgz",
+ "integrity": "sha512-4zqDFRvgGDTL5vTHuIhRxUpXFoh0Cy7Gm5Ywk19ASd8Settmd14YdPRZPmMxfgS1GH292PofV1fq1ifiSEJWBw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/sass-embedded-android-x64": {
+ "version": "1.99.0",
+ "resolved": "https://registry.npmmirror.com/sass-embedded-android-x64/-/sass-embedded-android-x64-1.99.0.tgz",
+ "integrity": "sha512-Uk53k/dGYt04RjOL4gFjZ0Z9DH9DKh8IA8WsXUkNqsxerAygoy3zqRBS2zngfE9K2jiOM87q+1R1p87ory9oQQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/sass-embedded-darwin-arm64": {
+ "version": "1.99.0",
+ "resolved": "https://registry.npmmirror.com/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.99.0.tgz",
+ "integrity": "sha512-u61/7U3IGLqoO6gL+AHeiAtlTPFwJK1+964U8gp45ZN0hzh1yrARf5O1mivXv8NnNgJvbG2wWJbiNZP0lG/lTg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/sass-embedded-darwin-x64": {
+ "version": "1.99.0",
+ "resolved": "https://registry.npmmirror.com/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.99.0.tgz",
+ "integrity": "sha512-j/kkk/NcXdIameLezSfXjgCiBkVcA+G60AXrX768/3g0miK1g7M9dj7xOhCb1i7/wQeiEI3rw2LLuO63xRIn4A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/sass-embedded-linux-arm": {
+ "version": "1.99.0",
+ "resolved": "https://registry.npmmirror.com/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.99.0.tgz",
+ "integrity": "sha512-d4IjJZrX2+AwB2YCy1JySwdptJECNP/WfAQLUl8txI3ka8/d3TUI155GtelnoZUkio211PwIeFvvAeZ9RXPQnw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": "glibc",
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/sass-embedded-linux-arm64": {
+ "version": "1.99.0",
+ "resolved": "https://registry.npmmirror.com/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.99.0.tgz",
+ "integrity": "sha512-btNcFpItcB56L40n8hDeL7sRSMLDXQ56nB5h2deddJx1n60rpKSElJmkaDGHtpkrY+CTtDRV0FZDjHeTJddYew==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": "glibc",
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/sass-embedded-linux-musl-arm": {
+ "version": "1.99.0",
+ "resolved": "https://registry.npmmirror.com/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.99.0.tgz",
+ "integrity": "sha512-2gvHOupgIw3ytatXT4nFUow71LFbuOZPEwG+HUzcNQDH8ue4Ez8cr03vsv5MDv3lIjOKcXwDvWD980t18MwkoQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": "musl",
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/sass-embedded-linux-musl-arm64": {
+ "version": "1.99.0",
+ "resolved": "https://registry.npmmirror.com/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.99.0.tgz",
+ "integrity": "sha512-Hi2bt/IrM5P4FBKz6EcHAlniwfpoz9mnTdvSd58y+avA3SANM76upIkAdSayA8ZGwyL3gZokru1AKDPF9lJDNw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": "musl",
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/sass-embedded-linux-musl-riscv64": {
+ "version": "1.99.0",
+ "resolved": "https://registry.npmmirror.com/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.99.0.tgz",
+ "integrity": "sha512-mKqGvVaJ9rHMqyZsF0kikQe4NO0f4osb67+X6nLhBiVDKvyazQHJ3zJQreNefIE36yL2sjHIclSB//MprzaQDg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": "musl",
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/sass-embedded-linux-musl-x64": {
+ "version": "1.99.0",
+ "resolved": "https://registry.npmmirror.com/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.99.0.tgz",
+ "integrity": "sha512-huhgOMmOc30r7CH7qbRbT9LerSEGSnWuS4CYNOskr9BvNeQp4dIneFufNRGZ7hkOAxUM8DglxIZJN/cyAT95Ew==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": "musl",
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/sass-embedded-linux-riscv64": {
+ "version": "1.99.0",
+ "resolved": "https://registry.npmmirror.com/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.99.0.tgz",
+ "integrity": "sha512-mevFPIFAVhrH90THifxLfOntFmHtcEKOcdWnep2gJ0X4DVva4AiVIRlQe/7w9JFx5+gnDRE1oaJJkzuFUuYZsA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": "glibc",
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/sass-embedded-linux-x64": {
+ "version": "1.99.0",
+ "resolved": "https://registry.npmmirror.com/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.99.0.tgz",
+ "integrity": "sha512-9k7IkULqIZdCIVt4Mboryt6vN8Mjmm3EhI1P3mClU5y5i3wLK5ExC3cbVWk047KsID/fvB1RLslqghXJx5BoxA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": "glibc",
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/sass-embedded-unknown-all": {
+ "version": "1.99.0",
+ "resolved": "https://registry.npmmirror.com/sass-embedded-unknown-all/-/sass-embedded-unknown-all-1.99.0.tgz",
+ "integrity": "sha512-P7MxiUtL/XzGo3PX0CaB8lNNEFLQWKikPA8pbKytx9ZCLZSDkt2NJcdAbblB/sqMs4AV3EK2NadV8rI/diq3xg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "!android",
+ "!darwin",
+ "!linux",
+ "!win32"
+ ],
+ "dependencies": {
+ "sass": "1.99.0"
+ }
+ },
+ "node_modules/sass-embedded-win32-arm64": {
+ "version": "1.99.0",
+ "resolved": "https://registry.npmmirror.com/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.99.0.tgz",
+ "integrity": "sha512-8whpsW7S+uO8QApKfQuc36m3P9EISzbVZOgC79goob4qGy09u8Gz/rYvw8h1prJDSjltpHGhOzBE6LDz7WvzVw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/sass-embedded-win32-x64": {
+ "version": "1.99.0",
+ "resolved": "https://registry.npmmirror.com/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.99.0.tgz",
+ "integrity": "sha512-ipuOv1R2K4MHeuCEAZGpuUbAgma4gb0sdacyrTjJtMOy/OY9UvWfVlwErdB09KIkp4fPDpQJDJfvYN6bC8jeNg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/sass/node_modules/chokidar": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-4.0.3.tgz",
+ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "readdirp": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 14.16.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/sass/node_modules/readdirp": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-4.1.2.tgz",
+ "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.18.0"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/sax": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmmirror.com/sax/-/sax-1.6.0.tgz",
+ "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=11.0.0"
+ }
+ },
+ "node_modules/scule": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmmirror.com/scule/-/scule-1.3.0.tgz",
+ "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/send": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmmirror.com/send/-/send-0.19.2.tgz",
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.1",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "~2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "~2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/send/node_modules/debug/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/send/node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmmirror.com/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/serve-static": {
+ "version": "1.16.3",
+ "resolved": "https://registry.npmmirror.com/serve-static/-/serve-static-1.16.3.tgz",
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "~0.19.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "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/source-map-support": {
+ "version": "0.5.21",
+ "resolved": "https://registry.npmmirror.com/source-map-support/-/source-map-support-0.5.21.tgz",
+ "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "buffer-from": "^1.0.0",
+ "source-map": "^0.6.0"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/string-hash": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmmirror.com/string-hash/-/string-hash-1.1.3.tgz",
+ "integrity": "sha512-kJUvRUFK49aub+a7T1nNE66EJbZBMnBgoC1UbCZ5n6bsZKBRga4KgBRTMn/pFkeCZSYtNeSyMxPDM0AXWELk2A==",
+ "license": "CC0-1.0"
+ },
+ "node_modules/strip-literal": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmmirror.com/strip-literal/-/strip-literal-2.1.1.tgz",
+ "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^9.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/strip-literal/node_modules/js-tokens": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-9.0.1.tgz",
+ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+ "license": "MIT"
+ },
+ "node_modules/supports-color": {
+ "version": "8.1.1",
+ "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-8.1.1.tgz",
+ "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/supports-color?sponsor=1"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/sync-child-process": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/sync-child-process/-/sync-child-process-1.0.2.tgz",
+ "integrity": "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "sync-message-port": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/sync-message-port": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmmirror.com/sync-message-port/-/sync-message-port-1.2.0.tgz",
+ "integrity": "sha512-gAQ9qrUN/UCypHtGFbbe7Rc/f9bzO88IwrG8TDo/aMKAApKyD6E3W4Cm0EfhfBb6Z6SKt59tTCTfD+n1xmAvMg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/systemjs": {
+ "version": "6.15.1",
+ "resolved": "https://registry.npmmirror.com/systemjs/-/systemjs-6.15.1.tgz",
+ "integrity": "sha512-Nk8c4lXvMB98MtbmjX7JwJRgJOL8fluecYCfCeYBznwmpOs8Bf15hLM6z4z71EDAhQVrQrI+wt1aLWSXZq+hXA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tapable": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmmirror.com/tapable/-/tapable-2.3.3.tgz",
+ "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/terser": {
+ "version": "5.47.1",
+ "resolved": "https://registry.npmmirror.com/terser/-/terser-5.47.1.tgz",
+ "integrity": "sha512-tPbLXTI6ohPASb/1YViL428oEHu6/qv1OxqYnfaonVCFHqx4+wCd95pHrQWsL5X4pl90CTyW9piSAsS2L0VoMw==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "@jridgewell/source-map": "^0.3.3",
+ "acorn": "^8.15.0",
+ "commander": "^2.20.0",
+ "source-map-support": "~0.5.20"
+ },
+ "bin": {
+ "terser": "bin/terser"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/timm": {
+ "version": "1.7.1",
+ "resolved": "https://registry.npmmirror.com/timm/-/timm-1.7.1.tgz",
+ "integrity": "sha512-IjZc9KIotudix8bMaBW6QvMuq64BrJWFs1+4V0lXwWGQZwH+LnX87doAYhem4caOEusRP9/g6jVDQmZ8XOk1nw==",
+ "license": "MIT"
+ },
+ "node_modules/tinycolor2": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmmirror.com/tinycolor2/-/tinycolor2-1.6.0.tgz",
+ "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==",
+ "license": "MIT"
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD"
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmmirror.com/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ufo": {
+ "version": "1.6.4",
+ "resolved": "https://registry.npmmirror.com/ufo/-/ufo-1.6.4.tgz",
+ "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==",
+ "license": "MIT"
+ },
+ "node_modules/unicode-canonical-property-names-ecmascript": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmmirror.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz",
+ "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/unicode-match-property-ecmascript": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz",
+ "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "unicode-canonical-property-names-ecmascript": "^2.0.0",
+ "unicode-property-aliases-ecmascript": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/unicode-match-property-value-ecmascript": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmmirror.com/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz",
+ "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/unicode-property-aliases-ecmascript": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmmirror.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz",
+ "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/unimport": {
+ "version": "3.14.6",
+ "resolved": "https://registry.npmmirror.com/unimport/-/unimport-3.14.6.tgz",
+ "integrity": "sha512-CYvbDaTT04Rh8bmD8jz3WPmHYZRG/NnvYVzwD6V1YAlvvKROlAeNDUBhkBGzNav2RKaeuXvlWYaa1V4Lfi/O0g==",
+ "license": "MIT",
+ "dependencies": {
+ "@rollup/pluginutils": "^5.1.4",
+ "acorn": "^8.14.0",
+ "escape-string-regexp": "^5.0.0",
+ "estree-walker": "^3.0.3",
+ "fast-glob": "^3.3.3",
+ "local-pkg": "^1.0.0",
+ "magic-string": "^0.30.17",
+ "mlly": "^1.7.4",
+ "pathe": "^2.0.1",
+ "picomatch": "^4.0.2",
+ "pkg-types": "^1.3.0",
+ "scule": "^1.3.0",
+ "strip-literal": "^2.1.1",
+ "unplugin": "^1.16.1"
+ }
+ },
+ "node_modules/unimport/node_modules/confbox": {
+ "version": "0.2.4",
+ "resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.2.4.tgz",
+ "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
+ "license": "MIT"
+ },
+ "node_modules/unimport/node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/unimport/node_modules/local-pkg": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-1.1.2.tgz",
+ "integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==",
+ "license": "MIT",
+ "dependencies": {
+ "mlly": "^1.7.4",
+ "pkg-types": "^2.3.0",
+ "quansync": "^0.2.11"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/unimport/node_modules/local-pkg/node_modules/pkg-types": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-2.3.1.tgz",
+ "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==",
+ "license": "MIT",
+ "dependencies": {
+ "confbox": "^0.2.4",
+ "exsolve": "^1.0.8",
+ "pathe": "^2.0.3"
+ }
+ },
+ "node_modules/universalify": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmmirror.com/universalify/-/universalify-2.0.1.tgz",
+ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10.0.0"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/unplugin": {
+ "version": "1.16.1",
+ "resolved": "https://registry.npmmirror.com/unplugin/-/unplugin-1.16.1.tgz",
+ "integrity": "sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==",
+ "license": "MIT",
+ "dependencies": {
+ "acorn": "^8.14.0",
+ "webpack-virtual-modules": "^0.6.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/utif": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmmirror.com/utif/-/utif-2.0.1.tgz",
+ "integrity": "sha512-Z/S1fNKCicQTf375lIP9G8Sa1H/phcysstNrrSdZKj1f9g58J4NMgb5IgiEZN9/nLMPDwF0W7hdOe9Qq2IYoLg==",
+ "license": "MIT",
+ "dependencies": {
+ "pako": "^1.0.5"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/varint": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmmirror.com/varint/-/varint-6.0.0.tgz",
+ "integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/vite": {
+ "version": "5.2.8",
+ "resolved": "https://registry.npmmirror.com/vite/-/vite-5.2.8.tgz",
+ "integrity": "sha512-OyZR+c1CE8yeHw5V5t59aXsUPPVTHMDjEZz8MgguLL/Q7NblxhZUlTu9xSPqlsUO/y+X7dlU05jdhvyycD55DA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.20.1",
+ "postcss": "^8.4.38",
+ "rollup": "^4.13.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.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",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vue": {
+ "version": "3.4.21",
+ "resolved": "https://registry.npmmirror.com/vue/-/vue-3.4.21.tgz",
+ "integrity": "sha512-5hjyV/jLEIKD/jYl4cavMcnzKwjMKohureP8ejn3hhEjwhWIhWeuzL2kJAjzl/WyVsgPY56Sy4Z40C3lVshxXA==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-dom": "3.4.21",
+ "@vue/compiler-sfc": "3.4.21",
+ "@vue/runtime-dom": "3.4.21",
+ "@vue/server-renderer": "3.4.21",
+ "@vue/shared": "3.4.21"
+ },
+ "peerDependencies": {
+ "typescript": "*"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/webpack-virtual-modules": {
+ "version": "0.6.2",
+ "resolved": "https://registry.npmmirror.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
+ "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
+ "license": "MIT"
+ },
+ "node_modules/ws": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmmirror.com/ws/-/ws-8.20.0.tgz",
+ "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/xhr": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmmirror.com/xhr/-/xhr-2.6.0.tgz",
+ "integrity": "sha512-/eCGLb5rxjx5e3mF1A7s+pLlR6CGyqWN91fv1JgER5mVWg1MZmlhBvy9kjcsOdRk8RrIujotWyJamfyrp+WIcA==",
+ "license": "MIT",
+ "dependencies": {
+ "global": "~4.4.0",
+ "is-function": "^1.0.1",
+ "parse-headers": "^2.0.0",
+ "xtend": "^4.0.0"
+ }
+ },
+ "node_modules/xml-parse-from-string": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/xml-parse-from-string/-/xml-parse-from-string-1.0.1.tgz",
+ "integrity": "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g==",
+ "license": "MIT"
+ },
+ "node_modules/xml2js": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmmirror.com/xml2js/-/xml2js-0.5.0.tgz",
+ "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==",
+ "license": "MIT",
+ "dependencies": {
+ "sax": ">=0.6.0",
+ "xmlbuilder": "~11.0.0"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/xmlbuilder": {
+ "version": "11.0.1",
+ "resolved": "https://registry.npmmirror.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
+ "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/xmlhttprequest": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmmirror.com/xmlhttprequest/-/xmlhttprequest-1.8.0.tgz",
+ "integrity": "sha512-58Im/U0mlVBLM38NdZjHyhuMtCqa61469k2YP/AaPbvCoV9aQGUpbJBj1QRm2ytRiVQBD/fsw7L2bJGDVQswBA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/xregexp": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmmirror.com/xregexp/-/xregexp-3.1.0.tgz",
+ "integrity": "sha512-4Y1x6DyB8xRoxosooa6PlGWqmmSKatbzhrftZ7Purmm4B8R4qIEJG1A2hZsdz5DhmIqS0msC0I7KEq93GphEVg==",
+ "license": "MIT"
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmmirror.com/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "license": "ISC"
+ },
+ "node_modules/yaml": {
+ "version": "1.10.3",
+ "resolved": "https://registry.npmmirror.com/yaml/-/yaml-1.10.3.tgz",
+ "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 6"
+ }
+ }
+ }
+}
diff --git a/uni-app/package.json b/uni-app/package.json
index c5a6585..32b49e1 100644
--- a/uni-app/package.json
+++ b/uni-app/package.json
@@ -11,15 +11,18 @@
"dependencies": {
"@dcloudio/uni-app": "3.0.0-4010520240507001",
"@dcloudio/uni-components": "3.0.0-4010520240507001",
+ "@dcloudio/uni-h5": "3.0.0-4010520240507001",
"@dcloudio/uni-mp-weixin": "3.0.0-4010520240507001",
- "@uview-plus/uni-ui": "^3.0.0-alpha-3010520240507001",
- "vue": "^3.4.21"
+ "vue": "3.4.21"
},
"devDependencies": {
- "@dcloudio/types": "^3.4.8",
+ "@dcloudio/types": "3.4.8",
"@dcloudio/uni-cli-shared": "3.0.0-4010520240507001",
"@dcloudio/uni-stacktracey": "3.0.0-4010520240507001",
"@dcloudio/vite-plugin-uni": "3.0.0-4010520240507001",
- "vite": "^5.2.8"
+ "@vue/runtime-core": "3.4.21",
+ "sass": "^1.99.0",
+ "sass-embedded": "^1.99.0",
+ "vite": "5.2.8"
}
-}
\ No newline at end of file
+}
diff --git a/uni-app/public/favicon.ico b/uni-app/public/favicon.ico
new file mode 100644
index 0000000..04cc4b9
Binary files /dev/null and b/uni-app/public/favicon.ico differ
diff --git a/uni-app/public/favicon.svg b/uni-app/public/favicon.svg
new file mode 100644
index 0000000..8a28af1
--- /dev/null
+++ b/uni-app/public/favicon.svg
@@ -0,0 +1,10 @@
+
diff --git a/uni-app/src/App.vue b/uni-app/src/App.vue
index 9c61abc..5ad713b 100644
--- a/uni-app/src/App.vue
+++ b/uni-app/src/App.vue
@@ -1,110 +1,45 @@
-
-
-
-
-
-
+
+
-
\ No newline at end of file
+
+html, body, #app {
+ height: 100%;
+ width: 100%;
+}
+
+/* Fix: make uni-page scrollable and properly sized for fixed tabbar */
+uni-page {
+ height: calc(100% - 50px) !important;
+ overflow-y: auto !important;
+ overflow-x: hidden !important;
+ -webkit-overflow-scrolling: touch !important;
+}
+uni-page-body, uni-page-wrapper {
+ overflow-y: auto !important;
+ overflow-x: hidden !important;
+ -webkit-overflow-scrolling: touch !important;
+}
+
+/* Ensure the page head doesn't block scrolling */
+uni-page-head {
+ flex-shrink: 0;
+}
+
+/* The uni-tabbar is already position:fixed by the framework */
+uni-tabbar {
+ z-index: 999 !important;
+}
+
diff --git a/uni-app/src/components/tabbar/custom-tabbar.vue b/uni-app/src/components/tabbar/custom-tabbar.vue
index 383d41e..d7e444c 100644
--- a/uni-app/src/components/tabbar/custom-tabbar.vue
+++ b/uni-app/src/components/tabbar/custom-tabbar.vue
@@ -1,5 +1,5 @@
-
+
+
+
\ No newline at end of file
diff --git a/uni-app/src/manifest.json b/uni-app/src/manifest.json
new file mode 100644
index 0000000..d951778
--- /dev/null
+++ b/uni-app/src/manifest.json
@@ -0,0 +1,97 @@
+{
+ "name": "trademate",
+ "appid": "",
+ "description": "外贸小助手 - 智能翻译、营销素材、客户管理",
+ "versionName": "1.0.0",
+ "versionCode": "100",
+ "transformPx": false,
+ "app-plus": {
+ "usingComponents": true,
+ "nvueStyleCompiler": "uni-app",
+ "compilerVersion": 3,
+ "splashscreen": {
+ "alwaysShowBeforeRender": true,
+ "waiting": true,
+ "autoclose": true,
+ "delay": 0
+ },
+ "modules": {},
+ "distribute": {
+ "android": {
+ "permissions": []
+ },
+ "ios": {}
+ }
+ },
+ "quickapp": {},
+ "mp-weixin": {
+ "appid": "",
+ "setting": {
+ "urlCheck": false,
+ "es6": true,
+ "enhance": true,
+ "postcss": true,
+ "preloadBackgroundData": false,
+ "minified": true,
+ "newFeature": false,
+ "coverView": true,
+ "nodeModules": false,
+ "autoAudits": false,
+ "showShadowRootInWxmlPanel": true,
+ "scopeDataCheck": false,
+ "uglifyFileName": false,
+ "checkInvalidKey": true,
+ "checkSiteMap": true,
+ "uploadWithSourceMap": true,
+ "compileHotReLoad": false,
+ "lazyloadPlaceholderEnable": false,
+ "useMultiFrameRuntime": true,
+ "useApiHook": true,
+ "useApiHostProcess": true,
+ "babelSetting": {
+ "ignore": [],
+ "disablePlugins": [],
+ "outputPath": ""
+ },
+ "enableEngineNative": false,
+ "useIsolateContext": true,
+ "userConfirmedBundleSwitch": false,
+ "packNpmManually": false,
+ "packNpmRelationList": [],
+ "minifyWXSS": true,
+ "showES6CompileOption": false,
+ "minifyWXML": true
+ },
+ "usingComponents": true
+ },
+ "mp-alipay": {
+ "usingComponents": true
+ },
+ "mp-baidu": {
+ "usingComponents": true
+ },
+ "mp-toutiao": {
+ "usingComponents": true
+ },
+ "uniStatistics": {
+ "enable": false
+ },
+ "h5": {
+ "title": "外贸小助手",
+ "template": "index.html",
+ "router": {
+ "mode": "hash"
+ },
+ "devServer": {
+ "port": 5173,
+ "disableHostCheck": true,
+ "proxy": {
+ "/api": {
+ "target": "http://localhost:8000",
+ "changeOrigin": true
+ }
+ }
+ }
+ },
+ "vueVersion": "3"
+}
diff --git a/uni-app/src/pages.json b/uni-app/src/pages.json
index 00f40e3..12297b4 100644
--- a/uni-app/src/pages.json
+++ b/uni-app/src/pages.json
@@ -1,12 +1,5 @@
{
"pages": [
- {
- "path": "pages/login/login",
- "style": {
- "navigationStyle": "custom",
- "navigationBarTitleText": "登录"
- }
- },
{
"path": "pages/index/index",
"style": {
@@ -37,11 +30,72 @@
"navigationBarTitleText": "报价单"
}
},
+ {
+ "path": "pages/login/login",
+ "style": {
+ "navigationStyle": "custom",
+ "navigationBarTitleText": "登录"
+ }
+ },
{
"path": "pages/product/product",
"style": {
"navigationBarTitleText": "产品库"
}
+ },
+ {
+ "path": "pages/admin/admin",
+ "style": {
+ "navigationBarTitleText": "管理后台"
+ }
+ },
+ {
+ "path": "pages/analytics/analytics",
+ "style": {
+ "navigationBarTitleText": "数据分析"
+ }
+ },
+ {
+ "path": "pages/team/team",
+ "style": {
+ "navigationBarTitleText": "团队协作"
+ }
+ },
+ {
+ "path": "pages/agreement/privacy",
+ "style": {
+ "navigationBarTitleText": "隐私政策"
+ }
+ },
+ {
+ "path": "pages/agreement/terms",
+ "style": {
+ "navigationBarTitleText": "用户协议"
+ }
+ },
+ {
+ "path": "pages/notification/notification",
+ "style": {
+ "navigationBarTitleText": "通知中心"
+ }
+ },
+ {
+ "path": "pages/feedback/feedback",
+ "style": {
+ "navigationBarTitleText": "意见反馈"
+ }
+ },
+ {
+ "path": "pages/upgrade/upgrade",
+ "style": {
+ "navigationBarTitleText": "升级会员"
+ }
+ },
+ {
+ "path": "pages/followup/followup",
+ "style": {
+ "navigationBarTitleText": "智能跟进"
+ }
}
],
"globalStyle": {
@@ -49,5 +103,33 @@
"navigationBarTitleText": "外贸小助手",
"navigationBarBackgroundColor": "#1890ff",
"backgroundColor": "#f5f5f5"
+ },
+ "tabBar": {
+ "custom": true,
+ "color": "#666666",
+ "selectedColor": "#1890ff",
+ "backgroundColor": "#ffffff",
+ "list": [
+ {
+ "pagePath": "pages/index/index",
+ "text": "首页"
+ },
+ {
+ "pagePath": "pages/translate/translate",
+ "text": "翻译"
+ },
+ {
+ "pagePath": "pages/customers/customers",
+ "text": "客户"
+ },
+ {
+ "pagePath": "pages/marketing/marketing",
+ "text": "营销"
+ },
+ {
+ "pagePath": "pages/quotation/quotation",
+ "text": "报价"
+ }
+ ]
}
}
\ No newline at end of file
diff --git a/uni-app/src/pages/admin/admin.vue b/uni-app/src/pages/admin/admin.vue
new file mode 100644
index 0000000..f91d36b
--- /dev/null
+++ b/uni-app/src/pages/admin/admin.vue
@@ -0,0 +1,126 @@
+
+
+
+
+
+
+ {{ dashboard.users?.total || 0 }}
+ 用户总数
+
+
+ {{ dashboard.teams?.total || 0 }}
+ 团队数
+
+
+ {{ dashboard.customers?.total || 0 }}
+ 客户总数
+
+
+ {{ dashboard.usage?.today || 0 }}
+ 今日请求
+
+
+
+
+
+
+
+
+ {{ u.username }}
+ {{ u.tier }}
+
+ {{ formatTime(u.created_at) }}
+
+
+
+
+
+
+
+
+
+ {{ u.username || u.phone }}
+ {{ u.tier }}
+
+
+ 免费
+ Pro
+ 企业
+
+
+
+
+
+
+
+
+
+
diff --git a/uni-app/src/pages/agreement/privacy.vue b/uni-app/src/pages/agreement/privacy.vue
new file mode 100644
index 0000000..ec4274d
--- /dev/null
+++ b/uni-app/src/pages/agreement/privacy.vue
@@ -0,0 +1,51 @@
+
+
+
+ 隐私政策
+ 更新日期:2026-05-09
+
+
+ 一、信息收集
+ 我们收集您提供的手机号码、微信OpenID用于账号注册和登录。您在使用翻译、营销文案生成等功能时,我们仅处理您提交的文本内容,不会存储超出服务所需的个人信息。
+
+
+
+ 二、信息使用
+ 您的信息仅用于:提供翻译、客户管理、报价单生成等核心服务;优化AI翻译和回复建议的质量。我们不会将您的个人信息用于任何与上述服务无关的目的。
+
+
+
+ 三、信息存储与保护
+ 您的数据存储在中国境内的服务器上。我们采用行业标准的安全措施(包括加密传输、访问控制、定期安全审计)保护您的信息安全。
+
+
+
+ 四、信息共享
+ 我们不会向任何第三方出售或分享您的个人信息。AI翻译功能会调用第三方API(如DeepL、OpenAI、Anthropic),仅传输需要翻译的文本内容。
+
+
+
+ 五、您的权利
+ 您可以随时查看、修改或删除您的个人信息。账号注销功能可联系客服处理。您也可以通过设置中的开关控制AI功能的数据使用。
+
+
+
+ 六、联系我们
+ 如果您对本隐私政策有任何疑问,请通过应用内的反馈功能联系我们。我们会在15个工作日内回复。
+
+
+
+
+
+
+
+
diff --git a/uni-app/src/pages/agreement/terms.vue b/uni-app/src/pages/agreement/terms.vue
new file mode 100644
index 0000000..af3b365
--- /dev/null
+++ b/uni-app/src/pages/agreement/terms.vue
@@ -0,0 +1,56 @@
+
+
+
+ 用户协议
+ 更新日期:2026-05-09
+
+
+ 一、服务说明
+ 外贸小助手(TradeMate)是一款为外贸从业者提供AI翻译、客户管理、营销素材生成等功能的工具类应用。使用本服务即表示您同意本协议的全部条款。
+
+
+
+ 二、账号管理
+ 您注册时需提供真实有效的手机号码。账号仅限本人使用,禁止转借或出售。因账号密码泄露导致的损失由您自行承担。连续180天未登录的账号,我们保留回收的权利。
+
+
+
+ 三、使用规范
+ 您承诺不利用本服务从事违法违规活动,包括但不限于:上传违法信息、侵犯他人知识产权、发送垃圾信息等。违反规范可能导致服务被终止。
+
+
+
+ 四、付费服务
+ 免费版用户享有有限次数的翻译和文案生成功能。Pro版和企业版通过付费订阅获得更多功能和服务配额。付费后不支持无理由退款,如因服务故障导致的损失可申请补偿。
+
+
+
+ 五、AI服务免责
+ AI翻译和文案生成结果仅供参考,不构成专业建议。用户应对使用AI生成内容的外贸沟通行为自行负责。我们不保证翻译的绝对准确性。
+
+
+
+ 六、服务变更与终止
+ 我们保留随时修改或终止服务的权利,重大变更将提前30天通知。如您违反本协议,我们有权立即终止服务。
+
+
+
+ 七、法律适用
+ 本协议适用中华人民共和国法律。因本协议引起的争议,双方应友好协商解决;协商不成的,提交服务提供方所在地人民法院管辖。
+
+
+
+
+
+
+
+
diff --git a/uni-app/src/pages/analytics/analytics.vue b/uni-app/src/pages/analytics/analytics.vue
new file mode 100644
index 0000000..51567b2
--- /dev/null
+++ b/uni-app/src/pages/analytics/analytics.vue
@@ -0,0 +1,125 @@
+
+
+
+
+
+ 客户分析
+
+
+ {{ data.customers.total }}
+ 客户总数
+
+
+ {{ data.customers.silent_customers }}
+ 沉默客户
+
+
+
+ 按状态分布
+
+ {{ statusMap[status] || status }}
+
+
+
+ {{ count }}
+
+
+
+ 国家分布 (TOP 10)
+
+ {{ country }}
+ {{ count }}
+
+
+
+
+
+ 翻译统计
+
+
+ {{ data.translations.today }}
+ 今日翻译
+
+
+ {{ data.translations.total }}
+ 累计翻译
+
+
+
+
+
+ 报价单统计
+
+
+ {{ data.quotations.total }}
+ 报价单数
+
+
+ {{ data.quotations.total_accepted_value }}
+ 成交总额
+
+
+
+
+
+ 消息统计
+
+
+ {{ data.messages.today }}
+ 今日消息
+
+
+ {{ data.messages.total }}
+ 累计消息
+
+
+
+
+
+
+
+
+
diff --git a/uni-app/src/pages/customers/customers.vue b/uni-app/src/pages/customers/customers.vue
index 0916ca5..c24b984 100644
--- a/uni-app/src/pages/customers/customers.vue
+++ b/uni-app/src/pages/customers/customers.vue
@@ -1,5 +1,24 @@
+
+
+ {{ healthOverview.active }}
+ 活跃客户
+
+
+ {{ healthOverview.watch }}
+ 需关注
+
+
+ {{ healthOverview.critical }}
+ 高危流失
+
+
+ {{ healthOverview.total }}
+ 共计
+
+
+
{{ item.name }}
{{ getStatusText(item.status) }}
+ {{ getRiskText(item.risk_level) }}
+ {{ getHealthLabel(getHealthGrade(item.id)) }}
{{ item.company }}
@@ -54,9 +83,13 @@
最后联系: {{ formatTime(item.last_contact_at) }}
沉默 {{ item.silence_days }} 天
+
+ {{ r }}
+
详
+ 聊
编
删
@@ -67,8 +100,19 @@
暂无客户数据
-
- +
+
+
+ CSV
+ 导出
+
+
+ 导
+ 导入
+
+
+ +
+ 新增
+
@@ -121,8 +165,63 @@
-
+
+
+ 健康度评分
+
+ 响应趋势
+
+
+
+ {{ currentHealth.dimensions?.response_trend?.score || 0 }}
+ {{ trendLabel(currentHealth.dimensions?.response_trend?.trend) }}
+
+
+ 情感轨迹
+
+
+
+ {{ currentHealth.dimensions?.sentiment?.score || 0 }}
+ {{ currentHealth.dimensions?.sentiment?.label }}
+
+
+ 询盘深度
+
+
+
+ {{ currentHealth.dimensions?.inquiry_depth?.score || 0 }}
+ 信号 {{ currentHealth.dimensions.inquiry_depth.signal_count }}
+
+
+ 沉默天数
+
+
+
+ {{ currentHealth.dimensions?.silence?.score || 0 }}
+ {{ currentHealth.dimensions?.silence?.days || 0 }}天
+
+
+ 商业价值
+
+
+
+ {{ currentHealth.dimensions?.business_value?.score || 0 }}
+ ${{ formatValue(currentHealth.dimensions.business_value.total_value) }}
+
+
+ 💡
+ {{ currentHealth.suggestion }}
+
+
+
+
公司:
{{ currentCustomer.company }}
@@ -143,25 +242,51 @@
邮箱:
{{ currentCustomer.email }}
-
+
+
+
+
+
+
+
+ {{ msg.direction === 'inbound' ? '客户' : '我' }}
+ {{ msg.content }}
+ {{ formatTime(msg.created_at) }}
+
+
+ 暂无沟通记录
+
+
+
+
+
+
diff --git a/uni-app/src/pages/followup/followup.vue b/uni-app/src/pages/followup/followup.vue
new file mode 100644
index 0000000..d793bd0
--- /dev/null
+++ b/uni-app/src/pages/followup/followup.vue
@@ -0,0 +1,187 @@
+
+
+
+
+ {{ stats.pending }}
+ 待跟进
+
+
+ {{ stats.sent }}
+ 已发送
+
+
+ {{ stats.replied }}
+ 已回复
+
+
+ {{ stats.completion_rate }}%
+ 完成率
+
+
+
+
+ 待跟进
+ 历史记录
+
+
+
+
+
+ {{ item.content }}
+
+
+
+
+
+
+ 暂无待跟进客户 🎉
+
+
+
+
+
+
+ {{ (item.content || '').slice(0, 60) }}...
+ {{ formatTime(item.created_at) }}
+
+
+ 暂无跟进记录
+
+
+
+
+
+ 编辑跟进内容
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/uni-app/src/pages/index/index.vue b/uni-app/src/pages/index/index.vue
index b61891b..77530b7 100644
--- a/uni-app/src/pages/index/index.vue
+++ b/uni-app/src/pages/index/index.vue
@@ -1,14 +1,18 @@
\ No newline at end of file
+
+.followup-card {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ border-radius: 16rpx;
+ padding: 32rpx;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.followup-count {
+ font-size: 64rpx;
+ font-weight: bold;
+ color: #fff;
+}
+
+.followup-label {
+ font-size: 28rpx;
+ color: rgba(255, 255, 255, 0.9);
+ margin-top: 8rpx;
+}
+
+.followup-hint {
+ font-size: 22rpx;
+ color: rgba(255, 255, 255, 0.7);
+ margin-top: 12rpx;
+}
+
+.more-section {
+ background: #fff;
+ border-radius: 16rpx;
+ padding: 30rpx;
+ margin-top: 20rpx;
+}
+
+.more-grid {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 20rpx;
+ margin-top: 20rpx;
+}
+
+.more-item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ position: relative;
+}
+
+.more-icon {
+ width: 72rpx;
+ height: 72rpx;
+ background: #f0f5ff;
+ color: #667eea;
+ border-radius: 16rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 28rpx;
+ font-weight: 600;
+ margin-bottom: 8rpx;
+}
+
+.more-text {
+ font-size: 22rpx;
+ color: #666;
+}
+
+.notif-badge {
+ position: absolute; top: -8rpx; right: -8rpx;
+ background: #ff4d4f; color: #fff; font-size: 18rpx;
+ min-width: 30rpx; height: 30rpx; border-radius: 15rpx;
+ text-align: center; line-height: 30rpx; padding: 0 6rpx;
+}
+
+.onboarding-overlay {
+ position: fixed; top: 0; left: 0; right: 0; bottom: 0;
+ background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center;
+ z-index: 999; padding: 40rpx;
+}
+
+.onboarding-modal {
+ background: #fff; border-radius: 24rpx; padding: 48rpx 40rpx;
+ width: 100%; max-width: 600rpx;
+}
+
+.ob-title { font-size: 36rpx; font-weight: 700; color: #333; display: block; text-align: center; }
+.ob-subtitle { font-size: 26rpx; color: #999; display: block; text-align: center; margin: 16rpx 0 40rpx; }
+.ob-input-group { margin-bottom: 24rpx; }
+.ob-label { font-size: 26rpx; color: #666; display: block; margin-bottom: 8rpx; }
+.ob-input { width: 100%; height: 80rpx; background: #f5f5f5; border-radius: 12rpx; padding: 0 20rpx; font-size: 28rpx; box-sizing: border-box; }
+.ob-textarea { height: 160rpx; padding: 20rpx; }
+.ob-generating { padding: 60rpx 0; text-align: center; }
+.ob-gen-text { font-size: 28rpx; color: #1890ff; }
+.ob-result { margin: 24rpx 0; }
+.ob-result-title { font-size: 26rpx; color: #333; font-weight: 500; display: block; margin-bottom: 16rpx; }
+.ob-content-item {
+ padding: 16rpx; background: #f9f9f9; border-radius: 12rpx; margin-bottom: 12rpx;
+}
+.ob-content-text { font-size: 24rpx; color: #555; line-height: 1.6; }
+.ob-result-hint { font-size: 22rpx; color: #999; display: block; text-align: center; margin-top: 16rpx; }
+.ob-actions { margin-top: 32rpx; }
+.ob-btn { width: 100%; height: 88rpx; border-radius: 12rpx; font-size: 30rpx; border: none; display: flex; align-items: center; justify-content: center; }
+.ob-btn-primary { background: #1890ff; color: #fff; }
+.ob-skip { display: block; text-align: center; margin-top: 24rpx; font-size: 24rpx; color: #999; }
+
diff --git a/uni-app/src/pages/login/login.vue b/uni-app/src/pages/login/login.vue
index 711ae67..ab7ee13 100644
--- a/uni-app/src/pages/login/login.vue
+++ b/uni-app/src/pages/login/login.vue
@@ -1,74 +1,110 @@
-
+
TradeMate
外贸小助手
+ 让外贸更简单 · 让沟通更高效
-
- {{ isRegister ? '注册' : '登录' }}
-
-
-
+
+
+ 🌐
+ 智能翻译
+ 支持中英双语商务翻译,精准理解外贸术语
-
-
-
+
+ 💬
+ 智能回复
+ AI 生成专业商务回复,多种风格可选
-
-
-
+
+ 📊
+ 客户管理
+ 客户信息、跟进提醒、数据分析一体化
+
+ 💰
+ 报价单生成
+ 快速生成专业报价单,支持导出 PDF
+
+
- {{ error }}
-
-
@@ -83,12 +119,17 @@ const username = ref('')
const isRegister = ref(false)
const loading = ref(false)
const error = ref('')
+const showForm = ref(false)
const toggleMode = () => {
isRegister.value = !isRegister.value
error.value = ''
}
+const toggleShowForm = () => {
+ showForm.value = !showForm.value
+}
+
const handleSubmit = async () => {
if (!phone.value || !password.value) {
error.value = '请输入手机号和密码'
@@ -108,15 +149,17 @@ const handleSubmit = async () => {
await authApi.register(phone.value, password.value, username.value)
uni.showToast({ title: '注册成功,请登录', icon: 'success' })
isRegister.value = false
- } else {
- const res = await authApi.login(phone.value, password.value)
- uni.setStorageSync('token', res.access_token)
- uni.setStorageSync('userInfo', res.user)
- uni.showToast({ title: '登录成功', icon: 'success' })
- setTimeout(() => {
- uni.switchTab({ url: '/pages/index/index' })
- }, 1000)
- }
+ } else {
+ const res = await authApi.login(phone.value, password.value)
+ uni.setStorageSync('token', res.access_token)
+ uni.setStorageSync('userInfo', res.user)
+ uni.setStorageSync('hasLogin', true)
+ uni.setStorageSync('isGuest', false)
+ uni.showToast({ title: '登录成功', icon: 'success' })
+ setTimeout(() => {
+ uni.switchTab({ url: '/pages/index/index' })
+ }, 1000)
+ }
} catch (err) {
error.value = err.message || '操作失败,请重试'
} finally {
@@ -125,83 +168,220 @@ const handleSubmit = async () => {
}
const handleWechatLogin = () => {
- uni.getUserProfile({
- desc: '用于完善用户资料',
- success: (res) => {
- console.log('微信登录', res.userInfo)
- uni.showToast({ title: '微信登录开发中', icon: 'none' })
+ uni.login({
+ provider: 'weixin',
+ success: async (loginRes) => {
+ try {
+ loading.value = true
+ const res = await authApi.wechatLogin(loginRes.code)
+ uni.setStorageSync('token', res.access_token)
+ uni.setStorageSync('userInfo', res.user)
+ uni.setStorageSync('hasLogin', true)
+ uni.setStorageSync('isGuest', false)
+ uni.showToast({ title: '登录成功', icon: 'success' })
+ setTimeout(() => {
+ uni.switchTab({ url: '/pages/index/index' })
+ }, 1000)
+ } catch (err) {
+ error.value = err.message || '微信登录失败'
+ } finally {
+ loading.value = false
+ }
},
fail: (err) => {
console.log('微信登录失败', err)
+ error.value = '微信登录取消或失败'
}
})
}
-const onPhoneInput = (e) => { phone.value = e.detail.value }
-const onPasswordInput = (e) => { password.value = e.detail.value }
-const onUsernameInput = (e) => { username.value = e.detail.value }
+const goToAgreement = (type) => {
+ uni.navigateTo({ url: `/pages/agreement/${type}` })
+}
+
+const goToQuickTry = async () => {
+ uni.setStorageSync('isGuest', true)
+ try {
+ const res = await authApi.guestLogin()
+ if (res.access_token) {
+ uni.setStorageSync('token', res.access_token)
+ if (res.user) {
+ uni.setStorageSync('userInfo', res.user)
+ }
+ }
+ } catch (e) {
+ console.log('Guest login failed, continuing without token:', e)
+ }
+ uni.switchTab({ url: '/pages/index/index' })
+}
\ No newline at end of file
+
diff --git a/uni-app/src/pages/marketing/marketing.vue b/uni-app/src/pages/marketing/marketing.vue
index 964cf42..eb04400 100644
--- a/uni-app/src/pages/marketing/marketing.vue
+++ b/uni-app/src/pages/marketing/marketing.vue
@@ -4,21 +4,21 @@
开发信
WhatsApp话术
产品描述
@@ -31,6 +31,21 @@
+
+
+ {{ stats.today_copy || 0 }}
+ 今日复制
+
+
+ {{ stats.today_send || 0 }}
+ 今日发送
+
+
+ {{ stats.weekly_total || 0 }}
+ 本周共
+
+
+
产品名称
@@ -77,10 +92,11 @@
-
+
@@ -88,6 +104,7 @@
复制
发送
+ 竞品分析
@@ -104,6 +121,16 @@
+
+
+
+ {{ competitorResult }}
+
+
+
输入产品信息,点击生成文案
@@ -112,12 +139,14 @@
\ No newline at end of file
+
diff --git a/uni-app/src/pages/notification/notification.vue b/uni-app/src/pages/notification/notification.vue
new file mode 100644
index 0000000..6fdfe66
--- /dev/null
+++ b/uni-app/src/pages/notification/notification.vue
@@ -0,0 +1,136 @@
+
+
+
+
+
+ 暂无通知
+
+
+
+
+
+ {{ n.content }}
+ {{ typeLabel(n.type) }}
+
+
+
+
+ 加载更多
+
+
+
+
+
+
+
diff --git a/uni-app/src/pages/product/product.vue b/uni-app/src/pages/product/product.vue
index 835c17d..c6ede97 100644
--- a/uni-app/src/pages/product/product.vue
+++ b/uni-app/src/pages/product/product.vue
@@ -119,7 +119,8 @@
diff --git a/uni-app/src/pages/translate/translate.vue b/uni-app/src/pages/translate/translate.vue
index 93803e0..8587679 100644
--- a/uni-app/src/pages/translate/translate.vue
+++ b/uni-app/src/pages/translate/translate.vue
@@ -42,11 +42,36 @@
{{ result }}
+
+ 评价:
+
+ {{ s <= rating ? '★' : '☆' }}
+
+
+
+
+
@@ -66,13 +91,23 @@
+
+
+
+ {{ preferences.preferred_tone || '尚未分析偏好' }}
+ 常用词: {{ preferences.common_words.join(', ') }}
+
+
+
+
+
diff --git a/uni-app/src/static/favicon.ico b/uni-app/src/static/favicon.ico
new file mode 100644
index 0000000..04cc4b9
Binary files /dev/null and b/uni-app/src/static/favicon.ico differ
diff --git a/uni-app/src/static/favicon.svg b/uni-app/src/static/favicon.svg
new file mode 100644
index 0000000..8a28af1
--- /dev/null
+++ b/uni-app/src/static/favicon.svg
@@ -0,0 +1,10 @@
+
diff --git a/uni-app/src/utils/api.js b/uni-app/src/utils/api.js
index 509c905..4f20461 100644
--- a/uni-app/src/utils/api.js
+++ b/uni-app/src/utils/api.js
@@ -1,4 +1,4 @@
-const BASE_URL = 'http://localhost:8000/api/v1'
+export const BASE_URL = 'http://localhost:8000/api/v1'
const getAuthHeader = () => {
const token = uni.getStorageSync('token')
@@ -33,10 +33,104 @@ const request = (url, method = 'GET', data = {}) => {
})
}
+const requestWithoutAuth = (url, method = 'GET', data = {}) => {
+ return new Promise((resolve, reject) => {
+ uni.request({
+ url: `${BASE_URL}${url}`,
+ method,
+ data,
+ header: {
+ 'Content-Type': 'application/json',
+ },
+ success: (res) => {
+ if (res.statusCode === 200) {
+ resolve(res.data)
+ } else {
+ reject(new Error(res.data?.detail || 'Request failed'))
+ }
+ },
+ fail: (err) => {
+ reject(err)
+ },
+ })
+ })
+}
+
export const authApi = {
login: (phone, password) => request('/auth/login', 'POST', { username: phone, password }),
register: (phone, password, username) => request('/auth/register', 'POST', { phone, password, username }),
getUserInfo: () => request('/auth/me'),
+ wechatLogin: (code) => request('/auth/wechat-login', 'POST', { code }),
+ guestLogin: () => requestWithoutAuth('/auth/login/guest', 'POST'),
+}
+
+export const marketingApi = {
+ generate: (productName, description, category, target = 'US importers', style = 'professional') =>
+ request('/marketing/generate', 'POST', {
+ product_name: productName,
+ description,
+ category,
+ target,
+ style,
+ }),
+ getKeywords: (productName, description, category = '', language = 'en', count = 10) =>
+ request('/marketing/keywords', 'POST', {
+ product_name: productName,
+ description,
+ category,
+ language,
+ count,
+ }),
+ competitorAnalysis: (productName, description, category = '', market = 'US') =>
+ request('/marketing/competitor-analysis', 'POST', {
+ product_name: productName,
+ description,
+ category,
+ market,
+ }),
+}
+
+export const quotationApi = {
+ list: (page = 1, size = 20) => request(`/quotations?page=${page}&size=${size}`),
+ get: (id) => request(`/quotations/${id}`),
+ create: (data) => request('/quotations', 'POST', data),
+ updateStatus: (id, status) => request(`/quotations/${id}/status`, 'PATCH', { status }),
+ exportPdf: (id) => `${BASE_URL}/quotations/${id}/pdf`,
+ exportCsv: () => `${BASE_URL}/quotations/export/csv`,
+ generateFromInquiry: (inquiryText, customerId = null) =>
+ request('/quotations/generate-from-inquiry', 'POST', { inquiry_text: inquiryText, customer_id: customerId }),
+}
+
+export const productApi = {
+ list: (page = 1, size = 20) => request(`/products?page=${page}&size=${size}`),
+ get: (id) => request(`/products/${id}`),
+ create: (data) => request('/products', 'POST', data),
+ update: (id, data) => request(`/products/${id}`, 'PATCH', data),
+ delete: (id) => request(`/products/${id}`, 'DELETE'),
+}
+
+export const adminApi = {
+ getDashboard: () => request('/admin/dashboard'),
+ listUsers: (page = 1, size = 20) => request(`/admin/users?page=${page}&size=${size}`),
+ updateUserTier: (userId, tier) => request(`/admin/users/${userId}/tier`, 'PATCH', { tier }),
+}
+
+export const analyticsApi = {
+ getOverview: () => request('/analytics/overview'),
+ getCustomers: () => request('/analytics/customers'),
+ getTranslations: () => request('/analytics/translations'),
+ getQuotations: () => request('/analytics/quotations'),
+ getMessages: () => request('/analytics/messages'),
+}
+
+export const teamApi = {
+ list: () => request('/teams'),
+ get: (id) => request(`/teams/${id}`),
+ create: (name, description) => request('/teams', 'POST', { name, description }),
+ invite: (teamId, userId) => request(`/teams/${teamId}/invite`, 'POST', { user_id: userId }),
+ removeMember: (teamId, memberId) => request(`/teams/${teamId}/members/${memberId}`, 'DELETE'),
+ leave: (teamId) => request(`/teams/${teamId}/leave`, 'POST'),
+ updateRole: (teamId, memberId, role) => request(`/teams/${teamId}/members/${memberId}/role`, 'PATCH', { role }),
}
export const translateApi = {
@@ -44,6 +138,88 @@ export const translateApi = {
request('/translate', 'POST', { text, target_lang: targetLang, source_lang: sourceLang }),
getReply: (inquiry, tone = 'professional', count = 3) =>
request('/translate/reply', 'POST', { inquiry, tone, count }),
+ extract: (text, extractType = 'auto') =>
+ request('/translate/extract', 'POST', { text, extract_type: extractType }),
+ sendFeedback: (entryId, rating) =>
+ request('/translate/feedback', 'POST', { entry_id: entryId, rating }),
+ publicTranslate: (text, targetLang, sourceLang = 'auto') =>
+ requestWithoutAuth('/translate/public/translate', 'POST', { text, target_lang: targetLang, source_lang: sourceLang }),
+ publicExtract: (text, extractType = 'auto') =>
+ requestWithoutAuth('/translate/public/extract', 'POST', { text, extract_type: extractType }),
+}
+
+export const notificationApi = {
+ list: (page = 1, size = 20, unreadOnly = false) =>
+ request(`/notifications?page=${page}&size=${size}&unread_only=${unreadOnly}`),
+ unreadCount: () => request('/notifications/unread-count'),
+ markRead: (id) => request(`/notifications/${id}/read`, 'PATCH'),
+ markAllRead: () => request('/notifications/read-all', 'POST'),
+ delete: (id) => request(`/notifications/${id}`, 'DELETE'),
+}
+
+export const paymentApi = {
+ plans: () => request('/payment/plans'),
+ subscription: () => request('/payment/subscription'),
+ createOrder: (plan) => request('/payment/create-order', 'POST', { plan }),
+}
+
+export const feedbackApi = {
+ submit: (content, category = 'general', contact = '') =>
+ request('/feedback', 'POST', { content, category, contact }),
+}
+
+export const onboardingApi = {
+ status: () => request('/onboarding/status'),
+ createProduct: (name, description, category, target) =>
+ request('/onboarding/product', 'POST', { name, description, category, target }),
+}
+
+export const interactionApi = {
+ selectSuggestion: (messageId, selectedIndex) =>
+ request('/interaction/select', 'POST', { message_id: messageId, selected_index: selectedIndex }),
+ recordEdit: (messageId, editedText) =>
+ request('/interaction/edit', 'POST', { message_id: messageId, edited_text: editedText }),
+ analyzePreferences: () => request('/interaction/analyze', 'POST'),
+ getPreferences: () => request('/interaction/preferences'),
+ trackMarketingEffect: (data) => request('/interaction/marketing-effect', 'POST', data),
+ getMarketingEffects: (page = 1, size = 20) =>
+ request(`/interaction/marketing-effects?page=${page}&size=${size}`),
+ getMarketingEffectStats: () => request('/interaction/marketing-effects/stats'),
+}
+
+export const exchangeApi = {
+ convert: (fromCurrency = 'USD', toCurrency = 'CNY', amount = 1) =>
+ request(`/exchange/convert?from_currency=${fromCurrency}&to_currency=${toCurrency}&amount=${amount}`),
+ rates: (base = 'USD') => request(`/exchange/rates?base=${base}`),
+}
+
+export const pushApi = {
+ register: (clientId, platform = 'weapp', pushToken = null, deviceInfo = null) =>
+ request('/push/register', 'POST', { client_id: clientId, platform, push_token: pushToken, device_info: deviceInfo }),
+ unregister: (clientId) =>
+ request('/push/unregister', 'POST', { client_id: clientId }),
+ listDevices: () => request('/push/devices'),
+}
+
+export const silentPatternApi = {
+ getRiskAnalysis: () => request('/silent-pattern/risk-analysis'),
+ getSuggestions: (customerId) => request(`/silent-pattern/${customerId}/suggestions`),
+}
+
+export const followupApi = {
+ strategies: () => request('/followup/strategies'),
+ pending: (page = 1) => request(`/followup/pending?page=${page}`),
+ logs: (page = 1) => request(`/followup/logs?page=${page}`),
+ markSent: (id) => request(`/followup/${id}/send`, 'POST'),
+ editAndSend: (id, editedText) => request(`/followup/${id}/edit`, 'POST', { edited_text: editedText }),
+ stats: () => request('/followup/stats'),
+ scan: () => request('/followup/scan', 'POST'),
+}
+
+export const healthApi = {
+ overview: () => request('/customers/health-overview'),
+ allScores: () => request('/customers/health-scores'),
+ customerHealth: (id) => request(`/customers/${id}/health`),
}
export const customerApi = {
@@ -59,30 +235,24 @@ export const customerApi = {
getSilent: (days = 3) => request(`/customers/silent?days=${days}`),
getConversation: (id, page = 1, size = 50) =>
request(`/customers/${id}/conversation?page=${page}&size=${size}`),
-}
-
-export const marketingApi = {
- generate: (productName, description, category, target = 'US importers', style = 'professional') =>
- request('/marketing/generate', 'POST', {
- product_name: productName,
- description,
- category,
- target,
- style,
- }),
-}
-
-export const quotationApi = {
- list: (page = 1, size = 20) => request(`/quotations?page=${page}&size=${size}`),
- get: (id) => request(`/quotations/${id}`),
- create: (data) => request('/quotations', 'POST', data),
- updateStatus: (id, status) => request(`/quotations/${id}/status`, 'PATCH', { status }),
-}
-
-export const productApi = {
- list: (page = 1, size = 20) => request(`/products?page=${page}&size=${size}`),
- get: (id) => request(`/products/${id}`),
- create: (data) => request('/products', 'POST', data),
- update: (id, data) => request(`/products/${id}`, 'PATCH', data),
- delete: (id) => request(`/products/${id}`, 'DELETE'),
+ exportCsv: () => `${BASE_URL}/customers/export/csv`,
+ importCustomers: (file) => {
+ return new Promise((resolve, reject) => {
+ const token = uni.getStorageSync('token')
+ uni.uploadFile({
+ url: `${BASE_URL}/customers/import`,
+ filePath: file,
+ name: 'file',
+ header: token ? { Authorization: `Bearer ${token}` } : {},
+ success: (res) => {
+ try {
+ resolve(JSON.parse(res.data))
+ } catch (e) {
+ resolve(res.data)
+ }
+ },
+ fail: reject,
+ })
+ })
+ },
}
\ No newline at end of file
diff --git a/uni-app/src/utils/push.js b/uni-app/src/utils/push.js
index a82b682..0c5aad1 100644
--- a/uni-app/src/utils/push.js
+++ b/uni-app/src/utils/push.js
@@ -1,4 +1,5 @@
import { ref } from 'vue'
+import { pushApi } from './api.js'
let pushClientId = ''
let isInitialized = ref(false)
@@ -24,9 +25,14 @@ export const pushService = {
})
// #endif
- // #ifndef APP-PLUS
- console.log('非App环境下跳过推送初始化')
- resolve(false)
+ // #ifdef MP-WEIXIN
+ this.registerWechatDevice()
+ resolve(true)
+ // #endif
+
+ // #ifdef H5
+ this.registerWebDevice()
+ resolve(true)
// #endif
})
},
@@ -50,16 +56,14 @@ export const pushService = {
/**
* 注册设备到服务器
*/
- async registerDevice(clientId) {
+ async registerDevice(clientId) {
if (!clientId) return
try {
- const { request } = require('./api.js')
- await request('/push/register', 'POST', {
- client_id: clientId,
- platform: uni.getSystemInfoSync().platform,
- device_info: uni.getSystemInfoSync(),
- })
+ const platform = uni.getSystemInfoSync().platform || 'web'
+ const deviceInfo = uni.getSystemInfoSync()
+
+ await pushApi.register(clientId, platform, '', deviceInfo)
console.log('Device registered successfully')
} catch (err) {
console.error('Register device failed:', err)
@@ -143,6 +147,35 @@ export const pushService = {
})
},
+ /**
+ * 微信小程序设备注册
+ */
+ registerWechatDevice() {
+ // #ifdef MP-WEIXIN
+ try {
+ const systemInfo = uni.getSystemInfoSync()
+ const clientId = `${systemInfo.platform}_${Date.now()}`
+ this.registerDevice(clientId)
+ } catch (err) {
+ console.error('Wechat device register failed:', err)
+ }
+ // #endif
+ },
+
+ /**
+ * H5 设备注册
+ */
+ registerWebDevice() {
+ // #ifdef H5
+ try {
+ const clientId = `web_${Date.now()}`
+ this.registerDevice(clientId)
+ } catch (err) {
+ console.error('Web device register failed:', err)
+ }
+ // #endif
+ },
+
/**
* 清除所有推送消息
*/
diff --git a/uni-app/vite.config.js b/uni-app/vite.config.js
index 8367b44..aa0619d 100644
--- a/uni-app/vite.config.js
+++ b/uni-app/vite.config.js
@@ -12,4 +12,4 @@ export default defineConfig({
},
},
},
-})
\ No newline at end of file
+})