feat: 首页展示最近生成的素材画廊
- 后端新增 GET /api/v1/assets 接口,返回已完成任务的素材列表(按创建时间倒序) - 前端新增 AssetGallery 组件,网格展示素材缩略图、类型标签和提示词 - HomePage 中工程列表上方展示最近生成素材
This commit is contained in:
@@ -78,6 +78,7 @@ func main() {
|
|||||||
v1Auth.POST("/generate", handler.Generate) // 素材生成管线
|
v1Auth.POST("/generate", handler.Generate) // 素材生成管线
|
||||||
v1Auth.GET("/tasks/:taskId", handler.GetTask) // 查询任务
|
v1Auth.GET("/tasks/:taskId", handler.GetTask) // 查询任务
|
||||||
v1Auth.GET("/tasks/:taskId/assets", handler.GetAssets) // 查询任务素材
|
v1Auth.GET("/tasks/:taskId/assets", handler.GetAssets) // 查询任务素材
|
||||||
|
v1Auth.GET("/assets", handler.GetRecentAssets) // 首页最近素材
|
||||||
// 图片编辑
|
// 图片编辑
|
||||||
v1Auth.POST("/images/edit", handler.EditImage) // 图片编辑
|
v1Auth.POST("/images/edit", handler.EditImage) // 图片编辑
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -306,6 +306,58 @@ func getTaskDBID(ctx context.Context, taskID string) uint {
|
|||||||
return task.ID
|
return task.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RecentAssetItem 首页素材展示项。
|
||||||
|
type RecentAssetItem struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Format string `json:"format"`
|
||||||
|
Prompt string `json:"prompt"`
|
||||||
|
AssetType string `json:"assetType"`
|
||||||
|
TaskID string `json:"taskId"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRecentAssets 获取最近生成的素材列表(已完成任务的全部素材)。
|
||||||
|
func GetRecentAssets(c *gin.Context) {
|
||||||
|
limit := 20
|
||||||
|
|
||||||
|
var dbRows []struct {
|
||||||
|
Key string
|
||||||
|
URL string
|
||||||
|
Format string
|
||||||
|
Prompt string
|
||||||
|
AssetType string
|
||||||
|
TaskID string
|
||||||
|
ProjectID uint
|
||||||
|
CreatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
db.GetDB().WithContext(c.Request.Context()).
|
||||||
|
Raw(`SELECT a.key, a.url, a.format, t.prompt, t.asset_type as asset_type,
|
||||||
|
t.external_id as task_id, t.project_id, t.created_at as created_at
|
||||||
|
FROM assets a
|
||||||
|
INNER JOIN tasks t ON a.task_id = t.id
|
||||||
|
WHERE t.status = 'completed'
|
||||||
|
ORDER BY a.created_at DESC
|
||||||
|
LIMIT ?`, limit).
|
||||||
|
Scan(&dbRows)
|
||||||
|
|
||||||
|
result := make([]RecentAssetItem, len(dbRows))
|
||||||
|
for i, r := range dbRows {
|
||||||
|
result[i] = RecentAssetItem{
|
||||||
|
Key: r.Key,
|
||||||
|
URL: storageSvc.GetSignedURL(r.Key),
|
||||||
|
Format: r.Format,
|
||||||
|
Prompt: r.Prompt,
|
||||||
|
AssetType: r.AssetType,
|
||||||
|
TaskID: r.TaskID,
|
||||||
|
CreatedAt: r.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, model.OK(result))
|
||||||
|
}
|
||||||
|
|
||||||
// toTaskResponse 转换任务响应格式。
|
// toTaskResponse 转换任务响应格式。
|
||||||
func toTaskResponse(task *model.Task) model.TaskResponse {
|
func toTaskResponse(task *model.Task) model.TaskResponse {
|
||||||
return model.TaskResponse{
|
return model.TaskResponse{
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { post, get } from './client'
|
import { post, get } from './client'
|
||||||
|
import type { RecentAssetItem } from './types'
|
||||||
|
|
||||||
|
export async function getRecentAssets(): Promise<RecentAssetItem[]> {
|
||||||
|
return get<RecentAssetItem[]>('/api/v1/assets')
|
||||||
|
}
|
||||||
import type {
|
import type {
|
||||||
Asset,
|
Asset,
|
||||||
AssetsResponse,
|
AssetsResponse,
|
||||||
|
|||||||
@@ -98,6 +98,17 @@ export interface Asset {
|
|||||||
index: number
|
index: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 首页最近素材
|
||||||
|
export interface RecentAssetItem {
|
||||||
|
key: string
|
||||||
|
url: string
|
||||||
|
format: string
|
||||||
|
prompt: string
|
||||||
|
assetType: string
|
||||||
|
taskId: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
// 生成请求 — 对应 POST /api/v1/generate
|
// 生成请求 — 对应 POST /api/v1/generate
|
||||||
export interface GenerateRequest {
|
export interface GenerateRequest {
|
||||||
projectId: string
|
projectId: string
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { getRecentAssets } from '../api/generate'
|
||||||
|
import type { RecentAssetItem } from '../api/types'
|
||||||
|
|
||||||
|
const ASSET_TYPE_LABELS: Record<string, string> = {
|
||||||
|
sprite: '精灵',
|
||||||
|
background: '背景',
|
||||||
|
ui: 'UI',
|
||||||
|
animation: '动画',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AssetGallery() {
|
||||||
|
const [assets, setAssets] = useState<RecentAssetItem[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getRecentAssets()
|
||||||
|
.then(setAssets)
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (loading) return null
|
||||||
|
|
||||||
|
if (assets.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ marginBottom: 32 }}>
|
||||||
|
<h2 style={{ fontSize: 18, marginBottom: 16 }}>最近生成</h2>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))',
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{assets.map((item, i) => (
|
||||||
|
<div
|
||||||
|
key={`${item.taskId}-${i}`}
|
||||||
|
className="card"
|
||||||
|
style={{ padding: 8, cursor: 'pointer' }}
|
||||||
|
onClick={() => {
|
||||||
|
// navigate to result page — need projectId for URL
|
||||||
|
// For now just open the image
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
aspectRatio: '1',
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
background: 'var(--bg-input)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={item.url}
|
||||||
|
alt={item.prompt}
|
||||||
|
loading="lazy"
|
||||||
|
style={{
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '100%',
|
||||||
|
objectFit: 'contain',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 6 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
padding: '1px 6px',
|
||||||
|
borderRadius: 999,
|
||||||
|
background: 'var(--accent-dim)',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ASSET_TYPE_LABELS[item.assetType] || item.assetType}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
marginTop: 4,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.prompt}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
|||||||
import { useProjectListStore } from '../stores/projectList'
|
import { useProjectListStore } from '../stores/projectList'
|
||||||
import { useToastStore } from '../stores/toast'
|
import { useToastStore } from '../stores/toast'
|
||||||
import ProjectCard from '../components/ProjectCard'
|
import ProjectCard from '../components/ProjectCard'
|
||||||
|
import AssetGallery from '../components/AssetGallery'
|
||||||
import CreateProjectModal from '../components/CreateProjectModal'
|
import CreateProjectModal from '../components/CreateProjectModal'
|
||||||
import EmptyState from '../components/EmptyState'
|
import EmptyState from '../components/EmptyState'
|
||||||
import Skeleton from '../components/Skeleton'
|
import Skeleton from '../components/Skeleton'
|
||||||
@@ -39,6 +40,8 @@ export default function HomePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container page-enter" style={{ paddingTop: 32, paddingBottom: 32 }}>
|
<div className="container page-enter" style={{ paddingTop: 32, paddingBottom: 32 }}>
|
||||||
|
<AssetGallery />
|
||||||
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||||
<h1 style={{ fontSize: 22 }}>我的工程</h1>
|
<h1 style={{ fontSize: 22 }}>我的工程</h1>
|
||||||
<button className="btn-primary" onClick={() => setModalOpen(true)}>
|
<button className="btn-primary" onClick={() => setModalOpen(true)}>
|
||||||
|
|||||||
Reference in New Issue
Block a user