Compare commits

..

7 Commits

Author SHA1 Message Date
zzc
34a06794f9 fix: my info 2026-01-30 18:50:33 +08:00
zzc
da85fa655d fix: my info 2026-01-30 17:56:14 +08:00
zzc
462e664ac9 fix: vip info 2026-01-30 17:48:48 +08:00
zzc
360947e0fc fix: vip info 2026-01-30 17:24:10 +08:00
zzc
18c6167bee fix: vip info 2026-01-30 17:11:38 +08:00
zzc
d98a6c48c7 fix: vip info 2026-01-30 16:48:57 +08:00
zzc
cd3423a587 fix: pay noticy api 2026-01-30 16:03:30 +08:00
8 changed files with 609 additions and 132 deletions

View File

@@ -1,10 +1,24 @@
import { request } from "@/utils/request.js" import { request } from "@/utils/request.js";
export const apiLogin = async (data) => {
return request({
url:"/api/user/login",
method: 'POST',
data
})
}
export const apiLogin = async (data) => {
return request({
url: "/api/user/login",
method: "POST",
data,
});
};
export const getUserInfo = async () => {
return request({
url: "/api/user/info",
method: "GET",
});
};
export const updateUserInfo = async (body) => {
return request({
url: "/api/user/info",
method: "PUT",
data: body,
});
};

View File

@@ -7,3 +7,10 @@ export const createOrder = async (data) => {
data, data,
}); });
}; };
export const getVipPlan = async () => {
return request({
url: "/api/blessing/vip/plan",
method: "GET",
});
};

View File

@@ -4,115 +4,113 @@
<view class="popup-header"> <view class="popup-header">
<text class="popup-title">登录授权</text> <text class="popup-title">登录授权</text>
</view> </view>
<view class="avatar-nickname"> <view class="avatar-nickname">
<button open-type="chooseAvatar" @chooseavatar="onChooseAvatar" class="avatar-selector custom-button"> <button
open-type="chooseAvatar"
@chooseavatar="onChooseAvatar"
class="avatar-selector custom-button"
>
<image v-if="avatarUrl" :src="avatarUrl" class="avatar-preview" /> <image v-if="avatarUrl" :src="avatarUrl" class="avatar-preview" />
<text v-else>获取头像</text> <text v-else>获取头像</text>
</button> </button>
<input <input
class="nickname-input" class="nickname-input"
type="nickname" type="nickname"
v-model="nickname" v-model="nickname"
placeholder="请输入昵称" placeholder="请输入昵称"
/> />
</view> </view>
<button class="confirm-btn custom-button" @tap="confirmLogin">确认登录</button> <button class="confirm-btn custom-button" @tap="confirmLogin">
确认登录
</button>
</view> </view>
</uni-popup> </uni-popup>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue' import { ref } from "vue";
import { useUserStore } from '@/stores/user' import { useUserStore } from "@/stores/user";
import { getPlatformProvider } from '@/utils/system' import { getPlatformProvider } from "@/utils/system";
import { apiLogin } from '@/api/auth.js' import { uploadImage } from "@/utils/common";
import { wxLogin } from '@/utils/login.js' import { apiLogin } from "@/api/auth.js";
import { wxLogin } from "@/utils/login.js";
const popupRef = ref(null) const popupRef = ref(null);
const avatarUrl = ref('') const avatarUrl = ref("");
const nickname = ref('') const nickname = ref("");
const userStore = useUserStore() const userStore = useUserStore();
const emit = defineEmits(['logind']) const emit = defineEmits(["logind"]);
const open = () => { const open = () => {
popupRef.value.open() popupRef.value.open();
} };
const close = () => { const close = () => {
popupRef.value.close() popupRef.value.close();
} };
const onChooseAvatar = (e) => { const onChooseAvatar = (e) => {
avatarUrl.value = e.detail.avatarUrl avatarUrl.value = e.detail.avatarUrl;
} };
const confirmLogin = async () => { const confirmLogin = async () => {
try { try {
const platform = getPlatformProvider() const platform = getPlatformProvider();
if (platform === 'mp-weixin') { if (platform === "mp-weixin") {
const code = await wxLogin() const code = await wxLogin();
console.log('准备登录:', { code, nickname: nickname.value, avatarUrl: avatarUrl.value }) const imageUrl = await uploadImage(avatarUrl.value);
// console.log('准备登录:', { code, nickname: nickname.value, avatarUrl: 'http://tmp/HXhtcEwQ5A3B58476c91ba545ab67c6bf9c67d9c2559.jpeg' })
const loginRes = await apiLogin({
const fileKeyRes = await uni.uploadFile({ code,
url: 'https://api.ai-meng.com/api/common/upload', nickname: nickname.value,
filePath: avatarUrl.value, avatarUrl: imageUrl,
name: 'file', // 和后端接收文件字段名一致 platform: "wx",
header: { });
'x-app-id': '69665538a49b8ae3be50fe5d', // 保存用户信息到store
}, userStore.setUserInfo({
}) nickName: loginRes?.user?.nickname || nickname.value,
if(fileKeyRes.statusCode < 400) { avatarUrl: loginRes?.user?.avatar || imageUrl,
const keyJson = JSON.parse(fileKeyRes.data) id: loginRes?.user?.id,
const url = `https://file.lihailezzc.com/${keyJson?.data.key}` isVip: loginRes?.isVip || false,
const loginRes = await apiLogin({ code, nickname: nickname.value, avatarUrl: url, platform: 'wx' }) vipExpireAt: loginRes?.vipExpireAt || null,
// 保存用户信息到store });
userStore.setUserInfo({
nickName: loginRes?.user?.nickname || nickname.value, userStore.setToken(loginRes.token);
avatarUrl: loginRes?.user?.avatar || url,
id: loginRes?.user?.id uni.showToast({ title: "登录成功", icon: "success" });
}) emit("logind");
popupRef.value.close();
userStore.setToken(loginRes.token)
// 重置临时变量
uni.showToast({ title: '登录成功', icon: 'success' }) avatarUrl.value = "";
emit('logind') nickname.value = "";
popupRef.value.close()
// 重置临时变量
avatarUrl.value = ''
nickname.value = ''
} else {
throw Error('获取失败')
}
} }
} catch (err) { } catch (err) {
uni.showToast({ title: '登录失败', icon: 'none' }) uni.showToast({ title: "登录失败", icon: "none" });
console.error(err) console.error(err);
} }
} };
defineExpose({ open, close }) defineExpose({ open, close });
</script> </script>
<style lang='scss'> <style lang="scss">
.custom-button { .custom-button {
border: none; border: none;
outline: none; outline: none;
background-color: transparent; background-color: transparent;
padding: 0; padding: 0;
margin: 0; margin: 0;
line-height: normal; line-height: normal;
font-family: inherit; font-family: inherit;
} }
.custom-button::after { .custom-button::after {
border: none; border: none;
} }
.popup-container { .popup-container {
background-color: #fff; background-color: #fff;
padding: 40rpx 30rpx 60rpx; padding: 40rpx 30rpx 60rpx;
@@ -146,7 +144,7 @@ defineExpose({ open, close })
margin-bottom: 20rpx; margin-bottom: 20rpx;
background-color: #eee; background-color: #eee;
} }
.avatar-preview { .avatar-preview {
width: 100%; width: 100%;
height: 100%; height: 100%;

View File

@@ -57,6 +57,14 @@
"enablePullDownRefresh": false "enablePullDownRefresh": false
} }
}, },
{
"path": "pages/mine/profile",
"style": {
"navigationBarTitleText": "个人信息",
"navigationStyle": "custom",
"enablePullDownRefresh": false
}
},
{ {
"path": "pages/mine/mine", "path": "pages/mine/mine",
"style": { "style": {

View File

@@ -171,7 +171,10 @@ const handleUserClick = () => {
if (!isLoggedIn.value) { if (!isLoggedIn.value) {
loginPopupRef.value.open(); loginPopupRef.value.open();
} else { } else {
// Navigate to profile details or do nothing // Navigate to profile details
uni.navigateTo({
url: "/pages/mine/profile",
});
} }
}; };

407
pages/mine/profile.vue Normal file
View File

@@ -0,0 +1,407 @@
<template>
<view class="profile-page" :style="{ paddingTop: navBarHeight + 'px' }">
<!-- Custom Navbar -->
<view
class="nav-bar"
:style="{
height: navBarHeight + 'px',
paddingTop: statusBarHeight + 'px',
}"
>
<view class="nav-left" @click="goBack">
<uni-icons type="left" size="24" color="#000" />
</view>
<text class="nav-title">个人信息</text>
</view>
<!-- Content -->
<view class="content">
<!-- Avatar Section -->
<view class="avatar-section">
<view class="avatar-wrapper" @click="handleAvatarClick">
<image
class="avatar"
:src="
userInfo.avatarUrl ||
'https://mmbiz.qpic.cn/mmbiz/icTdbqWNOwNRna42FI242Lcia07jQodd2FJGIYQfG0LAJGFxM4FbnQP6yfMxBgJ0F3YRqJCJ1aPAK2dQagdusBZg/0'
"
mode="aspectFill"
/>
<view class="camera-badge">
<uni-icons type="camera-filled" size="16" color="#ff3b30" />
</view>
</view>
<text class="change-avatar-text">更换头像</text>
</view>
<!-- Info List -->
<view class="info-card">
<view class="info-item" @click="handleEditName">
<text class="label">昵称</text>
<view class="value-box">
<text class="value">{{ form.nickName || "微信用户" }}</text>
<uni-icons type="right" size="14" color="#ccc" />
</view>
</view>
<view class="info-item" @click="handleEditGender">
<text class="label">性别</text>
<view class="value-box">
<text class="value">{{ genderText }}</text>
<uni-icons type="right" size="14" color="#ccc" />
</view>
</view>
<view class="info-item" @click="handleEditManifesto">
<text class="label">新春宣言</text>
<view class="value-box">
<text class="value red-text">{{ form.bio || "点击选择" }}</text>
<uni-icons type="compose" size="16" color="#ccc" />
</view>
</view>
</view>
<!-- Account Binding -->
<!-- <view class="section-header">
<text class="section-title">账号绑定</text>
</view>
<view class="info-card">
<view class="info-item">
<text class="label">手机号</text>
<view class="value-box">
<text class="value">138 **** 8888</text>
</view>
</view>
<view class="info-item">
<text class="label">微信号</text>
<view class="value-box">
<text class="value">WX_CN_2026</text>
</view>
</view>
</view> -->
<!-- Trophy Decoration -->
<view class="trophy-decoration">
<uni-icons type="medal" size="60" color="#e0e0e0" />
</view>
</view>
<!-- Bottom Action Bar -->
<view class="bottom-bar safe-area-bottom">
<button class="save-btn" @click="handleSave">
<text>保存修改</text>
</button>
</view>
</view>
</template>
<script setup>
import { ref, computed, watch } from "vue";
import { storeToRefs } from "pinia";
import {
getBavBarHeight,
getStatusBarHeight as getStatus,
} from "@/utils/system";
import { useUserStore } from "@/stores/user";
import { updateUserInfo } from "@/api/auth.js";
const navBarHeight = getBavBarHeight();
const statusBarHeight = getStatus();
const userStore = useUserStore();
const { userInfo } = storeToRefs(userStore);
const form = ref({
nickName: "",
gender: 1, // 1: 男, 2: 女
bio: "",
});
const genderText = computed(() => {
if (form.value.gender === 1) return "男";
if (form.value.gender === 2) return "女";
return "未知";
});
watch(
userInfo,
(newVal) => {
if (newVal) {
form.value.nickName = newVal.nickName || "";
form.value.gender = newVal.gender || 1;
form.value.bio = newVal.bio || "万事如意,岁岁平安";
}
},
{ immediate: true, deep: true },
);
const goBack = () => {
uni.navigateBack();
};
const handleAvatarClick = () => {
// Navigate to avatar page or open picker
uni.navigateTo({
url: "/pages/mine/avatar",
});
};
const handleEditName = () => {
uni.showModal({
title: "修改昵称",
editable: true,
placeholderText: "请输入昵称(最多5个字)",
content: form.value.nickName,
success: (res) => {
if (res.confirm) {
if (res.content.length > 5) {
uni.showToast({ title: "昵称最多5个字", icon: "none" });
return;
}
form.value.nickName = res.content;
}
},
});
};
const handleEditGender = () => {
uni.showActionSheet({
itemList: ["男", "女"],
success: (res) => {
form.value.gender = res.tapIndex + 1;
},
});
};
const manifestoOptions = [
"万事如意,岁岁平安",
"龙马精神,财运亨通",
"心想事成,大吉大利",
"身体健康,阖家幸福",
"吉星高照,福寿安康",
];
const handleEditManifesto = () => {
uni.showActionSheet({
itemList: manifestoOptions,
success: (res) => {
form.value.bio = manifestoOptions[res.tapIndex];
},
});
};
const handleSave = async () => {
if (!form.value.nickName) {
uni.showToast({ title: "请输入昵称", icon: "none" });
return;
}
uni.showLoading({ title: "保存中...", mask: true });
try {
const res = await updateUserInfo({
nickname: form.value.nickName, // Prompt said "nickname" lowercase, I used "nickName" in ref but need to check param name.
// Prompt: "请求参数分别是nickname gender bio"
// Wait, prompt said "nickname", usually it is nickName in Uni-app userInfo.
// But I will follow prompt "nickname".
gender: form.value.gender,
bio: form.value.bio,
});
// Check result
if (res && res.success) {
await userStore.fetchUserInfo();
uni.hideLoading();
uni.showToast({ title: "保存成功", icon: "success" });
} else {
uni.hideLoading();
// If res is not success but request didn't fail, maybe backend returns something else?
// Assuming request wrapper handles non-200 business codes by rejecting,
// so here we might just have data.
// If prompt says "returns { success: true }", then checking res.success is correct.
// If it fails, request throws error usually.
}
} catch (e) {
uni.hideLoading();
// Error is handled by request usually if showError is true.
// But we can show toast too.
console.error(e);
}
};
</script>
<style lang="scss" scoped>
.profile-page {
min-height: 100vh;
background-color: #fcfcfc;
box-sizing: border-box;
padding-bottom: 200rpx;
}
.nav-bar {
position: fixed;
top: 0;
left: 0;
width: 100%;
background-color: #fcfcfc;
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
.nav-left {
position: absolute;
left: 30rpx;
bottom: 0;
height: 44px;
display: flex;
align-items: center;
}
.nav-title {
font-size: 34rpx;
font-weight: 600;
color: #000;
}
}
.content {
padding: 30rpx;
}
/* Avatar Section */
.avatar-section {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 40rpx;
margin-bottom: 60rpx;
}
.avatar-wrapper {
position: relative;
width: 180rpx;
height: 180rpx;
background: #f8cb8c;
border-radius: 40rpx;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20rpx;
border: 4rpx solid #fff;
box-shadow: 0 10rpx 30rpx rgba(248, 203, 140, 0.4);
}
.avatar {
width: 160rpx;
height: 160rpx;
border-radius: 36rpx;
}
.camera-badge {
position: absolute;
bottom: -10rpx;
right: -10rpx;
background: #fff;
width: 50rpx;
height: 50rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 10rpx rgba(0, 0, 0, 0.1);
}
.change-avatar-text {
font-size: 26rpx;
color: #8a6d65;
}
/* Info List */
.info-card {
background: #fff;
border-radius: 30rpx;
padding: 0 30rpx;
margin-bottom: 40rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.02);
}
.info-item {
display: flex;
align-items: center;
justify-content: space-between;
height: 110rpx;
border-bottom: 2rpx solid #f5f5f5;
&:last-child {
border-bottom: none;
}
}
.label {
font-size: 30rpx;
color: #666;
}
.value-box {
display: flex;
align-items: center;
}
.value {
font-size: 30rpx;
color: #333;
margin-right: 10rpx;
font-weight: 500;
&.red-text {
color: #ff3b30;
}
}
/* Section Header */
.section-header {
padding: 0 10rpx;
margin-bottom: 20rpx;
}
.section-title {
font-size: 28rpx;
font-weight: bold;
color: #8a6d65;
}
/* Trophy Decoration */
.trophy-decoration {
display: flex;
justify-content: center;
margin-top: 60rpx;
opacity: 0.5;
}
/* Bottom Bar */
.bottom-bar {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
background: #fcfcfc;
padding: 20rpx 40rpx;
box-sizing: border-box;
z-index: 99;
}
.save-btn {
background: #d32f2f;
color: #fff;
border-radius: 50rpx;
height: 90rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
font-weight: bold;
border: none;
box-shadow: 0 10rpx 20rpx rgba(211, 47, 47, 0.2);
&:active {
opacity: 0.9;
}
}
</style>

View File

@@ -34,9 +34,13 @@
<view class="user-info"> <view class="user-info">
<view class="name-row"> <view class="name-row">
<text class="username">{{ userInfo.nickName || "微信用户" }}</text> <text class="username">{{ userInfo.nickName || "微信用户" }}</text>
<view class="vip-tag">VIP 祥瑞会员</view> <view class="vip-tag" v-if="userInfo.isVip">VIP 祥瑞会员</view>
<view class="vip-tag gray" v-else>普通用户</view>
</view> </view>
<text class="expiry-date">2026-02-15 到期</text> <text class="expiry-date" v-if="userInfo.isVip">
{{ userInfo.vipExpireAt }} 到期
</text>
<text class="expiry-date" v-else>开通会员解锁专属权益</text>
</view> </view>
</view> </view>
@@ -53,10 +57,10 @@
<view v-if="plan.badge" class="plan-badge" :class="plan.badgeType"> <view v-if="plan.badge" class="plan-badge" :class="plan.badgeType">
{{ plan.badge }} {{ plan.badge }}
</view> </view>
<text class="plan-duration">{{ plan.duration }}</text> <text class="plan-duration">{{ plan.name }}</text>
<view class="plan-price"> <view class="plan-price">
<text class="currency">¥</text> <text class="currency">¥</text>
<text class="amount">{{ plan.price }}</text> <text class="amount">{{ plan.price / 100 }}</text>
</view> </view>
</view> </view>
</view> </view>
@@ -113,39 +117,24 @@
</template> </template>
<script setup> <script setup>
import { ref } from "vue"; import { ref, onMounted } from "vue";
import { storeToRefs } from "pinia";
import { import {
getBavBarHeight, getBavBarHeight,
getStatusBarHeight as getStatus, getStatusBarHeight as getStatus,
} from "@/utils/system"; } from "@/utils/system";
import { useUserStore } from "@/stores/user"; import { useUserStore } from "@/stores/user";
import { createOrder } from "@/api/pay.js"; import { createOrder, getVipPlan } from "@/api/pay.js";
const navBarHeight = getBavBarHeight(); const navBarHeight = getBavBarHeight();
const statusBarHeight = getStatus(); const statusBarHeight = getStatus();
const userStore = useUserStore(); const userStore = useUserStore();
const userInfo = userStore.userInfo; const { userInfo } = storeToRefs(userStore);
const selectedPlanIndex = ref(1); const selectedPlanIndex = ref(1);
const plans = [ const plans = ref([]);
{ duration: "一个月", price: "8.8", total: 880 },
{
duration: "一个季度",
price: "18",
badge: "热销",
badgeType: "hot",
total: 18000,
},
{ duration: "一年", price: "48", total: 48000 },
{
duration: "永久会员",
price: "88",
badge: "最划算",
badgeType: "best",
total: 88000,
},
];
const benefits = [ const benefits = [
{ name: "高级模板", icon: "star-filled", color: "#ff3b30", bg: "#fff0f0" }, { name: "高级模板", icon: "star-filled", color: "#ff3b30", bg: "#fff0f0" },
@@ -168,6 +157,15 @@ const notes = [
"最终解释权归 2026 新春助手团队所有。", "最终解释权归 2026 新春助手团队所有。",
]; ];
onMounted(() => {
getVipPlanList();
});
const getVipPlanList = async () => {
const planRes = await getVipPlan();
plans.value = planRes;
};
const goBack = () => { const goBack = () => {
uni.navigateBack(); uni.navigateBack();
}; };
@@ -177,23 +175,49 @@ const selectPlan = (index) => {
}; };
const handlePurchase = async () => { const handlePurchase = async () => {
const plan = plans[selectedPlanIndex.value || 0]; if (selectedPlanIndex.value < 0 || !plans.value[selectedPlanIndex.value]) {
console.log("plan", plan); uni.showToast({ title: "请选择会员方案", icon: "none" });
const orderRes = await createOrder({ return;
description: `新春会员购买 ${plan.duration} ${plan.price}`, }
total: plan.total,
}); const plan = plans.value[selectedPlanIndex.value];
if (orderRes.payParams) {
wx.requestPayment({ uni.showLoading({ title: "正在发起支付...", mask: true });
...orderRes.payParams,
success(res) { try {
// 等后端回调,不要直接认为支付成功 const orderRes = await createOrder({
console.log(11111, res); planId: plan.id,
},
fail(res) {
console.log(22222, res);
},
}); });
if (orderRes?.payParams) {
uni.requestPayment({
provider: "wxpay",
...orderRes.payParams,
success(res) {
uni.showToast({ title: "支付成功", icon: "success" });
// 支付成功后可以刷新用户信息
userStore.fetchUserInfo();
},
fail(err) {
console.log("payment fail", err);
if (err.errMsg.indexOf("cancel") > -1) {
uni.showToast({ title: "支付已取消", icon: "none" });
} else {
uni.showToast({ title: "支付失败", icon: "none" });
}
},
complete() {
uni.hideLoading();
},
});
} else {
uni.hideLoading();
uni.showToast({ title: "获取支付参数失败", icon: "none" });
}
} catch (e) {
uni.hideLoading();
uni.showToast({ title: "创建订单失败", icon: "none" });
console.error(e);
} }
}; };
</script> </script>
@@ -320,6 +344,11 @@ const handlePurchase = async () => {
padding: 2rpx 10rpx; padding: 2rpx 10rpx;
border-radius: 20rpx; border-radius: 20rpx;
font-weight: 500; font-weight: 500;
&.gray {
background: rgba(0, 0, 0, 0.05);
color: #666;
}
} }
.expiry-date { .expiry-date {

View File

@@ -1,6 +1,7 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { wxLogin, wxGetUserProfile } from "@/utils/login.js"; import { wxLogin, wxGetUserProfile } from "@/utils/login.js";
import { getPlatformProvider } from "@/utils/system"; import { getPlatformProvider } from "@/utils/system";
import { getUserInfo } from "@/api/auth.js";
export const useUserStore = defineStore("user", { export const useUserStore = defineStore("user", {
state: () => ({ state: () => ({
@@ -44,6 +45,16 @@ export const useUserStore = defineStore("user", {
} }
} }
}, },
async fetchUserInfo() {
try {
const res = await getUserInfo();
if (res) {
this.setUserInfo(res);
}
} catch (e) {
console.error("fetchUserInfo error", e);
}
},
logout() { logout() {
this.userInfo = {}; this.userInfo = {};
this.token = ""; this.token = "";