feat: 新增微信头像授权获取与个人中心展示功能

- 新增 `wechat-profile` 工具库,实现微信用户信息的本地存储、格式校验与授权获取逻辑
- 编写对应单元测试覆盖核心功能流程
- 重构个人中心成员组件,新增头像、昵称展示与授权触发按钮
- 更新个人中心页面,集成微信头像授权流程与状态管理
This commit is contained in:
duanshuwen
2026-08-20 21:58:08 +08:00
parent 6296392e80
commit e7b8c5bf43
4 changed files with 213 additions and 8 deletions

View File

@@ -0,0 +1,49 @@
export const WECHAT_PROFILE_KEY = "miniapp:wechat-profile";
export type WechatProfile = {
avatarUrl: string;
nickName?: string;
};
function isWechatProfile(value: unknown): value is WechatProfile {
if (!value || typeof value !== "object") return false;
const profile = value as Partial<WechatProfile>;
return typeof profile.avatarUrl === "string" && profile.avatarUrl.trim().length > 0;
}
export function getStoredWechatProfile(): WechatProfile | null {
try {
const value = uni.getStorageSync(WECHAT_PROFILE_KEY);
return isWechatProfile(value) ? value : null;
} catch {
return null;
}
}
export function saveWechatProfile(profile: WechatProfile) {
if (!isWechatProfile(profile)) return;
uni.setStorageSync(WECHAT_PROFILE_KEY, profile);
}
export function requestWechatProfile(): Promise<WechatProfile | null> {
return new Promise((resolve) => {
if (typeof uni.getUserProfile !== "function") {
resolve(null);
return;
}
uni.getUserProfile({
provider: "weixin",
desc: "用于展示您的微信头像",
lang: "zh_CN",
success: (result) => {
const profile = {
avatarUrl: result.userInfo?.avatarUrl?.trim() ?? "",
nickName: result.userInfo?.nickName?.trim() || undefined,
};
resolve(isWechatProfile(profile) ? profile : null);
},
fail: () => resolve(null),
});
});
}

View File

@@ -1,15 +1,80 @@
<template> <template>
<view class="min-h-screen bg-transparent"> <view class="flex h-screen flex-col overflow-hidden bg-[#f5f2ed]">
<view class="px-4 pt-4"> <view class="relative h-[52%] min-h-[300px] overflow-hidden rounded-b-[24px] bg-[#211e1c]">
<view class="brand-panel rounded-[26px] p-5 text-white"> <image class="absolute inset-0 h-full w-full opacity-75"
<text class="block text-[12px] font-semibold uppercase tracking-[0.12em] text-[#f6dfb7]">万趣旅行</text> src="https://images.unsplash.com/photo-1774623703220-b3b5e6d3cedf?auto=format&fit=crop&fm=jpg&q=80&w=1200&h=1800"
<text class="mt-1 block text-[24px] font-bold">旅行需求中心</text> mode="aspectFill" />
<text class="mt-2 block text-[12px] leading-5 text-white/80">{{ customerLine }}</text> <view class="absolute inset-0 bg-[#171513]/45" />
<view class="absolute left-6" :style="brandPositionStyle">
<text class="block text-[11px] font-semibold tracking-[0.28em] text-white/90">WONDERQ</text>
<text class="mt-1 block text-[10px] tracking-[0.22em] text-white/55">MEMBER SPACE</text>
</view>
<view class="absolute bottom-6 left-6 right-6 flex items-end justify-end">
<view>
<text class="block text-[18px] font-medium tracking-[0.04em] text-white">把旅程交给懂你的人</text>
<text class="mt-2 block text-[10px] tracking-[0.2em] text-white/60">A QUIETER WAY TO TRAVEL</text>
</view>
</view>
</view>
<view
class="flex min-h-0 flex-1 flex-col items-stretch px-6 pb-[calc(12px+env(safe-area-inset-bottom))] pt-6">
<view
class="w-full rounded-[22px] border border-[#ded6cd] bg-white/95 px-4 py-4 shadow-[0_12px_30px_rgba(80,58,45,0.08)]">
<view class="flex items-center gap-3">
<view
class="flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-[18px] border border-[#e6ded5] bg-[#f4efe9]">
<image v-if="avatarUrl" class="h-full w-full" :src="avatarUrl" mode="aspectFill" />
<uni-icons v-else type="person" :size="32" color="#8f8176" />
</view>
<view class="min-w-0 flex-1">
<view class="flex items-center justify-between gap-2">
<text class="truncate text-[17px] font-semibold tracking-[0.02em] text-[#24211f]">{{ customerName }}</text>
<view class="shrink-0 rounded-full bg-[#edf5ef] px-2 py-1">
<text class="text-[10px] font-medium tracking-[0.06em] text-[#3f7b57]">已登录</text>
</view>
</view>
<text class="mt-2 block truncate text-[12px] leading-5 text-[#857d75]">{{ customerLine }}</text>
</view>
</view>
<button
v-if="showAvatarAction && !avatarUrl"
class="m-0 mt-4 flex min-h-[40px] w-full items-center justify-center rounded-full border border-[#d9c7bb] bg-[#fffaf5] px-4 text-[12px] font-medium tracking-[0.04em] text-[#8c4f39]"
@click="emit('request-avatar')">
<uni-icons type="person" :size="16" color="#8c4f39" />
<text class="ml-1">获取微信头像</text>
</button>
<text v-else-if="avatarUrl" class="mt-3 block text-[11px] leading-5 text-[#9a9189]">头像仅保存在当前设备</text>
<text v-else class="mt-3 block text-[11px] leading-5 text-[#9a9189]">微信头像可在授权后显示</text>
</view> </view>
</view> </view>
</view> </view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
defineProps<{ customerLine: string }>(); import { computed, ref } from "vue";
withDefaults(
defineProps<{
customerLine: string;
customerName?: string;
avatarUrl?: string;
showAvatarAction?: boolean;
}>(),
{
customerName: "万趣会员",
avatarUrl: "",
showAvatarAction: false,
},
);
const emit = defineEmits<{
"request-avatar": [];
}>();
const menuButtonTop = ref<number | null>(null);
const brandPositionStyle = computed(() =>
menuButtonTop.value === null ? {} : { top: `${menuButtonTop.value}px` },
);
</script> </script>

View File

@@ -3,7 +3,12 @@
<view class="wq-phone h-screen overflow-hidden"> <view class="wq-phone h-screen overflow-hidden">
<MineGuest v-if="!session" @login="goLogin" /> <MineGuest v-if="!session" @login="goLogin" />
<scroll-view v-else scroll-y class="h-full no-scrollbar"> <scroll-view v-else scroll-y class="h-full no-scrollbar">
<MineMember :customer-line="customerLine" /> <MineMember
:customer-line="customerLine"
:customer-name="wechatProfile?.nickName || '万趣会员'"
:avatar-url="wechatProfile?.avatarUrl"
:show-avatar-action="isWechatMiniProgram"
@request-avatar="loadWechatProfile" />
</scroll-view> </scroll-view>
</view> </view>
</view> </view>
@@ -14,15 +19,29 @@ import { ref } from "vue";
import { onShow } from "@dcloudio/uni-app"; import { onShow } from "@dcloudio/uni-app";
import { getAuthSession, loginWithPhoneCode } from "@/lib/auth"; import { getAuthSession, loginWithPhoneCode } from "@/lib/auth";
import { loadAppData } from "@/lib/store"; import { loadAppData } from "@/lib/store";
import {
getStoredWechatProfile,
requestWechatProfile,
saveWechatProfile,
} from "@/lib/wechat-profile";
import type { AuthSession } from "@/lib/types"; import type { AuthSession } from "@/lib/types";
import MineGuest from "./components/MineGuest.vue"; import MineGuest from "./components/MineGuest.vue";
import MineMember from "./components/MineMember.vue"; import MineMember from "./components/MineMember.vue";
const session = ref<AuthSession | null>(null); const session = ref<AuthSession | null>(null);
const customerLine = ref("可查看当前设备的需求、收藏和浏览记录。"); const customerLine = ref("可查看当前设备的需求、收藏和浏览记录。");
const wechatProfile = ref(getStoredWechatProfile());
const isWechatMiniProgram = ref(false);
const isLoggingIn = ref(false); const isLoggingIn = ref(false);
const isLoadingWechatProfile = ref(false);
onShow(() => { onShow(() => {
session.value = getAuthSession(); session.value = getAuthSession();
wechatProfile.value = getStoredWechatProfile();
try {
isWechatMiniProgram.value = uni.getSystemInfoSync().uniPlatform === "mp-weixin";
} catch {
isWechatMiniProgram.value = false;
}
customerLine.value = session.value customerLine.value = session.value
? `已绑定手机号 ${session.value.customer.phoneMasked}` ? `已绑定手机号 ${session.value.customer.phoneMasked}`
: "可查看当前设备的需求、收藏和浏览记录。"; : "可查看当前设备的需求、收藏和浏览记录。";
@@ -61,4 +80,25 @@ async function goLogin(event: WechatPhoneNumberEvent) {
uni.hideLoading(); uni.hideLoading();
} }
} }
async function loadWechatProfile() {
if (isLoadingWechatProfile.value) return;
isLoadingWechatProfile.value = true;
uni.showLoading({ title: "获取头像中" });
try {
const profile = await requestWechatProfile();
if (!profile) {
uni.showToast({ title: "未获取到头像,请允许授权后重试", icon: "none" });
return;
}
saveWechatProfile(profile);
wechatProfile.value = profile;
uni.showToast({ title: "头像已更新", icon: "none" });
} finally {
isLoadingWechatProfile.value = false;
uni.hideLoading();
}
}
</script> </script>

View File

@@ -0,0 +1,51 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
getStoredWechatProfile,
requestWechatProfile,
saveWechatProfile,
} from "@/lib/wechat-profile";
describe("wechat profile", () => {
const storage = new Map<string, unknown>();
const getUserProfile = vi.fn();
beforeEach(() => {
storage.clear();
getUserProfile.mockReset();
vi.stubGlobal("uni", {
getStorageSync: (key: string) => storage.get(key),
setStorageSync: (key: string, value: unknown) => storage.set(key, value),
getUserProfile,
});
});
it("stores and restores a valid avatar profile", () => {
saveWechatProfile({ avatarUrl: "https://example.com/avatar.jpg", nickName: "Damon" });
expect(getStoredWechatProfile()).toEqual({
avatarUrl: "https://example.com/avatar.jpg",
nickName: "Damon",
});
});
it("requests the profile only through the explicit profile API", async () => {
getUserProfile.mockImplementation(({ success }: { success: (result: unknown) => void }) => {
success({ userInfo: { avatarUrl: "https://example.com/wechat.jpg", nickName: "旅行者" } });
});
await expect(requestWechatProfile()).resolves.toEqual({
avatarUrl: "https://example.com/wechat.jpg",
nickName: "旅行者",
});
expect(getUserProfile).toHaveBeenCalledWith(
expect.objectContaining({ provider: "weixin", lang: "zh_CN" }),
);
});
it("returns null when the user declines profile permission", async () => {
getUserProfile.mockImplementation(({ fail }: { fail: () => void }) => fail());
await expect(requestWechatProfile()).resolves.toBeNull();
});
});