haha
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,6 +2,9 @@
|
|||||||
**/node_modules
|
**/node_modules
|
||||||
.pnpm-store/
|
.pnpm-store/
|
||||||
|
|
||||||
|
# Vite cache
|
||||||
|
.vite/
|
||||||
|
|
||||||
# Build outputs
|
# Build outputs
|
||||||
dist/
|
dist/
|
||||||
build/
|
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.
|
Admin API routes for managing core data.
|
||||||
"""
|
"""
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
from django.utils import timezone
|
||||||
from ninja import Router, Schema
|
from ninja import Router, Schema
|
||||||
from ninja.errors import HttpError
|
from ninja.errors import HttpError
|
||||||
from ninja_jwt.authentication import JWTAuth
|
from ninja_jwt.authentication import JWTAuth
|
||||||
|
from ninja.pagination import paginate, PageNumberPagination
|
||||||
|
|
||||||
from apps.users.models import User
|
from apps.users.models import User
|
||||||
from apps.products.models import Product, Website, Category
|
from apps.products.models import Product, Website, Category
|
||||||
@@ -14,7 +17,7 @@ router = Router()
|
|||||||
|
|
||||||
|
|
||||||
def require_admin(user):
|
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, "仅管理员可访问")
|
raise HttpError(403, "仅管理员可访问")
|
||||||
|
|
||||||
|
|
||||||
@@ -22,10 +25,9 @@ class UserOut(Schema):
|
|||||||
id: int
|
id: int
|
||||||
open_id: str
|
open_id: str
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
email: Optional[str] = None
|
|
||||||
role: str
|
role: str
|
||||||
is_active: bool
|
is_active: bool
|
||||||
created_at: str
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class UserUpdateIn(Schema):
|
class UserUpdateIn(Schema):
|
||||||
@@ -47,7 +49,14 @@ class BountyAdminOut(Schema):
|
|||||||
acceptor_id: Optional[int] = None
|
acceptor_id: Optional[int] = None
|
||||||
is_escrowed: bool
|
is_escrowed: bool
|
||||||
is_paid: 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):
|
class PaymentEventOut(Schema):
|
||||||
@@ -56,7 +65,10 @@ class PaymentEventOut(Schema):
|
|||||||
event_type: str
|
event_type: str
|
||||||
bounty_id: Optional[int] = None
|
bounty_id: Optional[int] = None
|
||||||
success: bool
|
success: bool
|
||||||
processed_at: str
|
processed_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
class DisputeOut(Schema):
|
class DisputeOut(Schema):
|
||||||
@@ -64,116 +76,160 @@ class DisputeOut(Schema):
|
|||||||
bounty_id: int
|
bounty_id: int
|
||||||
initiator_id: int
|
initiator_id: int
|
||||||
status: str
|
status: str
|
||||||
created_at: str
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
@router.get("/users/", response=List[UserOut], auth=JWTAuth())
|
@router.get("/users/", response=List[UserOut], auth=JWTAuth())
|
||||||
|
@paginate(PageNumberPagination, page_size=20)
|
||||||
def list_users(request):
|
def list_users(request):
|
||||||
require_admin(request.auth)
|
require_admin(request.auth)
|
||||||
users = User.objects.all().order_by('-created_at')
|
return 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
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/users/{user_id}", response=UserOut, auth=JWTAuth())
|
@router.patch("/users/{user_id}", response=UserOut, auth=JWTAuth())
|
||||||
def update_user(request, user_id: int, data: UserUpdateIn):
|
def update_user(request, user_id: int, data: UserUpdateIn):
|
||||||
require_admin(request.auth)
|
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)
|
update_data = data.dict(exclude_unset=True)
|
||||||
for key, value in update_data.items():
|
for key, value in update_data.items():
|
||||||
setattr(user, key, value)
|
setattr(user, key, value)
|
||||||
user.save()
|
user.save()
|
||||||
return UserOut(
|
return user
|
||||||
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(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/categories/", response=List[SimpleOut], auth=JWTAuth())
|
@router.get("/categories/", response=List[SimpleOut], auth=JWTAuth())
|
||||||
|
@paginate(PageNumberPagination, page_size=50)
|
||||||
def list_categories(request):
|
def list_categories(request):
|
||||||
require_admin(request.auth)
|
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())
|
@router.get("/websites/", response=List[SimpleOut], auth=JWTAuth())
|
||||||
|
@paginate(PageNumberPagination, page_size=50)
|
||||||
def list_websites(request):
|
def list_websites(request):
|
||||||
require_admin(request.auth)
|
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())
|
@router.get("/products/", response=List[SimpleOut], auth=JWTAuth())
|
||||||
|
@paginate(PageNumberPagination, page_size=50)
|
||||||
def list_products(request):
|
def list_products(request):
|
||||||
require_admin(request.auth)
|
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())
|
@router.get("/bounties/", response=List[BountyAdminOut], auth=JWTAuth())
|
||||||
|
@paginate(PageNumberPagination, page_size=20)
|
||||||
def list_bounties(request, status: Optional[str] = None):
|
def list_bounties(request, status: Optional[str] = None):
|
||||||
require_admin(request.auth)
|
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:
|
if status:
|
||||||
queryset = queryset.filter(status=status)
|
queryset = queryset.filter(status=status)
|
||||||
return [
|
return queryset
|
||||||
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
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/disputes/", response=List[DisputeOut], auth=JWTAuth())
|
@router.get("/disputes/", response=List[DisputeOut], auth=JWTAuth())
|
||||||
|
@paginate(PageNumberPagination, page_size=20)
|
||||||
def list_disputes(request, status: Optional[str] = None):
|
def list_disputes(request, status: Optional[str] = None):
|
||||||
require_admin(request.auth)
|
require_admin(request.auth)
|
||||||
disputes = BountyDispute.objects.all().order_by('-created_at')
|
disputes = BountyDispute.objects.all().order_by('-created_at')
|
||||||
if status:
|
if status:
|
||||||
disputes = disputes.filter(status=status)
|
disputes = disputes.filter(status=status)
|
||||||
return [
|
return disputes
|
||||||
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
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/payments/", response=List[PaymentEventOut], auth=JWTAuth())
|
@router.get("/payments/", response=List[PaymentEventOut], auth=JWTAuth())
|
||||||
|
@paginate(PageNumberPagination, page_size=20)
|
||||||
def list_payment_events(request):
|
def list_payment_events(request):
|
||||||
require_admin(request.auth)
|
require_admin(request.auth)
|
||||||
events = PaymentEvent.objects.all().order_by('-processed_at')
|
return PaymentEvent.objects.all().order_by('-processed_at')
|
||||||
return [
|
|
||||||
PaymentEventOut(
|
|
||||||
id=e.id,
|
# ==================== Product Review ====================
|
||||||
event_id=e.event_id,
|
|
||||||
event_type=e.event_type,
|
class ProductAdminOut(Schema):
|
||||||
bounty_id=e.bounty_id,
|
"""Product admin output schema with all fields."""
|
||||||
success=e.success,
|
id: int
|
||||||
processed_at=e.processed_at.isoformat(),
|
name: str
|
||||||
)
|
description: Optional[str] = None
|
||||||
for e in events
|
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.errors import HttpError
|
||||||
from ninja_jwt.authentication import JWTAuth
|
from ninja_jwt.authentication import JWTAuth
|
||||||
from ninja.pagination import paginate, PageNumberPagination
|
from ninja.pagination import paginate, PageNumberPagination
|
||||||
|
from django.conf import settings
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import Count, Q
|
from django.db.models import Count, Q
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
@@ -32,8 +33,9 @@ from .schemas import (
|
|||||||
BountyReviewOut, BountyReviewIn,
|
BountyReviewOut, BountyReviewIn,
|
||||||
BountyExtensionRequestOut, BountyExtensionRequestIn, BountyExtensionReviewIn,
|
BountyExtensionRequestOut, BountyExtensionRequestIn, BountyExtensionReviewIn,
|
||||||
)
|
)
|
||||||
from apps.users.schemas import UserOut
|
from apps.common.serializers import serialize_user, serialize_bounty
|
||||||
from apps.notifications.models import Notification, NotificationPreference
|
from apps.notifications.models import Notification
|
||||||
|
from apps.notifications.utils import should_notify
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
|
|
||||||
@@ -51,84 +53,17 @@ def parse_reward(raw_reward) -> Decimal:
|
|||||||
raise ValueError("reward must be a valid number")
|
raise ValueError("reward must be a valid number")
|
||||||
# Quantize to 2 decimal places
|
# Quantize to 2 decimal places
|
||||||
value = value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
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
|
min_reward = getattr(settings, "BOUNTY_MIN_REWARD", Decimal("0.01"))
|
||||||
# Max value: 99999999.99
|
max_reward = getattr(settings, "BOUNTY_MAX_REWARD", Decimal("99999999.99"))
|
||||||
if value < Decimal("0.01"):
|
if value < min_reward:
|
||||||
raise ValueError("reward must be at least 0.01")
|
raise ValueError(f"reward must be at least {min_reward}")
|
||||||
if value > Decimal("99999999.99"):
|
if value > max_reward:
|
||||||
raise ValueError("reward exceeds maximum allowed value")
|
raise ValueError("reward exceeds maximum allowed value")
|
||||||
return value
|
return value
|
||||||
except InvalidOperation:
|
except InvalidOperation:
|
||||||
raise ValueError("reward must be a valid number")
|
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 ====================
|
# ==================== Bounty Routes ====================
|
||||||
|
|
||||||
@router.get("/", response=List[BountyWithDetailsOut])
|
@router.get("/", response=List[BountyWithDetailsOut])
|
||||||
@@ -150,8 +85,8 @@ def list_bounties(request, filters: BountyFilter = Query(...)):
|
|||||||
queryset = queryset.filter(publisher_id=filters.publisher_id)
|
queryset = queryset.filter(publisher_id=filters.publisher_id)
|
||||||
if filters.acceptor_id:
|
if filters.acceptor_id:
|
||||||
queryset = queryset.filter(acceptor_id=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])
|
@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))
|
.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())
|
@router.get("/my-published/", response=List[BountyWithDetailsOut], auth=JWTAuth())
|
||||||
@@ -181,7 +116,7 @@ def my_published_bounties(request):
|
|||||||
)
|
)
|
||||||
.filter(publisher=request.auth)
|
.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())
|
@router.get("/my-accepted/", response=List[BountyWithDetailsOut], auth=JWTAuth())
|
||||||
@@ -196,7 +131,7 @@ def my_accepted_bounties(request):
|
|||||||
)
|
)
|
||||||
.filter(acceptor=request.auth)
|
.filter(acceptor=request.auth)
|
||||||
)
|
)
|
||||||
return [serialize_bounty(b, include_counts=True) for b in queryset]
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{bounty_id}", response=BountyWithDetailsOut)
|
@router.get("/{bounty_id}", response=BountyWithDetailsOut)
|
||||||
@@ -215,6 +150,21 @@ def get_bounty(request, bounty_id: int):
|
|||||||
@router.post("/", response=BountyOut, auth=JWTAuth())
|
@router.post("/", response=BountyOut, auth=JWTAuth())
|
||||||
def create_bounty(request, data: BountyIn):
|
def create_bounty(request, data: BountyIn):
|
||||||
"""Create a new bounty."""
|
"""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()
|
payload = data.dict()
|
||||||
try:
|
try:
|
||||||
payload["reward"] = parse_reward(payload.get("reward"))
|
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]:
|
if bounty.status not in [Bounty.Status.OPEN, Bounty.Status.IN_PROGRESS]:
|
||||||
raise HttpError(400, "无法取消此悬赏")
|
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.status = Bounty.Status.CANCELLED
|
||||||
bounty.save()
|
bounty.save()
|
||||||
|
|
||||||
@@ -273,7 +230,11 @@ def cancel_bounty(request, bounty_id: int):
|
|||||||
related_type="bounty",
|
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())
|
@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())
|
@router.post("/{bounty_id}/applications/", response=BountyApplicationOut, auth=JWTAuth())
|
||||||
def submit_application(request, bounty_id: int, data: BountyApplicationIn):
|
def submit_application(request, bounty_id: int, data: BountyApplicationIn):
|
||||||
"""Submit an application for a bounty."""
|
"""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)
|
bounty = get_object_or_404(Bounty, id=bounty_id)
|
||||||
|
|
||||||
if bounty.status != Bounty.Status.OPEN:
|
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())
|
@router.post("/{bounty_id}/applications/{application_id}/accept", response=MessageOut, auth=JWTAuth())
|
||||||
def accept_application(request, bounty_id: int, application_id: int):
|
def accept_application(request, bounty_id: int, application_id: int):
|
||||||
"""Accept an application (only by bounty publisher)."""
|
"""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():
|
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
|
# Accept this application
|
||||||
app.status = BountyApplication.Status.ACCEPTED
|
app.status = BountyApplication.Status.ACCEPTED
|
||||||
app.save()
|
app.save()
|
||||||
@@ -426,7 +401,7 @@ def accept_application(request, bounty_id: int, application_id: int):
|
|||||||
bounty.status = Bounty.Status.IN_PROGRESS
|
bounty.status = Bounty.Status.IN_PROGRESS
|
||||||
bounty.save()
|
bounty.save()
|
||||||
|
|
||||||
# Notify acceptor
|
# Notify acceptor (outside transaction for better performance)
|
||||||
if should_notify(app.applicant, Notification.Type.BOUNTY_ACCEPTED):
|
if should_notify(app.applicant, Notification.Type.BOUNTY_ACCEPTED):
|
||||||
Notification.objects.create(
|
Notification.objects.create(
|
||||||
user=app.applicant,
|
user=app.applicant,
|
||||||
@@ -469,8 +444,14 @@ def list_comments(request, bounty_id: int):
|
|||||||
@router.post("/{bounty_id}/comments/", response=BountyCommentOut, auth=JWTAuth())
|
@router.post("/{bounty_id}/comments/", response=BountyCommentOut, auth=JWTAuth())
|
||||||
def create_comment(request, bounty_id: int, data: BountyCommentIn):
|
def create_comment(request, bounty_id: int, data: BountyCommentIn):
|
||||||
"""Create a comment on a bounty."""
|
"""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(
|
comment = BountyComment.objects.create(
|
||||||
bounty=bounty,
|
bounty=bounty,
|
||||||
user=request.auth,
|
user=request.auth,
|
||||||
@@ -491,7 +472,9 @@ def create_comment(request, bounty_id: int, data: BountyCommentIn):
|
|||||||
|
|
||||||
# Notify parent comment author (if replying)
|
# Notify parent comment author (if replying)
|
||||||
if data.parent_id:
|
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):
|
if parent.user_id != request.auth.id and should_notify(parent.user, Notification.Type.NEW_COMMENT):
|
||||||
Notification.objects.create(
|
Notification.objects.create(
|
||||||
user=parent.user,
|
user=parent.user,
|
||||||
@@ -627,8 +610,28 @@ def list_disputes(request, bounty_id: int):
|
|||||||
def create_dispute(request, bounty_id: int, data: BountyDisputeIn):
|
def create_dispute(request, bounty_id: int, data: BountyDisputeIn):
|
||||||
"""Create a dispute (publisher or acceptor)."""
|
"""Create a dispute (publisher or acceptor)."""
|
||||||
bounty = get_object_or_404(Bounty, id=bounty_id)
|
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, "无权限发起争议")
|
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(
|
dispute = BountyDispute.objects.create(
|
||||||
bounty=bounty,
|
bounty=bounty,
|
||||||
initiator=request.auth,
|
initiator=request.auth,
|
||||||
@@ -665,13 +668,47 @@ def resolve_dispute(request, bounty_id: int, dispute_id: int, data: BountyDisput
|
|||||||
"""Resolve dispute (admin only)."""
|
"""Resolve dispute (admin only)."""
|
||||||
if request.auth.role != 'admin':
|
if request.auth.role != 'admin':
|
||||||
raise HttpError(403, "仅管理员可处理争议")
|
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)
|
dispute = get_object_or_404(BountyDispute, id=dispute_id, bounty_id=bounty_id)
|
||||||
|
|
||||||
if dispute.status != BountyDispute.Status.OPEN:
|
if dispute.status != BountyDispute.Status.OPEN:
|
||||||
raise HttpError(400, "争议已处理")
|
raise HttpError(400, "争议已处理")
|
||||||
|
|
||||||
dispute.status = BountyDispute.Status.RESOLVED if data.accepted else BountyDispute.Status.REJECTED
|
dispute.status = BountyDispute.Status.RESOLVED if data.accepted else BountyDispute.Status.REJECTED
|
||||||
dispute.resolution = data.resolution
|
dispute.resolution = data.resolution
|
||||||
dispute.resolved_at = timezone.now()
|
dispute.resolved_at = timezone.now()
|
||||||
dispute.save()
|
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)
|
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 = '悬赏'
|
||||||
verbose_name_plural = '悬赏'
|
verbose_name_plural = '悬赏'
|
||||||
ordering = ['-created_at']
|
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):
|
def __str__(self):
|
||||||
return self.title
|
return self.title
|
||||||
@@ -110,6 +115,10 @@ class BountyApplication(models.Model):
|
|||||||
verbose_name = '悬赏申请'
|
verbose_name = '悬赏申请'
|
||||||
verbose_name_plural = '悬赏申请'
|
verbose_name_plural = '悬赏申请'
|
||||||
unique_together = ['bounty', 'applicant']
|
unique_together = ['bounty', 'applicant']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["bounty", "status"]),
|
||||||
|
models.Index(fields=["applicant", "status"]),
|
||||||
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.applicant} -> {self.bounty.title}"
|
return f"{self.applicant} -> {self.bounty.title}"
|
||||||
@@ -148,6 +157,10 @@ class BountyComment(models.Model):
|
|||||||
verbose_name = '悬赏评论'
|
verbose_name = '悬赏评论'
|
||||||
verbose_name_plural = '悬赏评论'
|
verbose_name_plural = '悬赏评论'
|
||||||
ordering = ['created_at']
|
ordering = ['created_at']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["bounty", "created_at"]),
|
||||||
|
models.Index(fields=["parent", "created_at"]),
|
||||||
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.user} on {self.bounty.title}"
|
return f"{self.user} on {self.bounty.title}"
|
||||||
@@ -190,6 +203,10 @@ class BountyDelivery(models.Model):
|
|||||||
verbose_name = '悬赏交付'
|
verbose_name = '悬赏交付'
|
||||||
verbose_name_plural = '悬赏交付'
|
verbose_name_plural = '悬赏交付'
|
||||||
ordering = ['-submitted_at']
|
ordering = ['-submitted_at']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["bounty", "status"]),
|
||||||
|
models.Index(fields=["submitted_at"]),
|
||||||
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.bounty.title} - {self.submitter}"
|
return f"{self.bounty.title} - {self.submitter}"
|
||||||
@@ -233,6 +250,10 @@ class BountyDispute(models.Model):
|
|||||||
verbose_name = '悬赏争议'
|
verbose_name = '悬赏争议'
|
||||||
verbose_name_plural = '悬赏争议'
|
verbose_name_plural = '悬赏争议'
|
||||||
ordering = ['-created_at']
|
ordering = ['-created_at']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["bounty", "status"]),
|
||||||
|
models.Index(fields=["status", "created_at"]),
|
||||||
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.bounty.title} - {self.initiator}"
|
return f"{self.bounty.title} - {self.initiator}"
|
||||||
@@ -270,6 +291,10 @@ class BountyReview(models.Model):
|
|||||||
verbose_name_plural = '悬赏评价'
|
verbose_name_plural = '悬赏评价'
|
||||||
unique_together = ['bounty', 'reviewer']
|
unique_together = ['bounty', 'reviewer']
|
||||||
ordering = ['-created_at']
|
ordering = ['-created_at']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["bounty", "created_at"]),
|
||||||
|
models.Index(fields=["reviewee", "created_at"]),
|
||||||
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.bounty.title} - {self.reviewer}"
|
return f"{self.bounty.title} - {self.reviewer}"
|
||||||
@@ -341,6 +366,10 @@ class BountyExtensionRequest(models.Model):
|
|||||||
verbose_name = '延期申请'
|
verbose_name = '延期申请'
|
||||||
verbose_name_plural = '延期申请'
|
verbose_name_plural = '延期申请'
|
||||||
ordering = ['-created_at']
|
ordering = ['-created_at']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["bounty", "status"]),
|
||||||
|
models.Index(fields=["requester", "status"]),
|
||||||
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.bounty.title} - {self.requester}"
|
return f"{self.bounty.title} - {self.requester}"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Stripe payment integration for bounties.
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from ninja import Router
|
from ninja import Router
|
||||||
|
from ninja.errors import HttpError
|
||||||
from ninja_jwt.authentication import JWTAuth
|
from ninja_jwt.authentication import JWTAuth
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
@@ -15,7 +16,8 @@ import json
|
|||||||
|
|
||||||
from .models import Bounty, PaymentEvent
|
from .models import Bounty, PaymentEvent
|
||||||
from apps.users.models import User
|
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()
|
router = Router()
|
||||||
|
|
||||||
@@ -23,24 +25,6 @@ router = Router()
|
|||||||
stripe.api_key = settings.STRIPE_SECRET_KEY
|
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:
|
class PaymentSchemas:
|
||||||
"""Payment related schemas."""
|
"""Payment related schemas."""
|
||||||
|
|
||||||
@@ -81,13 +65,13 @@ def create_escrow(request, data: PaymentSchemas.EscrowIn):
|
|||||||
bounty = get_object_or_404(Bounty, id=data.bounty_id)
|
bounty = get_object_or_404(Bounty, id=data.bounty_id)
|
||||||
|
|
||||||
if bounty.publisher_id != request.auth.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:
|
if bounty.is_escrowed:
|
||||||
return {"error": "Bounty is already escrowed"}, 400
|
raise HttpError(400, "Bounty is already escrowed")
|
||||||
|
|
||||||
if bounty.status != Bounty.Status.OPEN:
|
if bounty.status != Bounty.Status.OPEN:
|
||||||
return {"error": "Can only escrow open bounties"}, 400
|
raise HttpError(400, "Can only escrow open bounties")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Create or get Stripe customer
|
# Create or get Stripe customer
|
||||||
@@ -137,7 +121,7 @@ def create_escrow(request, data: PaymentSchemas.EscrowIn):
|
|||||||
)
|
)
|
||||||
|
|
||||||
except stripe.error.StripeError as e:
|
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())
|
@router.get("/connect/status/", response=PaymentSchemas.ConnectStatusOut, auth=JWTAuth())
|
||||||
@@ -175,7 +159,7 @@ def get_connect_status(request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
except stripe.error.StripeError as e:
|
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())
|
@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:
|
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())
|
@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)
|
bounty = get_object_or_404(Bounty, id=bounty_id)
|
||||||
|
|
||||||
if bounty.publisher_id != request.auth.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:
|
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:
|
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:
|
if not bounty.is_escrowed:
|
||||||
return {"error": "Bounty is not escrowed"}, 400
|
raise HttpError(400, "Bounty is not escrowed")
|
||||||
|
|
||||||
if not bounty.acceptor:
|
if not bounty.acceptor:
|
||||||
return {"error": "No acceptor to pay"}, 400
|
raise HttpError(400, "No acceptor to pay")
|
||||||
|
|
||||||
acceptor = bounty.acceptor
|
acceptor = bounty.acceptor
|
||||||
if not acceptor.stripe_account_id:
|
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:
|
try:
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
@@ -249,7 +233,7 @@ def release_payout(request, bounty_id: int):
|
|||||||
stripe.PaymentIntent.capture(bounty.stripe_payment_intent_id)
|
stripe.PaymentIntent.capture(bounty.stripe_payment_intent_id)
|
||||||
|
|
||||||
# Calculate payout amount (minus platform fee if any)
|
# 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)
|
payout_amount = bounty.reward * (1 - platform_fee_percent)
|
||||||
|
|
||||||
# Create transfer to acceptor
|
# Create transfer to acceptor
|
||||||
@@ -282,7 +266,7 @@ def release_payout(request, bounty_id: int):
|
|||||||
return PaymentSchemas.MessageOut(message="赏金已释放", success=True)
|
return PaymentSchemas.MessageOut(message="赏金已释放", success=True)
|
||||||
|
|
||||||
except stripe.error.StripeError as e:
|
except stripe.error.StripeError as e:
|
||||||
return {"error": str(e)}, 400
|
raise HttpError(400, str(e))
|
||||||
|
|
||||||
|
|
||||||
def handle_webhook(request: HttpRequest) -> HttpResponse:
|
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,
|
MessageOut,
|
||||||
)
|
)
|
||||||
from apps.products.models import Product, Website, ProductPrice
|
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()
|
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):
|
def record_price_for_monitor(monitor: PriceMonitor, price: Decimal):
|
||||||
"""Record price history and update monitor stats."""
|
"""Record price history and update monitor stats."""
|
||||||
price_change = None
|
price_change = None
|
||||||
@@ -90,7 +86,7 @@ def record_price_for_monitor(monitor: PriceMonitor, price: Decimal):
|
|||||||
price <= monitor.target_price and
|
price <= monitor.target_price and
|
||||||
(monitor.last_notified_price is None or price < monitor.last_notified_price)
|
(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(
|
Notification.objects.create(
|
||||||
user=monitor.user,
|
user=monitor.user,
|
||||||
type=Notification.Type.PRICE_ALERT,
|
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')
|
).prefetch_related('tag_mappings', 'tag_mappings__tag')
|
||||||
|
|
||||||
if tag_id:
|
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]
|
return [serialize_favorite(f) for f in queryset]
|
||||||
|
|
||||||
@@ -197,22 +193,13 @@ def get_favorite(request, favorite_id: int):
|
|||||||
@router.get("/check/", auth=JWTAuth())
|
@router.get("/check/", auth=JWTAuth())
|
||||||
def is_favorited(request, product_id: int, website_id: int):
|
def is_favorited(request, product_id: int, website_id: int):
|
||||||
"""Check if a product is favorited."""
|
"""Check if a product is favorited."""
|
||||||
exists = Favorite.objects.filter(
|
favorite_id = Favorite.objects.filter(
|
||||||
user=request.auth,
|
user=request.auth,
|
||||||
product_id=product_id,
|
product_id=product_id,
|
||||||
website_id=website_id
|
website_id=website_id
|
||||||
).exists()
|
).values_list("id", flat=True).first()
|
||||||
|
|
||||||
favorite_id = None
|
return {"is_favorited": bool(favorite_id), "favorite_id": favorite_id}
|
||||||
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}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/", response=FavoriteOut, auth=JWTAuth())
|
@router.post("/", response=FavoriteOut, auth=JWTAuth())
|
||||||
@@ -274,7 +261,7 @@ def create_tag(request, data: FavoriteTagIn):
|
|||||||
"""Create a new tag."""
|
"""Create a new tag."""
|
||||||
# Check if tag with same name exists
|
# Check if tag with same name exists
|
||||||
if FavoriteTag.objects.filter(user=request.auth, name=data.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(
|
tag = FavoriteTag.objects.create(
|
||||||
user=request.auth,
|
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 = '收藏'
|
||||||
verbose_name_plural = '收藏'
|
verbose_name_plural = '收藏'
|
||||||
unique_together = ['user', 'product', 'website']
|
unique_together = ['user', 'product', 'website']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["user", "created_at"]),
|
||||||
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.user} - {self.product.name}"
|
return f"{self.user} - {self.product.name}"
|
||||||
@@ -59,6 +62,9 @@ class FavoriteTag(models.Model):
|
|||||||
verbose_name = '收藏标签'
|
verbose_name = '收藏标签'
|
||||||
verbose_name_plural = '收藏标签'
|
verbose_name_plural = '收藏标签'
|
||||||
unique_together = ['user', 'name']
|
unique_together = ['user', 'name']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["user", "created_at"]),
|
||||||
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
@@ -87,6 +93,9 @@ class FavoriteTagMapping(models.Model):
|
|||||||
verbose_name = '收藏标签映射'
|
verbose_name = '收藏标签映射'
|
||||||
verbose_name_plural = '收藏标签映射'
|
verbose_name_plural = '收藏标签映射'
|
||||||
unique_together = ['favorite', 'tag']
|
unique_together = ['favorite', 'tag']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["tag", "created_at"]),
|
||||||
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.favorite} - {self.tag.name}"
|
return f"{self.favorite} - {self.tag.name}"
|
||||||
@@ -153,6 +162,9 @@ class PriceMonitor(models.Model):
|
|||||||
db_table = 'priceMonitors'
|
db_table = 'priceMonitors'
|
||||||
verbose_name = '价格监控'
|
verbose_name = '价格监控'
|
||||||
verbose_name_plural = '价格监控'
|
verbose_name_plural = '价格监控'
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["user", "is_active"]),
|
||||||
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"Monitor: {self.favorite}"
|
return f"Monitor: {self.favorite}"
|
||||||
@@ -190,6 +202,9 @@ class PriceHistory(models.Model):
|
|||||||
verbose_name = '价格历史'
|
verbose_name = '价格历史'
|
||||||
verbose_name_plural = '价格历史'
|
verbose_name_plural = '价格历史'
|
||||||
ordering = ['-recorded_at']
|
ordering = ['-recorded_at']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["monitor", "recorded_at"]),
|
||||||
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.monitor.favorite} - {self.price}"
|
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.http import HttpResponse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from .models import Notification, NotificationPreference
|
from .models import Notification
|
||||||
|
from .utils import get_notification_preference
|
||||||
from .schemas import (
|
from .schemas import (
|
||||||
NotificationOut,
|
NotificationOut,
|
||||||
UnreadCountOut,
|
UnreadCountOut,
|
||||||
@@ -63,7 +64,7 @@ def list_notifications(
|
|||||||
@router.get("/preferences/", response=NotificationPreferenceOut, auth=JWTAuth())
|
@router.get("/preferences/", response=NotificationPreferenceOut, auth=JWTAuth())
|
||||||
def get_preferences(request):
|
def get_preferences(request):
|
||||||
"""Get current user's notification preferences."""
|
"""Get current user's notification preferences."""
|
||||||
preference, _ = NotificationPreference.objects.get_or_create(user=request.auth)
|
preference = get_notification_preference(request.auth)
|
||||||
return NotificationPreferenceOut(
|
return NotificationPreferenceOut(
|
||||||
user_id=preference.user_id,
|
user_id=preference.user_id,
|
||||||
enable_bounty=preference.enable_bounty,
|
enable_bounty=preference.enable_bounty,
|
||||||
@@ -76,7 +77,7 @@ def get_preferences(request):
|
|||||||
@router.patch("/preferences/", response=NotificationPreferenceOut, auth=JWTAuth())
|
@router.patch("/preferences/", response=NotificationPreferenceOut, auth=JWTAuth())
|
||||||
def update_preferences(request, data: NotificationPreferenceIn):
|
def update_preferences(request, data: NotificationPreferenceIn):
|
||||||
"""Update notification preferences."""
|
"""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)
|
update_data = data.dict(exclude_unset=True)
|
||||||
for key, value in update_data.items():
|
for key, value in update_data.items():
|
||||||
setattr(preference, key, value)
|
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.
|
Products API routes for categories, websites, products and prices.
|
||||||
"""
|
"""
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
import re
|
||||||
|
import time
|
||||||
from decimal import Decimal, InvalidOperation
|
from decimal import Decimal, InvalidOperation
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
@@ -9,9 +11,11 @@ from ninja import Router, Query, File
|
|||||||
from ninja.files import UploadedFile
|
from ninja.files import UploadedFile
|
||||||
from ninja_jwt.authentication import JWTAuth
|
from ninja_jwt.authentication import JWTAuth
|
||||||
from ninja.pagination import paginate, PageNumberPagination
|
from ninja.pagination import paginate, PageNumberPagination
|
||||||
from django.db.models import Count, Min, Max, Q
|
from django.conf import settings
|
||||||
from django.db import transaction
|
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.shortcuts import get_object_or_404
|
||||||
|
from django.views.decorators.cache import cache_page
|
||||||
|
|
||||||
from .models import Category, Website, Product, ProductPrice
|
from .models import Category, Website, Product, ProductPrice
|
||||||
from .schemas import (
|
from .schemas import (
|
||||||
@@ -19,7 +23,9 @@ from .schemas import (
|
|||||||
WebsiteOut, WebsiteIn, WebsiteFilter,
|
WebsiteOut, WebsiteIn, WebsiteFilter,
|
||||||
ProductOut, ProductIn, ProductWithPricesOut, ProductPriceOut, ProductPriceIn,
|
ProductOut, ProductIn, ProductWithPricesOut, ProductPriceOut, ProductPriceIn,
|
||||||
ProductFilter,
|
ProductFilter,
|
||||||
|
ProductSearchFilter,
|
||||||
ImportResultOut,
|
ImportResultOut,
|
||||||
|
MyProductOut,
|
||||||
)
|
)
|
||||||
from apps.favorites.models import Favorite
|
from apps.favorites.models import Favorite
|
||||||
|
|
||||||
@@ -31,21 +37,67 @@ website_router = Router()
|
|||||||
# ==================== Category Routes ====================
|
# ==================== Category Routes ====================
|
||||||
|
|
||||||
@category_router.get("/", response=List[CategoryOut])
|
@category_router.get("/", response=List[CategoryOut])
|
||||||
|
@cache_page(settings.CACHE_TTL_SECONDS)
|
||||||
def list_categories(request):
|
def list_categories(request):
|
||||||
"""Get all categories."""
|
"""Get all categories."""
|
||||||
return Category.objects.all()
|
return Category.objects.all()
|
||||||
|
|
||||||
|
|
||||||
@category_router.get("/{slug}", response=CategoryOut)
|
@category_router.get("/{slug}", response=CategoryOut)
|
||||||
|
@cache_page(settings.CACHE_TTL_SECONDS)
|
||||||
def get_category_by_slug(request, slug: str):
|
def get_category_by_slug(request, slug: str):
|
||||||
"""Get category by slug."""
|
"""Get category by slug."""
|
||||||
return get_object_or_404(Category, slug=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())
|
@category_router.post("/", response=CategoryOut, auth=JWTAuth())
|
||||||
def create_category(request, data: CategoryIn):
|
def create_category(request, data: CategoryIn):
|
||||||
"""Create a new category."""
|
"""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
|
return category
|
||||||
|
|
||||||
|
|
||||||
@@ -53,6 +105,7 @@ def create_category(request, data: CategoryIn):
|
|||||||
|
|
||||||
@website_router.get("/", response=List[WebsiteOut])
|
@website_router.get("/", response=List[WebsiteOut])
|
||||||
@paginate(PageNumberPagination, page_size=20)
|
@paginate(PageNumberPagination, page_size=20)
|
||||||
|
@cache_page(settings.CACHE_TTL_SECONDS)
|
||||||
def list_websites(request, filters: WebsiteFilter = Query(...)):
|
def list_websites(request, filters: WebsiteFilter = Query(...)):
|
||||||
"""Get all websites with optional filters."""
|
"""Get all websites with optional filters."""
|
||||||
queryset = Website.objects.all()
|
queryset = Website.objects.all()
|
||||||
@@ -66,6 +119,7 @@ def list_websites(request, filters: WebsiteFilter = Query(...)):
|
|||||||
|
|
||||||
|
|
||||||
@website_router.get("/{website_id}", response=WebsiteOut)
|
@website_router.get("/{website_id}", response=WebsiteOut)
|
||||||
|
@cache_page(settings.CACHE_TTL_SECONDS)
|
||||||
def get_website(request, website_id: int):
|
def get_website(request, website_id: int):
|
||||||
"""Get website by ID."""
|
"""Get website by ID."""
|
||||||
return get_object_or_404(Website, id=website_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())
|
@website_router.post("/", response=WebsiteOut, auth=JWTAuth())
|
||||||
def create_website(request, data: WebsiteIn):
|
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())
|
website = Website.objects.create(**data.dict())
|
||||||
return website
|
return website
|
||||||
|
|
||||||
@@ -225,15 +279,22 @@ def import_products_csv(request, file: UploadedFile = File(...)):
|
|||||||
@router.get("/recommendations/", response=List[ProductOut])
|
@router.get("/recommendations/", response=List[ProductOut])
|
||||||
def recommend_products(request, limit: int = 12):
|
def recommend_products(request, limit: int = 12):
|
||||||
"""Get recommended products based on favorites or popularity."""
|
"""Get recommended products based on favorites or popularity."""
|
||||||
|
# 限制 limit 最大值
|
||||||
|
if limit < 1:
|
||||||
|
limit = 1
|
||||||
|
if limit > 100:
|
||||||
|
limit = 100
|
||||||
|
|
||||||
user = getattr(request, "auth", None)
|
user = getattr(request, "auth", None)
|
||||||
base_queryset = Product.objects.all()
|
# 只显示已审核通过的商品
|
||||||
|
base_queryset = Product.objects.select_related("category").filter(status='approved')
|
||||||
|
|
||||||
if user:
|
if user:
|
||||||
favorite_product_ids = list(
|
favorite_product_ids = list(
|
||||||
Favorite.objects.filter(user=user).values_list("product_id", flat=True)
|
Favorite.objects.filter(user=user).values_list("product_id", flat=True)
|
||||||
)
|
)
|
||||||
category_ids = list(
|
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)
|
.values_list("category_id", flat=True)
|
||||||
.distinct()
|
.distinct()
|
||||||
)
|
)
|
||||||
@@ -251,9 +312,11 @@ def recommend_products(request, limit: int = 12):
|
|||||||
|
|
||||||
@router.get("/", response=List[ProductOut])
|
@router.get("/", response=List[ProductOut])
|
||||||
@paginate(PageNumberPagination, page_size=20)
|
@paginate(PageNumberPagination, page_size=20)
|
||||||
|
@cache_page(settings.CACHE_TTL_SECONDS)
|
||||||
def list_products(request, filters: ProductFilter = Query(...)):
|
def list_products(request, filters: ProductFilter = Query(...)):
|
||||||
"""Get all products with optional filters."""
|
"""Get all approved products with optional filters."""
|
||||||
queryset = Product.objects.all()
|
# 只显示已审核通过的商品
|
||||||
|
queryset = Product.objects.select_related("category").filter(status='approved')
|
||||||
|
|
||||||
if filters.category_id:
|
if filters.category_id:
|
||||||
queryset = queryset.filter(category_id=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)
|
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
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{product_id}", response=ProductOut)
|
@router.get("/{product_id}", response=ProductOut)
|
||||||
|
@cache_page(settings.CACHE_TTL_SECONDS)
|
||||||
def get_product(request, product_id: int):
|
def get_product(request, product_id: int):
|
||||||
"""Get product by ID."""
|
"""Get product by ID."""
|
||||||
return get_object_or_404(Product, id=product_id)
|
return get_object_or_404(Product, id=product_id)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{product_id}/with-prices", response=ProductWithPricesOut)
|
@router.get("/{product_id}/with-prices", response=ProductWithPricesOut)
|
||||||
|
@cache_page(settings.CACHE_TTL_SECONDS)
|
||||||
def get_product_with_prices(request, product_id: int):
|
def get_product_with_prices(request, product_id: int):
|
||||||
"""Get product with all prices from different websites."""
|
"""Get product with all prices from different websites."""
|
||||||
product = get_object_or_404(Product, id=product_id)
|
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])
|
@router.get("/search/", response=List[ProductWithPricesOut])
|
||||||
@paginate(PageNumberPagination, page_size=20)
|
@paginate(PageNumberPagination, page_size=20)
|
||||||
def search_products(request, q: str):
|
@cache_page(settings.CACHE_TTL_SECONDS)
|
||||||
"""Search products by name or description."""
|
def search_products(request, q: str, filters: ProductSearchFilter = Query(...)):
|
||||||
products = Product.objects.filter(
|
"""Search approved products by name or description."""
|
||||||
Q(name__icontains=q) | Q(description__icontains=q)
|
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 = []
|
result = []
|
||||||
for product in products:
|
for product in products:
|
||||||
prices = ProductPrice.objects.filter(product=product).select_related('website')
|
prices = list(product.prices.all())
|
||||||
price_list = [
|
price_list = [
|
||||||
ProductPriceOut(
|
ProductPriceOut(
|
||||||
id=pp.id,
|
id=pp.id,
|
||||||
@@ -342,8 +462,8 @@ def search_products(request, q: str):
|
|||||||
)
|
)
|
||||||
for pp in prices
|
for pp in prices
|
||||||
]
|
]
|
||||||
|
lowest_price = min((pp.price for pp in prices), default=None)
|
||||||
price_stats = prices.aggregate(lowest=Min('price'), highest=Max('price'))
|
highest_price = max((pp.price for pp in prices), default=None)
|
||||||
|
|
||||||
result.append(ProductWithPricesOut(
|
result.append(ProductWithPricesOut(
|
||||||
id=product.id,
|
id=product.id,
|
||||||
@@ -354,8 +474,8 @@ def search_products(request, q: str):
|
|||||||
created_at=product.created_at,
|
created_at=product.created_at,
|
||||||
updated_at=product.updated_at,
|
updated_at=product.updated_at,
|
||||||
prices=price_list,
|
prices=price_list,
|
||||||
lowest_price=price_stats['lowest'],
|
lowest_price=lowest_price,
|
||||||
highest_price=price_stats['highest'],
|
highest_price=highest_price,
|
||||||
))
|
))
|
||||||
|
|
||||||
return result
|
return result
|
||||||
@@ -363,14 +483,43 @@ def search_products(request, q: str):
|
|||||||
|
|
||||||
@router.post("/", response=ProductOut, auth=JWTAuth())
|
@router.post("/", response=ProductOut, auth=JWTAuth())
|
||||||
def create_product(request, data: ProductIn):
|
def create_product(request, data: ProductIn):
|
||||||
"""Create a new product."""
|
"""Create a new product. Admin creates approved, others create pending."""
|
||||||
product = Product.objects.create(**data.dict())
|
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
|
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())
|
@router.post("/prices/", response=ProductPriceOut, auth=JWTAuth())
|
||||||
def add_product_price(request, data: ProductPriceIn):
|
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())
|
price = ProductPrice.objects.create(**data.dict())
|
||||||
website = price.website
|
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.
|
Product models for categories, websites, products and prices.
|
||||||
"""
|
"""
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
class Category(models.Model):
|
class Category(models.Model):
|
||||||
@@ -28,6 +29,9 @@ class Category(models.Model):
|
|||||||
verbose_name = '分类'
|
verbose_name = '分类'
|
||||||
verbose_name_plural = '分类'
|
verbose_name_plural = '分类'
|
||||||
ordering = ['sort_order', 'id']
|
ordering = ['sort_order', 'id']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["parent", "sort_order"]),
|
||||||
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
@@ -58,6 +62,9 @@ class Website(models.Model):
|
|||||||
verbose_name = '网站'
|
verbose_name = '网站'
|
||||||
verbose_name_plural = '网站'
|
verbose_name_plural = '网站'
|
||||||
ordering = ['sort_order', 'id']
|
ordering = ['sort_order', 'id']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["category", "is_verified"]),
|
||||||
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
@@ -66,6 +73,11 @@ class Website(models.Model):
|
|||||||
class Product(models.Model):
|
class Product(models.Model):
|
||||||
"""Products for price comparison."""
|
"""Products for price comparison."""
|
||||||
|
|
||||||
|
class Status(models.TextChoices):
|
||||||
|
PENDING = 'pending', '待审核'
|
||||||
|
APPROVED = 'approved', '已通过'
|
||||||
|
REJECTED = 'rejected', '已拒绝'
|
||||||
|
|
||||||
id = models.AutoField(primary_key=True)
|
id = models.AutoField(primary_key=True)
|
||||||
name = models.CharField('商品名称', max_length=300)
|
name = models.CharField('商品名称', max_length=300)
|
||||||
description = models.TextField('描述', blank=True, null=True)
|
description = models.TextField('描述', blank=True, null=True)
|
||||||
@@ -76,6 +88,22 @@ class Product(models.Model):
|
|||||||
related_name='products',
|
related_name='products',
|
||||||
verbose_name='分类'
|
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)
|
created_at = models.DateTimeField('创建时间', auto_now_add=True)
|
||||||
updated_at = models.DateTimeField('更新时间', auto_now=True)
|
updated_at = models.DateTimeField('更新时间', auto_now=True)
|
||||||
|
|
||||||
@@ -83,6 +111,11 @@ class Product(models.Model):
|
|||||||
db_table = 'products'
|
db_table = 'products'
|
||||||
verbose_name = '商品'
|
verbose_name = '商品'
|
||||||
verbose_name_plural = '商品'
|
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):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
@@ -124,6 +157,9 @@ class ProductPrice(models.Model):
|
|||||||
verbose_name = '商品价格'
|
verbose_name = '商品价格'
|
||||||
verbose_name_plural = '商品价格'
|
verbose_name_plural = '商品价格'
|
||||||
unique_together = ['product', 'website']
|
unique_together = ['product', 'website']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["product", "website", "last_checked"]),
|
||||||
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.product.name} - {self.website.name}: {self.price}"
|
return f"{self.product.name} - {self.website.name}: {self.price}"
|
||||||
|
|||||||
@@ -79,6 +79,10 @@ class ProductOut(Schema):
|
|||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
image: Optional[str] = None
|
image: Optional[str] = None
|
||||||
category_id: int
|
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
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
@@ -98,6 +102,20 @@ class ProductIn(Schema):
|
|||||||
category_id: int
|
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):
|
class ProductPriceIn(Schema):
|
||||||
"""Product price input schema."""
|
"""Product price input schema."""
|
||||||
product_id: int
|
product_id: int
|
||||||
@@ -123,6 +141,17 @@ class ProductFilter(FilterSchema):
|
|||||||
"""Product filter schema."""
|
"""Product filter schema."""
|
||||||
category_id: Optional[int] = None
|
category_id: Optional[int] = None
|
||||||
search: Optional[str] = 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):
|
class WebsiteFilter(FilterSchema):
|
||||||
|
|||||||
@@ -2,15 +2,17 @@
|
|||||||
User authentication API routes.
|
User authentication API routes.
|
||||||
"""
|
"""
|
||||||
from typing import Optional
|
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.authentication import JWTAuth
|
||||||
from ninja_jwt.tokens import RefreshToken
|
from ninja_jwt.tokens import RefreshToken
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
|
from urllib.parse import urlparse, urlencode
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from .models import User
|
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()
|
router = Router()
|
||||||
|
|
||||||
@@ -22,18 +24,44 @@ def get_current_user(request: HttpRequest) -> Optional[User]:
|
|||||||
return None
|
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):
|
def get_me(request):
|
||||||
"""Get current user information."""
|
"""Get current user information."""
|
||||||
return request.auth
|
return request.auth
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/me", response=UserOut, auth=JWTAuth())
|
@router.patch("/me", response=UserPrivateOut, auth=JWTAuth())
|
||||||
def update_me(request, data: UserUpdate):
|
def update_me(request, data: UserUpdate):
|
||||||
"""Update current user information."""
|
"""Update current user information."""
|
||||||
user = request.auth
|
user = request.auth
|
||||||
|
|
||||||
|
# 验证邮箱格式
|
||||||
|
if data.email is not None:
|
||||||
|
validate_email(data.email)
|
||||||
|
|
||||||
if data.name is not None:
|
if data.name is not None:
|
||||||
|
if len(data.name) > 50:
|
||||||
|
raise HttpError(400, "名称不能超过50个字符")
|
||||||
user.name = data.name
|
user.name = data.name
|
||||||
if data.email is not None:
|
if data.email is not None:
|
||||||
user.email = data.email
|
user.email = data.email
|
||||||
@@ -55,6 +83,36 @@ def logout(request):
|
|||||||
return MessageOut(message="已退出登录", success=True)
|
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)
|
@router.post("/refresh", response=TokenOut)
|
||||||
def refresh_token(request, refresh_token: str):
|
def refresh_token(request, refresh_token: str):
|
||||||
"""Refresh access token using refresh token."""
|
"""Refresh access token using refresh token."""
|
||||||
@@ -64,19 +122,50 @@ def refresh_token(request, refresh_token: str):
|
|||||||
access_token=str(refresh.access_token),
|
access_token=str(refresh.access_token),
|
||||||
refresh_token=str(refresh),
|
refresh_token=str(refresh),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
return {"error": str(e)}, 401
|
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)
|
@router.post("/register", response=TokenOut)
|
||||||
def register(request, data: RegisterIn):
|
def register(request, data: RegisterIn):
|
||||||
"""Register new user with password."""
|
"""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():
|
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(
|
user = User.objects.create_user(
|
||||||
open_id=data.open_id,
|
open_id=data.open_id,
|
||||||
password=data.password,
|
password=data.password,
|
||||||
name=data.name,
|
name=data.name or data.open_id, # 默认显示名称为用户名
|
||||||
email=data.email,
|
email=data.email,
|
||||||
login_method="password",
|
login_method="password",
|
||||||
)
|
)
|
||||||
@@ -89,13 +178,23 @@ def register(request, data: RegisterIn):
|
|||||||
|
|
||||||
@router.post("/login", response=TokenOut)
|
@router.post("/login", response=TokenOut)
|
||||||
def login(request, data: LoginIn):
|
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:
|
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:
|
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):
|
if not user.check_password(data.password):
|
||||||
return {"error": "账号或密码错误"}, 401
|
raise HttpError(401, "账号或密码错误")
|
||||||
refresh = RefreshToken.for_user(user)
|
refresh = RefreshToken.for_user(user)
|
||||||
return TokenOut(
|
return TokenOut(
|
||||||
access_token=str(refresh.access_token),
|
access_token=str(refresh.access_token),
|
||||||
@@ -106,27 +205,29 @@ def login(request, data: LoginIn):
|
|||||||
@router.get("/oauth/url")
|
@router.get("/oauth/url")
|
||||||
def get_oauth_url(request, redirect_uri: Optional[str] = None):
|
def get_oauth_url(request, redirect_uri: Optional[str] = None):
|
||||||
"""Get OAuth authorization URL."""
|
"""Get OAuth authorization URL."""
|
||||||
# This would integrate with Manus SDK or other OAuth provider
|
_require_oauth_config()
|
||||||
client_id = settings.OAUTH_CLIENT_ID
|
|
||||||
redirect = redirect_uri or settings.OAUTH_REDIRECT_URI
|
redirect = redirect_uri or settings.OAUTH_REDIRECT_URI
|
||||||
|
if not _is_valid_url(redirect):
|
||||||
# Example OAuth URL (adjust based on actual OAuth provider)
|
raise HttpError(400, "回调地址无效")
|
||||||
oauth_url = f"https://oauth.example.com/authorize?client_id={client_id}&redirect_uri={redirect}&response_type=code"
|
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}
|
return {"url": oauth_url}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/oauth/callback", response=TokenOut)
|
@router.post("/oauth/callback", response=TokenOut)
|
||||||
def oauth_callback(request, data: OAuthCallbackIn):
|
def oauth_callback(request, data: OAuthCallbackIn):
|
||||||
"""Handle OAuth callback and create/update user."""
|
"""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:
|
try:
|
||||||
|
_require_oauth_config()
|
||||||
# Exchange code for access token
|
# Exchange code for access token
|
||||||
token_response = requests.post(
|
token_response = requests.post(
|
||||||
"https://oauth.example.com/token",
|
settings.OAUTH_TOKEN_URL,
|
||||||
data={
|
data={
|
||||||
"client_id": settings.OAUTH_CLIENT_ID,
|
"client_id": settings.OAUTH_CLIENT_ID,
|
||||||
"client_secret": settings.OAUTH_CLIENT_SECRET,
|
"client_secret": settings.OAUTH_CLIENT_SECRET,
|
||||||
@@ -137,18 +238,18 @@ def oauth_callback(request, data: OAuthCallbackIn):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if token_response.status_code != 200:
|
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()
|
oauth_data = token_response.json()
|
||||||
|
|
||||||
# Get user info from OAuth provider
|
# Get user info from OAuth provider
|
||||||
user_response = requests.get(
|
user_response = requests.get(
|
||||||
"https://oauth.example.com/userinfo",
|
settings.OAUTH_USERINFO_URL,
|
||||||
headers={"Authorization": f"Bearer {oauth_data['access_token']}"}
|
headers={"Authorization": f"Bearer {oauth_data['access_token']}"}
|
||||||
)
|
)
|
||||||
|
|
||||||
if user_response.status_code != 200:
|
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()
|
user_info = user_response.json()
|
||||||
|
|
||||||
@@ -171,8 +272,8 @@ def oauth_callback(request, data: OAuthCallbackIn):
|
|||||||
refresh_token=str(refresh),
|
refresh_token=str(refresh),
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception:
|
||||||
return {"error": str(e)}, 500
|
raise HttpError(500, "OAuth 登录失败")
|
||||||
|
|
||||||
|
|
||||||
# Development endpoint for testing without 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):
|
def dev_login(request, open_id: str, name: Optional[str] = None):
|
||||||
"""Development login endpoint (disable in production)."""
|
"""Development login endpoint (disable in production)."""
|
||||||
if not settings.DEBUG:
|
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(
|
user, created = User.objects.get_or_create(
|
||||||
open_id=open_id,
|
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'
|
db_table = 'users'
|
||||||
verbose_name = '用户'
|
verbose_name = '用户'
|
||||||
verbose_name_plural = '用户'
|
verbose_name_plural = '用户'
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["role", "is_active"]),
|
||||||
|
models.Index(fields=["created_at"]),
|
||||||
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name or self.open_id
|
return self.name or self.open_id
|
||||||
@@ -116,6 +120,10 @@ class FriendRequest(models.Model):
|
|||||||
name="no_self_friend_request",
|
name="no_self_friend_request",
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["receiver", "status"]),
|
||||||
|
models.Index(fields=["requester", "status"]),
|
||||||
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.requester_id}->{self.receiver_id} ({self.status})"
|
return f"{self.requester_id}->{self.receiver_id} ({self.status})"
|
||||||
|
|||||||
@@ -7,19 +7,23 @@ from ninja import Schema
|
|||||||
|
|
||||||
|
|
||||||
class UserOut(Schema):
|
class UserOut(Schema):
|
||||||
"""User output schema."""
|
"""Public user output schema."""
|
||||||
id: int
|
id: int
|
||||||
open_id: str
|
open_id: str
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
avatar: Optional[str] = None
|
avatar: Optional[str] = None
|
||||||
role: str
|
role: str
|
||||||
stripe_customer_id: Optional[str] = None
|
|
||||||
stripe_account_id: Optional[str] = None
|
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_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):
|
class UserBrief(Schema):
|
||||||
"""Minimal user info for social features."""
|
"""Minimal user info for social features."""
|
||||||
id: int
|
id: int
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
Django Ninja API configuration.
|
Django Ninja API configuration.
|
||||||
"""
|
"""
|
||||||
from ninja import NinjaAPI
|
from ninja import NinjaAPI
|
||||||
|
from ninja.errors import HttpError, ValidationError
|
||||||
from ninja_jwt.authentication import JWTAuth
|
from ninja_jwt.authentication import JWTAuth
|
||||||
|
|
||||||
# Import routers from apps
|
# 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.notifications.api import router as notifications_router
|
||||||
from apps.admin.api import router as admin_router
|
from apps.admin.api import router as admin_router
|
||||||
from config.search import router as search_router
|
from config.search import router as search_router
|
||||||
|
from apps.common.errors import build_error_payload
|
||||||
|
|
||||||
# Create main API instance
|
# Create main API instance
|
||||||
api = NinjaAPI(
|
api = NinjaAPI(
|
||||||
@@ -22,6 +24,39 @@ api = NinjaAPI(
|
|||||||
description="Backend API for AI Web application",
|
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
|
# Register routers
|
||||||
api.add_router("/auth/", auth_router, tags=["认证"])
|
api.add_router("/auth/", auth_router, tags=["认证"])
|
||||||
api.add_router("/friends/", friends_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 typing import List
|
||||||
from ninja import Router, Schema
|
from ninja import Router, Schema
|
||||||
from django.db.models import Count, Q
|
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.models import Product, Website
|
||||||
from apps.products.schemas import ProductOut, WebsiteOut
|
from apps.products.schemas import ProductOut, WebsiteOut
|
||||||
from apps.bounties.models import Bounty
|
from apps.bounties.models import Bounty
|
||||||
from apps.bounties.schemas import BountyWithDetailsOut
|
from apps.bounties.schemas import BountyWithDetailsOut
|
||||||
from apps.users.schemas import UserOut
|
from apps.common.serializers import serialize_bounty
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
|
|
||||||
@@ -20,47 +22,12 @@ class SearchResultsOut(Schema):
|
|||||||
bounties: List[BountyWithDetailsOut]
|
bounties: List[BountyWithDetailsOut]
|
||||||
|
|
||||||
|
|
||||||
def serialize_user(user):
|
def _serialize_bounty_with_counts(bounty):
|
||||||
if not user:
|
return serialize_bounty(bounty, include_counts=True)
|
||||||
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),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response=SearchResultsOut)
|
@router.get("/", response=SearchResultsOut)
|
||||||
|
@cache_page(settings.CACHE_TTL_SECONDS)
|
||||||
def global_search(request, q: str, limit: int = 10):
|
def global_search(request, q: str, limit: int = 10):
|
||||||
"""Search products, websites and bounties by keyword."""
|
"""Search products, websites and bounties by keyword."""
|
||||||
keyword = (q or "").strip()
|
keyword = (q or "").strip()
|
||||||
@@ -68,13 +35,13 @@ def global_search(request, q: str, limit: int = 10):
|
|||||||
return SearchResultsOut(products=[], websites=[], bounties=[])
|
return SearchResultsOut(products=[], websites=[], bounties=[])
|
||||||
|
|
||||||
products = list(
|
products = list(
|
||||||
Product.objects.filter(
|
Product.objects.select_related("category").filter(
|
||||||
Q(name__icontains=keyword) | Q(description__icontains=keyword)
|
Q(name__icontains=keyword) | Q(description__icontains=keyword)
|
||||||
).order_by("-created_at")[:limit]
|
).order_by("-created_at")[:limit]
|
||||||
)
|
)
|
||||||
|
|
||||||
websites = list(
|
websites = list(
|
||||||
Website.objects.filter(
|
Website.objects.select_related("category").filter(
|
||||||
Q(name__icontains=keyword) | Q(description__icontains=keyword)
|
Q(name__icontains=keyword) | Q(description__icontains=keyword)
|
||||||
).order_by("-created_at")[:limit]
|
).order_by("-created_at")[:limit]
|
||||||
)
|
)
|
||||||
@@ -92,5 +59,5 @@ def global_search(request, q: str, limit: int = 10):
|
|||||||
return SearchResultsOut(
|
return SearchResultsOut(
|
||||||
products=products,
|
products=products,
|
||||||
websites=websites,
|
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.
|
Django settings for ai_web project.
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
|
from decimal import Decimal
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
@@ -9,16 +10,33 @@ from dotenv import load_dotenv
|
|||||||
# Load environment variables
|
# Load environment variables
|
||||||
load_dotenv()
|
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'.
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
# SECURITY WARNING: keep the secret key used in production secret!
|
# 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!
|
ALLOWED_HOSTS = _env_csv("ALLOWED_HOSTS", "localhost,127.0.0.1" if DEBUG else "")
|
||||||
DEBUG = os.getenv('DEBUG', 'True').lower() == 'true'
|
if not DEBUG and not ALLOWED_HOSTS:
|
||||||
|
raise RuntimeError("ALLOWED_HOSTS must be configured in production")
|
||||||
ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',')
|
|
||||||
|
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
@@ -46,9 +64,11 @@ MIDDLEWARE = [
|
|||||||
'django.middleware.security.SecurityMiddleware',
|
'django.middleware.security.SecurityMiddleware',
|
||||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
'django.middleware.common.CommonMiddleware',
|
'django.middleware.common.CommonMiddleware',
|
||||||
|
'config.middleware.RateLimitMiddleware',
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
|
'config.middleware.ETagMiddleware',
|
||||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -74,28 +94,28 @@ WSGI_APPLICATION = 'config.wsgi.application'
|
|||||||
|
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
# 使用 SQLite 数据库(开发环境)
|
DB_ENGINE = os.getenv("DB_ENGINE", "sqlite").lower()
|
||||||
DATABASES = {
|
if DB_ENGINE == "mysql":
|
||||||
'default': {
|
DATABASES = {
|
||||||
'ENGINE': 'django.db.backends.sqlite3',
|
"default": {
|
||||||
'NAME': BASE_DIR / 'db.sqlite3',
|
"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
|
# Password validation
|
||||||
@@ -136,11 +156,17 @@ AUTH_USER_MODEL = 'users.User'
|
|||||||
|
|
||||||
|
|
||||||
# CORS settings
|
# CORS settings
|
||||||
CORS_ALLOWED_ORIGINS = os.getenv(
|
CORS_ALLOWED_ORIGINS = _env_csv(
|
||||||
'CORS_ALLOWED_ORIGINS',
|
"CORS_ALLOWED_ORIGINS",
|
||||||
'http://localhost:5173,http://127.0.0.1:5173'
|
"http://localhost:5173,http://127.0.0.1:5173" if DEBUG else "",
|
||||||
).split(',')
|
)
|
||||||
|
if not DEBUG and not CORS_ALLOWED_ORIGINS:
|
||||||
|
raise RuntimeError("CORS_ALLOWED_ORIGINS must be configured in production")
|
||||||
CORS_ALLOW_CREDENTIALS = True
|
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
|
# JWT settings
|
||||||
@@ -156,14 +182,52 @@ NINJA_JWT = {
|
|||||||
'AUTH_COOKIE_SAMESITE': 'Lax',
|
'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 settings
|
||||||
STRIPE_SECRET_KEY = os.getenv('STRIPE_SECRET_KEY', '')
|
STRIPE_SECRET_KEY = os.getenv('STRIPE_SECRET_KEY', '')
|
||||||
STRIPE_WEBHOOK_SECRET = os.getenv('STRIPE_WEBHOOK_SECRET', '')
|
STRIPE_WEBHOOK_SECRET = os.getenv('STRIPE_WEBHOOK_SECRET', '')
|
||||||
STRIPE_PUBLISHABLE_KEY = os.getenv('STRIPE_PUBLISHABLE_KEY', '')
|
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 settings (Manus SDK compatible)
|
||||||
OAUTH_CLIENT_ID = os.getenv('OAUTH_CLIENT_ID', '')
|
OAUTH_CLIENT_ID = os.getenv("OAUTH_CLIENT_ID", "")
|
||||||
OAUTH_CLIENT_SECRET = os.getenv('OAUTH_CLIENT_SECRET', '')
|
OAUTH_CLIENT_SECRET = os.getenv("OAUTH_CLIENT_SECRET", "")
|
||||||
OAUTH_REDIRECT_URI = os.getenv('OAUTH_REDIRECT_URI', 'http://localhost:8000/api/auth/callback')
|
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)
|
# HTTP client (for OAuth)
|
||||||
requests>=2.31.0
|
requests>=2.31.0
|
||||||
|
|
||||||
# Development
|
|
||||||
pytest>=7.4.0
|
|
||||||
pytest-django>=4.5.0
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"rsc": false,
|
"rsc": false,
|
||||||
"tsx": true,
|
"tsx": true,
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
"css": "client/src/index.css",
|
"css": "src/index.css",
|
||||||
"baseColor": "neutral",
|
"baseColor": "neutral",
|
||||||
"cssVariables": true,
|
"cssVariables": true,
|
||||||
"prefix": ""
|
"prefix": ""
|
||||||
@@ -87,6 +87,13 @@
|
|||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"tailwindcss>nanoid": "3.3.7"
|
"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.
|
* Import shared types from this single entry point.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type * from "../drizzle/schema";
|
|
||||||
export * from "./_core/errors";
|
export * from "./_core/errors";
|
||||||
@@ -1,21 +1,22 @@
|
|||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import NotFound from "@/pages/NotFound";
|
import NotFound from "@/features/common/pages/NotFound";
|
||||||
import { Route, Switch } from "wouter";
|
import { Route, Switch } from "wouter";
|
||||||
import ErrorBoundary from "./components/ErrorBoundary";
|
import ErrorBoundary from "./components/ErrorBoundary";
|
||||||
import FriendPanel from "./components/FriendPanel";
|
import FriendPanel from "@/features/friends/FriendPanel";
|
||||||
import { ThemeProvider } from "./contexts/ThemeContext";
|
import { ThemeProvider } from "./contexts/ThemeContext";
|
||||||
import Home from "./pages/Home";
|
import Home from "@/features/home/pages/Home";
|
||||||
import Login from "./pages/Login";
|
import Login from "@/features/auth/pages/Login";
|
||||||
import Products from "./pages/Products";
|
import Products from "@/features/products/pages/Products";
|
||||||
import ProductDetail from "./pages/ProductDetail";
|
import ProductDetail from "@/features/products/pages/ProductDetail";
|
||||||
import Bounties from "./pages/Bounties";
|
import Bounties from "@/features/bounties/pages/Bounties";
|
||||||
import BountyDetail from "./pages/BountyDetail";
|
import BountyDetail from "@/features/bounties/pages/BountyDetail";
|
||||||
import Dashboard from "./pages/Dashboard";
|
import Dashboard from "@/features/dashboard/pages/Dashboard";
|
||||||
import Favorites from "./pages/Favorites";
|
import Favorites from "@/features/favorites/pages/Favorites";
|
||||||
import ProductComparison from "./pages/ProductComparison";
|
import ProductComparison from "@/features/products/pages/ProductComparison";
|
||||||
import Admin from "./pages/Admin";
|
import Admin from "@/features/admin/pages/Admin";
|
||||||
import Search from "./pages/Search";
|
import Search from "@/features/search/pages/Search";
|
||||||
|
import Settings from "@/features/settings/pages/Settings";
|
||||||
|
|
||||||
function Router() {
|
function Router() {
|
||||||
return (
|
return (
|
||||||
@@ -31,6 +32,7 @@ function Router() {
|
|||||||
<Route path="/comparison" component={ProductComparison} />
|
<Route path="/comparison" component={ProductComparison} />
|
||||||
<Route path="/search" component={Search} />
|
<Route path="/search" component={Search} />
|
||||||
<Route path="/admin" component={Admin} />
|
<Route path="/admin" component={Admin} />
|
||||||
|
<Route path="/settings" component={Settings} />
|
||||||
<Route path="/404" component={NotFound} />
|
<Route path="/404" component={NotFound} />
|
||||||
<Route component={NotFound} />
|
<Route component={NotFound} />
|
||||||
</Switch>
|
</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 { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -20,15 +20,19 @@ import {
|
|||||||
useSidebar,
|
useSidebar,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { useIsMobile } from "@/hooks/useMobile";
|
import { useIsMobile } from "@/hooks/useMobile";
|
||||||
import { LayoutDashboard, LogOut, PanelLeft, Users, Heart, ShieldCheck } from "lucide-react";
|
import { LayoutDashboard, LogOut, PanelLeft, Users, Heart, ShieldCheck, Loader2, Settings, Home } from "lucide-react";
|
||||||
import { CSSProperties, useEffect, useRef, useState } from "react";
|
import { CSSProperties, useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
|
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { getErrorCopy } from "@/lib/i18n/errorMessages";
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
|
{ icon: Home, label: "返回首页", path: "/" },
|
||||||
{ icon: LayoutDashboard, label: "个人中心", path: "/dashboard" },
|
{ icon: LayoutDashboard, label: "个人中心", path: "/dashboard" },
|
||||||
{ icon: Heart, label: "我的收藏", path: "/favorites" },
|
{ icon: Heart, label: "我的收藏", path: "/favorites" },
|
||||||
|
{ icon: Settings, label: "账号设置", path: "/settings" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const adminMenuItems = [
|
const adminMenuItems = [
|
||||||
@@ -45,11 +49,27 @@ export default function DashboardLayout({
|
|||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
const [, navigate] = useLocation();
|
||||||
|
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||||
const saved = localStorage.getItem(SIDEBAR_WIDTH_KEY);
|
const saved = localStorage.getItem(SIDEBAR_WIDTH_KEY);
|
||||||
return saved ? parseInt(saved, 10) : DEFAULT_WIDTH;
|
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(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem(SIDEBAR_WIDTH_KEY, sidebarWidth.toString());
|
localStorage.setItem(SIDEBAR_WIDTH_KEY, sidebarWidth.toString());
|
||||||
@@ -93,7 +113,11 @@ export default function DashboardLayout({
|
|||||||
} as CSSProperties
|
} as CSSProperties
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<DashboardLayoutContent setSidebarWidth={setSidebarWidth}>
|
<DashboardLayoutContent
|
||||||
|
setSidebarWidth={setSidebarWidth}
|
||||||
|
handleLogout={handleLogout}
|
||||||
|
isLoggingOut={isLoggingOut}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</DashboardLayoutContent>
|
</DashboardLayoutContent>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
@@ -103,13 +127,17 @@ export default function DashboardLayout({
|
|||||||
type DashboardLayoutContentProps = {
|
type DashboardLayoutContentProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
setSidebarWidth: (width: number) => void;
|
setSidebarWidth: (width: number) => void;
|
||||||
|
handleLogout: () => void;
|
||||||
|
isLoggingOut: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function DashboardLayoutContent({
|
function DashboardLayoutContent({
|
||||||
children,
|
children,
|
||||||
setSidebarWidth,
|
setSidebarWidth,
|
||||||
|
handleLogout,
|
||||||
|
isLoggingOut,
|
||||||
}: DashboardLayoutContentProps) {
|
}: DashboardLayoutContentProps) {
|
||||||
const { user, logout } = useAuth();
|
const { user } = useAuth();
|
||||||
const [location, setLocation] = useLocation();
|
const [location, setLocation] = useLocation();
|
||||||
const { state, toggleSidebar } = useSidebar();
|
const { state, toggleSidebar } = useSidebar();
|
||||||
const isCollapsed = state === "collapsed";
|
const isCollapsed = state === "collapsed";
|
||||||
@@ -251,11 +279,16 @@ function DashboardLayoutContent({
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-48">
|
<DropdownMenuContent align="end" className="w-48">
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={logout}
|
onClick={handleLogout}
|
||||||
|
disabled={isLoggingOut}
|
||||||
className="cursor-pointer text-destructive focus:text-destructive"
|
className="cursor-pointer text-destructive focus:text-destructive"
|
||||||
>
|
>
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
{isLoggingOut ? (
|
||||||
<span>Sign out</span>
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
<span>{isLoggingOut ? "退出中..." : "退出登录"}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState } from "react";
|
|||||||
import { Link, useLocation } from "wouter";
|
import { Link, useLocation } from "wouter";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
|
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 { Sparkles, Menu, X, ShoppingBag, Trophy, Search, User, Heart, LogOut } from "lucide-react";
|
||||||
import { useUnreadNotificationCount } from "@/hooks/useApi";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Link, useLocation } from "wouter";
|
import { Link, useLocation } from "wouter";
|
||||||
import { Sparkles, Bell, LogOut } from "lucide-react";
|
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 { Link } from "wouter";
|
||||||
import { useLogin, useRegister } from "@/hooks/useApi";
|
import { useLogin, useRegister } from "@/hooks/useApi";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { getErrorCopy } from "@/lib/i18n/errorMessages";
|
||||||
|
import { getAndClearRedirectPath } from "@/hooks/useAuth";
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const [, setLocation] = useLocation();
|
const [, setLocation] = useLocation();
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [displayName, setDisplayName] = useState("");
|
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [isRegister, setIsRegister] = useState(false);
|
const [isRegister, setIsRegister] = useState(false);
|
||||||
@@ -20,6 +21,11 @@ export default function Login() {
|
|||||||
const loginMutation = useLogin();
|
const loginMutation = useLogin();
|
||||||
const registerMutation = useRegister();
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@@ -27,20 +33,33 @@ export default function Login() {
|
|||||||
toast.error("请输入用户名");
|
toast.error("请输入用户名");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isRegister && !email.trim()) {
|
||||||
|
toast.error("请输入邮箱");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isRegister && !validateEmail(email.trim())) {
|
||||||
|
toast.error("请输入正确的邮箱格式");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!password.trim()) {
|
if (!password.trim()) {
|
||||||
toast.error("请输入密码");
|
toast.error("请输入密码");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (password.length < 6) {
|
||||||
|
toast.error("密码长度至少6位");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isRegister) {
|
if (isRegister) {
|
||||||
await registerMutation.mutateAsync({
|
await registerMutation.mutateAsync({
|
||||||
openId: username.trim(),
|
openId: username.trim(),
|
||||||
password: password.trim(),
|
password: password.trim(),
|
||||||
name: displayName.trim() || undefined,
|
name: username.trim(), // 显示名称默认为用户名
|
||||||
email: email.trim() || undefined,
|
email: email.trim(),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
// 登录时,用户名字段可以是用户名或邮箱
|
||||||
await loginMutation.mutateAsync({
|
await loginMutation.mutateAsync({
|
||||||
openId: username.trim(),
|
openId: username.trim(),
|
||||||
password: password.trim(),
|
password: password.trim(),
|
||||||
@@ -51,12 +70,14 @@ export default function Login() {
|
|||||||
description: isRegister ? "账号已创建" : "欢迎回来!",
|
description: isRegister ? "账号已创建" : "欢迎回来!",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Redirect to home or dashboard
|
// 优先返回登录前的页面,否则跳转首页
|
||||||
setLocation("/");
|
const redirectPath = getAndClearRedirectPath();
|
||||||
} catch (error: any) {
|
setLocation(redirectPath || "/");
|
||||||
toast.error(isRegister ? "注册失败" : "登录失败", {
|
} catch (error: unknown) {
|
||||||
description: error.response?.data?.error || "请稍后重试",
|
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">
|
<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" />
|
<Sparkles className="w-7 h-7 text-primary-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-2xl">欢迎登录</CardTitle>
|
<CardTitle className="text-2xl">{isRegister ? "欢迎注册" : "欢迎登录"}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
登录资源聚合平台,享受更多功能
|
{isRegister ? "创建账号,开始使用资源聚合平台" : "登录资源聚合平台,享受更多功能"}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="username">用户名</Label>
|
<Label htmlFor="username">用户名 {!isRegister && <span className="text-muted-foreground text-xs">(或邮箱)</span>}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="username"
|
id="username"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="输入用户名或 ID"
|
placeholder={isRegister ? "输入用户名" : "输入用户名或邮箱"}
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
disabled={loginMutation.isPending || registerMutation.isPending}
|
disabled={loginMutation.isPending || registerMutation.isPending}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 && (
|
{isRegister && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email">邮箱(可选)</Label>
|
<Label htmlFor="email">邮箱 <span className="text-destructive">*</span></Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="输入邮箱"
|
placeholder="输入邮箱地址"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
disabled={loginMutation.isPending || registerMutation.isPending}
|
disabled={loginMutation.isPending || registerMutation.isPending}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<Button
|
||||||
type="submit"
|
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 { Badge } from "@/components/ui/badge";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
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 { Switch } from "@/components/ui/switch";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
|
||||||
import { Line, LineChart, XAxis, YAxis, CartesianGrid } from "recharts";
|
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 { useFavorites, useFavoriteTags, useRemoveFavorite, useCreateFavoriteTag, useUpdateFavoriteTag, useDeleteFavoriteTag, usePriceMonitor, usePriceHistory, useCreatePriceMonitor, useUpdatePriceMonitor, useRefreshPriceMonitor } from "@/hooks/useApi";
|
||||||
import { useDebounce } from "@/hooks/useDebounce";
|
import { useDebounce } from "@/hooks/useDebounce";
|
||||||
import { useAuth } from "@/_core/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { favoriteApi } from "@/lib/api";
|
import { favoriteApi } from "@/lib/api";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { getErrorCopy } from "@/lib/i18n/errorMessages";
|
||||||
import {
|
import {
|
||||||
Heart,
|
Heart,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -84,8 +87,9 @@ export default function Favorites() {
|
|||||||
link.click();
|
link.click();
|
||||||
link.remove();
|
link.remove();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
console.error(error);
|
const { title, description } = getErrorCopy(error, { context: "favorite.export" });
|
||||||
|
toast.error(title, { description });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -113,15 +117,26 @@ export default function Favorites() {
|
|||||||
});
|
});
|
||||||
}, [favorites, debouncedSearchQuery]);
|
}, [favorites, debouncedSearchQuery]);
|
||||||
|
|
||||||
|
// State for bulk delete confirmation
|
||||||
|
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
// Handle bulk delete
|
// Handle bulk delete
|
||||||
const handleBulkDelete = () => {
|
const handleBulkDelete = async () => {
|
||||||
if (selectedFavorites.size === 0) return;
|
if (selectedFavorites.size === 0) return;
|
||||||
selectedFavorites.forEach((id) => {
|
setIsDeleting(true);
|
||||||
removeFavoriteMutation.mutate(id, {
|
try {
|
||||||
onSuccess: () => refetchFavorites(),
|
const ids = Array.from(selectedFavorites);
|
||||||
});
|
await Promise.all(ids.map((id) => removeFavoriteMutation.mutateAsync(id)));
|
||||||
});
|
setSelectedFavorites(new Set());
|
||||||
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
|
// Handle create tag
|
||||||
@@ -132,10 +147,15 @@ export default function Favorites() {
|
|||||||
color: newTagColor,
|
color: newTagColor,
|
||||||
}, {
|
}, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
toast.success("标签创建成功");
|
||||||
setNewTagName("");
|
setNewTagName("");
|
||||||
setNewTagColor("#6366f1");
|
setNewTagColor("#6366f1");
|
||||||
refetchTags();
|
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: () => {
|
onSuccess: () => {
|
||||||
|
toast.success("标签更新成功");
|
||||||
setEditingTag(null);
|
setEditingTag(null);
|
||||||
setEditTagName("");
|
setEditTagName("");
|
||||||
refetchTags();
|
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) => {
|
const handleDeleteTag = (id: number) => {
|
||||||
deleteTagMutation.mutate(id, {
|
deleteTagMutation.mutate(id, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
toast.success("标签已删除");
|
||||||
if (selectedTag === id) {
|
if (selectedTag === id) {
|
||||||
setSelectedTag(null);
|
setSelectedTag(null);
|
||||||
}
|
}
|
||||||
refetchTags();
|
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;
|
const mutation = monitorData ? updateMonitorMutation : createMonitorMutation;
|
||||||
mutation.mutate({ favoriteId: monitorFavoriteId, data: payload }, {
|
mutation.mutate({ favoriteId: monitorFavoriteId, data: payload }, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
toast.success("监控设置已保存");
|
||||||
refetchFavorites();
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<DashboardLayout>
|
||||||
<Navbar />
|
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<section className="pt-24 border-b border-border py-8">
|
<section className="border-b border-border py-8">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<div className="flex items-center gap-3 mb-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">
|
<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} 件商品
|
已选择 {selectedFavorites.size} 件商品
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<AlertDialog open={bulkDeleteOpen} onOpenChange={setBulkDeleteOpen}>
|
||||||
variant="destructive"
|
<AlertDialogTrigger asChild>
|
||||||
size="sm"
|
<Button variant="destructive" size="sm">
|
||||||
onClick={handleBulkDelete}
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
>
|
批量删除
|
||||||
<Trash2 className="w-4 h-4 mr-2" />
|
</Button>
|
||||||
批量删除
|
</AlertDialogTrigger>
|
||||||
</Button>
|
<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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -649,6 +700,6 @@ export default function Favorites() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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 { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Link } from "wouter";
|
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 { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
import { useFavorites, useAllPriceMonitors, useAddFavorite, useRemoveFavorite } from "@/hooks/useApi";
|
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 { ArrowLeft, Trash2, Download, TrendingDown, Heart, Share2, Copy, Check } from "lucide-react";
|
||||||
import { QRCodeSVG as QRCode } from "qrcode.react";
|
import { QRCodeSVG as QRCode } from "qrcode.react";
|
||||||
import { Link } from "wouter";
|
import { Link } from "wouter";
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts";
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { getErrorCopy } from "@/lib/i18n/errorMessages";
|
||||||
|
|
||||||
export default function ProductComparison() {
|
export default function ProductComparison() {
|
||||||
const { user, isAuthenticated } = useAuth();
|
const { user, isAuthenticated } = useAuth();
|
||||||
const [selectedProducts, setSelectedProducts] = useState<Set<number>>(new Set());
|
const [selectedProducts, setSelectedProducts] = useState<Set<number>>(new Set());
|
||||||
const [comparisonMode, setComparisonMode] = useState(false);
|
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 [showShareDialog, setShowShareDialog] = useState(false);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const qrCodeRef = useRef<HTMLDivElement>(null);
|
const qrCodeRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -35,7 +36,7 @@ export default function ProductComparison() {
|
|||||||
// Initialize favorite IDs when favorites load
|
// Initialize favorite IDs when favorites load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (favorites.length > 0) {
|
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]);
|
}, [favorites]);
|
||||||
|
|
||||||
@@ -64,9 +65,10 @@ export default function ProductComparison() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleFavorite = (productId: number, websiteId: number) => {
|
const handleToggleFavorite = (productId: number, websiteId: number) => {
|
||||||
if (favoriteIds.has(productId)) {
|
const key = `${productId}-${websiteId}`;
|
||||||
|
if (favoriteKeys.has(key)) {
|
||||||
// Find the favorite ID to remove
|
// 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) {
|
if (fav) {
|
||||||
removeFavoriteMutation.mutate(fav.id, {
|
removeFavoriteMutation.mutate(fav.id, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -75,17 +77,18 @@ export default function ProductComparison() {
|
|||||||
duration: 2000,
|
duration: 2000,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: (error: unknown) => {
|
||||||
toast.error("取消收藏失败", {
|
const { title, description } = getErrorCopy(error, { context: "favorite.remove" });
|
||||||
description: "请稍后重试",
|
toast.error(title, {
|
||||||
|
description,
|
||||||
duration: 2000,
|
duration: 2000,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setFavoriteIds(prev => {
|
setFavoriteKeys(prev => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
next.delete(productId);
|
next.delete(key);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -96,14 +99,15 @@ export default function ProductComparison() {
|
|||||||
duration: 2000,
|
duration: 2000,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: (error: unknown) => {
|
||||||
toast.error("收藏失败", {
|
const { title, description } = getErrorCopy(error, { context: "favorite.add" });
|
||||||
description: "请稍后重试",
|
toast.error(title, {
|
||||||
|
description,
|
||||||
duration: 2000,
|
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;
|
let failureCount = 0;
|
||||||
|
|
||||||
for (const product of comparisonProducts) {
|
for (const product of comparisonProducts) {
|
||||||
if (!favoriteIds.has(product.product_id)) {
|
const key = `${product.product_id}-${product.website_id}`;
|
||||||
|
if (!favoriteKeys.has(key)) {
|
||||||
try {
|
try {
|
||||||
await addFavoriteMutation.mutateAsync({ product_id: product.product_id, website_id: product.website_id });
|
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++;
|
successCount++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failureCount++;
|
failureCount++;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useAuth } from "@/_core/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
import { Link, useParams } from "wouter";
|
import { Link, useParams } from "wouter";
|
||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { getErrorCopy } from "@/lib/i18n/errorMessages";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
@@ -58,9 +59,24 @@ export default function ProductDetail() {
|
|||||||
const [monitorNotifyEnabled, setMonitorNotifyEnabled] = useState(true);
|
const [monitorNotifyEnabled, setMonitorNotifyEnabled] = useState(true);
|
||||||
const [monitorNotifyOnTarget, setMonitorNotifyOnTarget] = useState(true);
|
const [monitorNotifyOnTarget, setMonitorNotifyOnTarget] = useState(true);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [selectedWebsiteId, setSelectedWebsiteId] = useState(0);
|
||||||
|
|
||||||
const { data: product, isLoading, error } = useProductWithPrices(productId);
|
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: monitorData } = usePriceMonitor(favoriteCheck?.favorite_id || 0);
|
||||||
const { data: priceHistoryData } = usePriceHistory(favoriteCheck?.favorite_id || 0);
|
const { data: priceHistoryData } = usePriceHistory(favoriteCheck?.favorite_id || 0);
|
||||||
const { data: recommendedProducts } = useRecommendedProducts(4);
|
const { data: recommendedProducts } = useRecommendedProducts(4);
|
||||||
@@ -102,7 +118,7 @@ export default function ProductDetail() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!product?.prices?.[0]) {
|
if (!selectedWebsiteId) {
|
||||||
toast.error("该商品暂无价格信息");
|
toast.error("该商品暂无价格信息");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -114,16 +130,24 @@ export default function ProductDetail() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ["favorites"] });
|
queryClient.invalidateQueries({ queryKey: ["favorites"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["favorites", "check"] });
|
queryClient.invalidateQueries({ queryKey: ["favorites", "check"] });
|
||||||
},
|
},
|
||||||
|
onError: (error: unknown) => {
|
||||||
|
const { title, description } = getErrorCopy(error, { context: "favorite.remove" });
|
||||||
|
toast.error(title, { description });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
addFavoriteMutation.mutate(
|
addFavoriteMutation.mutate(
|
||||||
{ product_id: productId, website_id: product.prices[0].website_id },
|
{ product_id: productId, website_id: selectedWebsiteId },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("已添加到收藏");
|
toast.success("已添加到收藏");
|
||||||
queryClient.invalidateQueries({ queryKey: ["favorites"] });
|
queryClient.invalidateQueries({ queryKey: ["favorites"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["favorites", "check"] });
|
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);
|
setIsMonitorOpen(false);
|
||||||
queryClient.invalidateQueries({ queryKey: ["favorites", favoriteId, "monitor"] });
|
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: ["products", productId, "prices"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["favorites", favoriteId, "monitor"] });
|
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) =>
|
const highestPrice = product?.prices?.reduce((max, p) =>
|
||||||
Number(p.price) > Number(max.price) ? p : max
|
Number(p.price) > Number(max.price) ? p : max
|
||||||
, product.prices?.[0]);
|
, product.prices?.[0]);
|
||||||
@@ -363,9 +391,7 @@ export default function ProductDetail() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
{product.prices && product.prices.length > 0 ? (
|
{product.prices && product.prices.length > 0 ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{product.prices
|
{sortedPrices.map((price) => (
|
||||||
.sort((a, b) => Number(a.price) - Number(b.price))
|
|
||||||
.map((price) => (
|
|
||||||
<div
|
<div
|
||||||
key={price.id}
|
key={price.id}
|
||||||
className={`flex items-center justify-between p-4 rounded-lg border transition-colors ${
|
className={`flex items-center justify-between p-4 rounded-lg border transition-colors ${
|
||||||
@@ -394,6 +420,11 @@ export default function ProductDetail() {
|
|||||||
最低价
|
最低价
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{selectedWebsiteId === price.website_id && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
当前收藏网站
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
{!price.in_stock && (
|
{!price.in_stock && (
|
||||||
<Badge variant="destructive" className="text-xs">
|
<Badge variant="destructive" className="text-xs">
|
||||||
缺货
|
缺货
|
||||||
@@ -429,6 +460,13 @@ export default function ProductDetail() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
variant={selectedWebsiteId === price.website_id ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedWebsiteId(price.website_id)}
|
||||||
|
>
|
||||||
|
{selectedWebsiteId === price.website_id ? "已选择" : "设为收藏网站"}
|
||||||
|
</Button>
|
||||||
<a
|
<a
|
||||||
href={price.url}
|
href={price.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -497,6 +535,22 @@ export default function ProductDetail() {
|
|||||||
<CardTitle className="text-lg">操作</CardTitle>
|
<CardTitle className="text-lg">操作</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<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 && (
|
{isAuthenticated && favoriteId && (
|
||||||
<Dialog open={isMonitorOpen} onOpenChange={setIsMonitorOpen}>
|
<Dialog open={isMonitorOpen} onOpenChange={setIsMonitorOpen}>
|
||||||
<DialogTrigger asChild>
|
<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
|
* React Query hooks for API calls
|
||||||
*/
|
*/
|
||||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
|
||||||
import {
|
import {
|
||||||
authApi,
|
authApi,
|
||||||
categoryApi,
|
categoryApi,
|
||||||
@@ -14,6 +15,8 @@ import {
|
|||||||
adminApi,
|
adminApi,
|
||||||
friendApi,
|
friendApi,
|
||||||
setAccessToken,
|
setAccessToken,
|
||||||
|
setRefreshToken,
|
||||||
|
clearRefreshToken,
|
||||||
searchApi,
|
searchApi,
|
||||||
type User,
|
type User,
|
||||||
type Bounty,
|
type Bounty,
|
||||||
@@ -54,7 +57,7 @@ export function useLogin() {
|
|||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
// Store tokens
|
// Store tokens
|
||||||
setAccessToken(data.access_token);
|
setAccessToken(data.access_token);
|
||||||
localStorage.setItem('refresh_token', data.refresh_token);
|
setRefreshToken(data.refresh_token);
|
||||||
// Refetch user data
|
// Refetch user data
|
||||||
queryClient.invalidateQueries({ queryKey: ['auth', 'me'] });
|
queryClient.invalidateQueries({ queryKey: ['auth', 'me'] });
|
||||||
},
|
},
|
||||||
@@ -69,7 +72,7 @@ export function useRegister() {
|
|||||||
authApi.register({ open_id: openId, password, name, email }),
|
authApi.register({ open_id: openId, password, name, email }),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setAccessToken(data.access_token);
|
setAccessToken(data.access_token);
|
||||||
localStorage.setItem('refresh_token', data.refresh_token);
|
setRefreshToken(data.refresh_token);
|
||||||
queryClient.invalidateQueries({ queryKey: ['auth', 'me'] });
|
queryClient.invalidateQueries({ queryKey: ['auth', 'me'] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -82,7 +85,7 @@ export function useLogout() {
|
|||||||
mutationFn: authApi.logout,
|
mutationFn: authApi.logout,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setAccessToken(null);
|
setAccessToken(null);
|
||||||
localStorage.removeItem('refresh_token');
|
clearRefreshToken();
|
||||||
queryClient.setQueryData(['auth', 'me'], null);
|
queryClient.setQueryData(['auth', 'me'], null);
|
||||||
queryClient.invalidateQueries({ queryKey: ['auth'] });
|
queryClient.invalidateQueries({ queryKey: ['auth'] });
|
||||||
},
|
},
|
||||||
@@ -100,6 +103,12 @@ export function useUpdateMe() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useChangePassword() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: authApi.changePassword,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Friends Hooks ====================
|
// ==================== Friends Hooks ====================
|
||||||
|
|
||||||
export function useFriends() {
|
export function useFriends() {
|
||||||
@@ -172,10 +181,11 @@ export function useCancelFriendRequest() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useSearchUsers(q: string, limit?: number) {
|
export function useSearchUsers(q: string, limit?: number) {
|
||||||
|
const debouncedQuery = useDebouncedValue(q, 300);
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['friends', 'search', q, limit],
|
queryKey: ['friends', 'search', debouncedQuery, limit],
|
||||||
queryFn: () => friendApi.searchUsers(q, limit),
|
queryFn: () => friendApi.searchUsers(debouncedQuery, limit),
|
||||||
enabled: !!q.trim(),
|
enabled: !!debouncedQuery.trim(),
|
||||||
staleTime: shortStaleTime,
|
staleTime: shortStaleTime,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -221,7 +231,7 @@ export function useWebsite(id: number) {
|
|||||||
|
|
||||||
// ==================== Product Hooks ====================
|
// ==================== 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({
|
return useQuery({
|
||||||
queryKey: ['products', params],
|
queryKey: ['products', params],
|
||||||
queryFn: () => productApi.list(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({
|
return useQuery({
|
||||||
queryKey: ['products', 'search', q, page],
|
queryKey: ['products', 'search', debouncedQuery, params],
|
||||||
queryFn: () => productApi.search(q, page),
|
queryFn: () => productApi.search({ q: debouncedQuery, ...params }),
|
||||||
enabled: !!q,
|
enabled: !!debouncedQuery.trim(),
|
||||||
staleTime: shortStaleTime,
|
staleTime: shortStaleTime,
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
@@ -287,10 +298,11 @@ export function useBounty(id: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useBountySearch(q: string, page?: number) {
|
export function useBountySearch(q: string, page?: number) {
|
||||||
|
const debouncedQuery = useDebouncedValue(q, 300);
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['bounties', 'search', q, page],
|
queryKey: ['bounties', 'search', debouncedQuery, page],
|
||||||
queryFn: () => bountyApi.search(q, page),
|
queryFn: () => bountyApi.search(debouncedQuery, page),
|
||||||
enabled: !!q,
|
enabled: !!debouncedQuery.trim(),
|
||||||
staleTime: shortStaleTime,
|
staleTime: shortStaleTime,
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
@@ -709,10 +721,11 @@ export function useNotifications(params?: { is_read?: boolean; type?: string; st
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useGlobalSearch(q: string, limit = 10) {
|
export function useGlobalSearch(q: string, limit = 10) {
|
||||||
|
const debouncedQuery = useDebouncedValue(q, 300);
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['search', q, limit],
|
queryKey: ['search', debouncedQuery, limit],
|
||||||
queryFn: () => searchApi.global(q, limit),
|
queryFn: () => searchApi.global(debouncedQuery, limit),
|
||||||
enabled: !!q.trim(),
|
enabled: !!debouncedQuery.trim(),
|
||||||
staleTime: shortStaleTime,
|
staleTime: shortStaleTime,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -848,3 +861,40 @@ export function useAdminDisputes(status?: string) {
|
|||||||
staleTime: shortStaleTime,
|
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