feat: 新增微信头像授权获取与个人中心展示功能
- 新增 `wechat-profile` 工具库,实现微信用户信息的本地存储、格式校验与授权获取逻辑 - 编写对应单元测试覆盖核心功能流程 - 重构个人中心成员组件,新增头像、昵称展示与授权触发按钮 - 更新个人中心页面,集成微信头像授权流程与状态管理
This commit is contained in:
49
WonderQ-MiniAPP/src/lib/wechat-profile.ts
Normal file
49
WonderQ-MiniAPP/src/lib/wechat-profile.ts
Normal 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),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
51
WonderQ-MiniAPP/tests/wechat-profile.test.ts
Normal file
51
WonderQ-MiniAPP/tests/wechat-profile.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user