PhotoCard.vue 4.91 KB
<template>
  <view class="card" @click="onCard">
    <view class="img-wrap" :class="imgWrapClass">
      <image
        class="img"
        :src="imageUrl"
        mode="aspectFill"
        @load="onImgLoad"
        @error="onImgError"
      />
    </view>
    <view class="body">
      <text class="title">{{ title }}</text>
      <view v-if="tags?.length" class="tags">
        <text v-for="(t, i) in tags" :key="i" class="tag">{{ t }}</text>
      </view>
      <view class="row">
        <text class="author">@{{ author }}</text>
        <view class="like" @click.stop="toggleLike">
          <image
            class="heart-img"
            :src="liked ? likeOnIcon : likeOffIcon"
            mode="aspectFit"
          />
          <text class="like-num">{{ likeCount }}</text>
        </view>
      </view>
    </view>
  </view>
</template>

<script setup lang="ts">
import { computed, ref, watch, withDefaults } from "vue";
import { apiEnabled } from "@/config/api";
import { apiFailureMessage, isMpSessionExpiredError, post } from "@/utils/api/http";
import { ensureMpSession } from "@/utils/api/mpSession";
import { staticAsset } from "@/utils/staticAsset";

const props = withDefaults(
  defineProps<{
    id: number;
    imageUrl: string;
    title: string;
    likes: number;
    author: string;
    tags?: string[];
    /** 接口返回:当前用户是否已赞(刷新首页后保持红心) */
    liked?: boolean;
  }>(),
  { liked: false }
);
const likeOnIcon = staticAsset("cang_on.png");
const likeOffIcon = staticAsset("cang.png");

/** 横图统一 4:3、竖图统一 3:4;加载前暂按竖图比例占位,避免高度乱跳 */
type ImgShape = "pending" | "landscape" | "portrait";

const imgShape = ref<ImgShape>("pending");

const imgWrapClass = computed(() => ({
  "img-wrap--landscape": imgShape.value === "landscape",
  "img-wrap--portrait": imgShape.value === "portrait",
  "img-wrap--pending": imgShape.value === "pending",
}));

function setShapeFromSize(width: number, height: number): void {
  if (width <= 0 || height <= 0) {
    imgShape.value = "landscape";
    return;
  }
  imgShape.value = width >= height ? "landscape" : "portrait";
}

function onImgLoad(e: { detail?: { width?: number; height?: number } }): void {
  const d = e?.detail;
  setShapeFromSize(Number(d?.width) || 0, Number(d?.height) || 0);
}

function onImgError(): void {
  imgShape.value = "landscape";
}

watch(
  () => props.imageUrl,
  () => {
    imgShape.value = "pending";
  }
);

const liked = ref(!!props.liked);
const likeCount = ref(props.likes);

watch(
  () => [props.likes, props.liked] as const,
  ([lv, likedV]) => {
    likeCount.value = lv;
    liked.value = !!likedV;
  }
);

async function toggleLike() {
  if (apiEnabled()) {
    const wid = Number(props.id);
    if (!Number.isFinite(wid) || wid <= 0) {
      uni.showToast({ title: "作品信息异常", icon: "none" });
      return;
    }
    try {
      await ensureMpSession();
      const res = await post<{ liked: boolean; likes: number }>(
        `/api/v1/works/${wid}/like`,
        {}
      );
      const d = res.data as { liked?: unknown; likes?: unknown };
      liked.value = !!d.liked;
      const n = Number(d.likes);
      likeCount.value = Number.isFinite(n) ? n : 0;
    } catch (e) {
      if (isMpSessionExpiredError(e)) {
        return;
      }
      uni.showToast({
        title: apiFailureMessage(e, "点赞失败"),
        icon: "none",
      });
    }
    return;
  }
  if (liked.value) likeCount.value -= 1;
  else likeCount.value += 1;
  liked.value = !liked.value;
}

function onCard() {
  uni.navigateTo({
    url: `/pages/photo-detail/photo-detail?id=${props.id}`,
  });
}
</script>

<style scoped>
.card {
  overflow: hidden;
  margin-bottom: 0;
  border-radius: 24rpx;
  background: #fff;
  box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
}

.img-wrap {
  position: relative;
  overflow: hidden;
  width: 100%;
  background: #f3f4f6;
}

/* 横向:统一 4:3;竖向:统一 3:4 */
.img-wrap--landscape {
  aspect-ratio: 4 / 3;
}

.img-wrap--portrait,
.img-wrap--pending {
  aspect-ratio: 3 / 4;
}

.img {
  position: absolute;
  left: 0;
  top: 0;
  display: block;
  width: 100%;
  height: 100%;
}

.body {
  padding: 12rpx 24rpx 24rpx;
}

.title {
  display: -webkit-box;
  overflow: hidden;
  font-size: 30rpx;
  color: #111827;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 1;
}

.tags {
  display: flex;
  flex-wrap: wrap;
  gap: 10rpx;
  margin-top: 10rpx;
}

.tag {
  padding: 6rpx 16rpx;
  font-size: 22rpx;
  color: #4a90e2;
  border: 1rpx solid rgba(74, 144, 226, 0.35);
  border-radius: 999rpx;
  background: rgba(74, 144, 226, 0.12);
}

.row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-top: 12rpx;
  font-size: 24rpx;
  color: #6b7280;
}

.author {
  font-size: 22rpx;
}

.like {
  display: flex;
  align-items: center;
  gap: 8rpx;
}

.heart-img {
  width: 28rpx;
  height: 28rpx;
  flex-shrink: 0;
}

.like-num {
  font-size: 22rpx;
}
</style>