feat(showcase): 去掉 Bento 灵感类型,只保留 hero / work

后端校验、分组、后台列表,以及后台页面与首页取图都只认
hero 和 work;启动时清掉遗留的 bento 记录。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
2026-10-27 15:57:12 +08:00
co-authored by Devin
parent 2fa0324a66
commit 8b85d5623a
6 changed files with 20 additions and 36 deletions
+4
View File
@@ -72,6 +72,10 @@ func NewApp(ctx context.Context) (*App, error) {
if err := db.WithContext(ctx).Exec(`ALTER TABLE model_configs DROP COLUMN IF EXISTS enabled`).Error; err != nil {
return nil, fmt.Errorf("drop model_configs.enabled: %w", err)
}
// 首页展示位不再支持 bento,清掉遗留记录。
if err := db.WithContext(ctx).Exec(`DELETE FROM showcase_items WHERE kind = 'bento'`).Error; err != nil {
return nil, fmt.Errorf("purge bento showcase: %w", err)
}
if err := seedDefaults(ctx, db); err != nil {
return nil, fmt.Errorf("seed defaults: %w", err)
}
+3 -3
View File
@@ -46,16 +46,16 @@ func (h *ShowcaseHandler) List(c *gin.Context) {
// AdminList is the paginated flat view for the admin 首页内容 page — the public
// List keeps its grouped shape for the home page, this one adds kind filter +
// limit/offset/total. Order mirrors the old client flattening: hero → bento → work.
// limit/offset/total. Order mirrors the old client flattening: hero → work.
func (h *ShowcaseHandler) AdminList(c *gin.Context) {
grouped, err := h.showcase.Grouped(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load showcase"})
return
}
kindFilter := c.Query("kind") // "" | hero | bento | work
kindFilter := c.Query("kind") // "" | hero | work
flat := make([]gin.H, 0, 16)
for _, kind := range []string{"hero", "bento", "work"} {
for _, kind := range []string{"hero", "work"} {
if kindFilter != "" && kind != kindFilter {
continue
}
+2 -3
View File
@@ -62,9 +62,8 @@ func (r *ShowcaseRepository) Grouped(ctx context.Context) (map[string][]model.Sh
}
grouped := map[string][]model.ShowcaseItem{
"hero": {},
"bento": {},
"work": {},
"hero": {},
"work": {},
}
for _, item := range items {
grouped[item.Kind] = append(grouped[item.Kind], item)
+3 -3
View File
@@ -292,7 +292,7 @@ func (s *AdminWriteService) DeleteUserAPIKey(ctx context.Context, userID, keyID
func (s *AdminWriteService) CreateShowcase(ctx context.Context, body map[string]any) (*model.ShowcaseItem, error) {
kind := normalizedShowcaseKind(stringValue(body["kind"]))
if kind == "" {
return nil, errors.New("kind must be hero, bento or work")
return nil, errors.New("kind must be hero or work")
}
image := strings.TrimSpace(stringValue(body["image"]))
if image == "" {
@@ -333,7 +333,7 @@ func (s *AdminWriteService) UpdateShowcase(ctx context.Context, entryID string,
if _, ok := body["kind"]; ok {
kind := normalizedShowcaseKind(stringValue(body["kind"]))
if kind == "" {
return nil, errors.New("kind must be hero, bento or work")
return nil, errors.New("kind must be hero or work")
}
patch["kind"] = kind
}
@@ -581,7 +581,7 @@ func normalizedStatus(status string) string {
func normalizedShowcaseKind(kind string) string {
switch strings.TrimSpace(kind) {
case "hero", "bento", "work":
case "hero", "work":
return strings.TrimSpace(kind)
default:
return ""
+2 -2
View File
@@ -233,8 +233,8 @@ onMounted(async () => {
])
onlineModels.value = (m.data?.data || []).filter(model => model.sellable)
const data = sc.data?.data || {}
// hero 拼贴按 hero → bento → 作品墙兜底取图,保证首屏有图可看
const pool = [...(data.hero || []), ...(data.bento || []), ...(data.work || [])]
// hero 拼贴按 hero → 作品墙兜底取图,保证首屏有图可看
const pool = [...(data.hero || []), ...(data.work || [])]
carouselItems.value = pool.filter(i => i.image).slice(0, 4)
if (!carouselItems.value.length) carouselItems.value = FALLBACK_HERO
inspireItems.value = (data.work || []).length ? data.work : FALLBACK_WORKS
+6 -25
View File
@@ -1,17 +1,16 @@
<script setup>
// Admin → 首页内容
// CRUD for the cards rendered on the public home page:
// - hero : top-3-by-weight stacked deck in the hero
// - bento : "从一个起点开始" grid
// - hero : top-4-by-weight collage in the hero
// - work : "我们的作品" marquee — admin-curated featured outputs
// All three kinds use a real image as the background; admins pick one from
// Both kinds use a real image as the background; admins pick one from
// the already-generated files or paste an external URL.
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { api, jsonBody, generatedUrl } from '../api'
import Icon from '../components/Icon.vue'
const items = ref([])
const filter = ref('all') // 'all' | 'hero' | 'bento' | 'work'
const filter = ref('all') // 'all' | 'hero' | 'work'
const loading = ref(false)
const editing = ref(null) // truthy when the form modal is open
const picking = ref(false) // truthy when the image-picker modal is open
@@ -70,7 +69,7 @@ const pageNumbers = computed(() => {
return out
})
const KIND_DEFAULT_WEIGHT = { hero: 200, bento: 300, work: 100 }
const KIND_DEFAULT_WEIGHT = { hero: 200, work: 100 }
function openNew(kind) {
editing.value = { kind }
@@ -174,7 +173,6 @@ function bgFor(image) {
}
}
const SPAN_PRESETS = ['', 'md:col-span-2', 'md:row-span-2', 'md:col-span-2 md:row-span-2']
onMounted(refresh)
</script>
@@ -186,12 +184,10 @@ onMounted(refresh)
<div class="flex items-center gap-1.5">
<button @click="setFilter('all')" class="filter-pill" :class="filter === 'all' && 'on'">全部</button>
<button @click="setFilter('hero')" class="filter-pill" :class="filter === 'hero' && 'on'">Hero 卡片</button>
<button @click="setFilter('bento')" class="filter-pill" :class="filter === 'bento' && 'on'">Bento 灵感</button>
<button @click="setFilter('work')" class="filter-pill" :class="filter === 'work' && 'on'">我们的作品</button>
</div>
<div class="flex items-center gap-2">
<button @click="openNew('hero')" class="btn-soft">+ Hero</button>
<button @click="openNew('bento')" class="btn-soft">+ Bento</button>
<button @click="openNew('work')" class="btn-soft">+ Work</button>
</div>
</div>
@@ -209,7 +205,6 @@ onMounted(refresh)
<div class="absolute top-3 left-3 flex items-center gap-1.5">
<span class="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded ring-1"
:class="rec.kind === 'hero' ? 'bg-rose-500/20 text-rose-200 ring-rose-400/30'
: rec.kind === 'bento' ? 'bg-orange-500/20 text-orange-200 ring-orange-400/30'
: 'bg-sky-500/20 text-sky-200 ring-sky-400/30'">
{{ rec.kind }}
</span>
@@ -255,7 +250,7 @@ onMounted(refresh)
<div class="px-5 py-3 border-b border-[color:var(--hairline)] flex items-center justify-between">
<h2 class="text-sm font-semibold">
{{ form.id ? '编辑' : '新增' }} ·
{{ form.kind === 'hero' ? 'Hero 卡片' : form.kind === 'bento' ? 'Bento 灵感' : '作品' }}
{{ form.kind === 'hero' ? 'Hero 卡片' : '作品' }}
</h2>
<button @click="closeForm" class="text-[color:var(--fg-faint)] hover:text-[color:var(--fg)]">
<Icon name="close" class="w-4 h-4" />
@@ -293,7 +288,6 @@ onMounted(refresh)
<label class="block text-xs text-[color:var(--fg-3)] mb-1.5">类型</label>
<div class="flex gap-1.5">
<button type="button" @click="form.kind = 'hero'" class="kind-btn" :class="form.kind === 'hero' && 'on'">Hero</button>
<button type="button" @click="form.kind = 'bento'" class="kind-btn" :class="form.kind === 'bento' && 'on'">Bento</button>
<button type="button" @click="form.kind = 'work'" class="kind-btn" :class="form.kind === 'work' && 'on'">Work</button>
</div>
</div>
@@ -315,7 +309,7 @@ onMounted(refresh)
</div>
</div>
<div>
<label class="block text-xs text-[color:var(--fg-3)] mb-1.5">提示词 <span class="text-[color:var(--fg-faint)]">(点 Bento 后会预填到画图)</span></label>
<label class="block text-xs text-[color:var(--fg-3)] mb-1.5">提示词 <span class="text-[color:var(--fg-faint)]">(点图后会预填到画图)</span></label>
<textarea v-model="form.prompt" rows="3" class="field resize-none"
placeholder="一位身穿米色风衣的女子站在雨夜的霓虹街道,胶片质感,浅景深,电影感"></textarea>
</div>
@@ -326,19 +320,6 @@ onMounted(refresh)
<input v-model="form.title" class="field" placeholder="留空则只展示图片" />
</div>
</template>
<div v-if="form.kind === 'bento'">
<label class="block text-xs text-[color:var(--fg-3)] mb-1.5">网格跨度 <span class="text-[color:var(--fg-faint)]">(Tailwind class)</span></label>
<div class="flex gap-1.5 flex-wrap mb-2">
<button v-for="s in SPAN_PRESETS" :key="s" type="button" @click="form.span = s"
class="px-2.5 py-1 text-[11px] rounded-lg ring-1 ring-[color:var(--hairline)] hover:bg-[color:var(--hover)]"
:class="form.span === s ? 'bg-[color:var(--btn-solid-bg)] text-[color:var(--btn-solid-fg)]' : 'bg-[color:var(--surface-2)] text-[color:var(--fg-2)]'">
{{ s || '默认 1×1' }}
</button>
</div>
<input v-model="form.span" class="field font-mono text-[11px]" placeholder="md:col-span-2" />
</div>
<p v-if="error" class="text-xs text-rose-500">{{ error }}</p>
</div>