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 = """ + + + + + + + +
+

QUOTATION

+

#{quotation_number}

+
+ +
+
+

Bill To

+

{customer_name}

+

{customer_company}

+

{customer_country}

+
+
+

Quote Details

+

Date: {date}

+

Valid Until: {valid_until}

+

Currency: {currency}

+
+
+ + + + + + + + + + + + + + {items_rows} + +
ItemDescriptionQtyUnitUnit PriceTotal
+ + + + + + +
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 @@ + + + + + + + + + TM + 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 @@