Kaynağa Gözat

Merge branch 'master' of http://www.sysuimars.cn:3000/feiniao/adopt-mini-h5

刘秀芳 1 hafta önce
ebeveyn
işleme
6a6baba276

BIN
src/assets/font/jiangxizhuokai.ttf


BIN
src/assets/img/guard/board.png


BIN
src/assets/img/guard/border-bg.png


+ 6 - 0
src/assets/less/index.less

@@ -15,6 +15,12 @@
     --primary-btn-color: #fe2c55;
 }
 
+@font-face {
+  font-family: jiangxizhuokai;
+  src: url('@/assets/font/jiangxizhuokai.ttf') format('truetype');
+  font-display: swap;
+}
+
 * {
     user-select: none;
     -webkit-tap-highlight-color: transparent;

+ 7 - 27
src/components/UserPanel.vue

@@ -28,7 +28,7 @@
             :key="v"
             class="grid-item"
           >
-            <img src="https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png" class="cover" alt="" />
+            <img src="https://fuss10.elemecdn.com/e/5d/4a731a90594a4af544c0c25941171jpeg.jpeg" class="cover" alt="" />
           </div>
         </div>
       </div>
@@ -51,22 +51,15 @@ type PanelItem = {
   author?: {
     nickname?: string
     signature?: string
-    avatar_168x168?: { url_list?: string[] }
   }
-  video?: { cover?: { url_list?: string[] } }
 }
 
 const props = defineProps({
   currentItem: { type: Object, default: () => ({}) },
-  active: { type: Boolean, default: false },
 })
 
 const emit = defineEmits<{
   back: []
-  toggleCanMove: [boolean]
-  showFollowSetting: []
-  showFollowSetting2: []
-  'update:currentItem': [unknown]
 }>()
 
 const router = useRouter()
@@ -96,23 +89,13 @@ const adoptFriendsText = computed(() => {
   return `${n}个朋友认养`
 })
 
-const avatarUrl = computed(() => {
-  const cover = item.value.video?.cover?.url_list?.[0]
-  const avatar = author.value.avatar_168x168?.url_list?.[0]
-  return (
-    // _checkImgUrl(cover || avatar) ||
-    'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png'
-  )
-})
+const avatarUrl =
+  'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png'
 
 function onGuard() {
   _notice('守护功能开发中')
   router.push('/my-guard')
 }
-
-defineExpose({
-  cancelFollow: () => { },
-})
 </script>
 
 <style scoped lang="less">
@@ -150,7 +133,7 @@ defineExpose({
   }
 
   .main {
-    padding: 6rem 18rem;
+    padding: 6rem 18rem 0;
 
     .farm-card {
       .farm-header {
@@ -206,25 +189,23 @@ defineExpose({
     }
 
     .works-section {
-      margin-top: 12rem;
       margin-left: -18rem;
       margin-right: -18rem;
 
       .works-tabs {
-        padding: 10rem 18rem 0;
+        padding: 24rem 18rem 0;
         background: #fff;
+        margin-bottom: 14rem;
 
         .tab {
           display: inline-block;
-          font-size: 15rem;
           font-weight: 500;
           color: #b0b0b0;
           padding-bottom: 10rem;
           position: relative;
 
           &.active {
-            color: #1d2129;
-            font-weight: 600;
+            color: #000;
 
             &::after {
               content: '';
@@ -245,7 +226,6 @@ defineExpose({
         grid-template-columns: repeat(3, 1fr);
         gap: 2rem;
         padding: 2rem 0;
-        background: #161616;
 
         .grid-item {
           aspect-ratio: 3 / 4;

+ 56 - 0
src/components/image/BaseImage.vue

@@ -0,0 +1,56 @@
+<template>
+  <div class="image-wrapper">
+    <img class="cover-image" :src="imageSrc" alt="" @error="loadFailed = true" />
+    <p v-if="loadFailed" class="error-tip">图片加载失败</p>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue'
+import { _checkImgUrl } from '@/utils/index'
+
+const props = defineProps({
+  item: { type: Object, required: true },
+})
+
+const loadFailed = ref(false)
+
+const imageSrc = computed(() => {
+  const item = props.item as {
+    imageUrl?: string
+    video?: { cover?: { url_list?: string[] } }
+  }
+  const raw = item.imageUrl || item.video?.cover?.url_list?.[0]
+  return _checkImgUrl(raw)
+})
+
+watch(imageSrc, () => {
+  loadFailed.value = false
+})
+</script>
+
+<style scoped lang="less">
+.image-wrapper {
+  position: relative;
+  width: 100%;
+  height: 100%;
+  background: #000;
+  overflow: hidden;
+
+  .cover-image {
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+    display: block;
+  }
+
+  .error-tip {
+    position: absolute;
+    left: 50%;
+    top: 50%;
+    transform: translate(-50%, -50%);
+    color: rgba(255, 255, 255, 0.7);
+    font-size: 14rem;
+  }
+}
+</style>

+ 19 - 0
src/mock/guardList.ts

@@ -0,0 +1,19 @@
+export type GuardListItem = {
+  name: string
+  major: string
+  identity: string
+  time: string
+}
+
+export const guardList: GuardListItem[] = [
+  { name: '张亮亮', major: '18级-经管', identity: '在职学生', time: '2025.05.06' },
+  { name: '张亮亮', major: '经管学院', identity: '在职老师', time: '2025.05.06' },
+  { name: '张亮亮', major: '18级-经管', identity: '在职学生', time: '2025.05.06' },
+  { name: '张亮亮', major: '经管学院', identity: '在职老师', time: '2025.05.06' },
+  { name: '张亮亮', major: '18级-经管', identity: '在职学生', time: '2025.05.06' },
+  { name: '张亮亮', major: '经管学院', identity: '在职老师', time: '2025.05.06' },
+  { name: '张亮亮', major: '18级-经管', identity: '在职学生', time: '2025.05.06' },
+  { name: '张亮亮', major: '经管学院', identity: '在职老师', time: '2025.05.06' },
+  { name: '张亮亮', major: '18级-经管', identity: '在职学生', time: '2025.05.06' },
+  { name: '张亮亮', major: '经管学院', identity: '在职老师', time: '2025.05.06' },
+]

+ 60 - 0
src/mock/homeData.ts

@@ -109,6 +109,66 @@ export const myVideos = buildVideoList(posts6.slice(2), {
   return v
 })
 
+export type GuardFeedItem = RecommendVideo & { imageUrl?: string }
+
+export type GuardFarm = {
+  id: string
+  name: string
+  level: string
+  images: GuardFeedItem[]
+}
+
+/** 我的守护演示图片(稳定 CDN) */
+export const GUARD_DEMO_IMAGES = [
+  'https://fuss10.elemecdn.com/e/5d/4a731a90594a4af544c0c25941171jpeg.jpeg',
+  'https://fuss10.elemecdn.com/a/3f/3302e58f9a181d2509f3dc0fa68b0jpeg.jpeg',
+  'https://fuss10.elemecdn.com/9/bb/e27858e973f5d7d3904835f46abbdjpeg.jpeg',
+]
+
+function buildGuardFarmImages(
+  farm: Pick<GuardFarm, 'id' | 'name' | 'level'>,
+  imageUrls: string[],
+  prefix: string,
+): GuardFeedItem[] {
+  const base = recommendVideos[0]
+  return imageUrls.map((imageUrl, i) => ({
+    ...base,
+    aweme_id: `${prefix}-${farm.id}-${i}`,
+    imageUrl,
+    farmName: farm.name,
+  })) as GuardFeedItem[]
+}
+
+function buildGuardFarms(
+  farms: Array<{ id: string; name: string; level: string; images: string[] }>,
+  prefix: string,
+): GuardFarm[] {
+  return farms.map((farm) => ({
+    id: farm.id,
+    name: farm.name,
+    level: farm.level,
+    images: buildGuardFarmImages(farm, farm.images, prefix),
+  }))
+}
+
+const guardMyFarmConfig = [
+  { id: 'f1', name: '农场名称一', level: '18级-经管', images: [GUARD_DEMO_IMAGES[0], GUARD_DEMO_IMAGES[1]] },
+  { id: 'f2', name: '农场名称二', level: '19级-农学', images: [GUARD_DEMO_IMAGES[1], GUARD_DEMO_IMAGES[2]] },
+  { id: 'f3', name: '农场名称三', level: '20级-园艺', images: [GUARD_DEMO_IMAGES[0], GUARD_DEMO_IMAGES[2]] },
+]
+
+const guardRecommendFarmConfig = [
+  { id: 'f1', name: '推荐农场一', level: '18级-经管', images: [GUARD_DEMO_IMAGES[2], GUARD_DEMO_IMAGES[0]] },
+  { id: 'f2', name: '推荐农场二', level: '17级-经管', images: [GUARD_DEMO_IMAGES[1], GUARD_DEMO_IMAGES[0]] },
+  { id: 'f3', name: '推荐农场三', level: '16级-园艺', images: [GUARD_DEMO_IMAGES[2], GUARD_DEMO_IMAGES[1]] },
+]
+
+/** 我的守护 - 我的 Tab(上下切农场,每农场内左右切图片) */
+export const guardMyFarms = buildGuardFarms(guardMyFarmConfig, 'guard-my')
+
+/** 我的守护 - 推荐 Tab */
+export const guardRecommendFarms = buildGuardFarms(guardRecommendFarmConfig, 'guard-recommend')
+
 const AVATAR =
   'https://p3-pc.douyinpic.com/img/aweme-avatar/tos-cn-avt-0015_99d3a4923c94e1e27b16209743eaec24~c5_168x168.jpeg?from=2956013662'
 

+ 6 - 0
src/router/mainRoutes.js

@@ -23,4 +23,10 @@ export default [
         meta: { title: '我的守护' },
         component: () => import('@/views/my-guard/index.vue'),
     },
+    {
+        path: '/guard-list',
+        name: 'GuardList',
+        meta: { title: '守护列表' },
+        component: () => import('@/views/my-guard/guardList.vue'),
+    },
 ]

+ 24 - 1
src/views/home/components/IndicatorHome.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="indicator-home">
+  <div class="indicator-home" :class="{ 'hide-mask': hideMask }">
     <div class="notice" :style="noticeStyle"><span>下拉刷新内容</span></div>
     <div ref="toolbar" class="toolbar" :style="toolbarStyle">
       <Icon
@@ -38,6 +38,7 @@ const props = defineProps({
   loading: { type: Boolean, default: false },
   name: { type: String, default: '' },
   index: { type: Number, default: 0 },
+  hideMask: { type: Boolean, default: false },
 })
 
 const emit = defineEmits<{ 'update:index': [number]; showSlidebar: [] }>()
@@ -167,6 +168,27 @@ onUnmounted(() => {
   height: var(--home-header-height);
   font-weight: bold;
 
+  &.hide-mask::before {
+    display: none;
+  }
+
+  &::before {
+    content: '';
+    position: absolute;
+    top: 0;
+    left: 0;
+    right: 0;
+    height: calc(var(--home-header-height) + 150rem);
+    background: linear-gradient(
+      180deg,
+      rgba(0, 0, 0, 0.67) 0%,
+      rgba(0, 0, 0, 0.4) 45%,
+      rgba(0, 0, 0, 0) 100%
+    );
+    pointer-events: none;
+    z-index: 1;
+  }
+
   .notice {
     opacity: 0;
     top: 0;
@@ -176,6 +198,7 @@ onUnmounted(() => {
     display: flex;
     justify-content: center;
     align-items: center;
+    z-index: 2;
   }
 
   .toolbar {

+ 3 - 10
src/views/home/index.vue

@@ -107,7 +107,7 @@
           :change-active-index-use-anim="false"
         >
           <Slide0 :active="state.navIndex === 0 && state.baseIndex === 1" />
-          <Slide4 :active="state.navIndex === 1 && state.baseIndex === 1" />
+          <Slide1 :active="state.navIndex === 1 && state.baseIndex === 1" />
         </SlideHorizontal>
 
         <BaseFooter :init-tab="1" />
@@ -120,12 +120,8 @@
       </SlideItem>
       <SlideItem>
         <UserPanel
-          v-model:current-item="state.currentItem"
-          :active="state.baseIndex === 2"
-          @toggle-can-move="(e: boolean) => (state.canMove = e)"
+          :current-item="state.currentItem"
           @back="state.baseIndex = 1"
-          @show-follow-setting="state.showFollowSetting = true"
-          @show-follow-setting2="state.showFollowSetting2 = true"
         />
       </SlideItem>
     </SlideHorizontal>
@@ -151,7 +147,7 @@ import bus, { EVENT_KEY } from '@/utils/bus'
 import { useNav } from '@/utils/hooks/useNav'
 import UserPanel from '@/components/UserPanel.vue'
 import Slide0 from './slide/Slide0.vue'
-import Slide4 from './slide/Slide4.vue'
+import Slide1 from './slide/Slide1.vue'
 import { _no, _notice } from '@/utils'
 import BaseMask from '@/components/BaseMask.vue'
 import BaseFooter from '@/components/BaseFooter.vue'
@@ -167,9 +163,6 @@ const state = reactive({
   active: true,
   baseIndex: 1,
   navIndex: 1,
-  canMove: true,
-  showFollowSetting: false,
-  showFollowSetting2: false,
   commentVisible: false,
   fullScreen: false,
   currentItem: {

+ 0 - 0
src/views/home/slide/Slide4.vue → src/views/home/slide/Slide1.vue


+ 0 - 58
src/views/home/slide/Slide2.vue

@@ -1,58 +0,0 @@
-<script setup lang="ts">
-import SlideItem from '@/components/slide/SlideItem.vue'
-import { followFeed, formatCount } from '@/mock/homeData'
-
-defineProps({ active: { type: Boolean, default: false } })
-</script>
-
-<template>
-  <SlideItem>
-    <div class="follow-feed">
-      <div v-for="item in followFeed" :key="item.aweme_id" class="item">
-        <img :src="item.video.cover.url_list[0]" class="cover" alt="" />
-        <div class="info">
-          <p class="desc">{{ item.desc }}</p>
-          <p class="meta">@{{ item.author.nickname }} · {{ formatCount(item.statistics.digg_count) }}赞</p>
-        </div>
-      </div>
-    </div>
-  </SlideItem>
-</template>
-
-<style scoped lang="less">
-.follow-feed {
-  height: 100%;
-  overflow-y: auto;
-  background: #000;
-  padding: 10rem;
-
-  .item {
-    margin-bottom: 12rem;
-    border-radius: 8rem;
-    overflow: hidden;
-    background: rgb(29, 29, 29);
-
-    .cover {
-      width: 100%;
-      height: 140rem;
-      object-fit: cover;
-    }
-
-    .info {
-      padding: 10rem 12rem;
-      color: white;
-
-      .desc {
-        margin: 0 0 6rem;
-        font-size: 14rem;
-      }
-
-      .meta {
-        margin: 0;
-        font-size: 12rem;
-        opacity: 0.6;
-      }
-    }
-  }
-}
-</style>

+ 187 - 0
src/views/my-guard/components/GuardImageOverlay.vue

@@ -0,0 +1,187 @@
+<template>
+  <div class="guard-overlay">
+    <div class="guard-top-mask" aria-hidden="true" />
+    <div class="guard-content">
+      <div class="guard-badge">
+        <img class="avatar" :src="avatarUrl" alt="" />
+        <span class="level">{{ levelText }}</span>
+        <span class="link" @click.stop="goGuardList">守护列表</span>
+      </div>
+
+      <div class="guard-bottom">
+        <div class="date-block">
+          <div class="month">{{ monthLabel }}</div>
+          <div class="day-row">
+            <span class="day">{{ dayLabel }}</span>
+            <span class="total">/{{ daysInMonth }}</span>
+          </div>
+        </div>
+
+        <div class="greeting-block">
+          <div class="title">{{ greetingTitle }}</div>
+          <div class="quote">不必在意他人目光与期待</div>
+          <div class="quote">想着自己的方向前进即可</div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+import { useRouter } from 'vue-router'
+import { GUARD_DEMO_IMAGES } from '@/mock/homeData'
+
+const props = defineProps({
+  level: { type: String, default: '18级-经管' },
+})
+
+const router = useRouter()
+const levelText = computed(() => props.level)
+
+const MONTH_NAMES = [
+  'January',
+  'February',
+  'March',
+  'April',
+  'May',
+  'June',
+  'July',
+  'August',
+  'September',
+  'October',
+  'November',
+  'December',
+]
+
+const avatarUrl = GUARD_DEMO_IMAGES[0]
+
+const now = new Date()
+const monthLabel = computed(() => MONTH_NAMES[now.getMonth()])
+const dayLabel = computed(() => now.getDate())
+const daysInMonth = computed(() =>
+  new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate(),
+)
+
+const greetingTitle = computed(() => {
+  const hour = now.getHours()
+  if (hour < 12) return '早安,你好'
+  if (hour < 18) return '午安,你好'
+  return '晚安,你好'
+})
+
+function goGuardList() {
+  router.push('/guard-list')
+}
+</script>
+
+<style scoped lang="less">
+.guard-overlay {
+  position: absolute;
+  inset: 0;
+  z-index: 2;
+  pointer-events: none;
+
+  .guard-top-mask {
+    position: absolute;
+    top: 0;
+    left: 0;
+    right: 0;
+    height: calc(var(--home-header-height) + 150rem);
+    pointer-events: none;
+    background: linear-gradient(
+      180deg,
+      rgba(0, 0, 0, 0.67) 0%,
+      rgba(0, 0, 0, 0.4) 45%,
+      rgba(0, 0, 0, 0) 100%
+    );
+    z-index: 1;
+  }
+
+  .guard-content {
+    position: relative;
+    z-index: 2;
+    height: 100%;
+    box-sizing: border-box;
+    padding:
+      calc(var(--home-header-height) + 18rem)
+      12rem
+      calc(var(--footer-safe-bottom) + 8rem);
+    pointer-events: none;
+
+    .guard-badge {
+      display: inline-flex;
+      align-items: center;
+      gap: 5rem;
+      padding: 2rem 10rem 2rem 4rem;
+      border-radius: 25rem;
+      background: rgba(0, 0, 0, 0.45);
+      pointer-events: auto;
+      color: #fff;
+
+      .avatar {
+        width: 32rem;
+        height: 32rem;
+        border-radius: 50%;
+        object-fit: cover;
+      }
+
+      .link {
+        color: #ff8400;
+      }
+    }
+
+    .guard-bottom {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      gap: 20rem;
+      margin-top: 16rem;
+
+      .date-block {
+        color: #fff;
+        font-family: 'Times New Roman', 'Songti SC', 'STSong', serif;
+
+        .month {
+          font-size: 16rem;
+          margin-bottom: -5rem;
+        }
+
+        .day-row {
+          display: flex;
+          align-items: flex-end;
+        }
+
+        .day {
+          font-size: 85rem;
+          font-weight: 700;
+        }
+
+        .total {
+          font-size: 23rem;
+          margin-bottom: 15rem;
+        }
+      }
+
+      .greeting-block {
+        text-align: right;
+        color: #fff;
+
+        .title {
+          font-size: 19rem;
+          font-weight: 700;
+          margin-bottom: 6rem;
+          font-family: 'Times New Roman', 'Songti SC', 'STSong', serif;
+        }
+
+        .quote {
+          font-size: 12rem;
+          line-height: 1.6;
+          font-weight: 700;
+          font-family: 'Times New Roman', 'Songti SC', 'STSong', serif;
+        }
+      }
+    }
+  }
+}
+</style>

+ 177 - 0
src/views/my-guard/components/GuardInfoBoard.vue

@@ -0,0 +1,177 @@
+<template>
+  <div class="guard-info-board">
+    <div class="board-wrap" :style="{ backgroundImage: `url(${boardImg})` }">
+      <div class="board-text">
+        <div class="board-title">{{ title }}</div>
+        <div class="board-sub">{{ subtitle }}</div>
+      </div>
+      <div class="edit-btn" aria-label="编辑" @click.stop="onEdit">
+        <Icon icon="solar:pen-new-square-linear" />
+      </div>
+    </div>
+
+    <div class="info-cards" :style="{ '--card-border-bg': `url(${borderBgImg})` }">
+      <div v-for="(card, index) in cards" :key="index" class="info-card">
+        <template v-if="index === 3">
+          <img class="badge-icon" :src="badgeIcon" alt="" />
+          <span class="badge-label">{{ card.bottom }}</span>
+        </template>
+        <template v-else>
+          <div class="card-top">{{ card.top }}</div>
+          <div class="card-bottom">{{ card.bottom }}</div>
+        </template>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { Icon } from '@iconify/vue'
+import boardImg from '@/assets/img/guard/board.png'
+import borderBgImg from '@/assets/img/guard/border-bg.png'
+
+export type GuardInfoCard = {
+  top?: string
+  bottom: string
+}
+
+withDefaults(
+  defineProps<{
+    title?: string
+    subtitle?: string
+    badgeIcon?: string
+    cards?: GuardInfoCard[]
+  }>(),
+  {
+    title: '【茜茜荔荔】',
+    subtitle: '用户昵称 2025.06.22',
+    badgeIcon: '',
+    cards: () => [
+      { top: '2025.05.16', bottom: '从化荔博园' },
+      { top: '16年老树', bottom: '妃子笑荔枝' },
+      { top: '成熟期', bottom: '温度适宜-梢期杀虫' },
+      { top: '', bottom: '润土行者' },
+    ],
+  },
+)
+
+const emit = defineEmits<{ edit: [] }>()
+
+function onEdit() {
+  emit('edit')
+}
+</script>
+
+<style scoped lang="less">
+.guard-info-board {
+  position: relative;
+  width: 100%;
+  pointer-events: none;
+  .board-wrap {
+    position: relative;
+    z-index: 1;
+    width: 200rem;
+    height: 335rem;
+    background-repeat: no-repeat;
+    background-position: center;
+    background-size: 100% 100%;
+    pointer-events: none;
+
+    .board-text {
+      position: absolute;
+      top: 0;
+      left: 0;
+      width: 100%;
+      height: 146rem;
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      justify-content: center;
+      box-sizing: border-box;
+      text-align: center;
+      color: #fff;
+      pointer-events: none;
+
+      .board-title {
+        font-family: jiangxizhuokai, 'Songti SC', 'STSong', serif;
+        font-size: 26rem;
+        text-shadow: 0 1rem 2rem rgba(0, 0, 0, 0.25);
+      }
+
+      .board-sub {
+        font-family: jiangxizhuokai, 'Songti SC', 'STSong', serif;
+        margin-top: 5rem;
+        font-size: 10rem;
+      }
+    }
+
+    .edit-btn {
+      position: absolute;
+      right: 0rem;
+      top: 120rem;
+      width: 23rem;
+      height: 23rem;
+      border-radius: 50%;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      background: rgba(0, 0, 0, 0.6);
+      color: #fff;
+      pointer-events: auto;
+  
+      svg {
+        font-size: 10rem;
+      }
+    }
+  }
+  .info-cards {
+    position: absolute;
+    left: 10rem;
+    right: 10rem;
+    bottom: calc(var(--footer-bottom-offset) + var(--footer-height) + 10rem);
+    z-index: 2;
+    display: grid;
+    grid-template-columns: repeat(4, 1fr);
+    gap: 4rem;
+    .info-card {
+      min-height: 58rem;
+      padding: 8rem 4rem;
+      box-sizing: border-box;
+      background: var(--card-border-bg) no-repeat center / 100% 100%;
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      justify-content: center;
+      text-align: center;
+      color: #fff;
+    
+      .card-top {
+        font-size: 11rem;
+        font-weight: 600;
+        line-height: 1.3;
+        margin-bottom: 4rem;
+      }
+    
+      .card-bottom {
+        font-size: 10rem;
+        line-height: 1.35;
+        opacity: 0.92;
+      }
+    
+      .badge-icon {
+        width: 28rem;
+        height: 28rem;
+        object-fit: contain;
+        margin-bottom: 4rem;
+      }
+    
+      .badge-label {
+        font-size: 11rem;
+        font-weight: 600;
+        color: #ffb400;
+        line-height: 1.3;
+      }
+    }
+  }
+}
+</style>

+ 160 - 0
src/views/my-guard/components/ImageFeed.vue

@@ -0,0 +1,160 @@
+<template>
+  <div class="image-feed-wrap">
+    <SlideVertical
+      v-model:index="farmIndex"
+      class="farm-feed"
+      :name="`${slideName}-farm`"
+    >
+      <SlideItem
+        v-for="(farm, farmIdx) in farms"
+        :key="farm.id"
+        class="feed-item"
+      >
+        <SlideHorizontal
+          v-model:index="imageIndexes[farmIdx]"
+          class="image-feed"
+          :name="`${slideName}-img-${farm.id}`"
+        >
+          <SlideItem
+            v-for="item in farm.images"
+            :key="item.aweme_id"
+            class="feed-item"
+          >
+            <BaseImage :item="item" />
+            <GuardImageOverlay :level="farm.level" />
+            <GuardInfoBoard :badge-icon="badgeIcon" class="info-board-layer" />
+          </SlideItem>
+        </SlideHorizontal>
+      </SlideItem>
+    </SlideVertical>
+
+    <button
+      type="button"
+      class="nav-btn prev"
+      :disabled="currentImageIndex <= 0"
+      aria-label="上一张"
+      @click="prevImage"
+    >
+      <Icon icon="eva:arrow-ios-back-fill" />
+    </button>
+    <button
+      type="button"
+      class="nav-btn next"
+      :disabled="currentImageIndex >= currentFarmImages.length - 1"
+      aria-label="下一张"
+      @click="nextImage"
+    >
+      <Icon icon="eva:arrow-ios-forward-fill" />
+    </button>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { Icon } from '@iconify/vue'
+import { computed, ref } from 'vue'
+import SlideVertical from '@/components/slide/SlideVertical.vue'
+import SlideHorizontal from '@/components/slide/SlideHorizontal.vue'
+import SlideItem from '@/components/slide/SlideItem.vue'
+import BaseImage from '@/components/image/BaseImage.vue'
+import GuardImageOverlay from './GuardImageOverlay.vue'
+import GuardInfoBoard from './GuardInfoBoard.vue'
+import type { GuardFarm } from '@/mock/homeData'
+
+const props = defineProps({
+  feedId: { type: String, required: true },
+  farms: { type: Array as () => GuardFarm[], required: true },
+  /** 第四张卡片顶部徽章图,路径由外部传入 */
+  badgeIcon: { type: String, default: '' },
+})
+
+const slideName = computed(() => `guard-feed-${props.feedId}`)
+
+const farms = computed(() => props.farms)
+const farmIndex = ref(0)
+const imageIndexes = ref(props.farms.map(() => 0))
+
+const currentFarmImages = computed(() => farms.value[farmIndex.value]?.images ?? [])
+const currentImageIndex = computed(() => imageIndexes.value[farmIndex.value] ?? 0)
+
+function prevImage() {
+  const idx = farmIndex.value
+  if (imageIndexes.value[idx] > 0) imageIndexes.value[idx] -= 1
+}
+
+function nextImage() {
+  const idx = farmIndex.value
+  const max = (farms.value[idx]?.images.length ?? 1) - 1
+  if (imageIndexes.value[idx] < max) imageIndexes.value[idx] += 1
+}
+</script>
+
+<style scoped lang="less">
+.image-feed-wrap {
+  position: relative;
+  height: 100%;
+  width: 100%;
+}
+
+.farm-feed,
+.image-feed {
+  height: 100%;
+  width: 100%;
+  background: #000;
+}
+
+.farm-feed {
+  :deep(.feed-item) {
+    position: relative;
+    height: 100%;
+    width: 100%;
+  }
+}
+
+.image-feed {
+  :deep(.feed-item) {
+    position: relative;
+    height: 100%;
+    width: 100%;
+  }
+}
+
+.info-board-layer {
+  position: absolute;
+  bottom: 0;
+  z-index: 2;
+  pointer-events: none;
+}
+
+.nav-btn {
+  position: absolute;
+  top: 50%;
+  z-index: 4;
+  transform: translateY(-50%);
+  width: 40rem;
+  height: 40rem;
+  border: none;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: rgba(0, 0, 0, 0.6);
+  color: #fff;
+  pointer-events: auto;
+
+  svg {
+    font-size: 32rem;
+  }
+
+  &.prev {
+    left: 12rem;
+  }
+
+  &.next {
+    right: 12rem;
+  }
+
+  &:disabled {
+    opacity: 0.5;
+  }
+}
+</style>

+ 106 - 0
src/views/my-guard/guardList.vue

@@ -0,0 +1,106 @@
+<template>
+  <div class="guard-list-page">
+    <header class="page-header">
+      <Icon icon="eva:arrow-ios-back-fill" class="back-icon" @click="goBack" />
+      <h1 class="title">守护列表</h1>
+    </header>
+
+    <div class="table-wrap">
+      <div class="table-head">
+        <span class="col col-name">守护人</span>
+        <span class="col col-major">届别&amp;专业/任教学院</span>
+        <span class="col col-role">身份</span>
+        <span class="col col-time">守护时间</span>
+      </div>
+      <div class="table-body">
+        <div v-for="(item, index) in guardList" :key="`${item.name}-${index}`" class="table-row">
+          <span class="col col-name">{{ item.name }}</span>
+          <span class="col col-major">{{ item.major }}</span>
+          <span class="col col-role">{{ item.identity }}</span>
+          <span class="col col-time">{{ item.time }}</span>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { Icon } from '@iconify/vue'
+import { useRouter } from 'vue-router'
+import { guardList } from '@/mock/guardList'
+
+const router = useRouter()
+
+function goBack() {
+  router.back()
+}
+</script>
+
+<style scoped lang="less">
+.guard-list-page {
+  min-height: calc(var(--vh, 1vh) * 100);
+  background: #f5f5f5;
+
+  .page-header {
+    position: relative;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    height: 44rem;
+    background: #fff;
+
+    .back-icon {
+      position: absolute;
+      left: 12rem;
+      font-size: 22rem;
+      color: #1d2129;
+      cursor: pointer;
+    }
+
+    .title {
+      margin: 0;
+      font-size: 17rem;
+      font-weight: 600;
+      color: #1d2129;
+    }
+  }
+
+  .table-wrap {
+    margin: 12rem 10rem 0;
+    background: #fff;
+    border-radius: 8rem 8rem 0 0;
+    overflow: hidden;
+
+    .table-head,
+    .table-row {
+      display: grid;
+      grid-template-columns: 1.1fr 1.6fr 1fr 1fr;
+      align-items: center;
+      text-align: center;
+    }
+
+    .table-head {
+      min-height: 44rem;
+      padding: 0 6rem;
+      background: #ffb800;
+      color: #fff;
+      font-size: 12rem;
+      font-weight: 500;
+    }
+
+    .table-body {
+      .table-row {
+        min-height: 44rem;
+        padding: 10rem 6rem;
+        font-size: 12rem;
+        color: #1d2129;
+        box-sizing: border-box;
+
+        &+.table-row {
+          border-top: 1px solid #f0f0f0;
+        }
+      }
+    }
+  }
+}
+</style>

+ 54 - 23
src/views/my-guard/index.vue

@@ -1,15 +1,46 @@
 <template>
-  <div class="tab-page">
-    <div class="page-body">
-      <h1>我的守护</h1>
-      <p class="desc">守护记录开发中</p>
+  <div id="my-guard-index" class="tab-page">
+    <SlideHorizontal
+      v-model:index="state.navIndex"
+      class="guard-horizontal"
+      name="guard-nav"
+      :change-active-index-use-anim="false"
+    >
+      <SlideItem>
+        <ImageFeed feed-id="guard-my" :farms="guardMyFarms" />
+      </SlideItem>
+      <SlideItem>
+        <ImageFeed feed-id="guard-recommend" :farms="guardRecommendFarms" />
+      </SlideItem>
+    </SlideHorizontal>
+    <div class="guard-indicator-wrap">
+      <IndicatorHome
+        v-model:index="state.navIndex"
+        :loading="loading"
+        hide-mask
+        name="guard-nav"
+        @show-slidebar="_no"
+      />
     </div>
     <BaseFooter :init-tab="3" />
   </div>
 </template>
 
 <script setup lang="ts">
+import { reactive, ref } from 'vue'
 import BaseFooter from '@/components/BaseFooter.vue'
+import SlideHorizontal from '@/components/slide/SlideHorizontal.vue'
+import SlideItem from '@/components/slide/SlideItem.vue'
+import IndicatorHome from '@/views/home/components/IndicatorHome.vue'
+import ImageFeed from './components/ImageFeed.vue'
+import { guardMyFarms, guardRecommendFarms } from '@/mock/homeData'
+import { _no } from '@/utils'
+
+const loading = ref(false)
+
+const state = reactive({
+  navIndex: 0,
+})
 </script>
 
 <style scoped lang="less">
@@ -17,31 +48,31 @@ import BaseFooter from '@/components/BaseFooter.vue'
   width: 100%;
   height: 100%;
   min-height: calc(var(--vh, 1vh) * 100);
-  background: var(--main-bg);
+  background: #000;
   position: relative;
   overflow: hidden;
 }
 
-.page-body {
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  justify-content: center;
-  height: calc(var(--vh, 1vh) * 100 - 80rem);
-  padding: 20rem;
-  box-sizing: border-box;
-
-  h1 {
-    margin: 0 0 12rem;
-    font-size: 20rem;
-    color: #fff;
-    font-weight: 500;
+.guard-horizontal {
+  width: 100%;
+  height: 100% !important;
+  overflow: hidden;
+}
+
+.guard-indicator-wrap {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  z-index: 3;
+  pointer-events: none;
+
+  :deep(.toolbar) {
+    pointer-events: auto;
   }
 
-  .desc {
-    margin: 0;
-    font-size: 14rem;
-    color: rgba(255, 255, 255, 0.5);
+  :deep(.search) {
+    pointer-events: auto;
   }
 }
 </style>