feature:新增提示词工坊功能
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
eaf3cae6-4a6
|
||||
@@ -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 ###
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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}")
|
||||
|
||||
@@ -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 = [
|
||||
"玄幻", "仙侠", "修真", "升级流", "热血",
|
||||
"武侠", "古风", "言情", "甜宠", "虐恋",
|
||||
"科幻", "星际", "末日", "悬疑", "推理",
|
||||
"惊悚", "历史", "架空", "都市", "职场",
|
||||
"游戏", "电竞", "二次元", "轻小说", "系统流",
|
||||
"无敌流", "慢热", "日常", "治愈"
|
||||
]
|
||||
+2
-1
@@ -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():
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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"<PromptWorkshopItem(id={self.id}, name={self.name})>"
|
||||
|
||||
|
||||
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"<PromptSubmission(id={self.id}, name={self.name}, status={self.status})>"
|
||||
|
||||
|
||||
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"<PromptWorkshopLike(user={self.user_identifier}, item={self.workshop_item_id})>"
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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<PromptWorkshopItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize] = useState(12);
|
||||
|
||||
// 筛选条件
|
||||
const [category, setCategory] = useState<string>('');
|
||||
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<PromptSubmission[]>([]);
|
||||
const [submissionsLoading, setSubmissionsLoading] = useState(false);
|
||||
|
||||
// 详情弹窗
|
||||
const [detailItem, setDetailItem] = useState<PromptWorkshopItem | null>(null);
|
||||
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
|
||||
|
||||
// 导入状态
|
||||
const [importingId, setImportingId] = useState<string | null>(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<string, string> = {
|
||||
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<string, { color: string; icon: React.ReactNode; text: string }> = {
|
||||
pending: { color: 'processing', icon: <ClockCircleOutlined />, text: '待审核' },
|
||||
approved: { color: 'success', icon: <CheckCircleOutlined />, text: '已通过' },
|
||||
rejected: { color: 'error', icon: <CloseCircleOutlined />, text: '已拒绝' },
|
||||
};
|
||||
const cfg = config[status] || config.pending;
|
||||
return <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>;
|
||||
};
|
||||
|
||||
// 网格配置
|
||||
const gridConfig = {
|
||||
gutter: isMobile ? 8 : 16,
|
||||
xs: 24,
|
||||
sm: 12,
|
||||
md: 8,
|
||||
lg: 6,
|
||||
xl: 6,
|
||||
};
|
||||
|
||||
// 渲染工坊列表
|
||||
const renderWorkshopList = () => (
|
||||
<div>
|
||||
{/* 服务状态 */}
|
||||
{serviceStatus && !serviceStatus.cloud_connected && serviceStatus.mode === 'client' && (
|
||||
<Alert
|
||||
type="warning"
|
||||
message="云端服务未连接"
|
||||
description="无法访问提示词工坊,请检查网络连接或稍后重试"
|
||||
icon={<DisconnectOutlined />}
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 筛选区域 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
marginBottom: 16,
|
||||
alignItems: 'center',
|
||||
}}>
|
||||
<Input
|
||||
placeholder="搜索提示词..."
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchKeyword}
|
||||
onChange={e => setSearchKeyword(e.target.value)}
|
||||
onPressEnter={() => { setCurrentPage(1); loadItems(); }}
|
||||
style={{ width: isMobile ? '100%' : 200 }}
|
||||
allowClear
|
||||
/>
|
||||
<Select
|
||||
placeholder="选择分类"
|
||||
value={category}
|
||||
onChange={v => { setCategory(v); setCurrentPage(1); }}
|
||||
style={{ width: isMobile ? '100%' : 150 }}
|
||||
allowClear
|
||||
>
|
||||
{categoryOptions.map(cat => (
|
||||
<Select.Option key={cat.value} value={cat.value}>{cat.label}</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
value={sortBy}
|
||||
onChange={v => { setSortBy(v); setCurrentPage(1); }}
|
||||
style={{ width: isMobile ? '100%' : 120 }}
|
||||
>
|
||||
<Select.Option value="newest">最新发布</Select.Option>
|
||||
<Select.Option value="popular">最受欢迎</Select.Option>
|
||||
<Select.Option value="downloads">下载最多</Select.Option>
|
||||
</Select>
|
||||
<Button
|
||||
icon={<SyncOutlined />}
|
||||
onClick={() => { setCurrentPage(1); loadItems(); }}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 列表区域 */}
|
||||
<Spin spinning={loading}>
|
||||
{items.length === 0 ? (
|
||||
<Empty description="暂无提示词" />
|
||||
) : (
|
||||
<>
|
||||
<Row gutter={[gridConfig.gutter, gridConfig.gutter]}>
|
||||
{items.map(item => (
|
||||
<Col
|
||||
key={item.id}
|
||||
xs={gridConfig.xs}
|
||||
sm={gridConfig.sm}
|
||||
md={gridConfig.md}
|
||||
lg={gridConfig.lg}
|
||||
xl={gridConfig.xl}
|
||||
>
|
||||
<Card
|
||||
hoverable
|
||||
style={{ height: '100%', borderRadius: 12 }}
|
||||
bodyStyle={{ padding: 16, display: 'flex', flexDirection: 'column', height: '100%' }}
|
||||
actions={[
|
||||
<Tooltip title="查看详情" key="view">
|
||||
<EyeOutlined onClick={() => handleViewDetail(item)} />
|
||||
</Tooltip>,
|
||||
<Tooltip title={item.is_liked ? '取消点赞' : '点赞'} key="like">
|
||||
<span onClick={() => handleLike(item)}>
|
||||
{item.is_liked ? (
|
||||
<HeartFilled style={{ color: '#ff4d4f' }} />
|
||||
) : (
|
||||
<HeartOutlined />
|
||||
)}
|
||||
<span style={{ marginLeft: 4 }}>{item.like_count || 0}</span>
|
||||
</span>
|
||||
</Tooltip>,
|
||||
<Tooltip title="导入到本地" key="import">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<DownloadOutlined />}
|
||||
loading={importingId === item.id}
|
||||
onClick={() => handleImport(item)}
|
||||
>
|
||||
{item.download_count || 0}
|
||||
</Button>
|
||||
</Tooltip>,
|
||||
]}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Space style={{ marginBottom: 8 }} wrap>
|
||||
<Text strong style={{ fontSize: 15 }}>{item.name}</Text>
|
||||
<Tag color={getCategoryColor(item.category)}>
|
||||
{getCategoryName(item.category)}
|
||||
</Tag>
|
||||
</Space>
|
||||
|
||||
{item.description && (
|
||||
<Paragraph
|
||||
type="secondary"
|
||||
style={{ fontSize: 13, marginBottom: 8 }}
|
||||
ellipsis={{ rows: 2 }}
|
||||
>
|
||||
{item.description}
|
||||
</Paragraph>
|
||||
)}
|
||||
|
||||
<Paragraph
|
||||
style={{
|
||||
fontSize: 12,
|
||||
backgroundColor: '#fafafa',
|
||||
padding: 8,
|
||||
borderRadius: 4,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
ellipsis={{ rows: 3 }}
|
||||
>
|
||||
{item.prompt_content}
|
||||
</Paragraph>
|
||||
|
||||
{item.tags && item.tags.length > 0 && (
|
||||
<Space size={4} wrap>
|
||||
{item.tags.slice(0, 3).map(tag => (
|
||||
<Tag key={tag} style={{ fontSize: 11 }}>{tag}</Tag>
|
||||
))}
|
||||
{item.tags.length > 3 && (
|
||||
<Tag style={{ fontSize: 11 }}>+{item.tags.length - 3}</Tag>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 8, color: '#999', fontSize: 12 }}>
|
||||
<Space>
|
||||
<span><UserOutlined /> {item.author_name || '匿名'}</span>
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
{total > pageSize && (
|
||||
<div style={{ marginTop: 24, textAlign: 'center' }}>
|
||||
<Pagination
|
||||
current={currentPage}
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
onChange={page => setCurrentPage(page)}
|
||||
showSizeChanger={false}
|
||||
showTotal={t => `共 ${t} 个提示词`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Spin>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 渲染我的提交
|
||||
const renderMySubmissions = () => (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Text>查看您提交的提示词及审核状态</Text>
|
||||
<Button icon={<SyncOutlined />} onClick={loadMySubmissions}>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Spin spinning={submissionsLoading}>
|
||||
{mySubmissions.length === 0 ? (
|
||||
<Empty description="暂无提交记录" />
|
||||
) : (
|
||||
<Row gutter={[16, 16]}>
|
||||
{mySubmissions.map(sub => (
|
||||
<Col key={sub.id} xs={24} sm={12} md={8} lg={6}>
|
||||
<Card
|
||||
style={{ borderRadius: 12 }}
|
||||
bodyStyle={{ padding: 16 }}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Text strong>{sub.name}</Text>
|
||||
{getStatusTag(sub.status)}
|
||||
</div>
|
||||
|
||||
<Tag color={getCategoryColor(sub.category)}>
|
||||
{getCategoryName(sub.category)}
|
||||
</Tag>
|
||||
|
||||
<Paragraph
|
||||
type="secondary"
|
||||
style={{ fontSize: 12, marginBottom: 0 }}
|
||||
ellipsis={{ rows: 2 }}
|
||||
>
|
||||
{sub.prompt_content}
|
||||
</Paragraph>
|
||||
|
||||
{sub.status === 'rejected' && sub.review_note && (
|
||||
<Alert
|
||||
type="error"
|
||||
message="拒绝原因"
|
||||
description={sub.review_note}
|
||||
style={{ fontSize: 12 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ fontSize: 12, color: '#999' }}>
|
||||
提交时间: {sub.created_at ? new Date(sub.created_at).toLocaleDateString() : '-'}
|
||||
</div>
|
||||
|
||||
{sub.status === 'pending' && (
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleWithdraw(sub.id)}
|
||||
>
|
||||
撤回
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
</Spin>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 标题和操作区 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
}}>
|
||||
<Space>
|
||||
<CloudOutlined style={{ fontSize: 20 }} />
|
||||
<Text strong style={{ fontSize: 16 }}>提示词工坊</Text>
|
||||
{serviceStatus?.mode === 'server' && (
|
||||
<Badge status="success" text="服务端模式" />
|
||||
)}
|
||||
</Space>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CloudUploadOutlined />}
|
||||
onClick={() => setIsSubmitModalOpen(true)}
|
||||
>
|
||||
分享我的提示词
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 标签页 */}
|
||||
<Tabs
|
||||
defaultActiveKey="browse"
|
||||
onChange={key => key === 'submissions' && loadMySubmissions()}
|
||||
items={[
|
||||
{
|
||||
key: 'browse',
|
||||
label: '浏览工坊',
|
||||
children: renderWorkshopList(),
|
||||
},
|
||||
{
|
||||
key: 'submissions',
|
||||
label: (
|
||||
<Badge count={mySubmissions.filter(s => s.status === 'pending').length} size="small">
|
||||
我的提交
|
||||
</Badge>
|
||||
),
|
||||
children: renderMySubmissions(),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* 提交弹窗 */}
|
||||
<Modal
|
||||
title="分享提示词到工坊"
|
||||
open={isSubmitModalOpen}
|
||||
onCancel={() => {
|
||||
setIsSubmitModalOpen(false);
|
||||
submitForm.resetFields();
|
||||
}}
|
||||
footer={null}
|
||||
width={isMobile ? '100%' : 600}
|
||||
>
|
||||
<Alert
|
||||
type="info"
|
||||
message="提交须知"
|
||||
description="您的提示词将提交给管理员审核,审核通过后会在工坊中展示。请确保内容原创且不含敏感信息。"
|
||||
style={{ marginBottom: 16 }}
|
||||
showIcon
|
||||
/>
|
||||
|
||||
<Form
|
||||
form={submitForm}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="名称"
|
||||
rules={[{ required: true, message: '请输入名称' }]}
|
||||
>
|
||||
<Input placeholder="给您的提示词起个名字" maxLength={50} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="category"
|
||||
label="分类"
|
||||
rules={[{ required: true, message: '请选择分类' }]}
|
||||
>
|
||||
<Select placeholder="选择分类">
|
||||
{categoryOptions.map(cat => (
|
||||
<Select.Option key={cat.value} value={cat.value}>{cat.label}</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="description" label="描述">
|
||||
<TextArea rows={2} placeholder="简要描述这个提示词的用途和效果" maxLength={200} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="prompt_content"
|
||||
label="提示词内容"
|
||||
rules={[{ required: true, message: '请输入提示词内容' }]}
|
||||
>
|
||||
<TextArea rows={6} placeholder="输入完整的提示词内容..." />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="tags" label="标签">
|
||||
<Input placeholder="输入标签,多个用逗号分隔,如: 武侠,对话,细腻" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
|
||||
<Button onClick={() => {
|
||||
setIsSubmitModalOpen(false);
|
||||
submitForm.resetFields();
|
||||
}}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="primary" htmlType="submit" loading={submitLoading}>
|
||||
提交审核
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 详情弹窗 */}
|
||||
<Modal
|
||||
title={detailItem?.name}
|
||||
open={isDetailModalOpen}
|
||||
onCancel={() => {
|
||||
setIsDetailModalOpen(false);
|
||||
setDetailItem(null);
|
||||
}}
|
||||
footer={[
|
||||
<Button key="close" onClick={() => setIsDetailModalOpen(false)}>
|
||||
关闭
|
||||
</Button>,
|
||||
<Button
|
||||
key="import"
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
loading={importingId === detailItem?.id}
|
||||
onClick={() => detailItem && handleImport(detailItem)}
|
||||
>
|
||||
导入到本地
|
||||
</Button>,
|
||||
]}
|
||||
width={isMobile ? '100%' : 700}
|
||||
>
|
||||
{detailItem && (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Tag color={getCategoryColor(detailItem.category)}>
|
||||
{getCategoryName(detailItem.category)}
|
||||
</Tag>
|
||||
{detailItem.tags?.map(tag => (
|
||||
<Tag key={tag}>{tag}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
|
||||
{detailItem.description && (
|
||||
<Paragraph style={{ marginBottom: 16 }}>
|
||||
{detailItem.description}
|
||||
</Paragraph>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
backgroundColor: '#f5f5f5',
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
}}>
|
||||
<Text strong style={{ display: 'block', marginBottom: 8 }}>提示词内容</Text>
|
||||
<pre style={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
margin: 0,
|
||||
fontSize: 13,
|
||||
}}>
|
||||
{detailItem.prompt_content}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Text type="secondary">作者</Text>
|
||||
<div><UserOutlined /> {detailItem.author_name || '匿名'}</div>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Text type="secondary">点赞</Text>
|
||||
<div><HeartOutlined /> {detailItem.like_count || 0}</div>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Text type="secondary">下载</Text>
|
||||
<div><DownloadOutlined /> {detailItem.download_count || 0}</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
@@ -12,18 +12,22 @@ import {
|
||||
Empty,
|
||||
Typography,
|
||||
Row,
|
||||
Col
|
||||
Col,
|
||||
Tabs,
|
||||
Badge,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
StarOutlined,
|
||||
StarFilled
|
||||
StarFilled,
|
||||
CloudOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { writingStyleApi } from '../services/api';
|
||||
import type { WritingStyle, WritingStyleCreate, WritingStyleUpdate } from '../types';
|
||||
import PromptWorkshop from '../components/PromptWorkshop';
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Text, Paragraph } = Typography;
|
||||
@@ -56,7 +60,7 @@ export default function WritingStyles() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentProject?.id]);
|
||||
|
||||
const loadStyles = async () => {
|
||||
const loadStyles = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// 如果有当前项目,使用项目API获取(包含is_default标记)
|
||||
@@ -80,7 +84,7 @@ export default function WritingStyles() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [currentProject?.id]);
|
||||
|
||||
const handleCreate = async (values: { name: string; description?: string; prompt_content: string }) => {
|
||||
try {
|
||||
@@ -164,36 +168,23 @@ export default function WritingStyles() {
|
||||
return styleType === 'preset' ? '预设' : '自定义';
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
// 渲染本地风格列表
|
||||
const renderLocalStyles = () => (
|
||||
<div>
|
||||
<div style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 10,
|
||||
backgroundColor: '#fff',
|
||||
padding: isMobile ? '12px 0' : '16px 0',
|
||||
marginBottom: isMobile ? 12 : 16,
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
marginBottom: 16,
|
||||
display: 'flex',
|
||||
flexDirection: isMobile ? 'column' : 'row',
|
||||
gap: isMobile ? 12 : 0,
|
||||
justifyContent: 'space-between',
|
||||
alignItems: isMobile ? 'stretch' : 'center'
|
||||
justifyContent: 'flex-end',
|
||||
}}>
|
||||
<h2 style={{ margin: 0, fontSize: isMobile ? 18 : 24 }}>
|
||||
<EditOutlined style={{ marginRight: 8 }} />
|
||||
写作风格管理
|
||||
</h2>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={showCreateModal}
|
||||
block={isMobile}
|
||||
>
|
||||
创建自定义风格
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{styles.length === 0 ? (
|
||||
<Empty description="暂无风格数据" />
|
||||
@@ -312,6 +303,54 @@ export default function WritingStyles() {
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 10,
|
||||
backgroundColor: '#fff',
|
||||
padding: isMobile ? '12px 0' : '16px 0',
|
||||
marginBottom: isMobile ? 12 : 16,
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
}}>
|
||||
<h2 style={{ margin: 0, fontSize: isMobile ? 18 : 24 }}>
|
||||
<EditOutlined style={{ marginRight: 8 }} />
|
||||
写作风格管理
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
defaultActiveKey="local"
|
||||
style={{ flex: 1 }}
|
||||
items={[
|
||||
{
|
||||
key: 'local',
|
||||
label: (
|
||||
<span>
|
||||
<EditOutlined />
|
||||
我的风格
|
||||
</span>
|
||||
),
|
||||
children: renderLocalStyles(),
|
||||
},
|
||||
{
|
||||
key: 'workshop',
|
||||
label: (
|
||||
<Badge dot>
|
||||
<span>
|
||||
<CloudOutlined />
|
||||
提示词工坊
|
||||
</span>
|
||||
</Badge>
|
||||
),
|
||||
children: <PromptWorkshop onImportSuccess={loadStyles} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* 创建自定义风格 Modal */}
|
||||
<Modal
|
||||
|
||||
@@ -34,6 +34,10 @@ import type {
|
||||
WritingStyleUpdate,
|
||||
PresetStyle,
|
||||
WritingStyleListResponse,
|
||||
PromptWorkshopListResponse,
|
||||
PromptWorkshopItem,
|
||||
PromptSubmission,
|
||||
PromptSubmissionCreate,
|
||||
MCPPlugin,
|
||||
MCPPluginCreate,
|
||||
MCPPluginUpdate,
|
||||
@@ -648,6 +652,54 @@ export const writingStyleApi = {
|
||||
api.post<unknown, WritingStyleListResponse>(`/writing-styles/project/${projectId}/initialize`, {}),
|
||||
};
|
||||
|
||||
export const promptWorkshopApi = {
|
||||
// 检查服务状态
|
||||
getStatus: () =>
|
||||
api.get<unknown, { mode: string; instance_id: string; cloud_url?: string; cloud_connected?: boolean }>('/prompt-workshop/status'),
|
||||
|
||||
// 获取工坊提示词列表
|
||||
getItems: (params?: {
|
||||
category?: string;
|
||||
search?: string;
|
||||
tags?: string;
|
||||
sort?: 'newest' | 'popular' | 'downloads';
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}) => api.get<unknown, PromptWorkshopListResponse>('/prompt-workshop/items', { params }),
|
||||
|
||||
// 获取单个提示词
|
||||
getItem: (itemId: string) =>
|
||||
api.get<unknown, { success: boolean; data: PromptWorkshopItem }>(`/prompt-workshop/items/${itemId}`),
|
||||
|
||||
// 导入到本地
|
||||
importItem: (itemId: string, customName?: string) =>
|
||||
api.post<unknown, { success: boolean; message: string; writing_style: WritingStyle }>(
|
||||
`/prompt-workshop/items/${itemId}/import`,
|
||||
{ custom_name: customName }
|
||||
),
|
||||
|
||||
// 点赞
|
||||
toggleLike: (itemId: string) =>
|
||||
api.post<unknown, { success: boolean; liked: boolean; like_count: number }>(
|
||||
`/prompt-workshop/items/${itemId}/like`
|
||||
),
|
||||
|
||||
// 提交提示词
|
||||
submit: (data: PromptSubmissionCreate) =>
|
||||
api.post<unknown, { success: boolean; message: string; submission: PromptSubmission }>('/prompt-workshop/submit', data),
|
||||
|
||||
// 我的提交
|
||||
getMySubmissions: (status?: string) =>
|
||||
api.get<unknown, { success: boolean; data: { total: number; items: PromptSubmission[] } }>(
|
||||
'/prompt-workshop/my-submissions',
|
||||
{ params: { status } }
|
||||
),
|
||||
|
||||
// 撤回提交
|
||||
withdrawSubmission: (submissionId: string) =>
|
||||
api.delete<unknown, { success: boolean; message: string }>(`/prompt-workshop/submissions/${submissionId}`),
|
||||
};
|
||||
|
||||
export const polishApi = {
|
||||
polishText: (data: PolishTextRequest) =>
|
||||
api.post<unknown, { polished_text: string }>('/polish', data),
|
||||
|
||||
@@ -880,4 +880,95 @@ export interface ForeshadowContextResponse {
|
||||
pending_resolve: Foreshadow[];
|
||||
overdue: Foreshadow[];
|
||||
recently_planted: Foreshadow[];
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 提示词工坊类型定义 ====================
|
||||
|
||||
export interface PromptWorkshopItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
prompt_content: string;
|
||||
category: string;
|
||||
tags?: string[];
|
||||
author_name?: string;
|
||||
is_official: boolean;
|
||||
download_count: number;
|
||||
like_count: number;
|
||||
is_liked?: boolean;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface PromptSubmission {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
prompt_content?: string;
|
||||
category: string;
|
||||
tags?: string[];
|
||||
author_display_name?: string;
|
||||
is_anonymous: boolean;
|
||||
status: 'pending' | 'approved' | 'rejected';
|
||||
review_note?: string;
|
||||
reviewed_at?: string;
|
||||
created_at?: string;
|
||||
source_instance?: string;
|
||||
submitter_name?: string;
|
||||
}
|
||||
|
||||
export interface PromptSubmissionCreate {
|
||||
name: string;
|
||||
description?: string;
|
||||
prompt_content: string;
|
||||
category: string;
|
||||
tags?: string[];
|
||||
author_display_name?: string;
|
||||
is_anonymous?: boolean;
|
||||
source_style_id?: number;
|
||||
}
|
||||
|
||||
export interface PromptWorkshopCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface PromptWorkshopListResponse {
|
||||
success: boolean;
|
||||
data: {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
items: PromptWorkshopItem[];
|
||||
categories: PromptWorkshopCategory[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface PromptWorkshopStatusResponse {
|
||||
mode: 'client' | 'server';
|
||||
instance_id: string;
|
||||
cloud_url?: string;
|
||||
cloud_connected?: boolean;
|
||||
}
|
||||
|
||||
export interface PromptWorkshopAdminStats {
|
||||
total_items: number;
|
||||
total_official: number;
|
||||
total_pending: number;
|
||||
total_downloads: number;
|
||||
total_likes: number;
|
||||
}
|
||||
|
||||
// 提示词工坊分类常量
|
||||
export const PROMPT_CATEGORIES: Record<string, string> = {
|
||||
general: '通用',
|
||||
fantasy: '玄幻/仙侠',
|
||||
martial: '武侠',
|
||||
romance: '言情',
|
||||
scifi: '科幻',
|
||||
horror: '悬疑/惊悚',
|
||||
history: '历史',
|
||||
urban: '都市',
|
||||
game: '游戏/电竞',
|
||||
other: '其他',
|
||||
};
|
||||
Reference in New Issue
Block a user