feat(studio): feed 卖相打磨——互动按钮玻璃质感 + 卡片封面"无图也好看"(评审 V-1/V-2)
- V-1 互动按钮(InteractBar):深蓝实底 rgba(16,21,34,.6)→半透白 rgba(255,255,255,.10)+backdrop-filter blur(8px)+box-shadow,抖音式悬浮玻璃;图标/计数/点赞态行为不变
- V-2 卡片封面(GameCard+新增 utils/coverGradient.ts):纯确定性 FNV-1a hash→5套渐变主题(青绿/森系/青紫/暖/冷)+3套装饰,无封面卡按 title 分化渐变+柔光点+波形(无图也好看);真实封面优先逻辑不变
- verify:vue-tsc 类型门逮修一处真类型问题(computed 早返回{});build双绿;真机 :4173 CDP 实算样式(bg rgba(255,255,255,.1)/blur8)+截图(9卡按title渐变各异)双证
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
22bc528243
commit
c50e336792
@ -7,6 +7,7 @@
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import type { FeedCard } from '../api/types'
|
||||
import { resolveCoverStyle } from '../utils/coverGradient'
|
||||
|
||||
const props = defineProps<{
|
||||
/** 游戏流卡片数据(严格贴契约 FeedCard) */
|
||||
@ -33,6 +34,26 @@ const hasCover = computed(() => {
|
||||
const url = props.card.coverUrl
|
||||
return !!url && !PLACEHOLDER_COVER_RE.test(url)
|
||||
})
|
||||
|
||||
/**
|
||||
* 无封面时的确定性渐变兜底样式(spec §3.3 V-2):按 title(兜底 gameId)算稳定 hash,
|
||||
* 取 ≥3 套青绿/青紫/暖色系渐变 + 柔光点 + 波形装饰之一,让无图卡也有辨识度、不千篇一律。
|
||||
* 仅在 !hasCover 时计算并下发到 CSS 变量(有真封面时为 null,不渲染兜底装饰)。
|
||||
*/
|
||||
const coverFallback = computed(() => (hasCover.value ? null : resolveCoverStyle(props.card)))
|
||||
/** 兜底底纹的内联 CSS 变量(渐变/柔光色/波形色/柔光落位);有真封面时返回空对象。 */
|
||||
const fallbackVars = computed<Record<string, string>>(() => {
|
||||
const f = coverFallback.value
|
||||
const vars: Record<string, string> = {}
|
||||
if (!f) return vars
|
||||
vars['--cover-grad'] = f.theme.gradient
|
||||
vars['--cover-glow'] = f.theme.glow
|
||||
vars['--cover-wave'] = f.theme.wave
|
||||
vars['--cover-glow-top'] = f.decor.glowTop
|
||||
vars['--cover-glow-right'] = f.decor.glowRight
|
||||
// 波形相位不走 CSS 变量,改用模板上的 --wave{N} 类切换 clip-path(见 template :class)
|
||||
return vars
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -43,8 +64,15 @@ const hasCover = computed(() => {
|
||||
:style="{ backgroundImage: hasCover ? `url(${card.coverUrl})` : undefined }"
|
||||
@click="emit('play', { gameId: card.gameId, versionId: card.versionId })"
|
||||
>
|
||||
<!-- 无封面(含 1×1 占位图)时的标题底纹兜底 -->
|
||||
<div v-if="!hasCover" class="game-card__cover-fallback">{{ card.title }}</div>
|
||||
<!-- 无封面(含 1×1 占位图)时的渐变底纹兜底:按 hash 选主题渐变 + 柔光点(::before) + 波形(::after) + 标题 -->
|
||||
<div
|
||||
v-if="!hasCover"
|
||||
class="game-card__cover-fallback"
|
||||
:class="`game-card__cover-fallback--wave${coverFallback?.decor.wavePhase ?? 0}`"
|
||||
:style="fallbackVars"
|
||||
>
|
||||
<span class="game-card__cover-fallback-title">{{ card.title }}</span>
|
||||
</div>
|
||||
<!-- 中央播放按钮 -->
|
||||
<div class="game-card__play">▶</div>
|
||||
</div>
|
||||
@ -82,6 +110,8 @@ const hasCover = computed(() => {
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* 无封面兜底底纹:渐变主题由 JS 按 hash 下发到 --cover-* 变量(多套青绿/青紫/暖色,见 utils/coverGradient.ts)。
|
||||
背景=主题渐变;::before=右上柔光聚光点;::after=底部波形装饰;标题居中浮于装饰之上。 */
|
||||
.game-card__cover-fallback {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@ -89,11 +119,53 @@ const hasCover = computed(() => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
overflow: hidden;
|
||||
/* --cover-grad 由组件按 hash 注入;变量缺省时退回原中性渐变(健壮兜底) */
|
||||
background: var(--cover-grad, linear-gradient(135deg, var(--panel), var(--panel-2)));
|
||||
}
|
||||
/* 右上柔光聚光点(对齐 demo .game-cover::before 的暖光球,落位由 --cover-glow-* 错开) */
|
||||
.game-card__cover-fallback::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: var(--cover-glow-top, 18px);
|
||||
right: var(--cover-glow-right, 28px);
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: var(--cover-glow, rgba(49, 230, 255, 0.28));
|
||||
box-shadow: 0 0 30px var(--cover-glow, rgba(49, 230, 255, 0.28));
|
||||
pointer-events: none;
|
||||
}
|
||||
/* 底部波形装饰(对齐 demo .game-cover::after 的折线波纹,向下渐隐融入卡底;相位由变体类切换 clip-path) */
|
||||
.game-card__cover-fallback::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -8%;
|
||||
right: -8%;
|
||||
bottom: 0;
|
||||
height: 54%;
|
||||
background: linear-gradient(180deg, var(--cover-wave, rgba(49, 230, 255, 0.4)), rgba(9, 11, 21, 0.96));
|
||||
clip-path: polygon(0 100%, 16% 40%, 27% 72%, 42% 25%, 58% 72%, 74% 36%, 100% 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
/* 三套波形相位(错开峰谷,让相邻同主题卡也不雷同) */
|
||||
.game-card__cover-fallback--wave1::after {
|
||||
clip-path: polygon(0 100%, 12% 56%, 30% 30%, 48% 66%, 63% 28%, 82% 60%, 100% 100%);
|
||||
}
|
||||
.game-card__cover-fallback--wave2::after {
|
||||
clip-path: polygon(0 100%, 20% 34%, 36% 70%, 52% 38%, 68% 74%, 86% 42%, 100% 100%);
|
||||
}
|
||||
/* 兜底标题(浮于渐变与装饰之上) */
|
||||
.game-card__cover-fallback-title {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 100%;
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--muted);
|
||||
background: linear-gradient(135deg, var(--panel), var(--panel-2));
|
||||
line-height: 1.35;
|
||||
color: var(--ink);
|
||||
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
/* 中央播放按钮 */
|
||||
.game-card__play {
|
||||
|
||||
@ -69,10 +69,16 @@ const emit = defineEmits<{ (e: 'interact', action: FeedAction): void }>()
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
background: rgba(16, 21, 34, 0.6);
|
||||
border: 1px solid var(--border);
|
||||
/* 抖音式悬浮玻璃感(对齐 demo .feed-action i,spec §3.3 V-1):
|
||||
半透白底 + 半透白描边 + 轻投影,配 backdrop-filter 模糊出通透玻璃层次。 */
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.26);
|
||||
/* 模糊适度(8px)兼顾移动端性能;-webkit- 前缀兜底 iOS Safari/旧 WebView */
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
backdrop-filter: blur(8px);
|
||||
border-radius: 50%;
|
||||
transition: transform 0.1s ease, color 0.15s ease;
|
||||
transition: transform 0.1s ease, color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.interact-bar__item:active .interact-bar__icon {
|
||||
transform: scale(0.9);
|
||||
|
||||
120
game-studio/src/utils/coverGradient.ts
Normal file
120
game-studio/src/utils/coverGradient.ts
Normal file
@ -0,0 +1,120 @@
|
||||
/**
|
||||
* coverGradient —— feed 卡片「无封面也好看」的确定性渐变兜底工具(纯前端,零副作用)。
|
||||
*
|
||||
* 背景(spec §3.3 V-2):真实生成多无缩略图,旧逻辑统一退「单渐变标题底纹」→ 千篇一律。
|
||||
* demo `.game-cover` 的做法是按主题给 ≥3 套渐变 + 轻装饰(聚光点/波形),让无图卡也有辨识度。
|
||||
* 本工具据卡片稳定标识(title 为主、gameId 兜底)算确定性 hash,从预置渐变/装饰集中取一套,
|
||||
* 保证「同 title 永远同渐变」(spec 硬约束)、不同卡尽量错开,不引入随机性、不依赖任何运行时状态。
|
||||
*
|
||||
* 纯函数定位:只做字符串 → 主题映射,绝不发请求、不读 store、不碰 DOM。
|
||||
* 真实封面(OSS http(s) 图)仍由组件优先用作背景,本兜底仅在无封面时启用。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 单套封面主题:背景渐变 + 一个聚光点色(柔光装饰)+ 一个波形描边色(底部波纹装饰)。
|
||||
* 三套覆盖 青绿 / 青紫 / 暖色 三个色系(对齐 demo `.game-cover` / `.ugc` / `.action` 三主题取向),
|
||||
* 全部沿用 D4 暗色科技风、与 tokens.css 色板同源,保证整体观感统一。
|
||||
*/
|
||||
export interface CoverTheme {
|
||||
/** 主背景渐变(CSS linear-gradient 字符串) */
|
||||
gradient: string
|
||||
/** 右上柔光聚光点颜色(rgba,含透明,叠 box-shadow 出光晕) */
|
||||
glow: string
|
||||
/** 底部波形装饰描边/填充色(rgba,向下渐隐融入卡底) */
|
||||
wave: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 预置主题集(≥3 套,覆盖青绿/青紫/暖三色系)。
|
||||
* 顺序即 hash 取模索引顺序;后续可追加,不影响既有 title 的稳定性(hash 不变、集合扩容会重映射,
|
||||
* 故扩容须谨慎——MVP 内固定为这 5 套,兼顾辨识度与统一感)。
|
||||
*/
|
||||
const COVER_THEMES: CoverTheme[] = [
|
||||
// 0 · 青绿主调(demo 默认 .game-cover 取向:青 → 紫红 → 深底)
|
||||
{
|
||||
gradient: 'linear-gradient(160deg, #18384a 0%, #4c2344 56%, #121626 100%)',
|
||||
glow: 'rgba(49, 230, 255, 0.30)', // cyan 柔光
|
||||
wave: 'rgba(49, 230, 255, 0.42)',
|
||||
},
|
||||
// 1 · 青绿森系(demo .ugc 取向:墨绿 → 靛蓝 → 深底)
|
||||
{
|
||||
gradient: 'linear-gradient(160deg, #1b3d35 0%, #26315e 58%, #111620 100%)',
|
||||
glow: 'rgba(124, 242, 164, 0.28)', // green 柔光
|
||||
wave: 'rgba(124, 242, 164, 0.40)',
|
||||
},
|
||||
// 2 · 青紫主调(demo .action 取向:紫 → 青 → 深底)
|
||||
{
|
||||
gradient: 'linear-gradient(160deg, #2b1b4a 0%, #0e5360 58%, #121626 100%)',
|
||||
glow: 'rgba(157, 124, 255, 0.30)', // violet 柔光
|
||||
wave: 'rgba(49, 230, 255, 0.38)',
|
||||
},
|
||||
// 3 · 暖色主调(粉橙 → 紫 → 深底,补 demo 之外的暖色系,拉开多样性)
|
||||
{
|
||||
gradient: 'linear-gradient(160deg, #4a2330 0%, #3a2456 55%, #141324 100%)',
|
||||
glow: 'rgba(255, 200, 87, 0.30)', // amber 柔光
|
||||
wave: 'rgba(255, 102, 157, 0.40)', // pink 波形
|
||||
},
|
||||
// 4 · 冷青主调(青绿双调,整体偏冷亮,与 0/1 错开)
|
||||
{
|
||||
gradient: 'linear-gradient(160deg, #123a44 0%, #1d4a5e 52%, #0f1622 100%)',
|
||||
glow: 'rgba(49, 230, 255, 0.26)',
|
||||
wave: 'rgba(124, 242, 164, 0.38)',
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* 装饰朝向变体(柔光点落位 + 波形相位错开),让相邻卡即便同主题也有细微差异。
|
||||
* 纯展示参数,组件据此摆放 ::before 聚光点与 ::after 波形 clip-path 的水平偏移。
|
||||
*/
|
||||
const DECOR_VARIANTS = [
|
||||
{ glowTop: '16px', glowRight: '20px', wavePhase: 0 },
|
||||
{ glowTop: '22px', glowRight: '64px', wavePhase: 1 },
|
||||
{ glowTop: '38px', glowRight: '28px', wavePhase: 2 },
|
||||
] as const
|
||||
|
||||
export type CoverDecor = (typeof DECOR_VARIANTS)[number]
|
||||
|
||||
/** 封面兜底样式解析结果:一套主题 + 一套装饰变体。 */
|
||||
export interface CoverStyle {
|
||||
theme: CoverTheme
|
||||
decor: CoverDecor
|
||||
}
|
||||
|
||||
/**
|
||||
* 32 位确定性字符串 hash(FNV-1a 变体)。
|
||||
* 同输入恒同输出、跨端一致、无随机,用于把卡片标识稳定映射到主题/装饰索引。
|
||||
* 返回非负整数。
|
||||
*/
|
||||
function hashString(input: string): number {
|
||||
let h = 0x811c9dc5 // FNV offset basis
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
h ^= input.charCodeAt(i)
|
||||
// FNV prime 乘法,用移位组合避免大数精度问题;最终用 >>> 0 收敛为 32 位无符号
|
||||
h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0
|
||||
}
|
||||
return h >>> 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 据卡片稳定标识解析无封面兜底的渐变主题 + 装饰变体。
|
||||
*
|
||||
* @param seed 卡片稳定标识:优先用 title(spec 硬约束「同 title 同渐变」),title 为空时退 gameId 字符串。
|
||||
* 传入对象时取 title || String(gameId),保证有可哈希种子、永不抛错。
|
||||
* @returns 一套确定性的 { theme, decor },供组件渲染兜底封面。
|
||||
*/
|
||||
export function resolveCoverStyle(seed: string | { title?: string; gameId?: number }): CoverStyle {
|
||||
// 归一化种子:字符串直接用;对象优先 title,再退 gameId,最后退固定串兜底(确保非空、可哈希)
|
||||
let key: string
|
||||
if (typeof seed === 'string') {
|
||||
key = seed
|
||||
} else {
|
||||
key = (seed.title && seed.title.trim()) || (seed.gameId != null ? `g${seed.gameId}` : '')
|
||||
}
|
||||
if (!key) key = 'zaomeng-fallback' // 极端兜底:无任何标识时给固定种子,避免 NaN/空哈希
|
||||
|
||||
const h = hashString(key)
|
||||
// 主题与装饰用 hash 的不同位段取模,降低「主题相同则装饰也相同」的相关性
|
||||
const theme = COVER_THEMES[h % COVER_THEMES.length]
|
||||
const decor = DECOR_VARIANTS[(h >>> 8) % DECOR_VARIANTS.length]
|
||||
return { theme, decor }
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user