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:
zizi 2026-06-16 10:24:13 +00:00
parent 22bc528243
commit c50e336792
3 changed files with 205 additions and 7 deletions

View File

@ -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 {

View File

@ -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 ispec §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);

View 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 hashFNV-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 titlespec 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 }
}