This commit is contained in:
27942
2026-01-28 16:00:56 +08:00
parent 03117098c7
commit 6262a67f46
116 changed files with 7821 additions and 5657 deletions

3
.gitignore vendored
View File

@@ -2,6 +2,9 @@
**/node_modules
.pnpm-store/
# Vite cache
.vite/
# Build outputs
dist/
build/

View File

@@ -1,32 +0,0 @@
# 最小验收清单(功能自测)
## 账号与权限
- 注册新账号并登录,能获取 `/api/auth/me` 返回用户信息
- 非管理员访问 `/admin` 自动跳回首页
- 管理员访问 `/admin` 正常看到用户/悬赏/支付事件列表
## 悬赏流程
- 发布悬赏 → 列表/详情可见
- 其他用户申请接单 → 发布者在详情页接受申请
- 接单者提交交付内容 → 发布者验收(通过/驳回)
- 验收通过后可完成悬赏
- 完成后双方可互评
## 支付流程
- 发布者创建托管支付(跳转 Stripe
- 完成支付后悬赏状态为已托管
- 发布者完成悬赏后释放赏金
- 支付事件在管理后台可查看
## 收藏与价格监控
- 收藏商品并设置监控(目标价/提醒开关)
- 刷新价格后产生价格历史记录
- 达到目标价时产生通知
## 通知与偏好
- 通知列表可查看、单条已读、全部已读
- 通知偏好开关能控制对应类型通知是否创建
## 争议与延期
- 接单者可提交延期申请,发布者可同意/拒绝
- 争议可由任一方发起,管理员可处理

131
README.md
View File

@@ -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 登录
- 个人中心
- 通知系统

View File

@@ -2,9 +2,12 @@
Admin API routes for managing core data.
"""
from typing import List, Optional
from datetime import datetime
from django.utils import timezone
from ninja import Router, Schema
from ninja.errors import HttpError
from ninja_jwt.authentication import JWTAuth
from ninja.pagination import paginate, PageNumberPagination
from apps.users.models import User
from apps.products.models import Product, Website, Category
@@ -14,7 +17,7 @@ router = Router()
def require_admin(user):
if not user or user.role != 'admin':
if not user or user.role != 'admin' or not user.is_active:
raise HttpError(403, "仅管理员可访问")
@@ -22,10 +25,9 @@ class UserOut(Schema):
id: int
open_id: str
name: Optional[str] = None
email: Optional[str] = None
role: str
is_active: bool
created_at: str
created_at: datetime
class UserUpdateIn(Schema):
@@ -47,7 +49,14 @@ class BountyAdminOut(Schema):
acceptor_id: Optional[int] = None
is_escrowed: bool
is_paid: bool
created_at: str
created_at: datetime
class Config:
from_attributes = True
@staticmethod
def resolve_reward(obj):
return str(obj.reward)
class PaymentEventOut(Schema):
@@ -56,7 +65,10 @@ class PaymentEventOut(Schema):
event_type: str
bounty_id: Optional[int] = None
success: bool
processed_at: str
processed_at: datetime
class Config:
from_attributes = True
class DisputeOut(Schema):
@@ -64,116 +76,160 @@ class DisputeOut(Schema):
bounty_id: int
initiator_id: int
status: str
created_at: str
created_at: datetime
class Config:
from_attributes = True
@router.get("/users/", response=List[UserOut], auth=JWTAuth())
@paginate(PageNumberPagination, page_size=20)
def list_users(request):
require_admin(request.auth)
users = User.objects.all().order_by('-created_at')
return [
UserOut(
id=u.id,
open_id=u.open_id,
name=u.name,
email=u.email,
role=u.role,
is_active=u.is_active,
created_at=u.created_at.isoformat(),
)
for u in users
]
return User.objects.all().order_by('-created_at')
@router.patch("/users/{user_id}", response=UserOut, auth=JWTAuth())
def update_user(request, user_id: int, data: UserUpdateIn):
require_admin(request.auth)
user = User.objects.get(id=user_id)
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist:
raise HttpError(404, "用户不存在")
update_data = data.dict(exclude_unset=True)
for key, value in update_data.items():
setattr(user, key, value)
user.save()
return UserOut(
id=user.id,
open_id=user.open_id,
name=user.name,
email=user.email,
role=user.role,
is_active=user.is_active,
created_at=user.created_at.isoformat(),
)
return user
@router.get("/categories/", response=List[SimpleOut], auth=JWTAuth())
@paginate(PageNumberPagination, page_size=50)
def list_categories(request):
require_admin(request.auth)
return [SimpleOut(id=c.id, name=c.name) for c in Category.objects.all()]
return Category.objects.values('id', 'name').order_by('name')
@router.get("/websites/", response=List[SimpleOut], auth=JWTAuth())
@paginate(PageNumberPagination, page_size=50)
def list_websites(request):
require_admin(request.auth)
return [SimpleOut(id=w.id, name=w.name) for w in Website.objects.all()]
return Website.objects.values('id', 'name').order_by('name')
@router.get("/products/", response=List[SimpleOut], auth=JWTAuth())
@paginate(PageNumberPagination, page_size=50)
def list_products(request):
require_admin(request.auth)
return [SimpleOut(id=p.id, name=p.name) for p in Product.objects.all()]
return Product.objects.values('id', 'name').order_by('-created_at')
@router.get("/bounties/", response=List[BountyAdminOut], auth=JWTAuth())
@paginate(PageNumberPagination, page_size=20)
def list_bounties(request, status: Optional[str] = None):
require_admin(request.auth)
queryset = Bounty.objects.all().order_by('-created_at')
queryset = Bounty.objects.select_related("publisher", "acceptor").all().order_by('-created_at')
if status:
queryset = queryset.filter(status=status)
return [
BountyAdminOut(
id=b.id,
title=b.title,
status=b.status,
reward=str(b.reward),
publisher_id=b.publisher_id,
acceptor_id=b.acceptor_id,
is_escrowed=b.is_escrowed,
is_paid=b.is_paid,
created_at=b.created_at.isoformat(),
)
for b in queryset
]
return queryset
@router.get("/disputes/", response=List[DisputeOut], auth=JWTAuth())
@paginate(PageNumberPagination, page_size=20)
def list_disputes(request, status: Optional[str] = None):
require_admin(request.auth)
disputes = BountyDispute.objects.all().order_by('-created_at')
if status:
disputes = disputes.filter(status=status)
return [
DisputeOut(
id=d.id,
bounty_id=d.bounty_id,
initiator_id=d.initiator_id,
status=d.status,
created_at=d.created_at.isoformat(),
)
for d in disputes
]
return disputes
@router.get("/payments/", response=List[PaymentEventOut], auth=JWTAuth())
@paginate(PageNumberPagination, page_size=20)
def list_payment_events(request):
require_admin(request.auth)
events = PaymentEvent.objects.all().order_by('-processed_at')
return [
PaymentEventOut(
id=e.id,
event_id=e.event_id,
event_type=e.event_type,
bounty_id=e.bounty_id,
success=e.success,
processed_at=e.processed_at.isoformat(),
)
for e in events
]
return PaymentEvent.objects.all().order_by('-processed_at')
# ==================== Product Review ====================
class ProductAdminOut(Schema):
"""Product admin output schema with all fields."""
id: int
name: str
description: Optional[str] = None
image: Optional[str] = None
category_id: int
category_name: Optional[str] = None
status: str
submitted_by_id: Optional[int] = None
submitted_by_name: Optional[str] = None
reject_reason: Optional[str] = None
reviewed_at: Optional[datetime] = None
created_at: datetime
updated_at: datetime
@staticmethod
def resolve_category_name(obj):
return obj.category.name if obj.category else None
@staticmethod
def resolve_submitted_by_name(obj):
if obj.submitted_by:
return obj.submitted_by.name or obj.submitted_by.open_id
return None
class ProductReviewIn(Schema):
"""Product review input schema."""
approved: bool
reject_reason: Optional[str] = None
@router.get("/products/pending/", response=List[ProductAdminOut], auth=JWTAuth())
@paginate(PageNumberPagination, page_size=20)
def list_pending_products(request):
"""List all pending products for review."""
require_admin(request.auth)
return Product.objects.select_related("category", "submitted_by").filter(
status='pending'
).order_by('-created_at')
@router.get("/products/all/", response=List[ProductAdminOut], auth=JWTAuth())
@paginate(PageNumberPagination, page_size=20)
def list_all_products(request, status: Optional[str] = None):
"""List all products with optional status filter."""
require_admin(request.auth)
queryset = Product.objects.select_related("category", "submitted_by").order_by('-created_at')
if status:
queryset = queryset.filter(status=status)
return queryset
@router.post("/products/{product_id}/review/", response=ProductAdminOut, auth=JWTAuth())
def review_product(request, product_id: int, data: ProductReviewIn):
"""Approve or reject a product."""
require_admin(request.auth)
try:
product = Product.objects.select_related("category", "submitted_by").get(id=product_id)
except Product.DoesNotExist:
raise HttpError(404, "商品不存在")
if product.status != 'pending':
raise HttpError(400, "只能审核待审核状态的商品")
if data.approved:
product.status = 'approved'
product.reject_reason = None
else:
if not data.reject_reason:
raise HttpError(400, "拒绝时需要提供原因")
product.status = 'rejected'
product.reject_reason = data.reject_reason
product.reviewed_at = timezone.now()
product.save()
return product

View File

@@ -8,6 +8,7 @@ from ninja import Router, Query
from ninja.errors import HttpError
from ninja_jwt.authentication import JWTAuth
from ninja.pagination import paginate, PageNumberPagination
from django.conf import settings
from django.db import transaction
from django.db.models import Count, Q
from django.shortcuts import get_object_or_404
@@ -32,8 +33,9 @@ from .schemas import (
BountyReviewOut, BountyReviewIn,
BountyExtensionRequestOut, BountyExtensionRequestIn, BountyExtensionReviewIn,
)
from apps.users.schemas import UserOut
from apps.notifications.models import Notification, NotificationPreference
from apps.common.serializers import serialize_user, serialize_bounty
from apps.notifications.models import Notification
from apps.notifications.utils import should_notify
router = Router()
@@ -51,84 +53,17 @@ def parse_reward(raw_reward) -> Decimal:
raise ValueError("reward must be a valid number")
# Quantize to 2 decimal places
value = value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
# Validate range: max_digits=10, decimal_places=2 means max integer part is 8 digits
# Max value: 99999999.99
if value < Decimal("0.01"):
raise ValueError("reward must be at least 0.01")
if value > Decimal("99999999.99"):
min_reward = getattr(settings, "BOUNTY_MIN_REWARD", Decimal("0.01"))
max_reward = getattr(settings, "BOUNTY_MAX_REWARD", Decimal("99999999.99"))
if value < min_reward:
raise ValueError(f"reward must be at least {min_reward}")
if value > max_reward:
raise ValueError("reward exceeds maximum allowed value")
return value
except InvalidOperation:
raise ValueError("reward must be a valid number")
def serialize_user(user):
"""Serialize user to UserOut."""
if not user:
return None
return UserOut(
id=user.id,
open_id=user.open_id,
name=user.name,
email=user.email,
avatar=user.avatar,
role=user.role,
stripe_customer_id=user.stripe_customer_id,
stripe_account_id=user.stripe_account_id,
created_at=user.created_at,
updated_at=user.updated_at,
)
def serialize_bounty(bounty, include_counts=False):
"""Serialize bounty to BountyOut or BountyWithDetailsOut."""
data = {
'id': bounty.id,
'title': bounty.title,
'description': bounty.description,
'reward': bounty.reward,
'currency': bounty.currency,
'publisher_id': bounty.publisher_id,
'publisher': serialize_user(bounty.publisher),
'acceptor_id': bounty.acceptor_id,
'acceptor': serialize_user(bounty.acceptor) if bounty.acceptor else None,
'status': bounty.status,
'deadline': bounty.deadline,
'completed_at': bounty.completed_at,
'is_paid': bounty.is_paid,
'is_escrowed': bounty.is_escrowed,
'created_at': bounty.created_at,
'updated_at': bounty.updated_at,
}
if include_counts:
applications_count = getattr(bounty, "applications_count", None)
comments_count = getattr(bounty, "comments_count", None)
data['applications_count'] = applications_count if applications_count is not None else bounty.applications.count()
data['comments_count'] = comments_count if comments_count is not None else bounty.comments.count()
return BountyWithDetailsOut(**data)
return BountyOut(**data)
def should_notify(user, notification_type: str) -> bool:
"""Check if user has enabled notification type."""
if not user:
return False
preference, _ = NotificationPreference.objects.get_or_create(user=user)
if notification_type == Notification.Type.PRICE_ALERT:
return preference.enable_price_alert
if notification_type in (
Notification.Type.BOUNTY_ACCEPTED,
Notification.Type.BOUNTY_COMPLETED,
Notification.Type.NEW_COMMENT,
):
return preference.enable_bounty
if notification_type == Notification.Type.SYSTEM:
return preference.enable_system
return True
# ==================== Bounty Routes ====================
@router.get("/", response=List[BountyWithDetailsOut])
@@ -150,8 +85,8 @@ def list_bounties(request, filters: BountyFilter = Query(...)):
queryset = queryset.filter(publisher_id=filters.publisher_id)
if filters.acceptor_id:
queryset = queryset.filter(acceptor_id=filters.acceptor_id)
return [serialize_bounty(b, include_counts=True) for b in queryset]
return queryset
@router.get("/search/", response=List[BountyWithDetailsOut])
@@ -166,7 +101,7 @@ def search_bounties(request, q: str):
)
.filter(Q(title__icontains=q) | Q(description__icontains=q))
)
return [serialize_bounty(b, include_counts=True) for b in queryset]
return queryset
@router.get("/my-published/", response=List[BountyWithDetailsOut], auth=JWTAuth())
@@ -181,7 +116,7 @@ def my_published_bounties(request):
)
.filter(publisher=request.auth)
)
return [serialize_bounty(b, include_counts=True) for b in queryset]
return queryset
@router.get("/my-accepted/", response=List[BountyWithDetailsOut], auth=JWTAuth())
@@ -196,7 +131,7 @@ def my_accepted_bounties(request):
)
.filter(acceptor=request.auth)
)
return [serialize_bounty(b, include_counts=True) for b in queryset]
return queryset
@router.get("/{bounty_id}", response=BountyWithDetailsOut)
@@ -215,6 +150,21 @@ def get_bounty(request, bounty_id: int):
@router.post("/", response=BountyOut, auth=JWTAuth())
def create_bounty(request, data: BountyIn):
"""Create a new bounty."""
# 标题和描述验证
if not data.title or len(data.title.strip()) < 2:
raise HttpError(400, "标题至少需要2个字符")
if len(data.title) > 200:
raise HttpError(400, "标题不能超过200个字符")
if not data.description or len(data.description.strip()) < 10:
raise HttpError(400, "描述至少需要10个字符")
if len(data.description) > 5000:
raise HttpError(400, "描述不能超过5000个字符")
# 截止时间验证
if data.deadline:
if data.deadline <= timezone.now():
raise HttpError(400, "截止时间必须是未来的时间")
payload = data.dict()
try:
payload["reward"] = parse_reward(payload.get("reward"))
@@ -259,6 +209,13 @@ def cancel_bounty(request, bounty_id: int):
if bounty.status not in [Bounty.Status.OPEN, Bounty.Status.IN_PROGRESS]:
raise HttpError(400, "无法取消此悬赏")
# 如果已托管资金且处于进行中状态,需要处理退款
if bounty.is_escrowed and bounty.status == Bounty.Status.IN_PROGRESS:
raise HttpError(400, "已托管资金的进行中悬赏无法直接取消,请联系客服处理退款")
# 如果只是开放状态且已托管,标记需要退款
refund_needed = bounty.is_escrowed and bounty.status == Bounty.Status.OPEN
bounty.status = Bounty.Status.CANCELLED
bounty.save()
@@ -273,7 +230,11 @@ def cancel_bounty(request, bounty_id: int):
related_type="bounty",
)
return MessageOut(message="悬赏已取消", success=True)
message = "悬赏已取消"
if refund_needed:
message = "悬赏已取消托管资金将在3-5个工作日内退回"
return MessageOut(message=message, success=True)
@router.post("/{bounty_id}/complete", response=MessageOut, auth=JWTAuth())
@@ -357,6 +318,10 @@ def my_application(request, bounty_id: int):
@router.post("/{bounty_id}/applications/", response=BountyApplicationOut, auth=JWTAuth())
def submit_application(request, bounty_id: int, data: BountyApplicationIn):
"""Submit an application for a bounty."""
# 申请消息长度验证
if data.message and len(data.message) > 1000:
raise HttpError(400, "申请消息不能超过1000个字符")
bounty = get_object_or_404(Bounty, id=bounty_id)
if bounty.status != Bounty.Status.OPEN:
@@ -401,17 +366,27 @@ def submit_application(request, bounty_id: int, data: BountyApplicationIn):
@router.post("/{bounty_id}/applications/{application_id}/accept", response=MessageOut, auth=JWTAuth())
def accept_application(request, bounty_id: int, application_id: int):
"""Accept an application (only by bounty publisher)."""
bounty = get_object_or_404(Bounty, id=bounty_id)
if bounty.publisher_id != request.auth.id:
raise HttpError(403, "只有发布者可以接受申请")
if bounty.status != Bounty.Status.OPEN:
raise HttpError(400, "无法接受此悬赏的申请")
app = get_object_or_404(BountyApplication, id=application_id, bounty_id=bounty_id)
with transaction.atomic():
# 使用 select_for_update 加锁防止并发
bounty = Bounty.objects.select_for_update().filter(id=bounty_id).first()
if not bounty:
raise HttpError(404, "悬赏不存在")
if bounty.publisher_id != request.auth.id:
raise HttpError(403, "只有发布者可以接受申请")
if bounty.status != Bounty.Status.OPEN:
raise HttpError(400, "无法接受此悬赏的申请")
app = BountyApplication.objects.select_for_update().filter(
id=application_id, bounty_id=bounty_id
).first()
if not app:
raise HttpError(404, "申请不存在")
if app.status != BountyApplication.Status.PENDING:
raise HttpError(400, "该申请已被处理")
# Accept this application
app.status = BountyApplication.Status.ACCEPTED
app.save()
@@ -426,7 +401,7 @@ def accept_application(request, bounty_id: int, application_id: int):
bounty.status = Bounty.Status.IN_PROGRESS
bounty.save()
# Notify acceptor
# Notify acceptor (outside transaction for better performance)
if should_notify(app.applicant, Notification.Type.BOUNTY_ACCEPTED):
Notification.objects.create(
user=app.applicant,
@@ -469,8 +444,14 @@ def list_comments(request, bounty_id: int):
@router.post("/{bounty_id}/comments/", response=BountyCommentOut, auth=JWTAuth())
def create_comment(request, bounty_id: int, data: BountyCommentIn):
"""Create a comment on a bounty."""
bounty = get_object_or_404(Bounty, id=bounty_id)
# 评论内容验证
if not data.content or len(data.content.strip()) < 1:
raise HttpError(400, "评论内容不能为空")
if len(data.content) > 2000:
raise HttpError(400, "评论内容不能超过2000个字符")
bounty = get_object_or_404(Bounty, id=bounty_id)
comment = BountyComment.objects.create(
bounty=bounty,
user=request.auth,
@@ -491,7 +472,9 @@ def create_comment(request, bounty_id: int, data: BountyCommentIn):
# Notify parent comment author (if replying)
if data.parent_id:
parent = BountyComment.objects.get(id=data.parent_id)
parent = get_object_or_404(
BountyComment.objects.select_related("user"), id=data.parent_id
)
if parent.user_id != request.auth.id and should_notify(parent.user, Notification.Type.NEW_COMMENT):
Notification.objects.create(
user=parent.user,
@@ -627,8 +610,28 @@ def list_disputes(request, bounty_id: int):
def create_dispute(request, bounty_id: int, data: BountyDisputeIn):
"""Create a dispute (publisher or acceptor)."""
bounty = get_object_or_404(Bounty, id=bounty_id)
if request.auth.id not in [bounty.publisher_id, bounty.acceptor_id]:
# 检查悬赏状态是否允许创建争议
if bounty.status not in [Bounty.Status.IN_PROGRESS, Bounty.Status.DISPUTED]:
raise HttpError(400, "只有进行中或已有争议的悬赏才能发起争议")
# 检查权限考虑acceptor可能为None
allowed_users = [bounty.publisher_id]
if bounty.acceptor_id:
allowed_users.append(bounty.acceptor_id)
if request.auth.id not in allowed_users:
raise HttpError(403, "无权限发起争议")
# 检查是否已有未解决的争议
if BountyDispute.objects.filter(bounty=bounty, status=BountyDispute.Status.OPEN).exists():
raise HttpError(400, "该悬赏已有未解决的争议")
# 争议原因验证
if not data.reason or len(data.reason.strip()) < 10:
raise HttpError(400, "争议原因至少需要10个字符")
if len(data.reason) > 2000:
raise HttpError(400, "争议原因不能超过2000个字符")
dispute = BountyDispute.objects.create(
bounty=bounty,
initiator=request.auth,
@@ -665,13 +668,47 @@ def resolve_dispute(request, bounty_id: int, dispute_id: int, data: BountyDisput
"""Resolve dispute (admin only)."""
if request.auth.role != 'admin':
raise HttpError(403, "仅管理员可处理争议")
bounty = get_object_or_404(Bounty, id=bounty_id)
dispute = get_object_or_404(BountyDispute, id=dispute_id, bounty_id=bounty_id)
if dispute.status != BountyDispute.Status.OPEN:
raise HttpError(400, "争议已处理")
dispute.status = BountyDispute.Status.RESOLVED if data.accepted else BountyDispute.Status.REJECTED
dispute.resolution = data.resolution
dispute.resolved_at = timezone.now()
dispute.save()
# 检查是否还有其他未解决的争议
has_open_disputes = BountyDispute.objects.filter(
bounty=bounty,
status=BountyDispute.Status.OPEN
).exists()
# 如果没有其他未解决的争议,将悬赏状态恢复为进行中
if not has_open_disputes and bounty.status == Bounty.Status.DISPUTED:
bounty.status = Bounty.Status.IN_PROGRESS
bounty.save()
# 通知相关用户
users_to_notify = []
if bounty.publisher and bounty.publisher_id != request.auth.id:
users_to_notify.append(bounty.publisher)
if bounty.acceptor and bounty.acceptor_id != request.auth.id:
users_to_notify.append(bounty.acceptor)
for user in users_to_notify:
if should_notify(user, Notification.Type.SYSTEM):
Notification.objects.create(
user=user,
type=Notification.Type.SYSTEM,
title="争议已处理",
content=f"悬赏 \"{bounty.title}\" 的争议已被管理员处理",
related_id=bounty.id,
related_type="bounty",
)
return MessageOut(message="争议已处理", success=True)

View 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"),
),
]

View File

@@ -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',
),
]

View File

@@ -69,6 +69,11 @@ class Bounty(models.Model):
verbose_name = '悬赏'
verbose_name_plural = '悬赏'
ordering = ['-created_at']
indexes = [
models.Index(fields=["status", "created_at"]),
models.Index(fields=["publisher", "created_at"]),
models.Index(fields=["acceptor", "created_at"]),
]
def __str__(self):
return self.title
@@ -110,6 +115,10 @@ class BountyApplication(models.Model):
verbose_name = '悬赏申请'
verbose_name_plural = '悬赏申请'
unique_together = ['bounty', 'applicant']
indexes = [
models.Index(fields=["bounty", "status"]),
models.Index(fields=["applicant", "status"]),
]
def __str__(self):
return f"{self.applicant} -> {self.bounty.title}"
@@ -148,6 +157,10 @@ class BountyComment(models.Model):
verbose_name = '悬赏评论'
verbose_name_plural = '悬赏评论'
ordering = ['created_at']
indexes = [
models.Index(fields=["bounty", "created_at"]),
models.Index(fields=["parent", "created_at"]),
]
def __str__(self):
return f"{self.user} on {self.bounty.title}"
@@ -190,6 +203,10 @@ class BountyDelivery(models.Model):
verbose_name = '悬赏交付'
verbose_name_plural = '悬赏交付'
ordering = ['-submitted_at']
indexes = [
models.Index(fields=["bounty", "status"]),
models.Index(fields=["submitted_at"]),
]
def __str__(self):
return f"{self.bounty.title} - {self.submitter}"
@@ -233,6 +250,10 @@ class BountyDispute(models.Model):
verbose_name = '悬赏争议'
verbose_name_plural = '悬赏争议'
ordering = ['-created_at']
indexes = [
models.Index(fields=["bounty", "status"]),
models.Index(fields=["status", "created_at"]),
]
def __str__(self):
return f"{self.bounty.title} - {self.initiator}"
@@ -270,6 +291,10 @@ class BountyReview(models.Model):
verbose_name_plural = '悬赏评价'
unique_together = ['bounty', 'reviewer']
ordering = ['-created_at']
indexes = [
models.Index(fields=["bounty", "created_at"]),
models.Index(fields=["reviewee", "created_at"]),
]
def __str__(self):
return f"{self.bounty.title} - {self.reviewer}"
@@ -341,6 +366,10 @@ class BountyExtensionRequest(models.Model):
verbose_name = '延期申请'
verbose_name_plural = '延期申请'
ordering = ['-created_at']
indexes = [
models.Index(fields=["bounty", "status"]),
models.Index(fields=["requester", "status"]),
]
def __str__(self):
return f"{self.bounty.title} - {self.requester}"

View File

@@ -4,6 +4,7 @@ Stripe payment integration for bounties.
from typing import Optional
from decimal import Decimal
from ninja import Router
from ninja.errors import HttpError
from ninja_jwt.authentication import JWTAuth
from django.conf import settings
from django.shortcuts import get_object_or_404
@@ -15,7 +16,8 @@ import json
from .models import Bounty, PaymentEvent
from apps.users.models import User
from apps.notifications.models import Notification, NotificationPreference
from apps.notifications.models import Notification
from apps.notifications.utils import should_notify
router = Router()
@@ -23,24 +25,6 @@ router = Router()
stripe.api_key = settings.STRIPE_SECRET_KEY
def should_notify(user, notification_type: str) -> bool:
"""Check if user has enabled notification type."""
if not user:
return False
preference, _ = NotificationPreference.objects.get_or_create(user=user)
if notification_type == Notification.Type.PRICE_ALERT:
return preference.enable_price_alert
if notification_type in (
Notification.Type.BOUNTY_ACCEPTED,
Notification.Type.BOUNTY_COMPLETED,
Notification.Type.NEW_COMMENT,
):
return preference.enable_bounty
if notification_type == Notification.Type.SYSTEM:
return preference.enable_system
return True
class PaymentSchemas:
"""Payment related schemas."""
@@ -81,13 +65,13 @@ def create_escrow(request, data: PaymentSchemas.EscrowIn):
bounty = get_object_or_404(Bounty, id=data.bounty_id)
if bounty.publisher_id != request.auth.id:
return {"error": "Only the publisher can create escrow"}, 403
raise HttpError(403, "Only the publisher can create escrow")
if bounty.is_escrowed:
return {"error": "Bounty is already escrowed"}, 400
raise HttpError(400, "Bounty is already escrowed")
if bounty.status != Bounty.Status.OPEN:
return {"error": "Can only escrow open bounties"}, 400
raise HttpError(400, "Can only escrow open bounties")
try:
# Create or get Stripe customer
@@ -137,7 +121,7 @@ def create_escrow(request, data: PaymentSchemas.EscrowIn):
)
except stripe.error.StripeError as e:
return {"error": str(e)}, 400
raise HttpError(400, str(e))
@router.get("/connect/status/", response=PaymentSchemas.ConnectStatusOut, auth=JWTAuth())
@@ -175,7 +159,7 @@ def get_connect_status(request):
)
except stripe.error.StripeError as e:
return {"error": str(e)}, 400
raise HttpError(400, str(e))
@router.post("/connect/setup/", response=PaymentSchemas.ConnectSetupOut, auth=JWTAuth())
@@ -215,7 +199,7 @@ def setup_connect_account(request, return_url: str, refresh_url: str):
)
except stripe.error.StripeError as e:
return {"error": str(e)}, 400
raise HttpError(400, str(e))
@router.post("/{bounty_id}/release/", response=PaymentSchemas.MessageOut, auth=JWTAuth())
@@ -224,23 +208,23 @@ def release_payout(request, bounty_id: int):
bounty = get_object_or_404(Bounty, id=bounty_id)
if bounty.publisher_id != request.auth.id:
return {"error": "Only the publisher can release payment"}, 403
raise HttpError(403, "Only the publisher can release payment")
if bounty.status != Bounty.Status.COMPLETED:
return {"error": "Bounty must be completed to release payment"}, 400
raise HttpError(400, "Bounty must be completed to release payment")
if bounty.is_paid:
return {"error": "Payment has already been released"}, 400
raise HttpError(400, "Payment has already been released")
if not bounty.is_escrowed:
return {"error": "Bounty is not escrowed"}, 400
raise HttpError(400, "Bounty is not escrowed")
if not bounty.acceptor:
return {"error": "No acceptor to pay"}, 400
raise HttpError(400, "No acceptor to pay")
acceptor = bounty.acceptor
if not acceptor.stripe_account_id:
return {"error": "Acceptor has not set up payment account"}, 400
raise HttpError(400, "Acceptor has not set up payment account")
try:
with transaction.atomic():
@@ -249,7 +233,7 @@ def release_payout(request, bounty_id: int):
stripe.PaymentIntent.capture(bounty.stripe_payment_intent_id)
# Calculate payout amount (minus platform fee if any)
platform_fee_percent = Decimal('0.05') # 5% platform fee
platform_fee_percent = getattr(settings, "BOUNTY_PLATFORM_FEE_PERCENT", Decimal("0.05"))
payout_amount = bounty.reward * (1 - platform_fee_percent)
# Create transfer to acceptor
@@ -282,7 +266,7 @@ def release_payout(request, bounty_id: int):
return PaymentSchemas.MessageOut(message="赏金已释放", success=True)
except stripe.error.StripeError as e:
return {"error": str(e)}, 400
raise HttpError(400, str(e))
def handle_webhook(request: HttpRequest) -> HttpResponse:

View File

@@ -0,0 +1 @@
"""Shared utilities for backend apps."""

View 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

View 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)

View File

@@ -22,7 +22,8 @@ from .schemas import (
MessageOut,
)
from apps.products.models import Product, Website, ProductPrice
from apps.notifications.models import Notification, NotificationPreference
from apps.notifications.models import Notification
from apps.notifications.utils import should_notify
router = Router()
@@ -55,11 +56,6 @@ def serialize_favorite(favorite):
)
def should_notify(user) -> bool:
preference, _ = NotificationPreference.objects.get_or_create(user=user)
return preference.enable_price_alert
def record_price_for_monitor(monitor: PriceMonitor, price: Decimal):
"""Record price history and update monitor stats."""
price_change = None
@@ -90,7 +86,7 @@ def record_price_for_monitor(monitor: PriceMonitor, price: Decimal):
price <= monitor.target_price and
(monitor.last_notified_price is None or price < monitor.last_notified_price)
)
if should_alert and should_notify(monitor.user):
if should_alert and should_notify(monitor.user, Notification.Type.PRICE_ALERT):
Notification.objects.create(
user=monitor.user,
type=Notification.Type.PRICE_ALERT,
@@ -116,7 +112,7 @@ def list_favorites(request, tag_id: Optional[int] = None):
).prefetch_related('tag_mappings', 'tag_mappings__tag')
if tag_id:
queryset = queryset.filter(tag_mappings__tag_id=tag_id)
queryset = queryset.filter(tag_mappings__tag_id=tag_id).distinct()
return [serialize_favorite(f) for f in queryset]
@@ -197,22 +193,13 @@ def get_favorite(request, favorite_id: int):
@router.get("/check/", auth=JWTAuth())
def is_favorited(request, product_id: int, website_id: int):
"""Check if a product is favorited."""
exists = Favorite.objects.filter(
favorite_id = Favorite.objects.filter(
user=request.auth,
product_id=product_id,
website_id=website_id
).exists()
favorite_id = None
if exists:
favorite = Favorite.objects.get(
user=request.auth,
product_id=product_id,
website_id=website_id
)
favorite_id = favorite.id
return {"is_favorited": exists, "favorite_id": favorite_id}
).values_list("id", flat=True).first()
return {"is_favorited": bool(favorite_id), "favorite_id": favorite_id}
@router.post("/", response=FavoriteOut, auth=JWTAuth())
@@ -274,7 +261,7 @@ def create_tag(request, data: FavoriteTagIn):
"""Create a new tag."""
# Check if tag with same name exists
if FavoriteTag.objects.filter(user=request.auth, name=data.name).exists():
return {"error": "Tag with this name already exists"}, 400
raise HttpError(400, "Tag with this name already exists")
tag = FavoriteTag.objects.create(
user=request.auth,

View 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"),
),
]

View File

@@ -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',
),
]

View File

@@ -34,6 +34,9 @@ class Favorite(models.Model):
verbose_name = '收藏'
verbose_name_plural = '收藏'
unique_together = ['user', 'product', 'website']
indexes = [
models.Index(fields=["user", "created_at"]),
]
def __str__(self):
return f"{self.user} - {self.product.name}"
@@ -59,6 +62,9 @@ class FavoriteTag(models.Model):
verbose_name = '收藏标签'
verbose_name_plural = '收藏标签'
unique_together = ['user', 'name']
indexes = [
models.Index(fields=["user", "created_at"]),
]
def __str__(self):
return self.name
@@ -87,6 +93,9 @@ class FavoriteTagMapping(models.Model):
verbose_name = '收藏标签映射'
verbose_name_plural = '收藏标签映射'
unique_together = ['favorite', 'tag']
indexes = [
models.Index(fields=["tag", "created_at"]),
]
def __str__(self):
return f"{self.favorite} - {self.tag.name}"
@@ -153,6 +162,9 @@ class PriceMonitor(models.Model):
db_table = 'priceMonitors'
verbose_name = '价格监控'
verbose_name_plural = '价格监控'
indexes = [
models.Index(fields=["user", "is_active"]),
]
def __str__(self):
return f"Monitor: {self.favorite}"
@@ -190,6 +202,9 @@ class PriceHistory(models.Model):
verbose_name = '价格历史'
verbose_name_plural = '价格历史'
ordering = ['-recorded_at']
indexes = [
models.Index(fields=["monitor", "recorded_at"]),
]
def __str__(self):
return f"{self.monitor.favorite} - {self.price}"

View File

@@ -11,7 +11,8 @@ from django.shortcuts import get_object_or_404
from django.http import HttpResponse
from django.utils import timezone
from .models import Notification, NotificationPreference
from .models import Notification
from .utils import get_notification_preference
from .schemas import (
NotificationOut,
UnreadCountOut,
@@ -63,7 +64,7 @@ def list_notifications(
@router.get("/preferences/", response=NotificationPreferenceOut, auth=JWTAuth())
def get_preferences(request):
"""Get current user's notification preferences."""
preference, _ = NotificationPreference.objects.get_or_create(user=request.auth)
preference = get_notification_preference(request.auth)
return NotificationPreferenceOut(
user_id=preference.user_id,
enable_bounty=preference.enable_bounty,
@@ -76,7 +77,7 @@ def get_preferences(request):
@router.patch("/preferences/", response=NotificationPreferenceOut, auth=JWTAuth())
def update_preferences(request, data: NotificationPreferenceIn):
"""Update notification preferences."""
preference, _ = NotificationPreference.objects.get_or_create(user=request.auth)
preference = get_notification_preference(request.auth)
update_data = data.dict(exclude_unset=True)
for key, value in update_data.items():
setattr(preference, key, value)

View File

@@ -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='类型'),
),
]

View 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

View File

@@ -2,6 +2,8 @@
Products API routes for categories, websites, products and prices.
"""
from typing import List, Optional
import re
import time
from decimal import Decimal, InvalidOperation
import csv
import io
@@ -9,9 +11,11 @@ from ninja import Router, Query, File
from ninja.files import UploadedFile
from ninja_jwt.authentication import JWTAuth
from ninja.pagination import paginate, PageNumberPagination
from django.db.models import Count, Min, Max, Q
from django.db import transaction
from django.conf import settings
from django.db.models import Count, Min, Max, Q, Prefetch, F
from django.db import transaction, IntegrityError
from django.shortcuts import get_object_or_404
from django.views.decorators.cache import cache_page
from .models import Category, Website, Product, ProductPrice
from .schemas import (
@@ -19,7 +23,9 @@ from .schemas import (
WebsiteOut, WebsiteIn, WebsiteFilter,
ProductOut, ProductIn, ProductWithPricesOut, ProductPriceOut, ProductPriceIn,
ProductFilter,
ProductSearchFilter,
ImportResultOut,
MyProductOut,
)
from apps.favorites.models import Favorite
@@ -31,21 +37,67 @@ website_router = Router()
# ==================== Category Routes ====================
@category_router.get("/", response=List[CategoryOut])
@cache_page(settings.CACHE_TTL_SECONDS)
def list_categories(request):
"""Get all categories."""
return Category.objects.all()
@category_router.get("/{slug}", response=CategoryOut)
@cache_page(settings.CACHE_TTL_SECONDS)
def get_category_by_slug(request, slug: str):
"""Get category by slug."""
return get_object_or_404(Category, slug=slug)
def require_admin(user):
"""Check if user is admin."""
from ninja.errors import HttpError
if not user or user.role != 'admin' or not user.is_active:
raise HttpError(403, "仅管理员可执行此操作")
def normalize_category_slug(name: str, slug: str) -> str:
"""Normalize category slug and ensure it's not empty."""
raw_slug = (slug or "").strip()
if raw_slug:
return raw_slug
base = re.sub(r"\s+", "-", name.strip().lower())
base = re.sub(r"[^a-z0-9-]", "", base)
if not base:
base = f"category-{int(time.time())}"
if Category.objects.filter(slug=base).exists():
suffix = 1
while Category.objects.filter(slug=f"{base}-{suffix}").exists():
suffix += 1
base = f"{base}-{suffix}"
return base
@category_router.post("/", response=CategoryOut, auth=JWTAuth())
def create_category(request, data: CategoryIn):
"""Create a new category."""
category = Category.objects.create(**data.dict())
name = (data.name or "").strip()
if not name:
raise HttpError(400, "分类名称不能为空")
slug = normalize_category_slug(name, data.slug)
if len(slug) > 100:
raise HttpError(400, "分类标识过长")
try:
category = Category.objects.create(
name=name,
slug=slug,
description=data.description,
icon=data.icon,
parent_id=data.parent_id,
sort_order=data.sort_order or 0,
)
except IntegrityError:
raise HttpError(400, "分类标识已存在")
return category
@@ -53,6 +105,7 @@ def create_category(request, data: CategoryIn):
@website_router.get("/", response=List[WebsiteOut])
@paginate(PageNumberPagination, page_size=20)
@cache_page(settings.CACHE_TTL_SECONDS)
def list_websites(request, filters: WebsiteFilter = Query(...)):
"""Get all websites with optional filters."""
queryset = Website.objects.all()
@@ -66,6 +119,7 @@ def list_websites(request, filters: WebsiteFilter = Query(...)):
@website_router.get("/{website_id}", response=WebsiteOut)
@cache_page(settings.CACHE_TTL_SECONDS)
def get_website(request, website_id: int):
"""Get website by ID."""
return get_object_or_404(Website, id=website_id)
@@ -73,7 +127,7 @@ def get_website(request, website_id: int):
@website_router.post("/", response=WebsiteOut, auth=JWTAuth())
def create_website(request, data: WebsiteIn):
"""Create a new website."""
"""Create a new website. Any authenticated user can create."""
website = Website.objects.create(**data.dict())
return website
@@ -225,15 +279,22 @@ def import_products_csv(request, file: UploadedFile = File(...)):
@router.get("/recommendations/", response=List[ProductOut])
def recommend_products(request, limit: int = 12):
"""Get recommended products based on favorites or popularity."""
# 限制 limit 最大值
if limit < 1:
limit = 1
if limit > 100:
limit = 100
user = getattr(request, "auth", None)
base_queryset = Product.objects.all()
# 只显示已审核通过的商品
base_queryset = Product.objects.select_related("category").filter(status='approved')
if user:
favorite_product_ids = list(
Favorite.objects.filter(user=user).values_list("product_id", flat=True)
)
category_ids = list(
Product.objects.filter(id__in=favorite_product_ids)
Product.objects.filter(id__in=favorite_product_ids, status='approved')
.values_list("category_id", flat=True)
.distinct()
)
@@ -251,9 +312,11 @@ def recommend_products(request, limit: int = 12):
@router.get("/", response=List[ProductOut])
@paginate(PageNumberPagination, page_size=20)
@cache_page(settings.CACHE_TTL_SECONDS)
def list_products(request, filters: ProductFilter = Query(...)):
"""Get all products with optional filters."""
queryset = Product.objects.all()
"""Get all approved products with optional filters."""
# 只显示已审核通过的商品
queryset = Product.objects.select_related("category").filter(status='approved')
if filters.category_id:
queryset = queryset.filter(category_id=filters.category_id)
@@ -263,16 +326,40 @@ def list_products(request, filters: ProductFilter = Query(...)):
Q(description__icontains=filters.search)
)
needs_price_stats = (
filters.min_price is not None
or filters.max_price is not None
or (filters.sort_by or "").lower() in ("price_asc", "price_desc")
)
if needs_price_stats:
queryset = queryset.annotate(lowest_price=Min("prices__price"))
if filters.min_price is not None:
queryset = queryset.filter(lowest_price__gte=filters.min_price)
if filters.max_price is not None:
queryset = queryset.filter(lowest_price__lte=filters.max_price)
sort_by = (filters.sort_by or "newest").lower()
if sort_by == "oldest":
queryset = queryset.order_by("created_at")
elif sort_by == "price_asc":
queryset = queryset.order_by(F("lowest_price").asc(nulls_last=True), "-created_at")
elif sort_by == "price_desc":
queryset = queryset.order_by(F("lowest_price").desc(nulls_last=True), "-created_at")
else:
queryset = queryset.order_by("-created_at")
return queryset
@router.get("/{product_id}", response=ProductOut)
@cache_page(settings.CACHE_TTL_SECONDS)
def get_product(request, product_id: int):
"""Get product by ID."""
return get_object_or_404(Product, id=product_id)
@router.get("/{product_id}/with-prices", response=ProductWithPricesOut)
@cache_page(settings.CACHE_TTL_SECONDS)
def get_product_with_prices(request, product_id: int):
"""Get product with all prices from different websites."""
product = get_object_or_404(Product, id=product_id)
@@ -317,15 +404,48 @@ def get_product_with_prices(request, product_id: int):
@router.get("/search/", response=List[ProductWithPricesOut])
@paginate(PageNumberPagination, page_size=20)
def search_products(request, q: str):
"""Search products by name or description."""
products = Product.objects.filter(
Q(name__icontains=q) | Q(description__icontains=q)
@cache_page(settings.CACHE_TTL_SECONDS)
def search_products(request, q: str, filters: ProductSearchFilter = Query(...)):
"""Search approved products by name or description."""
prices_prefetch = Prefetch(
"prices",
queryset=ProductPrice.objects.select_related("website"),
)
# 只搜索已审核通过的商品
products = (
Product.objects.select_related("category")
.filter(Q(name__icontains=q) | Q(description__icontains=q), status='approved')
)
if filters.category_id:
products = products.filter(category_id=filters.category_id)
needs_price_stats = (
filters.min_price is not None
or filters.max_price is not None
or (filters.sort_by or "").lower() in ("price_asc", "price_desc")
)
if needs_price_stats:
products = products.annotate(lowest_price=Min("prices__price"))
if filters.min_price is not None:
products = products.filter(lowest_price__gte=filters.min_price)
if filters.max_price is not None:
products = products.filter(lowest_price__lte=filters.max_price)
sort_by = (filters.sort_by or "newest").lower()
if sort_by == "oldest":
products = products.order_by("created_at")
elif sort_by == "price_asc":
products = products.order_by(F("lowest_price").asc(nulls_last=True), "-created_at")
elif sort_by == "price_desc":
products = products.order_by(F("lowest_price").desc(nulls_last=True), "-created_at")
else:
products = products.order_by("-created_at")
products = products.prefetch_related(prices_prefetch)
result = []
for product in products:
prices = ProductPrice.objects.filter(product=product).select_related('website')
prices = list(product.prices.all())
price_list = [
ProductPriceOut(
id=pp.id,
@@ -342,8 +462,8 @@ def search_products(request, q: str):
)
for pp in prices
]
price_stats = prices.aggregate(lowest=Min('price'), highest=Max('price'))
lowest_price = min((pp.price for pp in prices), default=None)
highest_price = max((pp.price for pp in prices), default=None)
result.append(ProductWithPricesOut(
id=product.id,
@@ -354,8 +474,8 @@ def search_products(request, q: str):
created_at=product.created_at,
updated_at=product.updated_at,
prices=price_list,
lowest_price=price_stats['lowest'],
highest_price=price_stats['highest'],
lowest_price=lowest_price,
highest_price=highest_price,
))
return result
@@ -363,14 +483,43 @@ def search_products(request, q: str):
@router.post("/", response=ProductOut, auth=JWTAuth())
def create_product(request, data: ProductIn):
"""Create a new product."""
product = Product.objects.create(**data.dict())
"""Create a new product. Admin creates approved, others create pending."""
user = request.auth
is_admin = user and user.role == 'admin' and user.is_active
product = Product.objects.create(
name=data.name,
description=data.description,
image=data.image,
category_id=data.category_id,
status='approved' if is_admin else 'pending',
submitted_by=user,
)
return product
@router.get("/my/", response=List[MyProductOut], auth=JWTAuth())
@paginate(PageNumberPagination, page_size=20)
def my_products(request, status: Optional[str] = None):
"""Get current user's submitted products."""
user = request.auth
queryset = Product.objects.filter(submitted_by=user).order_by('-created_at')
if status:
queryset = queryset.filter(status=status)
return queryset
@router.post("/prices/", response=ProductPriceOut, auth=JWTAuth())
def add_product_price(request, data: ProductPriceIn):
"""Add a price for a product."""
"""Add a price for a product. Admin or product owner can add."""
user = request.auth
is_admin = user and user.role == 'admin' and user.is_active
# 检查商品是否存在并验证权限
product = get_object_or_404(Product, id=data.product_id)
if not is_admin and product.submitted_by_id != user.id:
from ninja.errors import HttpError
raise HttpError(403, "只能为自己提交的商品添加价格")
price = ProductPrice.objects.create(**data.dict())
website = price.website

View 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("初始化完成!"))

View 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"),
),
]

View File

@@ -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',
),
]

View File

@@ -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'),
),
]

View File

@@ -2,6 +2,7 @@
Product models for categories, websites, products and prices.
"""
from django.db import models
from django.conf import settings
class Category(models.Model):
@@ -28,6 +29,9 @@ class Category(models.Model):
verbose_name = '分类'
verbose_name_plural = '分类'
ordering = ['sort_order', 'id']
indexes = [
models.Index(fields=["parent", "sort_order"]),
]
def __str__(self):
return self.name
@@ -58,6 +62,9 @@ class Website(models.Model):
verbose_name = '网站'
verbose_name_plural = '网站'
ordering = ['sort_order', 'id']
indexes = [
models.Index(fields=["category", "is_verified"]),
]
def __str__(self):
return self.name
@@ -66,6 +73,11 @@ class Website(models.Model):
class Product(models.Model):
"""Products for price comparison."""
class Status(models.TextChoices):
PENDING = 'pending', '待审核'
APPROVED = 'approved', '已通过'
REJECTED = 'rejected', '已拒绝'
id = models.AutoField(primary_key=True)
name = models.CharField('商品名称', max_length=300)
description = models.TextField('描述', blank=True, null=True)
@@ -76,6 +88,22 @@ class Product(models.Model):
related_name='products',
verbose_name='分类'
)
status = models.CharField(
'审核状态',
max_length=20,
choices=Status.choices,
default=Status.PENDING
)
submitted_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='submitted_products',
verbose_name='提交者'
)
reject_reason = models.TextField('拒绝原因', blank=True, null=True)
reviewed_at = models.DateTimeField('审核时间', blank=True, null=True)
created_at = models.DateTimeField('创建时间', auto_now_add=True)
updated_at = models.DateTimeField('更新时间', auto_now=True)
@@ -83,6 +111,11 @@ class Product(models.Model):
db_table = 'products'
verbose_name = '商品'
verbose_name_plural = '商品'
indexes = [
models.Index(fields=["category", "created_at"]),
models.Index(fields=["status", "created_at"]),
models.Index(fields=["submitted_by", "status"]),
]
def __str__(self):
return self.name
@@ -124,6 +157,9 @@ class ProductPrice(models.Model):
verbose_name = '商品价格'
verbose_name_plural = '商品价格'
unique_together = ['product', 'website']
indexes = [
models.Index(fields=["product", "website", "last_checked"]),
]
def __str__(self):
return f"{self.product.name} - {self.website.name}: {self.price}"

View File

@@ -79,6 +79,10 @@ class ProductOut(Schema):
description: Optional[str] = None
image: Optional[str] = None
category_id: int
status: str = "approved"
submitted_by_id: Optional[int] = None
reject_reason: Optional[str] = None
reviewed_at: Optional[datetime] = None
created_at: datetime
updated_at: datetime
@@ -98,6 +102,20 @@ class ProductIn(Schema):
category_id: int
class MyProductOut(Schema):
"""User's product output schema."""
id: int
name: str
description: Optional[str] = None
image: Optional[str] = None
category_id: int
status: str
reject_reason: Optional[str] = None
reviewed_at: Optional[datetime] = None
created_at: datetime
updated_at: datetime
class ProductPriceIn(Schema):
"""Product price input schema."""
product_id: int
@@ -123,6 +141,17 @@ class ProductFilter(FilterSchema):
"""Product filter schema."""
category_id: Optional[int] = None
search: Optional[str] = None
min_price: Optional[Decimal] = None
max_price: Optional[Decimal] = None
sort_by: Optional[str] = None
class ProductSearchFilter(FilterSchema):
"""Product search filter schema."""
category_id: Optional[int] = None
min_price: Optional[Decimal] = None
max_price: Optional[Decimal] = None
sort_by: Optional[str] = None
class WebsiteFilter(FilterSchema):

View File

@@ -2,15 +2,17 @@
User authentication API routes.
"""
from typing import Optional
from ninja import Router
from ninja import Router, Schema
from ninja.errors import HttpError
from ninja_jwt.authentication import JWTAuth
from ninja_jwt.tokens import RefreshToken
from django.conf import settings
from django.http import HttpRequest, HttpResponse
from urllib.parse import urlparse, urlencode
import requests
from .models import User
from .schemas import UserOut, UserUpdate, TokenOut, OAuthCallbackIn, MessageOut, RegisterIn, LoginIn
from .schemas import UserOut, UserPrivateOut, UserUpdate, TokenOut, OAuthCallbackIn, MessageOut, RegisterIn, LoginIn
router = Router()
@@ -22,18 +24,44 @@ def get_current_user(request: HttpRequest) -> Optional[User]:
return None
@router.get("/me", response=UserOut, auth=JWTAuth())
def _is_valid_url(value: str) -> bool:
if not value:
return False
parsed = urlparse(value)
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
def _require_oauth_config():
if not settings.OAUTH_CLIENT_ID:
raise HttpError(500, "OAuth 未配置客户端 ID")
if not settings.OAUTH_AUTHORIZE_URL:
raise HttpError(500, "OAuth 未配置授权地址")
if not settings.OAUTH_TOKEN_URL:
raise HttpError(500, "OAuth 未配置令牌地址")
if not settings.OAUTH_USERINFO_URL:
raise HttpError(500, "OAuth 未配置用户信息地址")
if not _is_valid_url(settings.OAUTH_REDIRECT_URI):
raise HttpError(500, "OAuth 回调地址无效")
@router.get("/me", response=UserPrivateOut, auth=JWTAuth())
def get_me(request):
"""Get current user information."""
return request.auth
@router.patch("/me", response=UserOut, auth=JWTAuth())
@router.patch("/me", response=UserPrivateOut, auth=JWTAuth())
def update_me(request, data: UserUpdate):
"""Update current user information."""
user = request.auth
# 验证邮箱格式
if data.email is not None:
validate_email(data.email)
if data.name is not None:
if len(data.name) > 50:
raise HttpError(400, "名称不能超过50个字符")
user.name = data.name
if data.email is not None:
user.email = data.email
@@ -55,6 +83,36 @@ def logout(request):
return MessageOut(message="已退出登录", success=True)
class ChangePasswordIn(Schema):
"""Change password input schema."""
current_password: str
new_password: str
@router.post("/change-password", response=MessageOut, auth=JWTAuth())
def change_password(request, data: ChangePasswordIn):
"""Change current user's password."""
user = request.auth
# 验证当前密码
if not user.check_password(data.current_password):
raise HttpError(400, "当前密码错误")
# 验证新密码
if len(data.new_password) < 6:
raise HttpError(400, "新密码长度至少6位")
if len(data.new_password) > 128:
raise HttpError(400, "新密码长度不能超过128位")
if data.current_password == data.new_password:
raise HttpError(400, "新密码不能与当前密码相同")
# 更新密码
user.set_password(data.new_password)
user.save()
return MessageOut(message="密码已更新", success=True)
@router.post("/refresh", response=TokenOut)
def refresh_token(request, refresh_token: str):
"""Refresh access token using refresh token."""
@@ -64,19 +122,50 @@ def refresh_token(request, refresh_token: str):
access_token=str(refresh.access_token),
refresh_token=str(refresh),
)
except Exception as e:
return {"error": str(e)}, 401
except Exception:
raise HttpError(401, "刷新令牌无效或已过期")
def validate_password(password: str) -> None:
"""Validate password strength."""
if len(password) < 6:
raise HttpError(400, "密码长度至少6位")
if len(password) > 128:
raise HttpError(400, "密码长度不能超过128位")
def validate_email(email: Optional[str]) -> None:
"""Validate email format."""
if email:
import re
email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
if not re.match(email_pattern, email):
raise HttpError(400, "邮箱格式不正确")
@router.post("/register", response=TokenOut)
def register(request, data: RegisterIn):
"""Register new user with password."""
# 输入验证
validate_password(data.password)
# 邮箱必填且格式验证
if not data.email:
raise HttpError(400, "邮箱为必填项")
validate_email(data.email)
# 检查用户名是否已存在
if User.objects.filter(open_id=data.open_id).exists():
return {"error": "账号已存在"}, 400
raise HttpError(400, "用户名已被使用")
# 检查邮箱是否已存在
if User.objects.filter(email=data.email).exists():
raise HttpError(400, "邮箱已被注册")
user = User.objects.create_user(
open_id=data.open_id,
password=data.password,
name=data.name,
name=data.name or data.open_id, # 默认显示名称为用户名
email=data.email,
login_method="password",
)
@@ -89,13 +178,23 @@ def register(request, data: RegisterIn):
@router.post("/login", response=TokenOut)
def login(request, data: LoginIn):
"""Login with open_id and password."""
"""Login with open_id or email and password."""
from django.db.models import Q
# 支持用户名或邮箱登录
try:
user = User.objects.get(open_id=data.open_id)
user = User.objects.get(Q(open_id=data.open_id) | Q(email=data.open_id))
except User.DoesNotExist:
return {"error": "账号或密码错误"}, 401
raise HttpError(401, "账号或密码错误")
except User.MultipleObjectsReturned:
# 如果同时匹配多个用户优先使用open_id匹配
try:
user = User.objects.get(open_id=data.open_id)
except User.DoesNotExist:
raise HttpError(401, "账号或密码错误")
if not user.check_password(data.password):
return {"error": "账号或密码错误"}, 401
raise HttpError(401, "账号或密码错误")
refresh = RefreshToken.for_user(user)
return TokenOut(
access_token=str(refresh.access_token),
@@ -106,27 +205,29 @@ def login(request, data: LoginIn):
@router.get("/oauth/url")
def get_oauth_url(request, redirect_uri: Optional[str] = None):
"""Get OAuth authorization URL."""
# This would integrate with Manus SDK or other OAuth provider
client_id = settings.OAUTH_CLIENT_ID
_require_oauth_config()
redirect = redirect_uri or settings.OAUTH_REDIRECT_URI
# Example OAuth URL (adjust based on actual OAuth provider)
oauth_url = f"https://oauth.example.com/authorize?client_id={client_id}&redirect_uri={redirect}&response_type=code"
if not _is_valid_url(redirect):
raise HttpError(400, "回调地址无效")
query = urlencode(
{
"client_id": settings.OAUTH_CLIENT_ID,
"redirect_uri": redirect,
"response_type": "code",
}
)
oauth_url = f"{settings.OAUTH_AUTHORIZE_URL}?{query}"
return {"url": oauth_url}
@router.post("/oauth/callback", response=TokenOut)
def oauth_callback(request, data: OAuthCallbackIn):
"""Handle OAuth callback and create/update user."""
# This would exchange the code for tokens with the OAuth provider
# and create or update the user in the database
# Example implementation (adjust based on actual OAuth provider)
try:
_require_oauth_config()
# Exchange code for access token
token_response = requests.post(
"https://oauth.example.com/token",
settings.OAUTH_TOKEN_URL,
data={
"client_id": settings.OAUTH_CLIENT_ID,
"client_secret": settings.OAUTH_CLIENT_SECRET,
@@ -137,18 +238,18 @@ def oauth_callback(request, data: OAuthCallbackIn):
)
if token_response.status_code != 200:
return {"error": "OAuth token exchange failed"}, 400
raise HttpError(400, "OAuth token exchange failed")
oauth_data = token_response.json()
# Get user info from OAuth provider
user_response = requests.get(
"https://oauth.example.com/userinfo",
settings.OAUTH_USERINFO_URL,
headers={"Authorization": f"Bearer {oauth_data['access_token']}"}
)
if user_response.status_code != 200:
return {"error": "Failed to get user info"}, 400
raise HttpError(400, "Failed to get user info")
user_info = user_response.json()
@@ -171,8 +272,8 @@ def oauth_callback(request, data: OAuthCallbackIn):
refresh_token=str(refresh),
)
except Exception as e:
return {"error": str(e)}, 500
except Exception:
raise HttpError(500, "OAuth 登录失败")
# Development endpoint for testing without OAuth
@@ -180,7 +281,7 @@ def oauth_callback(request, data: OAuthCallbackIn):
def dev_login(request, open_id: str, name: Optional[str] = None):
"""Development login endpoint (disable in production)."""
if not settings.DEBUG:
return {"error": "Not available in production"}, 403
raise HttpError(403, "Not available in production")
user, created = User.objects.get_or_create(
open_id=open_id,

View 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}')

View 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"),
),
]

View File

@@ -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'),
),
]

View File

@@ -67,6 +67,10 @@ class User(AbstractBaseUser, PermissionsMixin):
db_table = 'users'
verbose_name = '用户'
verbose_name_plural = '用户'
indexes = [
models.Index(fields=["role", "is_active"]),
models.Index(fields=["created_at"]),
]
def __str__(self):
return self.name or self.open_id
@@ -116,6 +120,10 @@ class FriendRequest(models.Model):
name="no_self_friend_request",
)
]
indexes = [
models.Index(fields=["receiver", "status"]),
models.Index(fields=["requester", "status"]),
]
def __str__(self):
return f"{self.requester_id}->{self.receiver_id} ({self.status})"

View File

@@ -7,19 +7,23 @@ from ninja import Schema
class UserOut(Schema):
"""User output schema."""
"""Public user output schema."""
id: int
open_id: str
name: Optional[str] = None
email: Optional[str] = None
avatar: Optional[str] = None
role: str
stripe_customer_id: Optional[str] = None
stripe_account_id: Optional[str] = None
created_at: datetime
updated_at: datetime
class UserPrivateOut(UserOut):
"""Private user output schema (includes sensitive fields)."""
stripe_customer_id: Optional[str] = None
stripe_account_id: Optional[str] = None
class UserBrief(Schema):
"""Minimal user info for social features."""
id: int

View File

@@ -2,6 +2,7 @@
Django Ninja API configuration.
"""
from ninja import NinjaAPI
from ninja.errors import HttpError, ValidationError
from ninja_jwt.authentication import JWTAuth
# Import routers from apps
@@ -14,6 +15,7 @@ from apps.favorites.api import router as favorites_router
from apps.notifications.api import router as notifications_router
from apps.admin.api import router as admin_router
from config.search import router as search_router
from apps.common.errors import build_error_payload
# Create main API instance
api = NinjaAPI(
@@ -22,6 +24,39 @@ api = NinjaAPI(
description="Backend API for AI Web application",
)
@api.exception_handler(HttpError)
def on_http_error(request, exc: HttpError):
return api.create_response(
request,
build_error_payload(status_code=exc.status_code, message=str(exc)),
status=exc.status_code,
)
@api.exception_handler(ValidationError)
def on_validation_error(request, exc: ValidationError):
details = getattr(exc, "errors", None)
return api.create_response(
request,
build_error_payload(
status_code=400,
message="请求参数校验失败",
details=details,
code="validation_error",
),
status=400,
)
@api.exception_handler(Exception)
def on_unhandled_error(request, exc: Exception):
return api.create_response(
request,
build_error_payload(status_code=500, message="服务器内部错误"),
status=500,
)
# Register routers
api.add_router("/auth/", auth_router, tags=["认证"])
api.add_router("/friends/", friends_router, tags=["好友"])

View 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

View File

@@ -4,12 +4,14 @@ Global search API routes.
from typing import List
from ninja import Router, Schema
from django.db.models import Count, Q
from django.conf import settings
from django.views.decorators.cache import cache_page
from apps.products.models import Product, Website
from apps.products.schemas import ProductOut, WebsiteOut
from apps.bounties.models import Bounty
from apps.bounties.schemas import BountyWithDetailsOut
from apps.users.schemas import UserOut
from apps.common.serializers import serialize_bounty
router = Router()
@@ -20,47 +22,12 @@ class SearchResultsOut(Schema):
bounties: List[BountyWithDetailsOut]
def serialize_user(user):
if not user:
return None
return UserOut(
id=user.id,
open_id=user.open_id,
name=user.name,
email=user.email,
avatar=user.avatar,
role=user.role,
stripe_customer_id=user.stripe_customer_id,
stripe_account_id=user.stripe_account_id,
created_at=user.created_at,
updated_at=user.updated_at,
)
def serialize_bounty(bounty):
return BountyWithDetailsOut(
id=bounty.id,
title=bounty.title,
description=bounty.description,
reward=bounty.reward,
currency=bounty.currency,
publisher_id=bounty.publisher_id,
publisher=serialize_user(bounty.publisher),
acceptor_id=bounty.acceptor_id,
acceptor=serialize_user(bounty.acceptor) if bounty.acceptor else None,
status=bounty.status,
deadline=bounty.deadline,
completed_at=bounty.completed_at,
is_paid=bounty.is_paid,
is_escrowed=bounty.is_escrowed,
created_at=bounty.created_at,
updated_at=bounty.updated_at,
applications_count=getattr(bounty, "applications_count", 0),
comments_count=getattr(bounty, "comments_count", 0),
)
def _serialize_bounty_with_counts(bounty):
return serialize_bounty(bounty, include_counts=True)
@router.get("/", response=SearchResultsOut)
@cache_page(settings.CACHE_TTL_SECONDS)
def global_search(request, q: str, limit: int = 10):
"""Search products, websites and bounties by keyword."""
keyword = (q or "").strip()
@@ -68,13 +35,13 @@ def global_search(request, q: str, limit: int = 10):
return SearchResultsOut(products=[], websites=[], bounties=[])
products = list(
Product.objects.filter(
Product.objects.select_related("category").filter(
Q(name__icontains=keyword) | Q(description__icontains=keyword)
).order_by("-created_at")[:limit]
)
websites = list(
Website.objects.filter(
Website.objects.select_related("category").filter(
Q(name__icontains=keyword) | Q(description__icontains=keyword)
).order_by("-created_at")[:limit]
)
@@ -92,5 +59,5 @@ def global_search(request, q: str, limit: int = 10):
return SearchResultsOut(
products=products,
websites=websites,
bounties=[serialize_bounty(b) for b in bounties],
bounties=[_serialize_bounty_with_counts(b) for b in bounties],
)

View File

@@ -2,6 +2,7 @@
Django settings for ai_web project.
"""
import os
from decimal import Decimal
from pathlib import Path
from datetime import timedelta
from dotenv import load_dotenv
@@ -9,16 +10,33 @@ from dotenv import load_dotenv
# Load environment variables
load_dotenv()
# Environment helpers
def _env_bool(key: str, default: bool = False) -> bool:
value = os.getenv(key, str(default))
return str(value).strip().lower() in {"1", "true", "yes", "y", "on"}
def _env_csv(key: str, default: str) -> list[str]:
raw = os.getenv(key)
if raw is None:
raw = default
return [item.strip() for item in raw.split(",") if item.strip()]
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'django-insecure-change-this-in-production')
DEBUG = _env_bool("DEBUG", False)
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY")
if not SECRET_KEY:
if DEBUG:
SECRET_KEY = "django-insecure-dev-key"
else:
raise RuntimeError("DJANGO_SECRET_KEY is required in production")
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = os.getenv('DEBUG', 'True').lower() == 'true'
ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',')
ALLOWED_HOSTS = _env_csv("ALLOWED_HOSTS", "localhost,127.0.0.1" if DEBUG else "")
if not DEBUG and not ALLOWED_HOSTS:
raise RuntimeError("ALLOWED_HOSTS must be configured in production")
# Application definition
@@ -46,9 +64,11 @@ MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'config.middleware.RateLimitMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'config.middleware.ETagMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
@@ -74,28 +94,28 @@ WSGI_APPLICATION = 'config.wsgi.application'
# Database
# 使用 SQLite 数据库(开发环境)
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
DB_ENGINE = os.getenv("DB_ENGINE", "sqlite").lower()
if DB_ENGINE == "mysql":
DATABASES = {
"default": {
"ENGINE": "django.db.backends.mysql",
"NAME": os.getenv("DB_NAME", "ai_web"),
"USER": os.getenv("DB_USER", "root"),
"PASSWORD": os.getenv("DB_PASSWORD", ""),
"HOST": os.getenv("DB_HOST", "localhost"),
"PORT": os.getenv("DB_PORT", "3306"),
"OPTIONS": {
"charset": "utf8mb4",
},
}
}
else:
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
}
# 如需使用 MySQL取消注释以下配置并注释上面的 SQLite 配置
# DATABASES = {
# 'default': {
# 'ENGINE': 'django.db.backends.mysql',
# 'NAME': os.getenv('DB_NAME', 'ai_web'),
# 'USER': os.getenv('DB_USER', 'root'),
# 'PASSWORD': os.getenv('DB_PASSWORD', ''),
# 'HOST': os.getenv('DB_HOST', 'localhost'),
# 'PORT': os.getenv('DB_PORT', '3306'),
# 'OPTIONS': {
# 'charset': 'utf8mb4',
# },
# }
# }
# Password validation
@@ -136,11 +156,17 @@ AUTH_USER_MODEL = 'users.User'
# CORS settings
CORS_ALLOWED_ORIGINS = os.getenv(
'CORS_ALLOWED_ORIGINS',
'http://localhost:5173,http://127.0.0.1:5173'
).split(',')
CORS_ALLOWED_ORIGINS = _env_csv(
"CORS_ALLOWED_ORIGINS",
"http://localhost:5173,http://127.0.0.1:5173" if DEBUG else "",
)
if not DEBUG and not CORS_ALLOWED_ORIGINS:
raise RuntimeError("CORS_ALLOWED_ORIGINS must be configured in production")
CORS_ALLOW_CREDENTIALS = True
CSRF_TRUSTED_ORIGINS = _env_csv(
"CSRF_TRUSTED_ORIGINS",
"http://localhost:5173,http://127.0.0.1:5173" if DEBUG else "",
)
# JWT settings
@@ -156,14 +182,52 @@ NINJA_JWT = {
'AUTH_COOKIE_SAMESITE': 'Lax',
}
# Cache settings
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "ai_web_cache",
"TIMEOUT": int(os.getenv("CACHE_DEFAULT_TIMEOUT", "300")),
}
}
CACHE_TTL_SECONDS = int(os.getenv("CACHE_TTL_SECONDS", "60"))
ETAG_MAX_BYTES = int(os.getenv("ETAG_MAX_BYTES", str(2 * 1024 * 1024)))
# Security settings for production
if not DEBUG:
SECURE_SSL_REDIRECT = _env_bool("SECURE_SSL_REDIRECT", True)
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SAMESITE = "Lax"
CSRF_COOKIE_SAMESITE = "Lax"
SECURE_HSTS_SECONDS = int(os.getenv("SECURE_HSTS_SECONDS", "31536000"))
SECURE_HSTS_INCLUDE_SUBDOMAINS = _env_bool("SECURE_HSTS_INCLUDE_SUBDOMAINS", True)
SECURE_HSTS_PRELOAD = _env_bool("SECURE_HSTS_PRELOAD", True)
SECURE_REFERRER_POLICY = os.getenv("SECURE_REFERRER_POLICY", "same-origin")
# Stripe settings
STRIPE_SECRET_KEY = os.getenv('STRIPE_SECRET_KEY', '')
STRIPE_WEBHOOK_SECRET = os.getenv('STRIPE_WEBHOOK_SECRET', '')
STRIPE_PUBLISHABLE_KEY = os.getenv('STRIPE_PUBLISHABLE_KEY', '')
# Bounty settings
BOUNTY_MIN_REWARD = Decimal(os.getenv("BOUNTY_MIN_REWARD", "0.01"))
BOUNTY_MAX_REWARD = Decimal(os.getenv("BOUNTY_MAX_REWARD", "99999999.99"))
BOUNTY_PLATFORM_FEE_PERCENT = Decimal(os.getenv("BOUNTY_PLATFORM_FEE_PERCENT", "0.05"))
# OAuth settings (Manus SDK compatible)
OAUTH_CLIENT_ID = os.getenv('OAUTH_CLIENT_ID', '')
OAUTH_CLIENT_SECRET = os.getenv('OAUTH_CLIENT_SECRET', '')
OAUTH_REDIRECT_URI = os.getenv('OAUTH_REDIRECT_URI', 'http://localhost:8000/api/auth/callback')
OAUTH_CLIENT_ID = os.getenv("OAUTH_CLIENT_ID", "")
OAUTH_CLIENT_SECRET = os.getenv("OAUTH_CLIENT_SECRET", "")
OAUTH_REDIRECT_URI = os.getenv("OAUTH_REDIRECT_URI", "http://localhost:8000/api/auth/callback")
OAUTH_AUTHORIZE_URL = os.getenv("OAUTH_AUTHORIZE_URL", "")
OAUTH_TOKEN_URL = os.getenv("OAUTH_TOKEN_URL", "")
OAUTH_USERINFO_URL = os.getenv("OAUTH_USERINFO_URL", "")
# Basic rate limiting
RATE_LIMIT_ENABLE = _env_bool("RATE_LIMIT_ENABLE", not DEBUG)
RATE_LIMIT_REQUESTS = int(os.getenv("RATE_LIMIT_REQUESTS", "120"))
RATE_LIMIT_WINDOW_SECONDS = int(os.getenv("RATE_LIMIT_WINDOW_SECONDS", "60"))
RATE_LIMIT_PATHS = _env_csv("RATE_LIMIT_PATHS", "/api/")

View File

@@ -0,0 +1,5 @@
-r requirements.txt
# Development
pytest>=7.4.0
pytest-django>=4.5.0

View File

@@ -17,6 +17,3 @@ pydantic>=2.0.0
# HTTP client (for OAuth)
requests>=2.31.0
# Development
pytest>=7.4.0
pytest-django>=4.5.0

View File

@@ -4,7 +4,7 @@
"rsc": false,
"tsx": true,
"tailwind": {
"css": "client/src/index.css",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""

View File

@@ -87,6 +87,13 @@
},
"overrides": {
"tailwindcss>nanoid": "3.3.7"
}
},
"ignoredBuiltDependencies": [
"@tailwindcss/oxide",
"esbuild"
],
"onlyBuiltDependencies": [
"@tailwindcss/oxide"
]
}
}

View File

@@ -3,5 +3,4 @@
* Import shared types from this single entry point.
*/
export type * from "../drizzle/schema";
export * from "./_core/errors";

View File

@@ -1,21 +1,22 @@
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import NotFound from "@/pages/NotFound";
import NotFound from "@/features/common/pages/NotFound";
import { Route, Switch } from "wouter";
import ErrorBoundary from "./components/ErrorBoundary";
import FriendPanel from "./components/FriendPanel";
import FriendPanel from "@/features/friends/FriendPanel";
import { ThemeProvider } from "./contexts/ThemeContext";
import Home from "./pages/Home";
import Login from "./pages/Login";
import Products from "./pages/Products";
import ProductDetail from "./pages/ProductDetail";
import Bounties from "./pages/Bounties";
import BountyDetail from "./pages/BountyDetail";
import Dashboard from "./pages/Dashboard";
import Favorites from "./pages/Favorites";
import ProductComparison from "./pages/ProductComparison";
import Admin from "./pages/Admin";
import Search from "./pages/Search";
import Home from "@/features/home/pages/Home";
import Login from "@/features/auth/pages/Login";
import Products from "@/features/products/pages/Products";
import ProductDetail from "@/features/products/pages/ProductDetail";
import Bounties from "@/features/bounties/pages/Bounties";
import BountyDetail from "@/features/bounties/pages/BountyDetail";
import Dashboard from "@/features/dashboard/pages/Dashboard";
import Favorites from "@/features/favorites/pages/Favorites";
import ProductComparison from "@/features/products/pages/ProductComparison";
import Admin from "@/features/admin/pages/Admin";
import Search from "@/features/search/pages/Search";
import Settings from "@/features/settings/pages/Settings";
function Router() {
return (
@@ -31,6 +32,7 @@ function Router() {
<Route path="/comparison" component={ProductComparison} />
<Route path="/search" component={Search} />
<Route path="/admin" component={Admin} />
<Route path="/settings" component={Settings} />
<Route path="/404" component={NotFound} />
<Route component={NotFound} />
</Switch>

View File

@@ -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,
};
}

View File

@@ -1,4 +1,4 @@
import { useAuth } from "@/_core/hooks/useAuth";
import { useAuth } from "@/hooks/useAuth";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
DropdownMenu,
@@ -20,15 +20,19 @@ import {
useSidebar,
} from "@/components/ui/sidebar";
import { useIsMobile } from "@/hooks/useMobile";
import { LayoutDashboard, LogOut, PanelLeft, Users, Heart, ShieldCheck } from "lucide-react";
import { CSSProperties, useEffect, useRef, useState } from "react";
import { LayoutDashboard, LogOut, PanelLeft, Users, Heart, ShieldCheck, Loader2, Settings, Home } from "lucide-react";
import { CSSProperties, useCallback, useEffect, useRef, useState } from "react";
import { useLocation } from "wouter";
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
import { Button } from "./ui/button";
import { toast } from "sonner";
import { getErrorCopy } from "@/lib/i18n/errorMessages";
const menuItems = [
{ icon: Home, label: "返回首页", path: "/" },
{ icon: LayoutDashboard, label: "个人中心", path: "/dashboard" },
{ icon: Heart, label: "我的收藏", path: "/favorites" },
{ icon: Settings, label: "账号设置", path: "/settings" },
];
const adminMenuItems = [
@@ -45,11 +49,27 @@ export default function DashboardLayout({
}: {
children: React.ReactNode;
}) {
const [, navigate] = useLocation();
const [isLoggingOut, setIsLoggingOut] = useState(false);
const [sidebarWidth, setSidebarWidth] = useState(() => {
const saved = localStorage.getItem(SIDEBAR_WIDTH_KEY);
return saved ? parseInt(saved, 10) : DEFAULT_WIDTH;
});
const { loading, user } = useAuth();
const { loading, user, logout } = useAuth();
const handleLogout = useCallback(async () => {
setIsLoggingOut(true);
try {
await logout();
toast.success("已退出登录");
navigate("/");
} catch (error: unknown) {
const { title, description } = getErrorCopy(error, { context: "auth.logout" });
toast.error(title, { description });
} finally {
setIsLoggingOut(false);
}
}, [logout, navigate]);
useEffect(() => {
localStorage.setItem(SIDEBAR_WIDTH_KEY, sidebarWidth.toString());
@@ -93,7 +113,11 @@ export default function DashboardLayout({
} as CSSProperties
}
>
<DashboardLayoutContent setSidebarWidth={setSidebarWidth}>
<DashboardLayoutContent
setSidebarWidth={setSidebarWidth}
handleLogout={handleLogout}
isLoggingOut={isLoggingOut}
>
{children}
</DashboardLayoutContent>
</SidebarProvider>
@@ -103,13 +127,17 @@ export default function DashboardLayout({
type DashboardLayoutContentProps = {
children: React.ReactNode;
setSidebarWidth: (width: number) => void;
handleLogout: () => void;
isLoggingOut: boolean;
};
function DashboardLayoutContent({
children,
setSidebarWidth,
handleLogout,
isLoggingOut,
}: DashboardLayoutContentProps) {
const { user, logout } = useAuth();
const { user } = useAuth();
const [location, setLocation] = useLocation();
const { state, toggleSidebar } = useSidebar();
const isCollapsed = state === "collapsed";
@@ -251,11 +279,16 @@ function DashboardLayoutContent({
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
onClick={logout}
onClick={handleLogout}
disabled={isLoggingOut}
className="cursor-pointer text-destructive focus:text-destructive"
>
<LogOut className="mr-2 h-4 w-4" />
<span>Sign out</span>
{isLoggingOut ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<LogOut className="mr-2 h-4 w-4" />
)}
<span>{isLoggingOut ? "退出中..." : "退出登录"}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -2,7 +2,7 @@ import { useState } from "react";
import { Link, useLocation } from "wouter";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
import { useAuth } from "@/_core/hooks/useAuth";
import { useAuth } from "@/hooks/useAuth";
import { Sparkles, Menu, X, ShoppingBag, Trophy, Search, User, Heart, LogOut } from "lucide-react";
import { useUnreadNotificationCount } from "@/hooks/useApi";

View File

@@ -1,4 +1,4 @@
import { useAuth } from "@/_core/hooks/useAuth";
import { useAuth } from "@/hooks/useAuth";
import { Button } from "@/components/ui/button";
import { Link, useLocation } from "wouter";
import { Sparkles, Bell, LogOut } from "lucide-react";

View 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>
);
}

View File

@@ -8,11 +8,12 @@ import { Sparkles, ArrowLeft, Loader2 } from "lucide-react";
import { Link } from "wouter";
import { useLogin, useRegister } from "@/hooks/useApi";
import { toast } from "sonner";
import { getErrorCopy } from "@/lib/i18n/errorMessages";
import { getAndClearRedirectPath } from "@/hooks/useAuth";
export default function Login() {
const [, setLocation] = useLocation();
const [username, setUsername] = useState("");
const [displayName, setDisplayName] = useState("");
const [password, setPassword] = useState("");
const [email, setEmail] = useState("");
const [isRegister, setIsRegister] = useState(false);
@@ -20,6 +21,11 @@ export default function Login() {
const loginMutation = useLogin();
const registerMutation = useRegister();
const validateEmail = (email: string): boolean => {
const emailPattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
return emailPattern.test(email);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -27,20 +33,33 @@ export default function Login() {
toast.error("请输入用户名");
return;
}
if (isRegister && !email.trim()) {
toast.error("请输入邮箱");
return;
}
if (isRegister && !validateEmail(email.trim())) {
toast.error("请输入正确的邮箱格式");
return;
}
if (!password.trim()) {
toast.error("请输入密码");
return;
}
if (password.length < 6) {
toast.error("密码长度至少6位");
return;
}
try {
if (isRegister) {
await registerMutation.mutateAsync({
openId: username.trim(),
password: password.trim(),
name: displayName.trim() || undefined,
email: email.trim() || undefined,
name: username.trim(), // 显示名称默认为用户名
email: email.trim(),
});
} else {
// 登录时,用户名字段可以是用户名或邮箱
await loginMutation.mutateAsync({
openId: username.trim(),
password: password.trim(),
@@ -51,12 +70,14 @@ export default function Login() {
description: isRegister ? "账号已创建" : "欢迎回来!",
});
// Redirect to home or dashboard
setLocation("/");
} catch (error: any) {
toast.error(isRegister ? "注册失败" : "登录失败", {
description: error.response?.data?.error || "请稍后重试",
// 优先返回登录前的页面,否则跳转首页
const redirectPath = getAndClearRedirectPath();
setLocation(redirectPath || "/");
} catch (error: unknown) {
const { title, description } = getErrorCopy(error, {
context: isRegister ? "auth.register" : "auth.login",
});
toast.error(title, { description });
}
};
@@ -80,62 +101,50 @@ export default function Login() {
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center mx-auto mb-4">
<Sparkles className="w-7 h-7 text-primary-foreground" />
</div>
<CardTitle className="text-2xl"></CardTitle>
<CardTitle className="text-2xl">{isRegister ? "欢迎注册" : "欢迎登录"}</CardTitle>
<CardDescription>
{isRegister ? "创建账号,开始使用资源聚合平台" : "登录资源聚合平台,享受更多功能"}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username"></Label>
<Label htmlFor="username"> {!isRegister && <span className="text-muted-foreground text-xs"></span>}</Label>
<Input
id="username"
type="text"
placeholder="输入用户名或 ID"
placeholder={isRegister ? "输入用户名" : "输入用户名或邮箱"}
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={loginMutation.isPending || registerMutation.isPending}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password"></Label>
<Input
id="password"
type="password"
placeholder="输入密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loginMutation.isPending || registerMutation.isPending}
/>
</div>
<div className="space-y-2">
<Label htmlFor="displayName"></Label>
<Input
id="displayName"
type="text"
placeholder="您希望显示的名称"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
disabled={loginMutation.isPending || registerMutation.isPending}
/>
</div>
{isRegister && (
<div className="space-y-2">
<Label htmlFor="email"></Label>
<Label htmlFor="email"> <span className="text-destructive">*</span></Label>
<Input
id="email"
type="email"
placeholder="输入邮箱"
placeholder="输入邮箱地址"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={loginMutation.isPending || registerMutation.isPending}
/>
</div>
)}
<div className="space-y-2">
<Label htmlFor="password"></Label>
<Input
id="password"
type="password"
placeholder={isRegister ? "设置密码至少6位" : "输入密码"}
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loginMutation.isPending || registerMutation.isPending}
/>
</div>
<Button
type="submit"

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -5,15 +5,18 @@ import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
import { Line, LineChart, XAxis, YAxis, CartesianGrid } from "recharts";
import { Navbar } from "@/components/Navbar";
import DashboardLayout from "@/components/DashboardLayout";
import { useFavorites, useFavoriteTags, useRemoveFavorite, useCreateFavoriteTag, useUpdateFavoriteTag, useDeleteFavoriteTag, usePriceMonitor, usePriceHistory, useCreatePriceMonitor, useUpdatePriceMonitor, useRefreshPriceMonitor } from "@/hooks/useApi";
import { useDebounce } from "@/hooks/useDebounce";
import { useAuth } from "@/_core/hooks/useAuth";
import { useAuth } from "@/hooks/useAuth";
import { favoriteApi } from "@/lib/api";
import { toast } from "sonner";
import { getErrorCopy } from "@/lib/i18n/errorMessages";
import {
Heart,
Trash2,
@@ -84,8 +87,9 @@ export default function Favorites() {
link.click();
link.remove();
URL.revokeObjectURL(url);
} catch (error) {
console.error(error);
} catch (error: unknown) {
const { title, description } = getErrorCopy(error, { context: "favorite.export" });
toast.error(title, { description });
}
};
@@ -113,15 +117,26 @@ export default function Favorites() {
});
}, [favorites, debouncedSearchQuery]);
// State for bulk delete confirmation
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
// Handle bulk delete
const handleBulkDelete = () => {
const handleBulkDelete = async () => {
if (selectedFavorites.size === 0) return;
selectedFavorites.forEach((id) => {
removeFavoriteMutation.mutate(id, {
onSuccess: () => refetchFavorites(),
});
});
setSelectedFavorites(new Set());
setIsDeleting(true);
try {
const ids = Array.from(selectedFavorites);
await Promise.all(ids.map((id) => removeFavoriteMutation.mutateAsync(id)));
setSelectedFavorites(new Set());
toast.success(`成功删除 ${ids.length} 件商品`);
setBulkDeleteOpen(false);
} catch (error: unknown) {
const { title, description } = getErrorCopy(error, { context: "favorite.remove" });
toast.error(title, { description });
} finally {
setIsDeleting(false);
}
};
// Handle create tag
@@ -132,10 +147,15 @@ export default function Favorites() {
color: newTagColor,
}, {
onSuccess: () => {
toast.success("标签创建成功");
setNewTagName("");
setNewTagColor("#6366f1");
refetchTags();
},
onError: (error: unknown) => {
const { title, description } = getErrorCopy(error, { context: "favorite.tag" });
toast.error(title, { description });
},
});
}
};
@@ -151,10 +171,15 @@ export default function Favorites() {
},
}, {
onSuccess: () => {
toast.success("标签更新成功");
setEditingTag(null);
setEditTagName("");
refetchTags();
},
onError: (error: unknown) => {
const { title, description } = getErrorCopy(error, { context: "favorite.tag" });
toast.error(title, { description });
},
});
}
};
@@ -163,11 +188,16 @@ export default function Favorites() {
const handleDeleteTag = (id: number) => {
deleteTagMutation.mutate(id, {
onSuccess: () => {
toast.success("标签已删除");
if (selectedTag === id) {
setSelectedTag(null);
}
refetchTags();
},
onError: (error: unknown) => {
const { title, description } = getErrorCopy(error, { context: "favorite.tag" });
toast.error(title, { description });
},
});
};
@@ -182,8 +212,13 @@ export default function Favorites() {
const mutation = monitorData ? updateMonitorMutation : createMonitorMutation;
mutation.mutate({ favoriteId: monitorFavoriteId, data: payload }, {
onSuccess: () => {
toast.success("监控设置已保存");
refetchFavorites();
},
onError: (error: unknown) => {
const { title, description } = getErrorCopy(error, { context: "favorite.monitor" });
toast.error(title, { description });
},
});
};
@@ -216,11 +251,9 @@ export default function Favorites() {
}
return (
<div className="min-h-screen bg-background">
<Navbar />
<DashboardLayout>
{/* Header */}
<section className="pt-24 border-b border-border py-8">
<section className="border-b border-border py-8">
<div className="container">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center">
@@ -453,14 +486,32 @@ export default function Favorites() {
{selectedFavorites.size}
</div>
<div className="flex gap-2">
<Button
variant="destructive"
size="sm"
onClick={handleBulkDelete}
>
<Trash2 className="w-4 h-4 mr-2" />
</Button>
<AlertDialog open={bulkDeleteOpen} onOpenChange={setBulkDeleteOpen}>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm">
<Trash2 className="w-4 h-4 mr-2" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{selectedFavorites.size}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}></AlertDialogCancel>
<AlertDialogAction
onClick={handleBulkDelete}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? "删除中..." : "确认删除"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardContent>
</Card>
@@ -649,6 +700,6 @@ export default function Favorites() {
</div>
</div>
</section>
</div>
</DashboardLayout>
);
}

View File

@@ -1,4 +1,4 @@
import { useAuth } from "@/_core/hooks/useAuth";
import { useAuth } from "@/hooks/useAuth";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Link } from "wouter";

View 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>
</>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -4,19 +4,20 @@ import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { useFavorites, useAllPriceMonitors, useAddFavorite, useRemoveFavorite } from "@/hooks/useApi";
import { useAuth } from "@/_core/hooks/useAuth";
import { useAuth } from "@/hooks/useAuth";
import { ArrowLeft, Trash2, Download, TrendingDown, Heart, Share2, Copy, Check } from "lucide-react";
import { QRCodeSVG as QRCode } from "qrcode.react";
import { Link } from "wouter";
import { useState, useRef, useEffect } from "react";
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts";
import { toast } from "sonner";
import { getErrorCopy } from "@/lib/i18n/errorMessages";
export default function ProductComparison() {
const { user, isAuthenticated } = useAuth();
const [selectedProducts, setSelectedProducts] = useState<Set<number>>(new Set());
const [comparisonMode, setComparisonMode] = useState(false);
const [favoriteIds, setFavoriteIds] = useState<Set<number>>(new Set());
const [favoriteKeys, setFavoriteKeys] = useState<Set<string>>(new Set());
const [showShareDialog, setShowShareDialog] = useState(false);
const [copied, setCopied] = useState(false);
const qrCodeRef = useRef<HTMLDivElement>(null);
@@ -35,7 +36,7 @@ export default function ProductComparison() {
// Initialize favorite IDs when favorites load
useEffect(() => {
if (favorites.length > 0) {
setFavoriteIds(new Set(favorites.map(f => f.product_id)));
setFavoriteKeys(new Set(favorites.map(f => `${f.product_id}-${f.website_id}`)));
}
}, [favorites]);
@@ -64,9 +65,10 @@ export default function ProductComparison() {
};
const handleToggleFavorite = (productId: number, websiteId: number) => {
if (favoriteIds.has(productId)) {
const key = `${productId}-${websiteId}`;
if (favoriteKeys.has(key)) {
// Find the favorite ID to remove
const fav = favorites.find(f => f.product_id === productId);
const fav = favorites.find(f => f.product_id === productId && f.website_id === websiteId);
if (fav) {
removeFavoriteMutation.mutate(fav.id, {
onSuccess: () => {
@@ -75,17 +77,18 @@ export default function ProductComparison() {
duration: 2000,
});
},
onError: () => {
toast.error("取消收藏失败", {
description: "请稍后重试",
onError: (error: unknown) => {
const { title, description } = getErrorCopy(error, { context: "favorite.remove" });
toast.error(title, {
description,
duration: 2000,
});
},
});
}
setFavoriteIds(prev => {
setFavoriteKeys(prev => {
const next = new Set(prev);
next.delete(productId);
next.delete(key);
return next;
});
} else {
@@ -96,14 +99,15 @@ export default function ProductComparison() {
duration: 2000,
});
},
onError: () => {
toast.error("收藏失败", {
description: "请稍后重试",
onError: (error: unknown) => {
const { title, description } = getErrorCopy(error, { context: "favorite.add" });
toast.error(title, {
description,
duration: 2000,
});
},
});
setFavoriteIds(prev => new Set(prev).add(productId));
setFavoriteKeys(prev => new Set(prev).add(key));
}
};
@@ -149,10 +153,11 @@ export default function ProductComparison() {
let failureCount = 0;
for (const product of comparisonProducts) {
if (!favoriteIds.has(product.product_id)) {
const key = `${product.product_id}-${product.website_id}`;
if (!favoriteKeys.has(key)) {
try {
await addFavoriteMutation.mutateAsync({ product_id: product.product_id, website_id: product.website_id });
setFavoriteIds(prev => new Set(prev).add(product.product_id));
setFavoriteKeys(prev => new Set(prev).add(key));
successCount++;
} catch (error) {
failureCount++;

View File

@@ -1,4 +1,4 @@
import { useAuth } from "@/_core/hooks/useAuth";
import { useAuth } from "@/hooks/useAuth";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
@@ -25,6 +25,7 @@ import {
import { Link, useParams } from "wouter";
import { useState, useEffect, useMemo } from "react";
import { toast } from "sonner";
import { getErrorCopy } from "@/lib/i18n/errorMessages";
import {
ArrowLeft,
Sparkles,
@@ -58,9 +59,24 @@ export default function ProductDetail() {
const [monitorNotifyEnabled, setMonitorNotifyEnabled] = useState(true);
const [monitorNotifyOnTarget, setMonitorNotifyOnTarget] = useState(true);
const [copied, setCopied] = useState(false);
const [selectedWebsiteId, setSelectedWebsiteId] = useState(0);
const { data: product, isLoading, error } = useProductWithPrices(productId);
const { data: favoriteCheck } = useCheckFavorite(productId, product?.prices?.[0]?.website_id || 0);
const lowestPrice = useMemo(() => {
if (!product?.prices?.length) return null;
return product.prices.reduce((min, current) =>
Number(current.price) < Number(min.price) ? current : min
);
}, [product?.prices]);
const sortedPrices = useMemo(() => {
if (!product?.prices?.length) return [];
return [...product.prices].sort((a, b) => Number(a.price) - Number(b.price));
}, [product?.prices]);
useEffect(() => {
if (selectedWebsiteId || !lowestPrice) return;
setSelectedWebsiteId(lowestPrice.website_id);
}, [lowestPrice, selectedWebsiteId]);
const { data: favoriteCheck } = useCheckFavorite(productId, selectedWebsiteId);
const { data: monitorData } = usePriceMonitor(favoriteCheck?.favorite_id || 0);
const { data: priceHistoryData } = usePriceHistory(favoriteCheck?.favorite_id || 0);
const { data: recommendedProducts } = useRecommendedProducts(4);
@@ -102,7 +118,7 @@ export default function ProductDetail() {
return;
}
if (!product?.prices?.[0]) {
if (!selectedWebsiteId) {
toast.error("该商品暂无价格信息");
return;
}
@@ -114,16 +130,24 @@ export default function ProductDetail() {
queryClient.invalidateQueries({ queryKey: ["favorites"] });
queryClient.invalidateQueries({ queryKey: ["favorites", "check"] });
},
onError: (error: unknown) => {
const { title, description } = getErrorCopy(error, { context: "favorite.remove" });
toast.error(title, { description });
},
});
} else {
addFavoriteMutation.mutate(
{ product_id: productId, website_id: product.prices[0].website_id },
{ product_id: productId, website_id: selectedWebsiteId },
{
onSuccess: () => {
toast.success("已添加到收藏");
queryClient.invalidateQueries({ queryKey: ["favorites"] });
queryClient.invalidateQueries({ queryKey: ["favorites", "check"] });
},
onError: (error: unknown) => {
const { title, description } = getErrorCopy(error, { context: "favorite.add" });
toast.error(title, { description });
},
}
);
}
@@ -149,6 +173,10 @@ export default function ProductDetail() {
setIsMonitorOpen(false);
queryClient.invalidateQueries({ queryKey: ["favorites", favoriteId, "monitor"] });
},
onError: (error: unknown) => {
const { title, description } = getErrorCopy(error, { context: "product.update" });
toast.error(title, { description });
},
});
};
@@ -169,14 +197,14 @@ export default function ProductDetail() {
queryClient.invalidateQueries({ queryKey: ["products", productId, "prices"] });
queryClient.invalidateQueries({ queryKey: ["favorites", favoriteId, "monitor"] });
},
onError: (error: unknown) => {
const { title, description } = getErrorCopy(error, { context: "product.update" });
toast.error(title, { description });
},
});
};
// 找到最低价和最高价
const lowestPrice = product?.prices?.reduce((min, p) =>
Number(p.price) < Number(min.price) ? p : min
, product.prices[0]);
const highestPrice = product?.prices?.reduce((max, p) =>
Number(p.price) > Number(max.price) ? p : max
, product.prices?.[0]);
@@ -363,9 +391,7 @@ export default function ProductDetail() {
<CardContent>
{product.prices && product.prices.length > 0 ? (
<div className="space-y-3">
{product.prices
.sort((a, b) => Number(a.price) - Number(b.price))
.map((price) => (
{sortedPrices.map((price) => (
<div
key={price.id}
className={`flex items-center justify-between p-4 rounded-lg border transition-colors ${
@@ -394,6 +420,11 @@ export default function ProductDetail() {
</Badge>
)}
{selectedWebsiteId === price.website_id && (
<Badge variant="secondary" className="text-xs">
</Badge>
)}
{!price.in_stock && (
<Badge variant="destructive" className="text-xs">
@@ -429,6 +460,13 @@ export default function ProductDetail() {
</p>
)}
</div>
<Button
variant={selectedWebsiteId === price.website_id ? "default" : "outline"}
size="sm"
onClick={() => setSelectedWebsiteId(price.website_id)}
>
{selectedWebsiteId === price.website_id ? "已选择" : "设为收藏网站"}
</Button>
<a
href={price.url}
target="_blank"
@@ -497,6 +535,22 @@ export default function ProductDetail() {
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{product.prices && product.prices.length > 0 && (
<div className="space-y-2">
<Label></Label>
<select
value={selectedWebsiteId}
onChange={(e) => setSelectedWebsiteId(Number(e.target.value))}
className="w-full px-3 py-2 border rounded-lg bg-background text-foreground text-sm"
>
{sortedPrices.map((price) => (
<option key={price.id} value={price.website_id}>
{price.website_name || "未知网站"} - ¥{price.price}
</option>
))}
</select>
</div>
)}
{isAuthenticated && favoriteId && (
<Dialog open={isMonitorOpen} onOpenChange={setIsMonitorOpen}>
<DialogTrigger asChild>

View 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>
);
}

View 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"> jpgpnggif </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>
);
}

View File

@@ -2,6 +2,7 @@
* React Query hooks for API calls
*/
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
import {
authApi,
categoryApi,
@@ -14,6 +15,8 @@ import {
adminApi,
friendApi,
setAccessToken,
setRefreshToken,
clearRefreshToken,
searchApi,
type User,
type Bounty,
@@ -54,7 +57,7 @@ export function useLogin() {
onSuccess: (data) => {
// Store tokens
setAccessToken(data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
setRefreshToken(data.refresh_token);
// Refetch user data
queryClient.invalidateQueries({ queryKey: ['auth', 'me'] });
},
@@ -69,7 +72,7 @@ export function useRegister() {
authApi.register({ open_id: openId, password, name, email }),
onSuccess: (data) => {
setAccessToken(data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
setRefreshToken(data.refresh_token);
queryClient.invalidateQueries({ queryKey: ['auth', 'me'] });
},
});
@@ -82,7 +85,7 @@ export function useLogout() {
mutationFn: authApi.logout,
onSuccess: () => {
setAccessToken(null);
localStorage.removeItem('refresh_token');
clearRefreshToken();
queryClient.setQueryData(['auth', 'me'], null);
queryClient.invalidateQueries({ queryKey: ['auth'] });
},
@@ -100,6 +103,12 @@ export function useUpdateMe() {
});
}
export function useChangePassword() {
return useMutation({
mutationFn: authApi.changePassword,
});
}
// ==================== Friends Hooks ====================
export function useFriends() {
@@ -172,10 +181,11 @@ export function useCancelFriendRequest() {
}
export function useSearchUsers(q: string, limit?: number) {
const debouncedQuery = useDebouncedValue(q, 300);
return useQuery({
queryKey: ['friends', 'search', q, limit],
queryFn: () => friendApi.searchUsers(q, limit),
enabled: !!q.trim(),
queryKey: ['friends', 'search', debouncedQuery, limit],
queryFn: () => friendApi.searchUsers(debouncedQuery, limit),
enabled: !!debouncedQuery.trim(),
staleTime: shortStaleTime,
});
}
@@ -221,7 +231,7 @@ export function useWebsite(id: number) {
// ==================== Product Hooks ====================
export function useProducts(params?: { category_id?: number; search?: string; page?: number }) {
export function useProducts(params?: { category_id?: number; search?: string; page?: number; min_price?: number; max_price?: number; sort_by?: string }) {
return useQuery({
queryKey: ['products', params],
queryFn: () => productApi.list(params),
@@ -256,11 +266,12 @@ export function useProductWithPrices(id: number) {
});
}
export function useProductSearch(q: string, page?: number) {
export function useProductSearch(q: string, params?: { page?: number; category_id?: number; min_price?: number; max_price?: number; sort_by?: string }) {
const debouncedQuery = useDebouncedValue(q, 300);
return useQuery({
queryKey: ['products', 'search', q, page],
queryFn: () => productApi.search(q, page),
enabled: !!q,
queryKey: ['products', 'search', debouncedQuery, params],
queryFn: () => productApi.search({ q: debouncedQuery, ...params }),
enabled: !!debouncedQuery.trim(),
staleTime: shortStaleTime,
placeholderData: keepPreviousData,
});
@@ -287,10 +298,11 @@ export function useBounty(id: number) {
}
export function useBountySearch(q: string, page?: number) {
const debouncedQuery = useDebouncedValue(q, 300);
return useQuery({
queryKey: ['bounties', 'search', q, page],
queryFn: () => bountyApi.search(q, page),
enabled: !!q,
queryKey: ['bounties', 'search', debouncedQuery, page],
queryFn: () => bountyApi.search(debouncedQuery, page),
enabled: !!debouncedQuery.trim(),
staleTime: shortStaleTime,
placeholderData: keepPreviousData,
});
@@ -709,10 +721,11 @@ export function useNotifications(params?: { is_read?: boolean; type?: string; st
}
export function useGlobalSearch(q: string, limit = 10) {
const debouncedQuery = useDebouncedValue(q, 300);
return useQuery({
queryKey: ['search', q, limit],
queryFn: () => searchApi.global(q, limit),
enabled: !!q.trim(),
queryKey: ['search', debouncedQuery, limit],
queryFn: () => searchApi.global(debouncedQuery, limit),
enabled: !!debouncedQuery.trim(),
staleTime: shortStaleTime,
});
}
@@ -848,3 +861,40 @@ export function useAdminDisputes(status?: string) {
staleTime: shortStaleTime,
});
}
export function useAdminPendingProducts() {
return useQuery({
queryKey: ['admin', 'products', 'pending'],
queryFn: adminApi.listPendingProducts,
staleTime: shortStaleTime,
});
}
export function useAdminAllProducts(status?: string) {
return useQuery({
queryKey: ['admin', 'products', 'all', status],
queryFn: () => adminApi.listAllProducts(status),
staleTime: shortStaleTime,
});
}
export function useReviewProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ productId, data }: { productId: number; data: { approved: boolean; reject_reason?: string } }) =>
adminApi.reviewProduct(productId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'products'] });
},
});
}
// ==================== My Products Hooks ====================
export function useMyProducts(status?: string) {
return useQuery({
queryKey: ['products', 'my', status],
queryFn: () => productApi.myProducts(status),
staleTime: shortStaleTime,
});
}

View 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,
};
}

View 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;
}

View File

@@ -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),
};

View 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),
};

View 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),
};

View 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),
};

View 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),
};

View 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));
}
);

View 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);
}

View 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),
};

View 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),
};

View 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";

View 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),
};

View 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