feat: silent wechat login, marketing tab optimization, admin page foundation

- Add silent WeChat login for MP/browser environments
- Fix Python 3.6 compatibility (remove typing.Annotated usage)
- Marketing page: tab-based content generation with category support
- Translate page: add auto-detect language default
- Homepage: add TTS playback, announcement ticker, remove redundant quick-actions
- Fix FAB button overlap with custom tabbar on customers/quotation pages
- Make openai/anthropic imports lazy for Python 3.6 compat
This commit is contained in:
TradeMate Dev
2026-05-14 00:30:48 +08:00
parent f70dd24c7d
commit 23a31f7c00
30 changed files with 485 additions and 269 deletions
BIN
View File
Binary file not shown.
+7 -1
View File
@@ -1,6 +1,5 @@
from typing import Dict, Any, Optional
import json
from anthropic import AsyncAnthropic
from app.ai.base import AIProvider
@@ -19,6 +18,13 @@ SYSTEM_PROMPTS = {
class ClaudeProvider(AIProvider):
def __init__(self, api_key: str, model: str = "claude-sonnet-4-20250514"):
try:
from anthropic import AsyncAnthropic
except ImportError:
raise ImportError(
"anthropic SDK is required for ClaudeProvider. "
"Install it with: pip install anthropic"
)
self.client = AsyncAnthropic(api_key=api_key)
self.model = model
self._name = f"claude-sonnet"
+7 -1
View File
@@ -1,6 +1,5 @@
from typing import Dict, Any, Optional
import json
from openai import AsyncOpenAI
from app.ai.base import AIProvider
@@ -20,6 +19,13 @@ SYSTEM_PROMPTS = {
class OpenAIProvider(AIProvider):
def __init__(self, api_key: str, model: str = "gpt-4o", base_url: Optional[str] = None):
try:
from openai import AsyncOpenAI
except ImportError:
raise ImportError(
"openai>=1.0 is required for OpenAIProvider. "
"Install it with: pip install 'openai>=1.0'"
)
kwargs = {"api_key": api_key}
if base_url:
kwargs["base_url"] = base_url
+4 -1
View File
@@ -1,6 +1,5 @@
from typing import Dict, Any, Optional
import json
from openai import AsyncOpenAI
from app.ai.base import AIProvider
@@ -18,6 +17,10 @@ SYSTEM_PROMPTS = {
class SparkProvider(AIProvider):
def __init__(self, api_key: str, model: str = "astron-code-latest", base_url: str = None):
from app.config import settings
try:
from openai import AsyncOpenAI
except ImportError:
raise ImportError("openai>=1.0 is required for SparkProvider")
self.client = AsyncOpenAI(
api_key=api_key,
base_url=base_url or settings.IFLYTEK_API_BASE,
+5 -6
View File
@@ -1,6 +1,5 @@
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
@@ -17,7 +16,7 @@ async def require_admin(current_user: dict = Depends(get_current_user)) -> dict:
@router.get("/dashboard")
async def get_dashboard(
_: dict = Depends(require_admin),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = AdminService(db)
return await service.get_dashboard()
@@ -28,7 +27,7 @@ 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,
db: AsyncSession = Depends(get_db),
):
service = AdminService(db)
return await service.list_users(page, size)
@@ -39,7 +38,7 @@ async def update_user_tier(
target_user_id: str,
data: dict,
_: dict = Depends(require_admin),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = AdminService(db)
tier = data.get("tier")
@@ -55,7 +54,7 @@ async def update_user_tier(
async def toggle_user_active(
target_user_id: str,
_: dict = Depends(require_admin),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = AdminService(db)
success = await service.toggle_user_active(target_user_id)
@@ -66,7 +65,7 @@ async def toggle_user_active(
@router.get("/health")
async def system_health(
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = AdminService(db)
return await service.get_system_health()
+6 -7
View File
@@ -1,6 +1,5 @@
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
@@ -11,7 +10,7 @@ router = APIRouter()
@router.get("/customers")
async def customer_analytics(
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = AnalyticsService(db)
return await service.get_customer_stats(user_id)
@@ -20,7 +19,7 @@ async def customer_analytics(
@router.get("/translations")
async def translation_analytics(
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = AnalyticsService(db)
return await service.get_translation_stats(user_id)
@@ -29,7 +28,7 @@ async def translation_analytics(
@router.get("/quotations")
async def quotation_analytics(
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = AnalyticsService(db)
return await service.get_quotation_stats(user_id)
@@ -38,7 +37,7 @@ async def quotation_analytics(
@router.get("/messages")
async def message_analytics(
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = AnalyticsService(db)
return await service.get_message_stats(user_id)
@@ -47,7 +46,7 @@ async def message_analytics(
@router.get("/overview")
async def overview(
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = AnalyticsService(db)
customers = await service.get_customer_stats(user_id)
@@ -67,7 +66,7 @@ async def overview(
@router.get("/marketing")
async def marketing_analytics(
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = AnalyticsService(db)
return await service.get_marketing_stats(user_id)
+16 -7
View File
@@ -2,7 +2,7 @@ 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, Optional
from typing import Optional
import uuid
from app.database import get_db
from app.models.user import User
@@ -31,7 +31,7 @@ class RefreshRequest(BaseModel):
@router.post("/register")
async def register(data: RegisterRequest, db: Annotated[AsyncSession, Depends(get_db)] = None):
async def register(data: RegisterRequest, db: AsyncSession = Depends(get_db)):
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")
@@ -56,8 +56,8 @@ async def register(data: RegisterRequest, db: Annotated[AsyncSession, Depends(ge
@router.post("/login", response_model=LoginResponse)
async def login(
form: Annotated[OAuth2PasswordRequestForm, Depends()],
db: Annotated[AsyncSession, Depends(get_db)] = None,
form: OAuth2PasswordRequestForm = Depends(),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(User).where(User.phone == form.username))
user = result.scalar_one_or_none()
@@ -128,7 +128,7 @@ async def refresh(data: RefreshRequest):
@router.get("/me")
async def get_me(
authorization: Optional[str] = Header(None, alias="Authorization"),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing token")
@@ -178,8 +178,17 @@ class WeChatLoginRequest(BaseModel):
iv: str = ""
@router.get("/wechat/config")
async def wechat_config():
from app.config import settings
return {
"available": bool(settings.WECHAT_APP_ID and settings.WECHAT_APP_SECRET),
"app_id": settings.WECHAT_APP_ID or "",
}
@router.post("/wechat-login")
async def wechat_login(data: WeChatLoginRequest, db: Annotated[AsyncSession, Depends(get_db)] = None):
async def wechat_login(data: WeChatLoginRequest, db: AsyncSession = Depends(get_db)):
from app.services.wechat import wechat_service
session = await wechat_service.code2session(data.code)
@@ -216,7 +225,7 @@ async def wechat_login(data: WeChatLoginRequest, db: Annotated[AsyncSession, Dep
async def update_settings(
data: SettingsUpdate,
authorization: Optional[str] = Header(None, alias="Authorization"),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing token")
+13 -13
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Response
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Annotated, Optional, List
from typing import Optional, List
from app.database import get_db
from app.services.customer import CustomerService
from app.services.customer_health import CustomerHealthService
@@ -18,7 +18,7 @@ async def list_customers(
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,
db: AsyncSession = Depends(get_db),
):
service = CustomerService(db)
return await service.list_customers(user_id, status, page, size)
@@ -28,7 +28,7 @@ async def list_customers(
async def get_silent(
days: int = Query(3, ge=1),
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = CustomerService(db)
customers = await service.get_silent_customers(user_id, days)
@@ -43,7 +43,7 @@ async def get_silent(
async def get_customer(
customer_id: str,
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = CustomerService(db)
customer = await service.get_customer(user_id, customer_id)
@@ -56,7 +56,7 @@ async def get_customer(
async def create_customer(
data: dict,
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = CustomerService(db)
customer = await service.create_customer(user_id, data)
@@ -68,7 +68,7 @@ async def update_customer(
customer_id: str,
data: dict,
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = CustomerService(db)
customer = await service.update_customer(user_id, customer_id, data)
@@ -81,7 +81,7 @@ async def update_customer(
async def delete_customer(
customer_id: str,
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = CustomerService(db)
deleted = await service.delete_customer(user_id, customer_id)
@@ -94,7 +94,7 @@ async def delete_customer(
async def import_customers(
file: UploadFile = File(...),
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
from app.workers.tasks import process_customer_import
@@ -135,7 +135,7 @@ async def import_customers(
async def export_customers(
status: Optional[str] = None,
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = CustomerService(db)
result = await service.list_customers(user_id, status, 1, 9999)
@@ -151,7 +151,7 @@ async def export_customers(
@router.get("/health-overview")
async def get_health_overview(
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = CustomerHealthService(db)
return await service.get_health_overview(user_id)
@@ -160,7 +160,7 @@ async def get_health_overview(
@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,
db: AsyncSession = Depends(get_db),
):
service = CustomerHealthService(db)
return {"items": await service.get_all_health_scores(user_id)}
@@ -170,7 +170,7 @@ async def get_all_health_scores(
async def get_customer_health(
customer_id: str,
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = CustomerHealthService(db)
health = await service.get_customer_health(user_id, customer_id)
@@ -185,7 +185,7 @@ async def get_conversation(
page: int = Query(1, ge=1),
size: int = Query(50, ge=1, le=200),
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = CustomerService(db)
return await service.get_conversation(user_id, customer_id, page, size)
+1 -2
View File
@@ -1,6 +1,5 @@
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
@@ -19,7 +18,7 @@ class FeedbackRequest(BaseModel):
async def submit_feedback(
data: FeedbackRequest,
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
if not data.content.strip():
raise HTTPException(status_code=400, detail="Content is required")
+8 -8
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Annotated, Optional
from typing import Optional
from app.database import get_db
from app.services.followup_engine import FollowupEngine
from app.api.v1.deps import get_current_user_id
@@ -11,7 +11,7 @@ router = APIRouter()
@router.get("/strategies")
async def list_strategies(
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
engine = FollowupEngine(db)
await engine.ensure_default_strategies()
@@ -23,7 +23,7 @@ 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,
db: AsyncSession = Depends(get_db),
):
engine = FollowupEngine(db)
return await engine.get_pending_followups(user_id, page, size)
@@ -34,7 +34,7 @@ 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,
db: AsyncSession = Depends(get_db),
):
engine = FollowupEngine(db)
return await engine.get_followup_logs(user_id, page, size)
@@ -44,7 +44,7 @@ async def get_followup_logs(
async def mark_followup_sent(
log_id: str,
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
engine = FollowupEngine(db)
success = await engine.mark_sent(user_id, log_id)
@@ -58,7 +58,7 @@ 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,
db: AsyncSession = Depends(get_db),
):
edited_text = body.get("edited_text", "")
if not edited_text:
@@ -73,7 +73,7 @@ async def edit_and_send_followup(
@router.get("/stats")
async def get_followup_stats(
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
engine = FollowupEngine(db)
return await engine.get_stats(user_id)
@@ -82,7 +82,7 @@ async def get_followup_stats(
@router.post("/scan")
async def trigger_followup_scan(
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
engine = FollowupEngine(db)
result = await engine.scan_and_followup()
+7 -8
View File
@@ -1,6 +1,5 @@
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
@@ -13,7 +12,7 @@ router = APIRouter()
async def record_selection(
data: dict,
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
message_id = data.get("message_id")
selected_index = data.get("selected_index")
@@ -30,7 +29,7 @@ async def record_selection(
async def record_edit(
data: dict,
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
message_id = data.get("message_id")
edited_text = data.get("edited_text")
@@ -46,7 +45,7 @@ async def record_edit(
@router.post("/analyze")
async def analyze_preferences(
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = UserPreferenceService(db)
preferences = await service.analyze_preferences(user_id)
@@ -56,7 +55,7 @@ async def analyze_preferences(
@router.get("/preferences")
async def get_preferences(
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = UserPreferenceService(db)
return await service.get_analysis(user_id)
@@ -66,7 +65,7 @@ async def get_preferences(
async def track_marketing_effect(
data: dict,
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = MarketingEffectService(db)
result = await service.track_event(
@@ -87,7 +86,7 @@ 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,
db: AsyncSession = Depends(get_db),
):
service = MarketingEffectService(db)
return await service.get_effects(user_id, page, size)
@@ -96,7 +95,7 @@ async def get_marketing_effects(
@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,
db: AsyncSession = Depends(get_db),
):
service = MarketingEffectService(db)
return await service.get_stats(user_id)
+2 -2
View File
@@ -1,5 +1,5 @@
from fastapi import APIRouter, HTTPException, Depends
from typing import Optional, Annotated
from typing import Optional
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
@@ -43,7 +43,7 @@ class CompetitorRequest(BaseModel):
async def generate_marketing(
data: MarketingRequest,
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = MarketingService()
pref_service = UserPreferenceService(db)
+6 -6
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Annotated, Optional
from typing import Optional
from app.database import get_db
from app.services.notification import NotificationService
from app.api.v1.deps import get_current_user_id
@@ -14,7 +14,7 @@ async def list_notifications(
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,
db: AsyncSession = Depends(get_db),
):
service = NotificationService(db)
return await service.list_notifications(user_id, page, size, unread_only)
@@ -23,7 +23,7 @@ async def list_notifications(
@router.get("/unread-count")
async def unread_count(
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = NotificationService(db)
count = await service.get_unread_count(user_id)
@@ -34,7 +34,7 @@ async def unread_count(
async def mark_read(
notification_id: str,
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = NotificationService(db)
success = await service.mark_read(user_id, notification_id)
@@ -46,7 +46,7 @@ async def mark_read(
@router.post("/read-all")
async def mark_all_read(
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = NotificationService(db)
count = await service.mark_all_read(user_id)
@@ -57,7 +57,7 @@ async def mark_all_read(
async def delete_notification(
notification_id: str,
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = NotificationService(db)
success = await service.delete_notification(user_id, notification_id)
+2 -3
View File
@@ -1,6 +1,5 @@
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
@@ -19,7 +18,7 @@ class OnboardingRequest(BaseModel):
@router.get("/status")
async def get_status(
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = OnboardingService(db)
return await service.check_status(user_id)
@@ -29,7 +28,7 @@ async def get_status(
async def create_first_product(
data: OnboardingRequest,
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
if not data.name.strip():
raise HTTPException(status_code=400, detail="Product name is required")
+3 -4
View File
@@ -1,6 +1,5 @@
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
@@ -27,7 +26,7 @@ async def get_plans():
@router.get("/subscription")
async def get_subscription(
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
svc = PaymentService(db)
return await svc.get_current_subscription(user_id)
@@ -37,7 +36,7 @@ async def get_subscription(
async def create_order(
data: CreateOrderRequest,
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
svc = PaymentService(db)
try:
@@ -49,7 +48,7 @@ async def create_order(
@router.post("/callback")
async def payment_callback(
data: PaymentCallbackRequest,
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
svc = PaymentService(db)
success = await svc.handle_payment_callback(data.payment_id, data.success)
+6 -6
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Annotated, Optional
from typing import Optional
from app.database import get_db
from app.services.product import ProductService
from app.api.v1.deps import get_current_user_id
@@ -44,7 +44,7 @@ async def list_products(
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,
db: AsyncSession = Depends(get_db),
):
service = ProductService(db)
return await service.list_products(user_id, category, page, size)
@@ -54,7 +54,7 @@ async def list_products(
async def get_product(
product_id: str,
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = ProductService(db)
product = await service.get_product(user_id, product_id)
@@ -67,7 +67,7 @@ async def get_product(
async def create_product(
data: ProductCreate,
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = ProductService(db)
product = await service.create_product(user_id, data.dict())
@@ -79,7 +79,7 @@ async def update_product(
product_id: str,
data: ProductUpdate,
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = ProductService(db)
product = await service.update_product(user_id, product_id, data.dict(exclude_unset=True))
@@ -92,7 +92,7 @@ async def update_product(
async def delete_product(
product_id: str,
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = ProductService(db)
deleted = await service.delete_product(user_id, product_id)
+4 -4
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Annotated, Optional
from typing import Optional
from pydantic import BaseModel
from app.database import get_db
from app.services.push import PushService
@@ -20,7 +20,7 @@ class DeviceRegisterRequest(BaseModel):
async def register_device(
data: DeviceRegisterRequest,
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = PushService(db)
device = await service.register_device(
@@ -41,7 +41,7 @@ async def register_device(
async def unregister_device(
data: dict,
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
client_id = data.get("client_id")
if not client_id:
@@ -56,7 +56,7 @@ async def unregister_device(
@router.get("/devices")
async def list_devices(
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = PushService(db)
devices = await service.get_user_devices(user_id)
+8 -8
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Annotated, Optional
from typing import Optional
from pydantic import BaseModel
from app.database import get_db
from app.services.quotation import QuotationService
@@ -23,7 +23,7 @@ class InquiryRequest(BaseModel):
async def generate_from_inquiry(
data: InquiryRequest,
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = QuotationService(db)
result = await service.generate_from_inquiry(
@@ -38,7 +38,7 @@ async def generate_from_inquiry(
async def create_quotation(
data: dict,
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = QuotationService(db)
try:
@@ -53,7 +53,7 @@ async def list_quotations(
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,
db: AsyncSession = Depends(get_db),
):
service = QuotationService(db)
return await service.list_quotations(user_id, page, size)
@@ -63,7 +63,7 @@ async def list_quotations(
async def get_quotation(
quotation_id: str,
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = QuotationService(db)
quotation = await service.get_quotation(user_id, quotation_id)
@@ -77,7 +77,7 @@ async def update_quotation_status(
quotation_id: str,
data: dict,
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = QuotationService(db)
quotation = await service.update_status(user_id, quotation_id, data.get("status", "draft"))
@@ -89,7 +89,7 @@ async def update_quotation_status(
@router.get("/export/csv")
async def export_quotations(
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = QuotationService(db)
result = await service.list_quotations(user_id, 1, 9999)
@@ -106,7 +106,7 @@ async def export_quotations(
async def export_quotation_pdf(
quotation_id: str,
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = QuotationService(db)
quotation = await service.get_quotation(user_id, quotation_id)
+2 -3
View File
@@ -1,6 +1,5 @@
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
@@ -11,7 +10,7 @@ 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,
db: AsyncSession = Depends(get_db),
):
service = SilentPatternService(db)
risks = await service.analyze_silent_risk(user_id)
@@ -27,7 +26,7 @@ async def get_silent_risk_analysis(
async def get_followup_suggestions(
customer_id: str,
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = SilentPatternService(db)
suggestions = await service.get_suggestions(user_id, customer_id)
+8 -8
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Annotated, Optional
from typing import Optional
from pydantic import BaseModel
from app.database import get_db
from app.services.team import TeamService
@@ -26,7 +26,7 @@ class UpdateRoleRequest(BaseModel):
async def create_team(
data: CreateTeamRequest,
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = TeamService(db)
try:
@@ -39,7 +39,7 @@ async def create_team(
@router.get("")
async def list_teams(
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = TeamService(db)
return {"teams": await service.list_user_teams(user_id)}
@@ -49,7 +49,7 @@ async def list_teams(
async def get_team(
team_id: str,
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = TeamService(db)
team = await service.get_team(team_id, user_id)
@@ -63,7 +63,7 @@ async def invite_member(
team_id: str,
data: InviteRequest,
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = TeamService(db)
try:
@@ -78,7 +78,7 @@ 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,
db: AsyncSession = Depends(get_db),
):
service = TeamService(db)
success = await service.remove_member(team_id, user_id, member_id)
@@ -91,7 +91,7 @@ async def remove_member(
async def leave_team(
team_id: str,
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = TeamService(db)
success = await service.leave_team(team_id, user_id)
@@ -106,7 +106,7 @@ async def update_member_role(
member_id: str,
data: UpdateRoleRequest,
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
service = TeamService(db)
if data.role not in ("admin", "member", "viewer"):
+4 -5
View File
@@ -1,6 +1,5 @@
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
@@ -10,7 +9,7 @@ router = APIRouter()
@router.post("/corpus/run")
async def run_corpus_training(
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
trainer = CorpusTrainer(db)
result = await trainer.run_pipeline()
@@ -20,7 +19,7 @@ async def run_corpus_training(
@router.post("/corpus/embeddings")
async def compute_embeddings(
batch_size: int = 50,
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
trainer = CorpusTrainer(db)
result = await trainer.compute_embeddings(batch_size)
@@ -29,7 +28,7 @@ async def compute_embeddings(
@router.get("/corpus/stats")
async def corpus_stats(
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
trainer = CorpusTrainer(db)
return await trainer.get_stats()
@@ -37,7 +36,7 @@ async def corpus_stats(
@router.post("/corpus/deduplicate")
async def deduplicate_corpus(
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
trainer = CorpusTrainer(db)
result = await trainer.deduplicate()
+2 -2
View File
@@ -1,5 +1,5 @@
from fastapi import APIRouter, HTTPException, Response, Depends
from typing import Optional, Dict, Any, Annotated
from typing import Optional, Dict, Any
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
@@ -51,7 +51,7 @@ async def translate_text(
async def generate_reply(
data: ReplyRequest,
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
pref_service = UserPreferenceService(db)
pref_context = await pref_service.get_preference_context(user_id, "reply")
+3 -3
View File
@@ -1,7 +1,7 @@
from fastapi import APIRouter, Request, HTTPException, Depends, Header
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
from typing import Annotated, Optional
from typing import Optional
from pydantic import BaseModel
from app.database import get_db
from app.services.whatsapp import WhatsAppService
@@ -30,7 +30,7 @@ async def verify_webhook(
async def handle_webhook(
request: Request,
x_hub_signature_256: Optional[str] = Header(None),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
svc = WhatsAppService()
body = await request.body()
@@ -80,7 +80,7 @@ class SendMessageRequest(BaseModel):
async def send_message(
data: SendMessageRequest,
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
db: AsyncSession = Depends(get_db),
):
svc = WhatsAppService()
+1 -1
View File
@@ -796,7 +796,7 @@ const deleteCustomer = async (id) => {
.bottom-actions {
position: fixed;
right: 40rpx;
bottom: 40rpx;
bottom: 100px;
display: flex;
flex-direction: column;
gap: 24rpx;
+165 -91
View File
@@ -5,7 +5,11 @@
<text class="username">{{ userInfo?.username || '用户' }}</text>
<text class="tier">{{ userInfo?.tier === 'pro' ? 'Pro' : '免费版' }}</text>
</view>
<view class="guest-info" v-else>
<view class="header-left" v-if="!hasLogin" @click="showAnnouncement = true">
<text class="announcement-icon">📢</text>
<text class="announcement-ticker">{{ announcements[currentAnnouncement] }}</text>
</view>
<view class="header-right" v-if="!hasLogin">
<text class="guest-label">👋 游客模式</text>
<button class="login-btn" @click="goToLogin">登录</button>
</view>
@@ -58,8 +62,11 @@
<view class="try-result" v-if="tryResult">
<view class="result-header">
<text class="result-label">翻译结果</text>
<view class="result-actions">
<text class="result-play" @click="playTryResult">朗读</text>
<text class="result-copy" @click="copyTryResult">复制</text>
</view>
</view>
<view class="result-content">
<text class="result-text">{{ tryResult }}</text>
</view>
@@ -92,39 +99,6 @@
<view class="empty" v-else>暂无待跟进客户</view>
</view>
<view class="quick-actions">
<view class="action-item" @click="hasLogin ? goToPage('/pages/product/product') : goToLogin()">
<view class="action-icon-wrap" style="background:#e6f7ff">
<text class="action-icon-text" style="color:#1890ff"></text>
</view>
<text class="action-label">产品库</text>
</view>
<view class="action-item" @click="hasLogin ? goToPage('/pages/followup/followup') : goToLogin()">
<view class="action-icon-wrap" style="background:#f0f0ff">
<text class="action-icon-text" style="color:#667eea"></text>
</view>
<text class="action-label">
<text>跟进</text>
<text class="action-badge" v-if="hasLogin && followupStats.pending > 0">{{ followupStats.pending > 99 ? '99+' : followupStats.pending }}</text>
</text>
</view>
<view class="action-item" @click="hasLogin ? goToPage('/pages/analytics/analytics') : goToLogin()">
<view class="action-icon-wrap" style="background:#f6ffed">
<text class="action-icon-text" style="color:#52c41a"></text>
</view>
<text class="action-label">数据</text>
</view>
<view class="action-item" @click="hasLogin ? goToPage('/pages/notification/notification') : goToLogin()">
<view class="action-icon-wrap" style="background:#fff7e6">
<text class="action-icon-text" style="color:#fa8c16"></text>
</view>
<text class="action-label">
<text>通知</text>
<text class="action-badge" v-if="hasLogin && unreadCount > 0">{{ unreadCount > 99 ? '99+' : unreadCount }}</text>
</text>
</view>
</view>
<view class="section" v-if="hasLogin && followupStats.pending > 0">
<view class="section-title">
<text>待跟进提醒</text>
@@ -138,7 +112,7 @@
</view>
<view class="more-section">
<view class="section-title">更多功能</view>
<view class="section-title">功能矩阵</view>
<view class="more-grid">
<view class="more-item" @click="hasLogin ? goToPage('/pages/product/product') : goToLogin()">
<text class="more-icon">📦</text>
@@ -221,15 +195,39 @@
<text class="ob-skip" @click="finishOnboarding" v-if="onboardingStep === 1">跳过以后再说</text>
</view>
</view>
<view class="modal-overlay" v-if="showAnnouncement" @click="showAnnouncement = false">
<view class="announcement-modal" @click.stop>
<text class="announcement-title">📢 系统公告</text>
<view class="announcement-body">
<text class="announcement-line">欢迎使用外贸小助手</text>
<text class="announcement-line">登录后可解锁以下功能</text>
<text class="announcement-item"> 客户管理 管理客户信息与跟进记录</text>
<text class="announcement-item"> 报价单 快速生成并导出专业报价</text>
<text class="announcement-item"> 数据分析 查看业务统计与趋势</text>
<text class="announcement-item"> 营销素材 AI 生成营销文案与关键词</text>
<text class="announcement-line" style="margin-top: 20rpx">现在登录体验全部功能 🚀</text>
</view>
<button class="announcement-btn" @click="showAnnouncement = false; goToLogin()">去登录</button>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import { ref, computed, onUnmounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { authApi, customerApi, analyticsApi, onboardingApi, notificationApi, followupApi, translateApi } from '@/utils/api.js'
import { authApi, customerApi, analyticsApi, onboardingApi, notificationApi, followupApi, translateApi, BASE_URL } from '@/utils/api.js'
const showAnnouncement = ref(false)
const currentAnnouncement = ref(0)
const announcements = [
'全新 AI 翻译引擎上线,支持多语言商务翻译',
'登录后免费使用客户管理与报价单功能',
'每日数据看板上线,实时掌握业务动态',
]
let announcementTimer = null
const userInfo = ref(null)
const hasLogin = computed(() => {
const token = uni.getStorageSync('token')
const isGuest = uni.getStorageSync('isGuest')
@@ -257,6 +255,10 @@ const tryExtracted = ref('')
const tryLoading = ref(false)
onShow(() => {
announcementTimer = setInterval(() => {
currentAnnouncement.value = (currentAnnouncement.value + 1) % announcements.length
}, 4000)
const token = uni.getStorageSync('token')
const isGuest = uni.getStorageSync('isGuest')
if (token && !isGuest) {
@@ -272,6 +274,10 @@ onShow(() => {
}
})
onUnmounted(() => {
if (announcementTimer) clearInterval(announcementTimer)
})
const checkOnboarding = async () => {
if (uni.getStorageSync('onboarded')) return
try {
@@ -435,6 +441,35 @@ const copyTryResult = () => {
},
})
}
const playTryResult = () => {
if (!tryResult.value || !tryText.value) return
const hasChinese = /[\u4e00-\u9fa5]/.test(tryText.value)
const lang = hasChinese ? 'en' : 'zh'
const token = uni.getStorageSync('token')
const url = `${BASE_URL}/translate/tts?text=${encodeURIComponent(tryResult.value)}&lang=${lang}`
uni.showLoading({ title: '语音生成中...' })
uni.downloadFile({
url,
header: token ? { Authorization: `Bearer ${token}` } : {},
success: (res) => {
uni.hideLoading()
if (res.statusCode === 200) {
const audioCtx = uni.createInnerAudioContext()
audioCtx.src = res.tempFilePath
audioCtx.play()
audioCtx.onEnded(() => audioCtx.destroy())
} else {
uni.showToast({ title: '语音生成失败', icon: 'none' })
}
},
fail: () => {
uni.hideLoading()
uni.showToast({ title: '语音生成失败', icon: 'none' })
},
})
}
</script>
<style lang="scss" scoped>
@@ -460,15 +495,39 @@ const copyTryResult = () => {
align-items: center;
}
.guest-info {
.header-left {
display: flex;
align-items: center;
gap: 16rpx;
gap: 12rpx;
flex: 1;
cursor: pointer;
overflow: hidden;
}
.announcement-icon {
font-size: 32rpx;
flex-shrink: 0;
}
.announcement-ticker {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.9);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.header-right {
display: flex;
align-items: center;
gap: 12rpx;
flex-shrink: 0;
}
.guest-label {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.9);
white-space: nowrap;
}
.login-btn {
@@ -654,11 +713,21 @@ const copyTryResult = () => {
font-weight: 500;
}
.result-actions {
display: flex;
gap: 16rpx;
}
.result-copy {
font-size: 24rpx;
color: #1890ff;
}
.result-play {
font-size: 24rpx;
color: #52c41a;
}
.result-content, .extracted-content {
background: #fff;
border-radius: 8rpx;
@@ -759,57 +828,6 @@ const copyTryResult = () => {
margin-top: 12rpx;
}
.quick-actions {
display: flex;
background: #fff;
border-radius: 16rpx;
padding: 20rpx 16rpx;
margin-bottom: 20rpx;
justify-content: space-around;
}
.action-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
cursor: pointer;
}
.action-icon-wrap {
width: 72rpx;
height: 72rpx;
border-radius: 36rpx;
display: flex;
align-items: center;
justify-content: center;
}
.action-icon-text {
font-size: 30rpx;
font-weight: bold;
}
.action-label {
font-size: 22rpx;
color: #666;
display: flex;
align-items: center;
gap: 4rpx;
}
.action-badge {
background: #ff4d4f;
color: #fff;
font-size: 18rpx;
min-width: 28rpx;
height: 28rpx;
border-radius: 14rpx;
text-align: center;
line-height: 28rpx;
padding: 0 6rpx;
}
.more-section {
background: #fff;
border-radius: 16rpx;
@@ -887,4 +905,60 @@ const copyTryResult = () => {
.ob-btn { width: 100%; height: 88rpx; border-radius: 12rpx; font-size: 30rpx; border: none; display: flex; align-items: center; justify-content: center; }
.ob-btn-primary { background: #1890ff; color: #fff; }
.ob-skip { display: block; text-align: center; margin-top: 24rpx; font-size: 24rpx; color: #999; }
.modal-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5);
display: flex; align-items: center; justify-content: center;
z-index: 999; padding: 40rpx;
}
.announcement-modal {
background: #fff;
border-radius: 24rpx;
padding: 48rpx 40rpx;
width: 100%;
max-width: 560rpx;
}
.announcement-title {
font-size: 34rpx;
font-weight: 700;
color: #333;
display: block;
text-align: center;
margin-bottom: 32rpx;
}
.announcement-body {
margin-bottom: 32rpx;
}
.announcement-line {
font-size: 26rpx;
color: #555;
display: block;
line-height: 1.6;
margin-bottom: 8rpx;
}
.announcement-item {
font-size: 24rpx;
color: #666;
display: block;
line-height: 1.8;
padding-left: 20rpx;
}
.announcement-btn {
width: 100%;
height: 88rpx;
background: #1890ff;
color: #fff;
border-radius: 12rpx;
font-size: 30rpx;
display: flex;
align-items: center;
justify-content: center;
}
</style>
+84 -10
View File
@@ -1,5 +1,9 @@
<template>
<view class="login-container">
<view class="silent-loading" v-if="silentLoading">
<text class="silent-loading-text">正在自动登录...</text>
</view>
<view class="welcome-section">
<text class="logo">TradeMate</text>
<text class="subtitle">外贸小助手</text>
@@ -110,7 +114,7 @@
</template>
<script setup>
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import { authApi } from '@/utils/api.js'
const phone = ref('')
@@ -118,14 +122,74 @@ const password = ref('')
const username = ref('')
const isRegister = ref(false)
const loading = ref(false)
const silentLoading = ref(true)
const error = ref('')
const showForm = ref(false)
const isWechatAvailable = ref(false)
const doWechatLogin = async (code) => {
const res = await authApi.wechatLogin(code)
uni.setStorageSync('token', res.access_token)
uni.setStorageSync('userInfo', res.user)
uni.setStorageSync('hasLogin', true)
uni.setStorageSync('isGuest', false)
uni.switchTab({ url: '/pages/index/index' })
}
onMounted(async () => {
// #ifdef MP-WEIXIN
// 微信小程序:静默登录
isWechatAvailable.value = true
try {
const loginRes = await new Promise((resolve, reject) => {
uni.login({ provider: 'weixin', success: resolve, fail: reject })
})
await doWechatLogin(loginRes.code)
} catch (_) {
silentLoading.value = false
}
// #endif
// #ifdef H5
// H5 微信内置浏览器:OAuth 静默登录
const isWechatBrowser = /MicroMessenger/i.test(navigator.userAgent)
if (isWechatBrowser) {
try {
const cfg = await authApi.wechatConfig()
if (cfg.available && cfg.app_id) {
const params = new URLSearchParams(window.location.search)
const code = params.get('code')
if (code) {
// OAuth 回调,携带 code
await doWechatLogin(code)
// 清除 URL 中的 code 参数
window.history.replaceState({}, '', window.location.pathname)
return
} else {
// 跳转微信 OAuth 授权
const redirectUri = encodeURIComponent(window.location.href.split('?')[0])
window.location.href =
`https://open.weixin.qq.com/connect/oauth2/authorize` +
`?appid=${cfg.app_id}` +
`&redirect_uri=${redirectUri}` +
`&response_type=code` +
`&scope=snsapi_base` +
`&state=STATE#wechat_redirect`
return
}
}
} catch (_) {}
}
silentLoading.value = false
// #endif
// #ifndef MP-WEIXIN
if (!/MicroMessenger/i.test(navigator.userAgent)) {
silentLoading.value = false
}
// #endif
})
const toggleMode = () => {
isRegister.value = !isRegister.value
error.value = ''
@@ -178,15 +242,7 @@ const handleWechatLogin = () => {
success: async (loginRes) => {
try {
loading.value = true
const res = await authApi.wechatLogin(loginRes.code)
uni.setStorageSync('token', res.access_token)
uni.setStorageSync('userInfo', res.user)
uni.setStorageSync('hasLogin', true)
uni.setStorageSync('isGuest', false)
uni.showToast({ title: '登录成功', icon: 'success' })
setTimeout(() => {
uni.switchTab({ url: '/pages/index/index' })
}, 1000)
await doWechatLogin(loginRes.code)
} catch (err) {
error.value = err.message || '微信登录失败'
} finally {
@@ -438,6 +494,24 @@ const goToQuickTry = async () => {
margin-right: 12rpx;
}
.silent-loading {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
}
.silent-loading-text {
font-size: 28rpx;
color: #999;
}
.footer {
text-align: center;
margin-top: 40rpx;
+95 -50
View File
@@ -4,28 +4,28 @@
<view
class="tab-item"
:class="{ active: activeTab === 'copy' }"
@click="activeTab = 'copy'; activeTab === 'copy' && loadStats()"
@click="switchTab('copy')"
>
开发信
</view>
<view
class="tab-item"
:class="{ active: activeTab === 'whatsapp' }"
@click="activeTab = 'whatsapp'; activeTab === 'whatsapp' && loadStats()"
@click="switchTab('whatsapp')"
>
WhatsApp话术
</view>
<view
class="tab-item"
:class="{ active: activeTab === 'product' }"
@click="activeTab = 'product'; activeTab === 'product' && loadStats()"
@click="switchTab('product')"
>
产品描述
</view>
<view
class="tab-item"
:class="{ active: activeTab === 'keywords' }"
@click="activeTab = 'keywords'"
@click="switchTab('keywords')"
>
关键词
</view>
@@ -49,19 +49,19 @@
<view class="form-section">
<view class="form-item">
<text class="form-label">产品名称</text>
<input class="form-input" v-model="formData.product_name" placeholder="如: 户外折叠椅" />
<input class="form-input" v-model="formData.product_name" :placeholder="tabConfig[activeTab].namePlaceholder" />
</view>
<view class="form-item">
<text class="form-label">产品描述</text>
<textarea class="form-textarea" v-model="formData.description" placeholder="描述产品的特点、规格、优势..." />
<text class="form-label">{{ tabConfig[activeTab].descLabel }}</text>
<textarea class="form-textarea" v-model="formData.description" :placeholder="tabConfig[activeTab].descPlaceholder" />
</view>
<view class="form-item">
<view class="form-item" v-if="tabConfig[activeTab].showTarget">
<text class="form-label">目标市场</text>
<picker :range="targetMarkets" @change="onTargetChange">
<view class="picker-value">{{ formData.target || '选择目标市场' }}</view>
</picker>
</view>
<view class="form-item">
<view class="form-item" v-if="tabConfig[activeTab].showStyle">
<text class="form-label">文案风格</text>
<view class="style-options">
<view
@@ -88,34 +88,30 @@
</view>
</view>
<button class="generate-btn" @click="generateContent" :disabled="loading">
{{ loading ? '生成中...' : '生成文案' }}
{{ loading ? '生成中...' : tabConfig[activeTab].btnText }}
</button>
</view>
<view class="results-section" v-if="results.length > 0 && activeTab !== 'keywords'">
<view class="results-section" v-if="resultsMap[activeTab] && resultsMap[activeTab].length > 0">
<view class="results-header">
<text class="results-title">生成的文案</text>
<text class="results-title">{{ tabConfig[activeTab].resultTitle }}</text>
<view class="results-actions">
<text class="refresh-btn" @click="generateContent">换一批</text>
<text class="export-btn" @click="exportCsv">导出CSV</text>
<text class="export-btn" @click="exportCsv" v-if="activeTab !== 'keywords'">导出CSV</text>
</view>
<view class="results-list">
<view class="result-item" v-for="(item, index) in results" :key="index">
</view>
<view class="results-list" v-if="activeTab !== 'keywords'">
<view class="result-item" v-for="(item, index) in resultsMap[activeTab]" :key="index">
<text class="result-text">{{ item }}</text>
<view class="result-actions">
<text class="copy-btn" @click="copyText(item)">复制</text>
<text class="send-btn" @click="sendToWhatsapp(item)">发送</text>
<text class="competitor-btn" @click="runCompetitorAnalysis">竞品分析</text>
<text class="send-btn" @click="sendToWhatsapp(item)" v-if="activeTab !== 'product'">发送</text>
<text class="competitor-btn" @click="runCompetitorAnalysis" v-if="activeTab === 'copy'">竞品分析</text>
</view>
</view>
</view>
</view>
<view class="history-section" v-if="activeTab === 'keywords' && keywords.length > 0">
<view class="history-header">
<text class="history-title">关键词建议</text>
</view>
<view class="keywords-list">
<view class="keyword-tag" v-for="(kw, idx) in keywords" :key="idx" @click="copyText(kw)">
<view class="keywords-list" v-if="activeTab === 'keywords' && resultsMap.keywords && resultsMap.keywords.length > 0">
<view class="keyword-tag" v-for="(kw, idx) in resultsMap.keywords" :key="idx" @click="copyText(kw)">
{{ kw }}
</view>
</view>
@@ -131,20 +127,70 @@
</view>
</view>
<view class="empty" v-if="!loading && results.length === 0 && activeTab !== 'keywords'">
<text>输入产品信息点击生成文案</text>
<view class="empty" v-if="!loading && (!resultsMap[activeTab] || resultsMap[activeTab].length === 0)">
<text>{{ tabConfig[activeTab].emptyHint }}</text>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { ref, reactive } from 'vue'
import { marketingApi, interactionApi } from '@/utils/api.js'
const tabConfig = {
copy: {
label: '开发信',
category: 'sales_letter',
namePlaceholder: '如: 户外折叠椅',
descLabel: '产品描述',
descPlaceholder: '描述产品的特点、规格、优势...',
btnText: '生成开发信',
resultTitle: '生成的开发信',
emptyHint: '输入产品信息,点击生成开发信',
showTarget: true,
showStyle: true,
},
whatsapp: {
label: 'WhatsApp话术',
category: 'whatsapp',
namePlaceholder: '如: 户外折叠椅',
descLabel: '产品及沟通场景',
descPlaceholder: '描述产品特点,以及和客户沟通的具体场景...',
btnText: '生成话术',
resultTitle: '生成的WhatsApp话术',
emptyHint: '输入产品信息,点击生成话术',
showTarget: true,
showStyle: true,
},
product: {
label: '产品描述',
category: 'product_description',
namePlaceholder: '如: 户外折叠椅',
descLabel: '产品详细规格',
descPlaceholder: '描述产品的材质、尺寸、承重、颜色、包装等规格...',
btnText: '生成描述',
resultTitle: '生成的产品描述',
emptyHint: '输入产品信息,点击生成描述',
showTarget: false,
showStyle: false,
},
keywords: {
label: '关键词',
category: '',
namePlaceholder: '如: 户外折叠椅',
descLabel: '产品卖点',
descPlaceholder: '描述产品的核心卖点和目标客户群体...',
btnText: '生成关键词',
resultTitle: '关键词建议',
emptyHint: '输入产品信息,点击生成关键词',
showTarget: false,
showStyle: false,
},
}
const activeTab = ref('copy')
const loading = ref(false)
const results = ref([])
const keywords = ref([])
const resultsMap = reactive({ copy: [], whatsapp: [], product: [], keywords: [] })
const competitorResult = ref(null)
const stats = ref(null)
@@ -167,6 +213,11 @@ const onTargetChange = (e) => {
formData.value.target = targetMarkets.value[e.detail.value]
}
const switchTab = (tab) => {
activeTab.value = tab
loadStats()
}
const loadStats = async () => {
try {
const res = await interactionApi.getMarketingEffectStats()
@@ -183,24 +234,26 @@ const generateContent = async () => {
}
loading.value = true
const tab = activeTab.value
try {
if (activeTab.value === 'keywords') {
if (tab === 'keywords') {
const res = await marketingApi.getKeywords(
formData.value.product_name,
formData.value.description,
''
)
keywords.value = res.keywords || []
resultsMap[tab] = res.keywords || []
} else {
const cfg = tabConfig[tab]
const res = await marketingApi.generate(
formData.value.product_name,
formData.value.description,
'',
cfg.category,
formData.value.target,
formData.value.style
)
results.value = res.results || []
resultsMap[tab] = res.results || []
loadStats()
}
} catch (err) {
@@ -222,9 +275,10 @@ const copyText = (text) => {
}
const exportCsv = () => {
if (results.value.length === 0) return
const items = resultsMap[activeTab.value]
if (!items || items.length === 0) return
let csv = 'Content\n'
results.value.forEach(r => { csv += `"${r.replace(/"/g, '""')}"\n` })
items.forEach(r => { csv += `"${r.replace(/"/g, '""')}"\n` })
const blob = new Blob([csv], { type: 'text/csv' })
const url = URL.createObjectURL(blob)
uni.downloadFile({
@@ -414,6 +468,11 @@ const runCompetitorAnalysis = async () => {
font-weight: 600;
}
.results-actions {
display: flex;
gap: 16rpx;
}
.refresh-btn {
font-size: 24rpx;
color: #1890ff;
@@ -422,7 +481,6 @@ const runCompetitorAnalysis = async () => {
.export-btn {
font-size: 24rpx;
color: #52c41a;
margin-left: 16rpx;
}
.results-list {
@@ -470,19 +528,6 @@ const runCompetitorAnalysis = async () => {
color: #722ed1;
}
.history-section {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
}
.history-title {
font-size: 28rpx;
font-weight: 600;
margin-bottom: 20rpx;
display: block;
}
.keywords-list {
display: flex;
flex-wrap: wrap;
+2 -2
View File
@@ -503,7 +503,7 @@ const exportPdf = (item) => {
.export-csv-btn {
position: fixed;
right: 40rpx;
bottom: 160rpx;
bottom: calc(100px + 100rpx + 24rpx);
width: 100rpx;
height: 100rpx;
background: #722ed1;
@@ -523,7 +523,7 @@ const exportPdf = (item) => {
.add-btn {
position: fixed;
right: 40rpx;
bottom: 40rpx;
bottom: 100px;
width: 100rpx;
height: 100rpx;
background: #1890ff;
+8 -2
View File
@@ -114,13 +114,14 @@ const inputText = ref('')
const result = ref('')
const suggestions = ref([])
const loading = ref(false)
const targetIndex = ref(1)
const targetIndex = ref(0)
const keyboardHeight = ref(0)
const rating = ref(0)
const extractedInfo = ref(null)
const preferences = ref(null)
const targetLangs = ref([
{ code: 'auto', name: '自动检测' },
{ code: 'en', name: 'English' },
{ code: 'zh', name: '中文' },
{ code: 'es', name: 'Español' },
@@ -143,9 +144,14 @@ const handleTranslate = async () => {
try {
if (mode.value === 'translate') {
let targetLang = targetLangs[targetIndex.value].code
if (targetLang === 'auto') {
const hasChinese = /[\u4e00-\u9fa5]/.test(inputText.value)
targetLang = hasChinese ? 'en' : 'zh'
}
const res = await translateApi.translate(
inputText.value,
targetLangs[targetIndex.value].code
targetLang
)
result.value = res.translated
loadPreferences()
+1
View File
@@ -61,6 +61,7 @@ export const authApi = {
register: (phone, password, username) => request('/auth/register', 'POST', { phone, password, username }),
getUserInfo: () => request('/auth/me'),
wechatLogin: (code) => request('/auth/wechat-login', 'POST', { code }),
wechatConfig: () => request('/auth/wechat/config'),
guestLogin: () => requestWithoutAuth('/auth/login/guest', 'POST'),
}