Commit 9bf9553a authored by shrabbit's avatar shrabbit
Browse files

feat: 新增社区内容页面

- 新增 /community 页面,含 6 个章节(总览/红点/表情包/游戏列表/新闻/结语)

- EmojiWall 表情包墙:分类切换、悬停高亮、点击预览,支持过渡动画

- GameGrid 游戏列表:9 款游戏按品牌色主题化,悬停浮起、点亮边框

- NewsCard 新闻卡片:图集导航、状态徽章脉冲动画、原文跳转链接

- community.ts 数据:9 款游戏 + 2 个表情包 + 红点模块 + 活动奖励 + 活动样本 + 4 段代码片段

- 全部 4 个页面(home/wc/sext/community)已通过 dev server HTTP 200 验证
parent 564551a0
Loading
Loading
Loading
Loading
+372 −0
Original line number Diff line number Diff line
<template>
  <div class="emoji-wall">
    <!-- 表情包切换 -->
    <div class="emoji-wall__tabs">
      <button
        v-for="pack in packs"
        :key="pack.id"
        class="emoji-wall__tab"
        :class="{ 'is-active': activePackId === pack.id }"
        :data-tone="pack.tone"
        type="button"
        @click="activePackId = pack.id"
      >
        <img
          :src="pack.icon"
          :alt="pack.name"
          class="emoji-wall__tab-icon"
          loading="lazy"
          decoding="async"
        />
        <div class="emoji-wall__tab-info">
          <span class="emoji-wall__tab-name">{{ pack.name }}</span>
          <span class="emoji-wall__tab-count">{{ pack.num }}</span>
        </div>
      </button>
    </div>

    <!-- 表情网格 -->
    <div class="emoji-wall__grid" :data-tone="activePack?.tone">
      <transition-group name="emoji-fade" tag="div" class="emoji-wall__grid-inner">
        <button
          v-for="(emoji, i) in activeEmojis"
          :key="emoji.id"
          class="emoji-wall__cell"
          :style="{ animationDelay: i * 0.04 + 's' }"
          type="button"
          :title="emoji.name"
          @click="onPick(emoji)"
          @mouseenter="hoverIdx = i"
          @mouseleave="hoverIdx = -1"
        >
          <img
            :src="emoji.icon"
            :alt="emoji.name"
            loading="lazy"
            decoding="async"
            class="emoji-wall__img"
          />
          <span class="emoji-wall__name">{{ emoji.name }}</span>
        </button>
      </transition-group>
    </div>

    <!-- 详情预览 -->
    <transition name="emoji-pop">
      <div v-if="picked" class="emoji-wall__picked">
        <img :src="picked.icon" :alt="picked.name" class="emoji-wall__picked-img" />
        <div class="emoji-wall__picked-info">
          <span class="emoji-wall__picked-tag">PICKED</span>
          <h4 class="emoji-wall__picked-name">{{ picked.name }}</h4>
          <span class="emoji-wall__picked-id">id: {{ picked.id }}</span>
        </div>
        <button class="emoji-wall__picked-close" type="button" @click="picked = null" aria-label="关闭">
          <svg width="12" height="12" viewBox="0 0 24 24" fill="none" aria-hidden="true">
            <path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
          </svg>
        </button>
      </div>
    </transition>
  </div>
</template>

<script setup lang="ts">
import type { EmojiPack, EmojiItem } from '~/data/community'

interface Props {
  packs: EmojiPack[]
}
const props = defineProps<Props>()

const activePackId = ref(props.packs[0]?.id)
const hoverIdx = ref(-1)
const picked = ref<EmojiItem | null>(null)

const activePack = computed(() =>
  props.packs.find((p) => p.id === activePackId.value)
)

const activeEmojis = computed(() =>
  activePack.value?.list.filter((e) => e.isAvailable) ?? []
)

const onPick = (emoji: EmojiItem) => {
  picked.value = picked.value?.id === emoji.id ? null : emoji
}
</script>

<style scoped>
.emoji-wall {
  background: var(--color-bg-elev);
  border: 1px solid var(--color-line);
  border-radius: var(--radius-lg);
  overflow: hidden;
}

.emoji-wall__tabs {
  display: flex;
  gap: var(--space-2);
  padding: var(--space-3) var(--space-4);
  background: rgba(212, 164, 90, 0.03);
  border-bottom: 1px solid var(--color-line);
  flex-wrap: wrap;
}

.emoji-wall__tab {
  display: inline-flex;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-2) var(--space-4);
  background: transparent;
  border: 1px solid transparent;
  border-radius: var(--radius-md);
  cursor: pointer;
  transition: all var(--duration-base) var(--ease-out-quint);
  color: var(--color-text-muted);
  font-family: inherit;
}

.emoji-wall__tab:hover {
  background: rgba(212, 164, 90, 0.04);
  color: var(--color-text-secondary);
}

.emoji-wall__tab.is-active {
  background: var(--color-bg-elev-2);
  border-color: var(--color-line-strong);
  color: var(--color-text-primary);
}

.emoji-wall__tab-icon {
  width: 32px;
  height: 32px;
  object-fit: contain;
  border-radius: var(--radius-sm);
}

.emoji-wall__tab-info {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 2px;
}

.emoji-wall__tab-name {
  font-size: 0.875rem;
  font-weight: 500;
}

.emoji-wall__tab-count {
  font-family: var(--font-mono);
  font-size: 0.625rem;
  color: var(--color-text-muted);
  letter-spacing: 0.1em;
}

.emoji-wall__tab.is-active .emoji-wall__tab-count {
  color: var(--tone-color, var(--color-accent));
}

.emoji-wall__tab[data-tone='anemo'].is-active .emoji-wall__tab-count { color: var(--color-anemo); }
.emoji-wall__tab[data-tone='geo'].is-active   .emoji-wall__tab-count { color: var(--color-geo); }
.emoji-wall__tab[data-tone='electro'].is-active .emoji-wall__tab-count { color: var(--color-electro); }
.emoji-wall__tab[data-tone='dendro'].is-active .emoji-wall__tab-count { color: var(--color-dendro); }
.emoji-wall__tab[data-tone='hydro'].is-active  .emoji-wall__tab-count { color: var(--color-hydro); }
.emoji-wall__tab[data-tone='pyro'].is-active   .emoji-wall__tab-count { color: var(--color-pyro); }
.emoji-wall__tab[data-tone='cryo'].is-active   .emoji-wall__tab-count { color: var(--color-cryo); }

.emoji-wall__grid {
  padding: var(--space-5);
  background: var(--color-bg-deep);
  position: relative;
}

.emoji-wall__grid::before {
  content: '';
  position: absolute;
  top: 0;
  left: 0;
  width: 4px;
  height: 100%;
  background: var(--tone-color, var(--color-accent));
  opacity: 0.6;
}

.emoji-wall__grid[data-tone='anemo']    { --tone-color: var(--color-anemo); }
.emoji-wall__grid[data-tone='geo']      { --tone-color: var(--color-geo); }
.emoji-wall__grid[data-tone='electro']  { --tone-color: var(--color-electro); }
.emoji-wall__grid[data-tone='dendro']   { --tone-color: var(--color-dendro); }
.emoji-wall__grid[data-tone='hydro']    { --tone-color: var(--color-hydro); }
.emoji-wall__grid[data-tone='pyro']     { --tone-color: var(--color-pyro); }
.emoji-wall__grid[data-tone='cryo']     { --tone-color: var(--color-cryo); }
.emoji-wall__grid[data-tone='none']     { --tone-color: var(--color-accent); }

.emoji-wall__grid-inner {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
  gap: var(--space-3);
}

.emoji-wall__cell {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-3);
  background: var(--color-bg-elev);
  border: 1px solid var(--color-line);
  border-radius: var(--radius-md);
  cursor: pointer;
  transition: all var(--duration-base) var(--ease-out-quint);
  animation: emoji-cell-in 0.5s var(--ease-out-quint) both;
  position: relative;
  overflow: hidden;
}

@keyframes emoji-cell-in {
  from {
    opacity: 0;
    transform: translateY(8px) scale(0.96);
  }
  to {
    opacity: 1;
    transform: translateY(0) scale(1);
  }
}

.emoji-wall__cell::before {
  content: '';
  position: absolute;
  inset: 0;
  background: radial-gradient(circle at center, var(--tone-color), transparent 70%);
  opacity: 0;
  transition: opacity var(--duration-base) var(--ease-out-quint);
  pointer-events: none;
}

.emoji-wall__cell:hover {
  border-color: var(--color-line-strong);
  transform: translateY(-2px);
  box-shadow: var(--shadow-card);
}

.emoji-wall__cell:hover::before {
  opacity: 0.08;
}

.emoji-wall__cell:hover .emoji-wall__name {
  color: var(--color-accent);
}

.emoji-wall__img {
  width: 56px;
  height: 56px;
  object-fit: contain;
  transition: transform var(--duration-base) var(--ease-out-quint);
}

.emoji-wall__cell:hover .emoji-wall__img {
  transform: scale(1.1) rotate(-4deg);
}

.emoji-wall__name {
  font-size: 0.6875rem;
  color: var(--color-text-muted);
  text-align: center;
  line-height: 1.3;
  font-family: var(--font-mono);
  letter-spacing: 0.02em;
  transition: color var(--duration-fast) var(--ease-out-quint);
}

.emoji-wall__picked {
  display: flex;
  align-items: center;
  gap: var(--space-5);
  padding: var(--space-4) var(--space-5);
  background: linear-gradient(90deg, rgba(212, 164, 90, 0.06), transparent);
  border-top: 1px solid var(--color-line);
  position: relative;
}

.emoji-wall__picked-img {
  width: 80px;
  height: 80px;
  object-fit: contain;
  background: var(--color-bg-elev);
  border: 1px solid var(--color-line);
  border-radius: var(--radius-md);
  padding: 4px;
  box-sizing: border-box;
}

.emoji-wall__picked-info {
  display: flex;
  flex-direction: column;
  gap: 4px;
  flex: 1;
}

.emoji-wall__picked-tag {
  font-family: var(--font-mono);
  font-size: 0.625rem;
  letter-spacing: 0.25em;
  color: var(--color-accent);
}

.emoji-wall__picked-name {
  font-family: var(--font-serif);
  font-size: 1.25rem;
  font-weight: 600;
  color: var(--color-text-primary);
  margin: 0;
}

.emoji-wall__picked-id {
  font-family: var(--font-mono);
  font-size: 0.75rem;
  color: var(--color-text-muted);
}

.emoji-wall__picked-close {
  background: transparent;
  border: 1px solid var(--color-line);
  width: 28px;
  height: 28px;
  border-radius: 50%;
  color: var(--color-text-muted);
  cursor: pointer;
  display: grid;
  place-items: center;
  transition: all var(--duration-fast) var(--ease-out-quint);
}

.emoji-wall__picked-close:hover {
  border-color: var(--color-accent);
  color: var(--color-accent);
}

/* Transitions */
.emoji-fade-enter-active,
.emoji-fade-leave-active {
  transition: all 0.3s var(--ease-out-quint);
}
.emoji-fade-enter-from {
  opacity: 0;
  transform: translateY(8px);
}
.emoji-fade-leave-to {
  opacity: 0;
  transform: translateY(-8px);
}

.emoji-pop-enter-active,
.emoji-pop-leave-active {
  transition: all 0.3s var(--ease-out-quint);
}
.emoji-pop-enter-from,
.emoji-pop-leave-to {
  opacity: 0;
  transform: translateY(8px);
}
</style>
+230 −0
Original line number Diff line number Diff line
<template>
  <div class="game-grid">
    <article
      v-for="(g, i) in games"
      :key="g.id"
      class="game-card"
      :class="{ 'is-active': activeId === g.id }"
      :data-tone="g.tone"
      :style="{
        '--game-color': '#' + g.mainColor,
        animationDelay: i * 0.06 + 's'
      }"
      tabindex="0"
      @click="activeId = (activeId === g.id ? null : g.id)"
      @focusin="activeId = g.id"
    >
      <div class="game-card__bg"></div>
      <div class="game-card__inner">
        <div class="game-card__icon-wrap">
          <img
            :src="g.icon"
            :alt="g.name"
            class="game-card__icon"
            loading="lazy"
            decoding="async"
          />
        </div>

        <div class="game-card__body">
          <h3 class="game-card__name">{{ g.name }}</h3>
          <span class="game-card__en">{{ g.enName }}</span>
          <div class="game-card__meta">
            <span class="game-card__id">#{{ g.id }}</span>
            <span class="game-card__sep">·</span>
            <span class="game-card__op">{{ g.opName }}</span>
          </div>
        </div>

        <div class="game-card__badges">
          <span v-if="g.hasWiki" class="game-card__badge game-card__badge--wiki">
            WIKI
          </span>
          <span
            class="game-card__badge game-card__badge--color"
            :style="{ background: '#' + g.mainColor + '20', color: '#' + g.mainColor, borderColor: '#' + g.mainColor + '60' }"
          >
            #{{ g.mainColor }}
          </span>
        </div>
      </div>
    </article>
  </div>
</template>

<script setup lang="ts">
import type { GameEntry } from '~/data/community'

interface Props {
  games: GameEntry[]
}
const props = defineProps<Props>()

const activeId = ref<number | null>(null)
</script>

<style scoped>
.game-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
  gap: var(--space-4);
}

.game-card {
  position: relative;
  background: var(--color-bg-elev);
  border: 1px solid var(--color-line);
  border-radius: var(--radius-lg);
  overflow: hidden;
  cursor: pointer;
  transition: all var(--duration-base) var(--ease-out-quint);
  animation: game-card-in 0.6s var(--ease-out-quint) both;
  outline: none;
}

@keyframes game-card-in {
  from {
    opacity: 0;
    transform: translateY(12px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.game-card:hover,
.game-card:focus-visible,
.game-card.is-active {
  transform: translateY(-4px);
  border-color: var(--game-color);
  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.5),
              0 0 0 1px var(--game-color);
}

.game-card__bg {
  position: absolute;
  inset: 0;
  background: linear-gradient(135deg, var(--game-color) 0%, transparent 50%);
  opacity: 0.08;
  transition: opacity var(--duration-base) var(--ease-out-quint);
  z-index: 0;
}

.game-card:hover .game-card__bg,
.game-card.is-active .game-card__bg {
  opacity: 0.18;
}

.game-card__inner {
  position: relative;
  z-index: 1;
  padding: var(--space-5);
  display: flex;
  align-items: flex-start;
  gap: var(--space-4);
  flex-wrap: wrap;
}

.game-card__icon-wrap {
  width: 64px;
  height: 64px;
  border-radius: var(--radius-md);
  overflow: hidden;
  background: var(--color-bg-elev-2);
  border: 1px solid var(--color-line);
  flex-shrink: 0;
  transition: all var(--duration-base) var(--ease-out-quint);
}

.game-card:hover .game-card__icon-wrap,
.game-card.is-active .game-card__icon-wrap {
  border-color: var(--game-color);
  box-shadow: 0 0 16px color-mix(in srgb, var(--game-color) 40%, transparent);
}

.game-card__icon {
  width: 100%;
  height: 100%;
  object-fit: contain;
}

.game-card__body {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.game-card__name {
  font-family: var(--font-serif);
  font-size: 1.125rem;
  font-weight: 600;
  color: var(--color-text-primary);
  margin: 0;
  line-height: 1.2;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.game-card__en {
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  letter-spacing: 0.15em;
  color: var(--game-color);
  text-transform: uppercase;
  opacity: 0.8;
}

.game-card__meta {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  margin-top: var(--space-2);
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  color: var(--color-text-muted);
}

.game-card__id {
  color: var(--color-text-secondary);
}

.game-card__sep {
  opacity: 0.4;
}

.game-card__op {
  color: var(--color-text-muted);
}

.game-card__badges {
  display: flex;
  gap: 6px;
  flex-wrap: wrap;
  width: 100%;
  margin-top: var(--space-3);
  padding-top: var(--space-3);
  border-top: 1px solid var(--color-line-faint);
}

.game-card__badge {
  display: inline-flex;
  align-items: center;
  padding: 2px 8px;
  font-family: var(--font-mono);
  font-size: 0.625rem;
  letter-spacing: 0.05em;
  border: 1px solid var(--color-line);
  border-radius: 2px;
  text-transform: uppercase;
}

.game-card__badge--wiki {
  color: var(--color-accent);
  border-color: rgba(212, 164, 90, 0.4);
  background: rgba(212, 164, 90, 0.08);
}
</style>
+427 −0

File added.

Preview size limit exceeded, changes collapsed.

+245 −0

File added.

Preview size limit exceeded, changes collapsed.

+687 −0

File added.

Preview size limit exceeded, changes collapsed.