haha
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,6 +2,9 @@
|
||||
**/node_modules
|
||||
.pnpm-store/
|
||||
|
||||
# Vite cache
|
||||
.vite/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
# 最小验收清单(功能自测)
|
||||
|
||||
## 账号与权限
|
||||
- 注册新账号并登录,能获取 `/api/auth/me` 返回用户信息
|
||||
- 非管理员访问 `/admin` 自动跳回首页
|
||||
- 管理员访问 `/admin` 正常看到用户/悬赏/支付事件列表
|
||||
|
||||
## 悬赏流程
|
||||
- 发布悬赏 → 列表/详情可见
|
||||
- 其他用户申请接单 → 发布者在详情页接受申请
|
||||
- 接单者提交交付内容 → 发布者验收(通过/驳回)
|
||||
- 验收通过后可完成悬赏
|
||||
- 完成后双方可互评
|
||||
|
||||
## 支付流程
|
||||
- 发布者创建托管支付(跳转 Stripe)
|
||||
- 完成支付后悬赏状态为已托管
|
||||
- 发布者完成悬赏后释放赏金
|
||||
- 支付事件在管理后台可查看
|
||||
|
||||
## 收藏与价格监控
|
||||
- 收藏商品并设置监控(目标价/提醒开关)
|
||||
- 刷新价格后产生价格历史记录
|
||||
- 达到目标价时产生通知
|
||||
|
||||
## 通知与偏好
|
||||
- 通知列表可查看、单条已读、全部已读
|
||||
- 通知偏好开关能控制对应类型通知是否创建
|
||||
|
||||
## 争议与延期
|
||||
- 接单者可提交延期申请,发布者可同意/拒绝
|
||||
- 争议可由任一方发起,管理员可处理
|
||||
131
README.md
131
README.md
@@ -1,131 +0,0 @@
|
||||
# AI Web 资源聚合平台
|
||||
|
||||
一个全栈 Web 应用,包含商品导航、悬赏任务系统、收藏管理等功能。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
ai_web/
|
||||
├── frontend/ # React 前端 (TypeScript + Vite)
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # UI 组件
|
||||
│ │ ├── pages/ # 页面组件
|
||||
│ │ ├── hooks/ # React Hooks
|
||||
│ │ ├── lib/ # API 客户端和工具
|
||||
│ │ └── contexts/ # React Context
|
||||
│ └── index.html
|
||||
├── backend/ # Django 后端
|
||||
│ ├── config/ # Django 项目配置
|
||||
│ ├── apps/ # Django 应用模块
|
||||
│ │ ├── users/ # 用户认证
|
||||
│ │ ├── products/ # 商品和分类
|
||||
│ │ ├── bounties/ # 悬赏系统
|
||||
│ │ ├── favorites/ # 收藏管理
|
||||
│ │ └── notifications/ # 通知系统
|
||||
│ ├── requirements.txt
|
||||
│ └── manage.py
|
||||
└── shared/ # 共享类型定义
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
|
||||
### 前端
|
||||
- React 18 + TypeScript
|
||||
- Vite
|
||||
- TanStack Query (React Query)
|
||||
- Tailwind CSS
|
||||
- Radix UI
|
||||
- Wouter (路由)
|
||||
|
||||
### 后端
|
||||
- Django 4.2
|
||||
- Django Ninja (API 框架)
|
||||
- MySQL
|
||||
- Stripe (支付)
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 安装前端依赖
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### 2. 安装后端依赖
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# 创建虚拟环境
|
||||
python -m venv venv
|
||||
|
||||
# 激活虚拟环境 (Windows)
|
||||
venv\Scripts\activate
|
||||
|
||||
# 激活虚拟环境 (Linux/Mac)
|
||||
source venv/bin/activate
|
||||
|
||||
# 安装依赖
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 3. 配置环境变量
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
cp .env.example .env
|
||||
# 编辑 .env 文件,填入实际配置
|
||||
```
|
||||
|
||||
### 4. 初始化数据库
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python manage.py migrate
|
||||
python manage.py createsuperuser # 创建管理员账号
|
||||
```
|
||||
|
||||
### 5. 运行项目
|
||||
|
||||
**启动后端** (端口 8000):
|
||||
```bash
|
||||
cd backend
|
||||
python manage.py runserver
|
||||
```
|
||||
|
||||
**启动前端** (端口 5173):
|
||||
```bash
|
||||
cd frontend
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
访问 http://localhost:5173 查看应用。
|
||||
|
||||
## API 文档
|
||||
|
||||
启动后端后,访问 http://localhost:8000/api/docs 查看 API 文档。
|
||||
|
||||
## 主要功能
|
||||
|
||||
### 商品导航
|
||||
- 浏览购物网站和商品
|
||||
- 多平台价格对比
|
||||
- 商品搜索与筛选
|
||||
|
||||
### 悬赏系统
|
||||
- 发布悬赏任务
|
||||
- 申请接取任务
|
||||
- 赏金托管 (Stripe)
|
||||
- 任务完成确认与支付
|
||||
|
||||
### 收藏管理
|
||||
- 商品收藏
|
||||
- 标签分类
|
||||
- 价格监控
|
||||
- 降价提醒
|
||||
|
||||
### 用户系统
|
||||
- OAuth 登录
|
||||
- 个人中心
|
||||
- 通知系统
|
||||
@@ -2,9 +2,12 @@
|
||||
Admin API routes for managing core data.
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from django.utils import timezone
|
||||
from ninja import Router, Schema
|
||||
from ninja.errors import HttpError
|
||||
from ninja_jwt.authentication import JWTAuth
|
||||
from ninja.pagination import paginate, PageNumberPagination
|
||||
|
||||
from apps.users.models import User
|
||||
from apps.products.models import Product, Website, Category
|
||||
@@ -14,7 +17,7 @@ router = Router()
|
||||
|
||||
|
||||
def require_admin(user):
|
||||
if not user or user.role != 'admin':
|
||||
if not user or user.role != 'admin' or not user.is_active:
|
||||
raise HttpError(403, "仅管理员可访问")
|
||||
|
||||
|
||||
@@ -22,10 +25,9 @@ class UserOut(Schema):
|
||||
id: int
|
||||
open_id: str
|
||||
name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
role: str
|
||||
is_active: bool
|
||||
created_at: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class UserUpdateIn(Schema):
|
||||
@@ -47,7 +49,14 @@ class BountyAdminOut(Schema):
|
||||
acceptor_id: Optional[int] = None
|
||||
is_escrowed: bool
|
||||
is_paid: bool
|
||||
created_at: str
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@staticmethod
|
||||
def resolve_reward(obj):
|
||||
return str(obj.reward)
|
||||
|
||||
|
||||
class PaymentEventOut(Schema):
|
||||
@@ -56,7 +65,10 @@ class PaymentEventOut(Schema):
|
||||
event_type: str
|
||||
bounty_id: Optional[int] = None
|
||||
success: bool
|
||||
processed_at: str
|
||||
processed_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class DisputeOut(Schema):
|
||||
@@ -64,116 +76,160 @@ class DisputeOut(Schema):
|
||||
bounty_id: int
|
||||
initiator_id: int
|
||||
status: str
|
||||
created_at: str
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.get("/users/", response=List[UserOut], auth=JWTAuth())
|
||||
@paginate(PageNumberPagination, page_size=20)
|
||||
def list_users(request):
|
||||
require_admin(request.auth)
|
||||
users = User.objects.all().order_by('-created_at')
|
||||
return [
|
||||
UserOut(
|
||||
id=u.id,
|
||||
open_id=u.open_id,
|
||||
name=u.name,
|
||||
email=u.email,
|
||||
role=u.role,
|
||||
is_active=u.is_active,
|
||||
created_at=u.created_at.isoformat(),
|
||||
)
|
||||
for u in users
|
||||
]
|
||||
return User.objects.all().order_by('-created_at')
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}", response=UserOut, auth=JWTAuth())
|
||||
def update_user(request, user_id: int, data: UserUpdateIn):
|
||||
require_admin(request.auth)
|
||||
user = User.objects.get(id=user_id)
|
||||
try:
|
||||
user = User.objects.get(id=user_id)
|
||||
except User.DoesNotExist:
|
||||
raise HttpError(404, "用户不存在")
|
||||
update_data = data.dict(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(user, key, value)
|
||||
user.save()
|
||||
return UserOut(
|
||||
id=user.id,
|
||||
open_id=user.open_id,
|
||||
name=user.name,
|
||||
email=user.email,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
created_at=user.created_at.isoformat(),
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/categories/", response=List[SimpleOut], auth=JWTAuth())
|
||||
@paginate(PageNumberPagination, page_size=50)
|
||||
def list_categories(request):
|
||||
require_admin(request.auth)
|
||||
return [SimpleOut(id=c.id, name=c.name) for c in Category.objects.all()]
|
||||
return Category.objects.values('id', 'name').order_by('name')
|
||||
|
||||
|
||||
@router.get("/websites/", response=List[SimpleOut], auth=JWTAuth())
|
||||
@paginate(PageNumberPagination, page_size=50)
|
||||
def list_websites(request):
|
||||
require_admin(request.auth)
|
||||
return [SimpleOut(id=w.id, name=w.name) for w in Website.objects.all()]
|
||||
return Website.objects.values('id', 'name').order_by('name')
|
||||
|
||||
|
||||
@router.get("/products/", response=List[SimpleOut], auth=JWTAuth())
|
||||
@paginate(PageNumberPagination, page_size=50)
|
||||
def list_products(request):
|
||||
require_admin(request.auth)
|
||||
return [SimpleOut(id=p.id, name=p.name) for p in Product.objects.all()]
|
||||
return Product.objects.values('id', 'name').order_by('-created_at')
|
||||
|
||||
|
||||
@router.get("/bounties/", response=List[BountyAdminOut], auth=JWTAuth())
|
||||
@paginate(PageNumberPagination, page_size=20)
|
||||
def list_bounties(request, status: Optional[str] = None):
|
||||
require_admin(request.auth)
|
||||
queryset = Bounty.objects.all().order_by('-created_at')
|
||||
queryset = Bounty.objects.select_related("publisher", "acceptor").all().order_by('-created_at')
|
||||
if status:
|
||||
queryset = queryset.filter(status=status)
|
||||
return [
|
||||
BountyAdminOut(
|
||||
id=b.id,
|
||||
title=b.title,
|
||||
status=b.status,
|
||||
reward=str(b.reward),
|
||||
publisher_id=b.publisher_id,
|
||||
acceptor_id=b.acceptor_id,
|
||||
is_escrowed=b.is_escrowed,
|
||||
is_paid=b.is_paid,
|
||||
created_at=b.created_at.isoformat(),
|
||||
)
|
||||
for b in queryset
|
||||
]
|
||||
return queryset
|
||||
|
||||
|
||||
@router.get("/disputes/", response=List[DisputeOut], auth=JWTAuth())
|
||||
@paginate(PageNumberPagination, page_size=20)
|
||||
def list_disputes(request, status: Optional[str] = None):
|
||||
require_admin(request.auth)
|
||||
disputes = BountyDispute.objects.all().order_by('-created_at')
|
||||
if status:
|
||||
disputes = disputes.filter(status=status)
|
||||
return [
|
||||
DisputeOut(
|
||||
id=d.id,
|
||||
bounty_id=d.bounty_id,
|
||||
initiator_id=d.initiator_id,
|
||||
status=d.status,
|
||||
created_at=d.created_at.isoformat(),
|
||||
)
|
||||
for d in disputes
|
||||
]
|
||||
return disputes
|
||||
|
||||
|
||||
@router.get("/payments/", response=List[PaymentEventOut], auth=JWTAuth())
|
||||
@paginate(PageNumberPagination, page_size=20)
|
||||
def list_payment_events(request):
|
||||
require_admin(request.auth)
|
||||
events = PaymentEvent.objects.all().order_by('-processed_at')
|
||||
return [
|
||||
PaymentEventOut(
|
||||
id=e.id,
|
||||
event_id=e.event_id,
|
||||
event_type=e.event_type,
|
||||
bounty_id=e.bounty_id,
|
||||
success=e.success,
|
||||
processed_at=e.processed_at.isoformat(),
|
||||
)
|
||||
for e in events
|
||||
]
|
||||
return PaymentEvent.objects.all().order_by('-processed_at')
|
||||
|
||||
|
||||
# ==================== Product Review ====================
|
||||
|
||||
class ProductAdminOut(Schema):
|
||||
"""Product admin output schema with all fields."""
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
image: Optional[str] = None
|
||||
category_id: int
|
||||
category_name: Optional[str] = None
|
||||
status: str
|
||||
submitted_by_id: Optional[int] = None
|
||||
submitted_by_name: Optional[str] = None
|
||||
reject_reason: Optional[str] = None
|
||||
reviewed_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@staticmethod
|
||||
def resolve_category_name(obj):
|
||||
return obj.category.name if obj.category else None
|
||||
|
||||
@staticmethod
|
||||
def resolve_submitted_by_name(obj):
|
||||
if obj.submitted_by:
|
||||
return obj.submitted_by.name or obj.submitted_by.open_id
|
||||
return None
|
||||
|
||||
|
||||
class ProductReviewIn(Schema):
|
||||
"""Product review input schema."""
|
||||
approved: bool
|
||||
reject_reason: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/products/pending/", response=List[ProductAdminOut], auth=JWTAuth())
|
||||
@paginate(PageNumberPagination, page_size=20)
|
||||
def list_pending_products(request):
|
||||
"""List all pending products for review."""
|
||||
require_admin(request.auth)
|
||||
return Product.objects.select_related("category", "submitted_by").filter(
|
||||
status='pending'
|
||||
).order_by('-created_at')
|
||||
|
||||
|
||||
@router.get("/products/all/", response=List[ProductAdminOut], auth=JWTAuth())
|
||||
@paginate(PageNumberPagination, page_size=20)
|
||||
def list_all_products(request, status: Optional[str] = None):
|
||||
"""List all products with optional status filter."""
|
||||
require_admin(request.auth)
|
||||
queryset = Product.objects.select_related("category", "submitted_by").order_by('-created_at')
|
||||
if status:
|
||||
queryset = queryset.filter(status=status)
|
||||
return queryset
|
||||
|
||||
|
||||
@router.post("/products/{product_id}/review/", response=ProductAdminOut, auth=JWTAuth())
|
||||
def review_product(request, product_id: int, data: ProductReviewIn):
|
||||
"""Approve or reject a product."""
|
||||
require_admin(request.auth)
|
||||
|
||||
try:
|
||||
product = Product.objects.select_related("category", "submitted_by").get(id=product_id)
|
||||
except Product.DoesNotExist:
|
||||
raise HttpError(404, "商品不存在")
|
||||
|
||||
if product.status != 'pending':
|
||||
raise HttpError(400, "只能审核待审核状态的商品")
|
||||
|
||||
if data.approved:
|
||||
product.status = 'approved'
|
||||
product.reject_reason = None
|
||||
else:
|
||||
if not data.reject_reason:
|
||||
raise HttpError(400, "拒绝时需要提供原因")
|
||||
product.status = 'rejected'
|
||||
product.reject_reason = data.reject_reason
|
||||
|
||||
product.reviewed_at = timezone.now()
|
||||
product.save()
|
||||
|
||||
return product
|
||||
|
||||
@@ -8,6 +8,7 @@ from ninja import Router, Query
|
||||
from ninja.errors import HttpError
|
||||
from ninja_jwt.authentication import JWTAuth
|
||||
from ninja.pagination import paginate, PageNumberPagination
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.db.models import Count, Q
|
||||
from django.shortcuts import get_object_or_404
|
||||
@@ -32,8 +33,9 @@ from .schemas import (
|
||||
BountyReviewOut, BountyReviewIn,
|
||||
BountyExtensionRequestOut, BountyExtensionRequestIn, BountyExtensionReviewIn,
|
||||
)
|
||||
from apps.users.schemas import UserOut
|
||||
from apps.notifications.models import Notification, NotificationPreference
|
||||
from apps.common.serializers import serialize_user, serialize_bounty
|
||||
from apps.notifications.models import Notification
|
||||
from apps.notifications.utils import should_notify
|
||||
|
||||
router = Router()
|
||||
|
||||
@@ -51,84 +53,17 @@ def parse_reward(raw_reward) -> Decimal:
|
||||
raise ValueError("reward must be a valid number")
|
||||
# Quantize to 2 decimal places
|
||||
value = value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
# Validate range: max_digits=10, decimal_places=2 means max integer part is 8 digits
|
||||
# Max value: 99999999.99
|
||||
if value < Decimal("0.01"):
|
||||
raise ValueError("reward must be at least 0.01")
|
||||
if value > Decimal("99999999.99"):
|
||||
min_reward = getattr(settings, "BOUNTY_MIN_REWARD", Decimal("0.01"))
|
||||
max_reward = getattr(settings, "BOUNTY_MAX_REWARD", Decimal("99999999.99"))
|
||||
if value < min_reward:
|
||||
raise ValueError(f"reward must be at least {min_reward}")
|
||||
if value > max_reward:
|
||||
raise ValueError("reward exceeds maximum allowed value")
|
||||
return value
|
||||
except InvalidOperation:
|
||||
raise ValueError("reward must be a valid number")
|
||||
|
||||
|
||||
def serialize_user(user):
|
||||
"""Serialize user to UserOut."""
|
||||
if not user:
|
||||
return None
|
||||
return UserOut(
|
||||
id=user.id,
|
||||
open_id=user.open_id,
|
||||
name=user.name,
|
||||
email=user.email,
|
||||
avatar=user.avatar,
|
||||
role=user.role,
|
||||
stripe_customer_id=user.stripe_customer_id,
|
||||
stripe_account_id=user.stripe_account_id,
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def serialize_bounty(bounty, include_counts=False):
|
||||
"""Serialize bounty to BountyOut or BountyWithDetailsOut."""
|
||||
data = {
|
||||
'id': bounty.id,
|
||||
'title': bounty.title,
|
||||
'description': bounty.description,
|
||||
'reward': bounty.reward,
|
||||
'currency': bounty.currency,
|
||||
'publisher_id': bounty.publisher_id,
|
||||
'publisher': serialize_user(bounty.publisher),
|
||||
'acceptor_id': bounty.acceptor_id,
|
||||
'acceptor': serialize_user(bounty.acceptor) if bounty.acceptor else None,
|
||||
'status': bounty.status,
|
||||
'deadline': bounty.deadline,
|
||||
'completed_at': bounty.completed_at,
|
||||
'is_paid': bounty.is_paid,
|
||||
'is_escrowed': bounty.is_escrowed,
|
||||
'created_at': bounty.created_at,
|
||||
'updated_at': bounty.updated_at,
|
||||
}
|
||||
|
||||
if include_counts:
|
||||
applications_count = getattr(bounty, "applications_count", None)
|
||||
comments_count = getattr(bounty, "comments_count", None)
|
||||
data['applications_count'] = applications_count if applications_count is not None else bounty.applications.count()
|
||||
data['comments_count'] = comments_count if comments_count is not None else bounty.comments.count()
|
||||
return BountyWithDetailsOut(**data)
|
||||
|
||||
return BountyOut(**data)
|
||||
|
||||
|
||||
def should_notify(user, notification_type: str) -> bool:
|
||||
"""Check if user has enabled notification type."""
|
||||
if not user:
|
||||
return False
|
||||
preference, _ = NotificationPreference.objects.get_or_create(user=user)
|
||||
if notification_type == Notification.Type.PRICE_ALERT:
|
||||
return preference.enable_price_alert
|
||||
if notification_type in (
|
||||
Notification.Type.BOUNTY_ACCEPTED,
|
||||
Notification.Type.BOUNTY_COMPLETED,
|
||||
Notification.Type.NEW_COMMENT,
|
||||
):
|
||||
return preference.enable_bounty
|
||||
if notification_type == Notification.Type.SYSTEM:
|
||||
return preference.enable_system
|
||||
return True
|
||||
|
||||
|
||||
# ==================== Bounty Routes ====================
|
||||
|
||||
@router.get("/", response=List[BountyWithDetailsOut])
|
||||
@@ -150,8 +85,8 @@ def list_bounties(request, filters: BountyFilter = Query(...)):
|
||||
queryset = queryset.filter(publisher_id=filters.publisher_id)
|
||||
if filters.acceptor_id:
|
||||
queryset = queryset.filter(acceptor_id=filters.acceptor_id)
|
||||
|
||||
return [serialize_bounty(b, include_counts=True) for b in queryset]
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
@router.get("/search/", response=List[BountyWithDetailsOut])
|
||||
@@ -166,7 +101,7 @@ def search_bounties(request, q: str):
|
||||
)
|
||||
.filter(Q(title__icontains=q) | Q(description__icontains=q))
|
||||
)
|
||||
return [serialize_bounty(b, include_counts=True) for b in queryset]
|
||||
return queryset
|
||||
|
||||
|
||||
@router.get("/my-published/", response=List[BountyWithDetailsOut], auth=JWTAuth())
|
||||
@@ -181,7 +116,7 @@ def my_published_bounties(request):
|
||||
)
|
||||
.filter(publisher=request.auth)
|
||||
)
|
||||
return [serialize_bounty(b, include_counts=True) for b in queryset]
|
||||
return queryset
|
||||
|
||||
|
||||
@router.get("/my-accepted/", response=List[BountyWithDetailsOut], auth=JWTAuth())
|
||||
@@ -196,7 +131,7 @@ def my_accepted_bounties(request):
|
||||
)
|
||||
.filter(acceptor=request.auth)
|
||||
)
|
||||
return [serialize_bounty(b, include_counts=True) for b in queryset]
|
||||
return queryset
|
||||
|
||||
|
||||
@router.get("/{bounty_id}", response=BountyWithDetailsOut)
|
||||
@@ -215,6 +150,21 @@ def get_bounty(request, bounty_id: int):
|
||||
@router.post("/", response=BountyOut, auth=JWTAuth())
|
||||
def create_bounty(request, data: BountyIn):
|
||||
"""Create a new bounty."""
|
||||
# 标题和描述验证
|
||||
if not data.title or len(data.title.strip()) < 2:
|
||||
raise HttpError(400, "标题至少需要2个字符")
|
||||
if len(data.title) > 200:
|
||||
raise HttpError(400, "标题不能超过200个字符")
|
||||
if not data.description or len(data.description.strip()) < 10:
|
||||
raise HttpError(400, "描述至少需要10个字符")
|
||||
if len(data.description) > 5000:
|
||||
raise HttpError(400, "描述不能超过5000个字符")
|
||||
|
||||
# 截止时间验证
|
||||
if data.deadline:
|
||||
if data.deadline <= timezone.now():
|
||||
raise HttpError(400, "截止时间必须是未来的时间")
|
||||
|
||||
payload = data.dict()
|
||||
try:
|
||||
payload["reward"] = parse_reward(payload.get("reward"))
|
||||
@@ -259,6 +209,13 @@ def cancel_bounty(request, bounty_id: int):
|
||||
if bounty.status not in [Bounty.Status.OPEN, Bounty.Status.IN_PROGRESS]:
|
||||
raise HttpError(400, "无法取消此悬赏")
|
||||
|
||||
# 如果已托管资金且处于进行中状态,需要处理退款
|
||||
if bounty.is_escrowed and bounty.status == Bounty.Status.IN_PROGRESS:
|
||||
raise HttpError(400, "已托管资金的进行中悬赏无法直接取消,请联系客服处理退款")
|
||||
|
||||
# 如果只是开放状态且已托管,标记需要退款
|
||||
refund_needed = bounty.is_escrowed and bounty.status == Bounty.Status.OPEN
|
||||
|
||||
bounty.status = Bounty.Status.CANCELLED
|
||||
bounty.save()
|
||||
|
||||
@@ -273,7 +230,11 @@ def cancel_bounty(request, bounty_id: int):
|
||||
related_type="bounty",
|
||||
)
|
||||
|
||||
return MessageOut(message="悬赏已取消", success=True)
|
||||
message = "悬赏已取消"
|
||||
if refund_needed:
|
||||
message = "悬赏已取消,托管资金将在3-5个工作日内退回"
|
||||
|
||||
return MessageOut(message=message, success=True)
|
||||
|
||||
|
||||
@router.post("/{bounty_id}/complete", response=MessageOut, auth=JWTAuth())
|
||||
@@ -357,6 +318,10 @@ def my_application(request, bounty_id: int):
|
||||
@router.post("/{bounty_id}/applications/", response=BountyApplicationOut, auth=JWTAuth())
|
||||
def submit_application(request, bounty_id: int, data: BountyApplicationIn):
|
||||
"""Submit an application for a bounty."""
|
||||
# 申请消息长度验证
|
||||
if data.message and len(data.message) > 1000:
|
||||
raise HttpError(400, "申请消息不能超过1000个字符")
|
||||
|
||||
bounty = get_object_or_404(Bounty, id=bounty_id)
|
||||
|
||||
if bounty.status != Bounty.Status.OPEN:
|
||||
@@ -401,17 +366,27 @@ def submit_application(request, bounty_id: int, data: BountyApplicationIn):
|
||||
@router.post("/{bounty_id}/applications/{application_id}/accept", response=MessageOut, auth=JWTAuth())
|
||||
def accept_application(request, bounty_id: int, application_id: int):
|
||||
"""Accept an application (only by bounty publisher)."""
|
||||
bounty = get_object_or_404(Bounty, id=bounty_id)
|
||||
|
||||
if bounty.publisher_id != request.auth.id:
|
||||
raise HttpError(403, "只有发布者可以接受申请")
|
||||
|
||||
if bounty.status != Bounty.Status.OPEN:
|
||||
raise HttpError(400, "无法接受此悬赏的申请")
|
||||
|
||||
app = get_object_or_404(BountyApplication, id=application_id, bounty_id=bounty_id)
|
||||
|
||||
with transaction.atomic():
|
||||
# 使用 select_for_update 加锁防止并发
|
||||
bounty = Bounty.objects.select_for_update().filter(id=bounty_id).first()
|
||||
if not bounty:
|
||||
raise HttpError(404, "悬赏不存在")
|
||||
|
||||
if bounty.publisher_id != request.auth.id:
|
||||
raise HttpError(403, "只有发布者可以接受申请")
|
||||
|
||||
if bounty.status != Bounty.Status.OPEN:
|
||||
raise HttpError(400, "无法接受此悬赏的申请")
|
||||
|
||||
app = BountyApplication.objects.select_for_update().filter(
|
||||
id=application_id, bounty_id=bounty_id
|
||||
).first()
|
||||
if not app:
|
||||
raise HttpError(404, "申请不存在")
|
||||
|
||||
if app.status != BountyApplication.Status.PENDING:
|
||||
raise HttpError(400, "该申请已被处理")
|
||||
|
||||
# Accept this application
|
||||
app.status = BountyApplication.Status.ACCEPTED
|
||||
app.save()
|
||||
@@ -426,7 +401,7 @@ def accept_application(request, bounty_id: int, application_id: int):
|
||||
bounty.status = Bounty.Status.IN_PROGRESS
|
||||
bounty.save()
|
||||
|
||||
# Notify acceptor
|
||||
# Notify acceptor (outside transaction for better performance)
|
||||
if should_notify(app.applicant, Notification.Type.BOUNTY_ACCEPTED):
|
||||
Notification.objects.create(
|
||||
user=app.applicant,
|
||||
@@ -469,8 +444,14 @@ def list_comments(request, bounty_id: int):
|
||||
@router.post("/{bounty_id}/comments/", response=BountyCommentOut, auth=JWTAuth())
|
||||
def create_comment(request, bounty_id: int, data: BountyCommentIn):
|
||||
"""Create a comment on a bounty."""
|
||||
bounty = get_object_or_404(Bounty, id=bounty_id)
|
||||
# 评论内容验证
|
||||
if not data.content or len(data.content.strip()) < 1:
|
||||
raise HttpError(400, "评论内容不能为空")
|
||||
if len(data.content) > 2000:
|
||||
raise HttpError(400, "评论内容不能超过2000个字符")
|
||||
|
||||
bounty = get_object_or_404(Bounty, id=bounty_id)
|
||||
|
||||
comment = BountyComment.objects.create(
|
||||
bounty=bounty,
|
||||
user=request.auth,
|
||||
@@ -491,7 +472,9 @@ def create_comment(request, bounty_id: int, data: BountyCommentIn):
|
||||
|
||||
# Notify parent comment author (if replying)
|
||||
if data.parent_id:
|
||||
parent = BountyComment.objects.get(id=data.parent_id)
|
||||
parent = get_object_or_404(
|
||||
BountyComment.objects.select_related("user"), id=data.parent_id
|
||||
)
|
||||
if parent.user_id != request.auth.id and should_notify(parent.user, Notification.Type.NEW_COMMENT):
|
||||
Notification.objects.create(
|
||||
user=parent.user,
|
||||
@@ -627,8 +610,28 @@ def list_disputes(request, bounty_id: int):
|
||||
def create_dispute(request, bounty_id: int, data: BountyDisputeIn):
|
||||
"""Create a dispute (publisher or acceptor)."""
|
||||
bounty = get_object_or_404(Bounty, id=bounty_id)
|
||||
if request.auth.id not in [bounty.publisher_id, bounty.acceptor_id]:
|
||||
|
||||
# 检查悬赏状态是否允许创建争议
|
||||
if bounty.status not in [Bounty.Status.IN_PROGRESS, Bounty.Status.DISPUTED]:
|
||||
raise HttpError(400, "只有进行中或已有争议的悬赏才能发起争议")
|
||||
|
||||
# 检查权限(考虑acceptor可能为None)
|
||||
allowed_users = [bounty.publisher_id]
|
||||
if bounty.acceptor_id:
|
||||
allowed_users.append(bounty.acceptor_id)
|
||||
if request.auth.id not in allowed_users:
|
||||
raise HttpError(403, "无权限发起争议")
|
||||
|
||||
# 检查是否已有未解决的争议
|
||||
if BountyDispute.objects.filter(bounty=bounty, status=BountyDispute.Status.OPEN).exists():
|
||||
raise HttpError(400, "该悬赏已有未解决的争议")
|
||||
|
||||
# 争议原因验证
|
||||
if not data.reason or len(data.reason.strip()) < 10:
|
||||
raise HttpError(400, "争议原因至少需要10个字符")
|
||||
if len(data.reason) > 2000:
|
||||
raise HttpError(400, "争议原因不能超过2000个字符")
|
||||
|
||||
dispute = BountyDispute.objects.create(
|
||||
bounty=bounty,
|
||||
initiator=request.auth,
|
||||
@@ -665,13 +668,47 @@ def resolve_dispute(request, bounty_id: int, dispute_id: int, data: BountyDisput
|
||||
"""Resolve dispute (admin only)."""
|
||||
if request.auth.role != 'admin':
|
||||
raise HttpError(403, "仅管理员可处理争议")
|
||||
|
||||
bounty = get_object_or_404(Bounty, id=bounty_id)
|
||||
dispute = get_object_or_404(BountyDispute, id=dispute_id, bounty_id=bounty_id)
|
||||
|
||||
if dispute.status != BountyDispute.Status.OPEN:
|
||||
raise HttpError(400, "争议已处理")
|
||||
|
||||
dispute.status = BountyDispute.Status.RESOLVED if data.accepted else BountyDispute.Status.REJECTED
|
||||
dispute.resolution = data.resolution
|
||||
dispute.resolved_at = timezone.now()
|
||||
dispute.save()
|
||||
|
||||
# 检查是否还有其他未解决的争议
|
||||
has_open_disputes = BountyDispute.objects.filter(
|
||||
bounty=bounty,
|
||||
status=BountyDispute.Status.OPEN
|
||||
).exists()
|
||||
|
||||
# 如果没有其他未解决的争议,将悬赏状态恢复为进行中
|
||||
if not has_open_disputes and bounty.status == Bounty.Status.DISPUTED:
|
||||
bounty.status = Bounty.Status.IN_PROGRESS
|
||||
bounty.save()
|
||||
|
||||
# 通知相关用户
|
||||
users_to_notify = []
|
||||
if bounty.publisher and bounty.publisher_id != request.auth.id:
|
||||
users_to_notify.append(bounty.publisher)
|
||||
if bounty.acceptor and bounty.acceptor_id != request.auth.id:
|
||||
users_to_notify.append(bounty.acceptor)
|
||||
|
||||
for user in users_to_notify:
|
||||
if should_notify(user, Notification.Type.SYSTEM):
|
||||
Notification.objects.create(
|
||||
user=user,
|
||||
type=Notification.Type.SYSTEM,
|
||||
title="争议已处理",
|
||||
content=f"悬赏 \"{bounty.title}\" 的争议已被管理员处理",
|
||||
related_id=bounty.id,
|
||||
related_type="bounty",
|
||||
)
|
||||
|
||||
return MessageOut(message="争议已处理", success=True)
|
||||
|
||||
|
||||
|
||||
70
backend/apps/bounties/migrations/0005_add_indexes.py
Normal file
70
backend/apps/bounties/migrations/0005_add_indexes.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("bounties", "0004_extension_request"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name="bounty",
|
||||
index=models.Index(fields=["status", "created_at"], name="bounty_status_created_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="bounty",
|
||||
index=models.Index(fields=["publisher", "created_at"], name="bounty_publisher_created_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="bounty",
|
||||
index=models.Index(fields=["acceptor", "created_at"], name="bounty_acceptor_created_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="bountyapplication",
|
||||
index=models.Index(fields=["bounty", "status"], name="bountyapp_bounty_status_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="bountyapplication",
|
||||
index=models.Index(fields=["applicant", "status"], name="bountyapp_applicant_status_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="bountycomment",
|
||||
index=models.Index(fields=["bounty", "created_at"], name="bountycomment_bounty_created_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="bountycomment",
|
||||
index=models.Index(fields=["parent", "created_at"], name="bountycomment_parent_created_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="bountydelivery",
|
||||
index=models.Index(fields=["bounty", "status"], name="bountydelivery_bounty_status_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="bountydelivery",
|
||||
index=models.Index(fields=["submitted_at"], name="bountydelivery_submitted_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="bountydispute",
|
||||
index=models.Index(fields=["bounty", "status"], name="bountydispute_bounty_status_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="bountydispute",
|
||||
index=models.Index(fields=["status", "created_at"], name="bountydispute_status_created_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="bountyreview",
|
||||
index=models.Index(fields=["bounty", "created_at"], name="bountyreview_bounty_created_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="bountyreview",
|
||||
index=models.Index(fields=["reviewee", "created_at"], name="bountyreview_reviewee_created_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="bountyextensionrequest",
|
||||
index=models.Index(fields=["bounty", "status"], name="bountyext_bounty_status_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="bountyextensionrequest",
|
||||
index=models.Index(fields=["requester", "status"], name="bountyext_requester_status_idx"),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,88 @@
|
||||
# Generated by Django 4.2.27 on 2026-01-28 07:35
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bounties', '0005_add_indexes'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameIndex(
|
||||
model_name='bounty',
|
||||
new_name='bounties_status_ba7f3d_idx',
|
||||
old_name='bounty_status_created_idx',
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name='bounty',
|
||||
new_name='bounties_publish_1e0a79_idx',
|
||||
old_name='bounty_publisher_created_idx',
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name='bounty',
|
||||
new_name='bounties_accepto_d36c7a_idx',
|
||||
old_name='bounty_acceptor_created_idx',
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name='bountyapplication',
|
||||
new_name='bountyAppli_bounty__e03270_idx',
|
||||
old_name='bountyapp_bounty_status_idx',
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name='bountyapplication',
|
||||
new_name='bountyAppli_applica_1cc9cb_idx',
|
||||
old_name='bountyapp_applicant_status_idx',
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name='bountycomment',
|
||||
new_name='bountyComme_bounty__375c15_idx',
|
||||
old_name='bountycomment_bounty_created_idx',
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name='bountycomment',
|
||||
new_name='bountyComme_parent__e9d6ac_idx',
|
||||
old_name='bountycomment_parent_created_idx',
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name='bountydelivery',
|
||||
new_name='bountyDeliv_bounty__fe1a17_idx',
|
||||
old_name='bountydelivery_bounty_status_idx',
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name='bountydelivery',
|
||||
new_name='bountyDeliv_submitt_86ba61_idx',
|
||||
old_name='bountydelivery_submitted_idx',
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name='bountydispute',
|
||||
new_name='bountyDispu_bounty__fda581_idx',
|
||||
old_name='bountydispute_bounty_status_idx',
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name='bountydispute',
|
||||
new_name='bountyDispu_status_f1e0a9_idx',
|
||||
old_name='bountydispute_status_created_idx',
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name='bountyextensionrequest',
|
||||
new_name='bountyExten_bounty__79bd84_idx',
|
||||
old_name='bountyext_bounty_status_idx',
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name='bountyextensionrequest',
|
||||
new_name='bountyExten_request_a34cea_idx',
|
||||
old_name='bountyext_requester_status_idx',
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name='bountyreview',
|
||||
new_name='bountyRevie_bounty__2cfe16_idx',
|
||||
old_name='bountyreview_bounty_created_idx',
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name='bountyreview',
|
||||
new_name='bountyRevie_reviewe_72fa13_idx',
|
||||
old_name='bountyreview_reviewee_created_idx',
|
||||
),
|
||||
]
|
||||
@@ -69,6 +69,11 @@ class Bounty(models.Model):
|
||||
verbose_name = '悬赏'
|
||||
verbose_name_plural = '悬赏'
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=["status", "created_at"]),
|
||||
models.Index(fields=["publisher", "created_at"]),
|
||||
models.Index(fields=["acceptor", "created_at"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
@@ -110,6 +115,10 @@ class BountyApplication(models.Model):
|
||||
verbose_name = '悬赏申请'
|
||||
verbose_name_plural = '悬赏申请'
|
||||
unique_together = ['bounty', 'applicant']
|
||||
indexes = [
|
||||
models.Index(fields=["bounty", "status"]),
|
||||
models.Index(fields=["applicant", "status"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.applicant} -> {self.bounty.title}"
|
||||
@@ -148,6 +157,10 @@ class BountyComment(models.Model):
|
||||
verbose_name = '悬赏评论'
|
||||
verbose_name_plural = '悬赏评论'
|
||||
ordering = ['created_at']
|
||||
indexes = [
|
||||
models.Index(fields=["bounty", "created_at"]),
|
||||
models.Index(fields=["parent", "created_at"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user} on {self.bounty.title}"
|
||||
@@ -190,6 +203,10 @@ class BountyDelivery(models.Model):
|
||||
verbose_name = '悬赏交付'
|
||||
verbose_name_plural = '悬赏交付'
|
||||
ordering = ['-submitted_at']
|
||||
indexes = [
|
||||
models.Index(fields=["bounty", "status"]),
|
||||
models.Index(fields=["submitted_at"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.bounty.title} - {self.submitter}"
|
||||
@@ -233,6 +250,10 @@ class BountyDispute(models.Model):
|
||||
verbose_name = '悬赏争议'
|
||||
verbose_name_plural = '悬赏争议'
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=["bounty", "status"]),
|
||||
models.Index(fields=["status", "created_at"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.bounty.title} - {self.initiator}"
|
||||
@@ -270,6 +291,10 @@ class BountyReview(models.Model):
|
||||
verbose_name_plural = '悬赏评价'
|
||||
unique_together = ['bounty', 'reviewer']
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=["bounty", "created_at"]),
|
||||
models.Index(fields=["reviewee", "created_at"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.bounty.title} - {self.reviewer}"
|
||||
@@ -341,6 +366,10 @@ class BountyExtensionRequest(models.Model):
|
||||
verbose_name = '延期申请'
|
||||
verbose_name_plural = '延期申请'
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=["bounty", "status"]),
|
||||
models.Index(fields=["requester", "status"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.bounty.title} - {self.requester}"
|
||||
|
||||
@@ -4,6 +4,7 @@ Stripe payment integration for bounties.
|
||||
from typing import Optional
|
||||
from decimal import Decimal
|
||||
from ninja import Router
|
||||
from ninja.errors import HttpError
|
||||
from ninja_jwt.authentication import JWTAuth
|
||||
from django.conf import settings
|
||||
from django.shortcuts import get_object_or_404
|
||||
@@ -15,7 +16,8 @@ import json
|
||||
|
||||
from .models import Bounty, PaymentEvent
|
||||
from apps.users.models import User
|
||||
from apps.notifications.models import Notification, NotificationPreference
|
||||
from apps.notifications.models import Notification
|
||||
from apps.notifications.utils import should_notify
|
||||
|
||||
router = Router()
|
||||
|
||||
@@ -23,24 +25,6 @@ router = Router()
|
||||
stripe.api_key = settings.STRIPE_SECRET_KEY
|
||||
|
||||
|
||||
def should_notify(user, notification_type: str) -> bool:
|
||||
"""Check if user has enabled notification type."""
|
||||
if not user:
|
||||
return False
|
||||
preference, _ = NotificationPreference.objects.get_or_create(user=user)
|
||||
if notification_type == Notification.Type.PRICE_ALERT:
|
||||
return preference.enable_price_alert
|
||||
if notification_type in (
|
||||
Notification.Type.BOUNTY_ACCEPTED,
|
||||
Notification.Type.BOUNTY_COMPLETED,
|
||||
Notification.Type.NEW_COMMENT,
|
||||
):
|
||||
return preference.enable_bounty
|
||||
if notification_type == Notification.Type.SYSTEM:
|
||||
return preference.enable_system
|
||||
return True
|
||||
|
||||
|
||||
class PaymentSchemas:
|
||||
"""Payment related schemas."""
|
||||
|
||||
@@ -81,13 +65,13 @@ def create_escrow(request, data: PaymentSchemas.EscrowIn):
|
||||
bounty = get_object_or_404(Bounty, id=data.bounty_id)
|
||||
|
||||
if bounty.publisher_id != request.auth.id:
|
||||
return {"error": "Only the publisher can create escrow"}, 403
|
||||
raise HttpError(403, "Only the publisher can create escrow")
|
||||
|
||||
if bounty.is_escrowed:
|
||||
return {"error": "Bounty is already escrowed"}, 400
|
||||
raise HttpError(400, "Bounty is already escrowed")
|
||||
|
||||
if bounty.status != Bounty.Status.OPEN:
|
||||
return {"error": "Can only escrow open bounties"}, 400
|
||||
raise HttpError(400, "Can only escrow open bounties")
|
||||
|
||||
try:
|
||||
# Create or get Stripe customer
|
||||
@@ -137,7 +121,7 @@ def create_escrow(request, data: PaymentSchemas.EscrowIn):
|
||||
)
|
||||
|
||||
except stripe.error.StripeError as e:
|
||||
return {"error": str(e)}, 400
|
||||
raise HttpError(400, str(e))
|
||||
|
||||
|
||||
@router.get("/connect/status/", response=PaymentSchemas.ConnectStatusOut, auth=JWTAuth())
|
||||
@@ -175,7 +159,7 @@ def get_connect_status(request):
|
||||
)
|
||||
|
||||
except stripe.error.StripeError as e:
|
||||
return {"error": str(e)}, 400
|
||||
raise HttpError(400, str(e))
|
||||
|
||||
|
||||
@router.post("/connect/setup/", response=PaymentSchemas.ConnectSetupOut, auth=JWTAuth())
|
||||
@@ -215,7 +199,7 @@ def setup_connect_account(request, return_url: str, refresh_url: str):
|
||||
)
|
||||
|
||||
except stripe.error.StripeError as e:
|
||||
return {"error": str(e)}, 400
|
||||
raise HttpError(400, str(e))
|
||||
|
||||
|
||||
@router.post("/{bounty_id}/release/", response=PaymentSchemas.MessageOut, auth=JWTAuth())
|
||||
@@ -224,23 +208,23 @@ def release_payout(request, bounty_id: int):
|
||||
bounty = get_object_or_404(Bounty, id=bounty_id)
|
||||
|
||||
if bounty.publisher_id != request.auth.id:
|
||||
return {"error": "Only the publisher can release payment"}, 403
|
||||
raise HttpError(403, "Only the publisher can release payment")
|
||||
|
||||
if bounty.status != Bounty.Status.COMPLETED:
|
||||
return {"error": "Bounty must be completed to release payment"}, 400
|
||||
raise HttpError(400, "Bounty must be completed to release payment")
|
||||
|
||||
if bounty.is_paid:
|
||||
return {"error": "Payment has already been released"}, 400
|
||||
raise HttpError(400, "Payment has already been released")
|
||||
|
||||
if not bounty.is_escrowed:
|
||||
return {"error": "Bounty is not escrowed"}, 400
|
||||
raise HttpError(400, "Bounty is not escrowed")
|
||||
|
||||
if not bounty.acceptor:
|
||||
return {"error": "No acceptor to pay"}, 400
|
||||
raise HttpError(400, "No acceptor to pay")
|
||||
|
||||
acceptor = bounty.acceptor
|
||||
if not acceptor.stripe_account_id:
|
||||
return {"error": "Acceptor has not set up payment account"}, 400
|
||||
raise HttpError(400, "Acceptor has not set up payment account")
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
@@ -249,7 +233,7 @@ def release_payout(request, bounty_id: int):
|
||||
stripe.PaymentIntent.capture(bounty.stripe_payment_intent_id)
|
||||
|
||||
# Calculate payout amount (minus platform fee if any)
|
||||
platform_fee_percent = Decimal('0.05') # 5% platform fee
|
||||
platform_fee_percent = getattr(settings, "BOUNTY_PLATFORM_FEE_PERCENT", Decimal("0.05"))
|
||||
payout_amount = bounty.reward * (1 - platform_fee_percent)
|
||||
|
||||
# Create transfer to acceptor
|
||||
@@ -282,7 +266,7 @@ def release_payout(request, bounty_id: int):
|
||||
return PaymentSchemas.MessageOut(message="赏金已释放", success=True)
|
||||
|
||||
except stripe.error.StripeError as e:
|
||||
return {"error": str(e)}, 400
|
||||
raise HttpError(400, str(e))
|
||||
|
||||
|
||||
def handle_webhook(request: HttpRequest) -> HttpResponse:
|
||||
|
||||
1
backend/apps/common/__init__.py
Normal file
1
backend/apps/common/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Shared utilities for backend apps."""
|
||||
36
backend/apps/common/errors.py
Normal file
36
backend/apps/common/errors.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
def map_status_to_code(status_code: int) -> str:
|
||||
if status_code == 400:
|
||||
return "bad_request"
|
||||
if status_code == 401:
|
||||
return "unauthorized"
|
||||
if status_code == 403:
|
||||
return "forbidden"
|
||||
if status_code == 404:
|
||||
return "not_found"
|
||||
if status_code == 409:
|
||||
return "conflict"
|
||||
if status_code == 429:
|
||||
return "rate_limited"
|
||||
if status_code >= 500:
|
||||
return "server_error"
|
||||
return "error"
|
||||
|
||||
|
||||
def build_error_payload(
|
||||
*,
|
||||
status_code: int,
|
||||
message: str,
|
||||
details: Optional[Any] = None,
|
||||
code: Optional[str] = None,
|
||||
) -> dict:
|
||||
payload = {
|
||||
"code": code or map_status_to_code(status_code),
|
||||
"message": message,
|
||||
"status": status_code,
|
||||
}
|
||||
if details is not None:
|
||||
payload["details"] = details
|
||||
return payload
|
||||
53
backend/apps/common/serializers.py
Normal file
53
backend/apps/common/serializers.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from apps.users.schemas import UserOut
|
||||
from apps.bounties.schemas import BountyOut, BountyWithDetailsOut
|
||||
|
||||
|
||||
def serialize_user(user):
|
||||
"""Serialize user to UserOut."""
|
||||
if not user:
|
||||
return None
|
||||
return UserOut(
|
||||
id=user.id,
|
||||
open_id=user.open_id,
|
||||
name=user.name,
|
||||
email=user.email,
|
||||
avatar=user.avatar,
|
||||
role=user.role,
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def serialize_bounty(bounty, include_counts: bool = False):
|
||||
"""Serialize bounty to BountyOut or BountyWithDetailsOut."""
|
||||
data = {
|
||||
"id": bounty.id,
|
||||
"title": bounty.title,
|
||||
"description": bounty.description,
|
||||
"reward": bounty.reward,
|
||||
"currency": bounty.currency,
|
||||
"publisher_id": bounty.publisher_id,
|
||||
"publisher": serialize_user(bounty.publisher),
|
||||
"acceptor_id": bounty.acceptor_id,
|
||||
"acceptor": serialize_user(bounty.acceptor) if bounty.acceptor else None,
|
||||
"status": bounty.status,
|
||||
"deadline": bounty.deadline,
|
||||
"completed_at": bounty.completed_at,
|
||||
"is_paid": bounty.is_paid,
|
||||
"is_escrowed": bounty.is_escrowed,
|
||||
"created_at": bounty.created_at,
|
||||
"updated_at": bounty.updated_at,
|
||||
}
|
||||
|
||||
if include_counts:
|
||||
applications_count = getattr(bounty, "applications_count", None)
|
||||
comments_count = getattr(bounty, "comments_count", None)
|
||||
data["applications_count"] = (
|
||||
applications_count if applications_count is not None else bounty.applications.count()
|
||||
)
|
||||
data["comments_count"] = (
|
||||
comments_count if comments_count is not None else bounty.comments.count()
|
||||
)
|
||||
return BountyWithDetailsOut(**data)
|
||||
|
||||
return BountyOut(**data)
|
||||
@@ -22,7 +22,8 @@ from .schemas import (
|
||||
MessageOut,
|
||||
)
|
||||
from apps.products.models import Product, Website, ProductPrice
|
||||
from apps.notifications.models import Notification, NotificationPreference
|
||||
from apps.notifications.models import Notification
|
||||
from apps.notifications.utils import should_notify
|
||||
|
||||
router = Router()
|
||||
|
||||
@@ -55,11 +56,6 @@ def serialize_favorite(favorite):
|
||||
)
|
||||
|
||||
|
||||
def should_notify(user) -> bool:
|
||||
preference, _ = NotificationPreference.objects.get_or_create(user=user)
|
||||
return preference.enable_price_alert
|
||||
|
||||
|
||||
def record_price_for_monitor(monitor: PriceMonitor, price: Decimal):
|
||||
"""Record price history and update monitor stats."""
|
||||
price_change = None
|
||||
@@ -90,7 +86,7 @@ def record_price_for_monitor(monitor: PriceMonitor, price: Decimal):
|
||||
price <= monitor.target_price and
|
||||
(monitor.last_notified_price is None or price < monitor.last_notified_price)
|
||||
)
|
||||
if should_alert and should_notify(monitor.user):
|
||||
if should_alert and should_notify(monitor.user, Notification.Type.PRICE_ALERT):
|
||||
Notification.objects.create(
|
||||
user=monitor.user,
|
||||
type=Notification.Type.PRICE_ALERT,
|
||||
@@ -116,7 +112,7 @@ def list_favorites(request, tag_id: Optional[int] = None):
|
||||
).prefetch_related('tag_mappings', 'tag_mappings__tag')
|
||||
|
||||
if tag_id:
|
||||
queryset = queryset.filter(tag_mappings__tag_id=tag_id)
|
||||
queryset = queryset.filter(tag_mappings__tag_id=tag_id).distinct()
|
||||
|
||||
return [serialize_favorite(f) for f in queryset]
|
||||
|
||||
@@ -197,22 +193,13 @@ def get_favorite(request, favorite_id: int):
|
||||
@router.get("/check/", auth=JWTAuth())
|
||||
def is_favorited(request, product_id: int, website_id: int):
|
||||
"""Check if a product is favorited."""
|
||||
exists = Favorite.objects.filter(
|
||||
favorite_id = Favorite.objects.filter(
|
||||
user=request.auth,
|
||||
product_id=product_id,
|
||||
website_id=website_id
|
||||
).exists()
|
||||
|
||||
favorite_id = None
|
||||
if exists:
|
||||
favorite = Favorite.objects.get(
|
||||
user=request.auth,
|
||||
product_id=product_id,
|
||||
website_id=website_id
|
||||
)
|
||||
favorite_id = favorite.id
|
||||
|
||||
return {"is_favorited": exists, "favorite_id": favorite_id}
|
||||
).values_list("id", flat=True).first()
|
||||
|
||||
return {"is_favorited": bool(favorite_id), "favorite_id": favorite_id}
|
||||
|
||||
|
||||
@router.post("/", response=FavoriteOut, auth=JWTAuth())
|
||||
@@ -274,7 +261,7 @@ def create_tag(request, data: FavoriteTagIn):
|
||||
"""Create a new tag."""
|
||||
# Check if tag with same name exists
|
||||
if FavoriteTag.objects.filter(user=request.auth, name=data.name).exists():
|
||||
return {"error": "Tag with this name already exists"}, 400
|
||||
raise HttpError(400, "Tag with this name already exists")
|
||||
|
||||
tag = FavoriteTag.objects.create(
|
||||
user=request.auth,
|
||||
|
||||
30
backend/apps/favorites/migrations/0004_add_indexes.py
Normal file
30
backend/apps/favorites/migrations/0004_add_indexes.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("favorites", "0003_price_monitor_notify"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name="favorite",
|
||||
index=models.Index(fields=["user", "created_at"], name="favorite_user_created_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="favoritetag",
|
||||
index=models.Index(fields=["user", "created_at"], name="favoritetag_user_created_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="favoritetagmapping",
|
||||
index=models.Index(fields=["tag", "created_at"], name="favoritetag_tag_created_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="pricemonitor",
|
||||
index=models.Index(fields=["user", "is_active"], name="pricemonitor_user_active_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="pricehistory",
|
||||
index=models.Index(fields=["monitor", "recorded_at"], name="pricehistory_monitor_recorded_idx"),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 4.2.27 on 2026-01-28 07:35
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('favorites', '0004_add_indexes'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameIndex(
|
||||
model_name='favorite',
|
||||
new_name='favorites_user_id_9cc509_idx',
|
||||
old_name='favorite_user_created_idx',
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name='favoritetag',
|
||||
new_name='favoriteTag_user_id_b8d48c_idx',
|
||||
old_name='favoritetag_user_created_idx',
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name='favoritetagmapping',
|
||||
new_name='favoriteTag_tag_id_f111e4_idx',
|
||||
old_name='favoritetag_tag_created_idx',
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name='pricehistory',
|
||||
new_name='priceHistor_monitor_ca804f_idx',
|
||||
old_name='pricehistory_monitor_recorded_idx',
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name='pricemonitor',
|
||||
new_name='priceMonito_user_id_d5804f_idx',
|
||||
old_name='pricemonitor_user_active_idx',
|
||||
),
|
||||
]
|
||||
@@ -34,6 +34,9 @@ class Favorite(models.Model):
|
||||
verbose_name = '收藏'
|
||||
verbose_name_plural = '收藏'
|
||||
unique_together = ['user', 'product', 'website']
|
||||
indexes = [
|
||||
models.Index(fields=["user", "created_at"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user} - {self.product.name}"
|
||||
@@ -59,6 +62,9 @@ class FavoriteTag(models.Model):
|
||||
verbose_name = '收藏标签'
|
||||
verbose_name_plural = '收藏标签'
|
||||
unique_together = ['user', 'name']
|
||||
indexes = [
|
||||
models.Index(fields=["user", "created_at"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -87,6 +93,9 @@ class FavoriteTagMapping(models.Model):
|
||||
verbose_name = '收藏标签映射'
|
||||
verbose_name_plural = '收藏标签映射'
|
||||
unique_together = ['favorite', 'tag']
|
||||
indexes = [
|
||||
models.Index(fields=["tag", "created_at"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.favorite} - {self.tag.name}"
|
||||
@@ -153,6 +162,9 @@ class PriceMonitor(models.Model):
|
||||
db_table = 'priceMonitors'
|
||||
verbose_name = '价格监控'
|
||||
verbose_name_plural = '价格监控'
|
||||
indexes = [
|
||||
models.Index(fields=["user", "is_active"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"Monitor: {self.favorite}"
|
||||
@@ -190,6 +202,9 @@ class PriceHistory(models.Model):
|
||||
verbose_name = '价格历史'
|
||||
verbose_name_plural = '价格历史'
|
||||
ordering = ['-recorded_at']
|
||||
indexes = [
|
||||
models.Index(fields=["monitor", "recorded_at"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.monitor.favorite} - {self.price}"
|
||||
|
||||
@@ -11,7 +11,8 @@ from django.shortcuts import get_object_or_404
|
||||
from django.http import HttpResponse
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import Notification, NotificationPreference
|
||||
from .models import Notification
|
||||
from .utils import get_notification_preference
|
||||
from .schemas import (
|
||||
NotificationOut,
|
||||
UnreadCountOut,
|
||||
@@ -63,7 +64,7 @@ def list_notifications(
|
||||
@router.get("/preferences/", response=NotificationPreferenceOut, auth=JWTAuth())
|
||||
def get_preferences(request):
|
||||
"""Get current user's notification preferences."""
|
||||
preference, _ = NotificationPreference.objects.get_or_create(user=request.auth)
|
||||
preference = get_notification_preference(request.auth)
|
||||
return NotificationPreferenceOut(
|
||||
user_id=preference.user_id,
|
||||
enable_bounty=preference.enable_bounty,
|
||||
@@ -76,7 +77,7 @@ def get_preferences(request):
|
||||
@router.patch("/preferences/", response=NotificationPreferenceOut, auth=JWTAuth())
|
||||
def update_preferences(request, data: NotificationPreferenceIn):
|
||||
"""Update notification preferences."""
|
||||
preference, _ = NotificationPreference.objects.get_or_create(user=request.auth)
|
||||
preference = get_notification_preference(request.auth)
|
||||
update_data = data.dict(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(preference, key, value)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.27 on 2026-01-28 07:35
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('notifications', '0003_notification_preferences'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='notification',
|
||||
name='type',
|
||||
field=models.CharField(choices=[('bounty_accepted', '悬赏被接受'), ('bounty_completed', '悬赏已完成'), ('new_comment', '新评论'), ('payment_received', '收到付款'), ('price_alert', '价格提醒'), ('system', '系统通知')], max_length=30, verbose_name='类型'),
|
||||
),
|
||||
]
|
||||
31
backend/apps/notifications/utils.py
Normal file
31
backend/apps/notifications/utils.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
Notification helpers for preference checks.
|
||||
"""
|
||||
from typing import Optional
|
||||
|
||||
from .models import Notification, NotificationPreference
|
||||
|
||||
|
||||
def get_notification_preference(user) -> Optional[NotificationPreference]:
|
||||
if not user:
|
||||
return None
|
||||
preference, _ = NotificationPreference.objects.get_or_create(user=user)
|
||||
return preference
|
||||
|
||||
|
||||
def should_notify(user, notification_type: str) -> bool:
|
||||
"""Check if user has enabled notification type."""
|
||||
preference = get_notification_preference(user)
|
||||
if not preference:
|
||||
return False
|
||||
if notification_type == Notification.Type.PRICE_ALERT:
|
||||
return preference.enable_price_alert
|
||||
if notification_type in (
|
||||
Notification.Type.BOUNTY_ACCEPTED,
|
||||
Notification.Type.BOUNTY_COMPLETED,
|
||||
Notification.Type.NEW_COMMENT,
|
||||
):
|
||||
return preference.enable_bounty
|
||||
if notification_type == Notification.Type.SYSTEM:
|
||||
return preference.enable_system
|
||||
return True
|
||||
@@ -2,6 +2,8 @@
|
||||
Products API routes for categories, websites, products and prices.
|
||||
"""
|
||||
from typing import List, Optional
|
||||
import re
|
||||
import time
|
||||
from decimal import Decimal, InvalidOperation
|
||||
import csv
|
||||
import io
|
||||
@@ -9,9 +11,11 @@ from ninja import Router, Query, File
|
||||
from ninja.files import UploadedFile
|
||||
from ninja_jwt.authentication import JWTAuth
|
||||
from ninja.pagination import paginate, PageNumberPagination
|
||||
from django.db.models import Count, Min, Max, Q
|
||||
from django.db import transaction
|
||||
from django.conf import settings
|
||||
from django.db.models import Count, Min, Max, Q, Prefetch, F
|
||||
from django.db import transaction, IntegrityError
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.views.decorators.cache import cache_page
|
||||
|
||||
from .models import Category, Website, Product, ProductPrice
|
||||
from .schemas import (
|
||||
@@ -19,7 +23,9 @@ from .schemas import (
|
||||
WebsiteOut, WebsiteIn, WebsiteFilter,
|
||||
ProductOut, ProductIn, ProductWithPricesOut, ProductPriceOut, ProductPriceIn,
|
||||
ProductFilter,
|
||||
ProductSearchFilter,
|
||||
ImportResultOut,
|
||||
MyProductOut,
|
||||
)
|
||||
from apps.favorites.models import Favorite
|
||||
|
||||
@@ -31,21 +37,67 @@ website_router = Router()
|
||||
# ==================== Category Routes ====================
|
||||
|
||||
@category_router.get("/", response=List[CategoryOut])
|
||||
@cache_page(settings.CACHE_TTL_SECONDS)
|
||||
def list_categories(request):
|
||||
"""Get all categories."""
|
||||
return Category.objects.all()
|
||||
|
||||
|
||||
@category_router.get("/{slug}", response=CategoryOut)
|
||||
@cache_page(settings.CACHE_TTL_SECONDS)
|
||||
def get_category_by_slug(request, slug: str):
|
||||
"""Get category by slug."""
|
||||
return get_object_or_404(Category, slug=slug)
|
||||
|
||||
|
||||
def require_admin(user):
|
||||
"""Check if user is admin."""
|
||||
from ninja.errors import HttpError
|
||||
if not user or user.role != 'admin' or not user.is_active:
|
||||
raise HttpError(403, "仅管理员可执行此操作")
|
||||
|
||||
|
||||
def normalize_category_slug(name: str, slug: str) -> str:
|
||||
"""Normalize category slug and ensure it's not empty."""
|
||||
raw_slug = (slug or "").strip()
|
||||
if raw_slug:
|
||||
return raw_slug
|
||||
|
||||
base = re.sub(r"\s+", "-", name.strip().lower())
|
||||
base = re.sub(r"[^a-z0-9-]", "", base)
|
||||
if not base:
|
||||
base = f"category-{int(time.time())}"
|
||||
|
||||
if Category.objects.filter(slug=base).exists():
|
||||
suffix = 1
|
||||
while Category.objects.filter(slug=f"{base}-{suffix}").exists():
|
||||
suffix += 1
|
||||
base = f"{base}-{suffix}"
|
||||
return base
|
||||
|
||||
|
||||
@category_router.post("/", response=CategoryOut, auth=JWTAuth())
|
||||
def create_category(request, data: CategoryIn):
|
||||
"""Create a new category."""
|
||||
category = Category.objects.create(**data.dict())
|
||||
name = (data.name or "").strip()
|
||||
if not name:
|
||||
raise HttpError(400, "分类名称不能为空")
|
||||
|
||||
slug = normalize_category_slug(name, data.slug)
|
||||
if len(slug) > 100:
|
||||
raise HttpError(400, "分类标识过长")
|
||||
|
||||
try:
|
||||
category = Category.objects.create(
|
||||
name=name,
|
||||
slug=slug,
|
||||
description=data.description,
|
||||
icon=data.icon,
|
||||
parent_id=data.parent_id,
|
||||
sort_order=data.sort_order or 0,
|
||||
)
|
||||
except IntegrityError:
|
||||
raise HttpError(400, "分类标识已存在")
|
||||
return category
|
||||
|
||||
|
||||
@@ -53,6 +105,7 @@ def create_category(request, data: CategoryIn):
|
||||
|
||||
@website_router.get("/", response=List[WebsiteOut])
|
||||
@paginate(PageNumberPagination, page_size=20)
|
||||
@cache_page(settings.CACHE_TTL_SECONDS)
|
||||
def list_websites(request, filters: WebsiteFilter = Query(...)):
|
||||
"""Get all websites with optional filters."""
|
||||
queryset = Website.objects.all()
|
||||
@@ -66,6 +119,7 @@ def list_websites(request, filters: WebsiteFilter = Query(...)):
|
||||
|
||||
|
||||
@website_router.get("/{website_id}", response=WebsiteOut)
|
||||
@cache_page(settings.CACHE_TTL_SECONDS)
|
||||
def get_website(request, website_id: int):
|
||||
"""Get website by ID."""
|
||||
return get_object_or_404(Website, id=website_id)
|
||||
@@ -73,7 +127,7 @@ def get_website(request, website_id: int):
|
||||
|
||||
@website_router.post("/", response=WebsiteOut, auth=JWTAuth())
|
||||
def create_website(request, data: WebsiteIn):
|
||||
"""Create a new website."""
|
||||
"""Create a new website. Any authenticated user can create."""
|
||||
website = Website.objects.create(**data.dict())
|
||||
return website
|
||||
|
||||
@@ -225,15 +279,22 @@ def import_products_csv(request, file: UploadedFile = File(...)):
|
||||
@router.get("/recommendations/", response=List[ProductOut])
|
||||
def recommend_products(request, limit: int = 12):
|
||||
"""Get recommended products based on favorites or popularity."""
|
||||
# 限制 limit 最大值
|
||||
if limit < 1:
|
||||
limit = 1
|
||||
if limit > 100:
|
||||
limit = 100
|
||||
|
||||
user = getattr(request, "auth", None)
|
||||
base_queryset = Product.objects.all()
|
||||
# 只显示已审核通过的商品
|
||||
base_queryset = Product.objects.select_related("category").filter(status='approved')
|
||||
|
||||
if user:
|
||||
favorite_product_ids = list(
|
||||
Favorite.objects.filter(user=user).values_list("product_id", flat=True)
|
||||
)
|
||||
category_ids = list(
|
||||
Product.objects.filter(id__in=favorite_product_ids)
|
||||
Product.objects.filter(id__in=favorite_product_ids, status='approved')
|
||||
.values_list("category_id", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
@@ -251,9 +312,11 @@ def recommend_products(request, limit: int = 12):
|
||||
|
||||
@router.get("/", response=List[ProductOut])
|
||||
@paginate(PageNumberPagination, page_size=20)
|
||||
@cache_page(settings.CACHE_TTL_SECONDS)
|
||||
def list_products(request, filters: ProductFilter = Query(...)):
|
||||
"""Get all products with optional filters."""
|
||||
queryset = Product.objects.all()
|
||||
"""Get all approved products with optional filters."""
|
||||
# 只显示已审核通过的商品
|
||||
queryset = Product.objects.select_related("category").filter(status='approved')
|
||||
|
||||
if filters.category_id:
|
||||
queryset = queryset.filter(category_id=filters.category_id)
|
||||
@@ -263,16 +326,40 @@ def list_products(request, filters: ProductFilter = Query(...)):
|
||||
Q(description__icontains=filters.search)
|
||||
)
|
||||
|
||||
needs_price_stats = (
|
||||
filters.min_price is not None
|
||||
or filters.max_price is not None
|
||||
or (filters.sort_by or "").lower() in ("price_asc", "price_desc")
|
||||
)
|
||||
if needs_price_stats:
|
||||
queryset = queryset.annotate(lowest_price=Min("prices__price"))
|
||||
if filters.min_price is not None:
|
||||
queryset = queryset.filter(lowest_price__gte=filters.min_price)
|
||||
if filters.max_price is not None:
|
||||
queryset = queryset.filter(lowest_price__lte=filters.max_price)
|
||||
|
||||
sort_by = (filters.sort_by or "newest").lower()
|
||||
if sort_by == "oldest":
|
||||
queryset = queryset.order_by("created_at")
|
||||
elif sort_by == "price_asc":
|
||||
queryset = queryset.order_by(F("lowest_price").asc(nulls_last=True), "-created_at")
|
||||
elif sort_by == "price_desc":
|
||||
queryset = queryset.order_by(F("lowest_price").desc(nulls_last=True), "-created_at")
|
||||
else:
|
||||
queryset = queryset.order_by("-created_at")
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
@router.get("/{product_id}", response=ProductOut)
|
||||
@cache_page(settings.CACHE_TTL_SECONDS)
|
||||
def get_product(request, product_id: int):
|
||||
"""Get product by ID."""
|
||||
return get_object_or_404(Product, id=product_id)
|
||||
|
||||
|
||||
@router.get("/{product_id}/with-prices", response=ProductWithPricesOut)
|
||||
@cache_page(settings.CACHE_TTL_SECONDS)
|
||||
def get_product_with_prices(request, product_id: int):
|
||||
"""Get product with all prices from different websites."""
|
||||
product = get_object_or_404(Product, id=product_id)
|
||||
@@ -317,15 +404,48 @@ def get_product_with_prices(request, product_id: int):
|
||||
|
||||
@router.get("/search/", response=List[ProductWithPricesOut])
|
||||
@paginate(PageNumberPagination, page_size=20)
|
||||
def search_products(request, q: str):
|
||||
"""Search products by name or description."""
|
||||
products = Product.objects.filter(
|
||||
Q(name__icontains=q) | Q(description__icontains=q)
|
||||
@cache_page(settings.CACHE_TTL_SECONDS)
|
||||
def search_products(request, q: str, filters: ProductSearchFilter = Query(...)):
|
||||
"""Search approved products by name or description."""
|
||||
prices_prefetch = Prefetch(
|
||||
"prices",
|
||||
queryset=ProductPrice.objects.select_related("website"),
|
||||
)
|
||||
# 只搜索已审核通过的商品
|
||||
products = (
|
||||
Product.objects.select_related("category")
|
||||
.filter(Q(name__icontains=q) | Q(description__icontains=q), status='approved')
|
||||
)
|
||||
if filters.category_id:
|
||||
products = products.filter(category_id=filters.category_id)
|
||||
|
||||
needs_price_stats = (
|
||||
filters.min_price is not None
|
||||
or filters.max_price is not None
|
||||
or (filters.sort_by or "").lower() in ("price_asc", "price_desc")
|
||||
)
|
||||
if needs_price_stats:
|
||||
products = products.annotate(lowest_price=Min("prices__price"))
|
||||
if filters.min_price is not None:
|
||||
products = products.filter(lowest_price__gte=filters.min_price)
|
||||
if filters.max_price is not None:
|
||||
products = products.filter(lowest_price__lte=filters.max_price)
|
||||
|
||||
sort_by = (filters.sort_by or "newest").lower()
|
||||
if sort_by == "oldest":
|
||||
products = products.order_by("created_at")
|
||||
elif sort_by == "price_asc":
|
||||
products = products.order_by(F("lowest_price").asc(nulls_last=True), "-created_at")
|
||||
elif sort_by == "price_desc":
|
||||
products = products.order_by(F("lowest_price").desc(nulls_last=True), "-created_at")
|
||||
else:
|
||||
products = products.order_by("-created_at")
|
||||
|
||||
products = products.prefetch_related(prices_prefetch)
|
||||
|
||||
result = []
|
||||
for product in products:
|
||||
prices = ProductPrice.objects.filter(product=product).select_related('website')
|
||||
prices = list(product.prices.all())
|
||||
price_list = [
|
||||
ProductPriceOut(
|
||||
id=pp.id,
|
||||
@@ -342,8 +462,8 @@ def search_products(request, q: str):
|
||||
)
|
||||
for pp in prices
|
||||
]
|
||||
|
||||
price_stats = prices.aggregate(lowest=Min('price'), highest=Max('price'))
|
||||
lowest_price = min((pp.price for pp in prices), default=None)
|
||||
highest_price = max((pp.price for pp in prices), default=None)
|
||||
|
||||
result.append(ProductWithPricesOut(
|
||||
id=product.id,
|
||||
@@ -354,8 +474,8 @@ def search_products(request, q: str):
|
||||
created_at=product.created_at,
|
||||
updated_at=product.updated_at,
|
||||
prices=price_list,
|
||||
lowest_price=price_stats['lowest'],
|
||||
highest_price=price_stats['highest'],
|
||||
lowest_price=lowest_price,
|
||||
highest_price=highest_price,
|
||||
))
|
||||
|
||||
return result
|
||||
@@ -363,14 +483,43 @@ def search_products(request, q: str):
|
||||
|
||||
@router.post("/", response=ProductOut, auth=JWTAuth())
|
||||
def create_product(request, data: ProductIn):
|
||||
"""Create a new product."""
|
||||
product = Product.objects.create(**data.dict())
|
||||
"""Create a new product. Admin creates approved, others create pending."""
|
||||
user = request.auth
|
||||
is_admin = user and user.role == 'admin' and user.is_active
|
||||
|
||||
product = Product.objects.create(
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
image=data.image,
|
||||
category_id=data.category_id,
|
||||
status='approved' if is_admin else 'pending',
|
||||
submitted_by=user,
|
||||
)
|
||||
return product
|
||||
|
||||
|
||||
@router.get("/my/", response=List[MyProductOut], auth=JWTAuth())
|
||||
@paginate(PageNumberPagination, page_size=20)
|
||||
def my_products(request, status: Optional[str] = None):
|
||||
"""Get current user's submitted products."""
|
||||
user = request.auth
|
||||
queryset = Product.objects.filter(submitted_by=user).order_by('-created_at')
|
||||
if status:
|
||||
queryset = queryset.filter(status=status)
|
||||
return queryset
|
||||
|
||||
|
||||
@router.post("/prices/", response=ProductPriceOut, auth=JWTAuth())
|
||||
def add_product_price(request, data: ProductPriceIn):
|
||||
"""Add a price for a product."""
|
||||
"""Add a price for a product. Admin or product owner can add."""
|
||||
user = request.auth
|
||||
is_admin = user and user.role == 'admin' and user.is_active
|
||||
|
||||
# 检查商品是否存在并验证权限
|
||||
product = get_object_or_404(Product, id=data.product_id)
|
||||
if not is_admin and product.submitted_by_id != user.id:
|
||||
from ninja.errors import HttpError
|
||||
raise HttpError(403, "只能为自己提交的商品添加价格")
|
||||
price = ProductPrice.objects.create(**data.dict())
|
||||
website = price.website
|
||||
|
||||
|
||||
114
backend/apps/products/management/commands/init_data.py
Normal file
114
backend/apps/products/management/commands/init_data.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
Management command to initialize sample categories and websites.
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from apps.products.models import Category, Website
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Initialize sample categories and websites"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Create categories
|
||||
categories_data = [
|
||||
{"name": "数码产品", "slug": "digital", "description": "手机、电脑、平板等数码产品", "icon": "💻"},
|
||||
{"name": "家用电器", "slug": "appliance", "description": "家电、厨房电器等", "icon": "🏠"},
|
||||
{"name": "服装鞋包", "slug": "fashion", "description": "服装、鞋子、箱包等", "icon": "👗"},
|
||||
{"name": "美妆护肤", "slug": "beauty", "description": "化妆品、护肤品等", "icon": "💄"},
|
||||
{"name": "食品饮料", "slug": "food", "description": "食品、零食、饮料等", "icon": "🍔"},
|
||||
{"name": "图书音像", "slug": "books", "description": "图书、音像制品等", "icon": "📚"},
|
||||
{"name": "运动户外", "slug": "sports", "description": "运动器材、户外装备等", "icon": "⚽"},
|
||||
{"name": "母婴用品", "slug": "baby", "description": "母婴、儿童用品等", "icon": "👶"},
|
||||
]
|
||||
|
||||
created_categories = 0
|
||||
for cat_data in categories_data:
|
||||
category, created = Category.objects.get_or_create(
|
||||
slug=cat_data["slug"],
|
||||
defaults=cat_data
|
||||
)
|
||||
if created:
|
||||
created_categories += 1
|
||||
self.stdout.write(f" 创建分类: {category.name}")
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"分类: 新建 {created_categories} 个"))
|
||||
|
||||
# Get digital category for websites
|
||||
digital_category = Category.objects.filter(slug="digital").first()
|
||||
appliance_category = Category.objects.filter(slug="appliance").first()
|
||||
fashion_category = Category.objects.filter(slug="fashion").first()
|
||||
|
||||
# Create websites
|
||||
websites_data = [
|
||||
{
|
||||
"name": "京东",
|
||||
"url": "https://www.jd.com",
|
||||
"description": "京东商城",
|
||||
"category": digital_category,
|
||||
"is_verified": True,
|
||||
},
|
||||
{
|
||||
"name": "淘宝",
|
||||
"url": "https://www.taobao.com",
|
||||
"description": "淘宝网",
|
||||
"category": fashion_category,
|
||||
"is_verified": True,
|
||||
},
|
||||
{
|
||||
"name": "天猫",
|
||||
"url": "https://www.tmall.com",
|
||||
"description": "天猫商城",
|
||||
"category": fashion_category,
|
||||
"is_verified": True,
|
||||
},
|
||||
{
|
||||
"name": "拼多多",
|
||||
"url": "https://www.pinduoduo.com",
|
||||
"description": "拼多多",
|
||||
"category": digital_category,
|
||||
"is_verified": True,
|
||||
},
|
||||
{
|
||||
"name": "苏宁易购",
|
||||
"url": "https://www.suning.com",
|
||||
"description": "苏宁易购",
|
||||
"category": appliance_category,
|
||||
"is_verified": True,
|
||||
},
|
||||
{
|
||||
"name": "国美",
|
||||
"url": "https://www.gome.com.cn",
|
||||
"description": "国美电器",
|
||||
"category": appliance_category,
|
||||
"is_verified": True,
|
||||
},
|
||||
{
|
||||
"name": "亚马逊中国",
|
||||
"url": "https://www.amazon.cn",
|
||||
"description": "亚马逊中国",
|
||||
"category": digital_category,
|
||||
"is_verified": True,
|
||||
},
|
||||
{
|
||||
"name": "当当网",
|
||||
"url": "https://www.dangdang.com",
|
||||
"description": "当当网",
|
||||
"category": Category.objects.filter(slug="books").first(),
|
||||
"is_verified": True,
|
||||
},
|
||||
]
|
||||
|
||||
created_websites = 0
|
||||
for web_data in websites_data:
|
||||
if web_data["category"] is None:
|
||||
continue
|
||||
website, created = Website.objects.get_or_create(
|
||||
name=web_data["name"],
|
||||
defaults=web_data
|
||||
)
|
||||
if created:
|
||||
created_websites += 1
|
||||
self.stdout.write(f" 创建网站: {website.name}")
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"网站: 新建 {created_websites} 个"))
|
||||
self.stdout.write(self.style.SUCCESS("初始化完成!"))
|
||||
26
backend/apps/products/migrations/0002_add_indexes.py
Normal file
26
backend/apps/products/migrations/0002_add_indexes.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("products", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name="category",
|
||||
index=models.Index(fields=["parent", "sort_order"], name="category_parent_sort_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="website",
|
||||
index=models.Index(fields=["category", "is_verified"], name="website_category_verified_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="product",
|
||||
index=models.Index(fields=["category", "created_at"], name="product_category_created_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="productprice",
|
||||
index=models.Index(fields=["product", "website", "last_checked"], name="productprice_prod_web_checked_idx"),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 4.2.27 on 2026-01-28 07:35
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('products', '0002_add_indexes'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameIndex(
|
||||
model_name='category',
|
||||
new_name='categories_parent__5c622c_idx',
|
||||
old_name='category_parent_sort_idx',
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name='product',
|
||||
new_name='products_categor_366566_idx',
|
||||
old_name='product_category_created_idx',
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name='productprice',
|
||||
new_name='productPric_product_7397d0_idx',
|
||||
old_name='productprice_prod_web_checked_idx',
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name='website',
|
||||
new_name='websites_categor_97d7c0_idx',
|
||||
old_name='website_category_verified_idx',
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,44 @@
|
||||
# Generated by Django 4.2.27 on 2026-01-28 07:53
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('products', '0003_rename_category_parent_sort_idx_categories_parent__5c622c_idx_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='product',
|
||||
name='reject_reason',
|
||||
field=models.TextField(blank=True, null=True, verbose_name='拒绝原因'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='product',
|
||||
name='reviewed_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='审核时间'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='product',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('pending', '待审核'), ('approved', '已通过'), ('rejected', '已拒绝')], default='pending', max_length=20, verbose_name='审核状态'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='product',
|
||||
name='submitted_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='submitted_products', to=settings.AUTH_USER_MODEL, verbose_name='提交者'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='product',
|
||||
index=models.Index(fields=['status', 'created_at'], name='products_status_678497_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='product',
|
||||
index=models.Index(fields=['submitted_by', 'status'], name='products_submitt_1319f6_idx'),
|
||||
),
|
||||
]
|
||||
@@ -2,6 +2,7 @@
|
||||
Product models for categories, websites, products and prices.
|
||||
"""
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class Category(models.Model):
|
||||
@@ -28,6 +29,9 @@ class Category(models.Model):
|
||||
verbose_name = '分类'
|
||||
verbose_name_plural = '分类'
|
||||
ordering = ['sort_order', 'id']
|
||||
indexes = [
|
||||
models.Index(fields=["parent", "sort_order"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -58,6 +62,9 @@ class Website(models.Model):
|
||||
verbose_name = '网站'
|
||||
verbose_name_plural = '网站'
|
||||
ordering = ['sort_order', 'id']
|
||||
indexes = [
|
||||
models.Index(fields=["category", "is_verified"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -66,6 +73,11 @@ class Website(models.Model):
|
||||
class Product(models.Model):
|
||||
"""Products for price comparison."""
|
||||
|
||||
class Status(models.TextChoices):
|
||||
PENDING = 'pending', '待审核'
|
||||
APPROVED = 'approved', '已通过'
|
||||
REJECTED = 'rejected', '已拒绝'
|
||||
|
||||
id = models.AutoField(primary_key=True)
|
||||
name = models.CharField('商品名称', max_length=300)
|
||||
description = models.TextField('描述', blank=True, null=True)
|
||||
@@ -76,6 +88,22 @@ class Product(models.Model):
|
||||
related_name='products',
|
||||
verbose_name='分类'
|
||||
)
|
||||
status = models.CharField(
|
||||
'审核状态',
|
||||
max_length=20,
|
||||
choices=Status.choices,
|
||||
default=Status.PENDING
|
||||
)
|
||||
submitted_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='submitted_products',
|
||||
verbose_name='提交者'
|
||||
)
|
||||
reject_reason = models.TextField('拒绝原因', blank=True, null=True)
|
||||
reviewed_at = models.DateTimeField('审核时间', blank=True, null=True)
|
||||
created_at = models.DateTimeField('创建时间', auto_now_add=True)
|
||||
updated_at = models.DateTimeField('更新时间', auto_now=True)
|
||||
|
||||
@@ -83,6 +111,11 @@ class Product(models.Model):
|
||||
db_table = 'products'
|
||||
verbose_name = '商品'
|
||||
verbose_name_plural = '商品'
|
||||
indexes = [
|
||||
models.Index(fields=["category", "created_at"]),
|
||||
models.Index(fields=["status", "created_at"]),
|
||||
models.Index(fields=["submitted_by", "status"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -124,6 +157,9 @@ class ProductPrice(models.Model):
|
||||
verbose_name = '商品价格'
|
||||
verbose_name_plural = '商品价格'
|
||||
unique_together = ['product', 'website']
|
||||
indexes = [
|
||||
models.Index(fields=["product", "website", "last_checked"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.product.name} - {self.website.name}: {self.price}"
|
||||
|
||||
@@ -79,6 +79,10 @@ class ProductOut(Schema):
|
||||
description: Optional[str] = None
|
||||
image: Optional[str] = None
|
||||
category_id: int
|
||||
status: str = "approved"
|
||||
submitted_by_id: Optional[int] = None
|
||||
reject_reason: Optional[str] = None
|
||||
reviewed_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -98,6 +102,20 @@ class ProductIn(Schema):
|
||||
category_id: int
|
||||
|
||||
|
||||
class MyProductOut(Schema):
|
||||
"""User's product output schema."""
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
image: Optional[str] = None
|
||||
category_id: int
|
||||
status: str
|
||||
reject_reason: Optional[str] = None
|
||||
reviewed_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ProductPriceIn(Schema):
|
||||
"""Product price input schema."""
|
||||
product_id: int
|
||||
@@ -123,6 +141,17 @@ class ProductFilter(FilterSchema):
|
||||
"""Product filter schema."""
|
||||
category_id: Optional[int] = None
|
||||
search: Optional[str] = None
|
||||
min_price: Optional[Decimal] = None
|
||||
max_price: Optional[Decimal] = None
|
||||
sort_by: Optional[str] = None
|
||||
|
||||
|
||||
class ProductSearchFilter(FilterSchema):
|
||||
"""Product search filter schema."""
|
||||
category_id: Optional[int] = None
|
||||
min_price: Optional[Decimal] = None
|
||||
max_price: Optional[Decimal] = None
|
||||
sort_by: Optional[str] = None
|
||||
|
||||
|
||||
class WebsiteFilter(FilterSchema):
|
||||
|
||||
@@ -2,15 +2,17 @@
|
||||
User authentication API routes.
|
||||
"""
|
||||
from typing import Optional
|
||||
from ninja import Router
|
||||
from ninja import Router, Schema
|
||||
from ninja.errors import HttpError
|
||||
from ninja_jwt.authentication import JWTAuth
|
||||
from ninja_jwt.tokens import RefreshToken
|
||||
from django.conf import settings
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from urllib.parse import urlparse, urlencode
|
||||
import requests
|
||||
|
||||
from .models import User
|
||||
from .schemas import UserOut, UserUpdate, TokenOut, OAuthCallbackIn, MessageOut, RegisterIn, LoginIn
|
||||
from .schemas import UserOut, UserPrivateOut, UserUpdate, TokenOut, OAuthCallbackIn, MessageOut, RegisterIn, LoginIn
|
||||
|
||||
router = Router()
|
||||
|
||||
@@ -22,18 +24,44 @@ def get_current_user(request: HttpRequest) -> Optional[User]:
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/me", response=UserOut, auth=JWTAuth())
|
||||
def _is_valid_url(value: str) -> bool:
|
||||
if not value:
|
||||
return False
|
||||
parsed = urlparse(value)
|
||||
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
|
||||
|
||||
|
||||
def _require_oauth_config():
|
||||
if not settings.OAUTH_CLIENT_ID:
|
||||
raise HttpError(500, "OAuth 未配置客户端 ID")
|
||||
if not settings.OAUTH_AUTHORIZE_URL:
|
||||
raise HttpError(500, "OAuth 未配置授权地址")
|
||||
if not settings.OAUTH_TOKEN_URL:
|
||||
raise HttpError(500, "OAuth 未配置令牌地址")
|
||||
if not settings.OAUTH_USERINFO_URL:
|
||||
raise HttpError(500, "OAuth 未配置用户信息地址")
|
||||
if not _is_valid_url(settings.OAUTH_REDIRECT_URI):
|
||||
raise HttpError(500, "OAuth 回调地址无效")
|
||||
|
||||
|
||||
@router.get("/me", response=UserPrivateOut, auth=JWTAuth())
|
||||
def get_me(request):
|
||||
"""Get current user information."""
|
||||
return request.auth
|
||||
|
||||
|
||||
@router.patch("/me", response=UserOut, auth=JWTAuth())
|
||||
@router.patch("/me", response=UserPrivateOut, auth=JWTAuth())
|
||||
def update_me(request, data: UserUpdate):
|
||||
"""Update current user information."""
|
||||
user = request.auth
|
||||
|
||||
# 验证邮箱格式
|
||||
if data.email is not None:
|
||||
validate_email(data.email)
|
||||
|
||||
if data.name is not None:
|
||||
if len(data.name) > 50:
|
||||
raise HttpError(400, "名称不能超过50个字符")
|
||||
user.name = data.name
|
||||
if data.email is not None:
|
||||
user.email = data.email
|
||||
@@ -55,6 +83,36 @@ def logout(request):
|
||||
return MessageOut(message="已退出登录", success=True)
|
||||
|
||||
|
||||
class ChangePasswordIn(Schema):
|
||||
"""Change password input schema."""
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
@router.post("/change-password", response=MessageOut, auth=JWTAuth())
|
||||
def change_password(request, data: ChangePasswordIn):
|
||||
"""Change current user's password."""
|
||||
user = request.auth
|
||||
|
||||
# 验证当前密码
|
||||
if not user.check_password(data.current_password):
|
||||
raise HttpError(400, "当前密码错误")
|
||||
|
||||
# 验证新密码
|
||||
if len(data.new_password) < 6:
|
||||
raise HttpError(400, "新密码长度至少6位")
|
||||
if len(data.new_password) > 128:
|
||||
raise HttpError(400, "新密码长度不能超过128位")
|
||||
if data.current_password == data.new_password:
|
||||
raise HttpError(400, "新密码不能与当前密码相同")
|
||||
|
||||
# 更新密码
|
||||
user.set_password(data.new_password)
|
||||
user.save()
|
||||
|
||||
return MessageOut(message="密码已更新", success=True)
|
||||
|
||||
|
||||
@router.post("/refresh", response=TokenOut)
|
||||
def refresh_token(request, refresh_token: str):
|
||||
"""Refresh access token using refresh token."""
|
||||
@@ -64,19 +122,50 @@ def refresh_token(request, refresh_token: str):
|
||||
access_token=str(refresh.access_token),
|
||||
refresh_token=str(refresh),
|
||||
)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}, 401
|
||||
except Exception:
|
||||
raise HttpError(401, "刷新令牌无效或已过期")
|
||||
|
||||
|
||||
def validate_password(password: str) -> None:
|
||||
"""Validate password strength."""
|
||||
if len(password) < 6:
|
||||
raise HttpError(400, "密码长度至少6位")
|
||||
if len(password) > 128:
|
||||
raise HttpError(400, "密码长度不能超过128位")
|
||||
|
||||
|
||||
def validate_email(email: Optional[str]) -> None:
|
||||
"""Validate email format."""
|
||||
if email:
|
||||
import re
|
||||
email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
||||
if not re.match(email_pattern, email):
|
||||
raise HttpError(400, "邮箱格式不正确")
|
||||
|
||||
|
||||
@router.post("/register", response=TokenOut)
|
||||
def register(request, data: RegisterIn):
|
||||
"""Register new user with password."""
|
||||
# 输入验证
|
||||
validate_password(data.password)
|
||||
|
||||
# 邮箱必填且格式验证
|
||||
if not data.email:
|
||||
raise HttpError(400, "邮箱为必填项")
|
||||
validate_email(data.email)
|
||||
|
||||
# 检查用户名是否已存在
|
||||
if User.objects.filter(open_id=data.open_id).exists():
|
||||
return {"error": "账号已存在"}, 400
|
||||
raise HttpError(400, "用户名已被使用")
|
||||
|
||||
# 检查邮箱是否已存在
|
||||
if User.objects.filter(email=data.email).exists():
|
||||
raise HttpError(400, "邮箱已被注册")
|
||||
|
||||
user = User.objects.create_user(
|
||||
open_id=data.open_id,
|
||||
password=data.password,
|
||||
name=data.name,
|
||||
name=data.name or data.open_id, # 默认显示名称为用户名
|
||||
email=data.email,
|
||||
login_method="password",
|
||||
)
|
||||
@@ -89,13 +178,23 @@ def register(request, data: RegisterIn):
|
||||
|
||||
@router.post("/login", response=TokenOut)
|
||||
def login(request, data: LoginIn):
|
||||
"""Login with open_id and password."""
|
||||
"""Login with open_id or email and password."""
|
||||
from django.db.models import Q
|
||||
|
||||
# 支持用户名或邮箱登录
|
||||
try:
|
||||
user = User.objects.get(open_id=data.open_id)
|
||||
user = User.objects.get(Q(open_id=data.open_id) | Q(email=data.open_id))
|
||||
except User.DoesNotExist:
|
||||
return {"error": "账号或密码错误"}, 401
|
||||
raise HttpError(401, "账号或密码错误")
|
||||
except User.MultipleObjectsReturned:
|
||||
# 如果同时匹配多个用户,优先使用open_id匹配
|
||||
try:
|
||||
user = User.objects.get(open_id=data.open_id)
|
||||
except User.DoesNotExist:
|
||||
raise HttpError(401, "账号或密码错误")
|
||||
|
||||
if not user.check_password(data.password):
|
||||
return {"error": "账号或密码错误"}, 401
|
||||
raise HttpError(401, "账号或密码错误")
|
||||
refresh = RefreshToken.for_user(user)
|
||||
return TokenOut(
|
||||
access_token=str(refresh.access_token),
|
||||
@@ -106,27 +205,29 @@ def login(request, data: LoginIn):
|
||||
@router.get("/oauth/url")
|
||||
def get_oauth_url(request, redirect_uri: Optional[str] = None):
|
||||
"""Get OAuth authorization URL."""
|
||||
# This would integrate with Manus SDK or other OAuth provider
|
||||
client_id = settings.OAUTH_CLIENT_ID
|
||||
_require_oauth_config()
|
||||
redirect = redirect_uri or settings.OAUTH_REDIRECT_URI
|
||||
|
||||
# Example OAuth URL (adjust based on actual OAuth provider)
|
||||
oauth_url = f"https://oauth.example.com/authorize?client_id={client_id}&redirect_uri={redirect}&response_type=code"
|
||||
|
||||
if not _is_valid_url(redirect):
|
||||
raise HttpError(400, "回调地址无效")
|
||||
query = urlencode(
|
||||
{
|
||||
"client_id": settings.OAUTH_CLIENT_ID,
|
||||
"redirect_uri": redirect,
|
||||
"response_type": "code",
|
||||
}
|
||||
)
|
||||
oauth_url = f"{settings.OAUTH_AUTHORIZE_URL}?{query}"
|
||||
return {"url": oauth_url}
|
||||
|
||||
|
||||
@router.post("/oauth/callback", response=TokenOut)
|
||||
def oauth_callback(request, data: OAuthCallbackIn):
|
||||
"""Handle OAuth callback and create/update user."""
|
||||
# This would exchange the code for tokens with the OAuth provider
|
||||
# and create or update the user in the database
|
||||
|
||||
# Example implementation (adjust based on actual OAuth provider)
|
||||
try:
|
||||
_require_oauth_config()
|
||||
# Exchange code for access token
|
||||
token_response = requests.post(
|
||||
"https://oauth.example.com/token",
|
||||
settings.OAUTH_TOKEN_URL,
|
||||
data={
|
||||
"client_id": settings.OAUTH_CLIENT_ID,
|
||||
"client_secret": settings.OAUTH_CLIENT_SECRET,
|
||||
@@ -137,18 +238,18 @@ def oauth_callback(request, data: OAuthCallbackIn):
|
||||
)
|
||||
|
||||
if token_response.status_code != 200:
|
||||
return {"error": "OAuth token exchange failed"}, 400
|
||||
raise HttpError(400, "OAuth token exchange failed")
|
||||
|
||||
oauth_data = token_response.json()
|
||||
|
||||
# Get user info from OAuth provider
|
||||
user_response = requests.get(
|
||||
"https://oauth.example.com/userinfo",
|
||||
settings.OAUTH_USERINFO_URL,
|
||||
headers={"Authorization": f"Bearer {oauth_data['access_token']}"}
|
||||
)
|
||||
|
||||
if user_response.status_code != 200:
|
||||
return {"error": "Failed to get user info"}, 400
|
||||
raise HttpError(400, "Failed to get user info")
|
||||
|
||||
user_info = user_response.json()
|
||||
|
||||
@@ -171,8 +272,8 @@ def oauth_callback(request, data: OAuthCallbackIn):
|
||||
refresh_token=str(refresh),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return {"error": str(e)}, 500
|
||||
except Exception:
|
||||
raise HttpError(500, "OAuth 登录失败")
|
||||
|
||||
|
||||
# Development endpoint for testing without OAuth
|
||||
@@ -180,7 +281,7 @@ def oauth_callback(request, data: OAuthCallbackIn):
|
||||
def dev_login(request, open_id: str, name: Optional[str] = None):
|
||||
"""Development login endpoint (disable in production)."""
|
||||
if not settings.DEBUG:
|
||||
return {"error": "Not available in production"}, 403
|
||||
raise HttpError(403, "Not available in production")
|
||||
|
||||
user, created = User.objects.get_or_create(
|
||||
open_id=open_id,
|
||||
|
||||
0
backend/apps/users/management/__init__.py
Normal file
0
backend/apps/users/management/__init__.py
Normal file
0
backend/apps/users/management/commands/__init__.py
Normal file
0
backend/apps/users/management/commands/__init__.py
Normal file
97
backend/apps/users/management/commands/createsuperadmin.py
Normal file
97
backend/apps/users/management/commands/createsuperadmin.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
Management command to create a superadmin user.
|
||||
"""
|
||||
import getpass
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from apps.users.models import User
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Create a superadmin user with admin role'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--username',
|
||||
type=str,
|
||||
help='Username for the admin account',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--email',
|
||||
type=str,
|
||||
help='Email for the admin account',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--password',
|
||||
type=str,
|
||||
help='Password for the admin account (not recommended, use interactive mode instead)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--name',
|
||||
type=str,
|
||||
help='Display name for the admin account',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--noinput',
|
||||
action='store_true',
|
||||
help='Do not prompt for input (requires --username and --password)',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
username = options.get('username')
|
||||
email = options.get('email')
|
||||
password = options.get('password')
|
||||
name = options.get('name')
|
||||
noinput = options.get('noinput')
|
||||
|
||||
if noinput:
|
||||
if not username or not password:
|
||||
raise CommandError('--username and --password are required when using --noinput')
|
||||
else:
|
||||
# Interactive mode
|
||||
if not username:
|
||||
username = input('Username: ').strip()
|
||||
if not username:
|
||||
raise CommandError('Username cannot be empty')
|
||||
|
||||
if not email:
|
||||
email = input('Email (optional): ').strip() or None
|
||||
|
||||
if not name:
|
||||
name = input('Display name (optional): ').strip() or None
|
||||
|
||||
if not password:
|
||||
password = getpass.getpass('Password: ')
|
||||
password_confirm = getpass.getpass('Password (again): ')
|
||||
if password != password_confirm:
|
||||
raise CommandError('Passwords do not match')
|
||||
|
||||
if not password:
|
||||
raise CommandError('Password cannot be empty')
|
||||
|
||||
if len(password) < 6:
|
||||
raise CommandError('Password must be at least 6 characters')
|
||||
|
||||
# Check if user already exists
|
||||
if User.objects.filter(open_id=username).exists():
|
||||
raise CommandError(f'User with username "{username}" already exists')
|
||||
|
||||
if email and User.objects.filter(email=email).exists():
|
||||
raise CommandError(f'User with email "{email}" already exists')
|
||||
|
||||
# Create the admin user
|
||||
user = User(
|
||||
open_id=username,
|
||||
email=email,
|
||||
name=name or username,
|
||||
role='admin',
|
||||
is_active=True,
|
||||
)
|
||||
user.set_password(password)
|
||||
user.save()
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f'Successfully created superadmin user "{username}"')
|
||||
)
|
||||
self.stdout.write(f' - Role: admin')
|
||||
self.stdout.write(f' - Email: {email or "(not set)"}')
|
||||
self.stdout.write(f' - Display name: {name or username}')
|
||||
26
backend/apps/users/migrations/0003_add_indexes.py
Normal file
26
backend/apps/users/migrations/0003_add_indexes.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("users", "0002_friend_request"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name="user",
|
||||
index=models.Index(fields=["role", "is_active"], name="user_role_active_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="user",
|
||||
index=models.Index(fields=["created_at"], name="user_created_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="friendrequest",
|
||||
index=models.Index(fields=["receiver", "status"], name="friendreq_receiver_status_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="friendrequest",
|
||||
index=models.Index(fields=["requester", "status"], name="friendreq_requester_status_idx"),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 4.2.27 on 2026-01-28 07:35
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0003_add_indexes'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameIndex(
|
||||
model_name='friendrequest',
|
||||
new_name='friend_requ_receive_383c2c_idx',
|
||||
old_name='friendreq_receiver_status_idx',
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name='friendrequest',
|
||||
new_name='friend_requ_request_97ff9a_idx',
|
||||
old_name='friendreq_requester_status_idx',
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name='user',
|
||||
new_name='users_role_a8f2ba_idx',
|
||||
old_name='user_role_active_idx',
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name='user',
|
||||
new_name='users_created_6541e9_idx',
|
||||
old_name='user_created_idx',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='friendrequest',
|
||||
name='id',
|
||||
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
]
|
||||
@@ -67,6 +67,10 @@ class User(AbstractBaseUser, PermissionsMixin):
|
||||
db_table = 'users'
|
||||
verbose_name = '用户'
|
||||
verbose_name_plural = '用户'
|
||||
indexes = [
|
||||
models.Index(fields=["role", "is_active"]),
|
||||
models.Index(fields=["created_at"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name or self.open_id
|
||||
@@ -116,6 +120,10 @@ class FriendRequest(models.Model):
|
||||
name="no_self_friend_request",
|
||||
)
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=["receiver", "status"]),
|
||||
models.Index(fields=["requester", "status"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.requester_id}->{self.receiver_id} ({self.status})"
|
||||
|
||||
@@ -7,19 +7,23 @@ from ninja import Schema
|
||||
|
||||
|
||||
class UserOut(Schema):
|
||||
"""User output schema."""
|
||||
"""Public user output schema."""
|
||||
id: int
|
||||
open_id: str
|
||||
name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
avatar: Optional[str] = None
|
||||
role: str
|
||||
stripe_customer_id: Optional[str] = None
|
||||
stripe_account_id: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class UserPrivateOut(UserOut):
|
||||
"""Private user output schema (includes sensitive fields)."""
|
||||
stripe_customer_id: Optional[str] = None
|
||||
stripe_account_id: Optional[str] = None
|
||||
|
||||
|
||||
class UserBrief(Schema):
|
||||
"""Minimal user info for social features."""
|
||||
id: int
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
Django Ninja API configuration.
|
||||
"""
|
||||
from ninja import NinjaAPI
|
||||
from ninja.errors import HttpError, ValidationError
|
||||
from ninja_jwt.authentication import JWTAuth
|
||||
|
||||
# Import routers from apps
|
||||
@@ -14,6 +15,7 @@ from apps.favorites.api import router as favorites_router
|
||||
from apps.notifications.api import router as notifications_router
|
||||
from apps.admin.api import router as admin_router
|
||||
from config.search import router as search_router
|
||||
from apps.common.errors import build_error_payload
|
||||
|
||||
# Create main API instance
|
||||
api = NinjaAPI(
|
||||
@@ -22,6 +24,39 @@ api = NinjaAPI(
|
||||
description="Backend API for AI Web application",
|
||||
)
|
||||
|
||||
|
||||
@api.exception_handler(HttpError)
|
||||
def on_http_error(request, exc: HttpError):
|
||||
return api.create_response(
|
||||
request,
|
||||
build_error_payload(status_code=exc.status_code, message=str(exc)),
|
||||
status=exc.status_code,
|
||||
)
|
||||
|
||||
|
||||
@api.exception_handler(ValidationError)
|
||||
def on_validation_error(request, exc: ValidationError):
|
||||
details = getattr(exc, "errors", None)
|
||||
return api.create_response(
|
||||
request,
|
||||
build_error_payload(
|
||||
status_code=400,
|
||||
message="请求参数校验失败",
|
||||
details=details,
|
||||
code="validation_error",
|
||||
),
|
||||
status=400,
|
||||
)
|
||||
|
||||
|
||||
@api.exception_handler(Exception)
|
||||
def on_unhandled_error(request, exc: Exception):
|
||||
return api.create_response(
|
||||
request,
|
||||
build_error_payload(status_code=500, message="服务器内部错误"),
|
||||
status=500,
|
||||
)
|
||||
|
||||
# Register routers
|
||||
api.add_router("/auth/", auth_router, tags=["认证"])
|
||||
api.add_router("/friends/", friends_router, tags=["好友"])
|
||||
|
||||
79
backend/config/middleware.py
Normal file
79
backend/config/middleware.py
Normal file
@@ -0,0 +1,79 @@
|
||||
import hashlib
|
||||
import time
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.http import JsonResponse
|
||||
|
||||
|
||||
class RateLimitMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
if not getattr(settings, "RATE_LIMIT_ENABLE", False):
|
||||
return self.get_response(request)
|
||||
|
||||
path = request.path or ""
|
||||
rate_limit_paths = getattr(settings, "RATE_LIMIT_PATHS", ["/api/"])
|
||||
if not any(path.startswith(prefix) for prefix in rate_limit_paths):
|
||||
return self.get_response(request)
|
||||
|
||||
window = int(getattr(settings, "RATE_LIMIT_WINDOW_SECONDS", 60))
|
||||
max_requests = int(getattr(settings, "RATE_LIMIT_REQUESTS", 120))
|
||||
now = int(time.time())
|
||||
window_key = now // max(window, 1)
|
||||
|
||||
ident = request.META.get("HTTP_X_FORWARDED_FOR", "").split(",")[0].strip()
|
||||
if not ident:
|
||||
ident = request.META.get("REMOTE_ADDR", "unknown")
|
||||
cache_key = f"rate:{ident}:{window_key}"
|
||||
|
||||
try:
|
||||
current = cache.incr(cache_key)
|
||||
except ValueError:
|
||||
cache.add(cache_key, 1, timeout=window)
|
||||
current = 1
|
||||
|
||||
if current > max_requests:
|
||||
return JsonResponse(
|
||||
{"message": "请求过于频繁,请稍后再试", "success": False},
|
||||
status=429,
|
||||
)
|
||||
|
||||
return self.get_response(request)
|
||||
|
||||
|
||||
class ETagMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
response = self.get_response(request)
|
||||
|
||||
if request.method not in {"GET", "HEAD"}:
|
||||
return response
|
||||
if response.status_code != 200:
|
||||
return response
|
||||
if response.has_header("ETag"):
|
||||
return response
|
||||
|
||||
content_type = response.get("Content-Type", "")
|
||||
if "application/json" not in content_type:
|
||||
return response
|
||||
|
||||
content = getattr(response, "content", b"") or b""
|
||||
max_bytes = int(getattr(settings, "ETAG_MAX_BYTES", 2 * 1024 * 1024))
|
||||
if len(content) > max_bytes:
|
||||
return response
|
||||
|
||||
etag = hashlib.sha256(content).hexdigest()
|
||||
etag_value = f'W/"{etag}"'
|
||||
response["ETag"] = etag_value
|
||||
response["Cache-Control"] = "private, max-age=0"
|
||||
|
||||
if_none_match = request.META.get("HTTP_IF_NONE_MATCH")
|
||||
if if_none_match and if_none_match == etag_value:
|
||||
response.status_code = 304
|
||||
response.content = b""
|
||||
|
||||
return response
|
||||
@@ -4,12 +4,14 @@ Global search API routes.
|
||||
from typing import List
|
||||
from ninja import Router, Schema
|
||||
from django.db.models import Count, Q
|
||||
from django.conf import settings
|
||||
from django.views.decorators.cache import cache_page
|
||||
|
||||
from apps.products.models import Product, Website
|
||||
from apps.products.schemas import ProductOut, WebsiteOut
|
||||
from apps.bounties.models import Bounty
|
||||
from apps.bounties.schemas import BountyWithDetailsOut
|
||||
from apps.users.schemas import UserOut
|
||||
from apps.common.serializers import serialize_bounty
|
||||
|
||||
router = Router()
|
||||
|
||||
@@ -20,47 +22,12 @@ class SearchResultsOut(Schema):
|
||||
bounties: List[BountyWithDetailsOut]
|
||||
|
||||
|
||||
def serialize_user(user):
|
||||
if not user:
|
||||
return None
|
||||
return UserOut(
|
||||
id=user.id,
|
||||
open_id=user.open_id,
|
||||
name=user.name,
|
||||
email=user.email,
|
||||
avatar=user.avatar,
|
||||
role=user.role,
|
||||
stripe_customer_id=user.stripe_customer_id,
|
||||
stripe_account_id=user.stripe_account_id,
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def serialize_bounty(bounty):
|
||||
return BountyWithDetailsOut(
|
||||
id=bounty.id,
|
||||
title=bounty.title,
|
||||
description=bounty.description,
|
||||
reward=bounty.reward,
|
||||
currency=bounty.currency,
|
||||
publisher_id=bounty.publisher_id,
|
||||
publisher=serialize_user(bounty.publisher),
|
||||
acceptor_id=bounty.acceptor_id,
|
||||
acceptor=serialize_user(bounty.acceptor) if bounty.acceptor else None,
|
||||
status=bounty.status,
|
||||
deadline=bounty.deadline,
|
||||
completed_at=bounty.completed_at,
|
||||
is_paid=bounty.is_paid,
|
||||
is_escrowed=bounty.is_escrowed,
|
||||
created_at=bounty.created_at,
|
||||
updated_at=bounty.updated_at,
|
||||
applications_count=getattr(bounty, "applications_count", 0),
|
||||
comments_count=getattr(bounty, "comments_count", 0),
|
||||
)
|
||||
def _serialize_bounty_with_counts(bounty):
|
||||
return serialize_bounty(bounty, include_counts=True)
|
||||
|
||||
|
||||
@router.get("/", response=SearchResultsOut)
|
||||
@cache_page(settings.CACHE_TTL_SECONDS)
|
||||
def global_search(request, q: str, limit: int = 10):
|
||||
"""Search products, websites and bounties by keyword."""
|
||||
keyword = (q or "").strip()
|
||||
@@ -68,13 +35,13 @@ def global_search(request, q: str, limit: int = 10):
|
||||
return SearchResultsOut(products=[], websites=[], bounties=[])
|
||||
|
||||
products = list(
|
||||
Product.objects.filter(
|
||||
Product.objects.select_related("category").filter(
|
||||
Q(name__icontains=keyword) | Q(description__icontains=keyword)
|
||||
).order_by("-created_at")[:limit]
|
||||
)
|
||||
|
||||
websites = list(
|
||||
Website.objects.filter(
|
||||
Website.objects.select_related("category").filter(
|
||||
Q(name__icontains=keyword) | Q(description__icontains=keyword)
|
||||
).order_by("-created_at")[:limit]
|
||||
)
|
||||
@@ -92,5 +59,5 @@ def global_search(request, q: str, limit: int = 10):
|
||||
return SearchResultsOut(
|
||||
products=products,
|
||||
websites=websites,
|
||||
bounties=[serialize_bounty(b) for b in bounties],
|
||||
bounties=[_serialize_bounty_with_counts(b) for b in bounties],
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
Django settings for ai_web project.
|
||||
"""
|
||||
import os
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from datetime import timedelta
|
||||
from dotenv import load_dotenv
|
||||
@@ -9,16 +10,33 @@ from dotenv import load_dotenv
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
# Environment helpers
|
||||
def _env_bool(key: str, default: bool = False) -> bool:
|
||||
value = os.getenv(key, str(default))
|
||||
return str(value).strip().lower() in {"1", "true", "yes", "y", "on"}
|
||||
|
||||
|
||||
def _env_csv(key: str, default: str) -> list[str]:
|
||||
raw = os.getenv(key)
|
||||
if raw is None:
|
||||
raw = default
|
||||
return [item.strip() for item in raw.split(",") if item.strip()]
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'django-insecure-change-this-in-production')
|
||||
DEBUG = _env_bool("DEBUG", False)
|
||||
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY")
|
||||
if not SECRET_KEY:
|
||||
if DEBUG:
|
||||
SECRET_KEY = "django-insecure-dev-key"
|
||||
else:
|
||||
raise RuntimeError("DJANGO_SECRET_KEY is required in production")
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = os.getenv('DEBUG', 'True').lower() == 'true'
|
||||
|
||||
ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',')
|
||||
ALLOWED_HOSTS = _env_csv("ALLOWED_HOSTS", "localhost,127.0.0.1" if DEBUG else "")
|
||||
if not DEBUG and not ALLOWED_HOSTS:
|
||||
raise RuntimeError("ALLOWED_HOSTS must be configured in production")
|
||||
|
||||
|
||||
# Application definition
|
||||
@@ -46,9 +64,11 @@ MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'config.middleware.RateLimitMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'config.middleware.ETagMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
]
|
||||
|
||||
@@ -74,28 +94,28 @@ WSGI_APPLICATION = 'config.wsgi.application'
|
||||
|
||||
|
||||
# Database
|
||||
# 使用 SQLite 数据库(开发环境)
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': BASE_DIR / 'db.sqlite3',
|
||||
DB_ENGINE = os.getenv("DB_ENGINE", "sqlite").lower()
|
||||
if DB_ENGINE == "mysql":
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.mysql",
|
||||
"NAME": os.getenv("DB_NAME", "ai_web"),
|
||||
"USER": os.getenv("DB_USER", "root"),
|
||||
"PASSWORD": os.getenv("DB_PASSWORD", ""),
|
||||
"HOST": os.getenv("DB_HOST", "localhost"),
|
||||
"PORT": os.getenv("DB_PORT", "3306"),
|
||||
"OPTIONS": {
|
||||
"charset": "utf8mb4",
|
||||
},
|
||||
}
|
||||
}
|
||||
else:
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": BASE_DIR / "db.sqlite3",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# 如需使用 MySQL,取消注释以下配置并注释上面的 SQLite 配置
|
||||
# DATABASES = {
|
||||
# 'default': {
|
||||
# 'ENGINE': 'django.db.backends.mysql',
|
||||
# 'NAME': os.getenv('DB_NAME', 'ai_web'),
|
||||
# 'USER': os.getenv('DB_USER', 'root'),
|
||||
# 'PASSWORD': os.getenv('DB_PASSWORD', ''),
|
||||
# 'HOST': os.getenv('DB_HOST', 'localhost'),
|
||||
# 'PORT': os.getenv('DB_PORT', '3306'),
|
||||
# 'OPTIONS': {
|
||||
# 'charset': 'utf8mb4',
|
||||
# },
|
||||
# }
|
||||
# }
|
||||
|
||||
|
||||
# Password validation
|
||||
@@ -136,11 +156,17 @@ AUTH_USER_MODEL = 'users.User'
|
||||
|
||||
|
||||
# CORS settings
|
||||
CORS_ALLOWED_ORIGINS = os.getenv(
|
||||
'CORS_ALLOWED_ORIGINS',
|
||||
'http://localhost:5173,http://127.0.0.1:5173'
|
||||
).split(',')
|
||||
CORS_ALLOWED_ORIGINS = _env_csv(
|
||||
"CORS_ALLOWED_ORIGINS",
|
||||
"http://localhost:5173,http://127.0.0.1:5173" if DEBUG else "",
|
||||
)
|
||||
if not DEBUG and not CORS_ALLOWED_ORIGINS:
|
||||
raise RuntimeError("CORS_ALLOWED_ORIGINS must be configured in production")
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
CSRF_TRUSTED_ORIGINS = _env_csv(
|
||||
"CSRF_TRUSTED_ORIGINS",
|
||||
"http://localhost:5173,http://127.0.0.1:5173" if DEBUG else "",
|
||||
)
|
||||
|
||||
|
||||
# JWT settings
|
||||
@@ -156,14 +182,52 @@ NINJA_JWT = {
|
||||
'AUTH_COOKIE_SAMESITE': 'Lax',
|
||||
}
|
||||
|
||||
# Cache settings
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
||||
"LOCATION": "ai_web_cache",
|
||||
"TIMEOUT": int(os.getenv("CACHE_DEFAULT_TIMEOUT", "300")),
|
||||
}
|
||||
}
|
||||
CACHE_TTL_SECONDS = int(os.getenv("CACHE_TTL_SECONDS", "60"))
|
||||
ETAG_MAX_BYTES = int(os.getenv("ETAG_MAX_BYTES", str(2 * 1024 * 1024)))
|
||||
|
||||
# Security settings for production
|
||||
if not DEBUG:
|
||||
SECURE_SSL_REDIRECT = _env_bool("SECURE_SSL_REDIRECT", True)
|
||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
SESSION_COOKIE_SECURE = True
|
||||
CSRF_COOKIE_SECURE = True
|
||||
SESSION_COOKIE_SAMESITE = "Lax"
|
||||
CSRF_COOKIE_SAMESITE = "Lax"
|
||||
SECURE_HSTS_SECONDS = int(os.getenv("SECURE_HSTS_SECONDS", "31536000"))
|
||||
SECURE_HSTS_INCLUDE_SUBDOMAINS = _env_bool("SECURE_HSTS_INCLUDE_SUBDOMAINS", True)
|
||||
SECURE_HSTS_PRELOAD = _env_bool("SECURE_HSTS_PRELOAD", True)
|
||||
SECURE_REFERRER_POLICY = os.getenv("SECURE_REFERRER_POLICY", "same-origin")
|
||||
|
||||
|
||||
# Stripe settings
|
||||
STRIPE_SECRET_KEY = os.getenv('STRIPE_SECRET_KEY', '')
|
||||
STRIPE_WEBHOOK_SECRET = os.getenv('STRIPE_WEBHOOK_SECRET', '')
|
||||
STRIPE_PUBLISHABLE_KEY = os.getenv('STRIPE_PUBLISHABLE_KEY', '')
|
||||
|
||||
# Bounty settings
|
||||
BOUNTY_MIN_REWARD = Decimal(os.getenv("BOUNTY_MIN_REWARD", "0.01"))
|
||||
BOUNTY_MAX_REWARD = Decimal(os.getenv("BOUNTY_MAX_REWARD", "99999999.99"))
|
||||
BOUNTY_PLATFORM_FEE_PERCENT = Decimal(os.getenv("BOUNTY_PLATFORM_FEE_PERCENT", "0.05"))
|
||||
|
||||
|
||||
# OAuth settings (Manus SDK compatible)
|
||||
OAUTH_CLIENT_ID = os.getenv('OAUTH_CLIENT_ID', '')
|
||||
OAUTH_CLIENT_SECRET = os.getenv('OAUTH_CLIENT_SECRET', '')
|
||||
OAUTH_REDIRECT_URI = os.getenv('OAUTH_REDIRECT_URI', 'http://localhost:8000/api/auth/callback')
|
||||
OAUTH_CLIENT_ID = os.getenv("OAUTH_CLIENT_ID", "")
|
||||
OAUTH_CLIENT_SECRET = os.getenv("OAUTH_CLIENT_SECRET", "")
|
||||
OAUTH_REDIRECT_URI = os.getenv("OAUTH_REDIRECT_URI", "http://localhost:8000/api/auth/callback")
|
||||
OAUTH_AUTHORIZE_URL = os.getenv("OAUTH_AUTHORIZE_URL", "")
|
||||
OAUTH_TOKEN_URL = os.getenv("OAUTH_TOKEN_URL", "")
|
||||
OAUTH_USERINFO_URL = os.getenv("OAUTH_USERINFO_URL", "")
|
||||
|
||||
# Basic rate limiting
|
||||
RATE_LIMIT_ENABLE = _env_bool("RATE_LIMIT_ENABLE", not DEBUG)
|
||||
RATE_LIMIT_REQUESTS = int(os.getenv("RATE_LIMIT_REQUESTS", "120"))
|
||||
RATE_LIMIT_WINDOW_SECONDS = int(os.getenv("RATE_LIMIT_WINDOW_SECONDS", "60"))
|
||||
RATE_LIMIT_PATHS = _env_csv("RATE_LIMIT_PATHS", "/api/")
|
||||
|
||||
5
backend/requirements-dev.txt
Normal file
5
backend/requirements-dev.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
-r requirements.txt
|
||||
|
||||
# Development
|
||||
pytest>=7.4.0
|
||||
pytest-django>=4.5.0
|
||||
@@ -17,6 +17,3 @@ pydantic>=2.0.0
|
||||
# HTTP client (for OAuth)
|
||||
requests>=2.31.0
|
||||
|
||||
# Development
|
||||
pytest>=7.4.0
|
||||
pytest-django>=4.5.0
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"css": "client/src/index.css",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
@@ -87,6 +87,13 @@
|
||||
},
|
||||
"overrides": {
|
||||
"tailwindcss>nanoid": "3.3.7"
|
||||
}
|
||||
},
|
||||
"ignoredBuiltDependencies": [
|
||||
"@tailwindcss/oxide",
|
||||
"esbuild"
|
||||
],
|
||||
"onlyBuiltDependencies": [
|
||||
"@tailwindcss/oxide"
|
||||
]
|
||||
}
|
||||
}
|
||||
0
pnpm-lock.yaml → frontend/pnpm-lock.yaml
generated
0
pnpm-lock.yaml → frontend/pnpm-lock.yaml
generated
@@ -3,5 +3,4 @@
|
||||
* Import shared types from this single entry point.
|
||||
*/
|
||||
|
||||
export type * from "../drizzle/schema";
|
||||
export * from "./_core/errors";
|
||||
@@ -1,21 +1,22 @@
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import NotFound from "@/pages/NotFound";
|
||||
import NotFound from "@/features/common/pages/NotFound";
|
||||
import { Route, Switch } from "wouter";
|
||||
import ErrorBoundary from "./components/ErrorBoundary";
|
||||
import FriendPanel from "./components/FriendPanel";
|
||||
import FriendPanel from "@/features/friends/FriendPanel";
|
||||
import { ThemeProvider } from "./contexts/ThemeContext";
|
||||
import Home from "./pages/Home";
|
||||
import Login from "./pages/Login";
|
||||
import Products from "./pages/Products";
|
||||
import ProductDetail from "./pages/ProductDetail";
|
||||
import Bounties from "./pages/Bounties";
|
||||
import BountyDetail from "./pages/BountyDetail";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import Favorites from "./pages/Favorites";
|
||||
import ProductComparison from "./pages/ProductComparison";
|
||||
import Admin from "./pages/Admin";
|
||||
import Search from "./pages/Search";
|
||||
import Home from "@/features/home/pages/Home";
|
||||
import Login from "@/features/auth/pages/Login";
|
||||
import Products from "@/features/products/pages/Products";
|
||||
import ProductDetail from "@/features/products/pages/ProductDetail";
|
||||
import Bounties from "@/features/bounties/pages/Bounties";
|
||||
import BountyDetail from "@/features/bounties/pages/BountyDetail";
|
||||
import Dashboard from "@/features/dashboard/pages/Dashboard";
|
||||
import Favorites from "@/features/favorites/pages/Favorites";
|
||||
import ProductComparison from "@/features/products/pages/ProductComparison";
|
||||
import Admin from "@/features/admin/pages/Admin";
|
||||
import Search from "@/features/search/pages/Search";
|
||||
import Settings from "@/features/settings/pages/Settings";
|
||||
|
||||
function Router() {
|
||||
return (
|
||||
@@ -31,6 +32,7 @@ function Router() {
|
||||
<Route path="/comparison" component={ProductComparison} />
|
||||
<Route path="/search" component={Search} />
|
||||
<Route path="/admin" component={Admin} />
|
||||
<Route path="/settings" component={Settings} />
|
||||
<Route path="/404" component={NotFound} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { useMe, useLogout } from "@/hooks/useApi";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
type UseAuthOptions = {
|
||||
redirectOnUnauthenticated?: boolean;
|
||||
redirectPath?: string;
|
||||
};
|
||||
|
||||
export function useAuth(options?: UseAuthOptions) {
|
||||
const { redirectOnUnauthenticated = false, redirectPath = "/login" } =
|
||||
options ?? {};
|
||||
|
||||
const meQuery = useMe();
|
||||
const logoutMutation = useLogout();
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await logoutMutation.mutateAsync();
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
error instanceof AxiosError &&
|
||||
error.response?.status === 401
|
||||
) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}, [logoutMutation]);
|
||||
|
||||
const state = useMemo(() => {
|
||||
localStorage.setItem(
|
||||
"manus-runtime-user-info",
|
||||
JSON.stringify(meQuery.data)
|
||||
);
|
||||
return {
|
||||
user: meQuery.data ?? null,
|
||||
loading: meQuery.isLoading || logoutMutation.isPending,
|
||||
error: meQuery.error ?? logoutMutation.error ?? null,
|
||||
isAuthenticated: Boolean(meQuery.data),
|
||||
};
|
||||
}, [
|
||||
meQuery.data,
|
||||
meQuery.error,
|
||||
meQuery.isLoading,
|
||||
logoutMutation.error,
|
||||
logoutMutation.isPending,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!redirectOnUnauthenticated) return;
|
||||
if (meQuery.isLoading || logoutMutation.isPending) return;
|
||||
if (state.user) return;
|
||||
if (typeof window === "undefined") return;
|
||||
if (window.location.pathname === redirectPath) return;
|
||||
|
||||
window.location.href = redirectPath
|
||||
}, [
|
||||
redirectOnUnauthenticated,
|
||||
redirectPath,
|
||||
logoutMutation.isPending,
|
||||
meQuery.isLoading,
|
||||
state.user,
|
||||
]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
refresh: () => meQuery.refetch(),
|
||||
logout,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -20,15 +20,19 @@ import {
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useIsMobile } from "@/hooks/useMobile";
|
||||
import { LayoutDashboard, LogOut, PanelLeft, Users, Heart, ShieldCheck } from "lucide-react";
|
||||
import { CSSProperties, useEffect, useRef, useState } from "react";
|
||||
import { LayoutDashboard, LogOut, PanelLeft, Users, Heart, ShieldCheck, Loader2, Settings, Home } from "lucide-react";
|
||||
import { CSSProperties, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
|
||||
import { Button } from "./ui/button";
|
||||
import { toast } from "sonner";
|
||||
import { getErrorCopy } from "@/lib/i18n/errorMessages";
|
||||
|
||||
const menuItems = [
|
||||
{ icon: Home, label: "返回首页", path: "/" },
|
||||
{ icon: LayoutDashboard, label: "个人中心", path: "/dashboard" },
|
||||
{ icon: Heart, label: "我的收藏", path: "/favorites" },
|
||||
{ icon: Settings, label: "账号设置", path: "/settings" },
|
||||
];
|
||||
|
||||
const adminMenuItems = [
|
||||
@@ -45,11 +49,27 @@ export default function DashboardLayout({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [, navigate] = useLocation();
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||
const saved = localStorage.getItem(SIDEBAR_WIDTH_KEY);
|
||||
return saved ? parseInt(saved, 10) : DEFAULT_WIDTH;
|
||||
});
|
||||
const { loading, user } = useAuth();
|
||||
const { loading, user, logout } = useAuth();
|
||||
|
||||
const handleLogout = useCallback(async () => {
|
||||
setIsLoggingOut(true);
|
||||
try {
|
||||
await logout();
|
||||
toast.success("已退出登录");
|
||||
navigate("/");
|
||||
} catch (error: unknown) {
|
||||
const { title, description } = getErrorCopy(error, { context: "auth.logout" });
|
||||
toast.error(title, { description });
|
||||
} finally {
|
||||
setIsLoggingOut(false);
|
||||
}
|
||||
}, [logout, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(SIDEBAR_WIDTH_KEY, sidebarWidth.toString());
|
||||
@@ -93,7 +113,11 @@ export default function DashboardLayout({
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<DashboardLayoutContent setSidebarWidth={setSidebarWidth}>
|
||||
<DashboardLayoutContent
|
||||
setSidebarWidth={setSidebarWidth}
|
||||
handleLogout={handleLogout}
|
||||
isLoggingOut={isLoggingOut}
|
||||
>
|
||||
{children}
|
||||
</DashboardLayoutContent>
|
||||
</SidebarProvider>
|
||||
@@ -103,13 +127,17 @@ export default function DashboardLayout({
|
||||
type DashboardLayoutContentProps = {
|
||||
children: React.ReactNode;
|
||||
setSidebarWidth: (width: number) => void;
|
||||
handleLogout: () => void;
|
||||
isLoggingOut: boolean;
|
||||
};
|
||||
|
||||
function DashboardLayoutContent({
|
||||
children,
|
||||
setSidebarWidth,
|
||||
handleLogout,
|
||||
isLoggingOut,
|
||||
}: DashboardLayoutContentProps) {
|
||||
const { user, logout } = useAuth();
|
||||
const { user } = useAuth();
|
||||
const [location, setLocation] = useLocation();
|
||||
const { state, toggleSidebar } = useSidebar();
|
||||
const isCollapsed = state === "collapsed";
|
||||
@@ -251,11 +279,16 @@ function DashboardLayoutContent({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem
|
||||
onClick={logout}
|
||||
onClick={handleLogout}
|
||||
disabled={isLoggingOut}
|
||||
className="cursor-pointer text-destructive focus:text-destructive"
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>Sign out</span>
|
||||
{isLoggingOut ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
<span>{isLoggingOut ? "退出中..." : "退出登录"}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { Sparkles, Menu, X, ShoppingBag, Trophy, Search, User, Heart, LogOut } from "lucide-react";
|
||||
import { useUnreadNotificationCount } from "@/hooks/useApi";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { Sparkles, Bell, LogOut } from "lucide-react";
|
||||
|
||||
515
frontend/src/features/admin/pages/Admin.tsx
Normal file
515
frontend/src/features/admin/pages/Admin.tsx
Normal file
@@ -0,0 +1,515 @@
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useAdminUsers, useUpdateAdminUser, useAdminBounties, useAdminPayments, useAdminDisputes, useResolveDispute, useAdminPendingProducts, useReviewProduct } from "@/hooks/useApi";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
import { Loader2, Package, Users, Trophy, CreditCard, AlertTriangle } from "lucide-react";
|
||||
import { useLocation } from "wouter";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { getErrorCopy } from "@/lib/i18n/errorMessages";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { zhCN } from "date-fns/locale";
|
||||
|
||||
export default function Admin() {
|
||||
const { user, isAuthenticated, loading } = useAuth();
|
||||
const [, navigate] = useLocation();
|
||||
const [rejectReason, setRejectReason] = useState("");
|
||||
const [rejectingProductId, setRejectingProductId] = useState<number | null>(null);
|
||||
|
||||
const { data: usersData, isLoading: usersLoading } = useAdminUsers();
|
||||
const { data: bountiesData, isLoading: bountiesLoading } = useAdminBounties();
|
||||
const { data: paymentsData, isLoading: paymentsLoading } = useAdminPayments();
|
||||
const { data: disputesData, isLoading: disputesLoading } = useAdminDisputes();
|
||||
const { data: pendingProductsData, isLoading: pendingProductsLoading } = useAdminPendingProducts();
|
||||
const updateUserMutation = useUpdateAdminUser();
|
||||
const resolveDisputeMutation = useResolveDispute();
|
||||
const reviewProductMutation = useReviewProduct();
|
||||
|
||||
// Extract items from paginated responses
|
||||
const users = usersData?.items || [];
|
||||
const bounties = bountiesData?.items || [];
|
||||
const payments = paymentsData?.items || [];
|
||||
const disputes = disputesData?.items || [];
|
||||
const pendingProducts = pendingProductsData?.items || [];
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && (!isAuthenticated || user?.role !== "admin")) {
|
||||
navigate("/");
|
||||
}
|
||||
}, [loading, isAuthenticated, user, navigate]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated || user?.role !== "admin") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bountyStats = {
|
||||
total: bounties?.length || 0,
|
||||
escrowed: bounties?.filter((b) => b.is_escrowed).length || 0,
|
||||
paid: bounties?.filter((b) => b.is_paid).length || 0,
|
||||
disputed: bounties?.filter((b) => b.status === "disputed").length || 0,
|
||||
};
|
||||
|
||||
const pendingProductsCount = pendingProducts?.length || 0;
|
||||
|
||||
const handleApproveProduct = (productId: number) => {
|
||||
reviewProductMutation.mutate(
|
||||
{ productId, data: { approved: true } },
|
||||
{
|
||||
onSuccess: () => toast.success("商品已通过审核"),
|
||||
onError: (error: unknown) => {
|
||||
const { title, description } = getErrorCopy(error, { context: "admin.review_product" });
|
||||
toast.error(title, { description });
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleRejectProduct = (productId: number) => {
|
||||
if (!rejectReason.trim()) {
|
||||
toast.error("请输入拒绝原因");
|
||||
return;
|
||||
}
|
||||
reviewProductMutation.mutate(
|
||||
{ productId, data: { approved: false, reject_reason: rejectReason } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("商品已拒绝");
|
||||
setRejectingProductId(null);
|
||||
setRejectReason("");
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const { title, description } = getErrorCopy(error, { context: "admin.review_product" });
|
||||
toast.error(title, { description });
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Navbar />
|
||||
|
||||
<div className="container pt-24 pb-12 space-y-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center">
|
||||
<Users className="w-6 h-6 text-primary-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">管理后台</h1>
|
||||
<p className="text-muted-foreground text-sm">审核商品、管理用户和悬赏</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
<Card className="card-elegant">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">待审核商品</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-2xl font-bold text-orange-500">{pendingProductsCount}</CardContent>
|
||||
</Card>
|
||||
<Card className="card-elegant">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">总悬赏</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-2xl font-bold">{bountyStats.total}</CardContent>
|
||||
</Card>
|
||||
<Card className="card-elegant">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">已托管</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-2xl font-bold">{bountyStats.escrowed}</CardContent>
|
||||
</Card>
|
||||
<Card className="card-elegant">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">已结算</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-2xl font-bold">{bountyStats.paid}</CardContent>
|
||||
</Card>
|
||||
<Card className="card-elegant">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">争议中</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-2xl font-bold text-red-500">{bountyStats.disputed}</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="products" className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-5 lg:w-auto lg:inline-grid">
|
||||
<TabsTrigger value="products" className="gap-2">
|
||||
<Package className="w-4 h-4" />
|
||||
商品审核
|
||||
{pendingProductsCount > 0 && (
|
||||
<Badge variant="destructive" className="ml-1 h-5 px-1.5">{pendingProductsCount}</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="users" className="gap-2">
|
||||
<Users className="w-4 h-4" />
|
||||
用户管理
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="bounties" className="gap-2">
|
||||
<Trophy className="w-4 h-4" />
|
||||
悬赏管理
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="disputes" className="gap-2">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
争议处理
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="payments" className="gap-2">
|
||||
<CreditCard className="w-4 h-4" />
|
||||
支付事件
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Products Review Tab */}
|
||||
<TabsContent value="products">
|
||||
<Card className="card-elegant">
|
||||
<CardHeader>
|
||||
<CardTitle>待审核商品</CardTitle>
|
||||
<CardDescription>审核用户提交的商品,通过后将在商品列表显示</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{pendingProductsLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin" />
|
||||
</div>
|
||||
) : pendingProducts && pendingProducts.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>商品名称</TableHead>
|
||||
<TableHead>分类</TableHead>
|
||||
<TableHead>提交者</TableHead>
|
||||
<TableHead>提交时间</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pendingProducts.map((product) => (
|
||||
<TableRow key={product.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
{product.image && (
|
||||
<img src={product.image} alt={product.name} className="w-10 h-10 rounded object-cover" />
|
||||
)}
|
||||
<div>
|
||||
<div className="font-medium">{product.name}</div>
|
||||
{product.description && (
|
||||
<div className="text-sm text-muted-foreground line-clamp-1">{product.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{product.category_name || "-"}</TableCell>
|
||||
<TableCell>{product.submitted_by_name || "-"}</TableCell>
|
||||
<TableCell>
|
||||
{formatDistanceToNow(new Date(product.created_at), { addSuffix: true, locale: zhCN })}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{rejectingProductId === product.id ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="拒绝原因"
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
className="px-2 py-1 border rounded text-sm w-32"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleRejectProduct(product.id)}
|
||||
disabled={reviewProductMutation.isPending}
|
||||
>
|
||||
确认
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setRejectingProductId(null);
|
||||
setRejectReason("");
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleApproveProduct(product.id)}
|
||||
disabled={reviewProductMutation.isPending}
|
||||
>
|
||||
通过
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setRejectingProductId(product.id)}
|
||||
disabled={reviewProductMutation.isPending}
|
||||
>
|
||||
拒绝
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Package className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p>暂无待审核商品</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Users Tab */}
|
||||
<TabsContent value="users">
|
||||
<Card className="card-elegant">
|
||||
<CardHeader>
|
||||
<CardTitle>用户管理</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{usersLoading ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>用户</TableHead>
|
||||
<TableHead>角色</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users?.map((u) => (
|
||||
<TableRow key={u.id}>
|
||||
<TableCell>{u.name || u.open_id}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={u.role === "admin" ? "default" : "secondary"}>
|
||||
{u.role === "admin" ? "管理员" : "普通用户"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={u.is_active ? "secondary" : "destructive"}>
|
||||
{u.is_active ? "正常" : "禁用"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
updateUserMutation.mutate(
|
||||
{ id: u.id, data: { role: u.role === "admin" ? "user" : "admin" } },
|
||||
{
|
||||
onError: (error: unknown) => {
|
||||
const { title, description } = getErrorCopy(error, { context: "admin.update_user" });
|
||||
toast.error(title, { description });
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
>
|
||||
{u.role === "admin" ? "降为用户" : "升为管理员"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
updateUserMutation.mutate(
|
||||
{ id: u.id, data: { is_active: !u.is_active } },
|
||||
{
|
||||
onError: (error: unknown) => {
|
||||
const { title, description } = getErrorCopy(error, { context: "admin.update_user" });
|
||||
toast.error(title, { description });
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
>
|
||||
{u.is_active ? "禁用" : "启用"}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Bounties Tab */}
|
||||
<TabsContent value="bounties">
|
||||
<Card className="card-elegant">
|
||||
<CardHeader>
|
||||
<CardTitle>悬赏管理</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{bountiesLoading ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>标题</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>金额</TableHead>
|
||||
<TableHead>支付</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{bounties?.map((b) => (
|
||||
<TableRow key={b.id}>
|
||||
<TableCell>{b.title}</TableCell>
|
||||
<TableCell>{b.status}</TableCell>
|
||||
<TableCell>{b.reward}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={b.is_paid ? "secondary" : "outline"}>
|
||||
{b.is_paid ? "已结算" : "未结算"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Disputes Tab */}
|
||||
<TabsContent value="disputes">
|
||||
<Card className="card-elegant">
|
||||
<CardHeader>
|
||||
<CardTitle>争议处理</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{disputesLoading ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>争议ID</TableHead>
|
||||
<TableHead>悬赏ID</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{disputes?.map((d) => (
|
||||
<TableRow key={d.id}>
|
||||
<TableCell>{d.id}</TableCell>
|
||||
<TableCell>{d.bounty_id}</TableCell>
|
||||
<TableCell>{d.status}</TableCell>
|
||||
<TableCell className="space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const resolution = window.prompt("请输入处理说明");
|
||||
if (!resolution) return;
|
||||
resolveDisputeMutation.mutate({
|
||||
bountyId: d.bounty_id,
|
||||
disputeId: d.id,
|
||||
data: { resolution, accepted: true },
|
||||
}, {
|
||||
onSuccess: () => toast.success("争议已处理"),
|
||||
onError: (error: unknown) => {
|
||||
const { title, description } = getErrorCopy(error, { context: "admin.resolve_dispute" });
|
||||
toast.error(title, { description });
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
通过
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const resolution = window.prompt("请输入驳回原因");
|
||||
if (!resolution) return;
|
||||
resolveDisputeMutation.mutate({
|
||||
bountyId: d.bounty_id,
|
||||
disputeId: d.id,
|
||||
data: { resolution, accepted: false },
|
||||
}, {
|
||||
onSuccess: () => toast.success("争议已驳回"),
|
||||
onError: (error: unknown) => {
|
||||
const { title, description } = getErrorCopy(error, { context: "admin.resolve_dispute" });
|
||||
toast.error(title, { description });
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
驳回
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Payments Tab */}
|
||||
<TabsContent value="payments">
|
||||
<Card className="card-elegant">
|
||||
<CardHeader>
|
||||
<CardTitle>支付事件</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{paymentsLoading ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>事件ID</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{payments?.map((p) => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell>{p.event_id}</TableCell>
|
||||
<TableCell>{p.event_type}</TableCell>
|
||||
<TableCell>{p.success ? "成功" : "失败"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,11 +8,12 @@ import { Sparkles, ArrowLeft, Loader2 } from "lucide-react";
|
||||
import { Link } from "wouter";
|
||||
import { useLogin, useRegister } from "@/hooks/useApi";
|
||||
import { toast } from "sonner";
|
||||
import { getErrorCopy } from "@/lib/i18n/errorMessages";
|
||||
import { getAndClearRedirectPath } from "@/hooks/useAuth";
|
||||
|
||||
export default function Login() {
|
||||
const [, setLocation] = useLocation();
|
||||
const [username, setUsername] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [isRegister, setIsRegister] = useState(false);
|
||||
@@ -20,6 +21,11 @@ export default function Login() {
|
||||
const loginMutation = useLogin();
|
||||
const registerMutation = useRegister();
|
||||
|
||||
const validateEmail = (email: string): boolean => {
|
||||
const emailPattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
|
||||
return emailPattern.test(email);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -27,20 +33,33 @@ export default function Login() {
|
||||
toast.error("请输入用户名");
|
||||
return;
|
||||
}
|
||||
if (isRegister && !email.trim()) {
|
||||
toast.error("请输入邮箱");
|
||||
return;
|
||||
}
|
||||
if (isRegister && !validateEmail(email.trim())) {
|
||||
toast.error("请输入正确的邮箱格式");
|
||||
return;
|
||||
}
|
||||
if (!password.trim()) {
|
||||
toast.error("请输入密码");
|
||||
return;
|
||||
}
|
||||
if (password.length < 6) {
|
||||
toast.error("密码长度至少6位");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isRegister) {
|
||||
await registerMutation.mutateAsync({
|
||||
openId: username.trim(),
|
||||
password: password.trim(),
|
||||
name: displayName.trim() || undefined,
|
||||
email: email.trim() || undefined,
|
||||
name: username.trim(), // 显示名称默认为用户名
|
||||
email: email.trim(),
|
||||
});
|
||||
} else {
|
||||
// 登录时,用户名字段可以是用户名或邮箱
|
||||
await loginMutation.mutateAsync({
|
||||
openId: username.trim(),
|
||||
password: password.trim(),
|
||||
@@ -51,12 +70,14 @@ export default function Login() {
|
||||
description: isRegister ? "账号已创建" : "欢迎回来!",
|
||||
});
|
||||
|
||||
// Redirect to home or dashboard
|
||||
setLocation("/");
|
||||
} catch (error: any) {
|
||||
toast.error(isRegister ? "注册失败" : "登录失败", {
|
||||
description: error.response?.data?.error || "请稍后重试",
|
||||
// 优先返回登录前的页面,否则跳转首页
|
||||
const redirectPath = getAndClearRedirectPath();
|
||||
setLocation(redirectPath || "/");
|
||||
} catch (error: unknown) {
|
||||
const { title, description } = getErrorCopy(error, {
|
||||
context: isRegister ? "auth.register" : "auth.login",
|
||||
});
|
||||
toast.error(title, { description });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -80,62 +101,50 @@ export default function Login() {
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center mx-auto mb-4">
|
||||
<Sparkles className="w-7 h-7 text-primary-foreground" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">欢迎登录</CardTitle>
|
||||
<CardTitle className="text-2xl">{isRegister ? "欢迎注册" : "欢迎登录"}</CardTitle>
|
||||
<CardDescription>
|
||||
登录资源聚合平台,享受更多功能
|
||||
{isRegister ? "创建账号,开始使用资源聚合平台" : "登录资源聚合平台,享受更多功能"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">用户名</Label>
|
||||
<Label htmlFor="username">用户名 {!isRegister && <span className="text-muted-foreground text-xs">(或邮箱)</span>}</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder="输入用户名或 ID"
|
||||
placeholder={isRegister ? "输入用户名" : "输入用户名或邮箱"}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
disabled={loginMutation.isPending || registerMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="输入密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={loginMutation.isPending || registerMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="displayName">显示名称(可选)</Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
type="text"
|
||||
placeholder="您希望显示的名称"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
disabled={loginMutation.isPending || registerMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isRegister && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">邮箱(可选)</Label>
|
||||
<Label htmlFor="email">邮箱 <span className="text-destructive">*</span></Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="输入邮箱"
|
||||
placeholder="输入邮箱地址"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={loginMutation.isPending || registerMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder={isRegister ? "设置密码(至少6位)" : "输入密码"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={loginMutation.isPending || registerMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
77
frontend/src/features/bounties/components/BountiesGrid.tsx
Normal file
77
frontend/src/features/bounties/components/BountiesGrid.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Calendar, Clock, DollarSign, Trophy, User } from "lucide-react";
|
||||
import { Link } from "wouter";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { zhCN } from "date-fns/locale";
|
||||
|
||||
type Bounty = {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
reward: string;
|
||||
status: string;
|
||||
deadline: string | null;
|
||||
created_at: string;
|
||||
publisher?: { name?: string | null } | null;
|
||||
};
|
||||
|
||||
type StatusMap = Record<string, { label: string; class: string }>;
|
||||
|
||||
type BountiesGridProps = {
|
||||
bounties: Bounty[];
|
||||
statusMap: StatusMap;
|
||||
};
|
||||
|
||||
export default function BountiesGrid({ bounties, statusMap }: BountiesGridProps) {
|
||||
return (
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{bounties.map((bounty) => (
|
||||
<Link key={bounty.id} href={`/bounties/${bounty.id}`}>
|
||||
<Card className="card-elegant h-full cursor-pointer group">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<Badge className={statusMap[bounty.status]?.class || "bg-muted"}>
|
||||
{statusMap[bounty.status]?.label || bounty.status}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-1 text-lg font-semibold text-primary">
|
||||
<DollarSign className="w-4 h-4" />
|
||||
<span>¥{bounty.reward}</span>
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="text-lg line-clamp-2 group-hover:text-primary transition-colors">
|
||||
{bounty.title}
|
||||
</CardTitle>
|
||||
<CardDescription className="line-clamp-3 mt-2">
|
||||
{bounty.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="w-4 h-4" />
|
||||
<span>{bounty.publisher?.name || "匿名用户"}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>
|
||||
{formatDistanceToNow(new Date(bounty.created_at), {
|
||||
addSuffix: true,
|
||||
locale: zhCN,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{bounty.deadline && (
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground mt-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>截止: {new Date(bounty.deadline).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
180
frontend/src/features/bounties/components/BountiesHeader.tsx
Normal file
180
frontend/src/features/bounties/components/BountiesHeader.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Plus, Search, Loader2 } from "lucide-react";
|
||||
import { Link } from "wouter";
|
||||
|
||||
type BountiesHeaderProps = {
|
||||
searchQuery: string;
|
||||
setSearchQuery: (value: string) => void;
|
||||
statusFilter: string;
|
||||
setStatusFilter: (value: string) => void;
|
||||
isAuthenticated: boolean;
|
||||
isCreateOpen: boolean;
|
||||
setIsCreateOpen: (value: boolean) => void;
|
||||
newBounty: {
|
||||
title: string;
|
||||
description: string;
|
||||
reward: string;
|
||||
deadline: string;
|
||||
};
|
||||
setNewBounty: (updater: (prev: BountiesHeaderProps["newBounty"]) => BountiesHeaderProps["newBounty"]) => void;
|
||||
onCreate: () => void;
|
||||
isCreating: boolean;
|
||||
};
|
||||
|
||||
export default function BountiesHeader({
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
isAuthenticated,
|
||||
isCreateOpen,
|
||||
setIsCreateOpen,
|
||||
newBounty,
|
||||
setNewBounty,
|
||||
onCreate,
|
||||
isCreating,
|
||||
}: BountiesHeaderProps) {
|
||||
return (
|
||||
<section className="pt-24 pb-8">
|
||||
<div className="container">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2" style={{ fontFamily: "'Playfair Display', serif" }}>
|
||||
悬赏大厅
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
发布需求或接取任务,让专业人士为您服务
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索悬赏..."
|
||||
className="pl-10 w-64"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isAuthenticated ? (
|
||||
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
发布悬赏
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>发布新悬赏</DialogTitle>
|
||||
<DialogDescription>
|
||||
填写悬赏详情,发布后其他用户可以申请接单
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="title">悬赏标题</Label>
|
||||
<Input
|
||||
id="title"
|
||||
placeholder="简要描述您的需求"
|
||||
value={newBounty.title}
|
||||
onChange={(e) => setNewBounty(prev => ({ ...prev, title: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">详细描述</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="详细说明您的需求、要求和期望结果"
|
||||
rows={4}
|
||||
value={newBounty.description}
|
||||
onChange={(e) => setNewBounty(prev => ({ ...prev, description: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="reward">赏金金额 (CNY)</Label>
|
||||
<Input
|
||||
id="reward"
|
||||
type="number"
|
||||
placeholder="100"
|
||||
min="1"
|
||||
value={newBounty.reward}
|
||||
onChange={(e) => setNewBounty(prev => ({ ...prev, reward: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="deadline">截止日期 (可选)</Label>
|
||||
<Input
|
||||
id="deadline"
|
||||
type="date"
|
||||
value={newBounty.deadline}
|
||||
onChange={(e) => setNewBounty(prev => ({ ...prev, deadline: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsCreateOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={onCreate} disabled={isCreating}>
|
||||
{isCreating && (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
)}
|
||||
发布悬赏
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
<Link href="/login">
|
||||
<Button className="gap-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
登录后发布
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Tabs */}
|
||||
<Tabs value={statusFilter} onValueChange={setStatusFilter} className="mb-8">
|
||||
<TabsList className="flex-wrap h-auto gap-2 bg-transparent p-0">
|
||||
<TabsTrigger
|
||||
value="all"
|
||||
className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground rounded-full px-4"
|
||||
>
|
||||
全部
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="open"
|
||||
className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground rounded-full px-4"
|
||||
>
|
||||
开放中
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="in_progress"
|
||||
className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground rounded-full px-4"
|
||||
>
|
||||
进行中
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="completed"
|
||||
className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground rounded-full px-4"
|
||||
>
|
||||
已完成
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
177
frontend/src/features/bounties/components/BountyActionsPanel.tsx
Normal file
177
frontend/src/features/bounties/components/BountyActionsPanel.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Link } from "wouter";
|
||||
import { CreditCard, CheckCircle, Loader2, ShieldCheck, Trophy, Wallet, XCircle } from "lucide-react";
|
||||
|
||||
type BountyActionsPanelProps = {
|
||||
canApply: boolean;
|
||||
isApplyOpen: boolean;
|
||||
setIsApplyOpen: (open: boolean) => void;
|
||||
applyMessage: string;
|
||||
setApplyMessage: (value: string) => void;
|
||||
onApply: () => void;
|
||||
isApplying: boolean;
|
||||
myApplication?: { status: string } | null;
|
||||
isPublisher: boolean;
|
||||
isAuthenticated: boolean;
|
||||
bountyIsEscrowed: boolean;
|
||||
bountyIsPaid: boolean;
|
||||
canEscrow: boolean;
|
||||
onEscrow: () => void;
|
||||
isEscrowing: boolean;
|
||||
canRelease: boolean;
|
||||
onRelease: () => void;
|
||||
isReleasing: boolean;
|
||||
canComplete: boolean;
|
||||
onComplete: () => void;
|
||||
isCompleting: boolean;
|
||||
canCancel: boolean;
|
||||
onCancel: () => void;
|
||||
isCancelling: boolean;
|
||||
};
|
||||
|
||||
export default function BountyActionsPanel({
|
||||
canApply,
|
||||
isApplyOpen,
|
||||
setIsApplyOpen,
|
||||
applyMessage,
|
||||
setApplyMessage,
|
||||
onApply,
|
||||
isApplying,
|
||||
myApplication,
|
||||
isPublisher,
|
||||
isAuthenticated,
|
||||
bountyIsEscrowed,
|
||||
bountyIsPaid,
|
||||
canEscrow,
|
||||
onEscrow,
|
||||
isEscrowing,
|
||||
canRelease,
|
||||
onRelease,
|
||||
isReleasing,
|
||||
canComplete,
|
||||
onComplete,
|
||||
isCompleting,
|
||||
canCancel,
|
||||
onCancel,
|
||||
isCancelling,
|
||||
}: BountyActionsPanelProps) {
|
||||
return (
|
||||
<Card className="card-elegant">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">操作</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{canApply && (
|
||||
<Dialog open={isApplyOpen} onOpenChange={setIsApplyOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="w-full gap-2">
|
||||
<Trophy className="w-4 h-4" />
|
||||
申请接单
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>申请接单</DialogTitle>
|
||||
<DialogDescription>向发布者说明您为什么适合完成这个任务</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<Textarea
|
||||
placeholder="介绍您的经验和能力(可选)"
|
||||
value={applyMessage}
|
||||
onChange={(e) => setApplyMessage(e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsApplyOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={onApply} disabled={isApplying}>
|
||||
{isApplying && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
提交申请
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{myApplication && (
|
||||
<div className="p-3 bg-muted/50 rounded-lg text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
您已申请此悬赏
|
||||
<Badge className="ml-2" variant="secondary">
|
||||
{myApplication.status === "pending"
|
||||
? "待审核"
|
||||
: myApplication.status === "accepted"
|
||||
? "已接受"
|
||||
: "已拒绝"}
|
||||
</Badge>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPublisher && bountyIsEscrowed && (
|
||||
<div className="p-3 bg-emerald-50 dark:bg-emerald-900/20 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-emerald-700 dark:text-emerald-400">
|
||||
<ShieldCheck className="w-5 h-5" />
|
||||
<span className="font-medium">赏金已托管</span>
|
||||
</div>
|
||||
<p className="text-xs text-emerald-600 dark:text-emerald-500 mt-1">
|
||||
赏金已安全托管,任务完成后可释放给接单者
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bountyIsPaid && (
|
||||
<div className="p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-purple-700 dark:text-purple-400">
|
||||
<Wallet className="w-5 h-5" />
|
||||
<span className="font-medium">赏金已结算</span>
|
||||
</div>
|
||||
<p className="text-xs text-purple-600 dark:text-purple-500 mt-1">
|
||||
赏金已转入接单者账户
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canEscrow && (
|
||||
<Button className="w-full gap-2" variant="default" onClick={onEscrow} disabled={isEscrowing}>
|
||||
{isEscrowing ? <Loader2 className="w-4 h-4 animate-spin" /> : <CreditCard className="w-4 h-4" />}
|
||||
托管赏金
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canRelease && (
|
||||
<Button className="w-full gap-2" variant="default" onClick={onRelease} disabled={isReleasing}>
|
||||
{isReleasing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Wallet className="w-4 h-4" />}
|
||||
释放赏金
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canComplete && (
|
||||
<Button className="w-full gap-2" variant="default" onClick={onComplete} disabled={isCompleting}>
|
||||
{isCompleting ? <Loader2 className="w-4 h-4 animate-spin" /> : <CheckCircle className="w-4 h-4" />}
|
||||
确认完成
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canCancel && (
|
||||
<Button className="w-full gap-2" variant="outline" onClick={onCancel} disabled={isCancelling}>
|
||||
{isCancelling ? <Loader2 className="w-4 h-4 animate-spin" /> : <XCircle className="w-4 h-4" />}
|
||||
取消悬赏
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!isAuthenticated && (
|
||||
<Link href="/login" className="block">
|
||||
<Button className="w-full">登录后操作</Button>
|
||||
</Link>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { zhCN } from "date-fns/locale";
|
||||
|
||||
type Application = {
|
||||
id: number;
|
||||
created_at: string;
|
||||
message?: string | null;
|
||||
status: "pending" | "accepted" | "rejected";
|
||||
applicant?: { name?: string | null; avatar?: string | null } | null;
|
||||
};
|
||||
|
||||
type BountyApplicationsListProps = {
|
||||
applications: Application[];
|
||||
onAccept: (applicationId: number) => void;
|
||||
isAccepting: boolean;
|
||||
};
|
||||
|
||||
export default function BountyApplicationsList({
|
||||
applications,
|
||||
onAccept,
|
||||
isAccepting,
|
||||
}: BountyApplicationsListProps) {
|
||||
if (!applications.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="card-elegant">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">申请列表 ({applications.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{applications.map((application) => (
|
||||
<div key={application.id} className="p-3 bg-muted/50 rounded-lg">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Avatar className="w-8 h-8">
|
||||
<AvatarImage src={application.applicant?.avatar || undefined} />
|
||||
<AvatarFallback>{application.applicant?.name?.[0] || "U"}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="font-medium text-sm">{application.applicant?.name || "匿名用户"}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(application.created_at), { addSuffix: true, locale: zhCN })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{application.message && (
|
||||
<p className="text-sm text-muted-foreground mb-3">{application.message}</p>
|
||||
)}
|
||||
{application.status === "pending" && (
|
||||
<Button size="sm" className="w-full" onClick={() => onAccept(application.id)} disabled={isAccepting}>
|
||||
{isAccepting ? <Loader2 className="w-4 h-4 animate-spin" /> : "接受申请"}
|
||||
</Button>
|
||||
)}
|
||||
{application.status !== "pending" && (
|
||||
<Badge variant="secondary" className="w-full justify-center">
|
||||
{application.status === "accepted" ? "已接受" : "已拒绝"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
110
frontend/src/features/bounties/components/BountyComments.tsx
Normal file
110
frontend/src/features/bounties/components/BountyComments.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MessageSquare, Send, Loader2 } from "lucide-react";
|
||||
import { Link } from "wouter";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { zhCN } from "date-fns/locale";
|
||||
|
||||
type Comment = {
|
||||
id: number;
|
||||
content: string;
|
||||
created_at: string;
|
||||
user?: { name?: string | null; avatar?: string | null } | null;
|
||||
};
|
||||
|
||||
type BountyCommentsProps = {
|
||||
isAuthenticated: boolean;
|
||||
user?: { name?: string | null; avatar?: string | null } | null;
|
||||
comments?: Comment[] | null;
|
||||
newComment: string;
|
||||
setNewComment: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
isSubmitting: boolean;
|
||||
};
|
||||
|
||||
export default function BountyComments({
|
||||
isAuthenticated,
|
||||
user,
|
||||
comments,
|
||||
newComment,
|
||||
setNewComment,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
}: BountyCommentsProps) {
|
||||
return (
|
||||
<Card className="card-elegant">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<MessageSquare className="w-5 h-5" />
|
||||
评论 ({comments?.length || 0})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isAuthenticated ? (
|
||||
<div className="flex gap-3 mb-6">
|
||||
<Avatar className="w-10 h-10">
|
||||
<AvatarImage src={user?.avatar || undefined} />
|
||||
<AvatarFallback>{user?.name?.[0] || "U"}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<Textarea
|
||||
placeholder="发表评论..."
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
<div className="flex justify-end mt-2">
|
||||
<Button size="sm" onClick={onSubmit} disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
发送
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-4 mb-6 bg-muted/50 rounded-lg">
|
||||
<p className="text-muted-foreground mb-2">登录后可以发表评论</p>
|
||||
<Link href="/login">
|
||||
<Button size="sm">登录</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{comments && comments.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{comments.map((comment) => (
|
||||
<div key={comment.id} className="flex gap-3">
|
||||
<Avatar className="w-10 h-10">
|
||||
<AvatarImage src={comment.user?.avatar || undefined} />
|
||||
<AvatarFallback>{comment.user?.name?.[0] || "U"}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium">{comment.user?.name || "匿名用户"}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(comment.created_at), {
|
||||
addSuffix: true,
|
||||
locale: zhCN,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{comment.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-center text-muted-foreground py-4">暂无评论</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
109
frontend/src/features/bounties/components/BountyDeliveries.tsx
Normal file
109
frontend/src/features/bounties/components/BountyDeliveries.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { CheckCircle, Loader2 } from "lucide-react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { zhCN } from "date-fns/locale";
|
||||
|
||||
type Delivery = {
|
||||
id: number;
|
||||
content: string;
|
||||
status: string;
|
||||
submitted_at: string;
|
||||
attachment_url?: string | null;
|
||||
};
|
||||
|
||||
type BountyDeliveriesProps = {
|
||||
deliveries?: Delivery[] | null;
|
||||
isAcceptor: boolean;
|
||||
isPublisher: boolean;
|
||||
bountyStatus: string;
|
||||
deliveryContent: string;
|
||||
setDeliveryContent: (value: string) => void;
|
||||
deliveryAttachment: string;
|
||||
setDeliveryAttachment: (value: string) => void;
|
||||
onSubmitDelivery: () => void;
|
||||
onReviewDelivery: (deliveryId: number, accept: boolean) => void;
|
||||
isSubmitting: boolean;
|
||||
};
|
||||
|
||||
export default function BountyDeliveries({
|
||||
deliveries,
|
||||
isAcceptor,
|
||||
isPublisher,
|
||||
bountyStatus,
|
||||
deliveryContent,
|
||||
setDeliveryContent,
|
||||
deliveryAttachment,
|
||||
setDeliveryAttachment,
|
||||
onSubmitDelivery,
|
||||
onReviewDelivery,
|
||||
isSubmitting,
|
||||
}: BountyDeliveriesProps) {
|
||||
return (
|
||||
<Card className="card-elegant">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
交付记录
|
||||
</CardTitle>
|
||||
<CardDescription>接单者提交交付内容,发布者进行验收</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{isAcceptor && bountyStatus === "in_progress" && (
|
||||
<div className="space-y-3">
|
||||
<Textarea
|
||||
placeholder="填写交付内容..."
|
||||
value={deliveryContent}
|
||||
onChange={(e) => setDeliveryContent(e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
<Input
|
||||
placeholder="附件链接(可选)"
|
||||
value={deliveryAttachment}
|
||||
onChange={(e) => setDeliveryAttachment(e.target.value)}
|
||||
/>
|
||||
<Button onClick={onSubmitDelivery} disabled={isSubmitting}>
|
||||
{isSubmitting ? <Loader2 className="w-4 h-4 animate-spin" /> : "提交交付"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deliveries && deliveries.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{deliveries.map((delivery) => (
|
||||
<div key={delivery.id} className="p-3 border rounded-lg space-y-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(delivery.submitted_at), { addSuffix: true, locale: zhCN })}
|
||||
</div>
|
||||
<div className="text-sm">{delivery.content}</div>
|
||||
{delivery.attachment_url && (
|
||||
<a className="text-sm text-primary" href={delivery.attachment_url} target="_blank" rel="noreferrer">
|
||||
查看附件
|
||||
</a>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary">{delivery.status}</Badge>
|
||||
{isPublisher && delivery.status === "submitted" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={() => onReviewDelivery(delivery.id, true)}>
|
||||
验收通过
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => onReviewDelivery(delivery.id, false)}>
|
||||
驳回
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">暂无交付记录</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
90
frontend/src/features/bounties/components/BountyDisputes.tsx
Normal file
90
frontend/src/features/bounties/components/BountyDisputes.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
|
||||
type Dispute = {
|
||||
id: number;
|
||||
created_at: string;
|
||||
reason: string;
|
||||
evidence_url?: string | null;
|
||||
status: string;
|
||||
};
|
||||
|
||||
type BountyDisputesProps = {
|
||||
disputes?: Dispute[] | null;
|
||||
canRaise: boolean;
|
||||
disputeReason: string;
|
||||
setDisputeReason: (value: string) => void;
|
||||
disputeEvidence: string;
|
||||
setDisputeEvidence: (value: string) => void;
|
||||
onCreateDispute: () => void;
|
||||
isSubmitting: boolean;
|
||||
};
|
||||
|
||||
export default function BountyDisputes({
|
||||
disputes,
|
||||
canRaise,
|
||||
disputeReason,
|
||||
setDisputeReason,
|
||||
disputeEvidence,
|
||||
setDisputeEvidence,
|
||||
onCreateDispute,
|
||||
isSubmitting,
|
||||
}: BountyDisputesProps) {
|
||||
return (
|
||||
<Card className="card-elegant">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
争议处理
|
||||
</CardTitle>
|
||||
<CardDescription>出现争议时可提交说明与证据</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{canRaise && (
|
||||
<div className="space-y-3">
|
||||
<Textarea
|
||||
placeholder="争议原因..."
|
||||
value={disputeReason}
|
||||
onChange={(e) => setDisputeReason(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
<Input
|
||||
placeholder="证据链接(可选)"
|
||||
value={disputeEvidence}
|
||||
onChange={(e) => setDisputeEvidence(e.target.value)}
|
||||
/>
|
||||
<Button onClick={onCreateDispute} disabled={isSubmitting}>
|
||||
发起争议
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{disputes && disputes.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{disputes.map((dispute) => (
|
||||
<div key={dispute.id} className="p-3 border rounded-lg space-y-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{format(new Date(dispute.created_at), "yyyy-MM-dd HH:mm")}
|
||||
</div>
|
||||
<div className="text-sm">{dispute.reason}</div>
|
||||
{dispute.evidence_url && (
|
||||
<a className="text-sm text-primary" href={dispute.evidence_url} target="_blank" rel="noreferrer">
|
||||
查看证据
|
||||
</a>
|
||||
)}
|
||||
<Badge variant="secondary">{dispute.status}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">暂无争议</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Clock } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
|
||||
type Extension = {
|
||||
id: number;
|
||||
created_at: string;
|
||||
proposed_deadline: string;
|
||||
reason?: string | null;
|
||||
status: string;
|
||||
};
|
||||
|
||||
type BountyExtensionsProps = {
|
||||
extensions?: Extension[] | null;
|
||||
isAcceptor: boolean;
|
||||
isPublisher: boolean;
|
||||
bountyStatus: string;
|
||||
extensionDeadline: string;
|
||||
setExtensionDeadline: (value: string) => void;
|
||||
extensionReason: string;
|
||||
setExtensionReason: (value: string) => void;
|
||||
onCreateExtension: () => void;
|
||||
onReviewExtension: (requestId: number, approve: boolean) => void;
|
||||
isSubmitting: boolean;
|
||||
};
|
||||
|
||||
export default function BountyExtensions({
|
||||
extensions,
|
||||
isAcceptor,
|
||||
isPublisher,
|
||||
bountyStatus,
|
||||
extensionDeadline,
|
||||
setExtensionDeadline,
|
||||
extensionReason,
|
||||
setExtensionReason,
|
||||
onCreateExtension,
|
||||
onReviewExtension,
|
||||
isSubmitting,
|
||||
}: BountyExtensionsProps) {
|
||||
return (
|
||||
<Card className="card-elegant">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Clock className="w-5 h-5" />
|
||||
延期申请
|
||||
</CardTitle>
|
||||
<CardDescription>接单者可申请延长截止时间</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{isAcceptor && bountyStatus === "in_progress" && (
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={extensionDeadline}
|
||||
onChange={(e) => setExtensionDeadline(e.target.value)}
|
||||
/>
|
||||
<Textarea
|
||||
placeholder="延期原因(可选)"
|
||||
value={extensionReason}
|
||||
onChange={(e) => setExtensionReason(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
<Button onClick={onCreateExtension} disabled={isSubmitting}>
|
||||
提交延期申请
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{extensions && extensions.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{extensions.map((ext) => (
|
||||
<div key={ext.id} className="p-3 border rounded-lg space-y-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
申请时间:{format(new Date(ext.created_at), "yyyy-MM-dd HH:mm")}
|
||||
</div>
|
||||
<div className="text-sm">申请截止:{format(new Date(ext.proposed_deadline), "yyyy-MM-dd HH:mm")}</div>
|
||||
{ext.reason && <div className="text-sm">{ext.reason}</div>}
|
||||
<Badge variant="secondary">{ext.status}</Badge>
|
||||
{isPublisher && ext.status === "pending" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={() => onReviewExtension(ext.id, true)}>同意</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => onReviewExtension(ext.id, false)}>拒绝</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">暂无延期申请</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Link } from "wouter";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
type BountyHeaderBarProps = {
|
||||
backHref?: string;
|
||||
};
|
||||
|
||||
export default function BountyHeaderBar({ backHref = "/bounties" }: BountyHeaderBarProps) {
|
||||
return (
|
||||
<Link href={backHref}>
|
||||
<Button variant="ghost" className="mb-6 gap-2">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
返回悬赏大厅
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
69
frontend/src/features/bounties/components/BountyInfoCard.tsx
Normal file
69
frontend/src/features/bounties/components/BountyInfoCard.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Calendar, Clock, DollarSign, User } from "lucide-react";
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
import { zhCN } from "date-fns/locale";
|
||||
import { BOUNTY_STATUS_MAP } from "@/const";
|
||||
|
||||
type BountyInfoCardProps = {
|
||||
bounty: {
|
||||
status: string;
|
||||
reward: number | string;
|
||||
title: string;
|
||||
description: string;
|
||||
created_at: string;
|
||||
deadline?: string | null;
|
||||
publisher?: { name?: string | null } | null;
|
||||
};
|
||||
};
|
||||
|
||||
export default function BountyInfoCard({ bounty }: BountyInfoCardProps) {
|
||||
return (
|
||||
<Card className="card-elegant">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<Badge className={`${BOUNTY_STATUS_MAP[bounty.status]?.class || "bg-muted"} text-sm px-3 py-1`}>
|
||||
{BOUNTY_STATUS_MAP[bounty.status]?.label || bounty.status}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-1 text-2xl font-bold text-primary">
|
||||
<DollarSign className="w-6 h-6" />
|
||||
<span>¥{bounty.reward}</span>
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="text-2xl" style={{ fontFamily: "'Playfair Display', serif" }}>
|
||||
{bounty.title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="prose prose-sm max-w-none text-muted-foreground">
|
||||
<p className="whitespace-pre-wrap">{bounty.description}</p>
|
||||
</div>
|
||||
|
||||
<Separator className="my-6" />
|
||||
|
||||
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="w-4 h-4" />
|
||||
<span>发布者: {bounty.publisher?.name || "匿名用户"}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>
|
||||
{formatDistanceToNow(new Date(bounty.created_at), {
|
||||
addSuffix: true,
|
||||
locale: zhCN,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{bounty.deadline && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>截止: {format(new Date(bounty.deadline), "yyyy-MM-dd")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { format } from "date-fns";
|
||||
|
||||
type PaymentStep = {
|
||||
key: string;
|
||||
label: string;
|
||||
done: boolean;
|
||||
time: string | null;
|
||||
};
|
||||
|
||||
type BountyPaymentTimelineProps = {
|
||||
paymentSteps: PaymentStep[];
|
||||
};
|
||||
|
||||
export default function BountyPaymentTimeline({ paymentSteps }: BountyPaymentTimelineProps) {
|
||||
return (
|
||||
<Card className="card-elegant">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">支付与交付流程</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{paymentSteps.map((step, index) => (
|
||||
<div key={step.key} className="flex items-start gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className={`w-3 h-3 rounded-full ${step.done ? "bg-primary" : "bg-muted"}`} />
|
||||
{index < paymentSteps.length - 1 && <div className="w-px h-8 bg-border mt-1" />}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{step.label}</span>
|
||||
<Badge variant={step.done ? "secondary" : "outline"}>
|
||||
{step.done ? "已完成" : "未开始"}
|
||||
</Badge>
|
||||
</div>
|
||||
{step.time && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{format(new Date(step.time), "yyyy-MM-dd HH:mm")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
87
frontend/src/features/bounties/components/BountyReviews.tsx
Normal file
87
frontend/src/features/bounties/components/BountyReviews.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Trophy } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
|
||||
type Review = {
|
||||
id: number;
|
||||
created_at: string;
|
||||
rating: number;
|
||||
comment?: string | null;
|
||||
};
|
||||
|
||||
type BountyReviewsProps = {
|
||||
reviews?: Review[] | null;
|
||||
canReview: boolean;
|
||||
reviewRating: number;
|
||||
setReviewRating: (value: number) => void;
|
||||
reviewComment: string;
|
||||
setReviewComment: (value: string) => void;
|
||||
onCreateReview: () => void;
|
||||
isSubmitting: boolean;
|
||||
canSubmit: boolean;
|
||||
};
|
||||
|
||||
export default function BountyReviews({
|
||||
reviews,
|
||||
canReview,
|
||||
reviewRating,
|
||||
setReviewRating,
|
||||
reviewComment,
|
||||
setReviewComment,
|
||||
onCreateReview,
|
||||
isSubmitting,
|
||||
canSubmit,
|
||||
}: BountyReviewsProps) {
|
||||
return (
|
||||
<Card className="card-elegant">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Trophy className="w-5 h-5" />
|
||||
评价
|
||||
</CardTitle>
|
||||
<CardDescription>任务完成后双方可互评</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{canReview && (
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={5}
|
||||
value={reviewRating}
|
||||
onChange={(e) => setReviewRating(Number(e.target.value))}
|
||||
/>
|
||||
<Textarea
|
||||
placeholder="评价内容(可选)"
|
||||
value={reviewComment}
|
||||
onChange={(e) => setReviewComment(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
<Button onClick={onCreateReview} disabled={isSubmitting || !canSubmit}>
|
||||
提交评价
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reviews && reviews.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{reviews.map((review) => (
|
||||
<div key={review.id} className="p-3 border rounded-lg space-y-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{format(new Date(review.created_at), "yyyy-MM-dd HH:mm")}
|
||||
</div>
|
||||
<div className="text-sm">评分:{review.rating}</div>
|
||||
{review.comment && <div className="text-sm">{review.comment}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">暂无评价</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
156
frontend/src/features/bounties/pages/Bounties.tsx
Normal file
156
frontend/src/features/bounties/pages/Bounties.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
import { BOUNTY_STATUS_MAP } from "@/const";
|
||||
import { useBounties, useCreateBounty } from "@/hooks/useApi";
|
||||
import { useDebounce } from "@/hooks/useDebounce";
|
||||
import { useState, useMemo } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { getErrorCopy } from "@/lib/i18n/errorMessages";
|
||||
import { Trophy, Sparkles, Plus, Loader2 } from "lucide-react";
|
||||
import BountiesHeader from "@/features/bounties/components/BountiesHeader";
|
||||
import BountiesGrid from "@/features/bounties/components/BountiesGrid";
|
||||
|
||||
const statusMap: Record<string, { label: string; class: string }> = {
|
||||
open: { label: "开放中", class: "badge-open" },
|
||||
in_progress: { label: "进行中", class: "badge-in-progress" },
|
||||
completed: { label: "已完成", class: "badge-completed" },
|
||||
cancelled: { label: "已取消", class: "badge-cancelled" },
|
||||
disputed: { label: "争议中", class: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400" },
|
||||
};
|
||||
|
||||
export default function Bounties() {
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [newBounty, setNewBounty] = useState({
|
||||
title: "",
|
||||
description: "",
|
||||
reward: "",
|
||||
deadline: "",
|
||||
});
|
||||
|
||||
const { data: bountiesData, isLoading, refetch } = useBounties({
|
||||
status: statusFilter === "all" ? undefined : statusFilter
|
||||
});
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300);
|
||||
|
||||
const bounties = bountiesData?.items || [];
|
||||
|
||||
const createBountyMutation = useCreateBounty();
|
||||
|
||||
const filteredBounties = useMemo(() => {
|
||||
if (!bounties) return [];
|
||||
const query = debouncedSearchQuery.trim().toLowerCase();
|
||||
if (!query) return bounties;
|
||||
return bounties.filter((b) =>
|
||||
b.title.toLowerCase().includes(query) ||
|
||||
b.description.toLowerCase().includes(query)
|
||||
);
|
||||
}, [bounties, debouncedSearchQuery]);
|
||||
|
||||
const handleCreateBounty = () => {
|
||||
if (!newBounty.title.trim()) {
|
||||
toast.error("请输入悬赏标题");
|
||||
return;
|
||||
}
|
||||
if (!newBounty.description.trim()) {
|
||||
toast.error("请输入悬赏描述");
|
||||
return;
|
||||
}
|
||||
const rewardValue = Number(newBounty.reward);
|
||||
if (!newBounty.reward || !Number.isFinite(rewardValue) || rewardValue <= 0) {
|
||||
toast.error("请输入有效的赏金金额");
|
||||
return;
|
||||
}
|
||||
|
||||
createBountyMutation.mutate({
|
||||
title: newBounty.title,
|
||||
description: newBounty.description,
|
||||
reward: rewardValue.toFixed(2),
|
||||
deadline: newBounty.deadline || undefined,
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success("悬赏发布成功!");
|
||||
setIsCreateOpen(false);
|
||||
setNewBounty({ title: "", description: "", reward: "", deadline: "" });
|
||||
refetch();
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const { title, description } = getErrorCopy(error, { context: "bounty.create" });
|
||||
toast.error(title, { description });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Navbar />
|
||||
|
||||
<BountiesHeader
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
statusFilter={statusFilter}
|
||||
setStatusFilter={setStatusFilter}
|
||||
isAuthenticated={isAuthenticated}
|
||||
isCreateOpen={isCreateOpen}
|
||||
setIsCreateOpen={setIsCreateOpen}
|
||||
newBounty={newBounty}
|
||||
setNewBounty={setNewBounty}
|
||||
onCreate={handleCreateBounty}
|
||||
isCreating={createBountyMutation.isPending}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<section className="pb-20">
|
||||
<div className="container">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : filteredBounties.length === 0 ? (
|
||||
<Card className="card-elegant">
|
||||
<CardContent className="py-16 text-center">
|
||||
<Trophy className="w-16 h-16 mx-auto text-muted-foreground mb-4" />
|
||||
<h3 className="text-xl font-semibold mb-2">暂无悬赏</h3>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
{statusFilter === "all"
|
||||
? "还没有人发布悬赏,成为第一个发布者吧!"
|
||||
: "该状态下暂无悬赏"}
|
||||
</p>
|
||||
{isAuthenticated && (
|
||||
<Button onClick={() => setIsCreateOpen(true)} className="gap-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
发布悬赏
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<BountiesGrid bounties={filteredBounties} statusMap={statusMap} />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="py-12 border-t border-border">
|
||||
<div className="container">
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center">
|
||||
<Sparkles className="w-5 h-5 text-primary-foreground" />
|
||||
</div>
|
||||
<span className="font-semibold">资源聚合平台</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
© 2026 资源聚合平台. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
534
frontend/src/features/bounties/pages/BountyDetail.tsx
Normal file
534
frontend/src/features/bounties/pages/BountyDetail.tsx
Normal file
@@ -0,0 +1,534 @@
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
import { BOUNTY_STATUS_MAP } from "@/const";
|
||||
import {
|
||||
useBounty,
|
||||
useBountyApplications,
|
||||
useMyBountyApplication,
|
||||
useBountyComments,
|
||||
useSubmitApplication,
|
||||
useAcceptApplication,
|
||||
useCompleteBounty,
|
||||
useCancelBounty,
|
||||
useCreateComment,
|
||||
useCreateEscrow,
|
||||
useReleasePayout,
|
||||
useDeliveries,
|
||||
useSubmitDelivery,
|
||||
useReviewDelivery,
|
||||
useDisputes,
|
||||
useCreateDispute,
|
||||
useBountyReviews,
|
||||
useCreateReview,
|
||||
useExtensionRequests,
|
||||
useCreateExtensionRequest,
|
||||
useReviewExtensionRequest,
|
||||
} from "@/hooks/useApi";
|
||||
import { Link, useParams, useLocation } from "wouter";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { getErrorCopy } from "@/lib/i18n/errorMessages";
|
||||
import {
|
||||
Sparkles,
|
||||
DollarSign,
|
||||
Loader2,
|
||||
AlertCircle
|
||||
} from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import BountyComments from "@/features/bounties/components/BountyComments";
|
||||
import BountyDeliveries from "@/features/bounties/components/BountyDeliveries";
|
||||
import BountyExtensions from "@/features/bounties/components/BountyExtensions";
|
||||
import BountyDisputes from "@/features/bounties/components/BountyDisputes";
|
||||
import BountyReviews from "@/features/bounties/components/BountyReviews";
|
||||
import BountyActionsPanel from "@/features/bounties/components/BountyActionsPanel";
|
||||
import BountyPaymentTimeline from "@/features/bounties/components/BountyPaymentTimeline";
|
||||
import BountyApplicationsList from "@/features/bounties/components/BountyApplicationsList";
|
||||
import BountyHeaderBar from "@/features/bounties/components/BountyHeaderBar";
|
||||
import BountyInfoCard from "@/features/bounties/components/BountyInfoCard";
|
||||
|
||||
const statusMap: Record<string, { label: string; class: string }> = {
|
||||
open: { label: "开放中", class: "badge-open" },
|
||||
in_progress: { label: "进行中", class: "badge-in-progress" },
|
||||
completed: { label: "已完成", class: "badge-completed" },
|
||||
cancelled: { label: "已取消", class: "badge-cancelled" },
|
||||
disputed: { label: "争议中", class: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400" },
|
||||
};
|
||||
|
||||
export default function BountyDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [, navigate] = useLocation();
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
const [applyMessage, setApplyMessage] = useState("");
|
||||
const [newComment, setNewComment] = useState("");
|
||||
const [isApplyOpen, setIsApplyOpen] = useState(false);
|
||||
const [deliveryContent, setDeliveryContent] = useState("");
|
||||
const [deliveryAttachment, setDeliveryAttachment] = useState("");
|
||||
const [disputeReason, setDisputeReason] = useState("");
|
||||
const [disputeEvidence, setDisputeEvidence] = useState("");
|
||||
const [reviewRating, setReviewRating] = useState(5);
|
||||
const [reviewComment, setReviewComment] = useState("");
|
||||
const [extensionDeadline, setExtensionDeadline] = useState("");
|
||||
const [extensionReason, setExtensionReason] = useState("");
|
||||
|
||||
const bountyId = parseInt(id || "0");
|
||||
|
||||
const { data: bounty, isLoading, refetch } = useBounty(bountyId);
|
||||
const { data: applications } = useBountyApplications(bountyId);
|
||||
const { data: comments, refetch: refetchComments } = useBountyComments(bountyId);
|
||||
const { data: myApplication } = useMyBountyApplication(bountyId);
|
||||
const canAccessWorkflow = Boolean(
|
||||
isAuthenticated &&
|
||||
(user?.id === bounty?.publisher_id || user?.id === bounty?.acceptor_id)
|
||||
);
|
||||
const { data: deliveries, refetch: refetchDeliveries } = useDeliveries(bountyId, canAccessWorkflow);
|
||||
const { data: disputes, refetch: refetchDisputes } = useDisputes(bountyId, canAccessWorkflow);
|
||||
const { data: reviews, refetch: refetchReviews } = useBountyReviews(bountyId);
|
||||
const { data: extensions, refetch: refetchExtensions } = useExtensionRequests(bountyId, canAccessWorkflow);
|
||||
|
||||
const applyMutation = useSubmitApplication();
|
||||
const acceptMutation = useAcceptApplication();
|
||||
const completeMutation = useCompleteBounty();
|
||||
const cancelMutation = useCancelBounty();
|
||||
const escrowMutation = useCreateEscrow();
|
||||
const releaseMutation = useReleasePayout();
|
||||
const commentMutation = useCreateComment();
|
||||
const deliveryMutation = useSubmitDelivery();
|
||||
const deliveryReviewMutation = useReviewDelivery();
|
||||
const disputeMutation = useCreateDispute();
|
||||
const reviewMutation = useCreateReview();
|
||||
const extensionMutation = useCreateExtensionRequest();
|
||||
const extensionReviewMutation = useReviewExtensionRequest();
|
||||
|
||||
const handleApply = () => {
|
||||
applyMutation.mutate({
|
||||
bountyId,
|
||||
data: { message: applyMessage || undefined },
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success("申请已提交!");
|
||||
setIsApplyOpen(false);
|
||||
setApplyMessage("");
|
||||
refetch();
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const { title, description } = getErrorCopy(error, { context: "bounty.apply" });
|
||||
toast.error(title, { description });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleAccept = (applicationId: number) => {
|
||||
acceptMutation.mutate({ bountyId, applicationId }, {
|
||||
onSuccess: () => {
|
||||
toast.success("已接受申请!");
|
||||
refetch();
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const { title, description } = getErrorCopy(error, { context: "bounty.accept" });
|
||||
toast.error(title, { description });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleComplete = () => {
|
||||
completeMutation.mutate(bountyId, {
|
||||
onSuccess: () => {
|
||||
toast.success("悬赏已完成!");
|
||||
refetch();
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const { title, description } = getErrorCopy(error, { context: "bounty.complete" });
|
||||
toast.error(title, { description });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
cancelMutation.mutate(bountyId, {
|
||||
onSuccess: () => {
|
||||
toast.success("悬赏已取消");
|
||||
refetch();
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const { title, description } = getErrorCopy(error, { context: "bounty.cancel" });
|
||||
toast.error(title, { description });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleEscrow = () => {
|
||||
escrowMutation.mutate({
|
||||
bounty_id: bountyId,
|
||||
success_url: window.location.href,
|
||||
cancel_url: window.location.href,
|
||||
}, {
|
||||
onSuccess: (data) => {
|
||||
if (data.checkout_url) {
|
||||
toast.info("正在跳转到支付页面...");
|
||||
window.open(data.checkout_url, "_blank");
|
||||
}
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const { title, description } = getErrorCopy(error, { context: "bounty.escrow" });
|
||||
toast.error(title, { description });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleRelease = () => {
|
||||
releaseMutation.mutate(bountyId, {
|
||||
onSuccess: () => {
|
||||
toast.success("赏金已释放给接单者!");
|
||||
refetch();
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const { title, description } = getErrorCopy(error, { context: "bounty.release" });
|
||||
toast.error(title, { description });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleComment = () => {
|
||||
if (!newComment.trim()) {
|
||||
toast.error("请输入评论内容");
|
||||
return;
|
||||
}
|
||||
commentMutation.mutate({
|
||||
bountyId,
|
||||
data: { content: newComment },
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success("评论已发布!");
|
||||
setNewComment("");
|
||||
refetchComments();
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const { title, description } = getErrorCopy(error, { context: "bounty.comment" });
|
||||
toast.error(title, { description });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmitDelivery = () => {
|
||||
if (!deliveryContent.trim()) {
|
||||
toast.error("请输入交付内容");
|
||||
return;
|
||||
}
|
||||
deliveryMutation.mutate({
|
||||
bountyId,
|
||||
data: {
|
||||
content: deliveryContent,
|
||||
attachment_url: deliveryAttachment || undefined,
|
||||
},
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success("交付已提交");
|
||||
setDeliveryContent("");
|
||||
setDeliveryAttachment("");
|
||||
refetchDeliveries();
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const { title, description } = getErrorCopy(error, { context: "bounty.delivery" });
|
||||
toast.error(title, { description });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleReviewDelivery = (deliveryId: number, accept: boolean) => {
|
||||
deliveryReviewMutation.mutate({ bountyId, deliveryId, accept }, {
|
||||
onSuccess: () => {
|
||||
toast.success("交付已处理");
|
||||
refetchDeliveries();
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const { title, description } = getErrorCopy(error, { context: "bounty.delivery" });
|
||||
toast.error(title, { description });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateDispute = () => {
|
||||
if (!disputeReason.trim()) {
|
||||
toast.error("请输入争议原因");
|
||||
return;
|
||||
}
|
||||
disputeMutation.mutate({
|
||||
bountyId,
|
||||
data: {
|
||||
reason: disputeReason,
|
||||
evidence_url: disputeEvidence || undefined,
|
||||
},
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success("争议已提交");
|
||||
setDisputeReason("");
|
||||
setDisputeEvidence("");
|
||||
refetchDisputes();
|
||||
refetch();
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const { title, description } = getErrorCopy(error, { context: "bounty.dispute" });
|
||||
toast.error(title, { description });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateReview = () => {
|
||||
if (!bounty) return;
|
||||
// 在函数内部计算 isPublisher,避免作用域问题
|
||||
const isCurrentUserPublisher = user?.id === bounty.publisher_id;
|
||||
reviewMutation.mutate({
|
||||
bountyId,
|
||||
data: {
|
||||
reviewee_id: isCurrentUserPublisher ? bounty.acceptor_id! : bounty.publisher_id,
|
||||
rating: reviewRating,
|
||||
comment: reviewComment || undefined,
|
||||
},
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success("评价已提交");
|
||||
setReviewComment("");
|
||||
refetchReviews();
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const { title, description } = getErrorCopy(error, { context: "bounty.review" });
|
||||
toast.error(title, { description });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateExtension = () => {
|
||||
if (!extensionDeadline) {
|
||||
toast.error("请选择延期截止时间");
|
||||
return;
|
||||
}
|
||||
extensionMutation.mutate({
|
||||
bountyId,
|
||||
data: {
|
||||
proposed_deadline: new Date(extensionDeadline).toISOString(),
|
||||
reason: extensionReason || undefined,
|
||||
},
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success("延期申请已提交");
|
||||
setExtensionDeadline("");
|
||||
setExtensionReason("");
|
||||
refetchExtensions();
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const { title, description } = getErrorCopy(error, { context: "bounty.extension" });
|
||||
toast.error(title, { description });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleReviewExtension = (requestId: number, approve: boolean) => {
|
||||
extensionReviewMutation.mutate({ bountyId, requestId, approve }, {
|
||||
onSuccess: () => {
|
||||
toast.success("延期申请已处理");
|
||||
refetchExtensions();
|
||||
refetch();
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const { title, description } = getErrorCopy(error, { context: "bounty.extension" });
|
||||
toast.error(title, { description });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!bounty) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<Card className="card-elegant max-w-md">
|
||||
<CardContent className="py-12 text-center">
|
||||
<AlertCircle className="w-16 h-16 mx-auto text-muted-foreground mb-4" />
|
||||
<h3 className="text-xl font-semibold mb-2">悬赏不存在</h3>
|
||||
<p className="text-muted-foreground mb-6">该悬赏可能已被删除或不存在</p>
|
||||
<Link href="/bounties">
|
||||
<Button>返回悬赏大厅</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isPublisher = user?.id === bounty.publisher_id;
|
||||
const isAcceptor = user?.id === bounty.acceptor_id;
|
||||
const canApply = isAuthenticated && !isPublisher && bounty.status === "open" && !myApplication;
|
||||
const canComplete = isPublisher && bounty.status === "in_progress";
|
||||
const canCancel = isPublisher && bounty.status === "open";
|
||||
const canEscrow = isPublisher && bounty.status === "open" && !bounty.is_escrowed;
|
||||
const canRelease = isPublisher && bounty.status === "completed" && bounty.is_escrowed && !bounty.is_paid;
|
||||
const acceptedDelivery = deliveries?.find((delivery) => delivery.status === "accepted");
|
||||
const paymentSteps = [
|
||||
{
|
||||
key: "created",
|
||||
label: "悬赏创建",
|
||||
done: true,
|
||||
time: bounty.created_at,
|
||||
},
|
||||
{
|
||||
key: "escrowed",
|
||||
label: "赏金托管",
|
||||
done: Boolean(bounty.is_escrowed),
|
||||
time: bounty.is_escrowed ? bounty.updated_at : null,
|
||||
},
|
||||
{
|
||||
key: "in_progress",
|
||||
label: "任务进行中",
|
||||
done: bounty.status === "in_progress" || bounty.status === "completed",
|
||||
time: bounty.updated_at,
|
||||
},
|
||||
{
|
||||
key: "delivery",
|
||||
label: "交付已验收",
|
||||
done: Boolean(acceptedDelivery),
|
||||
time: acceptedDelivery?.reviewed_at || null,
|
||||
},
|
||||
{
|
||||
key: "completed",
|
||||
label: "悬赏完成",
|
||||
done: bounty.status === "completed",
|
||||
time: bounty.completed_at,
|
||||
},
|
||||
{
|
||||
key: "paid",
|
||||
label: "赏金已结算",
|
||||
done: bounty.is_paid,
|
||||
time: bounty.is_paid ? bounty.updated_at : null,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Navbar />
|
||||
|
||||
{/* Content */}
|
||||
<section className="pt-24 pb-20">
|
||||
<div className="container max-w-4xl">
|
||||
<BountyHeaderBar />
|
||||
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
{/* Main Content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<BountyInfoCard bounty={bounty} />
|
||||
|
||||
<BountyComments
|
||||
isAuthenticated={isAuthenticated}
|
||||
user={user}
|
||||
comments={comments}
|
||||
newComment={newComment}
|
||||
setNewComment={setNewComment}
|
||||
onSubmit={handleComment}
|
||||
isSubmitting={commentMutation.isPending}
|
||||
/>
|
||||
|
||||
<BountyDeliveries
|
||||
deliveries={deliveries}
|
||||
isAcceptor={isAcceptor}
|
||||
isPublisher={isPublisher}
|
||||
bountyStatus={bounty.status}
|
||||
deliveryContent={deliveryContent}
|
||||
setDeliveryContent={setDeliveryContent}
|
||||
deliveryAttachment={deliveryAttachment}
|
||||
setDeliveryAttachment={setDeliveryAttachment}
|
||||
onSubmitDelivery={handleSubmitDelivery}
|
||||
onReviewDelivery={handleReviewDelivery}
|
||||
isSubmitting={deliveryMutation.isPending}
|
||||
/>
|
||||
|
||||
<BountyExtensions
|
||||
extensions={extensions}
|
||||
isAcceptor={isAcceptor}
|
||||
isPublisher={isPublisher}
|
||||
bountyStatus={bounty.status}
|
||||
extensionDeadline={extensionDeadline}
|
||||
setExtensionDeadline={setExtensionDeadline}
|
||||
extensionReason={extensionReason}
|
||||
setExtensionReason={setExtensionReason}
|
||||
onCreateExtension={handleCreateExtension}
|
||||
onReviewExtension={handleReviewExtension}
|
||||
isSubmitting={extensionMutation.isPending}
|
||||
/>
|
||||
|
||||
<BountyDisputes
|
||||
disputes={disputes}
|
||||
canRaise={isAuthenticated && (isPublisher || isAcceptor)}
|
||||
disputeReason={disputeReason}
|
||||
setDisputeReason={setDisputeReason}
|
||||
disputeEvidence={disputeEvidence}
|
||||
setDisputeEvidence={setDisputeEvidence}
|
||||
onCreateDispute={handleCreateDispute}
|
||||
isSubmitting={disputeMutation.isPending}
|
||||
/>
|
||||
|
||||
<BountyReviews
|
||||
reviews={reviews}
|
||||
canReview={bounty.status === "completed" && isAuthenticated && (isPublisher || isAcceptor)}
|
||||
reviewRating={reviewRating}
|
||||
setReviewRating={setReviewRating}
|
||||
reviewComment={reviewComment}
|
||||
setReviewComment={setReviewComment}
|
||||
onCreateReview={handleCreateReview}
|
||||
isSubmitting={reviewMutation.isPending}
|
||||
canSubmit={Boolean(bounty.acceptor_id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
<BountyActionsPanel
|
||||
canApply={canApply}
|
||||
isApplyOpen={isApplyOpen}
|
||||
setIsApplyOpen={setIsApplyOpen}
|
||||
applyMessage={applyMessage}
|
||||
setApplyMessage={setApplyMessage}
|
||||
onApply={handleApply}
|
||||
isApplying={applyMutation.isPending}
|
||||
myApplication={myApplication}
|
||||
isPublisher={isPublisher}
|
||||
isAuthenticated={isAuthenticated}
|
||||
bountyIsEscrowed={bounty.is_escrowed}
|
||||
bountyIsPaid={bounty.is_paid}
|
||||
canEscrow={canEscrow}
|
||||
onEscrow={handleEscrow}
|
||||
isEscrowing={escrowMutation.isPending}
|
||||
canRelease={canRelease}
|
||||
onRelease={handleRelease}
|
||||
isReleasing={releaseMutation.isPending}
|
||||
canComplete={canComplete}
|
||||
onComplete={handleComplete}
|
||||
isCompleting={completeMutation.isPending}
|
||||
canCancel={canCancel}
|
||||
onCancel={handleCancel}
|
||||
isCancelling={cancelMutation.isPending}
|
||||
/>
|
||||
|
||||
<BountyPaymentTimeline paymentSteps={paymentSteps} />
|
||||
|
||||
{isPublisher && bounty.status === "open" && applications && (
|
||||
<BountyApplicationsList
|
||||
applications={applications}
|
||||
onAccept={handleAccept}
|
||||
isAccepting={acceptMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
966
frontend/src/features/dashboard/pages/Dashboard.tsx
Normal file
966
frontend/src/features/dashboard/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,966 @@
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
import { BOUNTY_STATUS_MAP } from "@/const";
|
||||
import {
|
||||
useMyPublishedBounties,
|
||||
useMyAcceptedBounties,
|
||||
useNotifications,
|
||||
useUnreadNotificationCount,
|
||||
useMarkNotificationAsRead,
|
||||
useMarkAllNotificationsAsRead,
|
||||
useNotificationPreferences,
|
||||
useUpdateNotificationPreferences,
|
||||
useFavorites,
|
||||
useMyProducts,
|
||||
useCategories,
|
||||
} from "@/hooks/useApi";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { toast } from "sonner";
|
||||
import { getErrorCopy } from "@/lib/i18n/errorMessages";
|
||||
import {
|
||||
Sparkles,
|
||||
Trophy,
|
||||
Bell,
|
||||
LogOut,
|
||||
MoreVertical,
|
||||
Heart,
|
||||
Clock,
|
||||
DollarSign,
|
||||
FileText,
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
User,
|
||||
Package,
|
||||
Plus,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { zhCN } from "date-fns/locale";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { notificationApi, productApi, categoryApi, websiteApi } from "@/lib/api";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
|
||||
const statusMap: Record<string, { label: string; class: string }> = {
|
||||
open: { label: "开放中", class: "badge-open" },
|
||||
in_progress: { label: "进行中", class: "badge-in-progress" },
|
||||
completed: { label: "已完成", class: "badge-completed" },
|
||||
cancelled: { label: "已取消", class: "badge-cancelled" },
|
||||
disputed: { label: "争议中", class: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400" },
|
||||
};
|
||||
|
||||
const PRODUCT_STATUS_MAP: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline"; icon: typeof CheckCircle2 }> = {
|
||||
pending: { label: "待审核", variant: "outline", icon: Clock },
|
||||
approved: { label: "已通过", variant: "secondary", icon: CheckCircle2 },
|
||||
rejected: { label: "已拒绝", variant: "destructive", icon: XCircle },
|
||||
};
|
||||
|
||||
export default function Dashboard() {
|
||||
const { user, isAuthenticated, loading, logout } = useAuth();
|
||||
const [, navigate] = useLocation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// 创建商品对话框状态
|
||||
const [isAddProductOpen, setIsAddProductOpen] = useState(false);
|
||||
const [isCreatingProduct, setIsCreatingProduct] = useState(false);
|
||||
const [newProduct, setNewProduct] = useState({
|
||||
name: "",
|
||||
description: "",
|
||||
image: "",
|
||||
categoryId: "",
|
||||
websiteId: "",
|
||||
price: "",
|
||||
originalPrice: "",
|
||||
currency: "CNY",
|
||||
url: "",
|
||||
inStock: true,
|
||||
});
|
||||
const [isNewWebsite, setIsNewWebsite] = useState(false);
|
||||
const [newWebsite, setNewWebsite] = useState({ name: "", url: "" });
|
||||
const [isNewCategory, setIsNewCategory] = useState(false);
|
||||
const [newCategory, setNewCategory] = useState({ name: "", slug: "", description: "" });
|
||||
const [isCreatingCategory, setIsCreatingCategory] = useState(false);
|
||||
|
||||
const { data: publishedData, isLoading: publishedLoading } = useMyPublishedBounties();
|
||||
const { data: acceptedData, isLoading: acceptedLoading } = useMyAcceptedBounties();
|
||||
const { data: favoritesData, isLoading: favoritesLoading } = useFavorites();
|
||||
const { data: myProductsData, isLoading: myProductsLoading } = useMyProducts();
|
||||
const { data: categoriesData, isLoading: categoriesLoading } = useCategories();
|
||||
const { data: notificationsData, isLoading: notificationsLoading, refetch: refetchNotifications } = useNotifications();
|
||||
const { data: unreadCountData } = useUnreadNotificationCount();
|
||||
const { data: notificationPreferences } = useNotificationPreferences();
|
||||
|
||||
const publishedBounties = publishedData?.items || [];
|
||||
const acceptedBounties = acceptedData?.items || [];
|
||||
const favorites = favoritesData?.items || [];
|
||||
const myProducts = myProductsData?.items || [];
|
||||
const categories = categoriesData || [];
|
||||
const notifications = notificationsData?.items || [];
|
||||
const unreadCount = unreadCountData?.count || 0;
|
||||
|
||||
const markAsReadMutation = useMarkNotificationAsRead();
|
||||
const markAllAsReadMutation = useMarkAllNotificationsAsRead();
|
||||
const updatePreferencesMutation = useUpdateNotificationPreferences();
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !isAuthenticated) {
|
||||
navigate("/login");
|
||||
}
|
||||
}, [loading, isAuthenticated, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
const refreshNotifications = () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
queryClient.invalidateQueries({ queryKey: ["notifications"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["notifications", "unread-count"] });
|
||||
};
|
||||
const interval = window.setInterval(refreshNotifications, 30000);
|
||||
window.addEventListener("focus", refreshNotifications);
|
||||
return () => {
|
||||
window.clearInterval(interval);
|
||||
window.removeEventListener("focus", refreshNotifications);
|
||||
};
|
||||
}, [isAuthenticated, queryClient]);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
toast.success("已退出登录");
|
||||
navigate("/");
|
||||
};
|
||||
|
||||
const handleExportNotifications = async () => {
|
||||
try {
|
||||
const blob = await notificationApi.exportCsv();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "notifications.csv";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error: unknown) {
|
||||
const { title, description } = getErrorCopy(error, { context: "notification.export" });
|
||||
toast.error(title, { description });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateCategory = async () => {
|
||||
const name = newCategory.name.trim();
|
||||
if (!name) {
|
||||
toast.error("请输入分类名称");
|
||||
return;
|
||||
}
|
||||
const rawSlug = newCategory.slug.trim();
|
||||
const fallbackSlug = name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
|
||||
const slug = rawSlug || fallbackSlug || `category-${Date.now()}`;
|
||||
|
||||
setIsCreatingCategory(true);
|
||||
try {
|
||||
const category = await categoryApi.create({
|
||||
name,
|
||||
slug,
|
||||
description: newCategory.description.trim() || undefined,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["categories"] });
|
||||
setNewProduct((prev) => ({ ...prev, categoryId: category.id.toString() }));
|
||||
setIsNewCategory(false);
|
||||
setNewCategory({ name: "", slug: "", description: "" });
|
||||
toast.success("分类已创建");
|
||||
} catch (error: unknown) {
|
||||
const { title, description } = getErrorCopy(error, { context: "category.create" });
|
||||
toast.error(title, { description });
|
||||
} finally {
|
||||
setIsCreatingCategory(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateProduct = async () => {
|
||||
if (!newProduct.name.trim()) {
|
||||
toast.error("请输入商品名称");
|
||||
return;
|
||||
}
|
||||
if (!newProduct.categoryId) {
|
||||
toast.error("请选择分类");
|
||||
return;
|
||||
}
|
||||
if (isNewWebsite) {
|
||||
if (!newWebsite.name.trim()) {
|
||||
toast.error("请输入网站名称");
|
||||
return;
|
||||
}
|
||||
if (!newWebsite.url.trim()) {
|
||||
toast.error("请输入网站URL");
|
||||
return;
|
||||
}
|
||||
} else if (!newProduct.websiteId) {
|
||||
toast.error("请选择网站");
|
||||
return;
|
||||
}
|
||||
if (!newProduct.price || Number(newProduct.price) <= 0) {
|
||||
toast.error("请输入有效价格");
|
||||
return;
|
||||
}
|
||||
if (!newProduct.url.trim()) {
|
||||
toast.error("请输入商品链接");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreatingProduct(true);
|
||||
try {
|
||||
let websiteId = Number(newProduct.websiteId);
|
||||
|
||||
if (isNewWebsite) {
|
||||
const website = await websiteApi.create({
|
||||
name: newWebsite.name.trim(),
|
||||
url: newWebsite.url.trim(),
|
||||
category_id: Number(newProduct.categoryId),
|
||||
});
|
||||
websiteId = website.id;
|
||||
queryClient.invalidateQueries({ queryKey: ["websites"] });
|
||||
}
|
||||
|
||||
const product = await productApi.create({
|
||||
name: newProduct.name.trim(),
|
||||
description: newProduct.description.trim() || undefined,
|
||||
image: newProduct.image.trim() || undefined,
|
||||
category_id: Number(newProduct.categoryId),
|
||||
});
|
||||
await productApi.addPrice({
|
||||
product_id: product.id,
|
||||
website_id: websiteId,
|
||||
price: newProduct.price,
|
||||
original_price: newProduct.originalPrice || undefined,
|
||||
currency: newProduct.currency,
|
||||
url: newProduct.url.trim(),
|
||||
in_stock: newProduct.inStock,
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ["products", "my"] });
|
||||
setNewProduct({
|
||||
name: "",
|
||||
description: "",
|
||||
image: "",
|
||||
categoryId: "",
|
||||
websiteId: "",
|
||||
price: "",
|
||||
originalPrice: "",
|
||||
currency: "CNY",
|
||||
url: "",
|
||||
inStock: true,
|
||||
});
|
||||
setIsNewWebsite(false);
|
||||
setNewWebsite({ name: "", url: "" });
|
||||
setIsAddProductOpen(false);
|
||||
toast.success("商品已提交,等待审核");
|
||||
} catch (error: unknown) {
|
||||
const { title, description } = getErrorCopy(error, { context: "product.create" });
|
||||
toast.error(title, { description });
|
||||
} finally {
|
||||
setIsCreatingProduct(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const totalPublished = publishedBounties.length;
|
||||
const totalAccepted = acceptedBounties.length;
|
||||
const completedCount = [...publishedBounties, ...acceptedBounties]
|
||||
.filter(b => b.status === "completed").length;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Navbar />
|
||||
|
||||
{/* Content */}
|
||||
<section className="pt-24 pb-20">
|
||||
<div className="container max-w-6xl">
|
||||
{/* Profile Header */}
|
||||
<Card className="card-elegant mb-8">
|
||||
<CardContent className="py-8">
|
||||
<div className="flex flex-col md:flex-row items-center gap-6">
|
||||
<Avatar className="w-24 h-24">
|
||||
<AvatarImage src={user?.avatar || undefined} />
|
||||
<AvatarFallback className="text-2xl">{user?.name?.[0] || "U"}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="text-center md:text-left">
|
||||
<h1 className="text-2xl font-bold mb-1" style={{ fontFamily: "'Playfair Display', serif" }}>
|
||||
{user?.name || "用户"}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">{user?.email || "未设置邮箱"}</p>
|
||||
{user?.role === "admin" && (
|
||||
<Badge className="mt-2" variant="secondary">管理员</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="rounded-full">
|
||||
<MoreVertical className="w-5 h-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuItem onClick={() => navigate("/settings")}>
|
||||
账号设置
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleLogout} className="text-destructive focus:text-destructive">
|
||||
退出登录
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="grid grid-cols-3 gap-8 text-center">
|
||||
<div>
|
||||
<p className="text-3xl font-bold text-primary">{totalPublished}</p>
|
||||
<p className="text-sm text-muted-foreground">发布悬赏</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-3xl font-bold text-primary">{totalAccepted}</p>
|
||||
<p className="text-sm text-muted-foreground">接取任务</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-3xl font-bold text-primary">{completedCount}</p>
|
||||
<p className="text-sm text-muted-foreground">已完成</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue={new URLSearchParams(window.location.search).get('tab') || 'published'} className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-5 lg:w-auto lg:inline-grid">
|
||||
<TabsTrigger value="published" className="gap-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
我发布的
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="accepted" className="gap-2">
|
||||
<Trophy className="w-4 h-4" />
|
||||
我接取的
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="favorites" className="gap-2">
|
||||
<Heart className="w-4 h-4" />
|
||||
我的收藏
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="products" className="gap-2">
|
||||
<Package className="w-4 h-4" />
|
||||
我的商品
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="notifications" className="gap-2">
|
||||
<Bell className="w-4 h-4" />
|
||||
通知
|
||||
{unreadCount > 0 && (
|
||||
<Badge variant="destructive" className="ml-1 h-5 px-1.5">
|
||||
{unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Published Bounties */}
|
||||
<TabsContent value="published">
|
||||
<Card className="card-elegant">
|
||||
<CardHeader>
|
||||
<CardTitle>我发布的悬赏</CardTitle>
|
||||
<CardDescription>管理您发布的所有悬赏任务</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{publishedLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : publishedBounties.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{publishedBounties.map(bounty => (
|
||||
<Link key={bounty.id} href={`/bounties/${bounty.id}`}>
|
||||
<div className="flex items-center gap-4 p-4 rounded-lg bg-muted/50 hover:bg-muted transition-colors cursor-pointer">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-medium truncate">{bounty.title}</h3>
|
||||
<Badge className={BOUNTY_STATUS_MAP[bounty.status]?.class || "bg-muted"}>
|
||||
{BOUNTY_STATUS_MAP[bounty.status]?.label || bounty.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground line-clamp-1">
|
||||
{bounty.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-semibold text-primary">¥{bounty.reward}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(bounty.created_at), {
|
||||
addSuffix: true,
|
||||
locale: zhCN
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<FileText className="w-12 h-12 mx-auto text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground mb-4">您还没有发布过悬赏</p>
|
||||
<Link href="/bounties">
|
||||
<Button>去发布悬赏</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Accepted Bounties */}
|
||||
<TabsContent value="accepted">
|
||||
<Card className="card-elegant">
|
||||
<CardHeader>
|
||||
<CardTitle>我接取的任务</CardTitle>
|
||||
<CardDescription>查看您接取的所有悬赏任务</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{acceptedLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : acceptedBounties.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{acceptedBounties.map(bounty => (
|
||||
<Link key={bounty.id} href={`/bounties/${bounty.id}`}>
|
||||
<div className="flex items-center gap-4 p-4 rounded-lg bg-muted/50 hover:bg-muted transition-colors cursor-pointer">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-medium truncate">{bounty.title}</h3>
|
||||
<Badge className={BOUNTY_STATUS_MAP[bounty.status]?.class || "bg-muted"}>
|
||||
{BOUNTY_STATUS_MAP[bounty.status]?.label || bounty.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground line-clamp-1">
|
||||
{bounty.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-semibold text-primary">¥{bounty.reward}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(bounty.created_at), {
|
||||
addSuffix: true,
|
||||
locale: zhCN
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<Trophy className="w-12 h-12 mx-auto text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground mb-4">您还没有接取过任务</p>
|
||||
<Link href="/bounties">
|
||||
<Button>去接取任务</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Favorites */}
|
||||
<TabsContent value="favorites">
|
||||
<Card className="card-elegant">
|
||||
<CardHeader>
|
||||
<CardTitle>我的收藏</CardTitle>
|
||||
<CardDescription>查看您收藏的商品列表</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{favoritesLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : favorites.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{favorites.map(favorite => (
|
||||
<Link key={favorite.id} href={`/products/${favorite.product_id}`}>
|
||||
<div className="flex items-center gap-4 p-4 rounded-lg bg-muted/50 hover:bg-muted transition-colors cursor-pointer">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-medium truncate">{favorite.product_name || "未命名商品"}</h3>
|
||||
{favorite.website_name && (
|
||||
<Badge variant="secondary">{favorite.website_name}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
收藏于 {formatDistanceToNow(new Date(favorite.created_at), { addSuffix: true, locale: zhCN })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<Heart className="w-12 h-12 mx-auto text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground mb-4">您还没有收藏过商品</p>
|
||||
<Link href="/products">
|
||||
<Button>去看看商品</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* My Products */}
|
||||
<TabsContent value="products">
|
||||
<Card className="card-elegant">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>我的商品</CardTitle>
|
||||
<CardDescription>管理您提交的商品</CardDescription>
|
||||
</div>
|
||||
<Dialog open={isAddProductOpen} onOpenChange={setIsAddProductOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
发布商品
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>发布新商品</DialogTitle>
|
||||
<DialogDescription>填写商品信息,提交后需等待管理员审核</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="product-name">商品名称 *</Label>
|
||||
<Input
|
||||
id="product-name"
|
||||
value={newProduct.name}
|
||||
onChange={(e) => setNewProduct((prev) => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="输入商品名称"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="product-desc">商品描述</Label>
|
||||
<Input
|
||||
id="product-desc"
|
||||
value={newProduct.description}
|
||||
onChange={(e) => setNewProduct((prev) => ({ ...prev, description: e.target.value }))}
|
||||
placeholder="输入商品描述"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="product-image">商品图片URL</Label>
|
||||
<Input
|
||||
id="product-image"
|
||||
value={newProduct.image}
|
||||
onChange={(e) => setNewProduct((prev) => ({ ...prev, image: e.target.value }))}
|
||||
placeholder="输入图片URL"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>分类 *</Label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsNewCategory(!isNewCategory);
|
||||
if (!isNewCategory) {
|
||||
setNewProduct((prev) => ({ ...prev, categoryId: "" }));
|
||||
} else {
|
||||
setNewCategory({ name: "", slug: "", description: "" });
|
||||
}
|
||||
}}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
{isNewCategory ? "选择已有分类" : "+ 添加新分类"}
|
||||
</button>
|
||||
</div>
|
||||
{isNewCategory ? (
|
||||
<div className="space-y-2 p-3 border rounded-lg bg-muted/50">
|
||||
<Input
|
||||
value={newCategory.name}
|
||||
onChange={(e) => setNewCategory((prev) => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="分类名称 *"
|
||||
/>
|
||||
<Input
|
||||
value={newCategory.slug}
|
||||
onChange={(e) => setNewCategory((prev) => ({ ...prev, slug: e.target.value }))}
|
||||
placeholder="分类标识 (可选,留空自动生成)"
|
||||
/>
|
||||
<Input
|
||||
value={newCategory.description}
|
||||
onChange={(e) => setNewCategory((prev) => ({ ...prev, description: e.target.value }))}
|
||||
placeholder="分类描述 (可选)"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleCreateCategory}
|
||||
disabled={isCreatingCategory}
|
||||
>
|
||||
{isCreatingCategory ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
创建中...
|
||||
</>
|
||||
) : (
|
||||
"创建分类"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<select
|
||||
value={newProduct.categoryId}
|
||||
onChange={(e) => setNewProduct((prev) => ({ ...prev, categoryId: e.target.value }))}
|
||||
className="px-3 py-2 border rounded-lg bg-background text-foreground text-sm"
|
||||
>
|
||||
<option value="">请选择分类</option>
|
||||
{categories.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{categories.length === 0 && !categoriesLoading && (
|
||||
<p className="text-xs text-muted-foreground">暂无分类,请先创建</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>网站 *</Label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsNewWebsite(!isNewWebsite)}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
{isNewWebsite ? "选择已有网站" : "+ 添加新网站"}
|
||||
</button>
|
||||
</div>
|
||||
{isNewWebsite ? (
|
||||
<div className="space-y-2 p-3 border rounded-lg bg-muted/50">
|
||||
<Input
|
||||
value={newWebsite.name}
|
||||
onChange={(e) => setNewWebsite((prev) => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="网站名称 *"
|
||||
/>
|
||||
<Input
|
||||
value={newWebsite.url}
|
||||
onChange={(e) => setNewWebsite((prev) => ({ ...prev, url: e.target.value }))}
|
||||
placeholder="网站URL *"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={newProduct.websiteId}
|
||||
onChange={(e) => setNewProduct((prev) => ({ ...prev, websiteId: e.target.value }))}
|
||||
className="px-3 py-2 border rounded-lg bg-background text-foreground text-sm"
|
||||
disabled={!newProduct.categoryId}
|
||||
>
|
||||
<option value="">请先选择分类后选择网站</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="product-price">价格 *</Label>
|
||||
<Input
|
||||
id="product-price"
|
||||
type="number"
|
||||
value={newProduct.price}
|
||||
onChange={(e) => setNewProduct((prev) => ({ ...prev, price: e.target.value }))}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="product-original-price">原价</Label>
|
||||
<Input
|
||||
id="product-original-price"
|
||||
type="number"
|
||||
value={newProduct.originalPrice}
|
||||
onChange={(e) => setNewProduct((prev) => ({ ...prev, originalPrice: e.target.value }))}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="product-url">商品链接 *</Label>
|
||||
<Input
|
||||
id="product-url"
|
||||
value={newProduct.url}
|
||||
onChange={(e) => setNewProduct((prev) => ({ ...prev, url: e.target.value }))}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="product-in-stock"
|
||||
checked={newProduct.inStock}
|
||||
onCheckedChange={(checked) =>
|
||||
setNewProduct((prev) => ({ ...prev, inStock: checked === true }))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="product-in-stock">有货</Label>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsAddProductOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleCreateProduct} disabled={isCreatingProduct}>
|
||||
{isCreatingProduct ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
提交中...
|
||||
</>
|
||||
) : (
|
||||
"提交审核"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{myProductsLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : myProducts.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{myProducts.map((product) => {
|
||||
const statusInfo = PRODUCT_STATUS_MAP[product.status] || PRODUCT_STATUS_MAP.pending;
|
||||
const StatusIcon = statusInfo.icon;
|
||||
return (
|
||||
<div
|
||||
key={product.id}
|
||||
className="flex items-center gap-4 p-4 rounded-lg bg-muted/50"
|
||||
>
|
||||
{product.image && (
|
||||
<img
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
className="w-16 h-16 rounded object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-medium truncate">{product.name}</h3>
|
||||
<Badge variant={statusInfo.variant} className="gap-1">
|
||||
<StatusIcon className="w-3 h-3" />
|
||||
{statusInfo.label}
|
||||
</Badge>
|
||||
</div>
|
||||
{product.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-1 mb-1">
|
||||
{product.description}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
提交于 {formatDistanceToNow(new Date(product.created_at), { addSuffix: true, locale: zhCN })}
|
||||
</p>
|
||||
{product.status === "rejected" && product.reject_reason && (
|
||||
<div className="mt-2 p-2 bg-destructive/10 rounded text-sm text-destructive">
|
||||
<AlertCircle className="w-4 h-4 inline mr-1" />
|
||||
拒绝原因: {product.reject_reason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{product.status === "approved" && (
|
||||
<Link href={`/products/${product.id}`}>
|
||||
<Button variant="outline" size="sm">
|
||||
查看
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<Package className="w-12 h-12 mx-auto text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground mb-4">您还没有提交过商品</p>
|
||||
<Button onClick={() => setIsAddProductOpen(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
发布第一个商品
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Notifications */}
|
||||
<TabsContent value="notifications">
|
||||
<Card className="card-elegant">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>通知</CardTitle>
|
||||
<CardDescription>查看您的所有通知消息</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleExportNotifications}>
|
||||
导出通知
|
||||
</Button>
|
||||
{notifications.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => markAllAsReadMutation.mutate(undefined, {
|
||||
onSuccess: () => {
|
||||
toast.success("已全部标记为已读");
|
||||
refetchNotifications();
|
||||
},
|
||||
})}
|
||||
disabled={markAllAsReadMutation.isPending}
|
||||
>
|
||||
{markAllAsReadMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
"全部已读"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{notificationPreferences && (
|
||||
<div className="mb-6 p-4 rounded-lg border">
|
||||
<div className="text-sm font-medium mb-3">通知偏好</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">悬赏通知</span>
|
||||
<Switch
|
||||
checked={notificationPreferences.enable_bounty}
|
||||
onCheckedChange={(checked) =>
|
||||
updatePreferencesMutation.mutate({ enable_bounty: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">价格提醒</span>
|
||||
<Switch
|
||||
checked={notificationPreferences.enable_price_alert}
|
||||
onCheckedChange={(checked) =>
|
||||
updatePreferencesMutation.mutate({ enable_price_alert: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">系统通知</span>
|
||||
<Switch
|
||||
checked={notificationPreferences.enable_system}
|
||||
onCheckedChange={(checked) =>
|
||||
updatePreferencesMutation.mutate({ enable_system: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{notificationsLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : notifications.length > 0 ? (
|
||||
<ScrollArea className="h-[400px]">
|
||||
<div className="space-y-3">
|
||||
{notifications.map(notification => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className={`flex items-start gap-3 p-4 rounded-lg transition-colors cursor-pointer ${
|
||||
notification.is_read ? "bg-muted/30" : "bg-muted/50 border-l-4 border-primary"
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (!notification.is_read) {
|
||||
markAsReadMutation.mutate(notification.id, {
|
||||
onSuccess: () => refetchNotifications(),
|
||||
});
|
||||
}
|
||||
if (notification.related_type === "bounty" && notification.related_id) {
|
||||
navigate(`/bounties/${notification.related_id}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
||||
notification.type === "bounty_completed" ? "bg-purple-100 dark:bg-purple-900/30" :
|
||||
notification.type === "bounty_accepted" ? "bg-emerald-100 dark:bg-emerald-900/30" :
|
||||
notification.type === "new_comment" ? "bg-blue-100 dark:bg-blue-900/30" :
|
||||
"bg-muted"
|
||||
}`}>
|
||||
{notification.type === "bounty_completed" ? (
|
||||
<CheckCircle className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||
) : notification.type === "bounty_accepted" ? (
|
||||
<Trophy className="w-5 h-5 text-emerald-600 dark:text-emerald-400" />
|
||||
) : notification.type === "new_comment" ? (
|
||||
<Bell className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||
) : (
|
||||
<Bell className="w-5 h-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium">{notification.title}</h4>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{notification.content}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{formatDistanceToNow(new Date(notification.created_at), {
|
||||
addSuffix: true,
|
||||
locale: zhCN
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
{!notification.is_read && (
|
||||
<div className="w-2 h-2 rounded-full bg-primary" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<Bell className="w-12 h-12 mx-auto text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground">暂无通知</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,15 +5,18 @@ import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
|
||||
import { Line, LineChart, XAxis, YAxis, CartesianGrid } from "recharts";
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
import DashboardLayout from "@/components/DashboardLayout";
|
||||
import { useFavorites, useFavoriteTags, useRemoveFavorite, useCreateFavoriteTag, useUpdateFavoriteTag, useDeleteFavoriteTag, usePriceMonitor, usePriceHistory, useCreatePriceMonitor, useUpdatePriceMonitor, useRefreshPriceMonitor } from "@/hooks/useApi";
|
||||
import { useDebounce } from "@/hooks/useDebounce";
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { favoriteApi } from "@/lib/api";
|
||||
import { toast } from "sonner";
|
||||
import { getErrorCopy } from "@/lib/i18n/errorMessages";
|
||||
import {
|
||||
Heart,
|
||||
Trash2,
|
||||
@@ -84,8 +87,9 @@ export default function Favorites() {
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} catch (error: unknown) {
|
||||
const { title, description } = getErrorCopy(error, { context: "favorite.export" });
|
||||
toast.error(title, { description });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -113,15 +117,26 @@ export default function Favorites() {
|
||||
});
|
||||
}, [favorites, debouncedSearchQuery]);
|
||||
|
||||
// State for bulk delete confirmation
|
||||
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// Handle bulk delete
|
||||
const handleBulkDelete = () => {
|
||||
const handleBulkDelete = async () => {
|
||||
if (selectedFavorites.size === 0) return;
|
||||
selectedFavorites.forEach((id) => {
|
||||
removeFavoriteMutation.mutate(id, {
|
||||
onSuccess: () => refetchFavorites(),
|
||||
});
|
||||
});
|
||||
setSelectedFavorites(new Set());
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const ids = Array.from(selectedFavorites);
|
||||
await Promise.all(ids.map((id) => removeFavoriteMutation.mutateAsync(id)));
|
||||
setSelectedFavorites(new Set());
|
||||
toast.success(`成功删除 ${ids.length} 件商品`);
|
||||
setBulkDeleteOpen(false);
|
||||
} catch (error: unknown) {
|
||||
const { title, description } = getErrorCopy(error, { context: "favorite.remove" });
|
||||
toast.error(title, { description });
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle create tag
|
||||
@@ -132,10 +147,15 @@ export default function Favorites() {
|
||||
color: newTagColor,
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success("标签创建成功");
|
||||
setNewTagName("");
|
||||
setNewTagColor("#6366f1");
|
||||
refetchTags();
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const { title, description } = getErrorCopy(error, { context: "favorite.tag" });
|
||||
toast.error(title, { description });
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -151,10 +171,15 @@ export default function Favorites() {
|
||||
},
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success("标签更新成功");
|
||||
setEditingTag(null);
|
||||
setEditTagName("");
|
||||
refetchTags();
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const { title, description } = getErrorCopy(error, { context: "favorite.tag" });
|
||||
toast.error(title, { description });
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -163,11 +188,16 @@ export default function Favorites() {
|
||||
const handleDeleteTag = (id: number) => {
|
||||
deleteTagMutation.mutate(id, {
|
||||
onSuccess: () => {
|
||||
toast.success("标签已删除");
|
||||
if (selectedTag === id) {
|
||||
setSelectedTag(null);
|
||||
}
|
||||
refetchTags();
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const { title, description } = getErrorCopy(error, { context: "favorite.tag" });
|
||||
toast.error(title, { description });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -182,8 +212,13 @@ export default function Favorites() {
|
||||
const mutation = monitorData ? updateMonitorMutation : createMonitorMutation;
|
||||
mutation.mutate({ favoriteId: monitorFavoriteId, data: payload }, {
|
||||
onSuccess: () => {
|
||||
toast.success("监控设置已保存");
|
||||
refetchFavorites();
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const { title, description } = getErrorCopy(error, { context: "favorite.monitor" });
|
||||
toast.error(title, { description });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -216,11 +251,9 @@ export default function Favorites() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Navbar />
|
||||
|
||||
<DashboardLayout>
|
||||
{/* Header */}
|
||||
<section className="pt-24 border-b border-border py-8">
|
||||
<section className="border-b border-border py-8">
|
||||
<div className="container">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center">
|
||||
@@ -453,14 +486,32 @@ export default function Favorites() {
|
||||
已选择 {selectedFavorites.size} 件商品
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleBulkDelete}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
批量删除
|
||||
</Button>
|
||||
<AlertDialog open={bulkDeleteOpen} onOpenChange={setBulkDeleteOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" size="sm">
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
批量删除
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除选中的 {selectedFavorites.size} 件商品吗?此操作无法撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleBulkDelete}
|
||||
disabled={isDeleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isDeleting ? "删除中..." : "确认删除"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -649,6 +700,6 @@ export default function Favorites() {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Link } from "wouter";
|
||||
163
frontend/src/features/products/components/ProductsHeader.tsx
Normal file
163
frontend/src/features/products/components/ProductsHeader.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Search, Grid3X3, List, Filter } from "lucide-react";
|
||||
|
||||
type Category = {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type ProductsHeaderProps = {
|
||||
searchQuery: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
isSearchMode: boolean;
|
||||
showPriceFilter: boolean;
|
||||
setShowPriceFilter: (value: boolean) => void;
|
||||
priceRange: [number, number];
|
||||
setPriceRange: (range: [number, number]) => void;
|
||||
sortBy: "newest" | "oldest" | "price_asc" | "price_desc";
|
||||
setSortBy: (value: "newest" | "oldest" | "price_asc" | "price_desc") => void;
|
||||
viewMode: "grid" | "list";
|
||||
setViewMode: (value: "grid" | "list") => void;
|
||||
categories: Category[];
|
||||
selectedCategory: string;
|
||||
setSelectedCategory: (value: string) => void;
|
||||
};
|
||||
|
||||
export default function ProductsHeader({
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
isSearchMode,
|
||||
showPriceFilter,
|
||||
setShowPriceFilter,
|
||||
priceRange,
|
||||
setPriceRange,
|
||||
sortBy,
|
||||
setSortBy,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
categories,
|
||||
selectedCategory,
|
||||
setSelectedCategory,
|
||||
}: ProductsHeaderProps) {
|
||||
return (
|
||||
<>
|
||||
<section className="pt-24 pb-8">
|
||||
<div className="container">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2" style={{ fontFamily: "'Playfair Display', serif" }}>
|
||||
商品导航
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
发现优质购物网站,比较商品价格
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索商品或网站..."
|
||||
className="pl-10 w-full"
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isSearchMode && (
|
||||
<Popover open={showPriceFilter} onOpenChange={setShowPriceFilter}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Filter className="w-4 h-4" />
|
||||
价格筛选
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>价格范围:¥{priceRange[0]} - ¥{priceRange[1]}</Label>
|
||||
<Slider
|
||||
value={priceRange}
|
||||
onValueChange={setPriceRange}
|
||||
max={10000}
|
||||
min={0}
|
||||
step={100}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => setShowPriceFilter(false)}
|
||||
>
|
||||
应用筛选
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as ProductsHeaderProps["sortBy"])}
|
||||
className="px-3 py-2 border rounded-lg bg-background text-foreground text-sm"
|
||||
>
|
||||
<option value="newest">最新发布</option>
|
||||
<option value="oldest">最早发布</option>
|
||||
{isSearchMode && (
|
||||
<>
|
||||
<option value="price_asc">价格:低到高</option>
|
||||
<option value="price_desc">价格:高到低</option>
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
|
||||
<div className="flex items-center border rounded-lg p-1">
|
||||
<Button
|
||||
variant={viewMode === "grid" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setViewMode("grid")}
|
||||
>
|
||||
<Grid3X3 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === "list" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setViewMode("list")}
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Tabs */}
|
||||
<Tabs value={selectedCategory} onValueChange={setSelectedCategory} className="mb-8">
|
||||
<TabsList className="flex-wrap h-auto gap-2 bg-transparent p-0">
|
||||
<TabsTrigger
|
||||
value="all"
|
||||
className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground rounded-full px-4"
|
||||
>
|
||||
全部
|
||||
</TabsTrigger>
|
||||
{categories.map(category => (
|
||||
<TabsTrigger
|
||||
key={category.id}
|
||||
value={category.id.toString()}
|
||||
className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground rounded-full px-4"
|
||||
>
|
||||
{category.name}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ShoppingBag, Heart } from "lucide-react";
|
||||
import { Link } from "wouter";
|
||||
|
||||
type Product = {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
image: string | null;
|
||||
};
|
||||
|
||||
type RecommendedProductsProps = {
|
||||
products: Product[];
|
||||
};
|
||||
|
||||
export default function RecommendedProducts({ products }: RecommendedProductsProps) {
|
||||
if (products.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-12">
|
||||
<h2 className="text-xl font-semibold mb-6 flex items-center gap-2">
|
||||
<Heart className="w-5 h-5 text-rose-500" />
|
||||
为你推荐
|
||||
<Badge variant="secondary" className="ml-2">{products.length}</Badge>
|
||||
</h2>
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{products.map((product) => (
|
||||
<Link key={product.id} href={`/products/${product.id}`}>
|
||||
<Card className="card-elegant group cursor-pointer">
|
||||
<CardHeader>
|
||||
<div className="w-full aspect-square rounded-xl bg-muted flex items-center justify-center overflow-hidden">
|
||||
{product.image ? (
|
||||
<img
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<ShoppingBag className="w-8 h-8 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<CardTitle className="text-base line-clamp-2 mt-3">{product.name}</CardTitle>
|
||||
<CardDescription className="line-clamp-2">
|
||||
{product.description || "点击查看价格对比"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
frontend/src/features/products/components/WebsitesSection.tsx
Normal file
102
frontend/src/features/products/components/WebsitesSection.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ExternalLink, ShoppingBag, CheckCircle } from "lucide-react";
|
||||
|
||||
type Website = {
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
logo: string | null;
|
||||
description: string | null;
|
||||
rating: string;
|
||||
is_verified: boolean;
|
||||
};
|
||||
|
||||
type WebsitesSectionProps = {
|
||||
websites: Website[];
|
||||
viewMode: "grid" | "list";
|
||||
};
|
||||
|
||||
export default function WebsitesSection({ websites, viewMode }: WebsitesSectionProps) {
|
||||
return (
|
||||
<div className="mb-12">
|
||||
<h2 className="text-xl font-semibold mb-6 flex items-center gap-2">
|
||||
<ShoppingBag className="w-5 h-5 text-primary" />
|
||||
购物网站
|
||||
<Badge variant="secondary" className="ml-2">{websites.length}</Badge>
|
||||
</h2>
|
||||
|
||||
{websites.length === 0 ? (
|
||||
<Card className="card-elegant">
|
||||
<CardContent className="py-12 text-center">
|
||||
<ShoppingBag className="w-12 h-12 mx-auto text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground">暂无网站数据</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className={viewMode === "grid"
|
||||
? "grid sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"
|
||||
: "space-y-3"
|
||||
}>
|
||||
{websites.map(website => (
|
||||
<Card key={website.id} className="card-elegant group">
|
||||
<CardHeader className={viewMode === "list" ? "flex-row items-center gap-4 space-y-0" : ""}>
|
||||
<div className={`${viewMode === "list" ? "w-12 h-12" : "w-14 h-14"} rounded-xl bg-muted flex items-center justify-center overflow-hidden`}>
|
||||
{website.logo ? (
|
||||
<img
|
||||
src={website.logo}
|
||||
alt={website.name}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<ShoppingBag className="w-6 h-6 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-base">{website.name}</CardTitle>
|
||||
{website.is_verified && (
|
||||
<CheckCircle className="w-4 h-4 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<CardDescription className="line-clamp-2 mt-1">
|
||||
{website.description || "暂无描述"}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{viewMode === "list" && (
|
||||
<a href={website.url} target="_blank" rel="noopener noreferrer">
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
访问
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
</CardHeader>
|
||||
{viewMode === "grid" && (
|
||||
<CardContent className="pt-0">
|
||||
<div className="flex items-center justify-between">
|
||||
{website.rating && parseFloat(website.rating) > 0 && (
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
<span className="text-amber-500">★</span>
|
||||
<span>{website.rating}</span>
|
||||
</div>
|
||||
)}
|
||||
<a href={website.url} target="_blank" rel="noopener noreferrer">
|
||||
<Button variant="ghost" size="sm" className="gap-1 text-primary">
|
||||
访问
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,19 +4,20 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { useFavorites, useAllPriceMonitors, useAddFavorite, useRemoveFavorite } from "@/hooks/useApi";
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { ArrowLeft, Trash2, Download, TrendingDown, Heart, Share2, Copy, Check } from "lucide-react";
|
||||
import { QRCodeSVG as QRCode } from "qrcode.react";
|
||||
import { Link } from "wouter";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts";
|
||||
import { toast } from "sonner";
|
||||
import { getErrorCopy } from "@/lib/i18n/errorMessages";
|
||||
|
||||
export default function ProductComparison() {
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
const [selectedProducts, setSelectedProducts] = useState<Set<number>>(new Set());
|
||||
const [comparisonMode, setComparisonMode] = useState(false);
|
||||
const [favoriteIds, setFavoriteIds] = useState<Set<number>>(new Set());
|
||||
const [favoriteKeys, setFavoriteKeys] = useState<Set<string>>(new Set());
|
||||
const [showShareDialog, setShowShareDialog] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const qrCodeRef = useRef<HTMLDivElement>(null);
|
||||
@@ -35,7 +36,7 @@ export default function ProductComparison() {
|
||||
// Initialize favorite IDs when favorites load
|
||||
useEffect(() => {
|
||||
if (favorites.length > 0) {
|
||||
setFavoriteIds(new Set(favorites.map(f => f.product_id)));
|
||||
setFavoriteKeys(new Set(favorites.map(f => `${f.product_id}-${f.website_id}`)));
|
||||
}
|
||||
}, [favorites]);
|
||||
|
||||
@@ -64,9 +65,10 @@ export default function ProductComparison() {
|
||||
};
|
||||
|
||||
const handleToggleFavorite = (productId: number, websiteId: number) => {
|
||||
if (favoriteIds.has(productId)) {
|
||||
const key = `${productId}-${websiteId}`;
|
||||
if (favoriteKeys.has(key)) {
|
||||
// Find the favorite ID to remove
|
||||
const fav = favorites.find(f => f.product_id === productId);
|
||||
const fav = favorites.find(f => f.product_id === productId && f.website_id === websiteId);
|
||||
if (fav) {
|
||||
removeFavoriteMutation.mutate(fav.id, {
|
||||
onSuccess: () => {
|
||||
@@ -75,17 +77,18 @@ export default function ProductComparison() {
|
||||
duration: 2000,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("取消收藏失败", {
|
||||
description: "请稍后重试",
|
||||
onError: (error: unknown) => {
|
||||
const { title, description } = getErrorCopy(error, { context: "favorite.remove" });
|
||||
toast.error(title, {
|
||||
description,
|
||||
duration: 2000,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
setFavoriteIds(prev => {
|
||||
setFavoriteKeys(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(productId);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
@@ -96,14 +99,15 @@ export default function ProductComparison() {
|
||||
duration: 2000,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("收藏失败", {
|
||||
description: "请稍后重试",
|
||||
onError: (error: unknown) => {
|
||||
const { title, description } = getErrorCopy(error, { context: "favorite.add" });
|
||||
toast.error(title, {
|
||||
description,
|
||||
duration: 2000,
|
||||
});
|
||||
},
|
||||
});
|
||||
setFavoriteIds(prev => new Set(prev).add(productId));
|
||||
setFavoriteKeys(prev => new Set(prev).add(key));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -149,10 +153,11 @@ export default function ProductComparison() {
|
||||
let failureCount = 0;
|
||||
|
||||
for (const product of comparisonProducts) {
|
||||
if (!favoriteIds.has(product.product_id)) {
|
||||
const key = `${product.product_id}-${product.website_id}`;
|
||||
if (!favoriteKeys.has(key)) {
|
||||
try {
|
||||
await addFavoriteMutation.mutateAsync({ product_id: product.product_id, website_id: product.website_id });
|
||||
setFavoriteIds(prev => new Set(prev).add(product.product_id));
|
||||
setFavoriteKeys(prev => new Set(prev).add(key));
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
failureCount++;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { Link, useParams } from "wouter";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { getErrorCopy } from "@/lib/i18n/errorMessages";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Sparkles,
|
||||
@@ -58,9 +59,24 @@ export default function ProductDetail() {
|
||||
const [monitorNotifyEnabled, setMonitorNotifyEnabled] = useState(true);
|
||||
const [monitorNotifyOnTarget, setMonitorNotifyOnTarget] = useState(true);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [selectedWebsiteId, setSelectedWebsiteId] = useState(0);
|
||||
|
||||
const { data: product, isLoading, error } = useProductWithPrices(productId);
|
||||
const { data: favoriteCheck } = useCheckFavorite(productId, product?.prices?.[0]?.website_id || 0);
|
||||
const lowestPrice = useMemo(() => {
|
||||
if (!product?.prices?.length) return null;
|
||||
return product.prices.reduce((min, current) =>
|
||||
Number(current.price) < Number(min.price) ? current : min
|
||||
);
|
||||
}, [product?.prices]);
|
||||
const sortedPrices = useMemo(() => {
|
||||
if (!product?.prices?.length) return [];
|
||||
return [...product.prices].sort((a, b) => Number(a.price) - Number(b.price));
|
||||
}, [product?.prices]);
|
||||
useEffect(() => {
|
||||
if (selectedWebsiteId || !lowestPrice) return;
|
||||
setSelectedWebsiteId(lowestPrice.website_id);
|
||||
}, [lowestPrice, selectedWebsiteId]);
|
||||
const { data: favoriteCheck } = useCheckFavorite(productId, selectedWebsiteId);
|
||||
const { data: monitorData } = usePriceMonitor(favoriteCheck?.favorite_id || 0);
|
||||
const { data: priceHistoryData } = usePriceHistory(favoriteCheck?.favorite_id || 0);
|
||||
const { data: recommendedProducts } = useRecommendedProducts(4);
|
||||
@@ -102,7 +118,7 @@ export default function ProductDetail() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!product?.prices?.[0]) {
|
||||
if (!selectedWebsiteId) {
|
||||
toast.error("该商品暂无价格信息");
|
||||
return;
|
||||
}
|
||||
@@ -114,16 +130,24 @@ export default function ProductDetail() {
|
||||
queryClient.invalidateQueries({ queryKey: ["favorites"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["favorites", "check"] });
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const { title, description } = getErrorCopy(error, { context: "favorite.remove" });
|
||||
toast.error(title, { description });
|
||||
},
|
||||
});
|
||||
} else {
|
||||
addFavoriteMutation.mutate(
|
||||
{ product_id: productId, website_id: product.prices[0].website_id },
|
||||
{ product_id: productId, website_id: selectedWebsiteId },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("已添加到收藏");
|
||||
queryClient.invalidateQueries({ queryKey: ["favorites"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["favorites", "check"] });
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const { title, description } = getErrorCopy(error, { context: "favorite.add" });
|
||||
toast.error(title, { description });
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -149,6 +173,10 @@ export default function ProductDetail() {
|
||||
setIsMonitorOpen(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["favorites", favoriteId, "monitor"] });
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const { title, description } = getErrorCopy(error, { context: "product.update" });
|
||||
toast.error(title, { description });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -169,14 +197,14 @@ export default function ProductDetail() {
|
||||
queryClient.invalidateQueries({ queryKey: ["products", productId, "prices"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["favorites", favoriteId, "monitor"] });
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const { title, description } = getErrorCopy(error, { context: "product.update" });
|
||||
toast.error(title, { description });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 找到最低价和最高价
|
||||
const lowestPrice = product?.prices?.reduce((min, p) =>
|
||||
Number(p.price) < Number(min.price) ? p : min
|
||||
, product.prices[0]);
|
||||
|
||||
const highestPrice = product?.prices?.reduce((max, p) =>
|
||||
Number(p.price) > Number(max.price) ? p : max
|
||||
, product.prices?.[0]);
|
||||
@@ -363,9 +391,7 @@ export default function ProductDetail() {
|
||||
<CardContent>
|
||||
{product.prices && product.prices.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{product.prices
|
||||
.sort((a, b) => Number(a.price) - Number(b.price))
|
||||
.map((price) => (
|
||||
{sortedPrices.map((price) => (
|
||||
<div
|
||||
key={price.id}
|
||||
className={`flex items-center justify-between p-4 rounded-lg border transition-colors ${
|
||||
@@ -394,6 +420,11 @@ export default function ProductDetail() {
|
||||
最低价
|
||||
</Badge>
|
||||
)}
|
||||
{selectedWebsiteId === price.website_id && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
当前收藏网站
|
||||
</Badge>
|
||||
)}
|
||||
{!price.in_stock && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
缺货
|
||||
@@ -429,6 +460,13 @@ export default function ProductDetail() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant={selectedWebsiteId === price.website_id ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedWebsiteId(price.website_id)}
|
||||
>
|
||||
{selectedWebsiteId === price.website_id ? "已选择" : "设为收藏网站"}
|
||||
</Button>
|
||||
<a
|
||||
href={price.url}
|
||||
target="_blank"
|
||||
@@ -497,6 +535,22 @@ export default function ProductDetail() {
|
||||
<CardTitle className="text-lg">操作</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{product.prices && product.prices.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>收藏网站</Label>
|
||||
<select
|
||||
value={selectedWebsiteId}
|
||||
onChange={(e) => setSelectedWebsiteId(Number(e.target.value))}
|
||||
className="w-full px-3 py-2 border rounded-lg bg-background text-foreground text-sm"
|
||||
>
|
||||
{sortedPrices.map((price) => (
|
||||
<option key={price.id} value={price.website_id}>
|
||||
{price.website_name || "未知网站"} - ¥{price.price}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{isAuthenticated && favoriteId && (
|
||||
<Dialog open={isMonitorOpen} onOpenChange={setIsMonitorOpen}>
|
||||
<DialogTrigger asChild>
|
||||
732
frontend/src/features/products/pages/Products.tsx
Normal file
732
frontend/src/features/products/pages/Products.tsx
Normal file
@@ -0,0 +1,732 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
import { useCategories, useWebsites, useProducts, useFavorites, useAddFavorite, useRemoveFavorite, useRecommendedProducts, useProductSearch } from "@/hooks/useApi";
|
||||
import { useDebounce } from "@/hooks/useDebounce";
|
||||
import { categoryApi, productApi, websiteApi, type Product, type ProductWithPrices } from "@/lib/api";
|
||||
import { Link } from "wouter";
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import {
|
||||
ShoppingBag,
|
||||
ArrowUpDown,
|
||||
Loader2,
|
||||
Heart,
|
||||
Sparkles
|
||||
} from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { getErrorCopy } from "@/lib/i18n/errorMessages";
|
||||
import { MobileNav } from "@/components/MobileNav";
|
||||
import { ProductListSkeleton } from "@/components/ProductCardSkeleton";
|
||||
import { LazyImage } from "@/components/LazyImage";
|
||||
import ProductsHeader from "@/features/products/components/ProductsHeader";
|
||||
import WebsitesSection from "@/features/products/components/WebsitesSection";
|
||||
import RecommendedProducts from "@/features/products/components/RecommendedProducts";
|
||||
|
||||
export default function Products() {
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>("all");
|
||||
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
||||
const [sortBy, setSortBy] = useState<'newest' | 'oldest' | 'price_asc' | 'price_desc'>('newest');
|
||||
const [priceRange, setPriceRange] = useState<[number, number]>([0, 10000]);
|
||||
const [showPriceFilter, setShowPriceFilter] = useState(false);
|
||||
const [favorites, setFavorites] = useState<Set<string>>(new Set());
|
||||
const [favoriteWebsiteByProduct, setFavoriteWebsiteByProduct] = useState<Record<number, number>>({});
|
||||
const [favoriteDialogOpen, setFavoriteDialogOpen] = useState(false);
|
||||
const [favoriteDialogProduct, setFavoriteDialogProduct] = useState<ProductWithPrices | null>(null);
|
||||
const [isAddOpen, setIsAddOpen] = useState(false);
|
||||
const [isNewCategory, setIsNewCategory] = useState(false);
|
||||
const [isCreatingCategory, setIsCreatingCategory] = useState(false);
|
||||
const [newProduct, setNewProduct] = useState({
|
||||
name: "",
|
||||
description: "",
|
||||
image: "",
|
||||
categoryId: "",
|
||||
websiteId: "",
|
||||
price: "",
|
||||
originalPrice: "",
|
||||
currency: "CNY",
|
||||
url: "",
|
||||
inStock: true,
|
||||
});
|
||||
const [isNewWebsite, setIsNewWebsite] = useState(false);
|
||||
const [newWebsite, setNewWebsite] = useState({
|
||||
name: "",
|
||||
url: "",
|
||||
});
|
||||
const [newCategory, setNewCategory] = useState({
|
||||
name: "",
|
||||
slug: "",
|
||||
description: "",
|
||||
});
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300);
|
||||
|
||||
const { data: categoriesData, isLoading: categoriesLoading } = useCategories();
|
||||
const websiteParams = selectedCategory !== "all"
|
||||
? { category_id: Number(selectedCategory) }
|
||||
: undefined;
|
||||
const { data: websitesData, isLoading: websitesLoading } = useWebsites(websiteParams);
|
||||
const productsParams = selectedCategory !== "all"
|
||||
? { category_id: Number(selectedCategory), sort_by: sortBy }
|
||||
: { sort_by: sortBy };
|
||||
const searchParams = {
|
||||
...(selectedCategory !== "all" ? { category_id: Number(selectedCategory) } : {}),
|
||||
min_price: priceRange[0],
|
||||
max_price: priceRange[1],
|
||||
sort_by: sortBy,
|
||||
};
|
||||
const { data: productsData, isLoading: productsLoading } = useProducts(productsParams);
|
||||
const { data: searchResultsData, isLoading: searchLoading } = useProductSearch(debouncedSearchQuery, searchParams);
|
||||
const { data: recommendedData } = useRecommendedProducts(8);
|
||||
const { data: favoritesData } = useFavorites();
|
||||
|
||||
const categories = categoriesData || [];
|
||||
const websites = websitesData?.items || [];
|
||||
const products = productsData?.items || [];
|
||||
const searchProducts = searchResultsData?.items || [];
|
||||
const recommendedProducts = recommendedData || [];
|
||||
|
||||
// Use search results if searching, otherwise use regular products
|
||||
const isSearchMode = debouncedSearchQuery.trim().length > 0;
|
||||
const allProducts: Array<Product | ProductWithPrices> = isSearchMode ? searchProducts : products;
|
||||
|
||||
const addFavoriteMutation = useAddFavorite();
|
||||
const removeFavoriteMutation = useRemoveFavorite();
|
||||
|
||||
// Load favorites
|
||||
useEffect(() => {
|
||||
if (favoritesData?.items) {
|
||||
const keys = new Set<string>(favoritesData.items.map((f) => `${f.product_id}-${f.website_id}`));
|
||||
setFavorites(keys);
|
||||
}
|
||||
}, [favoritesData]);
|
||||
|
||||
const filteredWebsites = useMemo(() => {
|
||||
let filtered = websites;
|
||||
|
||||
const query = debouncedSearchQuery.trim().toLowerCase();
|
||||
if (query) {
|
||||
filtered = filtered.filter(w =>
|
||||
w.name.toLowerCase().includes(query) ||
|
||||
w.description?.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [websites, selectedCategory, debouncedSearchQuery]);
|
||||
|
||||
const favoriteDialogDefaultWebsiteId = useMemo(() => {
|
||||
if (!favoriteDialogProduct?.prices?.length) return 0;
|
||||
return favoriteDialogProduct.prices.reduce((min, current) =>
|
||||
Number(current.price) < Number(min.price) ? current : min
|
||||
).website_id;
|
||||
}, [favoriteDialogProduct]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!favoriteDialogProduct || !favoriteDialogDefaultWebsiteId) return;
|
||||
setFavoriteWebsiteByProduct((prev) => {
|
||||
if (prev[favoriteDialogProduct.id]) return prev;
|
||||
return { ...prev, [favoriteDialogProduct.id]: favoriteDialogDefaultWebsiteId };
|
||||
});
|
||||
}, [favoriteDialogProduct, favoriteDialogDefaultWebsiteId]);
|
||||
|
||||
const filteredProducts = useMemo(() => [...allProducts], [allProducts]);
|
||||
|
||||
const isLoading = categoriesLoading || websitesLoading || (debouncedSearchQuery.trim() ? searchLoading : productsLoading);
|
||||
|
||||
|
||||
const handleAddProduct = async () => {
|
||||
if (!newProduct.name.trim()) {
|
||||
toast.error("请输入商品名称");
|
||||
return;
|
||||
}
|
||||
if (!newProduct.categoryId) {
|
||||
toast.error("请选择分类");
|
||||
return;
|
||||
}
|
||||
if (isNewWebsite) {
|
||||
if (!newWebsite.name.trim()) {
|
||||
toast.error("请输入网站名称");
|
||||
return;
|
||||
}
|
||||
if (!newWebsite.url.trim()) {
|
||||
toast.error("请输入网站URL");
|
||||
return;
|
||||
}
|
||||
} else if (!newProduct.websiteId) {
|
||||
toast.error("请选择网站");
|
||||
return;
|
||||
}
|
||||
if (!newProduct.price || Number(newProduct.price) <= 0) {
|
||||
toast.error("请输入有效价格");
|
||||
return;
|
||||
}
|
||||
if (!newProduct.url.trim()) {
|
||||
toast.error("请输入商品链接");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let websiteId = Number(newProduct.websiteId);
|
||||
|
||||
// Create new website if needed
|
||||
if (isNewWebsite) {
|
||||
const website = await websiteApi.create({
|
||||
name: newWebsite.name.trim(),
|
||||
url: newWebsite.url.trim(),
|
||||
category_id: Number(newProduct.categoryId),
|
||||
});
|
||||
websiteId = website.id;
|
||||
queryClient.invalidateQueries({ queryKey: ["websites"] });
|
||||
}
|
||||
|
||||
const product = await productApi.create({
|
||||
name: newProduct.name.trim(),
|
||||
description: newProduct.description.trim() || undefined,
|
||||
image: newProduct.image.trim() || undefined,
|
||||
category_id: Number(newProduct.categoryId),
|
||||
});
|
||||
await productApi.addPrice({
|
||||
product_id: product.id,
|
||||
website_id: websiteId,
|
||||
price: newProduct.price,
|
||||
original_price: newProduct.originalPrice || undefined,
|
||||
currency: newProduct.currency || "CNY",
|
||||
url: newProduct.url.trim(),
|
||||
in_stock: newProduct.inStock,
|
||||
});
|
||||
toast.success("商品已添加");
|
||||
queryClient.invalidateQueries({ queryKey: ["products"] });
|
||||
setIsAddOpen(false);
|
||||
setNewProduct({
|
||||
name: "",
|
||||
description: "",
|
||||
image: "",
|
||||
categoryId: "",
|
||||
websiteId: "",
|
||||
price: "",
|
||||
originalPrice: "",
|
||||
currency: "CNY",
|
||||
url: "",
|
||||
inStock: true,
|
||||
});
|
||||
setIsNewWebsite(false);
|
||||
setNewWebsite({ name: "", url: "" });
|
||||
} catch (error: unknown) {
|
||||
const { title, description } = getErrorCopy(error, { context: "product.create" });
|
||||
toast.error(title, { description });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateCategory = async () => {
|
||||
const name = newCategory.name.trim();
|
||||
if (!name) {
|
||||
toast.error("请输入分类名称");
|
||||
return;
|
||||
}
|
||||
|
||||
const rawSlug = newCategory.slug.trim();
|
||||
const fallbackSlug = name
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[^a-z0-9-]/g, "");
|
||||
const slug = rawSlug || fallbackSlug || `category-${Date.now()}`;
|
||||
|
||||
setIsCreatingCategory(true);
|
||||
try {
|
||||
const category = await categoryApi.create({
|
||||
name,
|
||||
slug,
|
||||
description: newCategory.description.trim() || undefined,
|
||||
});
|
||||
queryClient.setQueryData(["categories"], (prev) => {
|
||||
if (Array.isArray(prev)) {
|
||||
const exists = prev.some((item) => item.id === category.id);
|
||||
return exists ? prev : [...prev, category];
|
||||
}
|
||||
return [category];
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["categories"] });
|
||||
setNewProduct((prev) => ({ ...prev, categoryId: category.id.toString() }));
|
||||
setIsNewCategory(false);
|
||||
setNewCategory({ name: "", slug: "", description: "" });
|
||||
toast.success("分类已创建");
|
||||
} catch (error: unknown) {
|
||||
const { title, description } = getErrorCopy(error, { context: "category.create" });
|
||||
toast.error(title, { description });
|
||||
} finally {
|
||||
setIsCreatingCategory(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Navbar>
|
||||
{isAuthenticated && (
|
||||
<>
|
||||
<Dialog open={isAddOpen} onOpenChange={setIsAddOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="hidden md:inline-flex">
|
||||
添加商品
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[520px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加商品</DialogTitle>
|
||||
<DialogDescription>填写商品信息以添加到列表</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="product-name">商品名称</Label>
|
||||
<Input
|
||||
id="product-name"
|
||||
value={newProduct.name}
|
||||
onChange={(e) => setNewProduct(prev => ({ ...prev, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="product-desc">描述</Label>
|
||||
<Input
|
||||
id="product-desc"
|
||||
value={newProduct.description}
|
||||
onChange={(e) => setNewProduct(prev => ({ ...prev, description: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="product-image">图片URL</Label>
|
||||
<Input
|
||||
id="product-image"
|
||||
value={newProduct.image}
|
||||
onChange={(e) => setNewProduct(prev => ({ ...prev, image: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>分类</Label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsNewCategory(!isNewCategory);
|
||||
if (!isNewCategory) {
|
||||
setNewProduct((prev) => ({ ...prev, categoryId: "" }));
|
||||
} else {
|
||||
setNewCategory({ name: "", slug: "", description: "" });
|
||||
}
|
||||
}}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
{isNewCategory ? "选择已有分类" : "+ 添加新分类"}
|
||||
</button>
|
||||
</div>
|
||||
{isNewCategory ? (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
placeholder="分类名称"
|
||||
value={newCategory.name}
|
||||
onChange={(e) => setNewCategory((prev) => ({ ...prev, name: e.target.value }))}
|
||||
disabled={isCreatingCategory}
|
||||
/>
|
||||
<Input
|
||||
placeholder="分类标识(可选,如: digital)"
|
||||
value={newCategory.slug}
|
||||
onChange={(e) => setNewCategory((prev) => ({ ...prev, slug: e.target.value }))}
|
||||
disabled={isCreatingCategory}
|
||||
/>
|
||||
<Input
|
||||
placeholder="分类描述(可选)"
|
||||
value={newCategory.description}
|
||||
onChange={(e) => setNewCategory((prev) => ({ ...prev, description: e.target.value }))}
|
||||
disabled={isCreatingCategory}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleCreateCategory}
|
||||
disabled={isCreatingCategory}
|
||||
>
|
||||
{isCreatingCategory ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
创建中...
|
||||
</>
|
||||
) : (
|
||||
"创建分类"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<select
|
||||
value={newProduct.categoryId}
|
||||
onChange={(e) => setNewProduct(prev => ({ ...prev, categoryId: e.target.value }))}
|
||||
className="px-3 py-2 border rounded-lg bg-background text-foreground text-sm"
|
||||
>
|
||||
<option value="">请选择分类</option>
|
||||
{categories.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{categories.length === 0 && !categoriesLoading && (
|
||||
<p className="text-xs text-muted-foreground">暂无分类,请先创建</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>网站</Label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsNewWebsite(!isNewWebsite);
|
||||
if (!isNewWebsite) {
|
||||
setNewProduct(prev => ({ ...prev, websiteId: "" }));
|
||||
} else {
|
||||
setNewWebsite({ name: "", url: "" });
|
||||
}
|
||||
}}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
{isNewWebsite ? "选择已有网站" : "+ 添加新网站"}
|
||||
</button>
|
||||
</div>
|
||||
{isNewWebsite ? (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
placeholder="网站名称 (如: 京东)"
|
||||
value={newWebsite.name}
|
||||
onChange={(e) => setNewWebsite(prev => ({ ...prev, name: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
placeholder="网站URL (如: https://www.jd.com)"
|
||||
value={newWebsite.url}
|
||||
onChange={(e) => setNewWebsite(prev => ({ ...prev, url: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={newProduct.websiteId}
|
||||
onChange={(e) => setNewProduct(prev => ({ ...prev, websiteId: e.target.value }))}
|
||||
className="px-3 py-2 border rounded-lg bg-background text-foreground text-sm"
|
||||
>
|
||||
<option value="">请选择网站</option>
|
||||
{websites.map((website) => (
|
||||
<option key={website.id} value={website.id}>
|
||||
{website.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="product-price">价格</Label>
|
||||
<Input
|
||||
id="product-price"
|
||||
type="number"
|
||||
value={newProduct.price}
|
||||
onChange={(e) => setNewProduct(prev => ({ ...prev, price: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="product-original-price">原价</Label>
|
||||
<Input
|
||||
id="product-original-price"
|
||||
type="number"
|
||||
value={newProduct.originalPrice}
|
||||
onChange={(e) => setNewProduct(prev => ({ ...prev, originalPrice: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="product-currency">币种</Label>
|
||||
<Input
|
||||
id="product-currency"
|
||||
value={newProduct.currency}
|
||||
onChange={(e) => setNewProduct(prev => ({ ...prev, currency: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="product-url">商品链接</Label>
|
||||
<Input
|
||||
id="product-url"
|
||||
value={newProduct.url}
|
||||
onChange={(e) => setNewProduct(prev => ({ ...prev, url: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={newProduct.inStock}
|
||||
onCheckedChange={(checked) =>
|
||||
setNewProduct(prev => ({ ...prev, inStock: Boolean(checked) }))
|
||||
}
|
||||
/>
|
||||
<span className="text-sm">有货</span>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsAddOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleAddProduct}>保存</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
</>
|
||||
)}
|
||||
</Navbar>
|
||||
|
||||
<ProductsHeader
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
isSearchMode={isSearchMode}
|
||||
showPriceFilter={showPriceFilter}
|
||||
setShowPriceFilter={setShowPriceFilter}
|
||||
priceRange={priceRange}
|
||||
setPriceRange={setPriceRange}
|
||||
sortBy={sortBy}
|
||||
setSortBy={setSortBy}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
categories={categories}
|
||||
selectedCategory={selectedCategory}
|
||||
setSelectedCategory={setSelectedCategory}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<section className="pb-20">
|
||||
<div className="container">
|
||||
{isLoading ? (
|
||||
<ProductListSkeleton count={8} />
|
||||
) : (
|
||||
<>
|
||||
<WebsitesSection websites={filteredWebsites} viewMode={viewMode} />
|
||||
|
||||
<RecommendedProducts products={recommendedProducts} />
|
||||
|
||||
{/* Products Section */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-6 flex items-center gap-2">
|
||||
<ArrowUpDown className="w-5 h-5 text-accent-foreground" />
|
||||
价格对比
|
||||
<Badge variant="secondary" className="ml-2">{filteredProducts.length}</Badge>
|
||||
</h2>
|
||||
|
||||
{filteredProducts.length === 0 ? (
|
||||
<Card className="card-elegant">
|
||||
<CardContent className="py-12 text-center">
|
||||
<ArrowUpDown className="w-12 h-12 mx-auto text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground">暂无商品数据</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className={viewMode === "grid"
|
||||
? "grid sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"
|
||||
: "space-y-3"
|
||||
}>
|
||||
{filteredProducts.map((product) => {
|
||||
const productWithPrices = isSearchMode ? (product as ProductWithPrices) : null;
|
||||
const defaultWebsiteId = productWithPrices?.prices?.length
|
||||
? productWithPrices.prices.reduce((min, current) =>
|
||||
Number(current.price) < Number(min.price) ? current : min
|
||||
).website_id
|
||||
: null;
|
||||
const selectedWebsiteId = favoriteWebsiteByProduct[product.id] ?? defaultWebsiteId;
|
||||
const lowestPrice = productWithPrices?.lowest_price;
|
||||
const priceCount = productWithPrices?.prices?.length || 0;
|
||||
const isFav = selectedWebsiteId
|
||||
? favorites.has(`${product.id}-${selectedWebsiteId}`)
|
||||
: false;
|
||||
return (
|
||||
<div key={product.id} className="relative">
|
||||
<Link href={`/products/${product.id}`}>
|
||||
<Card className="card-elegant group cursor-pointer h-full">
|
||||
<CardHeader className={viewMode === "list" ? "flex-row items-center gap-4 space-y-0" : ""}>
|
||||
<div className={`${viewMode === "list" ? "w-16 h-16 flex-shrink-0" : "w-full aspect-square"} rounded-xl overflow-hidden`}>
|
||||
<LazyImage
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
className="w-full h-full"
|
||||
aspectRatio={viewMode === "list" ? "1/1" : undefined}
|
||||
fallback={<ShoppingBag className="w-8 h-8 text-muted-foreground" />}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="text-base line-clamp-2">{product.name}</CardTitle>
|
||||
<CardDescription className="line-clamp-2 mt-1">
|
||||
{product.description || "点击查看价格对比"}
|
||||
</CardDescription>
|
||||
{lowestPrice && (
|
||||
<div className="mt-2">
|
||||
<span className="text-lg font-bold text-primary">¥{lowestPrice}</span>
|
||||
{priceCount > 1 && (
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
({priceCount}个平台)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</Link>
|
||||
{isAuthenticated && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!productWithPrices?.prices?.length) {
|
||||
toast.error("该商品暂无价格信息");
|
||||
return;
|
||||
}
|
||||
setFavoriteDialogProduct(productWithPrices);
|
||||
setFavoriteDialogOpen(true);
|
||||
}}
|
||||
className="absolute top-2 right-2 p-2 rounded-lg bg-background/80 hover:bg-background transition-colors z-10"
|
||||
>
|
||||
<Heart className={`w-5 h-5 ${isFav ? 'fill-red-500 text-red-500' : 'text-muted-foreground'}`} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Dialog open={favoriteDialogOpen} onOpenChange={setFavoriteDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[520px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>选择收藏网站</DialogTitle>
|
||||
<DialogDescription>
|
||||
{favoriteDialogProduct?.name || "请选择要收藏的网站"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
{favoriteDialogProduct?.prices?.length ? (
|
||||
favoriteDialogProduct.prices
|
||||
.slice()
|
||||
.sort((a, b) => Number(a.price) - Number(b.price))
|
||||
.map((price) => {
|
||||
const selectedWebsiteId =
|
||||
favoriteWebsiteByProduct[favoriteDialogProduct.id] ?? favoriteDialogDefaultWebsiteId;
|
||||
const isSelected = selectedWebsiteId === price.website_id;
|
||||
const isFavorited = favorites.has(`${favoriteDialogProduct.id}-${price.website_id}`);
|
||||
return (
|
||||
<div
|
||||
key={price.id}
|
||||
className={`flex items-center justify-between gap-3 p-3 rounded-lg border ${
|
||||
isSelected ? "border-primary" : "border-border"
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">
|
||||
{price.website_name || "未知网站"}
|
||||
</span>
|
||||
{isSelected && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
当前选择
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
¥{price.price}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setFavoriteWebsiteByProduct((prev) => ({
|
||||
...prev,
|
||||
[favoriteDialogProduct.id]: price.website_id,
|
||||
}))
|
||||
}
|
||||
>
|
||||
选择
|
||||
</Button>
|
||||
<Button
|
||||
variant={isFavorited ? "destructive" : "default"}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (isFavorited) {
|
||||
const fav = favoritesData?.items?.find(
|
||||
f => f.product_id === favoriteDialogProduct.id && f.website_id === price.website_id
|
||||
);
|
||||
if (!fav) return;
|
||||
removeFavoriteMutation.mutate(fav.id, {
|
||||
onSuccess: () => {
|
||||
toast.success("已取消收藏");
|
||||
queryClient.invalidateQueries({ queryKey: ["favorites"] });
|
||||
},
|
||||
});
|
||||
} else {
|
||||
addFavoriteMutation.mutate(
|
||||
{ product_id: favoriteDialogProduct.id, website_id: price.website_id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("已添加到收藏");
|
||||
queryClient.invalidateQueries({ queryKey: ["favorites"] });
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isFavorited ? "取消收藏" : "收藏"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">暂无可选网站</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setFavoriteDialogOpen(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="py-12 border-t border-border">
|
||||
<div className="container">
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center">
|
||||
<Sparkles className="w-5 h-5 text-primary-foreground" />
|
||||
</div>
|
||||
<span className="font-semibold">资源聚合平台</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
© 2026 资源聚合平台. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
298
frontend/src/features/settings/pages/Settings.tsx
Normal file
298
frontend/src/features/settings/pages/Settings.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useUpdateMe, useChangePassword } from "@/hooks/useApi";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
import { toast } from "sonner";
|
||||
import { getErrorCopy } from "@/lib/i18n/errorMessages";
|
||||
import { Settings as SettingsIcon, User, Lock, Camera, Loader2 } from "lucide-react";
|
||||
import { Link } from "wouter";
|
||||
|
||||
export default function Settings() {
|
||||
const { user, isAuthenticated, loading, refresh } = useAuth();
|
||||
|
||||
// Profile form state
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [avatar, setAvatar] = useState("");
|
||||
|
||||
// Password form state
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
|
||||
const updateMeMutation = useUpdateMe();
|
||||
const changePasswordMutation = useChangePassword();
|
||||
|
||||
// Initialize form with user data
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setName(user.name || "");
|
||||
setEmail(user.email || "");
|
||||
setAvatar(user.avatar || "");
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const validateEmail = (email: string): boolean => {
|
||||
const emailPattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
|
||||
return emailPattern.test(email);
|
||||
};
|
||||
|
||||
const handleUpdateProfile = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!name.trim()) {
|
||||
toast.error("用户名不能为空");
|
||||
return;
|
||||
}
|
||||
if (name.length > 50) {
|
||||
toast.error("用户名不能超过50个字符");
|
||||
return;
|
||||
}
|
||||
if (email.trim() && !validateEmail(email.trim())) {
|
||||
toast.error("请输入正确的邮箱格式");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateMeMutation.mutateAsync({
|
||||
name: name.trim(),
|
||||
email: email.trim() || undefined,
|
||||
avatar: avatar.trim() || undefined,
|
||||
});
|
||||
toast.success("个人信息已更新");
|
||||
refresh();
|
||||
} catch (error: unknown) {
|
||||
const { title, description } = getErrorCopy(error, { context: "settings.profile" });
|
||||
toast.error(title, { description });
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangePassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!currentPassword.trim()) {
|
||||
toast.error("请输入当前密码");
|
||||
return;
|
||||
}
|
||||
if (!newPassword.trim()) {
|
||||
toast.error("请输入新密码");
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 6) {
|
||||
toast.error("新密码长度至少6位");
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
toast.error("两次输入的密码不一致");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await changePasswordMutation.mutateAsync({
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
});
|
||||
toast.success("密码已更新");
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
} catch (error: unknown) {
|
||||
const { title, description } = getErrorCopy(error, { context: "settings.password" });
|
||||
toast.error(title, { description });
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Navbar />
|
||||
<div className="container pt-24 pb-12">
|
||||
<Card className="card-elegant max-w-md mx-auto">
|
||||
<CardContent className="py-12 text-center">
|
||||
<SettingsIcon className="w-12 h-12 mx-auto text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground mb-4">请登录后访问设置页面</p>
|
||||
<Link href="/login">
|
||||
<Button className="w-full">登录</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Navbar />
|
||||
<section className="pt-24 pb-12">
|
||||
<div className="container max-w-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center">
|
||||
<SettingsIcon className="w-6 h-6 text-primary-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">账号设置</h1>
|
||||
<p className="text-muted-foreground text-sm">管理您的账号信息和安全设置</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Profile Settings */}
|
||||
<Card className="card-elegant mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="w-5 h-5 text-primary" />
|
||||
个人信息
|
||||
</CardTitle>
|
||||
<CardDescription>更新您的用户名、邮箱和头像</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleUpdateProfile} className="space-y-6">
|
||||
{/* Avatar */}
|
||||
<div className="flex items-center gap-6">
|
||||
<Avatar className="w-20 h-20">
|
||||
<AvatarImage src={avatar} alt={name} />
|
||||
<AvatarFallback className="text-2xl bg-gradient-to-br from-primary to-primary/60 text-primary-foreground">
|
||||
{name?.charAt(0)?.toUpperCase() || "U"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 space-y-2">
|
||||
<Label htmlFor="avatar">头像链接</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="avatar"
|
||||
type="url"
|
||||
placeholder="输入头像图片URL"
|
||||
value={avatar}
|
||||
onChange={(e) => setAvatar(e.target.value)}
|
||||
disabled={updateMeMutation.isPending}
|
||||
/>
|
||||
<Button type="button" variant="outline" size="icon" disabled>
|
||||
<Camera className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">支持 jpg、png、gif 格式的图片链接</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">用户名</Label>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="输入用户名"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={updateMeMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">邮箱</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="输入邮箱地址"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={updateMeMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={updateMeMutation.isPending}>
|
||||
{updateMeMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
保存中...
|
||||
</>
|
||||
) : (
|
||||
"保存修改"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Password Settings */}
|
||||
<Card className="card-elegant">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Lock className="w-5 h-5 text-primary" />
|
||||
修改密码
|
||||
</CardTitle>
|
||||
<CardDescription>更新您的登录密码</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="currentPassword">当前密码</Label>
|
||||
<Input
|
||||
id="currentPassword"
|
||||
type="password"
|
||||
placeholder="输入当前密码"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
disabled={changePasswordMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="newPassword">新密码</Label>
|
||||
<Input
|
||||
id="newPassword"
|
||||
type="password"
|
||||
placeholder="输入新密码(至少6位)"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
disabled={changePasswordMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">确认新密码</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
placeholder="再次输入新密码"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
disabled={changePasswordMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={changePasswordMutation.isPending}>
|
||||
{changePasswordMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
修改中...
|
||||
</>
|
||||
) : (
|
||||
"修改密码"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
* React Query hooks for API calls
|
||||
*/
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
|
||||
import {
|
||||
authApi,
|
||||
categoryApi,
|
||||
@@ -14,6 +15,8 @@ import {
|
||||
adminApi,
|
||||
friendApi,
|
||||
setAccessToken,
|
||||
setRefreshToken,
|
||||
clearRefreshToken,
|
||||
searchApi,
|
||||
type User,
|
||||
type Bounty,
|
||||
@@ -54,7 +57,7 @@ export function useLogin() {
|
||||
onSuccess: (data) => {
|
||||
// Store tokens
|
||||
setAccessToken(data.access_token);
|
||||
localStorage.setItem('refresh_token', data.refresh_token);
|
||||
setRefreshToken(data.refresh_token);
|
||||
// Refetch user data
|
||||
queryClient.invalidateQueries({ queryKey: ['auth', 'me'] });
|
||||
},
|
||||
@@ -69,7 +72,7 @@ export function useRegister() {
|
||||
authApi.register({ open_id: openId, password, name, email }),
|
||||
onSuccess: (data) => {
|
||||
setAccessToken(data.access_token);
|
||||
localStorage.setItem('refresh_token', data.refresh_token);
|
||||
setRefreshToken(data.refresh_token);
|
||||
queryClient.invalidateQueries({ queryKey: ['auth', 'me'] });
|
||||
},
|
||||
});
|
||||
@@ -82,7 +85,7 @@ export function useLogout() {
|
||||
mutationFn: authApi.logout,
|
||||
onSuccess: () => {
|
||||
setAccessToken(null);
|
||||
localStorage.removeItem('refresh_token');
|
||||
clearRefreshToken();
|
||||
queryClient.setQueryData(['auth', 'me'], null);
|
||||
queryClient.invalidateQueries({ queryKey: ['auth'] });
|
||||
},
|
||||
@@ -100,6 +103,12 @@ export function useUpdateMe() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useChangePassword() {
|
||||
return useMutation({
|
||||
mutationFn: authApi.changePassword,
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Friends Hooks ====================
|
||||
|
||||
export function useFriends() {
|
||||
@@ -172,10 +181,11 @@ export function useCancelFriendRequest() {
|
||||
}
|
||||
|
||||
export function useSearchUsers(q: string, limit?: number) {
|
||||
const debouncedQuery = useDebouncedValue(q, 300);
|
||||
return useQuery({
|
||||
queryKey: ['friends', 'search', q, limit],
|
||||
queryFn: () => friendApi.searchUsers(q, limit),
|
||||
enabled: !!q.trim(),
|
||||
queryKey: ['friends', 'search', debouncedQuery, limit],
|
||||
queryFn: () => friendApi.searchUsers(debouncedQuery, limit),
|
||||
enabled: !!debouncedQuery.trim(),
|
||||
staleTime: shortStaleTime,
|
||||
});
|
||||
}
|
||||
@@ -221,7 +231,7 @@ export function useWebsite(id: number) {
|
||||
|
||||
// ==================== Product Hooks ====================
|
||||
|
||||
export function useProducts(params?: { category_id?: number; search?: string; page?: number }) {
|
||||
export function useProducts(params?: { category_id?: number; search?: string; page?: number; min_price?: number; max_price?: number; sort_by?: string }) {
|
||||
return useQuery({
|
||||
queryKey: ['products', params],
|
||||
queryFn: () => productApi.list(params),
|
||||
@@ -256,11 +266,12 @@ export function useProductWithPrices(id: number) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useProductSearch(q: string, page?: number) {
|
||||
export function useProductSearch(q: string, params?: { page?: number; category_id?: number; min_price?: number; max_price?: number; sort_by?: string }) {
|
||||
const debouncedQuery = useDebouncedValue(q, 300);
|
||||
return useQuery({
|
||||
queryKey: ['products', 'search', q, page],
|
||||
queryFn: () => productApi.search(q, page),
|
||||
enabled: !!q,
|
||||
queryKey: ['products', 'search', debouncedQuery, params],
|
||||
queryFn: () => productApi.search({ q: debouncedQuery, ...params }),
|
||||
enabled: !!debouncedQuery.trim(),
|
||||
staleTime: shortStaleTime,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
@@ -287,10 +298,11 @@ export function useBounty(id: number) {
|
||||
}
|
||||
|
||||
export function useBountySearch(q: string, page?: number) {
|
||||
const debouncedQuery = useDebouncedValue(q, 300);
|
||||
return useQuery({
|
||||
queryKey: ['bounties', 'search', q, page],
|
||||
queryFn: () => bountyApi.search(q, page),
|
||||
enabled: !!q,
|
||||
queryKey: ['bounties', 'search', debouncedQuery, page],
|
||||
queryFn: () => bountyApi.search(debouncedQuery, page),
|
||||
enabled: !!debouncedQuery.trim(),
|
||||
staleTime: shortStaleTime,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
@@ -709,10 +721,11 @@ export function useNotifications(params?: { is_read?: boolean; type?: string; st
|
||||
}
|
||||
|
||||
export function useGlobalSearch(q: string, limit = 10) {
|
||||
const debouncedQuery = useDebouncedValue(q, 300);
|
||||
return useQuery({
|
||||
queryKey: ['search', q, limit],
|
||||
queryFn: () => searchApi.global(q, limit),
|
||||
enabled: !!q.trim(),
|
||||
queryKey: ['search', debouncedQuery, limit],
|
||||
queryFn: () => searchApi.global(debouncedQuery, limit),
|
||||
enabled: !!debouncedQuery.trim(),
|
||||
staleTime: shortStaleTime,
|
||||
});
|
||||
}
|
||||
@@ -848,3 +861,40 @@ export function useAdminDisputes(status?: string) {
|
||||
staleTime: shortStaleTime,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminPendingProducts() {
|
||||
return useQuery({
|
||||
queryKey: ['admin', 'products', 'pending'],
|
||||
queryFn: adminApi.listPendingProducts,
|
||||
staleTime: shortStaleTime,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminAllProducts(status?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['admin', 'products', 'all', status],
|
||||
queryFn: () => adminApi.listAllProducts(status),
|
||||
staleTime: shortStaleTime,
|
||||
});
|
||||
}
|
||||
|
||||
export function useReviewProduct() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ productId, data }: { productId: number; data: { approved: boolean; reject_reason?: string } }) =>
|
||||
adminApi.reviewProduct(productId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'products'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== My Products Hooks ====================
|
||||
|
||||
export function useMyProducts(status?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['products', 'my', status],
|
||||
queryFn: () => productApi.myProducts(status),
|
||||
staleTime: shortStaleTime,
|
||||
});
|
||||
}
|
||||
|
||||
109
frontend/src/hooks/useAuth.ts
Normal file
109
frontend/src/hooks/useAuth.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useMe, useLogout } from "@/hooks/useApi";
|
||||
import { isApiError } from "@/lib/api";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
|
||||
type UseAuthOptions = {
|
||||
redirectOnUnauthenticated?: boolean;
|
||||
redirectPath?: string;
|
||||
};
|
||||
|
||||
// 用于保存登录前的路径
|
||||
const REDIRECT_KEY = "auth_redirect_path";
|
||||
|
||||
export function saveRedirectPath(path: string) {
|
||||
if (path && path !== "/login" && path !== "/") {
|
||||
sessionStorage.setItem(REDIRECT_KEY, path);
|
||||
}
|
||||
}
|
||||
|
||||
export function getAndClearRedirectPath(): string | null {
|
||||
const path = sessionStorage.getItem(REDIRECT_KEY);
|
||||
sessionStorage.removeItem(REDIRECT_KEY);
|
||||
return path;
|
||||
}
|
||||
|
||||
export function useAuth(options?: UseAuthOptions) {
|
||||
const { redirectOnUnauthenticated = false, redirectPath = "/login" } =
|
||||
options ?? {};
|
||||
|
||||
const [location, navigate] = useLocation();
|
||||
const hasRedirected = useRef(false);
|
||||
const meQuery = useMe();
|
||||
const logoutMutation = useLogout();
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await logoutMutation.mutateAsync();
|
||||
} catch (error: unknown) {
|
||||
if (isApiError(error) && error.status === 401) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}, [logoutMutation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (meQuery.data) {
|
||||
localStorage.setItem(
|
||||
"manus-runtime-user-info",
|
||||
JSON.stringify(meQuery.data)
|
||||
);
|
||||
} else {
|
||||
localStorage.removeItem("manus-runtime-user-info");
|
||||
}
|
||||
}, [meQuery.data]);
|
||||
|
||||
const state = useMemo(() => {
|
||||
return {
|
||||
user: meQuery.data ?? null,
|
||||
loading: meQuery.isLoading || logoutMutation.isPending,
|
||||
error: meQuery.error ?? logoutMutation.error ?? null,
|
||||
isAuthenticated: Boolean(meQuery.data),
|
||||
};
|
||||
}, [
|
||||
meQuery.data,
|
||||
meQuery.error,
|
||||
meQuery.isLoading,
|
||||
logoutMutation.error,
|
||||
logoutMutation.isPending,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!redirectOnUnauthenticated) return;
|
||||
if (meQuery.isLoading || logoutMutation.isPending) return;
|
||||
if (state.user) return;
|
||||
if (typeof window === "undefined") return;
|
||||
if (location === redirectPath) return;
|
||||
if (hasRedirected.current) return;
|
||||
|
||||
// 保存当前路径以便登录后返回
|
||||
saveRedirectPath(location);
|
||||
hasRedirected.current = true;
|
||||
|
||||
// 使用 wouter 导航而非页面刷新
|
||||
navigate(redirectPath);
|
||||
}, [
|
||||
redirectOnUnauthenticated,
|
||||
redirectPath,
|
||||
logoutMutation.isPending,
|
||||
meQuery.isLoading,
|
||||
state.user,
|
||||
location,
|
||||
navigate,
|
||||
]);
|
||||
|
||||
// 重置重定向标记
|
||||
useEffect(() => {
|
||||
if (state.user) {
|
||||
hasRedirected.current = false;
|
||||
}
|
||||
}, [state.user]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
refresh: () => meQuery.refetch(),
|
||||
logout,
|
||||
};
|
||||
}
|
||||
14
frontend/src/hooks/useDebouncedValue.ts
Normal file
14
frontend/src/hooks/useDebouncedValue.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useDebouncedValue<T>(value: T, delay = 300) {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
@@ -1,579 +0,0 @@
|
||||
/**
|
||||
* API client for Django backend
|
||||
*/
|
||||
import axios, { AxiosError, AxiosRequestConfig } from 'axios';
|
||||
|
||||
// Create axios instance
|
||||
export const api = axios.create({
|
||||
baseURL: '/api',
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
// Token management
|
||||
let accessToken: string | null = null;
|
||||
|
||||
export function setAccessToken(token: string | null) {
|
||||
accessToken = token;
|
||||
if (token) {
|
||||
localStorage.setItem('access_token', token);
|
||||
} else {
|
||||
localStorage.removeItem('access_token');
|
||||
}
|
||||
}
|
||||
|
||||
export function getAccessToken(): string | null {
|
||||
if (!accessToken) {
|
||||
accessToken = localStorage.getItem('access_token');
|
||||
}
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
// Add auth header interceptor
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = getAccessToken();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Response interceptor for error handling
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: AxiosError) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Token expired or invalid
|
||||
setAccessToken(null);
|
||||
localStorage.removeItem('refresh_token');
|
||||
// Optionally redirect to login
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
open_id: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
avatar: string | null;
|
||||
role: 'user' | 'admin';
|
||||
stripe_customer_id: string | null;
|
||||
stripe_account_id: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface UserBrief {
|
||||
id: number;
|
||||
open_id: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
avatar: string | null;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
parent_id: number | null;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Website {
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
logo: string | null;
|
||||
description: string | null;
|
||||
category_id: number;
|
||||
rating: string;
|
||||
is_verified: boolean;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
image: string | null;
|
||||
category_id: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ProductPrice {
|
||||
id: number;
|
||||
product_id: number;
|
||||
website_id: number;
|
||||
website_name: string | null;
|
||||
website_logo: string | null;
|
||||
price: string;
|
||||
original_price: string | null;
|
||||
currency: string;
|
||||
url: string;
|
||||
in_stock: boolean;
|
||||
last_checked: string;
|
||||
}
|
||||
|
||||
export interface ProductWithPrices extends Product {
|
||||
prices: ProductPrice[];
|
||||
lowest_price: string | null;
|
||||
highest_price: string | null;
|
||||
}
|
||||
|
||||
export interface Bounty {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
reward: string;
|
||||
currency: string;
|
||||
publisher_id: number;
|
||||
publisher: User | null;
|
||||
acceptor_id: number | null;
|
||||
acceptor: User | null;
|
||||
status: 'open' | 'in_progress' | 'completed' | 'cancelled' | 'disputed';
|
||||
deadline: string | null;
|
||||
completed_at: string | null;
|
||||
is_paid: boolean;
|
||||
is_escrowed: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
applications_count?: number;
|
||||
comments_count?: number;
|
||||
}
|
||||
|
||||
export interface BountyApplication {
|
||||
id: number;
|
||||
bounty_id: number;
|
||||
applicant_id: number;
|
||||
applicant: User | null;
|
||||
message: string | null;
|
||||
status: 'pending' | 'accepted' | 'rejected';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface BountyComment {
|
||||
id: number;
|
||||
bounty_id: number;
|
||||
user_id: number;
|
||||
user: User | null;
|
||||
content: string;
|
||||
parent_id: number | null;
|
||||
replies: BountyComment[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Favorite {
|
||||
id: number;
|
||||
user_id: number;
|
||||
product_id: number;
|
||||
product_name: string | null;
|
||||
product_image: string | null;
|
||||
website_id: number;
|
||||
website_name: string | null;
|
||||
website_logo: string | null;
|
||||
tags: FavoriteTag[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface FavoriteTag {
|
||||
id: number;
|
||||
user_id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
description: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PriceMonitor {
|
||||
id: number;
|
||||
favorite_id: number;
|
||||
user_id: number;
|
||||
current_price: string | null;
|
||||
target_price: string | null;
|
||||
lowest_price: string | null;
|
||||
highest_price: string | null;
|
||||
notify_enabled: boolean;
|
||||
notify_on_target: boolean;
|
||||
last_notified_price: string | null;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PriceHistory {
|
||||
id: number;
|
||||
monitor_id: number;
|
||||
price: string;
|
||||
price_change: string | null;
|
||||
percent_change: string | null;
|
||||
recorded_at: string;
|
||||
}
|
||||
|
||||
export interface Notification {
|
||||
id: number;
|
||||
user_id: number;
|
||||
type: string;
|
||||
title: string;
|
||||
content: string | null;
|
||||
related_id: number | null;
|
||||
related_type: string | null;
|
||||
is_read: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface NotificationPreference {
|
||||
user_id: number;
|
||||
enable_bounty: boolean;
|
||||
enable_price_alert: boolean;
|
||||
enable_system: boolean;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface BountyDelivery {
|
||||
id: number;
|
||||
bounty_id: number;
|
||||
submitter_id: number;
|
||||
content: string;
|
||||
attachment_url: string | null;
|
||||
status: string;
|
||||
submitted_at: string;
|
||||
reviewed_at: string | null;
|
||||
}
|
||||
|
||||
export interface BountyDispute {
|
||||
id: number;
|
||||
bounty_id: number;
|
||||
initiator_id: number;
|
||||
reason: string;
|
||||
evidence_url: string | null;
|
||||
status: string;
|
||||
resolution: string | null;
|
||||
created_at: string;
|
||||
resolved_at: string | null;
|
||||
}
|
||||
|
||||
export interface BountyReview {
|
||||
id: number;
|
||||
bounty_id: number;
|
||||
reviewer_id: number;
|
||||
reviewee_id: number;
|
||||
rating: number;
|
||||
comment: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface BountyExtensionRequest {
|
||||
id: number;
|
||||
bounty_id: number;
|
||||
requester_id: number;
|
||||
proposed_deadline: string;
|
||||
reason: string | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
reviewed_at: string | null;
|
||||
}
|
||||
|
||||
export interface SearchResults {
|
||||
products: Product[];
|
||||
websites: Website[];
|
||||
bounties: Bounty[];
|
||||
}
|
||||
|
||||
export interface AdminUser {
|
||||
id: number;
|
||||
open_id: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
role: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AdminBounty {
|
||||
id: number;
|
||||
title: string;
|
||||
status: string;
|
||||
reward: string;
|
||||
publisher_id: number;
|
||||
acceptor_id: number | null;
|
||||
is_escrowed: boolean;
|
||||
is_paid: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AdminPaymentEvent {
|
||||
id: number;
|
||||
event_id: string;
|
||||
event_type: string;
|
||||
bounty_id: number | null;
|
||||
success: boolean;
|
||||
processed_at: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface MessageResponse {
|
||||
message: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface FriendRequest {
|
||||
id: number;
|
||||
requester: UserBrief;
|
||||
receiver: UserBrief;
|
||||
status: 'pending' | 'accepted' | 'rejected' | 'canceled';
|
||||
accepted_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Friend {
|
||||
request_id: number;
|
||||
user: UserBrief;
|
||||
since: string | null;
|
||||
}
|
||||
|
||||
// ==================== API Functions ====================
|
||||
|
||||
// Auth API
|
||||
export const authApi = {
|
||||
me: () => api.get<User>('/auth/me').then(r => r.data),
|
||||
logout: () => api.post<MessageResponse>('/auth/logout').then(r => r.data),
|
||||
updateMe: (data: { name?: string; email?: string; avatar?: string }) =>
|
||||
api.patch<User>('/auth/me', data).then(r => r.data),
|
||||
login: (data: { open_id: string; password: string }) =>
|
||||
api.post<{ access_token: string; refresh_token: string }>('/auth/login', data).then(r => r.data),
|
||||
register: (data: { open_id: string; password: string; name?: string; email?: string }) =>
|
||||
api.post<{ access_token: string; refresh_token: string }>('/auth/register', data).then(r => r.data),
|
||||
devLogin: (openId: string, name?: string) =>
|
||||
api.post<{ access_token: string; refresh_token: string }>('/auth/dev/login', null, { params: { open_id: openId, name } }).then(r => r.data),
|
||||
};
|
||||
|
||||
// Friends API
|
||||
export const friendApi = {
|
||||
list: () => api.get<Friend[]>('/friends/').then(r => r.data),
|
||||
incoming: () => api.get<FriendRequest[]>('/friends/requests/incoming').then(r => r.data),
|
||||
outgoing: () => api.get<FriendRequest[]>('/friends/requests/outgoing').then(r => r.data),
|
||||
sendRequest: (data: { receiver_id: number }) =>
|
||||
api.post<FriendRequest>('/friends/requests', data).then(r => r.data),
|
||||
acceptRequest: (requestId: number) =>
|
||||
api.post<FriendRequest>(`/friends/requests/${requestId}/accept`).then(r => r.data),
|
||||
rejectRequest: (requestId: number) =>
|
||||
api.post<FriendRequest>(`/friends/requests/${requestId}/reject`).then(r => r.data),
|
||||
cancelRequest: (requestId: number) =>
|
||||
api.post<FriendRequest>(`/friends/requests/${requestId}/cancel`).then(r => r.data),
|
||||
searchUsers: (q: string, limit?: number) =>
|
||||
api.get<UserBrief[]>('/friends/search', { params: { q, limit } }).then(r => r.data),
|
||||
};
|
||||
|
||||
// Categories API
|
||||
export const categoryApi = {
|
||||
list: () => api.get<Category[]>('/categories/').then(r => r.data),
|
||||
getBySlug: (slug: string) => api.get<Category>(`/categories/${slug}`).then(r => r.data),
|
||||
create: (data: { name: string; slug: string; description?: string; icon?: string; parent_id?: number; sort_order?: number }) =>
|
||||
api.post<Category>('/categories/', data).then(r => r.data),
|
||||
};
|
||||
|
||||
// Websites API
|
||||
export const websiteApi = {
|
||||
list: (params?: { category_id?: number; is_verified?: boolean; page?: number }) =>
|
||||
api.get<PaginatedResponse<Website>>('/websites/', { params }).then(r => r.data),
|
||||
get: (id: number) => api.get<Website>(`/websites/${id}`).then(r => r.data),
|
||||
create: (data: { name: string; url: string; logo?: string; description?: string; category_id: number }) =>
|
||||
api.post<Website>('/websites/', data).then(r => r.data),
|
||||
};
|
||||
|
||||
// Products API
|
||||
export const productApi = {
|
||||
list: (params?: { category_id?: number; search?: string; page?: number }) =>
|
||||
api.get<PaginatedResponse<Product>>('/products/', { params }).then(r => r.data),
|
||||
recommendations: (limit?: number) =>
|
||||
api.get<Product[]>('/products/recommendations/', { params: { limit } }).then(r => r.data),
|
||||
importCsv: (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
return api.post("/products/import/", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
}).then(r => r.data);
|
||||
},
|
||||
get: (id: number) => api.get<Product>(`/products/${id}`).then(r => r.data),
|
||||
getWithPrices: (id: number) => api.get<ProductWithPrices>(`/products/${id}/with-prices`).then(r => r.data),
|
||||
search: (q: string, page?: number) =>
|
||||
api.get<PaginatedResponse<ProductWithPrices>>('/products/search/', { params: { q, page } }).then(r => r.data),
|
||||
create: (data: { name: string; description?: string; image?: string; category_id: number }) =>
|
||||
api.post<Product>('/products/', data).then(r => r.data),
|
||||
addPrice: (data: { product_id: number; website_id: number; price: string; original_price?: string; currency?: string; url: string; in_stock?: boolean }) =>
|
||||
api.post<ProductPrice>('/products/prices/', data).then(r => r.data),
|
||||
};
|
||||
|
||||
// Bounties API
|
||||
export const bountyApi = {
|
||||
list: (params?: { status?: string; publisher_id?: number; acceptor_id?: number; page?: number }) =>
|
||||
api.get<PaginatedResponse<Bounty>>('/bounties/', { params }).then(r => r.data),
|
||||
search: (q: string, page?: number) =>
|
||||
api.get<PaginatedResponse<Bounty>>('/bounties/search/', { params: { q, page } }).then(r => r.data),
|
||||
get: (id: number) => api.get<Bounty>(`/bounties/${id}`).then(r => r.data),
|
||||
create: (data: { title: string; description: string; reward: string; currency?: string; deadline?: string }) =>
|
||||
api.post<Bounty>('/bounties/', data).then(r => r.data),
|
||||
update: (id: number, data: { title?: string; description?: string; reward?: string; deadline?: string }) =>
|
||||
api.patch<Bounty>(`/bounties/${id}`, data).then(r => r.data),
|
||||
cancel: (id: number) => api.post<MessageResponse>(`/bounties/${id}/cancel`).then(r => r.data),
|
||||
complete: (id: number) => api.post<MessageResponse>(`/bounties/${id}/complete`).then(r => r.data),
|
||||
myPublished: (page?: number) =>
|
||||
api.get<PaginatedResponse<Bounty>>('/bounties/my-published/', { params: { page } }).then(r => r.data),
|
||||
myAccepted: (page?: number) =>
|
||||
api.get<PaginatedResponse<Bounty>>('/bounties/my-accepted/', { params: { page } }).then(r => r.data),
|
||||
|
||||
// Applications
|
||||
listApplications: (bountyId: number) =>
|
||||
api.get<BountyApplication[]>(`/bounties/${bountyId}/applications/`).then(r => r.data),
|
||||
myApplication: (bountyId: number) =>
|
||||
api.get<BountyApplication | null>(`/bounties/${bountyId}/my-application/`).then(r => r.data),
|
||||
submitApplication: (bountyId: number, data: { message?: string }) =>
|
||||
api.post<BountyApplication>(`/bounties/${bountyId}/applications/`, data).then(r => r.data),
|
||||
acceptApplication: (bountyId: number, applicationId: number) =>
|
||||
api.post<MessageResponse>(`/bounties/${bountyId}/applications/${applicationId}/accept`).then(r => r.data),
|
||||
|
||||
// Comments
|
||||
listComments: (bountyId: number) =>
|
||||
api.get<BountyComment[]>(`/bounties/${bountyId}/comments/`).then(r => r.data),
|
||||
createComment: (bountyId: number, data: { content: string; parent_id?: number }) =>
|
||||
api.post<BountyComment>(`/bounties/${bountyId}/comments/`, data).then(r => r.data),
|
||||
|
||||
// Deliveries
|
||||
listDeliveries: (bountyId: number) =>
|
||||
api.get<BountyDelivery[]>(`/bounties/${bountyId}/deliveries/`).then(r => r.data),
|
||||
submitDelivery: (bountyId: number, data: { content: string; attachment_url?: string }) =>
|
||||
api.post<BountyDelivery>(`/bounties/${bountyId}/deliveries/`, data).then(r => r.data),
|
||||
reviewDelivery: (bountyId: number, deliveryId: number, accept: boolean) =>
|
||||
api.post<MessageResponse>(`/bounties/${bountyId}/deliveries/${deliveryId}/review`, { accept }).then(r => r.data),
|
||||
|
||||
// Disputes
|
||||
listDisputes: (bountyId: number) =>
|
||||
api.get<BountyDispute[]>(`/bounties/${bountyId}/disputes/`).then(r => r.data),
|
||||
createDispute: (bountyId: number, data: { reason: string; evidence_url?: string }) =>
|
||||
api.post<BountyDispute>(`/bounties/${bountyId}/disputes/`, data).then(r => r.data),
|
||||
resolveDispute: (bountyId: number, disputeId: number, data: { resolution: string; accepted: boolean }) =>
|
||||
api.post<MessageResponse>(`/bounties/${bountyId}/disputes/${disputeId}/resolve`, data).then(r => r.data),
|
||||
|
||||
// Reviews
|
||||
listReviews: (bountyId: number) =>
|
||||
api.get<BountyReview[]>(`/bounties/${bountyId}/reviews/`).then(r => r.data),
|
||||
createReview: (bountyId: number, data: { reviewee_id: number; rating: number; comment?: string }) =>
|
||||
api.post<BountyReview>(`/bounties/${bountyId}/reviews/`, data).then(r => r.data),
|
||||
|
||||
// Extension requests
|
||||
listExtensions: (bountyId: number) =>
|
||||
api.get<BountyExtensionRequest[]>(`/bounties/${bountyId}/extension-requests/`).then(r => r.data),
|
||||
createExtension: (bountyId: number, data: { proposed_deadline: string; reason?: string }) =>
|
||||
api.post<BountyExtensionRequest>(`/bounties/${bountyId}/extension-requests/`, data).then(r => r.data),
|
||||
reviewExtension: (bountyId: number, requestId: number, approve: boolean) =>
|
||||
api.post<MessageResponse>(`/bounties/${bountyId}/extension-requests/${requestId}/review`, { approve }).then(r => r.data),
|
||||
};
|
||||
|
||||
// Favorites API
|
||||
export const favoriteApi = {
|
||||
list: (params?: { tag_id?: number; page?: number }) =>
|
||||
api.get<PaginatedResponse<Favorite>>('/favorites/', { params }).then(r => r.data),
|
||||
exportCsv: () => api.get<Blob>('/favorites/export/', { responseType: 'blob' }).then(r => r.data),
|
||||
get: (id: number) => api.get<Favorite>(`/favorites/${id}`).then(r => r.data),
|
||||
check: (productId: number, websiteId: number) =>
|
||||
api.get<{ is_favorited: boolean; favorite_id: number | null }>('/favorites/check/', { params: { product_id: productId, website_id: websiteId } }).then(r => r.data),
|
||||
add: (data: { product_id: number; website_id: number }) =>
|
||||
api.post<Favorite>('/favorites/', data).then(r => r.data),
|
||||
remove: (id: number) => api.delete<MessageResponse>(`/favorites/${id}`).then(r => r.data),
|
||||
|
||||
// Tags
|
||||
listTags: () => api.get<FavoriteTag[]>('/favorites/tags/').then(r => r.data),
|
||||
createTag: (data: { name: string; color?: string; description?: string }) =>
|
||||
api.post<FavoriteTag>('/favorites/tags/', data).then(r => r.data),
|
||||
updateTag: (id: number, data: { name?: string; color?: string; description?: string }) =>
|
||||
api.patch<FavoriteTag>(`/favorites/tags/${id}`, data).then(r => r.data),
|
||||
deleteTag: (id: number) => api.delete<MessageResponse>(`/favorites/tags/${id}`).then(r => r.data),
|
||||
addTagToFavorite: (favoriteId: number, tagId: number) =>
|
||||
api.post<MessageResponse>(`/favorites/${favoriteId}/tags/`, { tag_id: tagId }).then(r => r.data),
|
||||
removeTagFromFavorite: (favoriteId: number, tagId: number) =>
|
||||
api.delete<MessageResponse>(`/favorites/${favoriteId}/tags/${tagId}`).then(r => r.data),
|
||||
|
||||
// Price Monitor
|
||||
getMonitor: (favoriteId: number) =>
|
||||
api.get<PriceMonitor | null>(`/favorites/${favoriteId}/monitor/`).then(r => r.data),
|
||||
createMonitor: (favoriteId: number, data: { target_price?: string; is_active?: boolean; notify_enabled?: boolean; notify_on_target?: boolean }) =>
|
||||
api.post<PriceMonitor>(`/favorites/${favoriteId}/monitor/`, data).then(r => r.data),
|
||||
updateMonitor: (favoriteId: number, data: { target_price?: string; is_active?: boolean; notify_enabled?: boolean; notify_on_target?: boolean }) =>
|
||||
api.patch<PriceMonitor>(`/favorites/${favoriteId}/monitor/`, data).then(r => r.data),
|
||||
deleteMonitor: (favoriteId: number) =>
|
||||
api.delete<MessageResponse>(`/favorites/${favoriteId}/monitor/`).then(r => r.data),
|
||||
getMonitorHistory: (favoriteId: number, page?: number) =>
|
||||
api.get<PaginatedResponse<PriceHistory>>(`/favorites/${favoriteId}/monitor/history/`, { params: { page } }).then(r => r.data),
|
||||
recordPrice: (favoriteId: number, price: string) =>
|
||||
api.post<PriceHistory>(`/favorites/${favoriteId}/monitor/record/`, { price }).then(r => r.data),
|
||||
refreshMonitor: (favoriteId: number) =>
|
||||
api.post<PriceMonitor>(`/favorites/${favoriteId}/monitor/refresh/`).then(r => r.data),
|
||||
listAllMonitors: (page?: number) =>
|
||||
api.get<PaginatedResponse<PriceMonitor>>('/favorites/monitors/all/', { params: { page } }).then(r => r.data),
|
||||
};
|
||||
|
||||
// Notifications API
|
||||
export const notificationApi = {
|
||||
list: (params?: { is_read?: boolean; type?: string; start?: string; end?: string; page?: number }) =>
|
||||
api.get<PaginatedResponse<Notification>>('/notifications/', { params }).then(r => r.data),
|
||||
exportCsv: () => api.get<Blob>('/notifications/export/', { responseType: 'blob' }).then(r => r.data),
|
||||
unreadCount: () => api.get<{ count: number }>('/notifications/unread-count/').then(r => r.data),
|
||||
markAsRead: (id: number) => api.post<MessageResponse>(`/notifications/${id}/read/`).then(r => r.data),
|
||||
markAllAsRead: () => api.post<MessageResponse>('/notifications/read-all/').then(r => r.data),
|
||||
delete: (id: number) => api.delete<MessageResponse>(`/notifications/${id}`).then(r => r.data),
|
||||
deleteAllRead: () => api.delete<MessageResponse>('/notifications/').then(r => r.data),
|
||||
getPreferences: () => api.get<NotificationPreference>('/notifications/preferences/').then(r => r.data),
|
||||
updatePreferences: (data: { enable_bounty?: boolean; enable_price_alert?: boolean; enable_system?: boolean }) =>
|
||||
api.patch<NotificationPreference>('/notifications/preferences/', data).then(r => r.data),
|
||||
};
|
||||
|
||||
// Global search API
|
||||
export const searchApi = {
|
||||
global: (q: string, limit?: number) =>
|
||||
api.get<SearchResults>('/search/', { params: { q, limit } }).then(r => r.data),
|
||||
};
|
||||
|
||||
// Payments API
|
||||
export const paymentApi = {
|
||||
createEscrow: (data: { bounty_id: number; success_url: string; cancel_url: string }) =>
|
||||
api.post<{ checkout_url: string; session_id: string }>('/payments/escrow/', data).then(r => r.data),
|
||||
getConnectStatus: () =>
|
||||
api.get<{ has_account: boolean; account_id: string | null; is_complete: boolean; dashboard_url: string | null }>('/payments/connect/status/').then(r => r.data),
|
||||
setupConnectAccount: (returnUrl: string, refreshUrl: string) =>
|
||||
api.post<{ onboarding_url: string; account_id: string }>('/payments/connect/setup/', null, { params: { return_url: returnUrl, refresh_url: refreshUrl } }).then(r => r.data),
|
||||
releasePayout: (bountyId: number) =>
|
||||
api.post<MessageResponse>(`/payments/${bountyId}/release/`).then(r => r.data),
|
||||
};
|
||||
|
||||
// Admin API
|
||||
export const adminApi = {
|
||||
listUsers: () => api.get<AdminUser[]>('/admin/users/').then(r => r.data),
|
||||
updateUser: (id: number, data: { role?: string; is_active?: boolean }) =>
|
||||
api.patch<AdminUser>(`/admin/users/${id}`, data).then(r => r.data),
|
||||
listCategories: () => api.get<{ id: number; name: string }[]>('/admin/categories/').then(r => r.data),
|
||||
listWebsites: () => api.get<{ id: number; name: string }[]>('/admin/websites/').then(r => r.data),
|
||||
listProducts: () => api.get<{ id: number; name: string }[]>('/admin/products/').then(r => r.data),
|
||||
listBounties: (status?: string) => api.get<AdminBounty[]>('/admin/bounties/', { params: { status } }).then(r => r.data),
|
||||
listDisputes: (status?: string) => api.get<{ id: number; bounty_id: number; initiator_id: number; status: string; created_at: string }[]>('/admin/disputes/', { params: { status } }).then(r => r.data),
|
||||
listPayments: () => api.get<AdminPaymentEvent[]>('/admin/payments/').then(r => r.data),
|
||||
};
|
||||
27
frontend/src/lib/api/admin.ts
Normal file
27
frontend/src/lib/api/admin.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { api } from "./client";
|
||||
import type { AdminBounty, AdminPaymentEvent, AdminUser, AdminProduct, PaginatedResponse } from "../types";
|
||||
|
||||
export const adminApi = {
|
||||
listUsers: () => api.get<PaginatedResponse<AdminUser>>("/admin/users/").then((r) => r.data),
|
||||
updateUser: (id: number, data: { role?: string; is_active?: boolean }) =>
|
||||
api.patch<AdminUser>(`/admin/users/${id}`, data).then((r) => r.data),
|
||||
listCategories: () => api.get<PaginatedResponse<{ id: number; name: string }>>("/admin/categories/").then((r) => r.data),
|
||||
listWebsites: () => api.get<PaginatedResponse<{ id: number; name: string }>>("/admin/websites/").then((r) => r.data),
|
||||
listProducts: () => api.get<PaginatedResponse<{ id: number; name: string }>>("/admin/products/").then((r) => r.data),
|
||||
listBounties: (status?: string) =>
|
||||
api.get<PaginatedResponse<AdminBounty>>("/admin/bounties/", { params: { status } }).then((r) => r.data),
|
||||
listDisputes: (status?: string) =>
|
||||
api.get<PaginatedResponse<{ id: number; bounty_id: number; initiator_id: number; status: string; created_at: string }>>>(
|
||||
"/admin/disputes/",
|
||||
{ params: { status } }
|
||||
).then((r) => r.data),
|
||||
listPayments: () => api.get<PaginatedResponse<AdminPaymentEvent>>("/admin/payments/").then((r) => r.data),
|
||||
|
||||
// Product review APIs
|
||||
listPendingProducts: () =>
|
||||
api.get<PaginatedResponse<AdminProduct>>("/admin/products/pending/").then((r) => r.data),
|
||||
listAllProducts: (status?: string) =>
|
||||
api.get<PaginatedResponse<AdminProduct>>("/admin/products/all/", { params: { status } }).then((r) => r.data),
|
||||
reviewProduct: (productId: number, data: { approved: boolean; reject_reason?: string }) =>
|
||||
api.post<AdminProduct>(`/admin/products/${productId}/review/`, data).then((r) => r.data),
|
||||
};
|
||||
30
frontend/src/lib/api/auth.ts
Normal file
30
frontend/src/lib/api/auth.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { api } from "./client";
|
||||
import type { MessageResponse, User, TokenResponse, OAuthCallbackData } from "../types";
|
||||
|
||||
export const authApi = {
|
||||
me: () => api.get<User>("/auth/me").then((r) => r.data),
|
||||
logout: () => api.post<MessageResponse>("/auth/logout").then((r) => r.data),
|
||||
updateMe: (data: { name?: string; email?: string; avatar?: string }) =>
|
||||
api.patch<User>("/auth/me", data).then((r) => r.data),
|
||||
changePassword: (data: { current_password: string; new_password: string }) =>
|
||||
api.post<MessageResponse>("/auth/change-password", data).then((r) => r.data),
|
||||
login: (data: { open_id: string; password: string }) =>
|
||||
api.post<TokenResponse>("/auth/login", data).then((r) => r.data),
|
||||
register: (data: { open_id: string; password: string; name?: string; email?: string }) =>
|
||||
api.post<TokenResponse>("/auth/register", data).then((r) => r.data),
|
||||
devLogin: (openId: string, name?: string) =>
|
||||
api.post<TokenResponse>(
|
||||
"/auth/dev/login",
|
||||
null,
|
||||
{ params: { open_id: openId, name } }
|
||||
).then((r) => r.data),
|
||||
|
||||
// OAuth 相关API
|
||||
getOAuthUrl: (redirectUri?: string) =>
|
||||
api.get<{ url: string }>("/auth/oauth/url", {
|
||||
params: redirectUri ? { redirect_uri: redirectUri } : undefined
|
||||
}).then((r) => r.data),
|
||||
|
||||
oauthCallback: (data: OAuthCallbackData) =>
|
||||
api.post<TokenResponse>("/auth/oauth/callback", data).then((r) => r.data),
|
||||
};
|
||||
70
frontend/src/lib/api/bounties.ts
Normal file
70
frontend/src/lib/api/bounties.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { api } from "./client";
|
||||
import type {
|
||||
Bounty,
|
||||
BountyApplication,
|
||||
BountyComment,
|
||||
BountyDelivery,
|
||||
BountyDispute,
|
||||
BountyExtensionRequest,
|
||||
BountyReview,
|
||||
MessageResponse,
|
||||
PaginatedResponse,
|
||||
} from "../types";
|
||||
|
||||
export const bountyApi = {
|
||||
list: (params?: { status?: string; publisher_id?: number; acceptor_id?: number; page?: number }) =>
|
||||
api.get<PaginatedResponse<Bounty>>("/bounties/", { params }).then((r) => r.data),
|
||||
search: (q: string, page?: number) =>
|
||||
api.get<PaginatedResponse<Bounty>>("/bounties/search/", { params: { q, page } }).then((r) => r.data),
|
||||
get: (id: number) => api.get<Bounty>(`/bounties/${id}`).then((r) => r.data),
|
||||
create: (data: { title: string; description: string; reward: string; currency?: string; deadline?: string }) =>
|
||||
api.post<Bounty>("/bounties/", data).then((r) => r.data),
|
||||
update: (id: number, data: { title?: string; description?: string; reward?: string; deadline?: string }) =>
|
||||
api.patch<Bounty>(`/bounties/${id}`, data).then((r) => r.data),
|
||||
cancel: (id: number) => api.post<MessageResponse>(`/bounties/${id}/cancel`).then((r) => r.data),
|
||||
complete: (id: number) => api.post<MessageResponse>(`/bounties/${id}/complete`).then((r) => r.data),
|
||||
myPublished: (page?: number) =>
|
||||
api.get<PaginatedResponse<Bounty>>("/bounties/my-published/", { params: { page } }).then((r) => r.data),
|
||||
myAccepted: (page?: number) =>
|
||||
api.get<PaginatedResponse<Bounty>>("/bounties/my-accepted/", { params: { page } }).then((r) => r.data),
|
||||
|
||||
listApplications: (bountyId: number) =>
|
||||
api.get<BountyApplication[]>(`/bounties/${bountyId}/applications/`).then((r) => r.data),
|
||||
myApplication: (bountyId: number) =>
|
||||
api.get<BountyApplication | null>(`/bounties/${bountyId}/my-application/`).then((r) => r.data),
|
||||
submitApplication: (bountyId: number, data: { message?: string }) =>
|
||||
api.post<BountyApplication>(`/bounties/${bountyId}/applications/`, data).then((r) => r.data),
|
||||
acceptApplication: (bountyId: number, applicationId: number) =>
|
||||
api.post<MessageResponse>(`/bounties/${bountyId}/applications/${applicationId}/accept`).then((r) => r.data),
|
||||
|
||||
listComments: (bountyId: number) =>
|
||||
api.get<BountyComment[]>(`/bounties/${bountyId}/comments/`).then((r) => r.data),
|
||||
createComment: (bountyId: number, data: { content: string; parent_id?: number }) =>
|
||||
api.post<BountyComment>(`/bounties/${bountyId}/comments/`, data).then((r) => r.data),
|
||||
|
||||
listDeliveries: (bountyId: number) =>
|
||||
api.get<BountyDelivery[]>(`/bounties/${bountyId}/deliveries/`).then((r) => r.data),
|
||||
submitDelivery: (bountyId: number, data: { content: string; attachment_url?: string }) =>
|
||||
api.post<BountyDelivery>(`/bounties/${bountyId}/deliveries/`, data).then((r) => r.data),
|
||||
reviewDelivery: (bountyId: number, deliveryId: number, accept: boolean) =>
|
||||
api.post<MessageResponse>(`/bounties/${bountyId}/deliveries/${deliveryId}/review`, { accept }).then((r) => r.data),
|
||||
|
||||
listDisputes: (bountyId: number) =>
|
||||
api.get<BountyDispute[]>(`/bounties/${bountyId}/disputes/`).then((r) => r.data),
|
||||
createDispute: (bountyId: number, data: { reason: string; evidence_url?: string }) =>
|
||||
api.post<BountyDispute>(`/bounties/${bountyId}/disputes/`, data).then((r) => r.data),
|
||||
resolveDispute: (bountyId: number, disputeId: number, data: { resolution: string; accepted: boolean }) =>
|
||||
api.post<MessageResponse>(`/bounties/${bountyId}/disputes/${disputeId}/resolve`, data).then((r) => r.data),
|
||||
|
||||
listReviews: (bountyId: number) =>
|
||||
api.get<BountyReview[]>(`/bounties/${bountyId}/reviews/`).then((r) => r.data),
|
||||
createReview: (bountyId: number, data: { reviewee_id: number; rating: number; comment?: string }) =>
|
||||
api.post<BountyReview>(`/bounties/${bountyId}/reviews/`, data).then((r) => r.data),
|
||||
|
||||
listExtensions: (bountyId: number) =>
|
||||
api.get<BountyExtensionRequest[]>(`/bounties/${bountyId}/extension-requests/`).then((r) => r.data),
|
||||
createExtension: (bountyId: number, data: { proposed_deadline: string; reason?: string }) =>
|
||||
api.post<BountyExtensionRequest>(`/bounties/${bountyId}/extension-requests/`, data).then((r) => r.data),
|
||||
reviewExtension: (bountyId: number, requestId: number, approve: boolean) =>
|
||||
api.post<MessageResponse>(`/bounties/${bountyId}/extension-requests/${requestId}/review`, { approve }).then((r) => r.data),
|
||||
};
|
||||
9
frontend/src/lib/api/categories.ts
Normal file
9
frontend/src/lib/api/categories.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { api } from "./client";
|
||||
import type { Category } from "../types";
|
||||
|
||||
export const categoryApi = {
|
||||
list: () => api.get<Category[]>("/categories/").then((r) => r.data),
|
||||
getBySlug: (slug: string) => api.get<Category>(`/categories/${slug}`).then((r) => r.data),
|
||||
create: (data: { name: string; slug: string; description?: string; icon?: string; parent_id?: number; sort_order?: number }) =>
|
||||
api.post<Category>("/categories/", data).then((r) => r.data),
|
||||
};
|
||||
129
frontend/src/lib/api/client.ts
Normal file
129
frontend/src/lib/api/client.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import axios, { AxiosRequestConfig } from "axios";
|
||||
import { normalizeApiError } from "./errors";
|
||||
|
||||
const defaultTimeout = 12000;
|
||||
export const searchTimeout = 8000;
|
||||
export const uploadTimeout = 30000;
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: "/api",
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout: defaultTimeout,
|
||||
});
|
||||
|
||||
const refreshApi = axios.create({
|
||||
baseURL: "/api",
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout: defaultTimeout,
|
||||
});
|
||||
|
||||
let accessToken: string | null = null;
|
||||
let refreshToken: string | null = null;
|
||||
let refreshPromise: Promise<string | null> | null = null;
|
||||
|
||||
export function setAccessToken(token: string | null) {
|
||||
accessToken = token;
|
||||
if (token) {
|
||||
localStorage.setItem("access_token", token);
|
||||
} else {
|
||||
localStorage.removeItem("access_token");
|
||||
}
|
||||
}
|
||||
|
||||
export function getAccessToken(): string | null {
|
||||
if (!accessToken) {
|
||||
accessToken = localStorage.getItem("access_token");
|
||||
}
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
export function setRefreshToken(token: string | null) {
|
||||
refreshToken = token;
|
||||
if (token) {
|
||||
sessionStorage.setItem("refresh_token", token);
|
||||
} else {
|
||||
sessionStorage.removeItem("refresh_token");
|
||||
}
|
||||
}
|
||||
|
||||
export function getRefreshToken(): string | null {
|
||||
if (!refreshToken) {
|
||||
refreshToken = sessionStorage.getItem("refresh_token");
|
||||
}
|
||||
return refreshToken;
|
||||
}
|
||||
|
||||
export function clearRefreshToken() {
|
||||
refreshToken = null;
|
||||
sessionStorage.removeItem("refresh_token");
|
||||
}
|
||||
|
||||
async function refreshAccessToken() {
|
||||
const token = getRefreshToken();
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = refreshApi
|
||||
.post<{ access_token: string; refresh_token: string; token_type: string }>(
|
||||
"/auth/refresh",
|
||||
{ refresh_token: token }
|
||||
)
|
||||
.then((response) => {
|
||||
setAccessToken(response.data.access_token);
|
||||
setRefreshToken(response.data.refresh_token);
|
||||
return response.data.access_token;
|
||||
})
|
||||
.catch(() => {
|
||||
setAccessToken(null);
|
||||
clearRefreshToken();
|
||||
return null;
|
||||
})
|
||||
.finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
}
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = getAccessToken();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const config = error?.config as (AxiosRequestConfig & { _retry?: boolean }) | undefined;
|
||||
|
||||
if (error?.response?.status === 401 && config && !config._retry) {
|
||||
config._retry = true;
|
||||
const newToken = await refreshAccessToken();
|
||||
if (newToken) {
|
||||
config.headers = { ...(config.headers || {}), Authorization: `Bearer ${newToken}` };
|
||||
return api(config);
|
||||
}
|
||||
setAccessToken(null);
|
||||
clearRefreshToken();
|
||||
}
|
||||
|
||||
if (config && !config._retry && (!error.response || (error.response.status >= 500 && error.response.status < 600))) {
|
||||
const method = (config.method || "get").toLowerCase();
|
||||
if (["get", "head", "options"].includes(method)) {
|
||||
config._retry = true;
|
||||
return api(config);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(normalizeApiError(error));
|
||||
}
|
||||
);
|
||||
52
frontend/src/lib/api/errors.ts
Normal file
52
frontend/src/lib/api/errors.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import axios from "axios";
|
||||
|
||||
export type ApiError = {
|
||||
code: string;
|
||||
message: string;
|
||||
status?: number;
|
||||
details?: unknown;
|
||||
isNetworkError?: boolean;
|
||||
};
|
||||
|
||||
function toMessage(value: unknown) {
|
||||
if (typeof value === "string") return value;
|
||||
if (value && typeof value === "object" && "message" in value) {
|
||||
return String((value as { message?: unknown }).message || "请求失败");
|
||||
}
|
||||
return "请求失败";
|
||||
}
|
||||
|
||||
export function normalizeApiError(error: unknown): ApiError {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const status = error.response?.status;
|
||||
const data = error.response?.data as
|
||||
| { code?: string; message?: string; details?: unknown; status?: number }
|
||||
| undefined;
|
||||
|
||||
if (data?.code || data?.message) {
|
||||
return {
|
||||
code: data.code || "error",
|
||||
message: data.message || "请求失败",
|
||||
status: data.status ?? status,
|
||||
details: data.details,
|
||||
isNetworkError: !error.response,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
code: !error.response ? "network_error" : "error",
|
||||
message: toMessage(error.message),
|
||||
status,
|
||||
isNetworkError: !error.response,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
code: "error",
|
||||
message: toMessage(error),
|
||||
};
|
||||
}
|
||||
|
||||
export function isApiError(error: unknown): error is ApiError {
|
||||
return Boolean(error && typeof error === "object" && "code" in error && "message" in error);
|
||||
}
|
||||
55
frontend/src/lib/api/favorites.ts
Normal file
55
frontend/src/lib/api/favorites.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { api } from "./client";
|
||||
import type {
|
||||
Favorite,
|
||||
FavoriteTag,
|
||||
MessageResponse,
|
||||
PaginatedResponse,
|
||||
PriceHistory,
|
||||
PriceMonitor,
|
||||
} from "../types";
|
||||
|
||||
export const favoriteApi = {
|
||||
list: (params?: { tag_id?: number; page?: number }) =>
|
||||
api.get<PaginatedResponse<Favorite>>("/favorites/", { params }).then((r) => r.data),
|
||||
exportCsv: () => api.get<Blob>("/favorites/export/", { responseType: "blob" }).then((r) => r.data),
|
||||
get: (id: number) => api.get<Favorite>(`/favorites/${id}`).then((r) => r.data),
|
||||
check: (productId: number, websiteId: number) =>
|
||||
api.get<{ is_favorited: boolean; favorite_id: number | null }>(
|
||||
"/favorites/check/",
|
||||
{ params: { product_id: productId, website_id: websiteId } }
|
||||
).then((r) => r.data),
|
||||
add: (data: { product_id: number; website_id: number }) =>
|
||||
api.post<Favorite>("/favorites/", data).then((r) => r.data),
|
||||
remove: (id: number) => api.delete<MessageResponse>(`/favorites/${id}`).then((r) => r.data),
|
||||
|
||||
listTags: () => api.get<FavoriteTag[]>("/favorites/tags/").then((r) => r.data),
|
||||
createTag: (data: { name: string; color?: string; description?: string }) =>
|
||||
api.post<FavoriteTag>("/favorites/tags/", data).then((r) => r.data),
|
||||
updateTag: (id: number, data: { name?: string; color?: string; description?: string }) =>
|
||||
api.patch<FavoriteTag>(`/favorites/tags/${id}`, data).then((r) => r.data),
|
||||
deleteTag: (id: number) => api.delete<MessageResponse>(`/favorites/tags/${id}`).then((r) => r.data),
|
||||
addTagToFavorite: (favoriteId: number, tagId: number) =>
|
||||
api.post<MessageResponse>(`/favorites/${favoriteId}/tags/`, { tag_id: tagId }).then((r) => r.data),
|
||||
removeTagFromFavorite: (favoriteId: number, tagId: number) =>
|
||||
api.delete<MessageResponse>(`/favorites/${favoriteId}/tags/${tagId}`).then((r) => r.data),
|
||||
|
||||
getMonitor: (favoriteId: number) =>
|
||||
api.get<PriceMonitor | null>(`/favorites/${favoriteId}/monitor/`).then((r) => r.data),
|
||||
createMonitor: (favoriteId: number, data: { target_price?: string; is_active?: boolean; notify_enabled?: boolean; notify_on_target?: boolean }) =>
|
||||
api.post<PriceMonitor>(`/favorites/${favoriteId}/monitor/`, data).then((r) => r.data),
|
||||
updateMonitor: (favoriteId: number, data: { target_price?: string; is_active?: boolean; notify_enabled?: boolean; notify_on_target?: boolean }) =>
|
||||
api.patch<PriceMonitor>(`/favorites/${favoriteId}/monitor/`, data).then((r) => r.data),
|
||||
deleteMonitor: (favoriteId: number) =>
|
||||
api.delete<MessageResponse>(`/favorites/${favoriteId}/monitor/`).then((r) => r.data),
|
||||
getMonitorHistory: (favoriteId: number, page?: number) =>
|
||||
api.get<PaginatedResponse<PriceHistory>>(
|
||||
`/favorites/${favoriteId}/monitor/history/`,
|
||||
{ params: { page } }
|
||||
).then((r) => r.data),
|
||||
recordPrice: (favoriteId: number, price: string) =>
|
||||
api.post<PriceHistory>(`/favorites/${favoriteId}/monitor/record/`, { price }).then((r) => r.data),
|
||||
refreshMonitor: (favoriteId: number) =>
|
||||
api.post<PriceMonitor>(`/favorites/${favoriteId}/monitor/refresh/`).then((r) => r.data),
|
||||
listAllMonitors: (page?: number) =>
|
||||
api.get<PaginatedResponse<PriceMonitor>>("/favorites/monitors/all/", { params: { page } }).then((r) => r.data),
|
||||
};
|
||||
21
frontend/src/lib/api/friends.ts
Normal file
21
frontend/src/lib/api/friends.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { api, searchTimeout } from "./client";
|
||||
import type { Friend, FriendRequest, UserBrief } from "../types";
|
||||
|
||||
export const friendApi = {
|
||||
list: () => api.get<Friend[]>("/friends/").then((r) => r.data),
|
||||
incoming: () => api.get<FriendRequest[]>("/friends/requests/incoming").then((r) => r.data),
|
||||
outgoing: () => api.get<FriendRequest[]>("/friends/requests/outgoing").then((r) => r.data),
|
||||
sendRequest: (data: { receiver_id: number }) =>
|
||||
api.post<FriendRequest>("/friends/requests", data).then((r) => r.data),
|
||||
acceptRequest: (requestId: number) =>
|
||||
api.post<FriendRequest>(`/friends/requests/${requestId}/accept`).then((r) => r.data),
|
||||
rejectRequest: (requestId: number) =>
|
||||
api.post<FriendRequest>(`/friends/requests/${requestId}/reject`).then((r) => r.data),
|
||||
cancelRequest: (requestId: number) =>
|
||||
api.post<FriendRequest>(`/friends/requests/${requestId}/cancel`).then((r) => r.data),
|
||||
searchUsers: (q: string, limit?: number) =>
|
||||
api.get<UserBrief[]>(
|
||||
"/friends/search",
|
||||
{ params: { q, limit }, timeout: searchTimeout }
|
||||
).then((r) => r.data),
|
||||
};
|
||||
46
frontend/src/lib/api/index.ts
Normal file
46
frontend/src/lib/api/index.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export { api, setAccessToken, setRefreshToken, clearRefreshToken } from "./client";
|
||||
export { authApi } from "./auth";
|
||||
export { friendApi } from "./friends";
|
||||
export { categoryApi } from "./categories";
|
||||
export { websiteApi } from "./websites";
|
||||
export { productApi } from "./products";
|
||||
export { bountyApi } from "./bounties";
|
||||
export { favoriteApi } from "./favorites";
|
||||
export { notificationApi } from "./notifications";
|
||||
export { searchApi } from "./search";
|
||||
export { paymentApi } from "./payments";
|
||||
export { adminApi } from "./admin";
|
||||
export { isApiError, normalizeApiError, type ApiError } from "./errors";
|
||||
|
||||
export type {
|
||||
User,
|
||||
UserBrief,
|
||||
Category,
|
||||
Website,
|
||||
Product,
|
||||
ProductPrice,
|
||||
ProductWithPrices,
|
||||
Bounty,
|
||||
BountyApplication,
|
||||
BountyComment,
|
||||
Favorite,
|
||||
FavoriteTag,
|
||||
PriceMonitor,
|
||||
PriceHistory,
|
||||
Notification,
|
||||
NotificationPreference,
|
||||
BountyDelivery,
|
||||
BountyDispute,
|
||||
BountyReview,
|
||||
BountyExtensionRequest,
|
||||
SearchResults,
|
||||
AdminUser,
|
||||
AdminBounty,
|
||||
AdminPaymentEvent,
|
||||
PaginatedResponse,
|
||||
MessageResponse,
|
||||
FriendRequest,
|
||||
Friend,
|
||||
TokenResponse,
|
||||
OAuthCallbackData,
|
||||
} from "../types";
|
||||
16
frontend/src/lib/api/notifications.ts
Normal file
16
frontend/src/lib/api/notifications.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { api } from "./client";
|
||||
import type { MessageResponse, Notification, NotificationPreference, PaginatedResponse } from "../types";
|
||||
|
||||
export const notificationApi = {
|
||||
list: (params?: { is_read?: boolean; type?: string; start?: string; end?: string; page?: number }) =>
|
||||
api.get<PaginatedResponse<Notification>>("/notifications/", { params }).then((r) => r.data),
|
||||
exportCsv: () => api.get<Blob>("/notifications/export/", { responseType: "blob" }).then((r) => r.data),
|
||||
unreadCount: () => api.get<{ count: number }>("/notifications/unread-count/").then((r) => r.data),
|
||||
markAsRead: (id: number) => api.post<MessageResponse>(`/notifications/${id}/read/`).then((r) => r.data),
|
||||
markAllAsRead: () => api.post<MessageResponse>("/notifications/read-all/").then((r) => r.data),
|
||||
delete: (id: number) => api.delete<MessageResponse>(`/notifications/${id}`).then((r) => r.data),
|
||||
deleteAllRead: () => api.delete<MessageResponse>("/notifications/").then((r) => r.data),
|
||||
getPreferences: () => api.get<NotificationPreference>("/notifications/preferences/").then((r) => r.data),
|
||||
updatePreferences: (data: { enable_bounty?: boolean; enable_price_alert?: boolean; enable_system?: boolean }) =>
|
||||
api.patch<NotificationPreference>("/notifications/preferences/", data).then((r) => r.data),
|
||||
};
|
||||
19
frontend/src/lib/api/payments.ts
Normal file
19
frontend/src/lib/api/payments.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { api } from "./client";
|
||||
import type { MessageResponse } from "../types";
|
||||
|
||||
export const paymentApi = {
|
||||
createEscrow: (data: { bounty_id: number; success_url: string; cancel_url: string }) =>
|
||||
api.post<{ checkout_url: string; session_id: string }>("/payments/escrow/", data).then((r) => r.data),
|
||||
getConnectStatus: () =>
|
||||
api.get<{ has_account: boolean; account_id: string | null; is_complete: boolean; dashboard_url: string | null }>(
|
||||
"/payments/connect/status/"
|
||||
).then((r) => r.data),
|
||||
setupConnectAccount: (returnUrl: string, refreshUrl: string) =>
|
||||
api.post<{ onboarding_url: string; account_id: string }>(
|
||||
"/payments/connect/setup/",
|
||||
null,
|
||||
{ params: { return_url: returnUrl, refresh_url: refreshUrl } }
|
||||
).then((r) => r.data),
|
||||
releasePayout: (bountyId: number) =>
|
||||
api.post<MessageResponse>(`/payments/${bountyId}/release/`).then((r) => r.data),
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user