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:
@@ -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 {
|
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)
|
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 {
|
if err := seedDefaults(ctx, db); err != nil {
|
||||||
return nil, fmt.Errorf("seed defaults: %w", err)
|
return nil, fmt.Errorf("seed defaults: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,16 +46,16 @@ func (h *ShowcaseHandler) List(c *gin.Context) {
|
|||||||
|
|
||||||
// AdminList is the paginated flat view for the admin 首页内容 page — the public
|
// 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 +
|
// 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) {
|
func (h *ShowcaseHandler) AdminList(c *gin.Context) {
|
||||||
grouped, err := h.showcase.Grouped(c.Request.Context())
|
grouped, err := h.showcase.Grouped(c.Request.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load showcase"})
|
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load showcase"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
kindFilter := c.Query("kind") // "" | hero | bento | work
|
kindFilter := c.Query("kind") // "" | hero | work
|
||||||
flat := make([]gin.H, 0, 16)
|
flat := make([]gin.H, 0, 16)
|
||||||
for _, kind := range []string{"hero", "bento", "work"} {
|
for _, kind := range []string{"hero", "work"} {
|
||||||
if kindFilter != "" && kind != kindFilter {
|
if kindFilter != "" && kind != kindFilter {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ func (r *ShowcaseRepository) Grouped(ctx context.Context) (map[string][]model.Sh
|
|||||||
|
|
||||||
grouped := map[string][]model.ShowcaseItem{
|
grouped := map[string][]model.ShowcaseItem{
|
||||||
"hero": {},
|
"hero": {},
|
||||||
"bento": {},
|
|
||||||
"work": {},
|
"work": {},
|
||||||
}
|
}
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
|
|||||||
@@ -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) {
|
func (s *AdminWriteService) CreateShowcase(ctx context.Context, body map[string]any) (*model.ShowcaseItem, error) {
|
||||||
kind := normalizedShowcaseKind(stringValue(body["kind"]))
|
kind := normalizedShowcaseKind(stringValue(body["kind"]))
|
||||||
if 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"]))
|
image := strings.TrimSpace(stringValue(body["image"]))
|
||||||
if image == "" {
|
if image == "" {
|
||||||
@@ -333,7 +333,7 @@ func (s *AdminWriteService) UpdateShowcase(ctx context.Context, entryID string,
|
|||||||
if _, ok := body["kind"]; ok {
|
if _, ok := body["kind"]; ok {
|
||||||
kind := normalizedShowcaseKind(stringValue(body["kind"]))
|
kind := normalizedShowcaseKind(stringValue(body["kind"]))
|
||||||
if 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
|
patch["kind"] = kind
|
||||||
}
|
}
|
||||||
@@ -581,7 +581,7 @@ func normalizedStatus(status string) string {
|
|||||||
|
|
||||||
func normalizedShowcaseKind(kind string) string {
|
func normalizedShowcaseKind(kind string) string {
|
||||||
switch strings.TrimSpace(kind) {
|
switch strings.TrimSpace(kind) {
|
||||||
case "hero", "bento", "work":
|
case "hero", "work":
|
||||||
return strings.TrimSpace(kind)
|
return strings.TrimSpace(kind)
|
||||||
default:
|
default:
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -233,8 +233,8 @@ onMounted(async () => {
|
|||||||
])
|
])
|
||||||
onlineModels.value = (m.data?.data || []).filter(model => model.sellable)
|
onlineModels.value = (m.data?.data || []).filter(model => model.sellable)
|
||||||
const data = sc.data?.data || {}
|
const data = sc.data?.data || {}
|
||||||
// hero 拼贴按 hero → bento → 作品墙兜底取图,保证首屏有图可看
|
// hero 拼贴按 hero → 作品墙兜底取图,保证首屏有图可看
|
||||||
const pool = [...(data.hero || []), ...(data.bento || []), ...(data.work || [])]
|
const pool = [...(data.hero || []), ...(data.work || [])]
|
||||||
carouselItems.value = pool.filter(i => i.image).slice(0, 4)
|
carouselItems.value = pool.filter(i => i.image).slice(0, 4)
|
||||||
if (!carouselItems.value.length) carouselItems.value = FALLBACK_HERO
|
if (!carouselItems.value.length) carouselItems.value = FALLBACK_HERO
|
||||||
inspireItems.value = (data.work || []).length ? data.work : FALLBACK_WORKS
|
inspireItems.value = (data.work || []).length ? data.work : FALLBACK_WORKS
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
// Admin → 首页内容
|
// Admin → 首页内容
|
||||||
// CRUD for the cards rendered on the public home page:
|
// CRUD for the cards rendered on the public home page:
|
||||||
// - hero : top-3-by-weight stacked deck in the hero
|
// - hero : top-4-by-weight collage in the hero
|
||||||
// - bento : "从一个起点开始" grid
|
|
||||||
// - work : "我们的作品" marquee — admin-curated featured outputs
|
// - 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.
|
// the already-generated files or paste an external URL.
|
||||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||||
import { api, jsonBody, generatedUrl } from '../api'
|
import { api, jsonBody, generatedUrl } from '../api'
|
||||||
import Icon from '../components/Icon.vue'
|
import Icon from '../components/Icon.vue'
|
||||||
|
|
||||||
const items = ref([])
|
const items = ref([])
|
||||||
const filter = ref('all') // 'all' | 'hero' | 'bento' | 'work'
|
const filter = ref('all') // 'all' | 'hero' | 'work'
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const editing = ref(null) // truthy when the form modal is open
|
const editing = ref(null) // truthy when the form modal is open
|
||||||
const picking = ref(false) // truthy when the image-picker modal is open
|
const picking = ref(false) // truthy when the image-picker modal is open
|
||||||
@@ -70,7 +69,7 @@ const pageNumbers = computed(() => {
|
|||||||
return out
|
return out
|
||||||
})
|
})
|
||||||
|
|
||||||
const KIND_DEFAULT_WEIGHT = { hero: 200, bento: 300, work: 100 }
|
const KIND_DEFAULT_WEIGHT = { hero: 200, work: 100 }
|
||||||
|
|
||||||
function openNew(kind) {
|
function openNew(kind) {
|
||||||
editing.value = { 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)
|
onMounted(refresh)
|
||||||
</script>
|
</script>
|
||||||
@@ -186,12 +184,10 @@ onMounted(refresh)
|
|||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<button @click="setFilter('all')" class="filter-pill" :class="filter === 'all' && 'on'">全部</button>
|
<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('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>
|
<button @click="setFilter('work')" class="filter-pill" :class="filter === 'work' && 'on'">我们的作品</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button @click="openNew('hero')" class="btn-soft">+ Hero</button>
|
<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>
|
<button @click="openNew('work')" class="btn-soft">+ Work</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -209,7 +205,6 @@ onMounted(refresh)
|
|||||||
<div class="absolute top-3 left-3 flex items-center gap-1.5">
|
<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"
|
<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'
|
: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'">
|
: 'bg-sky-500/20 text-sky-200 ring-sky-400/30'">
|
||||||
{{ rec.kind }}
|
{{ rec.kind }}
|
||||||
</span>
|
</span>
|
||||||
@@ -255,7 +250,7 @@ onMounted(refresh)
|
|||||||
<div class="px-5 py-3 border-b border-[color:var(--hairline)] flex items-center justify-between">
|
<div class="px-5 py-3 border-b border-[color:var(--hairline)] flex items-center justify-between">
|
||||||
<h2 class="text-sm font-semibold">
|
<h2 class="text-sm font-semibold">
|
||||||
{{ form.id ? '编辑' : '新增' }} ·
|
{{ form.id ? '编辑' : '新增' }} ·
|
||||||
{{ form.kind === 'hero' ? 'Hero 卡片' : form.kind === 'bento' ? 'Bento 灵感' : '作品' }}
|
{{ form.kind === 'hero' ? 'Hero 卡片' : '作品' }}
|
||||||
</h2>
|
</h2>
|
||||||
<button @click="closeForm" class="text-[color:var(--fg-faint)] hover:text-[color:var(--fg)]">
|
<button @click="closeForm" class="text-[color:var(--fg-faint)] hover:text-[color:var(--fg)]">
|
||||||
<Icon name="close" class="w-4 h-4" />
|
<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>
|
<label class="block text-xs text-[color:var(--fg-3)] mb-1.5">类型</label>
|
||||||
<div class="flex gap-1.5">
|
<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 = '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>
|
<button type="button" @click="form.kind = 'work'" class="kind-btn" :class="form.kind === 'work' && 'on'">Work</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -315,7 +309,7 @@ onMounted(refresh)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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"
|
<textarea v-model="form.prompt" rows="3" class="field resize-none"
|
||||||
placeholder="一位身穿米色风衣的女子站在雨夜的霓虹街道,胶片质感,浅景深,电影感"></textarea>
|
placeholder="一位身穿米色风衣的女子站在雨夜的霓虹街道,胶片质感,浅景深,电影感"></textarea>
|
||||||
</div>
|
</div>
|
||||||
@@ -326,19 +320,6 @@ onMounted(refresh)
|
|||||||
<input v-model="form.title" class="field" placeholder="留空则只展示图片" />
|
<input v-model="form.title" class="field" placeholder="留空则只展示图片" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
<p v-if="error" class="text-xs text-rose-500">{{ error }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user