diff --git a/backend/.env.example b/backend/.env.example index b63ce6e..e87f0f2 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -92,3 +92,16 @@ LOCAL_AUTH_DISPLAY_NAME=本地管理员 # ========================================== SESSION_EXPIRE_MINUTES=120 SESSION_REFRESH_THRESHOLD_MINUTES=30 + +# ========================================== +# 提示词工坊配置 +# ========================================== +# 运行模式:client(本地部署)或 server(云端服务器) +# 只有 mumuverse.space:1566 需要设置为 server +WORKSHOP_MODE=client + +# 云端服务地址(client 模式使用) +WORKSHOP_CLOUD_URL=https://mumuverse.space:1566 + +# 云端 API 请求超时时间(秒) +WORKSHOP_API_TIMEOUT=30 diff --git a/backend/.instance_id b/backend/.instance_id new file mode 100644 index 0000000..9300b34 --- /dev/null +++ b/backend/.instance_id @@ -0,0 +1 @@ +eaf3cae6-4a6 \ No newline at end of file diff --git a/backend/alembic/sqlite/versions/20260127_1354_927bcb55b756_添加提示词工坊相关表结构.py b/backend/alembic/sqlite/versions/20260127_1354_927bcb55b756_添加提示词工坊相关表结构.py new file mode 100644 index 0000000..b4fd24b --- /dev/null +++ b/backend/alembic/sqlite/versions/20260127_1354_927bcb55b756_添加提示词工坊相关表结构.py @@ -0,0 +1,108 @@ +"""添加提示词工坊相关表结构 + +Revision ID: 927bcb55b756 +Revises: 951919659e0f +Create Date: 2026-01-27 13:54:34.486645 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '927bcb55b756' +down_revision: Union[str, None] = '951919659e0f' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('prompt_submissions', + sa.Column('id', sa.String(length=36), nullable=False, comment='UUID'), + sa.Column('submitter_id', sa.String(length=255), nullable=False, comment='提交者标识(实例ID:用户ID)'), + sa.Column('submitter_name', sa.String(length=100), nullable=True, comment='提交者显示名称'), + sa.Column('source_instance', sa.String(length=255), nullable=False, comment='来源实例标识'), + sa.Column('name', sa.String(length=100), nullable=False, comment='提示词名称'), + sa.Column('description', sa.Text(), nullable=True, comment='提示词描述'), + sa.Column('prompt_content', sa.Text(), nullable=False, comment='提示词内容'), + sa.Column('category', sa.String(length=50), nullable=True, comment='分类'), + sa.Column('tags', sa.JSON(), nullable=True, comment='标签数组'), + sa.Column('author_display_name', sa.String(length=100), nullable=True, comment='希望显示的作者名'), + sa.Column('is_anonymous', sa.Boolean(), nullable=True, comment='是否匿名发布'), + sa.Column('status', sa.String(length=20), nullable=True, comment='状态:pending/approved/rejected'), + sa.Column('reviewer_id', sa.String(length=100), nullable=True, comment='审核人ID(云端管理员)'), + sa.Column('review_note', sa.Text(), nullable=True, comment='审核备注(拒绝理由等)'), + sa.Column('reviewed_at', sa.DateTime(), nullable=True, comment='审核时间'), + sa.Column('workshop_item_id', sa.String(length=36), nullable=True, comment='审核通过后关联的工坊条目ID'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='创建时间'), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='更新时间'), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('prompt_submissions', schema=None) as batch_op: + batch_op.create_index('idx_submissions_created_at', ['created_at'], unique=False) + batch_op.create_index('idx_submissions_source', ['source_instance'], unique=False) + batch_op.create_index('idx_submissions_status', ['status'], unique=False) + batch_op.create_index('idx_submissions_submitter', ['submitter_id'], unique=False) + + op.create_table('prompt_workshop_items', + sa.Column('id', sa.String(length=36), nullable=False, comment='UUID'), + sa.Column('name', sa.String(length=100), nullable=False, comment='提示词名称'), + sa.Column('description', sa.Text(), nullable=True, comment='提示词描述'), + sa.Column('prompt_content', sa.Text(), nullable=False, comment='提示词内容'), + sa.Column('category', sa.String(length=50), nullable=True, comment='分类'), + sa.Column('tags', sa.JSON(), nullable=True, comment='标签数组'), + sa.Column('author_id', sa.String(length=255), nullable=True, comment='作者用户标识(实例ID:用户ID)'), + sa.Column('author_name', sa.String(length=100), nullable=True, comment='作者显示名称'), + sa.Column('source_instance', sa.String(length=255), nullable=True, comment='来源实例标识'), + sa.Column('is_official', sa.Boolean(), nullable=True, comment='是否官方提示词'), + sa.Column('download_count', sa.Integer(), nullable=True, comment='下载/导入次数'), + sa.Column('like_count', sa.Integer(), nullable=True, comment='点赞数'), + sa.Column('status', sa.String(length=20), nullable=True, comment='状态:active/hidden/deprecated'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='创建时间'), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='更新时间'), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('prompt_workshop_items', schema=None) as batch_op: + batch_op.create_index('idx_workshop_items_category', ['category'], unique=False) + batch_op.create_index('idx_workshop_items_created_at', ['created_at'], unique=False) + batch_op.create_index('idx_workshop_items_download_count', ['download_count'], unique=False) + batch_op.create_index('idx_workshop_items_status', ['status'], unique=False) + + op.create_table('prompt_workshop_likes', + sa.Column('id', sa.String(length=36), nullable=False, comment='UUID'), + sa.Column('user_identifier', sa.String(length=255), nullable=False, comment='用户标识(实例ID:用户ID)'), + sa.Column('workshop_item_id', sa.String(length=36), nullable=False, comment='工坊条目ID'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='创建时间'), + sa.ForeignKeyConstraint(['workshop_item_id'], ['prompt_workshop_items.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('prompt_workshop_likes', schema=None) as batch_op: + batch_op.create_index('idx_likes_user_item', ['user_identifier', 'workshop_item_id'], unique=True) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('prompt_workshop_likes', schema=None) as batch_op: + batch_op.drop_index('idx_likes_user_item') + + op.drop_table('prompt_workshop_likes') + with op.batch_alter_table('prompt_workshop_items', schema=None) as batch_op: + batch_op.drop_index('idx_workshop_items_status') + batch_op.drop_index('idx_workshop_items_download_count') + batch_op.drop_index('idx_workshop_items_created_at') + batch_op.drop_index('idx_workshop_items_category') + + op.drop_table('prompt_workshop_items') + with op.batch_alter_table('prompt_submissions', schema=None) as batch_op: + batch_op.drop_index('idx_submissions_submitter') + batch_op.drop_index('idx_submissions_status') + batch_op.drop_index('idx_submissions_source') + batch_op.drop_index('idx_submissions_created_at') + + op.drop_table('prompt_submissions') + # ### end Alembic commands ### \ No newline at end of file diff --git a/backend/app/api/prompt_workshop.py b/backend/app/api/prompt_workshop.py new file mode 100644 index 0000000..d996f2d --- /dev/null +++ b/backend/app/api/prompt_workshop.py @@ -0,0 +1,760 @@ +"""提示词工坊 API""" +from fastapi import APIRouter, Depends, HTTPException, Request +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, or_ +from typing import Optional +import uuid + +from app.database import get_db +from app.config import settings, INSTANCE_ID, is_workshop_server +from app.models.writing_style import WritingStyle +from app.models.prompt_workshop import PromptWorkshopItem, PromptSubmission, PromptWorkshopLike +from app.schemas.prompt_workshop import ( + ImportRequest, DownloadRequest, PromptSubmissionCreate, + ReviewRequest, AdminItemCreate, AdminItemUpdate +) +from app.services.workshop_client import workshop_client, WorkshopClientError +from app.constants.prompt_categories import PROMPT_CATEGORIES +from app.logger import get_logger + +router = APIRouter(prefix="/prompt-workshop", tags=["prompt-workshop"]) +logger = get_logger(__name__) + + +# ==================== 辅助函数 ==================== + +def get_current_user_id(request: Request) -> str: + """获取当前登录用户ID""" + user_id = getattr(request.state, 'user_id', None) + if not user_id: + raise HTTPException(status_code=401, detail="未登录") + return user_id + + +def get_user_identifier(user_id: str) -> str: + """生成云端用户标识""" + return f"{INSTANCE_ID}:{user_id}" + + +def _item_to_dict(item: PromptWorkshopItem, is_liked: bool = False) -> dict: + """将模型转换为字典""" + return { + "id": item.id, + "name": item.name, + "description": item.description, + "prompt_content": item.prompt_content, + "category": item.category, + "tags": item.tags, + "author_name": item.author_name, + "is_official": item.is_official, + "download_count": item.download_count, + "like_count": item.like_count, + "is_liked": is_liked, + "created_at": item.created_at.isoformat() if item.created_at else None + } + + +def _submission_to_dict(submission: PromptSubmission) -> dict: + """将提交记录转换为字典""" + return { + "id": submission.id, + "name": submission.name, + "description": submission.description, + "prompt_content": submission.prompt_content, + "category": submission.category, + "tags": submission.tags, + "author_display_name": submission.author_display_name, + "is_anonymous": submission.is_anonymous, + "status": submission.status, + "review_note": submission.review_note, + "reviewed_at": submission.reviewed_at.isoformat() if submission.reviewed_at else None, + "created_at": submission.created_at.isoformat() if submission.created_at else None, + "source_instance": submission.source_instance, + "submitter_name": submission.submitter_name + } + + +async def check_workshop_admin(request: Request): + """检查是否为工坊管理员(必须是云端实例的管理员)""" + if not is_workshop_server(): + raise HTTPException(status_code=403, detail="此功能仅在云端服务可用") + + user = getattr(request.state, "user", None) + if not user: + raise HTTPException(status_code=401, detail="未登录") + + if not user.is_admin: + raise HTTPException(status_code=403, detail="需要管理员权限") + + return user + + +# ==================== 公开 API ==================== + +@router.get("/status") +async def get_status(): + """获取服务状态""" + result = { + "mode": settings.WORKSHOP_MODE, + "instance_id": INSTANCE_ID + } + + if not is_workshop_server(): + result["cloud_url"] = settings.WORKSHOP_CLOUD_URL + try: + result["cloud_connected"] = await workshop_client.check_connection() + except Exception: + result["cloud_connected"] = False + + return result + + +@router.get("/items") +async def get_items( + request: Request, + category: Optional[str] = None, + search: Optional[str] = None, + tags: Optional[str] = None, + sort: str = "newest", + page: int = 1, + limit: int = 20, + db: AsyncSession = Depends(get_db) +): + """获取提示词列表""" + user_id = getattr(request.state, 'user_id', None) + user_identifier = get_user_identifier(user_id) if user_id else None + + if is_workshop_server(): + # 服务端模式:直接查询本地数据库 + return await _get_items_local(db, category, search, tags, sort, page, limit, user_identifier) + else: + # 客户端模式:代理到云端 + try: + return await workshop_client.get_items( + category=category, search=search, tags=tags, + sort=sort, page=page, limit=limit, + user_identifier=user_identifier + ) + except WorkshopClientError as e: + raise HTTPException(status_code=503, detail=str(e)) + + +async def _get_items_local( + db: AsyncSession, + category: Optional[str], + search: Optional[str], + tags: Optional[str], + sort: str, + page: int, + limit: int, + user_identifier: Optional[str] +) -> dict: + """本地查询提示词列表""" + # 构建查询 + query = select(PromptWorkshopItem).where(PromptWorkshopItem.status == "active") + count_query = select(func.count(PromptWorkshopItem.id)).where(PromptWorkshopItem.status == "active") + + if category: + query = query.where(PromptWorkshopItem.category == category) + count_query = count_query.where(PromptWorkshopItem.category == category) + + if search: + search_filter = or_( + PromptWorkshopItem.name.ilike(f"%{search}%"), + PromptWorkshopItem.description.ilike(f"%{search}%") + ) + query = query.where(search_filter) + count_query = count_query.where(search_filter) + + # 排序 + if sort == "popular": + query = query.order_by(PromptWorkshopItem.like_count.desc()) + elif sort == "downloads": + query = query.order_by(PromptWorkshopItem.download_count.desc()) + else: # newest + query = query.order_by(PromptWorkshopItem.created_at.desc()) + + # 计数 + count_result = await db.execute(count_query) + total = count_result.scalar_one() + + # 分页 + query = query.offset((page - 1) * limit).limit(limit) + result = await db.execute(query) + items = result.scalars().all() + + # 获取用户点赞状态 + liked_ids = set() + if user_identifier: + like_result = await db.execute( + select(PromptWorkshopLike.workshop_item_id).where( + PromptWorkshopLike.user_identifier == user_identifier + ) + ) + liked_ids = {row[0] for row in like_result.fetchall()} + + # 获取分类统计 + cat_result = await db.execute( + select( + PromptWorkshopItem.category, + func.count(PromptWorkshopItem.id) + ).where(PromptWorkshopItem.status == "active") + .group_by(PromptWorkshopItem.category) + ) + categories = [ + {"id": cat, "name": PROMPT_CATEGORIES.get(cat, cat), "count": count} + for cat, count in cat_result.fetchall() + ] + + return { + "success": True, + "data": { + "total": total, + "page": page, + "limit": limit, + "items": [ + _item_to_dict(item, is_liked=item.id in liked_ids) + for item in items + ], + "categories": categories + } + } + + +@router.get("/items/{item_id}") +async def get_item(item_id: str, db: AsyncSession = Depends(get_db)): + """获取单个提示词详情""" + if is_workshop_server(): + result = await db.execute( + select(PromptWorkshopItem).where( + PromptWorkshopItem.id == item_id, + PromptWorkshopItem.status == "active" + ) + ) + item = result.scalar_one_or_none() + if not item: + raise HTTPException(status_code=404, detail="提示词不存在") + return {"success": True, "data": _item_to_dict(item)} + else: + try: + return await workshop_client.get_item(item_id) + except WorkshopClientError as e: + raise HTTPException(status_code=503, detail=str(e)) + + +@router.post("/items/{item_id}/import") +async def import_item( + item_id: str, + data: ImportRequest, + request: Request, + db: AsyncSession = Depends(get_db) +): + """导入提示词到本地写作风格""" + user_id = get_current_user_id(request) + user_identifier = get_user_identifier(user_id) + + # 获取提示词详情 + if is_workshop_server(): + result = await db.execute( + select(PromptWorkshopItem).where(PromptWorkshopItem.id == item_id) + ) + item = result.scalar_one_or_none() + if not item: + raise HTTPException(status_code=404, detail="提示词不存在") + item_data = _item_to_dict(item) + + # 增加下载计数 + item.download_count += 1 + await db.commit() + else: + # 从云端获取 + try: + result = await workshop_client.get_item(item_id) + item_data = result.get("data", result) + + # 通知云端增加下载计数 + try: + await workshop_client.record_download(item_id, user_identifier) + except Exception as e: + logger.warning(f"通知云端下载计数失败: {e}") + except WorkshopClientError as e: + raise HTTPException(status_code=503, detail=str(e)) + + # 创建本地写作风格 + count_result = await db.execute( + select(func.count(WritingStyle.id)).where(WritingStyle.user_id == user_id) + ) + max_order = count_result.scalar_one() + + new_style = WritingStyle( + user_id=user_id, + name=data.custom_name or item_data["name"], + style_type="custom", + description=f"从提示词工坊导入: {item_data.get('description', '') or ''}", + prompt_content=item_data["prompt_content"], + order_index=max_order + 1 + ) + db.add(new_style) + await db.commit() + await db.refresh(new_style) + + return { + "success": True, + "message": "导入成功", + "writing_style": { + "id": new_style.id, + "name": new_style.name, + "style_type": new_style.style_type, + "prompt_content": new_style.prompt_content + } + } + + +@router.post("/items/{item_id}/like") +async def toggle_like( + item_id: str, + request: Request, + db: AsyncSession = Depends(get_db) +): + """点赞/取消点赞""" + user_id = get_current_user_id(request) + user_identifier = get_user_identifier(user_id) + + if is_workshop_server(): + # 检查是否已点赞 + result = await db.execute( + select(PromptWorkshopLike).where( + PromptWorkshopLike.user_identifier == user_identifier, + PromptWorkshopLike.workshop_item_id == item_id + ) + ) + existing_like = result.scalar_one_or_none() + + # 获取提示词 + item_result = await db.execute( + select(PromptWorkshopItem).where(PromptWorkshopItem.id == item_id) + ) + item = item_result.scalar_one_or_none() + if not item: + raise HTTPException(status_code=404, detail="提示词不存在") + + if existing_like: + # 取消点赞 + await db.delete(existing_like) + item.like_count = max(0, item.like_count - 1) + liked = False + else: + # 添加点赞 + new_like = PromptWorkshopLike( + id=str(uuid.uuid4()), + user_identifier=user_identifier, + workshop_item_id=item_id + ) + db.add(new_like) + item.like_count += 1 + liked = True + + await db.commit() + return {"success": True, "liked": liked, "like_count": item.like_count} + else: + try: + return await workshop_client.toggle_like(item_id, user_identifier) + except WorkshopClientError as e: + raise HTTPException(status_code=503, detail=str(e)) + + +@router.post("/items/{item_id}/download") +async def record_download( + item_id: str, + data: DownloadRequest, + db: AsyncSession = Depends(get_db) +): + """记录下载(仅云端实例使用)""" + if not is_workshop_server(): + raise HTTPException(status_code=403, detail="此接口仅供云端实例使用") + + result = await db.execute( + select(PromptWorkshopItem).where(PromptWorkshopItem.id == item_id) + ) + item = result.scalar_one_or_none() + if not item: + raise HTTPException(status_code=404, detail="提示词不存在") + + item.download_count += 1 + await db.commit() + + return {"success": True, "download_count": item.download_count} + + +@router.post("/submit") +async def submit_prompt( + data: PromptSubmissionCreate, + request: Request, + db: AsyncSession = Depends(get_db) +): + """提交提示词""" + user_id = get_current_user_id(request) + user_identifier = get_user_identifier(user_id) + + # 获取用户显示名称 + from app.user_manager import user_manager + user = await user_manager.get_user(user_id) + submitter_name = user.display_name if user else "未知用户" + + if is_workshop_server(): + # 直接创建提交记录 + submission = PromptSubmission( + id=str(uuid.uuid4()), + submitter_id=user_identifier, + submitter_name=submitter_name, + source_instance=INSTANCE_ID, + name=data.name, + description=data.description, + prompt_content=data.prompt_content, + category=data.category, + tags=data.tags, + author_display_name=data.author_display_name or submitter_name, + is_anonymous=data.is_anonymous, + status="pending" + ) + db.add(submission) + await db.commit() + await db.refresh(submission) + + return { + "success": True, + "message": "提交成功,等待管理员审核", + "submission": { + "id": submission.id, + "status": submission.status, + "created_at": submission.created_at.isoformat() if submission.created_at else None + } + } + else: + try: + return await workshop_client.submit( + user_identifier=user_identifier, + submitter_name=submitter_name, + data=data.model_dump() + ) + except WorkshopClientError as e: + raise HTTPException(status_code=503, detail=str(e)) + + +@router.get("/my-submissions") +async def get_my_submissions( + request: Request, + status: Optional[str] = None, + db: AsyncSession = Depends(get_db) +): + """获取我的提交记录""" + user_id = get_current_user_id(request) + user_identifier = get_user_identifier(user_id) + + if is_workshop_server(): + query = select(PromptSubmission).where( + PromptSubmission.submitter_id == user_identifier + ) + if status: + query = query.where(PromptSubmission.status == status) + query = query.order_by(PromptSubmission.created_at.desc()) + + result = await db.execute(query) + submissions = result.scalars().all() + + return { + "success": True, + "data": { + "total": len(submissions), + "items": [_submission_to_dict(s) for s in submissions] + } + } + else: + try: + return await workshop_client.get_submissions(user_identifier, status) + except WorkshopClientError as e: + raise HTTPException(status_code=503, detail=str(e)) + + +@router.delete("/submissions/{submission_id}") +async def withdraw_submission( + submission_id: str, + request: Request, + db: AsyncSession = Depends(get_db) +): + """撤回待审核的提交""" + user_id = get_current_user_id(request) + user_identifier = get_user_identifier(user_id) + + if is_workshop_server(): + result = await db.execute( + select(PromptSubmission).where( + PromptSubmission.id == submission_id, + PromptSubmission.submitter_id == user_identifier + ) + ) + submission = result.scalar_one_or_none() + + if not submission: + raise HTTPException(status_code=404, detail="提交记录不存在") + if submission.status != "pending": + raise HTTPException(status_code=400, detail="只能撤回待审核的提交") + + await db.delete(submission) + await db.commit() + + return {"success": True, "message": "撤回成功"} + else: + try: + return await workshop_client.withdraw_submission(submission_id, user_identifier) + except WorkshopClientError as e: + raise HTTPException(status_code=503, detail=str(e)) + + +# ==================== 管理员 API(仅云端实例) ==================== + +@router.get("/admin/submissions") +async def admin_get_submissions( + request: Request, + status: Optional[str] = None, + source: Optional[str] = None, + page: int = 1, + limit: int = 20, + db: AsyncSession = Depends(get_db) +): + """获取待审核列表(管理员)""" + await check_workshop_admin(request) + + query = select(PromptSubmission) + count_query = select(func.count(PromptSubmission.id)) + + if status and status != "all": + query = query.where(PromptSubmission.status == status) + count_query = count_query.where(PromptSubmission.status == status) + if source: + query = query.where(PromptSubmission.source_instance == source) + count_query = count_query.where(PromptSubmission.source_instance == source) + + # 计数 + count_result = await db.execute(count_query) + total = count_result.scalar_one() + + # 待审核数量 + pending_result = await db.execute( + select(func.count(PromptSubmission.id)).where(PromptSubmission.status == "pending") + ) + pending_count = pending_result.scalar_one() + + # 分页查询 + query = query.order_by(PromptSubmission.created_at.desc()) + query = query.offset((page - 1) * limit).limit(limit) + result = await db.execute(query) + submissions = result.scalars().all() + + return { + "success": True, + "data": { + "total": total, + "pending_count": pending_count, + "page": page, + "limit": limit, + "items": [_submission_to_dict(s) for s in submissions] + } + } + + +@router.post("/admin/submissions/{submission_id}/review") +async def admin_review_submission( + submission_id: str, + data: ReviewRequest, + request: Request, + db: AsyncSession = Depends(get_db) +): + """审核提交(管理员)""" + admin = await check_workshop_admin(request) + + result = await db.execute( + select(PromptSubmission).where(PromptSubmission.id == submission_id) + ) + submission = result.scalar_one_or_none() + + if not submission: + raise HTTPException(status_code=404, detail="提交记录不存在") + if submission.status != "pending": + raise HTTPException(status_code=400, detail="该提交已被审核") + + admin_user_id = getattr(admin, 'user_id', str(admin)) + + if data.action == "approve": + # 创建工坊条目 + new_item = PromptWorkshopItem( + id=str(uuid.uuid4()), + name=submission.name, + description=submission.description, + prompt_content=submission.prompt_content, + category=data.category or submission.category, + tags=data.tags or submission.tags, + author_id=None if submission.is_anonymous else submission.submitter_id, + author_name=submission.author_display_name if not submission.is_anonymous else None, + source_instance=submission.source_instance, + is_official=False, + status="active" + ) + db.add(new_item) + + submission.status = "approved" + submission.workshop_item_id = new_item.id + submission.reviewer_id = admin_user_id + submission.review_note = data.review_note + submission.reviewed_at = func.now() + + await db.commit() + await db.refresh(new_item) + + return { + "success": True, + "message": "已通过审核并发布", + "workshop_item": _item_to_dict(new_item) + } + else: + submission.status = "rejected" + submission.reviewer_id = admin_user_id + submission.review_note = data.review_note + submission.reviewed_at = func.now() + + await db.commit() + + return { + "success": True, + "message": "已拒绝", + "submission": _submission_to_dict(submission) + } + + +@router.post("/admin/items") +async def admin_create_item( + data: AdminItemCreate, + request: Request, + db: AsyncSession = Depends(get_db) +): + """添加官方提示词(管理员)""" + await check_workshop_admin(request) + + new_item = PromptWorkshopItem( + id=str(uuid.uuid4()), + name=data.name, + description=data.description, + prompt_content=data.prompt_content, + category=data.category, + tags=data.tags, + author_name="官方", + is_official=True, + status="active" + ) + db.add(new_item) + await db.commit() + await db.refresh(new_item) + + return {"success": True, "item": _item_to_dict(new_item)} + + +@router.put("/admin/items/{item_id}") +async def admin_update_item( + item_id: str, + data: AdminItemUpdate, + request: Request, + db: AsyncSession = Depends(get_db) +): + """编辑提示词(管理员)""" + await check_workshop_admin(request) + + result = await db.execute( + select(PromptWorkshopItem).where(PromptWorkshopItem.id == item_id) + ) + item = result.scalar_one_or_none() + if not item: + raise HTTPException(status_code=404, detail="提示词不存在") + + update_data = data.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(item, key, value) + + await db.commit() + await db.refresh(item) + + return {"success": True, "item": _item_to_dict(item)} + + +@router.delete("/admin/items/{item_id}") +async def admin_delete_item( + item_id: str, + request: Request, + db: AsyncSession = Depends(get_db) +): + """删除提示词(管理员)""" + await check_workshop_admin(request) + + result = await db.execute( + select(PromptWorkshopItem).where(PromptWorkshopItem.id == item_id) + ) + item = result.scalar_one_or_none() + if not item: + raise HTTPException(status_code=404, detail="提示词不存在") + + await db.delete(item) + await db.commit() + + return {"success": True, "message": "删除成功"} + + +@router.get("/admin/stats") +async def admin_get_stats( + request: Request, + db: AsyncSession = Depends(get_db) +): + """获取统计数据(管理员)""" + await check_workshop_admin(request) + + # 提示词总数 + items_count = await db.execute( + select(func.count(PromptWorkshopItem.id)).where(PromptWorkshopItem.status == "active") + ) + total_items = items_count.scalar_one() + + # 官方提示词数量 + official_count = await db.execute( + select(func.count(PromptWorkshopItem.id)).where( + PromptWorkshopItem.status == "active", + PromptWorkshopItem.is_official == True + ) + ) + total_official = official_count.scalar_one() + + # 待审核数量 + pending_count = await db.execute( + select(func.count(PromptSubmission.id)).where(PromptSubmission.status == "pending") + ) + total_pending = pending_count.scalar_one() + + # 总下载量 + downloads_sum = await db.execute( + select(func.sum(PromptWorkshopItem.download_count)) + ) + total_downloads = downloads_sum.scalar_one() or 0 + + # 总点赞量 + likes_sum = await db.execute( + select(func.sum(PromptWorkshopItem.like_count)) + ) + total_likes = likes_sum.scalar_one() or 0 + + return { + "success": True, + "data": { + "total_items": total_items, + "total_official": total_official, + "total_pending": total_pending, + "total_downloads": total_downloads, + "total_likes": total_likes + } + } \ No newline at end of file diff --git a/backend/app/config.py b/backend/app/config.py index 7c28375..1aedc1f 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -4,6 +4,7 @@ from typing import Optional from pathlib import Path import logging import os +import uuid # 获取项目根目录(从backend/app/config.py向上两级) PROJECT_ROOT = Path(__file__).parent.parent @@ -106,6 +107,11 @@ class Settings(BaseSettings): SESSION_EXPIRE_MINUTES: int = 120 # 会话过期时间(分钟),默认2小时 SESSION_REFRESH_THRESHOLD_MINUTES: int = 30 # 会话刷新阈值(分钟),剩余时间少于此值时可刷新 + # 提示词工坊配置 + WORKSHOP_MODE: str = "client" # client: 本地部署实例, server: 云端中央服务器 + WORKSHOP_CLOUD_URL: str = "https://mumuverse.space:1566" # 云端服务地址 + WORKSHOP_API_TIMEOUT: int = 30 # 云端API请求超时时间(秒) + class Config: env_file = ".env" case_sensitive = False @@ -117,3 +123,27 @@ settings = Settings() config_logger.info(f"配置加载完成: {settings.app_name} v{settings.app_version}") config_logger.debug(f"调试模式: {settings.debug}") config_logger.debug(f"AI提供商: {settings.default_ai_provider}") + + +# ==================== 提示词工坊实例标识 ==================== + +def get_or_create_instance_id() -> str: + """获取或创建实例唯一标识""" + instance_file = PROJECT_ROOT / ".instance_id" + if instance_file.exists(): + with open(instance_file, 'r') as f: + return f.read().strip() + else: + instance_id = str(uuid.uuid4())[:12] + with open(instance_file, 'w') as f: + f.write(instance_id) + config_logger.info(f"生成新的实例标识: {instance_id}") + return instance_id + +INSTANCE_ID = get_or_create_instance_id() + +def is_workshop_server() -> bool: + """判断当前实例是否为工坊服务端""" + return settings.WORKSHOP_MODE.lower() == "server" + +config_logger.info(f"提示词工坊模式: {settings.WORKSHOP_MODE}, 实例ID: {INSTANCE_ID}") diff --git a/backend/app/constants/prompt_categories.py b/backend/app/constants/prompt_categories.py new file mode 100644 index 0000000..bb4baf9 --- /dev/null +++ b/backend/app/constants/prompt_categories.py @@ -0,0 +1,28 @@ +"""提示词工坊分类常量""" + +PROMPT_CATEGORIES = { + "general": "通用", + "fantasy": "玄幻/仙侠", + "martial": "武侠", + "romance": "言情", + "scifi": "科幻", + "horror": "悬疑/惊悚", + "history": "历史", + "urban": "都市", + "game": "游戏/电竞", + "other": "其他", +} + +CATEGORY_LIST = [ + {"id": k, "name": v} for k, v in PROMPT_CATEGORIES.items() +] + +# 热门标签(建议) +POPULAR_TAGS = [ + "玄幻", "仙侠", "修真", "升级流", "热血", + "武侠", "古风", "言情", "甜宠", "虐恋", + "科幻", "星际", "末日", "悬疑", "推理", + "惊悚", "历史", "架空", "都市", "职场", + "游戏", "电竞", "二次元", "轻小说", "系统流", + "无敌流", "慢热", "日常", "治愈" +] \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py index e35be8b..0ab1490 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -130,7 +130,7 @@ from app.api import ( wizard_stream, relationships, organizations, auth, users, settings, writing_styles, memories, mcp_plugins, admin, inspiration, prompt_templates, - changelog, careers, foreshadows + changelog, careers, foreshadows, prompt_workshop ) app.include_router(auth.router, prefix="/api") @@ -153,6 +153,7 @@ app.include_router(foreshadows.router) # 伏笔管理API (已包含/api前缀) app.include_router(mcp_plugins.router, prefix="/api") # MCP插件管理API app.include_router(prompt_templates.router, prefix="/api") # 提示词模板管理API app.include_router(changelog.router, prefix="/api") # 更新日志API +app.include_router(prompt_workshop.router, prefix="/api") # 提示词工坊API static_dir = Path(__file__).parent.parent / "static" if static_dir.exists(): diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 8acb79f..7e9a442 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -17,6 +17,7 @@ from app.models.regeneration_task import RegenerationTask from app.models.career import Career, CharacterCareer from app.models.prompt_template import PromptTemplate from app.models.foreshadow import Foreshadow +from app.models.prompt_workshop import PromptWorkshopItem, PromptSubmission, PromptWorkshopLike __all__ = [ "Project", @@ -42,5 +43,8 @@ __all__ = [ "Career", "CharacterCareer", "PromptTemplate", - "Foreshadow" + "Foreshadow", + "PromptWorkshopItem", + "PromptSubmission", + "PromptWorkshopLike" ] \ No newline at end of file diff --git a/backend/app/models/prompt_workshop.py b/backend/app/models/prompt_workshop.py new file mode 100644 index 0000000..a4c31fc --- /dev/null +++ b/backend/app/models/prompt_workshop.py @@ -0,0 +1,89 @@ +"""提示词工坊数据模型""" +from sqlalchemy import Column, String, Text, Boolean, DateTime, Integer, JSON, ForeignKey, Index +from sqlalchemy.sql import func +from app.database import Base + + +class PromptWorkshopItem(Base): + """提示词工坊条目 - 已审核通过的公开提示词""" + __tablename__ = "prompt_workshop_items" + + id = Column(String(36), primary_key=True, comment="UUID") + name = Column(String(100), nullable=False, comment="提示词名称") + description = Column(Text, comment="提示词描述") + prompt_content = Column(Text, nullable=False, comment="提示词内容") + category = Column(String(50), default="general", comment="分类") + tags = Column(JSON, comment="标签数组") + author_id = Column(String(255), comment="作者用户标识(实例ID:用户ID)") + author_name = Column(String(100), comment="作者显示名称") + source_instance = Column(String(255), comment="来源实例标识") + is_official = Column(Boolean, default=False, comment="是否官方提示词") + download_count = Column(Integer, default=0, comment="下载/导入次数") + like_count = Column(Integer, default=0, comment="点赞数") + status = Column(String(20), default="active", comment="状态:active/hidden/deprecated") + created_at = Column(DateTime, server_default=func.now(), comment="创建时间") + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间") + + __table_args__ = ( + Index('idx_workshop_items_category', 'category'), + Index('idx_workshop_items_status', 'status'), + Index('idx_workshop_items_download_count', 'download_count'), + Index('idx_workshop_items_created_at', 'created_at'), + ) + + def __repr__(self): + return f"" + + +class PromptSubmission(Base): + """用户提交的待审核提示词""" + __tablename__ = "prompt_submissions" + + id = Column(String(36), primary_key=True, comment="UUID") + submitter_id = Column(String(255), nullable=False, comment="提交者标识(实例ID:用户ID)") + submitter_name = Column(String(100), comment="提交者显示名称") + source_instance = Column(String(255), nullable=False, comment="来源实例标识") + name = Column(String(100), nullable=False, comment="提示词名称") + description = Column(Text, comment="提示词描述") + prompt_content = Column(Text, nullable=False, comment="提示词内容") + category = Column(String(50), default="general", comment="分类") + tags = Column(JSON, comment="标签数组") + author_display_name = Column(String(100), comment="希望显示的作者名") + is_anonymous = Column(Boolean, default=False, comment="是否匿名发布") + + # 审核相关 + status = Column(String(20), default="pending", comment="状态:pending/approved/rejected") + reviewer_id = Column(String(100), comment="审核人ID(云端管理员)") + review_note = Column(Text, comment="审核备注(拒绝理由等)") + reviewed_at = Column(DateTime, comment="审核时间") + workshop_item_id = Column(String(36), comment="审核通过后关联的工坊条目ID") + + created_at = Column(DateTime, server_default=func.now(), comment="创建时间") + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间") + + __table_args__ = ( + Index('idx_submissions_submitter', 'submitter_id'), + Index('idx_submissions_source', 'source_instance'), + Index('idx_submissions_status', 'status'), + Index('idx_submissions_created_at', 'created_at'), + ) + + def __repr__(self): + return f"" + + +class PromptWorkshopLike(Base): + """提示词点赞记录""" + __tablename__ = "prompt_workshop_likes" + + id = Column(String(36), primary_key=True, comment="UUID") + user_identifier = Column(String(255), nullable=False, comment="用户标识(实例ID:用户ID)") + workshop_item_id = Column(String(36), ForeignKey("prompt_workshop_items.id", ondelete="CASCADE"), nullable=False, comment="工坊条目ID") + created_at = Column(DateTime, server_default=func.now(), comment="创建时间") + + __table_args__ = ( + Index('idx_likes_user_item', 'user_identifier', 'workshop_item_id', unique=True), + ) + + def __repr__(self): + return f"" \ No newline at end of file diff --git a/backend/app/schemas/prompt_workshop.py b/backend/app/schemas/prompt_workshop.py new file mode 100644 index 0000000..da3a027 --- /dev/null +++ b/backend/app/schemas/prompt_workshop.py @@ -0,0 +1,119 @@ +"""提示词工坊 Pydantic Schema""" +from pydantic import BaseModel, Field +from typing import Optional, List +from datetime import datetime + + +# ==================== 请求模型 ==================== + +class ImportRequest(BaseModel): + """导入提示词请求""" + custom_name: Optional[str] = Field(None, max_length=100, description="自定义名称") + + +class DownloadRequest(BaseModel): + """记录下载请求(云端使用)""" + instance_id: str = Field(..., description="实例标识") + user_identifier: str = Field(..., description="用户标识") + + +class PromptSubmissionCreate(BaseModel): + """提交提示词请求""" + name: str = Field(..., max_length=100, description="提示词名称") + description: Optional[str] = Field(None, description="提示词描述") + prompt_content: str = Field(..., description="提示词内容") + category: str = Field(default="general", max_length=50, description="分类") + tags: Optional[List[str]] = Field(None, description="标签列表") + author_display_name: Optional[str] = Field(None, max_length=100, description="作者显示名") + is_anonymous: bool = Field(default=False, description="是否匿名发布") + source_style_id: Optional[int] = Field(None, description="来源写作风格ID") + + +class ReviewRequest(BaseModel): + """审核请求""" + action: str = Field(..., pattern="^(approve|reject)$", description="操作:approve/reject") + review_note: Optional[str] = Field(None, description="审核备注") + category: Optional[str] = Field(None, description="分类(可调整)") + tags: Optional[List[str]] = Field(None, description="标签(可调整)") + + +class AdminItemCreate(BaseModel): + """管理员创建提示词""" + name: str = Field(..., max_length=100, description="提示词名称") + description: Optional[str] = Field(None, description="提示词描述") + prompt_content: str = Field(..., description="提示词内容") + category: str = Field(default="general", description="分类") + tags: Optional[List[str]] = Field(None, description="标签列表") + + +class AdminItemUpdate(BaseModel): + """管理员更新提示词""" + name: Optional[str] = Field(None, max_length=100, description="提示词名称") + description: Optional[str] = Field(None, description="提示词描述") + prompt_content: Optional[str] = Field(None, description="提示词内容") + category: Optional[str] = Field(None, description="分类") + tags: Optional[List[str]] = Field(None, description="标签列表") + status: Optional[str] = Field(None, description="状态") + + +# ==================== 响应模型 ==================== + +class PromptWorkshopItemResponse(BaseModel): + """提示词条目响应""" + id: str + name: str + description: Optional[str] = None + prompt_content: str + category: str + tags: Optional[List[str]] = None + author_name: Optional[str] = None + is_official: bool + download_count: int + like_count: int + is_liked: Optional[bool] = None + created_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +class PromptSubmissionResponse(BaseModel): + """提交记录响应""" + id: str + name: str + description: Optional[str] = None + prompt_content: Optional[str] = None + category: str + tags: Optional[List[str]] = None + author_display_name: Optional[str] = None + is_anonymous: bool + status: str + review_note: Optional[str] = None + reviewed_at: Optional[datetime] = None + created_at: Optional[datetime] = None + source_instance: Optional[str] = None + submitter_name: Optional[str] = None + + class Config: + from_attributes = True + + +class CategoryInfo(BaseModel): + """分类信息""" + id: str + name: str + count: int + + +class WorkshopItemsListResponse(BaseModel): + """提示词列表响应""" + success: bool = True + data: dict # 包含 total, page, limit, items, categories + + +class WorkshopStatusResponse(BaseModel): + """服务状态响应""" + mode: str + instance_id: str + cloud_url: Optional[str] = None + cloud_connected: Optional[bool] = None \ No newline at end of file diff --git a/backend/app/services/workshop_client.py b/backend/app/services/workshop_client.py new file mode 100644 index 0000000..6dc5ab3 --- /dev/null +++ b/backend/app/services/workshop_client.py @@ -0,0 +1,169 @@ +"""云端提示词工坊 API 客户端(client 模式使用)""" +import httpx +from typing import Optional, Dict, Any +from app.config import settings, INSTANCE_ID +from app.logger import get_logger + +logger = get_logger(__name__) + + +class WorkshopClientError(Exception): + """工坊客户端错误""" + pass + + +class WorkshopClient: + """云端 API 客户端""" + + def __init__(self): + self.base_url = settings.WORKSHOP_CLOUD_URL + self.timeout = settings.WORKSHOP_API_TIMEOUT + + async def _request( + self, + method: str, + path: str, + params: Optional[Dict] = None, + json: Optional[Dict] = None, + user_identifier: Optional[str] = None + ) -> Dict[str, Any]: + """发送请求到云端""" + headers = { + "X-Instance-ID": INSTANCE_ID, + "Content-Type": "application/json" + } + if user_identifier: + headers["X-User-ID"] = user_identifier + + url = f"{self.base_url}/api/prompt-workshop{path}" + + try: + async with httpx.AsyncClient(timeout=self.timeout, verify=False) as client: + response = await client.request( + method=method, + url=url, + params=params, + json=json, + headers=headers + ) + response.raise_for_status() + return response.json() + except httpx.ConnectError as e: + logger.error(f"无法连接到云端服务: {self.base_url}, 错误: {e}") + raise WorkshopClientError("无法连接到云端服务,请检查网络连接") + except httpx.TimeoutException: + logger.error(f"云端服务请求超时: {url}") + raise WorkshopClientError("云端服务请求超时,请稍后重试") + except httpx.HTTPStatusError as e: + logger.error(f"云端服务返回错误: {e.response.status_code}, {e.response.text}") + raise WorkshopClientError(f"云端服务错误: {e.response.status_code}") + except Exception as e: + logger.error(f"请求云端服务异常: {e}") + raise WorkshopClientError(f"请求云端服务失败: {str(e)}") + + async def check_connection(self) -> bool: + """检查云端连接状态""" + try: + await self._request("GET", "/status") + return True + except Exception as e: + logger.warning(f"云端连接检查失败: {e}") + return False + + async def get_items( + self, + category: Optional[str] = None, + search: Optional[str] = None, + tags: Optional[str] = None, + sort: str = "newest", + page: int = 1, + limit: int = 20, + user_identifier: Optional[str] = None + ) -> Dict: + """获取提示词列表""" + params = { + "sort": sort, + "page": page, + "limit": limit + } + if category: + params["category"] = category + if search: + params["search"] = search + if tags: + params["tags"] = tags + + return await self._request( + "GET", "/items", + params=params, + user_identifier=user_identifier + ) + + async def get_item(self, item_id: str) -> Dict: + """获取单个提示词详情""" + return await self._request("GET", f"/items/{item_id}") + + async def record_download(self, item_id: str, user_identifier: str) -> Dict: + """记录下载""" + return await self._request( + "POST", + f"/items/{item_id}/download", + json={ + "instance_id": INSTANCE_ID, + "user_identifier": user_identifier + } + ) + + async def toggle_like(self, item_id: str, user_identifier: str) -> Dict: + """点赞/取消点赞""" + return await self._request( + "POST", + f"/items/{item_id}/like", + user_identifier=user_identifier + ) + + async def submit( + self, + user_identifier: str, + submitter_name: str, + data: Dict + ) -> Dict: + """提交提示词""" + payload = { + "instance_id": INSTANCE_ID, + "submitter_id": user_identifier, + "submitter_name": submitter_name, + **data + } + return await self._request("POST", "/submit", json=payload) + + async def get_submissions( + self, + user_identifier: str, + status: Optional[str] = None + ) -> Dict: + """获取用户的提交记录""" + params = {} + if status: + params["status"] = status + return await self._request( + "GET", "/my-submissions", + params=params, + user_identifier=user_identifier + ) + + async def withdraw_submission( + self, + submission_id: str, + user_identifier: str + ) -> Dict: + """撤回提交""" + return await self._request( + "DELETE", + f"/submissions/{submission_id}", + user_identifier=user_identifier + ) + + +# 全局客户端实例 +workshop_client = WorkshopClient() \ No newline at end of file diff --git a/frontend/src/components/PromptWorkshop.tsx b/frontend/src/components/PromptWorkshop.tsx new file mode 100644 index 0000000..4a5d27b --- /dev/null +++ b/frontend/src/components/PromptWorkshop.tsx @@ -0,0 +1,721 @@ +import { useState, useEffect, useCallback } from 'react'; +import { + Card, + Row, + Col, + Input, + Select, + Button, + Tag, + Space, + Empty, + Spin, + Modal, + Form, + message, + Tooltip, + Badge, + Tabs, + Typography, + Pagination, + Alert, +} from 'antd'; +import { + SearchOutlined, + DownloadOutlined, + HeartOutlined, + HeartFilled, + CloudUploadOutlined, + EyeOutlined, + UserOutlined, + ClockCircleOutlined, + CheckCircleOutlined, + CloseCircleOutlined, + SyncOutlined, + DeleteOutlined, + CloudOutlined, + DisconnectOutlined, +} from '@ant-design/icons'; +import { promptWorkshopApi } from '../services/api'; +import type { + PromptWorkshopItem, + PromptSubmission, + PromptSubmissionCreate, +} from '../types'; +import { PROMPT_CATEGORIES } from '../types'; + +const { TextArea } = Input; +const { Text, Paragraph } = Typography; + +interface PromptWorkshopProps { + onImportSuccess?: () => void; +} + +export default function PromptWorkshop({ onImportSuccess }: PromptWorkshopProps) { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [total, setTotal] = useState(0); + const [currentPage, setCurrentPage] = useState(1); + const [pageSize] = useState(12); + + // 筛选条件 + const [category, setCategory] = useState(''); + const [searchKeyword, setSearchKeyword] = useState(''); + const [sortBy, setSortBy] = useState<'newest' | 'popular' | 'downloads'>('newest'); + + // 服务状态 + const [serviceStatus, setServiceStatus] = useState<{ + mode: string; + instance_id: string; + cloud_connected?: boolean; + } | null>(null); + + // 提交相关 + const [isSubmitModalOpen, setIsSubmitModalOpen] = useState(false); + const [submitLoading, setSubmitLoading] = useState(false); + const [submitForm] = Form.useForm(); + + // 我的提交 + const [mySubmissions, setMySubmissions] = useState([]); + const [submissionsLoading, setSubmissionsLoading] = useState(false); + + // 详情弹窗 + const [detailItem, setDetailItem] = useState(null); + const [isDetailModalOpen, setIsDetailModalOpen] = useState(false); + + // 导入状态 + const [importingId, setImportingId] = useState(null); + + const isMobile = window.innerWidth <= 768; + + // 加载服务状态 + useEffect(() => { + const checkStatus = async () => { + try { + const status = await promptWorkshopApi.getStatus(); + setServiceStatus(status); + } catch (error) { + console.error('Failed to check workshop status:', error); + } + }; + checkStatus(); + }, []); + + // 加载工坊列表 + const loadItems = useCallback(async () => { + setLoading(true); + try { + const response = await promptWorkshopApi.getItems({ + category: category || undefined, + search: searchKeyword || undefined, + sort: sortBy, + page: currentPage, + limit: pageSize, + }); + setItems(response.data?.items || []); + setTotal(response.data?.total || 0); + } catch (error) { + console.error('Failed to load workshop items:', error); + message.error('加载提示词工坊失败'); + } finally { + setLoading(false); + } + }, [category, searchKeyword, sortBy, currentPage, pageSize]); + + useEffect(() => { + loadItems(); + }, [loadItems]); + + // 加载我的提交 + const loadMySubmissions = async () => { + setSubmissionsLoading(true); + try { + const response = await promptWorkshopApi.getMySubmissions(); + setMySubmissions(response.data?.items || []); + } catch (error) { + console.error('Failed to load submissions:', error); + } finally { + setSubmissionsLoading(false); + } + }; + + // 导入到本地 + const handleImport = async (item: PromptWorkshopItem) => { + setImportingId(item.id); + try { + await promptWorkshopApi.importItem(item.id); + message.success(`已导入「${item.name}」到本地写作风格`); + onImportSuccess?.(); + // 刷新列表更新下载计数 + loadItems(); + } catch (error) { + console.error('Failed to import item:', error); + message.error('导入失败'); + } finally { + setImportingId(null); + } + }; + + // 点赞 + const handleLike = async (item: PromptWorkshopItem) => { + try { + const response = await promptWorkshopApi.toggleLike(item.id); + // 更新本地状态 + setItems(prev => prev.map(i => + i.id === item.id + ? { ...i, is_liked: response.liked, like_count: response.like_count } + : i + )); + } catch (error) { + console.error('Failed to toggle like:', error); + message.error('操作失败'); + } + }; + + // 提交新提示词 + const handleSubmit = async (values: PromptSubmissionCreate) => { + setSubmitLoading(true); + try { + await promptWorkshopApi.submit({ + ...values, + tags: values.tags ? (values.tags as unknown as string).split(',').map((t: string) => t.trim()).filter(Boolean) : [], + }); + message.success('提交成功,等待管理员审核'); + setIsSubmitModalOpen(false); + submitForm.resetFields(); + loadMySubmissions(); + } catch (error) { + console.error('Failed to submit:', error); + message.error('提交失败'); + } finally { + setSubmitLoading(false); + } + }; + + // 撤回提交 + const handleWithdraw = async (submissionId: string) => { + try { + await promptWorkshopApi.withdrawSubmission(submissionId); + message.success('已撤回'); + loadMySubmissions(); + } catch (error) { + console.error('Failed to withdraw:', error); + message.error('撤回失败'); + } + }; + + // 查看详情 + const handleViewDetail = async (item: PromptWorkshopItem) => { + try { + const response = await promptWorkshopApi.getItem(item.id); + setDetailItem(response.data); + setIsDetailModalOpen(true); + } catch (error) { + console.error('Failed to load detail:', error); + message.error('加载详情失败'); + } + }; + + // 获取分类标签颜色 + const getCategoryColor = (cat: string) => { + const colors: Record = { + general: 'blue', + fantasy: 'purple', + martial: 'orange', + romance: 'pink', + scifi: 'cyan', + horror: 'red', + history: 'gold', + urban: 'green', + game: 'magenta', + other: 'default', + }; + return colors[cat] || 'default'; + }; + + // 获取分类名称 + const getCategoryName = (cat: string) => { + return PROMPT_CATEGORIES[cat] || cat; + }; + + // 获取分类选项列表 + const categoryOptions = Object.entries(PROMPT_CATEGORIES).map(([value, label]) => ({ + value, + label, + })); + + // 获取提交状态标签 + const getStatusTag = (status: string) => { + const config: Record = { + pending: { color: 'processing', icon: , text: '待审核' }, + approved: { color: 'success', icon: , text: '已通过' }, + rejected: { color: 'error', icon: , text: '已拒绝' }, + }; + const cfg = config[status] || config.pending; + return {cfg.text}; + }; + + // 网格配置 + const gridConfig = { + gutter: isMobile ? 8 : 16, + xs: 24, + sm: 12, + md: 8, + lg: 6, + xl: 6, + }; + + // 渲染工坊列表 + const renderWorkshopList = () => ( +
+ {/* 服务状态 */} + {serviceStatus && !serviceStatus.cloud_connected && serviceStatus.mode === 'client' && ( + } + showIcon + style={{ marginBottom: 16 }} + /> + )} + + {/* 筛选区域 */} +
+ } + value={searchKeyword} + onChange={e => setSearchKeyword(e.target.value)} + onPressEnter={() => { setCurrentPage(1); loadItems(); }} + style={{ width: isMobile ? '100%' : 200 }} + allowClear + /> + + + +
+ + {/* 列表区域 */} + + {items.length === 0 ? ( + + ) : ( + <> + + {items.map(item => ( + + + handleViewDetail(item)} /> + , + + handleLike(item)}> + {item.is_liked ? ( + + ) : ( + + )} + {item.like_count || 0} + + , + + + , + ]} + > +
+ + {item.name} + + {getCategoryName(item.category)} + + + + {item.description && ( + + {item.description} + + )} + + + {item.prompt_content} + + + {item.tags && item.tags.length > 0 && ( + + {item.tags.slice(0, 3).map(tag => ( + {tag} + ))} + {item.tags.length > 3 && ( + +{item.tags.length - 3} + )} + + )} +
+ +
+ + {item.author_name || '匿名'} + +
+
+ + ))} +
+ + {total > pageSize && ( +
+ setCurrentPage(page)} + showSizeChanger={false} + showTotal={t => `共 ${t} 个提示词`} + /> +
+ )} + + )} +
+
+ ); + + // 渲染我的提交 + const renderMySubmissions = () => ( +
+
+ 查看您提交的提示词及审核状态 + +
+ + + {mySubmissions.length === 0 ? ( + + ) : ( + + {mySubmissions.map(sub => ( + + + +
+ {sub.name} + {getStatusTag(sub.status)} +
+ + + {getCategoryName(sub.category)} + + + + {sub.prompt_content} + + + {sub.status === 'rejected' && sub.review_note && ( + + )} + +
+ 提交时间: {sub.created_at ? new Date(sub.created_at).toLocaleDateString() : '-'} +
+ + {sub.status === 'pending' && ( + + )} +
+
+ + ))} +
+ )} +
+
+ ); + + return ( +
+ {/* 标题和操作区 */} +
+ + + 提示词工坊 + {serviceStatus?.mode === 'server' && ( + + )} + + +
+ + {/* 标签页 */} + key === 'submissions' && loadMySubmissions()} + items={[ + { + key: 'browse', + label: '浏览工坊', + children: renderWorkshopList(), + }, + { + key: 'submissions', + label: ( + s.status === 'pending').length} size="small"> + 我的提交 + + ), + children: renderMySubmissions(), + }, + ]} + /> + + {/* 提交弹窗 */} + { + setIsSubmitModalOpen(false); + submitForm.resetFields(); + }} + footer={null} + width={isMobile ? '100%' : 600} + > + + +
+ + + + + + + + + +