解决dist冲突

This commit is contained in:
Lrd
2026-06-26 18:09:42 +08:00
46 changed files with 4077 additions and 434 deletions
+4
View File
@@ -17,9 +17,11 @@ from app.api.v1.image_engines import router as image_engines_router
from app.api.v1.generation_ai import router as generation_ai_router
from app.api.v1.hot_opening_replicate import router as hot_opening_replicate_router
from app.api.v1.shot_replicate import router as shot_replicate_router
from app.api.v1.recent_generation import router as recent_generation_router
from app.api.v1.test import router as test_router
from app.api.v1.user_oauth import router as user_oauth_router
from app.api.v1.user_oauth_app import router as user_oauth_app_router
from app.api.v1.user_oauth_account import router as user_oauth_account_router
from app.api.v1.upload_material import router as upload_material_router
from app.api.v1.pre_test_template import router as pre_test_template_router
from app.api.v1.material_consumption import router as material_consumption_router
@@ -46,9 +48,11 @@ api_router.include_router(image_engines_router)
api_router.include_router(generation_ai_router)
api_router.include_router(hot_opening_replicate_router)
api_router.include_router(shot_replicate_router)
api_router.include_router(recent_generation_router)
api_router.include_router(test_router)
api_router.include_router(user_oauth_router)
api_router.include_router(user_oauth_app_router)
api_router.include_router(user_oauth_account_router)
api_router.include_router(upload_material_router)
api_router.include_router(pre_test_template_router)
api_router.include_router(material_consumption_router)
+3 -2
View File
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.models.notification import Notification
from app.schemas.notification import NotificationOut, NotificationListOut, UnreadCountOut
from app.schemas.notification import NotificationListOut, NotificationCreditsOut, UnreadCountOut
from app.services.auth import decode_access_token
from app.services.notification import (
get_notifications,
@@ -112,7 +112,8 @@ async def list_notifications(
db: AsyncSession = Depends(get_db),
):
items, total = await get_notifications(db, current_user.id, page, page_size, is_read)
return {"items": items, "total": total}
credits = NotificationCreditsOut(balance=round(float(current_user.credits or 0.0), 2))
return {"items": items, "total": total, "credits": credits}
@router.put("/{notification_id}/read")
@@ -0,0 +1,104 @@
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, get_db
from app.enums.recent_generation import RecentGenerationModuleEnum
from app.models.user import User
from app.schemas.recent_generation import RecentGenerationGroupOut
from app.services.recent_generation_service import (
DEFAULT_RECENT_GENERATION_LIMIT,
MAX_RECENT_GENERATION_LIMIT,
list_recent_generations,
)
router = APIRouter(
prefix="/recent-generations",
tags=["recent-generations"],
)
@router.get(
"",
response_model=RecentGenerationGroupOut,
summary="获取当前用户各模块最近生成记录",
description=(
"获取当前登录用户在多个生成模块下最近生成成功的图片/视频记录。"
"返回结构固定为 project、chat_ai、hot_opening_replicate、shot_replicate 四个数组。"
"modules 不传时查询全部模块;modules 可重复传参指定一个或多个模块,例如 "
"?modules=project&modules=chat_ai。"
"limit 表示每个模块最多返回多少条,默认 5 条,最大 100 条。"
"接口只查询和返回展示所需轻量字段,不返回 prompt、engine_snapshot、provider_response_json 等大字段。"
),
responses={
200: {
"description": "查询成功,固定返回四个模块数组;没有数据的模块返回空数组。",
"content": {
"application/json": {
"example": {
"project": [],
"chat_ai": [],
"hot_opening_replicate": [],
"shot_replicate": [
{
"generated_time": "2026-06-26T14:30:00",
"result_url": "https://example.com/generate/video/demo.mp4?exp=1780000000&sign=xxxx",
"cover_url": "https://example.com/generate/cover/demo.jpg?exp=1780000000&sign=xxxx",
"module": "shot_replicate",
"shot_task_set_id": "0019ef0000000000001",
"shot_segment_id": "0019ef0000000000002",
"module_project_id": "0019ef0000000000003",
"module_step_id": "0019ef0000000000004",
"generation_id": "0019ef0000000000005",
"resource_type": "video",
}
],
}
}
},
},
401: {"description": "未登录或 Token 无效"},
403: {"description": "账号需要先设置登录密码或无权限"},
422: {"description": "参数校验失败,例如 limit 超出范围或 modules 枚举值非法"},
},
)
async def get_recent_generations(
limit: Annotated[
int,
Query(
ge=1,
le=MAX_RECENT_GENERATION_LIMIT,
description=(
"每个模块返回的最近生成记录数量,默认 5,最大 100。"
"例如 limit=10 表示 project/chat_ai/hot_opening_replicate/shot_replicate 每个模块最多返回 10 条。"
),
examples=[DEFAULT_RECENT_GENERATION_LIMIT],
),
] = DEFAULT_RECENT_GENERATION_LIMIT,
modules: Annotated[
list[RecentGenerationModuleEnum] | None,
Query(
description=(
"模块枚举,可不传或重复传参。"
"不传表示查询全部模块。"
"可选值:"
"project=项目生成 GenerationRecord"
"chat_ai=AI创作 ChatGenerationTask.generation_mode=chatapi_async"
"hot_opening_replicate=爆款开头复刻 ChatGenerationTask.generation_mode=hot_opening_replicate"
"shot_replicate=拆镜复刻 ChatGenerationTask.generation_mode=shot_replicate。"
),
examples=[["project", "chat_ai"]],
),
] = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> RecentGenerationGroupOut:
return await list_recent_generations(
db,
user_id=current_user.id,
modules=modules,
limit=limit,
)
@@ -46,6 +46,30 @@ class UpdateFileName(BaseModel):
class FileNameUpdateRequest(BaseModel):
filenames: list[UpdateFileName] = Field(..., description="批量修改文件名列表,格式: [{\"source_id\":\"资源id\",\"file_name\":\"文件名称\"}]")
# @router.post(
# "/batch-upload",
# summary="批量上传素材到平台",
# description="支持批量上传多个授权账户下的资源到素材库,预留下前测功能",
# )
# async def batch_upload_material(
# current_user: User = Depends(get_current_user),
# db: AsyncSession = Depends(get_db),
# ) -> Any | dict:
# try:
# result = await upload_material_to_platform(
# ["0019ef7700c503991c9"],
# ["1856633793022992"],
# "0019f018b4b3612e184",
# db,
# current_user.id,
# None,
# )
# return result
# except Exception as e:
# return {
# "code": 0,
# "message": str(e),
# }
@router.post(
"/async-batch-upload",
@@ -0,0 +1,100 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, get_db
from app.models.user import User
from app.schemas.user_oauth_account import (
OAuthAccountListResponse,
DeleteOAuthAccountRequest,
)
from app.services.user_oauth_account_service import (
get_oauth_account_list,
delete_oauth_account,
)
router = APIRouter(prefix="/oauth-account", tags=["oauth-account"])
@router.get(
"/list",
summary="获取授权账户列表",
description="获取授权账户列表,支持按广告主ID、授权ID、广告账户名称筛选",
)
async def oauth_account_list(
advertiser_id: str | None = Query(None, description="广告主账户ID"),
oauth_id: str | None = Query(None, description="授权ID"),
advertiser_name: str | None = Query(None, description="广告账户名称(模糊查询)"),
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(10, ge=1, le=100, description="每页数量"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> dict:
"""
获取授权账户列表
- **advertiser_id**: 广告主账户ID(可选)
- **oauth_id**: 授权ID(可选)
- **advertiser_name**: 广告账户名称,支持模糊查询(可选)
- **page**: 页码,默认为1
- **page_size**: 每页数量,默认为10,最大100
"""
try:
result = await get_oauth_account_list(
db=db,
page=page,
page_size=page_size,
advertiser_id=advertiser_id,
oauth_id=oauth_id,
advertiser_name=advertiser_name,
user_id=current_user.id,
)
return {
"code": 0,
"message": "查询成功",
"data": result["data"],
"pagination": {
"page": result["page"],
"page_size": result["page_size"],
"total": result["total"],
"total_pages": result["total_pages"],
},
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
@router.get(
"/delete",
summary="删除授权账户",
description="软删除授权账户",
)
async def delete_oauth_account_api(
id: str = Query(..., description="授权账户表id"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> dict:
try:
await delete_oauth_account(
db=db,
account_id=id,
user_id=current_user.id,
)
return {
"code": 0,
"message": "删除成功",
}
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e),
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e),
)
+3
View File
@@ -7,3 +7,6 @@ from app.enums.user import *
from app.enums.credit_record import *
from app.enums.token_usage import *
from app.enums.generation_task import *
from app.enums.generation_status import *
from app.enums.sms import *
from app.enums.notification import *
+49
View File
@@ -0,0 +1,49 @@
from enum import Enum
class GenerationMode(str, Enum):
"""生成模式。"""
STANDARD = "standard"
FAST = "fast"
class GenerationType(str, Enum):
"""生成类型。"""
video = "video"
image = "image"
class ChatGenerationTaskStatus(str, Enum):
"""聊天生成任务状态。"""
pending = "pending"
processing = "processing"
completed = "completed"
failed = "failed"
class ChatGenerationPipelineStage(str, Enum):
"""聊天生成任务阶段。"""
waiting = "waiting"
prompt_optimization = "prompt_optimization"
video_generation = "video_generation"
post_processing = "post_processing"
completed = "completed"
failed = "failed"
class ChatGenerationTaskEventType(str, Enum):
"""聊天生成任务事件类型。"""
TASK_CREATED = "TASK_CREATED"
TASK_DELETED = "TASK_DELETED"
TASK_CANCELLED = "TASK_CANCELLED"
PROMPT_OPT_STARTED = "PROMPT_OPT_STARTED"
PROMPT_OPT_COMPLETED = "PROMPT_OPT_COMPLETED"
PROMPT_OPT_FAILED = "PROMPT_OPT_FAILED"
VIDEO_GEN_STARTED = "VIDEO_GEN_STARTED"
VIDEO_GEN_COMPLETED = "VIDEO_GEN_COMPLETED"
VIDEO_GEN_FAILED = "VIDEO_GEN_FAILED"
POST_PROCESSING_STARTED = "POST_PROCESSING_STARTED"
POST_PROCESSING_COMPLETED = "POST_PROCESSING_COMPLETED"
POST_PROCESSING_FAILED = "POST_PROCESSING_FAILED"
TASK_COMPLETED = "TASK_COMPLETED"
TASK_FAILED = "TASK_FAILED"
@@ -0,0 +1,22 @@
from enum import Enum
class GenerationStatus(str, Enum):
"""生成状态。"""
prompt_optimized = "prompt_optimized"
generating = "generating"
completed = "completed"
failed = "failed"
class GenerationType(str, Enum):
"""生成类型。"""
video = "video"
image = "image"
# 生成配置常量
DURATIONS = [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
ASPECT_RATIOS = ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"]
RESOLUTIONS = ["480p", "720p", "1080p"]
IMAGE_SIZES = ["2K", "4K"]
+26
View File
@@ -0,0 +1,26 @@
from __future__ import annotations
from enum import StrEnum
class NotificationType(StrEnum):
"""通知类型。"""
SYSTEM = "system"
CREDIT = "credit"
VIDEO = "video"
GENERATION = "generation"
PAYMENT = "payment"
RECHARGE = "recharge"
UNKNOWN = "unknown"
NOTIFICATION_TYPE_LABELS = {
NotificationType.SYSTEM.value: "系统通知",
NotificationType.CREDIT.value: "积分通知",
NotificationType.VIDEO.value: "视频通知",
NotificationType.GENERATION.value: "生成通知",
NotificationType.PAYMENT.value: "支付通知",
NotificationType.RECHARGE.value: "充值通知",
NotificationType.UNKNOWN.value: "未知通知",
}
@@ -0,0 +1,57 @@
from __future__ import annotations
from enum import StrEnum
from app.enums.generation_task import ChatGenerationTaskStatus, GenerationMode
class RecentGenerationModuleEnum(StrEnum):
"""最近生成记录接口支持的模块分组枚举。"""
PROJECT = "project"
CHAT_AI = "chat_ai"
HOT_OPENING_REPLICATE = "hot_opening_replicate"
SHOT_REPLICATE = "shot_replicate"
class RecentGenerationResourceTypeEnum(StrEnum):
"""最近生成记录接口返回的资源类型枚举。"""
IMAGE = "image"
VIDEO = "video"
RECENT_GENERATION_ALL_MODULES: tuple[RecentGenerationModuleEnum, ...] = (
RecentGenerationModuleEnum.PROJECT,
RecentGenerationModuleEnum.CHAT_AI,
RecentGenerationModuleEnum.HOT_OPENING_REPLICATE,
RecentGenerationModuleEnum.SHOT_REPLICATE,
)
"""最近生成记录接口默认查询的全部模块。"""
RECENT_GENERATION_CHAT_TASK_MODULES: tuple[RecentGenerationModuleEnum, ...] = (
RecentGenerationModuleEnum.CHAT_AI,
RecentGenerationModuleEnum.HOT_OPENING_REPLICATE,
RecentGenerationModuleEnum.SHOT_REPLICATE,
)
"""来自 chat_generation_tasks 表的模块集合。"""
RECENT_GENERATION_MODULE_TO_TASK_MODE: dict[RecentGenerationModuleEnum, GenerationMode] = {
RecentGenerationModuleEnum.CHAT_AI: GenerationMode.CHATAPI_ASYNC,
RecentGenerationModuleEnum.HOT_OPENING_REPLICATE: GenerationMode.HOT_OPENING_REPLICATE,
RecentGenerationModuleEnum.SHOT_REPLICATE: GenerationMode.SHOT_REPLICATE,
}
"""最近生成记录模块枚举到 ChatGenerationTask.generation_mode 的映射。"""
RECENT_GENERATION_TASK_MODE_VALUE_TO_MODULE: dict[str, RecentGenerationModuleEnum] = {
task_mode.value: module
for module, task_mode in RECENT_GENERATION_MODULE_TO_TASK_MODE.items()
}
"""ChatGenerationTask.generation_mode 字符串值到最近生成记录模块枚举的映射。"""
RECENT_GENERATION_COMPLETED_STATUS = ChatGenerationTaskStatus.COMPLETED.value
"""最近生成记录只展示生成成功的数据,状态值与 ChatGenerationTaskStatus.COMPLETED 保持一致。"""
+9
View File
@@ -0,0 +1,9 @@
from enum import Enum
class SmsScene(str, Enum):
"""短信场景。"""
register = "register"
login = "login"
common = "common"
set_password = "set_password"
+8 -20
View File
@@ -1,29 +1,17 @@
from enum import Enum
from pydantic import BaseModel, Field
from app.enums.generation_status import (
GenerationStatus,
GenerationType,
DURATIONS,
ASPECT_RATIOS,
RESOLUTIONS,
IMAGE_SIZES,
)
from app.schemas.common import NaiveDatetime, NaiveDatetimeOptional
from app.services.operation_log import log_operation
class GenerationStatus(str, Enum):
prompt_optimized = "prompt_optimized"
generating = "generating"
completed = "completed"
failed = "failed"
class GenerationType(str, Enum):
video = "video"
image = "image"
DURATIONS = [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
ASPECT_RATIOS = ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"]
RESOLUTIONS = ["480p", "720p", "1080p"]
IMAGE_SIZES = ["2K", "4K"]
class OptimizeParams(BaseModel):
project_id: str
prompt: str = Field(..., max_length=500)
+25 -2
View File
@@ -1,22 +1,45 @@
from pydantic import BaseModel
from __future__ import annotations
from pydantic import BaseModel, Field, field_validator
from app.enums.notification import NotificationType
from app.schemas.common import NaiveDatetime
class NotificationCreditsOut(BaseModel):
"""通知轮询接口携带的当前用户积分信息。"""
balance: float = Field(..., description="当前用户最新积分余额")
class NotificationOut(BaseModel):
id: str
title: str
content: str
type: str
type: NotificationType
is_read: bool
created_at: NaiveDatetime
@field_validator("type", mode="before")
@classmethod
def normalize_type(cls, value):
"""兼容历史数据或后台自定义通知类型,避免响应模型校验失败。"""
if isinstance(value, NotificationType):
return value
if value is None:
return NotificationType.UNKNOWN
try:
return NotificationType(str(value))
except ValueError:
return NotificationType.UNKNOWN
model_config = {"from_attributes": True}
class NotificationListOut(BaseModel):
items: list[NotificationOut]
total: int
credits: NotificationCreditsOut
class UnreadCountOut(BaseModel):
@@ -0,0 +1,119 @@
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, Field
from app.enums.recent_generation import RecentGenerationModuleEnum, RecentGenerationResourceTypeEnum
from app.schemas.common import NaiveDatetimeOptional
class RecentGenerationItemOut(BaseModel):
"""最近生成记录响应项。"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"generated_time": "2026-06-26T14:30:00",
"result_url": "https://example.com/generate/video/demo.mp4?exp=1780000000&sign=xxxx",
"cover_url": "https://example.com/generate/cover/demo.jpg?exp=1780000000&sign=xxxx",
"module": "shot_replicate",
"shot_task_set_id": "0019ef0000000000001",
"shot_segment_id": "0019ef0000000000002",
"module_project_id": "0019ef0000000000003",
"module_step_id": "0019ef0000000000004",
"generation_id": "0019ef0000000000005",
"resource_type": "video",
}
}
)
generated_time: NaiveDatetimeOptional = Field(
None,
description="生成完成时间。优先使用 generated_at;历史数据 generated_at 为空时回退 updated_at,再回退 created_at。",
)
result_url: str | None = Field(
None,
description="生成结果链接。resource_type=image 时为图片链接;resource_type=video 时为视频链接。返回前会按项目资源签名规则追加 exp/sign。",
)
cover_url: str | None = Field(
None,
description="视频封面链接。仅视频资源通常有值;图片资源或历史无封面数据时返回 null。返回前会按项目资源签名规则追加 exp/sign。",
)
module: RecentGenerationModuleEnum = Field(
...,
description=(
"数据模块枚举:"
"project=项目生成 GenerationRecord"
"chat_ai=AI创作 ChatGenerationTask.generation_mode=chatapi_async"
"hot_opening_replicate=爆款开头复刻 ChatGenerationTask.generation_mode=hot_opening_replicate"
"shot_replicate=拆镜复刻 ChatGenerationTask.generation_mode=shot_replicate。"
),
)
shot_task_set_id: str | None = Field(
None,
description="关联拆镜总任务ID。仅 shot_replicate 模块可能有值,来源 shot_replicate_segments.task_set_id;其他模块返回 null。",
)
shot_segment_id: str | None = Field(
None,
description="关联拆镜片段ID。仅 shot_replicate 模块可能有值,来源 shot_replicate_segments.id;其他模块返回 null。",
)
module_project_id: str | None = Field(
None,
description="通用模块项目ID。hot_opening_replicate/shot_replicate 模块可能有值,来源 module_generation_steps.project_idproject/chat_ai 返回 null。",
)
module_step_id: str | None = Field(
None,
description="通用模块步骤ID。hot_opening_replicate/shot_replicate 模块可能有值,来源 module_generation_steps.idproject/chat_ai 返回 null。",
)
generation_id: str = Field(
...,
description="生成ID。project 模块为 generation_records.idchat_ai/hot_opening_replicate/shot_replicate 模块为 chat_generation_tasks.id。",
)
resource_type: RecentGenerationResourceTypeEnum = Field(
...,
description="资源类型枚举:image=图片资源;video=视频资源。前端可据此决定预览组件。",
)
class RecentGenerationGroupOut(BaseModel):
"""最近生成记录固定分组响应。"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"project": [],
"chat_ai": [
{
"generated_time": "2026-06-26T14:30:00",
"result_url": "https://example.com/generate/image/demo.png?exp=1780000000&sign=xxxx",
"cover_url": None,
"module": "chat_ai",
"shot_task_set_id": None,
"shot_segment_id": None,
"module_project_id": None,
"module_step_id": None,
"generation_id": "0019ef0000000000010",
"resource_type": "image",
}
],
"hot_opening_replicate": [],
"shot_replicate": [],
}
}
)
project: list[RecentGenerationItemOut] = Field(
default_factory=list,
description="项目生成最近记录数组,来源 generation_records。未查询该模块或无数据时返回空数组。",
)
chat_ai: list[RecentGenerationItemOut] = Field(
default_factory=list,
description="AI创作最近记录数组,来源 chat_generation_tasks,条件 generation_mode=chatapi_async。未查询该模块或无数据时返回空数组。",
)
hot_opening_replicate: list[RecentGenerationItemOut] = Field(
default_factory=list,
description="爆款开头复刻最近记录数组,来源 chat_generation_tasks,条件 generation_mode=hot_opening_replicate。未查询该模块或无数据时返回空数组。",
)
shot_replicate: list[RecentGenerationItemOut] = Field(
default_factory=list,
description="拆镜复刻最近记录数组,来源 chat_generation_tasks,条件 generation_mode=shot_replicate。未查询该模块或无数据时返回空数组。",
)
+1 -8
View File
@@ -1,13 +1,6 @@
from enum import Enum
from pydantic import BaseModel, Field
class SmsScene(str, Enum):
register = "register"
login = "login"
common = "common"
set_password = "set_password"
from app.enums.sms import SmsScene
class SmsSendRequest(BaseModel):
@@ -0,0 +1,32 @@
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field
class UserOAuthAccountOut(BaseModel):
id: str = Field(..., description="主键")
oauth_id: str = Field(..., description="授权表中的id")
advertiser_id: Optional[str] = Field(None, description="广告主账户id")
advertiser_name: Optional[str] = Field(None, description="广告账户名")
advertiser_role: Optional[str] = Field(None, description="广告账户类型")
created_at: datetime = Field(..., description="创建时间")
updated_at: datetime = Field(..., description="更新时间")
class PaginationInfo(BaseModel):
page: int = Field(..., description="当前页码")
page_size: int = Field(..., description="每页数量")
total: int = Field(..., description="总记录数")
total_pages: int = Field(..., description="总页数")
class OAuthAccountListResponse(BaseModel):
code: int = Field(0, description="返回码,0表示成功")
message: str = Field("查询成功", description="返回消息")
data: List[UserOAuthAccountOut] = Field(..., description="授权账户列表数据")
pagination: PaginationInfo = Field(..., description="分页信息")
class DeleteOAuthAccountRequest(BaseModel):
id: str = Field(..., description="授权账户表id")
@@ -0,0 +1,407 @@
from __future__ import annotations
from collections.abc import Iterable
from datetime import datetime
from typing import Any, TypedDict
from sqlalchemy import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.generation_task import GenerationType
from app.enums.recent_generation import (
RECENT_GENERATION_ALL_MODULES,
RECENT_GENERATION_CHAT_TASK_MODULES,
RECENT_GENERATION_COMPLETED_STATUS,
RECENT_GENERATION_MODULE_TO_TASK_MODE,
RECENT_GENERATION_TASK_MODE_VALUE_TO_MODULE,
RecentGenerationModuleEnum,
RecentGenerationResourceTypeEnum,
)
from app.models.chat_generation_task import ChatGenerationTask
from app.models.generation_record import GenerationRecord
from app.models.module_generation_step import ModuleGenerationStep
from app.models.shot_replicate_segment import ShotReplicateSegment
from app.schemas.recent_generation import RecentGenerationGroupOut, RecentGenerationItemOut
from app.services.resource_signed_url_service import build_resource_signed_url
DEFAULT_RECENT_GENERATION_LIMIT = 5
MAX_RECENT_GENERATION_LIMIT = 100
class _StepLinkInfo(TypedDict):
module_project_id: str | None
module_step_id: str | None
module: str | None
class _ShotLinkInfo(TypedDict):
shot_task_set_id: str | None
shot_segment_id: str | None
def _normalize_limit(limit: int | None) -> int:
if limit is None:
return DEFAULT_RECENT_GENERATION_LIMIT
return min(max(int(limit), 1), MAX_RECENT_GENERATION_LIMIT)
def _normalize_modules(
modules: Iterable[RecentGenerationModuleEnum] | None,
) -> list[RecentGenerationModuleEnum]:
if not modules:
return list(RECENT_GENERATION_ALL_MODULES)
normalized: list[RecentGenerationModuleEnum] = []
seen: set[RecentGenerationModuleEnum] = set()
for module in modules:
module_enum = RecentGenerationModuleEnum(module)
if module_enum not in seen:
normalized.append(module_enum)
seen.add(module_enum)
return normalized
def _has_url(column) -> Any:
return and_(column.is_not(None), column != "")
def _detect_resource_type(
gen_type: str | None,
image_url: str | None,
video_url: str | None,
) -> RecentGenerationResourceTypeEnum:
gen_type_value = (gen_type or "").strip().lower()
if gen_type_value == GenerationType.IMAGE.value and image_url:
return RecentGenerationResourceTypeEnum.IMAGE
if gen_type_value == GenerationType.VIDEO.value and video_url:
return RecentGenerationResourceTypeEnum.VIDEO
if video_url:
return RecentGenerationResourceTypeEnum.VIDEO
return RecentGenerationResourceTypeEnum.IMAGE
def _sign_url(url: str | None) -> str | None:
if not url:
return None
return build_resource_signed_url(url)
def _build_item(
*,
generation_id: str,
module: RecentGenerationModuleEnum,
gen_type: str | None,
image_url: str | None,
video_url: str | None,
video_cover_url: str | None,
generated_time: datetime | None,
step_info: _StepLinkInfo | None = None,
shot_info: _ShotLinkInfo | None = None,
) -> RecentGenerationItemOut:
resource_type = _detect_resource_type(
gen_type=gen_type,
image_url=image_url,
video_url=video_url,
)
raw_result_url = video_url if resource_type == RecentGenerationResourceTypeEnum.VIDEO else image_url
raw_cover_url = video_cover_url if resource_type == RecentGenerationResourceTypeEnum.VIDEO else None
return RecentGenerationItemOut(
generated_time=generated_time,
result_url=_sign_url(raw_result_url),
cover_url=_sign_url(raw_cover_url),
module=module,
shot_task_set_id=shot_info["shot_task_set_id"] if shot_info else None,
shot_segment_id=shot_info["shot_segment_id"] if shot_info else None,
module_project_id=step_info["module_project_id"] if step_info else None,
module_step_id=step_info["module_step_id"] if step_info else None,
generation_id=generation_id,
resource_type=resource_type,
)
async def _list_project_recent_items(
db: AsyncSession,
*,
user_id: str,
limit: int,
) -> list[RecentGenerationItemOut]:
generated_time_expr = func.coalesce(
GenerationRecord.generated_at,
GenerationRecord.updated_at,
GenerationRecord.created_at,
).label("generated_time")
stmt = (
select(
GenerationRecord.id.label("generation_id"),
GenerationRecord.gen_type.label("gen_type"),
GenerationRecord.image_url.label("image_url"),
GenerationRecord.video_url.label("video_url"),
GenerationRecord.video_cover_url.label("video_cover_url"),
generated_time_expr,
)
.where(
GenerationRecord.user_id == user_id,
GenerationRecord.deleted_at.is_(None),
GenerationRecord.status == RECENT_GENERATION_COMPLETED_STATUS,
or_(_has_url(GenerationRecord.image_url), _has_url(GenerationRecord.video_url)),
)
.order_by(generated_time_expr.desc(), GenerationRecord.created_at.desc())
.limit(limit)
)
rows = (await db.execute(stmt)).mappings().all()
return [
_build_item(
generation_id=row["generation_id"],
module=RecentGenerationModuleEnum.PROJECT,
gen_type=row["gen_type"],
image_url=row["image_url"],
video_url=row["video_url"],
video_cover_url=row["video_cover_url"],
generated_time=row["generated_time"],
)
for row in rows
]
async def _list_chat_task_recent_rows(
db: AsyncSession,
*,
user_id: str,
modules: list[RecentGenerationModuleEnum],
limit: int,
) -> list[dict[str, Any]]:
task_mode_values = [
RECENT_GENERATION_MODULE_TO_TASK_MODE[module].value
for module in modules
if module in RECENT_GENERATION_CHAT_TASK_MODULES
]
if not task_mode_values:
return []
generated_time_expr = func.coalesce(
ChatGenerationTask.generated_at,
ChatGenerationTask.updated_at,
ChatGenerationTask.created_at,
)
ranked_subquery = (
select(
ChatGenerationTask.id.label("generation_id"),
ChatGenerationTask.generation_mode.label("generation_mode"),
ChatGenerationTask.gen_type.label("gen_type"),
ChatGenerationTask.image_url.label("image_url"),
ChatGenerationTask.video_url.label("video_url"),
ChatGenerationTask.video_cover_url.label("video_cover_url"),
generated_time_expr.label("generated_time"),
func.row_number()
.over(
partition_by=ChatGenerationTask.generation_mode,
order_by=(generated_time_expr.desc(), ChatGenerationTask.created_at.desc()),
)
.label("row_num"),
)
.where(
ChatGenerationTask.user_id == user_id,
ChatGenerationTask.deleted_at.is_(None),
ChatGenerationTask.status == RECENT_GENERATION_COMPLETED_STATUS,
ChatGenerationTask.generation_mode.in_(task_mode_values),
or_(_has_url(ChatGenerationTask.image_url), _has_url(ChatGenerationTask.video_url)),
)
.subquery()
)
stmt = (
select(ranked_subquery)
.where(ranked_subquery.c.row_num <= limit)
.order_by(ranked_subquery.c.generation_mode.asc(), ranked_subquery.c.generated_time.desc())
)
return [dict(row) for row in (await db.execute(stmt)).mappings().all()]
async def _load_step_link_map(
db: AsyncSession,
*,
chat_task_ids: list[str],
) -> dict[str, _StepLinkInfo]:
if not chat_task_ids:
return {}
stmt = (
select(
ModuleGenerationStep.chat_task_id.label("chat_task_id"),
ModuleGenerationStep.id.label("module_step_id"),
ModuleGenerationStep.project_id.label("module_project_id"),
ModuleGenerationStep.module.label("module"),
ModuleGenerationStep.is_current.label("is_current"),
ModuleGenerationStep.updated_at.label("updated_at"),
)
.where(
ModuleGenerationStep.deleted_at.is_(None),
ModuleGenerationStep.chat_task_id.in_(chat_task_ids),
ModuleGenerationStep.module.in_(
[
RecentGenerationModuleEnum.HOT_OPENING_REPLICATE.value,
RecentGenerationModuleEnum.SHOT_REPLICATE.value,
]
),
)
.order_by(
ModuleGenerationStep.chat_task_id.asc(),
ModuleGenerationStep.is_current.desc(),
ModuleGenerationStep.updated_at.desc(),
)
)
link_map: dict[str, _StepLinkInfo] = {}
rows = (await db.execute(stmt)).mappings().all()
for row in rows:
chat_task_id = row["chat_task_id"]
if not chat_task_id or chat_task_id in link_map:
continue
link_map[chat_task_id] = {
"module_project_id": row["module_project_id"],
"module_step_id": row["module_step_id"],
"module": row["module"],
}
return link_map
async def _load_shot_link_map(
db: AsyncSession,
*,
module_project_ids: list[str],
) -> dict[str, _ShotLinkInfo]:
if not module_project_ids:
return {}
stmt = (
select(
ShotReplicateSegment.module_project_id.label("module_project_id"),
ShotReplicateSegment.id.label("shot_segment_id"),
ShotReplicateSegment.task_set_id.label("shot_task_set_id"),
)
.where(
ShotReplicateSegment.deleted_at.is_(None),
ShotReplicateSegment.module_project_id.in_(module_project_ids),
)
.order_by(ShotReplicateSegment.updated_at.desc())
)
link_map: dict[str, _ShotLinkInfo] = {}
rows = (await db.execute(stmt)).mappings().all()
for row in rows:
module_project_id = row["module_project_id"]
if not module_project_id or module_project_id in link_map:
continue
link_map[module_project_id] = {
"shot_task_set_id": row["shot_task_set_id"],
"shot_segment_id": row["shot_segment_id"],
}
return link_map
async def _build_chat_task_group_items(
db: AsyncSession,
*,
rows: list[dict[str, Any]],
) -> dict[RecentGenerationModuleEnum, list[RecentGenerationItemOut]]:
grouped: dict[RecentGenerationModuleEnum, list[RecentGenerationItemOut]] = {
RecentGenerationModuleEnum.CHAT_AI: [],
RecentGenerationModuleEnum.HOT_OPENING_REPLICATE: [],
RecentGenerationModuleEnum.SHOT_REPLICATE: [],
}
if not rows:
return grouped
module_task_rows: list[dict[str, Any]] = []
module_chat_task_ids: list[str] = []
for row in rows:
module = RECENT_GENERATION_TASK_MODE_VALUE_TO_MODULE.get(row["generation_mode"])
if module in (
RecentGenerationModuleEnum.HOT_OPENING_REPLICATE,
RecentGenerationModuleEnum.SHOT_REPLICATE,
):
module_task_rows.append(row)
module_chat_task_ids.append(row["generation_id"])
step_link_map = await _load_step_link_map(db, chat_task_ids=module_chat_task_ids)
shot_project_ids = [
step_info["module_project_id"]
for row in module_task_rows
if (step_info := step_link_map.get(row["generation_id"]))
and step_info.get("module") == RecentGenerationModuleEnum.SHOT_REPLICATE.value
and step_info.get("module_project_id")
]
shot_link_map = await _load_shot_link_map(db, module_project_ids=shot_project_ids)
for row in rows:
module = RECENT_GENERATION_TASK_MODE_VALUE_TO_MODULE.get(row["generation_mode"])
if not module:
continue
step_info = step_link_map.get(row["generation_id"])
shot_info = None
if module == RecentGenerationModuleEnum.SHOT_REPLICATE and step_info:
module_project_id = step_info.get("module_project_id")
if module_project_id:
shot_info = shot_link_map.get(module_project_id)
grouped[module].append(
_build_item(
generation_id=row["generation_id"],
module=module,
gen_type=row["gen_type"],
image_url=row["image_url"],
video_url=row["video_url"],
video_cover_url=row["video_cover_url"],
generated_time=row["generated_time"],
step_info=step_info,
shot_info=shot_info,
)
)
return grouped
async def list_recent_generations(
db: AsyncSession,
*,
user_id: str,
modules: Iterable[RecentGenerationModuleEnum] | None = None,
limit: int | None = None,
) -> RecentGenerationGroupOut:
"""获取当前用户各模块最近生成成功的图片/视频记录。"""
normalized_limit = _normalize_limit(limit)
normalized_modules = _normalize_modules(modules)
response = RecentGenerationGroupOut()
if RecentGenerationModuleEnum.PROJECT in normalized_modules:
response.project = await _list_project_recent_items(
db,
user_id=user_id,
limit=normalized_limit,
)
chat_modules = [module for module in normalized_modules if module in RECENT_GENERATION_CHAT_TASK_MODULES]
if chat_modules:
chat_rows = await _list_chat_task_recent_rows(
db,
user_id=user_id,
modules=chat_modules,
limit=normalized_limit,
)
chat_grouped = await _build_chat_task_group_items(db, rows=chat_rows)
response.chat_ai = chat_grouped[RecentGenerationModuleEnum.CHAT_AI]
response.hot_opening_replicate = chat_grouped[RecentGenerationModuleEnum.HOT_OPENING_REPLICATE]
response.shot_replicate = chat_grouped[RecentGenerationModuleEnum.SHOT_REPLICATE]
return response
@@ -334,8 +334,14 @@ async def _upload_to_juliang(
file_content = f.read()
image_signature = hashlib.md5(file_content).hexdigest()
# data = {
# "advertiser_id": advertiser_id,
# "upload_type": "UPLOAD_BY_FILE",
# "image_signature": image_signature,
# "filename": filename,
# }
data = {
"advertiser_id": advertiser_id,
"local_account_id": advertiser_id,
"upload_type": "UPLOAD_BY_FILE",
"image_signature": image_signature,
"filename": filename,
@@ -345,7 +351,9 @@ async def _upload_to_juliang(
"image_file": (filename, file_content, "image/png"),
}
response = await douyin_api.upload_image_material(oauth_id, data, files)
# 上传本地推图片
response = await douyin_api.upload_local_image_material(oauth_id, data, files)
#response = await douyin_api.upload_image_material(oauth_id, data, files)
if response["code"] != 0:
return {
@@ -416,8 +424,14 @@ async def _upload_to_juliang(
file_content = f.read()
video_signature = hashlib.md5(file_content).hexdigest()
# data = {
# "advertiser_id": advertiser_id,
# "upload_type": "UPLOAD_BY_FILE",
# "video_signature": video_signature,
# "filename": filename,
# }
data = {
"advertiser_id": advertiser_id,
"local_account_id": advertiser_id,
"upload_type": "UPLOAD_BY_FILE",
"video_signature": video_signature,
"filename": filename,
@@ -428,6 +442,7 @@ async def _upload_to_juliang(
}
response = await douyin_api.upload_video_material(oauth_id, data, files)
#response = await douyin_api.upload_local_video_material(oauth_id, data, files)
if response["code"] != 0:
return {
+29 -8
View File
@@ -15,6 +15,7 @@ from app.models.pre_test_template import PreTestTemplate
from app.utils.id_gen import generate_id
from app.utils.douyinApi import DouyinApi
from app.utils.logger import get_logger
import os
import hashlib
import json
@@ -27,7 +28,6 @@ class UploadQueue:
def __init__(self):
self.queue: asyncio.Queue[str] = asyncio.Queue()
self.running = False
async def enqueue(self, task_id: str):
"""Add a task to the queue."""
await self.queue.put(task_id)
@@ -277,6 +277,20 @@ async def _upload_to_juliang(
else:
filename = os.path.basename(storage_path)
#查询授权记录
oauth = await db.execute(
select(UserOAuth).where(
UserOAuth.id == oauth_id,
)
)
oauth = oauth.scalar_one_or_none()
if not oauth:
return {
"success": False,
"error": "授权记录不存在"
}
account_role = oauth.account_role
if resource_type == "image":
if resource.file_size_bytes > 5 * 1024 * 1024:
return {
@@ -292,17 +306,21 @@ async def _upload_to_juliang(
image_signature = hashlib.md5(file_content).hexdigest()
data = {
"advertiser_id": advertiser_id,
"upload_type": "UPLOAD_BY_FILE",
"image_signature": image_signature,
"filename": filename,
}
files = {
"image_file": (filename, file_content, "image/png"),
}
response = await douyin_api.upload_image_material(oauth_id, data, files)
#如果account_role授权角色包含:LOCAL,那么就是本地推接口,其他是广告千川接口
if "LOCAL" in account_role:
data["local_account_id"] = advertiser_id
response = await douyin_api.upload_local_image_material(oauth_id, data, files)
else:
data["advertiser_id"] = advertiser_id
response = await douyin_api.upload_image_material(oauth_id, data, files)
if response["code"] != 0:
return {
@@ -375,17 +393,20 @@ async def _upload_to_juliang(
video_signature = hashlib.md5(file_content).hexdigest()
data = {
"advertiser_id": advertiser_id,
"upload_type": "UPLOAD_BY_FILE",
"video_signature": video_signature,
"filename": filename,
}
files = {
"video_file": (filename, file_content, "video/mp4"),
}
response = await douyin_api.upload_video_material(oauth_id, data, files)
if "LOCAL" in account_role:
data["local_account_id"] = advertiser_id
response = await douyin_api.upload_local_video_material(oauth_id, data, files)
else:
data["advertiser_id"] = advertiser_id
response = await douyin_api.upload_video_material(oauth_id, data, files)
if response["code"] != 0:
return {
@@ -622,4 +643,4 @@ async def _update_material_pre_test_status(
)
upload_queue = UploadQueue()
upload_queue = UploadQueue()
@@ -0,0 +1,111 @@
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user_oauth_account import UserOAuthAccount
from app.models.user_oauth import UserOAuth
async def get_oauth_account_list(
db: AsyncSession,
page: int = 1,
page_size: int = 20,
advertiser_id: str | None = None,
oauth_id: str | None = None,
advertiser_name: str | None = None,
user_id: str | None = None,
) -> dict:
# 构建连表查询
query = select(
UserOAuthAccount.id,
UserOAuthAccount.advertiser_id,
UserOAuthAccount.advertiser_name,
UserOAuthAccount.advertiser_role,
UserOAuthAccount.oauth_id,
UserOAuthAccount.created_at,
UserOAuth.account_id,
UserOAuth.account_name,
UserOAuth.account_role,
UserOAuth.account_username,
UserOAuth.account_userid,
UserOAuth.open_type,
).join(
UserOAuth,
UserOAuth.id == UserOAuthAccount.oauth_id
).where(
UserOAuth.deleted_at.is_(None),
UserOAuthAccount.deleted_at.is_(None),
UserOAuth.user_id == user_id, # 过滤当前登录用户
)
# 添加筛选条件
if advertiser_id:
query = query.where(UserOAuthAccount.advertiser_id == advertiser_id)
if oauth_id:
query = query.where(UserOAuthAccount.oauth_id == oauth_id)
if advertiser_name:
query = query.where(UserOAuthAccount.advertiser_name.like(f"%{advertiser_name}%"))
# 查询总数
count_query = select(func.count()).select_from(query.subquery())
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# 查询分页数据
offset = (page - 1) * page_size
query = query.offset(offset).limit(page_size).order_by(UserOAuthAccount.created_at.desc())
result = await db.execute(query)
accounts = result.all()
total_pages = (total + page_size - 1) // page_size if total > 0 else 0
# 转换为字典列表(或 Pydantic 实例列表)
data = [
{
"id": row.id,
"advertiser_id": row.advertiser_id,
"advertiser_name": row.advertiser_name,
"advertiser_role": row.advertiser_role,
"oauth_id": row.oauth_id,
"account_id": row.account_id,
"account_name": row.account_name,
"account_role": row.account_role,
"account_username": row.account_username,
"account_userid": row.account_userid,
"open_type": row.open_type,
"created_at": row.created_at,
}
for row in accounts
]
return {
"data": data,
"page": page,
"page_size": page_size,
"total": total,
"total_pages": total_pages,
}
async def delete_oauth_account(
db: AsyncSession,
account_id: str,
user_id: str,
) -> bool:
result = await db.execute(
select(UserOAuthAccount).join(
UserOAuth,
UserOAuth.id == UserOAuthAccount.oauth_id
).where(
UserOAuthAccount.id == account_id,
UserOAuthAccount.deleted_at.is_(None),
UserOAuth.user_id == user_id, # 过滤当前登录用户
)
)
account = result.scalar_one_or_none()
if not account:
raise ValueError("授权账户不存在")
# 软删除
account.deleted_at = func.now()
await db.commit()
return True
+38
View File
@@ -19,6 +19,24 @@ class DouyinApi:
'GET',
{'params': params or {}}
)
#上传本地推图片
async def upload_local_image_material(self, oauth_id: str, data: Optional[Dict[str, Any]] = None, files: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
if not oauth_id:
raise RuntimeError('OAuth ID is not set.')
url = "https://api.oceanengine.com/open_api/v3.0/local/image/upload/"
options: Dict[str, Any] = {}
if data:
options['data'] = data
if files:
options['files'] = files
return await self.request.request_with_token_with_context(
oauth_id,
url,
'POST',
options,
request_count = 3
)
async def upload_image_material(self, oauth_id: str, data: Optional[Dict[str, Any]] = None, files: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
if not oauth_id:
@@ -57,6 +75,26 @@ class DouyinApi:
request_count = 3
)
#上传本地推视频素材
async def upload_local_video_material(self, oauth_id: str, data: Optional[Dict[str, Any]] = None, files: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
if not oauth_id:
raise RuntimeError('OAuth ID is not set.')
url = "https://api.oceanengine.com/open_api/v3.0/local/file/video/upload/"
options: Dict[str, Any] = {}
if data:
options['data'] = data
if files:
options['files'] = files
return await self.request.request_with_token_with_context(
oauth_id,
url,
'POST',
options,
request_count = 3
)
#获取区域信息
async def get_area(self, oauth_id: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
if not oauth_id:
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+40
View File
@@ -1,3 +1,4 @@
<<<<<<< HEAD
<!doctype html>
<html lang="zh-CN">
<head>
@@ -35,3 +36,42 @@
<div id="root"></div>
</body>
</html>
=======
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<title>民众智创</title>
<script>
(function() {
var cached = localStorage.getItem('siteInfo');
if (cached) {
try {
var info = JSON.parse(cached);
if (info.siteName) {
document.title = info.siteName;
}
if (info.siteLogo) {
var link = document.querySelector('link[rel="icon"]');
if (link) {
link.href = info.siteLogo;
link.type = 'image/png';
}
}
} catch (e) {}
}
})();
</script>
<script type="module" crossorigin src="/assets/index-Cp_h_kxu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-xCZbcxht.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
>>>>>>> 6c8543a5f66c8c60bcb8347069cf1b2c6c6cd64f
+2
View File
@@ -22,6 +22,7 @@ import AuthorizationPage from './pages/AuthorizationPage';
import MaterialListPage from './pages/MaterialListPage';
import RemoveInfo from './pages/RemoveInfo';
import RemoveRw from './pages/RemoveRw';
import HomePage from './pages/HomePage';
// import RemoveFenbu from './pages/RemoveFenbu';
import ConsumePage from './pages/ConsumePage';
import AuthorizationWaitingPage from './pages/AuthorizationWaitingPage';
@@ -102,6 +103,7 @@ const App = () => {
<Route path="order-records" element={<UserCenterPage />} />
<Route path="user-center" element={<UserCenterPage />} />
<Route path="messages" element={<MessagesPage />} />
<Route path="home" element={<HomePage />} />
<Route path="conversation" element={<GenerateConver />} />
<Route path="initial" element={<InitialReplication />} />
<Route path="initial/:creatID/initialinfo" element={<InitialInfo />} />
+5 -4
View File
@@ -197,7 +197,7 @@ export async function verifySms(phone: string, code: string): Promise<{ token: s
return api.post('/sms/verify', { phone, code }, false);
}
// ── Notifications ─────────────────────────────────────────
export async function getNotifications(page = 1, pageSize = 20, isRead?: boolean): Promise<{ items: AdminNotification[], total: number }> {
export async function getNotifications(page = 1, pageSize = 20, isRead?: boolean): Promise<{ items: AdminNotification[], total: number, credits?: { balance: number } }> {
if (USE_MOCK) {
const items = await mock.mockGetAdminNotifications();
return { items, total: items.length };
@@ -700,8 +700,10 @@ export async function getResourcesMaterialList(params: ResourcesMaterialListPara
if (params.page_size !== undefined) query.set('page_size', String(params.page_size));
return api.get(`/resources-material/list?${query.toString()}`);
}
// 全部开户方式列表
// home 获取各模块媒体
export async function getmedit(limit:number): Promise<any> {
return api.get(`/recent-generations?limit=${limit}&modules=project&modules=chat_ai&modules=hot_opening_replicate&modules=shot_replicate`);
}
export interface OpenTypeItem {
id: string;
openType: number;
@@ -712,7 +714,6 @@ export interface OpenTypeItem {
export async function getOpenTypeAll(): Promise<{ data: OpenTypeItem[] }> {
return api.get('/open-type/open_type_all');
}
// 获取授权账户列表,支持按广告主ID、授权ID、广告账户名称筛选
export interface OAuthAccountParams {
advertiser_id: string;
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

@@ -0,0 +1,247 @@
.desktop-sidebar {
display: flex;
}
.desktop-content {
display: block;
}
.mobile-header {
display: none;
}
.mobile-content {
display: none;
}
.mobile-menu-drawer {
display: none;
}
.mobile-menu-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
margin: 2px 8px;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s ease;
min-height: 48px;
}
.mobile-menu-item:hover {
background: rgba(99, 102, 241, 0.05);
}
.mobile-menu-item-active {
background: linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.08) 100%) !important;
}
.mobile-menu-group {
cursor: default;
}
.mobile-menu-group:hover {
background: transparent;
}
.mobile-menu-icon {
font-size: 18px;
flex-shrink: 0;
width: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.mobile-menu-label {
flex: 1;
font-size: 15px;
}
.mobile-submenu {
padding-left: 12px;
background: rgba(248, 250, 252, 0.5);
margin: 0 8px;
border-radius: 8px;
}
.mobile-submenu-item {
padding: 10px 16px 10px 20px;
margin: 1px 0;
min-height: 44px;
}
.mobile-recharge-item {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%) !important;
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.4);
}
.mobile-recharge-item:hover {
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%) !important;
}
.contact-button-wrapper {
position: fixed;
right: 24px;
bottom: 24px;
z-index: 1000;
}
.contact-tooltip {
position: absolute;
right: 64px;
bottom: 8px;
padding: 8px 16px;
background: #1e293b;
color: #ffffff;
border-radius: 8px;
font-size: 13px;
white-space: nowrap;
transition: opacity 0.2s ease;
pointer-events: none;
}
.contact-button {
width: 40px;
height: 40px;
border-radius: 50%;
border: none;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: #ffffff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 20px rgba(99, 102, 241, 0.4);
transition: all 0.3s ease;
}
.contact-button:hover {
transform: scale(1.05);
box-shadow: 0 6px 24px rgba(99, 102, 241, 0.5);
}
@media (max-width: 767px) {
.desktop-sidebar {
display: none !important;
}
.desktop-content {
display: none !important;
}
.mobile-header {
display: block;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.mobile-header-content {
display: flex;
align-items: center;
justify-content: space-between;
height: 56px;
padding: 0 16px;
}
.mobile-menu-btn {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
cursor: pointer;
color: #475569;
transition: all 0.2s ease;
}
.mobile-menu-btn:hover {
background: rgba(99, 102, 241, 0.08);
color: #6366f1;
}
.mobile-header-title {
font-size: 18px;
font-weight: 700;
color: #1e293b;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.mobile-header-right {
display: flex;
align-items: center;
gap: 12px;
}
.mobile-credits-badge {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: rgba(99, 102, 241, 0.08);
border-radius: 20px;
color: #6366f1;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.mobile-credits-badge:hover {
background: rgba(99, 102, 241, 0.15);
}
.mobile-content {
display: block;
padding-top: 56px;
padding-bottom: 24px;
min-height: 100vh;
background: #f8fafc;
}
.mobile-content > div {
margin: 12px;
padding: 16px;
background: #ffffff;
border-radius: 16px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
border: 1px solid rgba(0, 0, 0, 0.04);
min-height: calc(100vh - 104px);
}
.contact-button-wrapper {
right: 16px;
bottom: 16px;
}
.contact-tooltip {
display: none;
}
.contact-button {
width: 44px;
height: 44px;
}
.recharge-modal .ant-modal {
max-width: calc(100vw - 32px) !important;
margin: 16px !important;
}
.contact-modal .ant-modal {
max-width: calc(100vw - 32px) !important;
margin: 16px !important;
}
}
+343 -192
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { Layout, Avatar, Dropdown, Space, Modal, Form, Input, message, Tooltip, Tag, Button, Typography, Radio } from 'antd';
import { Layout, Avatar, Dropdown, Space, Modal, Form, Input, message, Tooltip, Tag, Button, Typography, Radio, Drawer } from 'antd';
import { QRCodeSVG } from 'qrcode.react';
import {
PlayCircleOutlined,
@@ -56,11 +56,14 @@ import {
MessageOutlined,
DownOutlined,
InfoOutlined,
MenuOutlined,
ArrowLeftOutlined,
} from '@ant-design/icons';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../../store/useAuthStore';
import { getMenuConfigs, getRechargePackages, getPaymentMethods, createRechargeOrder, getPaymentOrder, cancelPaymentOrder, getSiteInfo, getUnreadCount, createContactRequest } from '../../api';
import NotificationPopup from '../NotificationPopup';
import './AppLayout.css';
interface MenuConfig {
id: string;
@@ -137,8 +140,9 @@ const AppLayout: React.FC = () => {
const [selectedPlan, setSelectedPlan] = useState<number | null>(null);
const [menuItems, setMenuItems] = useState<MenuConfig[]>([]);
const [rechargeOptions, setRechargeOptions] = useState<any[]>([]);
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
const [unreadCount, setUnreadCount] = useState(0);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [mobileExpandedMenus, setMobileExpandedMenus] = useState<Record<string, boolean>>({});
const [siteName, setSiteName] = useState(() => {
const cached = localStorage.getItem('siteInfo');
const name = cached ? JSON.parse(cached).siteName || '' : '';
@@ -165,7 +169,7 @@ const AppLayout: React.FC = () => {
const [currentPaymentInfo, setCurrentPaymentInfo] = useState<{ price: number; credits: number; qrCode: string; method: string } | null>(null);
const [paymentMethod, setPaymentMethod] = useState<string>('alipay');
const [paying, setPaying] = useState(false);
const [countdown, setCountdown] = useState(180); // 默认180秒超时
const [countdown, setCountdown] = useState(180);
const pollingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const countdownTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const currentOrderNoRef = useRef<string | null>(null);
@@ -175,11 +179,8 @@ const AppLayout: React.FC = () => {
const [contactHovered, setContactHovered] = useState(false);
const [submittingContact, setSubmittingContact] = useState(false);
// LocalStorage keys
const PENDING_ORDER_KEY = 'pending_payment_order';
useEffect(() => {
getSiteInfo().then(info => {
const name = info.siteName || '民众智创';
@@ -212,17 +213,14 @@ const AppLayout: React.FC = () => {
}).catch(() => { });
};
// 检查并恢复待处理的支付订单
useEffect(() => {
const checkPendingOrder = async () => {
const savedOrderStr = localStorage.getItem(PENDING_ORDER_KEY);
if (savedOrderStr) {
try {
const savedOrder = JSON.parse(savedOrderStr);
// 查询订单状态
const order = await getPaymentOrder(savedOrder.orderNo);
if (order.status === 'pending') {
// 订单仍然待支付,恢复弹窗
setCurrentPaymentInfo({
price: savedOrder.price,
credits: savedOrder.credits,
@@ -230,7 +228,6 @@ const AppLayout: React.FC = () => {
method: savedOrder.method,
});
currentOrderNoRef.current = savedOrder.orderNo;
// 计算剩余时间
const now = Date.now();
const createdAt = new Date(savedOrder.createdAt).getTime();
const timeoutSeconds = savedOrder.timeoutSeconds || 180;
@@ -241,20 +238,16 @@ const AppLayout: React.FC = () => {
setQrCodeModalOpen(true);
startPolling(savedOrder.orderNo, remainingSeconds);
} else {
// 已超时,清除
localStorage.removeItem(PENDING_ORDER_KEY);
}
} else if (order.status === 'paid') {
// 已支付
message.success('支付成功!积分已到账');
useAuthStore.getState().refreshUser();
localStorage.removeItem(PENDING_ORDER_KEY);
} else {
// 订单已取消或其他状态,清除
localStorage.removeItem(PENDING_ORDER_KEY);
}
} catch {
// 查询失败,清除
localStorage.removeItem(PENDING_ORDER_KEY);
}
}
@@ -273,7 +266,6 @@ const AppLayout: React.FC = () => {
}).catch(() => { });
getPaymentMethods().then(data => {
setEnabledMethods(data);
// Auto-select the first enabled method
if (data.alipay) setPaymentMethod('alipay');
else if (data.wechat) setPaymentMethod('wechat');
}).catch(() => { });
@@ -283,6 +275,45 @@ const AppLayout: React.FC = () => {
const selectedKey = location.pathname.startsWith('/records') ? '/records' : location.pathname;
const sidebarW = SIDEBAR_W;
const childMap: Record<string, MenuConfig[]> = {};
menuItems.forEach(m => {
const pid = m.parent_id ?? m.parentId;
if (pid) {
if (!childMap[pid]) childMap[pid] = [];
childMap[pid].push(m);
}
});
const topLevelItems = menuItems.filter(m => !(m.parent_id ?? m.parentId));
topLevelItems.sort((a, b) => {
const orderA = typeof a.sortOrder === 'number' ? a.sortOrder : Infinity;
const orderB = typeof b.sortOrder === 'number' ? b.sortOrder : Infinity;
return orderA - orderB;
});
Object.keys(childMap).forEach(key => {
childMap[key].sort((a, b) => {
const orderA = typeof a.sortOrder === 'number' ? a.sortOrder : Infinity;
const orderB = typeof b.sortOrder === 'number' ? b.sortOrder : Infinity;
return orderA - orderB;
});
});
const handleMobileMenuClick = (item: MenuConfig) => {
const menuType = item.menu_type ?? item.menuType;
const hasChildren = childMap[item.id] && childMap[item.id].length > 0;
if (menuType === 'group' || hasChildren) {
setMobileExpandedMenus(prev => ({
...prev,
[item.id]: !prev[item.id]
}));
} else if (item.path) {
navigate(item.path);
setMobileMenuOpen(false);
}
};
const userMenuItems = [
{ key: 'profile', icon: <UserOutlined />, label: `账号: ${user?.username}`, disabled: true },
{ key: 'credits', icon: <WalletOutlined style={{ color: '#c9a96e' }} />, label: `积分: ${user?.credits ?? 0}`, disabled: true },
@@ -290,7 +321,6 @@ const AppLayout: React.FC = () => {
{ key: 'myCredits', icon: <WalletOutlined />, label: '积分明细' },
{ key: 'orderRecords', icon: <FileTextOutlined />, label: '订单记录' },
{ key: 'messages', icon: <BellOutlined />, label: `消息中心${unreadCount > 0 ? `(${unreadCount})` : ''}` },
// { key: 'recharge', icon: <PlusCircleOutlined />, label: '充值积分' },
{ type: 'divider' as const },
{ key: 'changePwd', icon: <LockOutlined />, label: '修改密码' },
{ type: 'divider' as const },
@@ -311,7 +341,7 @@ const AppLayout: React.FC = () => {
await pwdForm.validateFields();
message.success('密码修改成功(演示)');
setPwdModalOpen(false); pwdForm.resetFields();
} catch { /* validation */ }
} catch { }
};
const handleLogout = () => {
@@ -363,7 +393,6 @@ const AppLayout: React.FC = () => {
stopPolling();
setCountdown(timeoutSeconds);
// 订单状态轮询(每2秒查询一次,只查询当前订单
const pollingTimer = setInterval(async () => {
try {
const order = await getPaymentOrder(orderNo);
@@ -382,16 +411,13 @@ const AppLayout: React.FC = () => {
localStorage.removeItem(PENDING_ORDER_KEY);
}
} catch {
// ignore polling errors
}
}, 2000);
pollingTimerRef.current = pollingTimer;
// 倒计时
const countdownTimer = setInterval(() => {
setCountdown(prev => {
if (prev <= 1) {
// 超时自动取消
stopPolling();
if (currentOrderNoRef.current) {
cancelPaymentOrder(currentOrderNoRef.current).catch(() => { });
@@ -410,9 +436,67 @@ const AppLayout: React.FC = () => {
countdownTimerRef.current = countdownTimer;
}, [stopPolling]);
const renderMenuItem = (item: MenuConfig, depth: number = 0) => {
const isActive = item.path === selectedKey;
const menuIcon = iconMap[item.icon] || <HomeOutlined />;
const hasChildren = childMap[item.id] && childMap[item.id].length > 0;
const menuType = item.menu_type ?? item.menuType;
const isGroup = menuType === 'group';
return (
<div key={item.id}>
<div
onClick={() => {
if (!(isGroup || hasChildren) && item.path) {
navigate(item.path);
}
}}
style={{
display: 'flex', alignItems: 'center',
justifyContent: 'flex-start',
gap: 10,
padding: depth > 0 ? '6px 12px 6px 32px' : '6px 14px',
borderRadius: 12, margin: '1px 4px',
cursor: isGroup || hasChildren ? 'default' : 'pointer',
fontSize: depth > 0 ? 13 : 14, fontWeight: isActive ? 600 : 400,
color: isActive ? '#4f46e5' : (isGroup ? '#94a3b8' : '#475569'),
background: isActive ? 'linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.08) 100%)' : 'transparent',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
if (!isGroup && !isActive && !(isGroup || hasChildren)) {
e.currentTarget.style.background = 'rgba(99, 102, 241, 0.05)';
}
}}
onMouseLeave={(e) => {
if (!isActive) {
e.currentTarget.style.background = 'transparent';
}
}}
>
{!isGroup && (
<span style={{
fontSize: depth > 0 ? 14 : 16,
flexShrink: 0,
color: isActive ? '#6366f1' : '#64748b',
}}>{menuIcon}</span>
)}
<span style={{ whiteSpace: 'nowrap', flex: 1, textAlign: 'left', fontWeight: isGroup ? 600 : (isActive ? 600 : 400), fontSize: isGroup ? 12 : (depth > 0 ? 13 : 14), textTransform: isGroup ? 'uppercase' : 'none', letterSpacing: isGroup ? 0.5 : 0 }}>
{item.label}
</span>
</div>
{(isGroup || hasChildren) && (
<div style={{ overflow: 'hidden' }}>
{(childMap[item.id] || []).map(c => renderMenuItem(c, depth + 1))}
</div>
)}
</div>
);
};
return (
<Layout style={{ minHeight: '100vh' }}>
{/* Desktop Sidebar */}
<div className="desktop-sidebar" style={{
width: sidebarW, position: 'fixed', left: 16, top: 16, bottom: 16, zIndex: 100,
background: 'linear-gradient(180deg, #ffffff 0%, #f8fafc 100%)',
@@ -423,7 +507,6 @@ const AppLayout: React.FC = () => {
overflow: 'hidden',
border: '1px solid rgba(0, 0, 0, 0.06)',
}}>
{/* Logo */}
<div style={{
height: 80, display: 'flex', alignItems: 'center',
justifyContent: 'flex-start',
@@ -431,14 +514,19 @@ const AppLayout: React.FC = () => {
flexShrink: 0,
background: 'linear-gradient(135deg, rgba(99, 102, 241, 0.06) 0%, rgba(139, 92, 246, 0.04) 100%)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14, cursor: 'pointer' }}
onClick={() => {
navigate('/home');
}}>
<div style={{
width: 42, height: 42, borderRadius: 14, flexShrink: 0,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #a78bfa 100%)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 4px 16px rgba(99, 102, 241, 0.35)',
overflow: 'hidden',
}}>
}}
>
{siteLogo ? (
<img src={siteLogo} alt="logo" style={{ width: 28, height: 28, objectFit: 'contain' }} />
) : (
@@ -458,94 +546,10 @@ const AppLayout: React.FC = () => {
</div>
</div>
{/* Menu */}
<div style={{ flex: 1, padding: '8px 8px', overflow: 'auto' }}>
{/* {!collapsed && (
<div style={{ color: 'rgba(0,0,0,0.3)', fontSize: 11, fontWeight: 600, padding: '8px 12px 6px', letterSpacing: 1 }}>
导航
</div>
)} */}
{(() => {
// Build child map for all menu items
const childMap: Record<string, MenuConfig[]> = {};
menuItems.forEach(m => {
const pid = m.parent_id ?? m.parentId;
if (pid) {
if (!childMap[pid]) childMap[pid] = [];
childMap[pid].push(m);
}
});
// Get top-level items (parent_id is null/undefined)
const topLevelItems = menuItems.filter(m => !(m.parent_id ?? m.parentId));
// Sort top-level items by sort_order
topLevelItems.sort((a, b) => {
const orderA = typeof a.sortOrder === 'number' ? a.sortOrder : Infinity;
const orderB = typeof b.sortOrder === 'number' ? b.sortOrder : Infinity;
return orderA - orderB;
});
const items: React.ReactNode[] = [];
const renderMenuItem = (item: MenuConfig, depth: number = 0) => {
const isActive = item.path === selectedKey;
const menuIcon = iconMap[item.icon] || <HomeOutlined />;
return (
<div key={item.id} onClick={() => item.path && navigate(item.path)} style={{
display: 'flex', alignItems: 'center',
justifyContent: 'flex-start',
gap: 10,
padding: depth > 0 ? '6px 12px 6px 32px' : '6px 14px',
borderRadius: 12, margin: '1px 4px', cursor: 'pointer',
fontSize: depth > 0 ? 13 : 14, fontWeight: isActive ? 600 : 400,
color: isActive ? '#4f46e5' : '#475569',
background: isActive ? 'linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.08) 100%)' : 'transparent',
transition: 'all 0.2s ease',
}}>
<span style={{
fontSize: depth > 0 ? 14 : 16,
flexShrink: 0,
color: isActive ? '#6366f1' : '#64748b',
}}>{menuIcon}</span>
<span style={{ whiteSpace: 'nowrap' }}>{item.label}</span>
</div>
);
};
// Render top-level items and their children in order
topLevelItems.forEach(item => {
const menuType = item.menu_type ?? item.menuType;
if (menuType === 'group') {
// Render group with its children
const children = (childMap[item.id] || []).sort((a, b) => {
const orderA = typeof a.sortOrder === 'number' ? a.sortOrder : Infinity;
const orderB = typeof b.sortOrder === 'number' ? b.sortOrder : Infinity;
return orderA - orderB;
});
items.push(
<div key={item.id}>
<div style={{
color: '#94a3b8', fontSize: 12, fontWeight: 600,
padding: '10px 14px 4px', letterSpacing: 0.5, textTransform: 'uppercase',
}}>
{item.label}
</div>
{children.map(c => renderMenuItem(c, 1))}
</div>
);
} else {
// Render standalone page
items.push(renderMenuItem(item));
}
});
return items;
})()}
{topLevelItems.map(item => renderMenuItem(item))}
</div>
{/* Recharge button - opens modal */}
<div onClick={() => setRechargeModalOpen(true)} style={{
margin: '8px 12px',
padding: '12px 20px',
@@ -572,7 +576,6 @@ const AppLayout: React.FC = () => {
<span></span>
</div>
{/* User block at bottom-left */}
<div style={{ padding: '16px 16px', flexShrink: 0 }}>
<Dropdown menu={{ items: userMenuItems, onClick: handleUserMenuClick }} placement="topRight" arrow>
<div style={{
@@ -620,14 +623,12 @@ const AppLayout: React.FC = () => {
</div>
</div>
{/* Main Content */}
<div className="desktop-content" style={{
marginLeft: sidebarW + 28,
marginRight: 12,
marginTop: 16,
marginBottom: 16,
flex: 1,
// height: '100%',
minHeight: 'calc(100vh - 32px)',
background: 'transparent',
padding: 0,
@@ -647,31 +648,230 @@ const AppLayout: React.FC = () => {
</div>
</div>
{/* Mobile Bottom Nav */}
<div className="mobile-bottom-nav">
{menuItems.filter((item) => (item.menu_type ?? item.menuType) !== 'group').map((item) => {
if (!item.path) return null;
const isActive = item.path === selectedKey;
return (
<div key={item.id}
className={`nav-item ${isActive ? 'active' : ''}`}
onClick={() => navigate(item.path)}>
<span className="nav-icon">{iconMap[item.icon] || <HomeOutlined />}</span>
<span>{item.label}</span>
<div className="mobile-header">
<div className="mobile-header-content">
<div className="mobile-menu-btn" onClick={() => setMobileMenuOpen(true)}>
<MenuOutlined style={{ fontSize: 20 }} />
</div>
<div className="mobile-header-title">{siteName}</div>
<div className="mobile-header-right">
<div className="mobile-credits-badge" onClick={() => setRechargeModalOpen(true)}>
<WalletOutlined />
<span>{user?.credits || 0}</span>
</div>
);
})}
<div className="nav-item" onClick={handleMobileRecharge}>
<span className="nav-icon"><PlusCircleOutlined /></span>
<span></span>
</div>
<div className="nav-item" onClick={handleLogout}>
<span className="nav-icon"><LogoutOutlined /></span>
<span>退</span>
</div>
</div>
</div>
{/* Change Password Modal */}
<div className="mobile-content">
<Outlet />
</div>
<Drawer
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 36, height: 36, borderRadius: 10, flexShrink: 0,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #a78bfa 100%)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 4px 12px rgba(99, 102, 241, 0.35)',
}}>
{siteLogo ? (
<img src={siteLogo} alt="logo" style={{ width: 24, height: 24, objectFit: 'contain' }} />
) : (
<ThunderboltOutlined style={{ fontSize: 18, color: '#ffffff' }} />
)}
</div>
<span style={{ fontWeight: 700, fontSize: 16, color: '#1e293b' }}>{siteName}</span>
</div>
}
placement="left"
onClose={() => setMobileMenuOpen(false)}
open={mobileMenuOpen}
width={280}
closable={true}
className="mobile-menu-drawer"
styles={{
header: { borderBottom: '1px solid #f1f5f9', padding: '16px 20px' },
body: { padding: '12px 8px', display: 'flex', flexDirection: 'column' },
}}
>
<div style={{ flex: 1, overflow: 'auto', paddingBottom: 12 }}>
<div style={{ padding: '12px 8px 16px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<Avatar size={44} style={{ background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', flexShrink: 0 }}>
<UserOutlined />
</Avatar>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 15, fontWeight: 600, color: '#1e293b', marginBottom: 2 }}>
{user?.username || '用户'}
</div>
<div style={{ fontSize: 13, color: '#64748b' }}>
<WalletOutlined style={{ color: '#f59e0b', marginRight: 4 }} />
: <span style={{ color: '#f59e0b', fontWeight: 600 }}>{user?.credits ?? 0}</span>
</div>
</div>
</div>
</div>
{topLevelItems.map(item => {
const menuType = item.menu_type ?? item.menuType;
const hasChildren = childMap[item.id] && childMap[item.id].length > 0;
const isActive = item.path === selectedKey;
const isExpanded = mobileExpandedMenus[item.id];
const menuIcon = iconMap[item.icon] || <HomeOutlined />;
return (
<div key={item.id}>
<div
className={`mobile-menu-item ${isActive && !hasChildren ? 'mobile-menu-item-active' : ''} ${menuType === 'group' ? 'mobile-menu-group' : ''}`}
onClick={() => handleMobileMenuClick(item)}
>
{menuType !== 'group' && (
<span className="mobile-menu-icon" style={{ color: isActive ? '#6366f1' : '#64748b' }}>
{menuIcon}
</span>
)}
<span className="mobile-menu-label" style={{
paddingLeft: menuType === 'group' ? 0 : 0,
color: menuType === 'group' ? '#94a3b8' : (isActive ? '#4f46e5' : '#475569'),
fontWeight: menuType === 'group' ? 600 : (isActive ? 600 : 400),
fontSize: menuType === 'group' ? 12 : 15,
textTransform: menuType === 'group' ? 'uppercase' : 'none',
letterSpacing: menuType === 'group' ? 0.5 : 0,
}}>
{item.label}
</span>
{(menuType === 'group' || hasChildren) && (
<span style={{
fontSize: 12,
color: '#cbd5e1',
transition: 'transform 0.2s ease',
transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',
}}>
<RightOutlined />
</span>
)}
</div>
{(menuType === 'group' || hasChildren) && isExpanded && (
<div className="mobile-submenu">
{(childMap[item.id] || []).map(child => {
const childActive = child.path === selectedKey;
const childIcon = iconMap[child.icon] || <HomeOutlined />;
return (
<div
key={child.id}
className={`mobile-menu-item mobile-submenu-item ${childActive ? 'mobile-menu-item-active' : ''}`}
onClick={() => handleMobileMenuClick(child)}
>
<span className="mobile-menu-icon" style={{ color: childActive ? '#6366f1' : '#94a3b8' }}>
{childIcon}
</span>
<span className="mobile-menu-label" style={{
color: childActive ? '#4f46e5' : '#64748b',
fontWeight: childActive ? 600 : 400,
fontSize: 14,
}}>
{child.label}
</span>
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
<div style={{ padding: '8px 0', borderTop: '1px solid #f1f5f9' }}>
<div
className="mobile-menu-item"
onClick={() => {
navigate('/user-center?tab=credits');
setMobileMenuOpen(false);
}}
>
<span className="mobile-menu-icon" style={{ color: '#f59e0b' }}>
<WalletOutlined />
</span>
<span className="mobile-menu-label" style={{ color: '#475569' }}></span>
</div>
<div
className="mobile-menu-item"
onClick={() => {
navigate('/user-center?tab=orders');
setMobileMenuOpen(false);
}}
>
<span className="mobile-menu-icon" style={{ color: '#6366f1' }}>
<FileTextOutlined />
</span>
<span className="mobile-menu-label" style={{ color: '#475569' }}></span>
</div>
<div
className="mobile-menu-item"
onClick={() => {
navigate('/messages');
setMobileMenuOpen(false);
}}
>
<span className="mobile-menu-icon" style={{ color: '#8b5cf6' }}>
<BellOutlined />
</span>
<span className="mobile-menu-label" style={{ color: '#475569' }}>
{unreadCount > 0 && (
<Tag color="red" style={{ marginLeft: 8, fontSize: 11 }}>{unreadCount}</Tag>
)}
</span>
</div>
<div
className="mobile-menu-item"
onClick={() => {
setPwdModalOpen(true);
setMobileMenuOpen(false);
}}
>
<span className="mobile-menu-icon" style={{ color: '#0ea5e9' }}>
<LockOutlined />
</span>
<span className="mobile-menu-label" style={{ color: '#475569' }}></span>
</div>
<div style={{ height: 8 }} />
<div
className="mobile-menu-item mobile-recharge-item"
onClick={() => {
setRechargeModalOpen(true);
setMobileMenuOpen(false);
}}
>
<span className="mobile-menu-icon" style={{ color: '#fff' }}>
<PlusOutlined />
</span>
<span className="mobile-menu-label" style={{ color: '#fff', fontWeight: 600 }}></span>
</div>
<div
className="mobile-menu-item"
onClick={() => {
handleLogout();
setMobileMenuOpen(false);
}}
>
<span className="mobile-menu-icon" style={{ color: '#ef4444' }}>
<LogoutOutlined />
</span>
<span className="mobile-menu-label" style={{ color: '#ef4444' }}>退</span>
</div>
</div>
</Drawer>
<Modal title={<Space><LockOutlined /></Space>} open={pwdModalOpen}
onOk={handleChangePwd} onCancel={() => { setPwdModalOpen(false); pwdForm.resetFields(); }}
okText="确认修改" cancelText="取消" width={440}>
@@ -696,10 +896,11 @@ const AppLayout: React.FC = () => {
</Form>
</Modal>
{/* Recharge Modal */}
<Modal title={<Space><GiftOutlined /></Space>} open={rechargeModalOpen}
onCancel={() => { setRechargeModalOpen(false); setSelectedPlan(null); }}
footer={null} width={680}>
footer={null} width={680}
className="recharge-modal"
styles={{ body: { maxHeight: '70vh', overflowY: 'auto' } }}>
<div style={{ marginTop: 16 }}>
<Space style={{ marginBottom: 16 }}>
<WalletOutlined style={{ color: '#6366f1' }} />
@@ -756,7 +957,6 @@ const AppLayout: React.FC = () => {
})}
</div>
{/* Payment method selection */}
{(!enabledMethods.alipay && !enabledMethods.wechat) ? (
<div style={{ marginTop: 20, marginBottom: 8, padding: 16, background: '#fef2f2', borderRadius: 12, border: '1px solid #fecaca' }}>
<Typography.Text style={{ color: '#dc2626', fontSize: 13 }}>
@@ -802,10 +1002,8 @@ const AppLayout: React.FC = () => {
try {
setPaying(true);
const order = await createRechargeOrder(plan.id, paymentMethod);
// 检查是否有二维码信息,支持支付宝和微信支付
const qrCode = order.qrUrl || order.codeUrl || order.qr_code || order.code_url;
if ((order.paymentMethod === 'alipay' || order.paymentMethod === 'wechat') && qrCode) {
// Alipay or WeChat Pay: show the QR code
const paymentInfo = {
price: plan.price,
credits: totalCredits,
@@ -817,7 +1015,6 @@ const AppLayout: React.FC = () => {
setQrCodeModalOpen(true);
currentOrderNoRef.current = order.orderNo;
// 保存到 localStorage
localStorage.setItem(PENDING_ORDER_KEY, JSON.stringify({
orderNo: order.orderNo,
price: plan.price,
@@ -828,10 +1025,8 @@ const AppLayout: React.FC = () => {
timeoutSeconds: 180,
}));
// Start polling for payment status
startPolling(order.orderNo);
} else {
// Mock mode (auto-completes, no QR needed)
message.success('充值成功!积分已到账');
useAuthStore.getState().refreshUser();
setRechargeModalOpen(false);
@@ -854,12 +1049,10 @@ const AppLayout: React.FC = () => {
</div>
</Modal>
{/* QR Code Payment Modal */}
<Modal
open={qrCodeModalOpen}
onCancel={async () => {
stopPolling();
// Mark order as cancelled if it's still pending
if (currentOrderNoRef.current) {
try { await cancelPaymentOrder(currentOrderNoRef.current); } catch { }
currentOrderNoRef.current = null;
@@ -876,7 +1069,6 @@ const AppLayout: React.FC = () => {
}}
>
<div style={{ padding: '24px' }}>
{/* Header */}
<div style={{ textAlign: 'center', marginBottom: 24 }}>
<div style={{
width: 48, height: 48,
@@ -901,7 +1093,6 @@ const AppLayout: React.FC = () => {
</Typography.Text>
</div>
{/* QR Code */}
<div style={{
background: '#fff',
borderRadius: 16,
@@ -945,7 +1136,6 @@ const AppLayout: React.FC = () => {
}}>
{currentPaymentInfo?.credits || 0}
</div>
{/* 倒计时显示 */}
<div style={{
marginTop: 12,
padding: '8px 16px',
@@ -965,7 +1155,6 @@ const AppLayout: React.FC = () => {
</div>
</div>
{/* Tips */}
<div style={{ marginTop: 20, padding: 16, background: '#fef3c7', borderRadius: 12 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
<div style={{ fontSize: 16, marginTop: -2 }}>💡</div>
@@ -979,7 +1168,6 @@ const AppLayout: React.FC = () => {
</div>
</div>
{/* Footer Buttons */}
<div style={{ marginTop: 20 }}>
<Button
size="large"
@@ -1005,73 +1193,36 @@ const AppLayout: React.FC = () => {
<NotificationPopup />
{/* Contact Button */}
<div style={{
position: 'fixed',
right: 24,
bottom: 24,
zIndex: 1000,
}}>
<div className="contact-button-wrapper">
<div style={{
position: 'relative',
}}>
<div
className="contact-tooltip"
style={{
position: 'absolute',
right: 64,
bottom: 8,
padding: '8px 16px',
background: '#1e293b',
color: '#ffffff',
borderRadius: 8,
fontSize: 13,
whiteSpace: 'nowrap',
opacity: contactHovered ? 1 : 0,
transition: 'opacity 0.2s ease',
pointerEvents: 'none',
}}
>
</div>
<button
onClick={() => setContactModalOpen(true)}
style={{
width: 40,
height: 40,
borderRadius: '50%',
border: 'none',
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
color: '#ffffff',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 4px 20px rgba(99, 102, 241, 0.4)',
transition: 'all 0.3s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(1.05)';
e.currentTarget.style.boxShadow = '0 6px 24px rgba(99, 102, 241, 0.5)';
setContactHovered(true);
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
e.currentTarget.style.boxShadow = '0 4px 20px rgba(99, 102, 241, 0.4)';
setContactHovered(false);
}}
className="contact-button"
onMouseEnter={() => setContactHovered(true)}
onMouseLeave={() => setContactHovered(false)}
>
<MessageOutlined style={{ fontSize: 20 }} />
</button>
</div>
</div>
{/* Contact Modal */}
<Modal
title={<Space><MessageOutlined /></Space>}
open={contactModalOpen}
onCancel={() => { setContactModalOpen(false); contactForm.resetFields(); }}
footer={null}
width={480}
className="contact-modal"
>
<div style={{ marginTop: 8 }}>
<Form form={contactForm} layout="vertical">
@@ -24,18 +24,30 @@ const NotificationPopup: React.FC = () => {
const [notifications, setNotifications] = useState<Notification[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const refreshUser = useAuthStore((state) => state.refreshUser);
const setUserCredits = useAuthStore((state) => state.setUserCredits);
const fetchNotifications = useCallback(async () => {
try {
const result = await getNotifications(1, 20, false);
// 接口返回结构:{ credits: { balance: 4317.23 }, items: [...], total: N }
const data = result.items || [];
// 同步积分:result.credits.balance 与当前 user.credits 不一致时,直接替换
const newBalance = result?.credits?.balance;
if (typeof newBalance === 'number') {
const currentUser = useAuthStore.getState().user;
if (currentUser && currentUser.credits !== newBalance) {
setUserCredits(newBalance);
}
}
setNotifications(data);
if (data.length > 0 && !visible) {
setVisible(true);
setCurrentIndex(0);
}
} catch { /* ignore */ }
}, [visible]);
}, [visible, setUserCredits]);
useEffect(() => {
fetchNotifications();
+40
View File
@@ -200,6 +200,30 @@ html, body {
.recharge-card .recharge-price { transition: all 0.3s ease; }
.recharge-card:hover .recharge-price { transform: scale(1.08); }
/* ── Homepage tabs ────────────────────── */
.homepage-tabs .ant-tabs-nav {
margin-bottom: 8px !important;
}
.homepage-tabs .ant-tabs-tab {
padding: 8px 16px !important;
font-size: 13px !important;
font-weight: 500 !important;
color: #6b7280 !important;
transition: all 0.25s !important;
}
.homepage-tabs .ant-tabs-tab:hover {
color: #6366f1 !important;
}
.homepage-tabs .ant-tabs-tab-active .ant-tabs-tab-btn {
color: #6366f1 !important;
font-weight: 600 !important;
}
.homepage-tabs .ant-tabs-ink-bar {
background: linear-gradient(90deg, #6366f1, #a855f7) !important;
height: 3px !important;
border-radius: 2px !important;
}
/* ── Project card hover ────────────────── */
.project-card {
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1) !important;
@@ -440,3 +464,19 @@ html, body {
/* Card grid */
.card-grid { grid-template-columns: repeat(3, 1fr) !important; }
}
/* ── HomePage Tabs ────────────── */
.homepage-tabs .ant-tabs-nav { margin-bottom: 0; }
.homepage-tabs .ant-tabs-tab {
font-size: 14px;
font-weight: 500;
padding: 8px 20px;
margin-right: 4px;
}
.homepage-tabs .ant-tabs-tab-active {
color: #6366f1;
font-weight: 600;
}
.homepage-tabs .ant-tabs-ink-bar {
background: linear-gradient(135deg, #6366f1, #8b5cf6);
}
+5 -4
View File
@@ -1603,14 +1603,15 @@ const AIChatPage: React.FC = () => {
}}
size="middle"
>
<Option value="image">
<PictureOutlined style={{ marginRight: 6, fontSize: 14 }} />
<span style={{ fontSize: 14, fontWeight: 500, color: '#64748b' }}></span>
</Option>
<Option value="video">
<VideoCameraOutlined style={{ marginRight: 6, fontSize: 14 }} />
<span style={{ fontSize: 14, fontWeight: 500, color: '#64748b' }}></span>
</Option>
<Option value="image">
<PictureOutlined style={{ marginRight: 6, fontSize: 14 }} />
<span style={{ fontSize: 14, fontWeight: 500, color: '#64748b' }}></span>
</Option>
</Select>
{/* </div> */}
+17 -9
View File
@@ -247,7 +247,7 @@ const GeneratePage: React.FC = () => {
const [lastTextTokens, setLastTextTokens] = useState(0);
// Media type: 1 for image, 2 for video
const [mediaType, setMediaType] = useState<any>("image");
const [mediaType, setMediaType] = useState<any>("video");
// Image parameters
const [selectedRatio, setSelectedRatio] = useState<string>("1:1");
@@ -1400,7 +1400,14 @@ const GeneratePage: React.FC = () => {
};
return (
<div>
<div
style={{
margin: '-24px -32px -32px',
borderRadius: 20,
height: 'calc(100vh - 34px)',
padding:'24px'
}}>
{/* Header */}
<div
className="gen-header animate-fadeInUp"
@@ -1531,16 +1538,10 @@ const GeneratePage: React.FC = () => {
{/* 单选按钮组 */}
<Radio.Group
defaultValue="image"
defaultValue="video"
buttonStyle="solid"
onChange={handleMediaTypeChange}
>
{/* 图片选项 */}
<Radio.Button value="image" style={{ zIndex: 0 }}>
<PictureOutlined style={{ marginRight: 6, fontSize: 14 }} />{" "}
{/* 图片图标 */}
</Radio.Button>
{/* 视频选项 */}
<Radio.Button value="video">
<VideoCameraOutlined
@@ -1549,6 +1550,13 @@ const GeneratePage: React.FC = () => {
{/* 视频图标 */}
</Radio.Button>
{/* 图片选项 */}
<Radio.Button value="image" style={{ zIndex: 0 }}>
<PictureOutlined style={{ marginRight: 6, fontSize: 14 }} />{" "}
{/* 图片图标 */}
</Radio.Button>
</Radio.Group>
</div>
{mediaType === "video" &&
+832
View File
@@ -0,0 +1,832 @@
import React, { useEffect, useState } from 'react';
import { Button, Input, Tabs, Tag, Upload, message } from 'antd';
import {
FileTextOutlined,
ScissorOutlined,
RobotOutlined,
ArrowRightOutlined,
UploadOutlined,
VideoCameraOutlined,
PictureOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { getmedit } from '../api';
import hot from '../assets/homebtn1.png';
import mashup from '../assets/homebtn2.png';
import aicreate from '../assets/homebtn3.png';
// 把 ISO 时间格式化成 MM-DD HH:mm(与图片一致)
function formatShortDate(iso?: string | null): string {
if (!iso) return '-';
let s = String(iso).trim();
if (!s.includes('T')) s = s.replace(' ', 'T');
const dotIdx = s.indexOf('.');
if (dotIdx > 0) s = s.slice(0, dotIdx);
s = s.replace(/[+-]\d{2}:?\d{0,2}$/, '').replace(/Z$/, '');
// s 形如 2026-06-18T17:17:00
const [datePart = '', timePart = ''] = s.split('T');
const [, month = '', day = ''] = datePart.split('-');
const hhmm = timePart.slice(0, 5);
if (!month || !day) return '-';
return `${month}-${day} ${hhmm}`;
}
const HomePage: React.FC = () => {
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState('project');
const [inputValue, setInputValue] = useState('');
const [mockVideos, setMockVideos] = useState<any[]>([]);
useEffect(() => {
const fetchAll = async () => {
try {
getmedit(5).then((res) => {
console.log('getmedit:', res);
for (let item in res) {
res[item].forEach(element => {
// 给每条 element 标记它所属的模块(item 是接口返回的 key)
element.type = item;
// 依次追加到 mockVideos(函数式更新,保证拿到最新 state)
setMockVideos(prev => [...prev, element]);
});
}
// 最终再打印一次汇总(state 异步更新,这里打印的是本次追加的累计长度)
setMockVideos(prev => {
console.log('mockVideos 最终条数:', prev.length);
return prev;
});
});
} catch (err) {
console.error('HomePage fetch error:', err);
}
};
fetchAll();
}, []);
const handleTabChange = (key: string) => {
setActiveTab(key);
};
const aiEntries = [
{
icon: <FileTextOutlined style={{ fontSize: 24, color: '#6366f1' }} />,
title: '爆款开头复刻',
description: '一键复刻热门视频开篇,快速替换自有商品素材',
action: '立即创作',
path: '/initial',
},
{
icon: <ScissorOutlined style={{ fontSize: 24, color: '#f97316' }} />,
title: '批量混剪',
description: '多素材批量自动剪辑,智能筛选高清优质镜头片段',
action: '开始混剪',
path: '/removelens',
},
{
icon: <RobotOutlined style={{ fontSize: 24, color: '#10b981' }} />,
title: 'AI成片',
description: 'AI全自动快速出片,支持文案生成/上传参考素材制作',
action: '立即生成',
path: '/conversation',
},
];
const tabs = [
{ key: 'all', label: '全部' },
{ key: 'project', label: '项目媒体' },
{ key: 'chatAi', label: 'AI成片' },
{ key: 'hotOpeningReplicate', label: '爆款复刻' },
{ key: 'shotReplicate', label: '拆镜复刻' },
];
const filteredVideos = activeTab === 'all'
? mockVideos
: mockVideos.filter(v => v.type === activeTab);
const materialCases = [
'https://trae-api-cn.mchost.guru/api/ide/v1/text_to_image?prompt=modern%20city%20skyline%20night%20view&image_size=landscape_16_9',
'https://trae-api-cn.mchost.guru/api/ide/v1/text_to_image?prompt=nature%20forest%20landscape%20sunlight&image_size=landscape_16_9',
'https://trae-api-cn.mchost.guru/api/ide/v1/text_to_image?prompt=abstract%20technology%20background%20digital&image_size=landscape_16_9',
'https://trae-api-cn.mchost.guru/api/ide/v1/text_to_image?prompt=food%20cooking%20kitchen%20delicious&image_size=landscape_16_9',
'https://trae-api-cn.mchost.guru/api/ide/v1/text_to_image?prompt=fashion%20clothing%20style%20elegant&image_size=landscape_16_9',
];
return (
<div style={{
margin: '-24px -32px -32px',
borderRadius: 20,
height: 'calc(100vh - 34px)',
overflow: 'auto',
scrollbarWidth: 'none',
padding: '0 28px 32px',
}}>
{/* ========== 顶部工作台引导区域(三步流程) ========== */}
<div className="animate-fadeInUp" style={{
padding: '24px 28px',
borderRadius: 16,
background: 'linear-gradient(135deg, #f0f9ff 0%, #faf5ff 50%, #fef3c7 100%)',
border: '1px solid rgba(99,102,241,0.10)',
margin: '24px 0 20px',
position: 'relative',
overflow: 'hidden',
}}>
{/* 区域标题 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 18 }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{
width: 4, height: 18, borderRadius: 2,
background: 'linear-gradient(180deg, #6366f1, #a855f7)',
}} />
<div style={{ fontSize: 17, fontWeight: 700, color: '#1f2937', letterSpacing: 0.3 }}>
</div>
</div>
</div>
<div >
<span style={{
cursor: 'pointer',
fontSize: 12, color: '#1a50bbff', letterSpacing: 0.3,
}}
onClick={() => {
navigate('/authorization')
}}
>
<ArrowRightOutlined style={{ marginLeft: 8, transform: 'rotate(0deg)' }} />
</span>
</div>
</div>
<div style={{
display: 'flex',
alignItems: 'center',
gap: 12,
}}>
<div style={{
flex: 1,
background: '#fff',
borderRadius: 14,
padding: '20px 18px',
display: 'flex',
flexDirection: 'column',
boxShadow: '0 2px 8px rgba(15, 23, 42, 0.04)',
}}>
<div style={{ textAlign: 'center', fontSize: 20, fontWeight: 700, color: '#1e293b', marginBottom: 14, letterSpacing: 1 }}>
1
</div>
{/* 插图占位:项目卡(标题/描述输入框 + 行业分类 chip) */}
<div style={{
flex: 1,
minHeight: 140,
border: '1px dashed #c7d2fe',
borderRadius: 8,
background: '#fafbff',
padding: 10,
display: 'flex',
flexDirection: 'column',
gap: 8,
marginBottom: 12,
}}>
{/* 项目名称占位 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<div style={{ width: 8, height: 8, borderRadius: 2, background: '#6366f1' }} />
<div style={{ flex: 1, height: 22, background: '#fff', border: '1px solid #e2e8f0', borderRadius: 4, display: 'flex', alignItems: 'center', padding: '0 8px', fontSize: 10, color: '#94a3b8' }}>
...
</div>
</div>
{/* 行业分类 chip */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
<div style={{ padding: '2px 8px', background: '#eef2ff', color: '#6366f1', borderRadius: 10, fontSize: 10, fontWeight: 500, border: '1px solid #c7d2fe' }}></div>
<div style={{ padding: '2px 8px', background: '#fff7ed', color: '#f97316', borderRadius: 10, fontSize: 10, fontWeight: 500, border: '1px solid #fed7aa' }}></div>
<div style={{ padding: '2px 8px', background: '#ecfdf5', color: '#10b981', borderRadius: 10, fontSize: 10, fontWeight: 500, border: '1px solid #a7f3d0' }}>3C数码</div>
<div style={{ padding: '2px 8px', background: '#f5f3ff', color: '#8b5cf6', borderRadius: 10, fontSize: 10, fontWeight: 500, border: '1px solid #ddd6fe' }}></div>
</div>
{/* 描述占位行 */}
<div style={{ height: 16, background: '#fff', border: '1px solid #e2e8f0', borderRadius: 4 }} />
<div style={{ height: 16, width: '70%', background: '#fff', border: '1px solid #e2e8f0', borderRadius: 4 }} />
</div>
<div style={{
background: '#f1f5f9',
borderRadius: 10,
padding: '14px 12px',
textAlign: 'center',
fontSize: 13,
color: '#475569',
lineHeight: 1.6,
}}>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', color: '#94a3b8', fontSize: 22 }}>
<ArrowRightOutlined style={{ transform: 'rotate(0deg)' }} />
</div>
<div style={{
flex: 1,
background: '#fff',
borderRadius: 14,
padding: '20px 18px',
display: 'flex',
flexDirection: 'column',
boxShadow: '0 2px 8px rgba(15, 23, 42, 0.04)',
}}>
<div style={{ textAlign: 'center', fontSize: 20, fontWeight: 700, color: '#1e293b', marginBottom: 14, letterSpacing: 1 }}>
2
</div>
{/* 插图占位:图片/视频切换 + 尺寸/时长参数 */}
<div style={{
flex: 1,
minHeight: 140,
border: '1px solid #e2e8f0',
borderRadius: 8,
background: '#fafafa',
padding: 10,
display: 'flex',
flexDirection: 'column',
gap: 8,
marginBottom: 12,
}}>
{/* 图片 / 视频 切换 */}
<div style={{ display: 'flex', background: '#f1f5f9', borderRadius: 6, padding: 2, gap: 2 }}>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4, padding: '5px 0', background: '#fff', borderRadius: 4, fontSize: 11, fontWeight: 600, color: '#6366f1', boxShadow: '0 1px 3px rgba(99,102,241,0.15)' }}>
<VideoCameraOutlined style={{ fontSize: 11 }} />
</div>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4, padding: '5px 0', fontSize: 11, color: '#94a3b8' }}>
<PictureOutlined style={{ fontSize: 11 }} />
</div>
</div>
{/* 尺寸参数 */}
<div>
<div style={{ fontSize: 9, color: '#94a3b8', marginBottom: 3 }}></div>
<div style={{ display: 'flex', gap: 4 }}>
<div style={{ flex: 1, textAlign: 'center', padding: '4px 0', background: '#eef2ff', color: '#6366f1', border: '1px solid #c7d2fe', borderRadius: 4, fontSize: 10, fontWeight: 600 }}>9:16</div>
<div style={{ flex: 1, textAlign: 'center', padding: '4px 0', background: '#fff', color: '#64748b', border: '1px solid #e2e8f0', borderRadius: 4, fontSize: 10 }}>16:9</div>
<div style={{ flex: 1, textAlign: 'center', padding: '4px 0', background: '#fff', color: '#64748b', border: '1px solid #e2e8f0', borderRadius: 4, fontSize: 10 }}>1:1</div>
</div>
</div>
{/* 时长参数 */}
<div>
<div style={{ fontSize: 9, color: '#94a3b8', marginBottom: 3 }}></div>
<div style={{ display: 'flex', gap: 4 }}>
<div style={{ flex: 1, textAlign: 'center', padding: '4px 0', background: '#fff', color: '#64748b', border: '1px solid #e2e8f0', borderRadius: 4, fontSize: 10 }}>5s</div>
<div style={{ flex: 1, textAlign: 'center', padding: '4px 0', background: '#fff7ed', color: '#f97316', border: '1px solid #fed7aa', borderRadius: 4, fontSize: 10, fontWeight: 600 }}>10s</div>
<div style={{ flex: 1, textAlign: 'center', padding: '4px 0', background: '#fff', color: '#64748b', border: '1px solid #e2e8f0', borderRadius: 4, fontSize: 10 }}>15s</div>
</div>
</div>
</div>
<div style={{
background: '#f1f5f9',
borderRadius: 10,
padding: '14px 12px',
textAlign: 'center',
fontSize: 13,
color: '#475569',
lineHeight: 1.6,
}}>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', color: '#94a3b8', fontSize: 22 }}>
<ArrowRightOutlined />
</div>
<div style={{
flex: 1,
background: '#fff',
borderRadius: 14,
padding: '20px 18px',
display: 'flex',
flexDirection: 'column',
boxShadow: '0 2px 8px rgba(15, 23, 42, 0.04)',
}}>
<div style={{ textAlign: 'center', fontSize: 20, fontWeight: 700, color: '#1e293b', marginBottom: 14, letterSpacing: 1 }}>
3
</div>
{/* 插图占位:核对清单(✓ 项)+ 一键生成按钮 */}
<div style={{
flex: 1,
minHeight: 140,
border: '1px solid #e2e8f0',
borderRadius: 8,
background: '#fafafa',
padding: 10,
display: 'flex',
flexDirection: 'column',
gap: 6,
marginBottom: 12,
justifyContent: 'space-between',
}}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '5px 8px', background: '#f0fdf4', border: '1px solid #bbf7d0', borderRadius: 5 }}>
<div style={{ width: 14, height: 14, borderRadius: '50%', background: '#10b981', color: '#fff', fontSize: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 700 }}></div>
<div style={{ fontSize: 10, color: '#065f46', fontWeight: 500 }}></div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '5px 8px', background: '#f0fdf4', border: '1px solid #bbf7d0', borderRadius: 5 }}>
<div style={{ width: 14, height: 14, borderRadius: '50%', background: '#10b981', color: '#fff', fontSize: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 700 }}></div>
<div style={{ fontSize: 10, color: '#065f46', fontWeight: 500 }}> 9:16 · 5s</div>
</div>
{/* <div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '5px 8px', background: '#fff7ed', border: '1px solid #fed7aa', borderRadius: 5 }}>
<div style={{ width: 14, height: 14, borderRadius: '50%', background: '#f97316', color: '#fff', fontSize: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 700 }}>!</div>
<div style={{ fontSize: 10, color: '#9a3412', fontWeight: 500 }}>参考素材 0/3</div>
</div> */}
</div>
{/* 一键生成按钮 */}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
padding: '6px 0',
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
color: '#fff',
borderRadius: 5,
fontSize: 11,
fontWeight: 600,
boxShadow: '0 2px 6px rgba(99,102,241,0.25)',
}}>
<ThunderboltOutlined style={{ fontSize: 11 }} />
</div>
</div>
<div style={{
background: '#f1f5f9',
borderRadius: 10,
padding: '14px 12px',
textAlign: 'center',
fontSize: 13,
color: '#475569',
lineHeight: 1.6,
}}>
/
</div>
</div>
<div
onClick={() => navigate('/projects')}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px) scale(1.05)';
e.currentTarget.style.boxShadow = '0 14px 32px rgba(99,102,241,0.45)';
const arrow = e.currentTarget.querySelector('.round-arrow') as HTMLElement | null;
if (arrow) arrow.style.transform = 'translateX(3px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0) scale(1)';
e.currentTarget.style.boxShadow = '0 8px 20px rgba(99,102,241,0.30)';
const arrow = e.currentTarget.querySelector('.round-arrow') as HTMLElement | null;
if (arrow) arrow.style.transform = 'translateX(0)';
}}
style={{
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
width: 64,
height: 64,
borderRadius: '50%',
textAlign: 'center',
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #a855f7 100%)',
color: '#fff',
fontSize: 14,
fontWeight: 600,
letterSpacing: 0.5,
boxShadow: '0 8px 20px rgba(99,102,241,0.30)',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
userSelect: 'none',
position: 'relative',
}}
>
{/* 高光层(hover 时轻微变亮) */}
<span style={{
position: 'absolute',
inset: 0,
borderRadius: '50%',
background: 'radial-gradient(circle at 30% 25%, rgba(255,255,255,0.35) 0%, transparent 55%)',
pointerEvents: 'none',
}} />
<ArrowRightOutlined
className="round-arrow"
style={{ fontSize: 13, transition: 'transform 0.3s ease' }}
/>
</div>
</div>
</div>
{/* ========== AI 创作入口区域 ========== */}
<div className="animate-fadeInUp stagger-children" style={{
padding: '24px 28px',
borderRadius: 16,
background: '#fff',
border: '1px solid #e2e8f0',
marginBottom: 20,
}}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: 8,
marginBottom: 18,
}}>
<div style={{
width: 4, height: 18, borderRadius: 2,
background: 'linear-gradient(180deg, #6366f1, #a855f7)',
}} />
<div style={{ fontSize: 17, fontWeight: 700, color: '#1f2937', letterSpacing: 0.3 }}>
AI
</div>
<div style={{ fontSize: 12, color: '#94a3b8', marginLeft: 8 }}>
</div>
</div>
<div style={{ display: 'flex', gap: 16 }}>
{aiEntries.map((entry, index) => {
// 三个入口用三种不同色调的渐变光晕作为视觉区分,但都保持白底卡片
const accentMap = [
{ color: '#6366f1', light: 'rgba(99,102,241,0.10)', tag: '复刻', bg: hot },
{ color: '#f97316', light: 'rgba(249,115,22,0.10)', tag: '混剪', bg: mashup },
{ color: '#10b981', light: 'rgba(16,185,129,0.10)', tag: '云创', bg: aicreate },
];
const accent = accentMap[index] || accentMap[0];
return (
<div
key={index}
onClick={() => navigate(entry.path)}
className="project-card"
style={{
flex: 1,
padding: '20px 22px',
borderRadius: 14,
background: '#fff',
border: '1px solid #e2e8f0',
cursor: 'pointer',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
position: 'relative',
overflow: 'hidden',
backgroundImage: `url(${accent.bg})`,
backgroundRepeat: 'no-repeat',
backgroundSize: '100% 100%',
backgroundPosition: 'center',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = accent.color;
e.currentTarget.style.boxShadow = `0 12px 32px ${accent.light}`;
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = '#e2e8f0';
e.currentTarget.style.boxShadow = 'none';
}}
>
{/* 顶部装饰光带 */}
<div style={{
position: 'absolute',
top: 0, left: 0, right: 0, height: 3,
background: `linear-gradient(90deg, ${accent.color}, ${accent.color}88)`,
}} />
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<div style={{
width: 48, height: 48,
borderRadius: 12,
background: accent.light,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<span style={{ color: accent.color, fontSize: 22, display: 'flex' }}>{entry.icon}</span>
</div>
<div style={{
fontSize: 11, color: accent.color,
padding: '2px 8px', borderRadius: 6,
background: accent.light, fontWeight: 600,
}}>{accent.tag}</div>
</div>
<div style={{ fontSize: 16, fontWeight: 700, color: '#1f2937', marginBottom: 4 }}>
{entry.title}
</div>
<div style={{ fontSize: 12, color: '#6b7280', marginBottom: 14, lineHeight: 1.5, minHeight: 36 }}>
{entry.description}
</div>
<div style={{
display: 'flex',
alignItems: 'center',
gap: 4,
color: accent.color,
fontSize: 13,
fontWeight: 600,
}}>
{entry.action}
<ArrowRightOutlined style={{ fontSize: 12 }} />
</div>
</div>
);
})}
</div>
</div>
{/* ========== 近期作品区域 ========== */}
<div className="animate-fadeInUp" style={{
padding: '24px 28px',
borderRadius: 16,
background: '#fff',
border: '1px solid #e2e8f0',
marginBottom: 20,
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{
width: 4, height: 18, borderRadius: 2,
background: 'linear-gradient(180deg, #6366f1, #a855f7)',
}} />
<div style={{ fontSize: 17, fontWeight: 700, color: '#1f2937', letterSpacing: 0.3 }}>
</div>
</div>
</div>
{/* Tab切换 */}
<div style={{ marginBottom: 20 }}>
<Tabs
activeKey={activeTab}
onChange={handleTabChange}
items={tabs.map(tab => ({
key: tab.key,
label: tab.label,
}))}
className="homepage-tabs"
/>
</div>
{/* 视频网格 */}
<div className="stagger-children" style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 16 }}>
{filteredVideos.length === 0 ? (
<div style={{
gridColumn: '1 / -1',
padding: '60px 0',
textAlign: 'center',
color: '#94a3b8',
fontSize: 14,
}}>
<PictureOutlined style={{ fontSize: 36, color: '#cbd5e1', marginBottom: 8 }} />
<div></div>
</div>
) : filteredVideos.map((video) => (
<div
key={video.id || `${video.type}-${Math.random()}`}
className="project-card"
onClick={() => {
// 按模块分发跳转:
// - 爆款复刻(hot)→ 复刻详情页
// - AI 成片(ai)→ 对话/生成页
// - 项目记录(project)→ 项目详情页
const id = video.moduleProjectId;
if (video.type === 'hotOpeningReplicate' && id != null) {
navigate(`/initial/${id}/initialinfo`);
} else if (video.type === 'shotReplicate' && id != null) {
navigate(`/removelens/${id}/removefenbu`);
} else if (video.type === 'chatAi') {
navigate(`/conversation`);
} else if (video.type === 'project') {
navigate(`/project`);
}
}}
style={{
borderRadius: 12,
overflow: 'hidden',
cursor: 'pointer',
background: '#fff',
border: '1px solid #e2e8f0',
}}
>
<div style={{
position: 'relative',
aspectRatio: '16/9',
// background: '#1a1a2e',
}}>
{(() => {
// 1) 读取后端 API 基础地址;环境变量未配置时降级到本地 8000
const apiBase = (import.meta.env.VITE_API_BASE as string) || 'http://localhost:8000';
// 2) 判断当前作品是否为"图片":
// - 爆款复刻(type === 'hot')始终是视频,不参与图片判断
// - 其他模块(项目记录 / AI 成片)根据 genType 判定
// - genType 可能是字符串 'image',也可能是数字 1(兼容两种后端约定)
const isImage = video.type !== 'hotOpeningReplicate'
&& (
String(video.resourceType ?? '').toLowerCase() === 'image'
|| video.resourceType === 1
|| String(video.resourceType ?? '') === '1'
);
// 3) 根据媒体类型选择对应的资源路径:
// - 图片:后端返回的 imageUrl 已经是带签名的完整相对路径
// 形如 /static/generate/images/2026/06/26/0019f017f5924df4123.png?exp=...&sign=...&w=300&p=50
// - 视频:使用视频封面 videoCoverUrl(这是视频作品的静态缩略图)
// - 爆款复刻(type === 'hot')特殊处理:使用 finalVideoCoverUrl
let rawPath = '';
if (isImage) {
rawPath = '/static' + video.resultUrl + '&w=300&p=50' || '';
} else if (video.type === 'hot') {
rawPath = video.coverUrl || video.resultUrl || video.resultUrl || '';
} else {
rawPath = video.coverUrl || video.resultUrl || video.resultUrl || '';
}
// 4) 拼装最终 src
// - rawPath 为空 → 用空串(让 <img> 走 onError 兜底)
// - 已经是 http(s) 完整 URL → 直接使用(OSS / CDN 场景)
// - 否则视为后端相对路径,前面拼 apiBase
const src = rawPath
? (rawPath.startsWith('http') ? rawPath : apiBase + rawPath)
: '';
return (
<img
src={src}
alt={video.title || video.name || '作品'}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
);
})()}
{(() => {
const isImage = String(video.resourceType ?? '').toLowerCase() === 'image';
if (isImage) return null;
return (
<div style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(0,0,0,0.2)',
}}>
<VideoCameraOutlined style={{ fontSize: 28, color: '#fff' }} />
</div>
);
})()}
</div>
<div style={{
padding: '10px 12px',
background: '#f8fafc',
}}>
{/* <div style={{
fontSize: 13,
color: '#334155',
fontWeight: 500,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}>
{video.title || video.name || video.originalPrompt || video.prompt || '未命名作品'}
</div> */}
<div style={{
marginTop: 6,
display: 'flex',
alignItems: 'center',
gap: 6,
fontSize: 12,
color: '#64748b',
}}>
{(() => {
// 显示所属模块,而非媒体类型
const moduleMap: Record<string, string> = {
project: '项目媒体',
chatAi: 'AI成片',
hotOpeningReplicate: '爆款复刻',
shotReplicate: '拆镜复刻',
};
const moduleLabel = moduleMap[video.type] || '其他';
return (
<span style={{
display: 'inline-block',
padding: '1px 6px',
border: '1px solid #3b82f6',
borderRadius: 4,
color: '#3b82f6',
fontSize: 11,
fontWeight: 500,
background: '#fff',
lineHeight: 1.4,
whiteSpace: 'nowrap',
}}>
{moduleLabel}
</span>
);
})()}
<span style={{ color: '#94a3b8' }}>·</span>
<span style={{ whiteSpace: 'nowrap' }}>{formatShortDate(video.generatedTime)}</span>
</div>
</div>
</div>
))}
</div>
</div>
{/* ========== 素材案例区域 ========== */}
<div className="animate-fadeInUp" style={{
padding: '24px 28px',
borderRadius: 16,
background: '#fff',
border: '1px solid #e2e8f0',
}}>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 18,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{
width: 4, height: 18, borderRadius: 2,
background: 'linear-gradient(180deg, #6366f1, #a855f7)',
}} />
<div style={{ fontSize: 17, fontWeight: 700, color: '#1f2937', letterSpacing: 0.3 }}>
</div>
<div style={{ fontSize: 12, color: '#94a3b8', marginLeft: 4 }}>
</div>
</div>
<div style={{
fontSize: 13, color: '#6366f1', cursor: 'pointer', fontWeight: 500,
display: 'flex', alignItems: 'center', gap: 2,
}}>
<ArrowRightOutlined style={{ fontSize: 11 }} />
</div>
</div>
<div className="stagger-children" style={{ display: 'flex', gap: 14 }}>
{materialCases.map((caseUrl, index) => (
<div
key={index}
className="project-card"
style={{
flex: 1,
aspectRatio: '16/9',
borderRadius: 12,
overflow: 'hidden',
cursor: 'pointer',
background: '#f1f5f9',
position: 'relative',
}}
onMouseEnter={(e) => {
const overlay = e.currentTarget.querySelector('.case-overlay') as HTMLElement | null;
if (overlay) overlay.style.opacity = '1';
}}
onMouseLeave={(e) => {
const overlay = e.currentTarget.querySelector('.case-overlay') as HTMLElement | null;
if (overlay) overlay.style.opacity = '0';
}}
>
<img
src={caseUrl}
alt={`素材案例 ${index + 1}`}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
{/* 案例悬停遮罩(由父级 hover 触发) */}
<div
className="case-overlay"
style={{
position: 'absolute',
inset: 0,
background: 'linear-gradient(180deg, transparent 40%, rgba(99,102,241,0.75) 100%)',
opacity: 0,
transition: 'opacity 0.3s',
display: 'flex',
alignItems: 'flex-end',
justifyContent: 'center',
padding: 14,
color: '#fff',
fontSize: 13,
fontWeight: 600,
letterSpacing: 0.5,
}}
>
{index + 1}
</div>
</div>
))}
</div>
</div>
</div>
);
};
export default HomePage;
+377
View File
@@ -0,0 +1,377 @@
.login-page {
min-height: 100vh;
display: flex;
flex-direction: column;
background-image: url(/backimage.png);
background-size: cover;
background-position: center;
background-repeat: no-repeat;
background-attachment: fixed;
position: relative;
overflow-x: hidden;
}
@media (min-width: 900px) {
.login-page {
flex-direction: row;
}
}
.login-bg-overlay {
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(240,244,255,0.9) 0%, rgba(232,236,248,0.85) 40%, rgba(240,240,255,0.9) 70%, rgba(248,249,255,0.95) 100%);
z-index: 0;
}
.login-decoration {
position: absolute;
border-radius: 50%;
filter: blur(40px);
z-index: 0;
}
.login-decoration-1 {
width: 500px;
height: 500px;
background: radial-gradient(circle, rgba(99,102,241,0.08) 0%, transparent 70%);
top: -150px;
right: -100px;
}
.login-decoration-2 {
width: 400px;
height: 400px;
background: radial-gradient(circle, rgba(139,92,246,0.06) 0%, transparent 70%);
bottom: -100px;
left: -80px;
filter: blur(50px);
}
.login-left-section {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
padding: 32px 16px;
z-index: 1;
}
@media (min-width: 900px) {
.login-left-section {
padding: 0 80px;
}
}
.login-left-content {
width: 100%;
}
.login-logo-row {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 20px;
}
@media (max-width: 480px) {
.login-logo-row {
justify-content: center;
margin-bottom: 16px;
}
}
.login-logo-img {
width: 52px;
height: 52px;
border-radius: 14px;
objectFit: contain;
}
.login-logo-placeholder {
width: 52px;
height: 52px;
border-radius: 14px;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8px 24px rgba(99,102,241,0.25);
}
.login-site-name {
color: #1e293b;
font-size: 28px;
font-weight: 800;
letter-spacing: -0.5px;
}
@media (max-width: 480px) {
.login-site-name {
font-size: 24px;
}
}
.login-desc {
color: #64748b !important;
font-size: 17px !important;
max-width: 480px;
line-height: 1.8 !important;
margin: 0 !important;
}
@media (max-width: 899px) {
.login-desc {
display: none !important;
}
}
.login-features-list {
display: none;
flex-direction: column;
gap: 20px;
}
@media (min-width: 900px) {
.login-features-list {
display: flex;
}
}
.login-feature-card {
position: relative;
display: flex;
gap: 16px;
align-items: flex-start;
padding: 18px 22px;
border-radius: 14px;
background: rgba(255,255,255,0.85);
backdrop-filter: blur(12px);
overflow: hidden;
}
.login-feature-icon {
width: 44px;
height: 44px;
border-radius: 12px;
flex-shrink: 0;
background: linear-gradient(135deg, rgba(99,102,241,0.15), rgba(139,92,246,0.15));
display: flex;
align-items: center;
justify-content: center;
color: #6366f1;
font-size: 20px;
}
.login-feature-title {
color: #1e293b !important;
font-size: 15px !important;
font-weight: 600 !important;
display: block !important;
margin-bottom: 4px !important;
}
.login-feature-desc {
color: #64748b !important;
font-size: 13px !important;
line-height: 1.6 !important;
}
.login-right-section {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
padding: 16px 16px 32px;
}
@media (min-width: 900px) {
.login-right-section {
padding: 40px 24px;
}
}
.login-card {
width: 100%;
max-width: 440px;
border-radius: 20px !important;
box-shadow: 0 20px 60px rgba(0,0,0,0.08) !important;
border: 1px solid #e2e8f0 !important;
background: #fff !important;
}
@media (max-width: 480px) {
.login-card {
border-radius: 16px !important;
}
.login-card .ant-card-body {
padding: 24px 20px !important;
}
}
.login-card-title {
text-align: center !important;
margin-bottom: 6px !important;
color: #1e293b !important;
font-weight: 700 !important;
}
.login-card-subtitle {
display: block;
text-align: center;
margin-bottom: 28px;
color: #94a3b8;
font-size: 14px;
}
.login-tabs {
display: flex;
gap: 0;
margin-bottom: 24px;
background: #f1f5f9;
border-radius: 10px;
padding: 4px;
border: 1px solid #e2e8f0;
}
.login-tab {
flex: 1;
text-align: center;
padding: 10px 0;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 400;
color: #64748b;
background: transparent;
border: 1px solid transparent;
transition: all 0.3s ease;
}
.login-tab-active {
font-weight: 600 !important;
color: #6366f1 !important;
background: #fff !important;
border: 1px solid rgba(99,102,241,0.2) !important;
box-shadow: 0 2px 8px rgba(99,102,241,0.1) !important;
}
.login-submit-btn {
height: 48px !important;
border-radius: 10px !important;
font-size: 16px !important;
font-weight: 600 !important;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%) !important;
border: none !important;
box-shadow: 0 8px 24px rgba(99,102,241,0.25) !important;
}
.login-code-btn {
height: 48px !important;
border-radius: 0 10px 10px 0 !important;
border: 1.5px solid #e2e8f0 !important;
border-left: none !important;
font-weight: 600 !important;
min-width: 100px !important;
}
.login-agreement {
margin-bottom: 16px;
}
.login-agreement-text {
font-size: 13px;
color: #64748b;
}
.login-link {
color: #6366f1;
cursor: pointer;
}
.login-footer {
display: flex;
justify-content: center;
}
@media (min-width: 480px) {
.login-footer {
justify-content: flex-start;
}
}
.login-switch-btn {
font-size: 13px !important;
color: #6366f1 !important;
cursor: pointer;
}
.login-page .ant-input-affix-wrapper {
padding: 0 11px !important;
height: 48px !important;
}
.login-page .ant-input-affix-wrapper .ant-input-prefix {
margin-right: 10px !important;
margin-left: 0 !important;
}
.login-page .ant-input {
padding-left: 11px !important;
height: 48px !important;
}
.login-code-btn {
height: 48px !important;
}
@media (max-width: 767px) {
.login-left-section {
padding: 28px 16px 12px;
}
.login-right-section {
padding: 12px 16px 32px;
}
.login-card {
max-width: 100%;
}
}
@media (max-width: 480px) {
.login-left-section {
padding: 24px 12px 8px;
text-align: center;
}
.login-right-section {
padding: 8px 12px 24px;
}
.shiny-text-container {
text-align: center;
}
.login-page .ant-input,
.login-page .ant-input-affix-wrapper {
height: 44px !important;
font-size: 14px !important;
}
.login-page .ant-input-affix-wrapper .ant-input {
height: 100% !important;
}
.login-code-btn {
height: 44px !important;
min-width: 90px !important;
font-size: 13px !important;
}
.login-submit-btn {
height: 44px !important;
font-size: 15px !important;
}
}
+80 -177
View File
@@ -8,6 +8,7 @@ import {
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/useAuthStore';
import { sendSms,phonelogin, getSiteInfo, register } from '../api';
import './LoginPage.css';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000/api';
@@ -73,16 +74,14 @@ const LoginPage: React.FC = () => {
if (!checkAgreed()) return;
try {
const values = await pwdForm.validateFields();
// setLoading(true);
await login(values.phone, values.password, undefined, values.rememberMe);
message.success('登录成功,欢迎回来');
await checkAuth();
navigate('/projects');
navigate('/home');
} catch (error: any) {
const errorMsg = error?.response?.data?.detail || error?.response?.data?.message || error?.message || '登录失败';
message.error(errorMsg);
} finally {
// setLoading(false);
}
};
@@ -98,7 +97,7 @@ const LoginPage: React.FC = () => {
await phonelogin(values.phone, values.code);
message.success('登录成功,欢迎回来');
await checkAuth();
navigate('/projects');
navigate('/home');
} catch (error: any) {
const errorMsg = error?.response?.data?.detail || error?.response?.data?.message || error?.message || '登录失败';
message.error(errorMsg);
@@ -115,7 +114,7 @@ const LoginPage: React.FC = () => {
const user = await register(values.phone, values.regCode, values.password);
message.success('注册成功');
await checkAuth();
navigate('/projects');
navigate('/home');
} catch (error: any) {
const errorMsg = error?.response?.data?.detail || error?.response?.data?.message || error?.message || '注册失败';
message.error(errorMsg);
@@ -131,7 +130,6 @@ const LoginPage: React.FC = () => {
if (c <= 1) {
clearInterval(timer);
setShowSliderVerify(false);
// 倒计时结束后,设置重新发送状态
if (isReg) {
setShowResend(true);
} else {
@@ -144,7 +142,6 @@ const LoginPage: React.FC = () => {
}, 1000);
};
// 登录模式倒计时结束后重置验证状态
useEffect(() => {
if (countdown === 0 && mode === 'phone') {
setLoginSliderVerified(false);
@@ -158,14 +155,12 @@ const LoginPage: React.FC = () => {
return;
}
// 注册时需要滑动验证
if (isReg) {
setShowSliderVerify(true);
setSliderVerified(false);
return;
}
// 登录时也需要滑动验证
if (!loginSliderVerified) {
setShowSliderVerify(true);
setLoginSliderVerified(false);
@@ -185,7 +180,6 @@ const LoginPage: React.FC = () => {
} catch (error: any) {
const errorMsg = error?.response?.data?.detail || error?.response?.data?.message || error?.message || '登录失败';
message.error(errorMsg);
// 发送失败,重置验证状态、倒计时和滑块组件
if (mode === 'register') {
setSliderVerified(false);
setRegCountdown(0);
@@ -193,18 +187,13 @@ const LoginPage: React.FC = () => {
setLoginSliderVerified(false);
setCountdown(0);
}
// 通过更新 key 强制刷新滑块组件
setSliderKey(prev => prev + 1);
}
};
// 滑动验证成功后的回调
const handleSliderSuccess = async (isResend = false) => {
// 根据当前模式获取手机号
const phone = mode === 'register' ? regForm.getFieldValue('phone') : phoneForm.getFieldValue('phone');
try {
let mode2 = '';
if (mode === 'register') {
mode2 = 'register';
@@ -213,7 +202,6 @@ const LoginPage: React.FC = () => {
}
await sendSms(phone,mode2);
// 设置对应的验证状态
if (mode === 'register') {
setSliderVerified(true);
startCountdown(setRegCountdown, true);
@@ -225,7 +213,6 @@ const LoginPage: React.FC = () => {
} catch (error: any) {
const errorMsg = error?.response?.data?.detail || error?.response?.data?.message || error?.message || '登录失败';
message.error(errorMsg);
// 发送失败,重置验证状态、倒计时和滑块组件
if (mode === 'register') {
setSliderVerified(false);
setRegCountdown(0);
@@ -235,7 +222,6 @@ const LoginPage: React.FC = () => {
setCountdown(0);
setLoginShowResend(true);
}
// 通过更新 key 强制刷新滑块组件
setSliderKey(prev => prev + 1);
}
};
@@ -250,9 +236,7 @@ const LoginPage: React.FC = () => {
setShowSliderVerify(false);
setShowResend(false);
setLoginShowResend(false);
// 刷新滑动验证组件
setSliderKey(prev => prev + 1);
// 清空当前模式相关的表单
if (t === 'password') {
pwdForm.resetFields();
} else {
@@ -270,7 +254,6 @@ const LoginPage: React.FC = () => {
background: '#fff',
border: '1.5px solid #e2e8f0',
color: '#1e293b',
height: 48,
borderRadius: 10,
fontSize: 14,
};
@@ -280,93 +263,45 @@ const LoginPage: React.FC = () => {
};
return (
<div style={{
minHeight: '100vh',
display: 'flex',
backgroundImage: `url(/backimage.png)`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
backgroundAttachment: 'fixed',
position: 'relative',
overflow: 'hidden',
}}>
<div style={{
position: 'absolute',
inset: 0,
background: 'linear-gradient(135deg, rgba(240,244,255,0.9) 0%, rgba(232,236,248,0.85) 40%, rgba(240,240,255,0.9) 70%, rgba(248,249,255,0.95) 100%)',
}} />
<div style={{
position: 'absolute',
width: 500, height: 500, borderRadius: '50%',
background: 'radial-gradient(circle, rgba(99,102,241,0.08) 0%, transparent 70%)',
top: -150, right: -100, filter: 'blur(40px)',
}} />
<div style={{
position: 'absolute',
width: 400, height: 400, borderRadius: '50%',
background: 'radial-gradient(circle, rgba(139,92,246,0.06) 0%, transparent 70%)',
bottom: -100, left: -80, filter: 'blur(50px)',
}} />
<div className="login-page">
<div className="login-bg-overlay" />
<div className="login-decoration login-decoration-1" />
<div className="login-decoration login-decoration-2" />
{/* Left side - features */}
<div style={{
flex: 1, display: 'flex', flexDirection: 'column',
justifyContent: 'center', padding: '0 80px', zIndex: 1,
}}>
<Space direction="vertical" size={36}>
<div className="login-left-section">
<Space direction="vertical" size={36} className="login-left-content">
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 20 }}>
<div className="login-logo-row">
{siteLogo ? (
<img src={siteLogo} alt="logo" style={{ width: 52, height: 52, borderRadius: 14, objectFit: 'contain' }} />
<img src={siteLogo} alt="logo" className="login-logo-img" />
) : (
<div style={{
width: 52, height: 52, borderRadius: 14,
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 8px 24px rgba(99,102,241,0.25)',
}}>
<div className="login-logo-placeholder">
<ThunderboltOutlined style={{ fontSize: 26, color: '#fff' }} />
</div>
)}
<span style={{ color: '#1e293b', fontSize: 28, fontWeight: 800, letterSpacing: -0.5 }}>
{siteName}
</span>
<span className="login-site-name">{siteName}</span>
</div>
<div className="shiny-text-container">
<span className="shiny-text">AI赋能创意</span>
</div>
<Typography.Paragraph style={{ color: '#64748b', fontSize: 17, maxWidth: 480, lineHeight: 1.8 }}>
<Typography.Paragraph className="login-desc">
AI <br />
</Typography.Paragraph>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
<div className="login-features-list">
{features.map((f, i) => (
<div
key={i}
className="electric-border-card"
style={{
position: 'relative',
display: 'flex', gap: 16, alignItems: 'flex-start',
padding: '18px 22px', borderRadius: 14,
background: 'rgba(255,255,255,0.85)',
backdropFilter: 'blur(12px)',
overflow: 'hidden',
}}
className="electric-border-card login-feature-card"
>
<div className="electric-border" />
<div className="electric-border-inner" />
<div style={{ position: 'relative', zIndex: 1 }}>
<div style={{
width: 44, height: 44, borderRadius: 12, flexShrink: 0,
background: 'linear-gradient(135deg, rgba(99,102,241,0.15), rgba(139,92,246,0.15))',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#6366f1', fontSize: 20,
}}>{f.icon}</div>
<div className="login-feature-icon">{f.icon}</div>
</div>
<div style={{ position: 'relative', zIndex: 1 }}>
<Typography.Text style={{ color: '#1e293b', fontSize: 15, fontWeight: 600, display: 'block', marginBottom: 4 }}>{f.title}</Typography.Text>
<Typography.Text style={{ color: '#64748b', fontSize: 13, lineHeight: 1.6 }}>{f.desc}</Typography.Text>
<Typography.Text className="login-feature-title">{f.title}</Typography.Text>
<Typography.Text className="login-feature-desc">{f.desc}</Typography.Text>
</div>
</div>
))}
@@ -374,48 +309,26 @@ const LoginPage: React.FC = () => {
</Space>
</div>
{/* Right side - login/register form */}
<div style={{
flex: 1, display: 'flex', alignItems: 'center',
justifyContent: 'center', zIndex: 1, padding: '40px 24px',
}}>
<Card style={{
width: 440, maxWidth: '100%', borderRadius: 20,
boxShadow: '0 20px 60px rgba(0,0,0,0.08)',
border: '1px solid #e2e8f0', background: '#fff',
}} styles={{ body: { padding: '36px 28px' } }}>
<Typography.Title level={3} style={{ textAlign: 'center', marginBottom: 6, color: '#1e293b', fontWeight: 700 }}>
<div className="login-right-section">
<Card className="login-card" styles={{ body: { padding: '36px 28px' } }}>
<Typography.Title level={3} className="login-card-title">
{mode === 'register' ? '创建账号' : '欢迎回来'}
</Typography.Title>
<Typography.Text style={{ display: 'block', textAlign: 'center', marginBottom: 28, color: '#94a3b8', fontSize: 14 }}>
<Typography.Text className="login-card-subtitle">
{mode === 'register' ? '注册新账号,开始创作视频' : '登录您的账号,开始创作视频'}
</Typography.Text>
{/* Tab switcher - only for login modes */}
{mode !== 'register' && (
<div style={{
display: 'flex', gap: 0, marginBottom: 24,
background: '#f1f5f9', borderRadius: 10, padding: 4,
border: '1px solid #e2e8f0',
}}>
<div className="login-tabs">
{(['password', 'phone'] as const).map((t) => (
<div key={t} onClick={() => switchTab(t)} style={{
flex: 1, textAlign: 'center', padding: '10px 0',
borderRadius: 8, cursor: 'pointer', fontSize: 14,
fontWeight: tab === t ? 600 : 400,
color: tab === t ? '#6366f1' : '#64748b',
background: tab === t ? '#fff' : 'transparent',
border: tab === t ? '1px solid rgba(99,102,241,0.2)' : '1px solid transparent',
boxShadow: tab === t ? '0 2px 8px rgba(99,102,241,0.1)' : 'none',
transition: 'all 0.3s ease',
}}>
<div key={t} onClick={() => switchTab(t)}
className={`login-tab ${tab === t ? 'login-tab-active' : ''}`}>
{t === 'password' ? '密码登录' : '验证码登录'}
</div>
))}
</div>
)}
{/* Password Login */}
{mode === 'password' && (
<Form form={pwdForm} size="large" layout="vertical" onFinish={handlePasswordLogin}>
<Form.Item name="phone" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}>
@@ -428,16 +341,11 @@ const LoginPage: React.FC = () => {
<Checkbox></Checkbox>
</Form.Item>
<Form.Item style={{ marginBottom: 12 }}>
<Button type="primary" htmlType="submit" loading={loading} block style={{
height: 48, borderRadius: 10, fontSize: 16, fontWeight: 600,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
border: 'none', boxShadow: '0 8px 24px rgba(99,102,241,0.25)',
}}> </Button>
<Button type="primary" htmlType="submit" loading={loading} block className="login-submit-btn"> </Button>
</Form.Item>
</Form>
)}
{/* Phone Login */}
{mode === 'phone' && (
<Form form={phoneForm} size="large" layout="vertical">
<Form.Item name="phone" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}>
@@ -454,27 +362,18 @@ const LoginPage: React.FC = () => {
<Button disabled={countdown > 0}
onClick={() => {
if (loginShowResend) {
// 重新发送时,只刷新滑块,不启动倒计时
setLoginSliderVerified(false);
setShowSliderVerify(true);
setSliderKey(prev => prev + 1);
} else {
// 首次点击,调用发送验证码,等待滑块验证
handleSendCode(phoneForm.getFieldValue('phone'));
}
}}
style={{
height: 48, borderRadius: '0 10px 10px 0',
background: countdown > 0 ? '#f1f5f9' : (loginSliderVerified ? '#f1f5f9' : 'rgba(99,102,241,0.1)'),
border: '1.5px solid #e2e8f0', borderLeft: 'none',
color: countdown > 0 ? '#94a3b8' : '#6366f1',
fontWeight: 600, minWidth: 100,
}}>
className="login-code-btn">
{countdown > 0 ? `${countdown}s` : (loginShowResend ? '重新发送' : '获取验证码')}
</Button>
</Space.Compact>
</Form.Item>
{/* 滑动验证 - 获取验证码后显示 */}
{showSliderVerify && (
<Form.Item style={{ marginBottom: 12 }} key={sliderKey}>
<SliderVerify
@@ -485,16 +384,11 @@ const LoginPage: React.FC = () => {
)}
<Form.Item style={{ marginBottom: 12 }}>
<Button type="primary" onClick={handlePhoneLogin} loading={loading} block style={{
height: 48, borderRadius: 10, fontSize: 16, fontWeight: 600,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
border: 'none', boxShadow: '0 8px 24px rgba(99,102,241,0.25)',
}}> </Button>
<Button type="primary" onClick={handlePhoneLogin} loading={loading} block className="login-submit-btn"> </Button>
</Form.Item>
</Form>
)}
{/* Register */}
{mode === 'register' && (
<Form form={regForm} size="large" layout="vertical">
<Form.Item name="phone" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}>
@@ -511,27 +405,18 @@ const LoginPage: React.FC = () => {
<Button disabled={regCountdown > 0}
onClick={() => {
if (showResend) {
// 重新发送时,只刷新滑块,不启动倒计时
setSliderVerified(false);
setShowSliderVerify(true);
setSliderKey(prev => prev + 1);
} else {
// 首次点击,调用发送验证码,等待滑块验证
handleSendCode(regForm.getFieldValue('phone'), true);
}
}}
style={{
height: 48, borderRadius: '0 10px 10px 0',
background: regCountdown > 0 ? '#f1f5f9' : (sliderVerified ? '#f1f5f9' : 'rgba(99,102,241,0.1)'),
border: '1.5px solid #e2e8f0', borderLeft: 'none',
color: regCountdown > 0 ? '#94a3b8' : '#6366f1',
fontWeight: 600, minWidth: 100,
}}>
className="login-code-btn">
{regCountdown > 0 ? `${regCountdown}s` : (showResend ? '重新发送' : '获取验证码')}
</Button>
</Space.Compact>
</Form.Item>
{/* 滑动验证 - 获取验证码后显示 */}
{showSliderVerify && (
<Form.Item style={{ marginBottom: 12 }} key={sliderKey}>
<SliderVerify
@@ -544,41 +429,33 @@ const LoginPage: React.FC = () => {
<Input.Password prefix={<LockOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} placeholder="请设置密码(至少6位)" style={inputStyle} />
</Form.Item>
<Form.Item style={{ marginBottom: 12 }}>
<Button type="primary" onClick={handleRegister} loading={loading} block style={{
height: 48, borderRadius: 10, fontSize: 16, fontWeight: 600,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
border: 'none', boxShadow: '0 8px 24px rgba(99,102,241,0.25)',
}}> </Button>
<Button type="primary" onClick={handleRegister} loading={loading} block className="login-submit-btn"> </Button>
</Form.Item>
</Form>
)}
{/* Agreement checkbox */}
<div style={{ marginBottom: 16 }}>
<div className="login-agreement">
<Checkbox checked={agreed} onChange={e => setAgreed(e.target.checked)}>
<span style={{ fontSize: 13, color: '#64748b' }}>
<span className="login-agreement-text">
<span
onClick={e => { e.stopPropagation(); openPdf(agreementUrl); }}
style={{ color: '#6366f1', cursor: 'pointer' }}
className="login-link"
></span>
<span
onClick={e => { e.stopPropagation(); openPdf(policyUrl); }}
style={{ color: '#6366f1', cursor: 'pointer' }}
className="login-link"
></span>
</span>
</Checkbox>
</div>
{/* Bottom left: switch between login and register */}
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<div className="login-footer">
{mode === 'register' ? (
<Typography.Text
style={{ fontSize: 13, color: '#6366f1', cursor: 'pointer' }}
className="login-switch-btn"
onClick={() => {
setMode('password');
setTab('password');
@@ -594,7 +471,7 @@ const LoginPage: React.FC = () => {
</Typography.Text>
) : (
<Typography.Text
style={{ fontSize: 13, color: '#6366f1', cursor: 'pointer' }}
className="login-switch-btn"
onClick={() => {
setMode('register');
setShowResend(false);
@@ -614,7 +491,6 @@ const LoginPage: React.FC = () => {
);
};
// 滑动验证组件
const SliderVerify: React.FC<{
onSuccess: () => void;
isVerified: boolean;
@@ -623,13 +499,12 @@ const SliderVerify: React.FC<{
const sliderRef = React.useRef<HTMLDivElement>(null);
const trackRef = React.useRef<HTMLDivElement>(null);
const positionRef = React.useRef(0);
const successRef = React.useRef(false); // 防止重复触发成功回调
const successRef = React.useRef(false);
const [containerWidth, setContainerWidth] = React.useState(360); // 容器宽度,自适应
const sliderWidth = 50; // 滑块宽度
const [containerWidth, setContainerWidth] = React.useState(360);
const sliderWidth = 50;
const maxPosition = containerWidth - sliderWidth;
// 监听容器尺寸变化,使其自适应父容器宽度
React.useEffect(() => {
const updateWidth = () => {
if (containerRef.current) {
@@ -656,13 +531,11 @@ const SliderVerify: React.FC<{
}, []);
const updatePosition = (x: number) => {
// 如果已经成功,不再处理
if (successRef.current || isVerified) return;
const newPosition = Math.max(0, Math.min(x, maxPosition));
positionRef.current = newPosition;
// 直接操作DOM,避免React状态更新的开销
if (sliderRef.current) {
sliderRef.current.style.left = `${newPosition}px`;
}
@@ -670,12 +543,9 @@ const SliderVerify: React.FC<{
trackRef.current.style.width = `${newPosition + sliderWidth}px`;
}
// 验证成功
if (newPosition >= maxPosition - 5) {
// 设置成功标志,防止重复触发
successRef.current = true;
// 设置验证成功状态
if (sliderRef.current) {
sliderRef.current.style.background = '#22c55e';
sliderRef.current.innerHTML = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>`;
@@ -685,7 +555,6 @@ const SliderVerify: React.FC<{
trackRef.current.style.background = '#dcfce7';
}
// 调用成功回调
onSuccess();
}
};
@@ -708,7 +577,6 @@ const SliderVerify: React.FC<{
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
// 如果没有滑到终点,重置位置
if (!successRef.current && positionRef.current < maxPosition - 5) {
positionRef.current = 0;
if (sliderRef.current) {
@@ -723,11 +591,49 @@ const SliderVerify: React.FC<{
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
const handleTouchStart = (e: React.TouchEvent) => {
if (successRef.current || isVerified) return;
e.preventDefault();
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const touch = e.touches[0];
const startX = touch.clientX - rect.left - sliderWidth / 2;
updatePosition(startX);
const handleTouchMove = (moveEvent: TouchEvent) => {
const moveTouch = moveEvent.touches[0];
const x = moveTouch.clientX - rect.left - sliderWidth / 2;
updatePosition(x);
};
const handleTouchEnd = () => {
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
if (!successRef.current && positionRef.current < maxPosition - 5) {
positionRef.current = 0;
if (sliderRef.current) {
sliderRef.current.style.left = '0px';
}
if (trackRef.current) {
trackRef.current.style.width = `${sliderWidth}px`;
}
}
};
document.addEventListener('touchmove', handleTouchMove, { passive: false });
document.addEventListener('touchend', handleTouchEnd);
};
return (
<div
ref={containerRef}
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
style={{
width: '100%',
height: 50,
@@ -742,7 +648,6 @@ const SliderVerify: React.FC<{
touchAction: 'none',
}}
>
{/* 已滑动部分背景 - 包含阴影弧度 */}
<div
ref={trackRef}
style={{
@@ -762,7 +667,6 @@ const SliderVerify: React.FC<{
}}
/>
{/* 文字提示 */}
<div
style={{
position: 'absolute',
@@ -793,7 +697,6 @@ const SliderVerify: React.FC<{
)}
</div>
{/* 滑块 */}
<div
ref={sliderRef}
style={{
+13 -1
View File
@@ -97,7 +97,15 @@ const ProjectsPage: React.FC = () => {
};
return (
<div>
<div
style={
{
margin: '-24px -32px -32px',
borderRadius: 20,
height: 'calc(100vh - 34px)',
padding:'24px'
}
}>
{/* Header banner */}
<div className="animate-fadeInUp" style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
@@ -116,6 +124,10 @@ const ProjectsPage: React.FC = () => {
background: 'rgba(255,255,255,0.15)', border: '1px solid rgba(255,255,255,0.3)',
color: '#fff', fontWeight: 600, backdropFilter: 'blur(10px)', borderRadius: 10, height: 42,
}}></Button>
<Button icon={<PlusOutlined />} onClick={() => navigate('/records')} size="large" style={{
background: 'rgba(255,255,255,0.15)', border: '1px solid rgba(255,255,255,0.3)',
color: '#fff', fontWeight: 600, backdropFilter: 'blur(10px)', borderRadius: 10, height: 42,
}}></Button>
</div>
{projects.length === 0 ? (
+1 -1
View File
@@ -65,7 +65,7 @@ export const useAppStore = create<AppState>((set, get) => ({
loading: false,
// 生成配置状态初始值
mediaType: 'image',
mediaType: 'video',
countType: '请选择',
selectedRatio: '1:1',
selectedResolution: '2K',
+7
View File
@@ -10,6 +10,7 @@ interface AuthState {
checkAuth: () => Promise<void>;
changePassword: (oldPwd: string, newPwd: string) => Promise<void>;
refreshUser: () => Promise<void>;
setUserCredits: (credits: number) => void;
}
export const useAuthStore = create<AuthState>((set) => ({
@@ -52,4 +53,10 @@ export const useAuthStore = create<AuthState>((set) => ({
console.error('Failed to refresh user:', error);
}
},
// 用通知接口返回的 credits.balance 直接覆盖当前 user.credits
// 避免再请求一次 getUser 接口
setUserCredits: (credits: number) => {
set((state) => (state.user ? { user: { ...state.user, credits } } : {}));
},
}));