Files
nl-blogs/blog-wot-uniapp/src/subPackages/content/pages/about/index.vue
李琦 4e245f789b 1. 小程序端
2. 视频优化2
2026-07-30 14:14:28 +08:00

215 lines
6.3 KiB
Vue

<script setup lang="ts">
/**
* 关于页:档案、联系、技术栈、经历时间轴(对齐 PC About 信息架构)。
*/
import { computed, ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import AppNavbar from '@/components/layout/AppNavbar.vue'
import AppPageShell from '@/components/layout/AppPageShell.vue'
import ContentPageHeader from '../../components/ContentPageHeader.vue'
import { getAbout, type AboutProfile } from '@/api'
import { resolveMediaUrl, rewriteHtmlMediaUrls } from '@/utils/request'
const loading = ref(false)
const profile = ref<AboutProfile | null>(null)
function normalizeProfile(data: AboutProfile | AboutProfile[] | null): AboutProfile | null {
if (!data) return null
if (Array.isArray(data)) return data[0] || null
return data
}
async function load() {
loading.value = true
try {
const data = await getAbout()
profile.value = normalizeProfile(data as AboutProfile | AboutProfile[])
}
catch (e: unknown) {
uni.showToast({ title: e instanceof Error ? e.message : '加载失败', icon: 'none' })
}
finally {
loading.value = false
}
}
onShow(load)
const avatarUrl = computed(() => resolveMediaUrl(String(profile.value?.avatar || '')))
const techList = computed(() => {
const t = profile.value?.techStack
return Array.isArray(t) ? t.map(String) : []
})
const experiences = computed(() => profile.value?.experiences || [])
const bioHtml = computed(() => rewriteHtmlMediaUrls(String(profile.value?.bio || '')))
const copyText = (label: string, value?: string) => {
const v = String(value || '').trim()
if (!v) return
uni.setClipboardData({
data: v,
success: () => uni.showToast({ title: `${label}已复制`, icon: 'none' }),
})
}
</script>
<template>
<AppPageShell>
<AppNavbar title="关于" show-back />
<view class="page-pad">
<view v-if="profile" class="animate-reveal">
<ContentPageHeader eyebrow="个人档案" title="关于我" />
<view class="identity">
<image
v-if="avatarUrl"
class="avatar"
:src="avatarUrl"
mode="aspectFill"
/>
<view class="identity-meta">
<text class="name">{{ profile.name || '关于我' }}</text>
<view v-if="profile.location" class="loc text-art-muted">
<wd-icon name="location" size="14px" color="rgb(212,179,131)" />
<text>{{ profile.location }}</text>
</view>
</view>
</view>
<rich-text v-if="bioHtml" class="bio" :nodes="bioHtml" />
<view v-if="profile.email || profile.wechat" class="art-card contact">
<text class="block-title">联系方式</text>
<view
v-if="profile.email"
class="contact-row"
@click="copyText('邮箱', profile.email)"
>
<wd-icon name="email" size="18px" color="rgb(212,179,131)" />
<text class="contact-text">{{ profile.email }}</text>
</view>
<view
v-if="profile.wechat"
class="contact-row"
@click="copyText('微信', profile.wechat)"
>
<wd-icon name="message" size="18px" color="rgb(212,179,131)" />
<text class="contact-text">WeChat: {{ profile.wechat }}</text>
</view>
</view>
<view v-if="techList.length" class="art-card panel">
<text class="block-title">技术栈</text>
<view class="pills">
<text v-for="(t, i) in techList" :key="i" class="pill">{{ t }}</text>
</view>
</view>
<view v-if="experiences.length" class="art-card panel">
<text class="block-title">经历</text>
<view class="timeline">
<view
v-for="(exp, i) in experiences"
:key="i"
class="exp"
>
<view class="dot" :class="{ 'dot--first': i === 0 }" />
<view class="exp-body">
<text class="exp-year font-mono-label" :class="i === 0 ? 'text-art-accent' : 'text-art-muted'">
{{ exp.year }}
</text>
<text class="exp-role">{{ exp.role }}</text>
<text class="exp-company text-art-muted">{{ exp.company }}</text>
</view>
</view>
</view>
</view>
</view>
<wd-loading v-if="loading" />
<wd-empty v-if="!loading && !profile" description="暂无资料" />
</view>
</AppPageShell>
</template>
<style scoped lang="scss">
.page-pad { padding: 24rpx 32rpx 80rpx; }
.identity {
display: flex;
align-items: center;
gap: 28rpx;
margin-bottom: 32rpx;
}
.avatar {
width: 144rpx;
height: 144rpx;
border-radius: 50%;
border: 2rpx solid rgba(212, 179, 131, 0.55);
flex-shrink: 0;
}
.identity-meta { display: flex; flex-direction: column; gap: 12rpx; min-width: 0; }
.name {
font-size: 40rpx;
color: rgb(var(--art-text));
font-weight: 600;
}
.loc { display: flex; align-items: center; gap: 8rpx; font-size: 26rpx; }
.bio {
display: block;
font-size: 30rpx;
line-height: 1.8;
color: rgb(var(--art-muted));
margin-bottom: 36rpx;
}
.contact, .panel {
padding: 28rpx 32rpx;
margin-bottom: 28rpx;
}
.block-title {
display: block;
font-size: 30rpx;
font-weight: 600;
color: rgb(var(--art-text));
margin-bottom: 20rpx;
}
.contact-row {
display: flex;
align-items: center;
gap: 16rpx;
padding: 14rpx 0;
}
.contact-text { font-size: 26rpx; color: rgb(var(--art-muted)); }
.pills { display: flex; flex-wrap: wrap; gap: 12rpx; }
.pill {
padding: 10rpx 18rpx;
border-radius: 999rpx;
border: 1px solid var(--art-card-border);
font-size: 22rpx;
color: rgb(var(--art-text));
background: rgba(212, 179, 131, 0.06);
}
.timeline {
border-left: 2rpx solid var(--art-card-border);
margin-left: 10rpx;
padding-left: 28rpx;
}
.exp {
position: relative;
margin-bottom: 28rpx;
}
.dot {
position: absolute;
left: -36rpx;
top: 10rpx;
width: 14rpx;
height: 14rpx;
border-radius: 50%;
background: rgba(127, 127, 127, 0.35);
}
.dot--first { background: rgb(var(--art-accent)); }
.exp-body { display: flex; flex-direction: column; gap: 6rpx; }
.exp-year { font-size: 22rpx; }
.exp-role { font-size: 28rpx; color: rgb(var(--art-text)); font-weight: 600; }
.exp-company { font-size: 24rpx; }
</style>