Files
hfb_sys/frontend/src/views/mobile/MobileProfileView.vue
T
2026-05-23 09:00:23 +08:00

719 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { useRouter, useRoute } from "vue-router";
import { useSessionStore } from "@/stores/session";
import { showDialog, showToast } from "vant";
const session = useSessionStore();
const router = useRouter();
const route = useRoute();
/** 设置面板 */
const showSettings = ref(false);
const showProfileEditor = ref(false);
const savingProfile = ref(false);
const profileForm = reactive({
nickname: "",
avatar_url: "",
});
const settingsGroups = [
{
title: "账号与安全",
items: [
{ label: "资料更改", icon: "edit", action: "profile" },
{ label: "实名认证", icon: "idcard", action: "realname" },
{ label: "注销账号", icon: "delete-o", action: "cancel-account" },
],
},
{
title: "法律与隐私",
items: [
{ label: "隐私政策", icon: "shield-o", action: "privacy" },
{ label: "用户协议", icon: "description", action: "terms" },
],
},
];
function onSettingClick(action: string) {
showSettings.value = false;
switch (action) {
case "profile":
openProfileEditor();
break;
case "realname":
router.push("/m/realname");
break;
case "cancel-account":
showDialog({
title: "注销账号",
message: "注销后账号数据将无法恢复,确认注销吗?",
showCancelButton: true,
confirmButtonText: "确认注销",
confirmButtonColor: "#ee0a24",
cancelButtonText: "再想想",
})
.then(() => {
showToast({ message: "注销功能开发中", icon: "info-o" });
})
.catch(() => {});
break;
case "privacy":
showToast({ message: "隐私政策页面开发中", icon: "info-o" });
break;
case "terms":
showToast({ message: "用户协议页面开发中", icon: "info-o" });
break;
}
}
function openProfileEditor() {
profileForm.nickname = session.nickname || defaultName.value;
profileForm.avatar_url = session.avatarUrl || "";
showProfileEditor.value = true;
}
async function saveProfile() {
const nickname = profileForm.nickname.trim();
const avatarURL = profileForm.avatar_url.trim();
if (!nickname) {
showToast({ message: "请输入昵称", icon: "warning-o" });
return;
}
if (nickname.length > 24) {
showToast({ message: "昵称不能超过 24 个字符", icon: "warning-o" });
return;
}
savingProfile.value = true;
try {
await session.updateProfile({ nickname, avatar_url: avatarURL });
showProfileEditor.value = false;
showToast({ message: "资料已更新", icon: "passed" });
} catch (error) {
showToast({ message: readError(error, "资料更新失败"), icon: "cross" });
} finally {
savingProfile.value = false;
}
}
function readError(error: unknown, fallback: string) {
if (typeof error === "object" && error && "response" in error) {
const response = (error as { response?: { data?: { message?: string } } }).response;
return response?.data?.message || fallback;
}
return fallback;
}
function confirmLogout() {
showSettings.value = false;
showDialog({
title: "退出登录",
message: "确定要退出当前账号吗?",
showCancelButton: true,
confirmButtonText: "退出",
confirmButtonColor: "#ee0a24",
cancelButtonText: "取消",
})
.then(() => {
session.logout();
router.replace("/m");
})
.catch(() => {});
}
/** 判断当前底部导航是否激活 */
function isNavActive(path: string) {
if (path === "/m") return route.path === "/m";
return route.path.startsWith(path);
}
const isLoggedIn = computed(() => Boolean(session.token));
onMounted(() => {
if (!isLoggedIn.value) {
router.replace({ path: "/m/login", query: { redirect: route.fullPath } });
}
});
const defaultName = computed(() =>
session.phone
? `用户${session.phone.slice(-6)}`
: `用户${session.userId || 883099}`
);
const displayName = computed(() => session.nickname || defaultName.value);
const displayId = computed(() => session.userId || 138865);
const maskedPhone = computed(() =>
session.phone
? `${session.phone.slice(0, 3)}****${session.phone.slice(-4)}`
: "登录后查看手机号"
);
const avatarText = computed(() => displayName.value.slice(0, 1));
</script>
<template>
<main class="mobile-profile-shell">
<!-- 已登录区 -->
<template v-if="isLoggedIn">
<!-- Hero 顶部蓝色横幅 -->
<section class="profile-hero">
<div class="hero-bg"></div>
<div class="profile-hero-content">
<div class="avatar-wrap">
<div class="avatar-circle">
<img v-if="session.avatarUrl" :src="session.avatarUrl" alt="" />
<span v-else>{{ avatarText }}</span>
</div>
</div>
<div class="hero-user">
<h1>{{ displayName }}</h1>
<p>ID: {{ displayId }} · {{ maskedPhone }}</p>
</div>
<button class="hero-setting-btn" @click="showSettings = true">
<van-icon
name="setting-o"
:size="22"
color="#64748b"
/>
</button>
</div>
</section>
<!-- 余额卡片 - 白色浮层 -->
<div class="balance-card">
<div class="balance-row">
<div class="balance-info">
<span class="balance-label">账户余额(元)</span>
<strong class="balance-value">¥0.00</strong>
</div>
<van-button type="primary" size="small" round class="withdraw-btn"
>提现</van-button
>
</div>
<div class="balance-footer">
<van-button
size="mini"
plain
hairline
type="warning"
class="detail-btn"
>查看明细</van-button
>
</div>
</div>
</template>
<!-- 设置面板 - Popup -->
<van-popup
v-model:show="showSettings"
position="right"
:style="{ width: '80%', height: '100%' }"
>
<div class="settings-panel">
<header class="settings-header">
<h2>设置</h2>
<button class="settings-close" @click="showSettings = false">
<van-icon name="cross" :size="20" />
</button>
</header>
<div
v-for="group in settingsGroups"
:key="group.title"
class="settings-group"
>
<h3 class="settings-group-title">{{ group.title }}</h3>
<van-cell-group :border="false">
<van-cell
v-for="item in group.items"
:key="item.action"
:title="item.label"
:icon="item.icon"
is-link
@click="onSettingClick(item.action)"
/>
</van-cell-group>
</div>
<div class="settings-logout">
<van-button plain type="danger" block round @click="confirmLogout">
退出登录
</van-button>
</div>
</div>
</van-popup>
<van-popup
v-model:show="showProfileEditor"
position="bottom"
round
:style="{ maxWidth: '430px', margin: '0 auto', left: 0, right: 0 }"
>
<section class="profile-editor">
<header class="profile-editor-header">
<h2>资料更改</h2>
<button type="button" @click="showProfileEditor = false">
<van-icon name="cross" :size="20" />
</button>
</header>
<div class="profile-preview">
<div class="profile-preview-avatar">
<img v-if="profileForm.avatar_url" :src="profileForm.avatar_url" alt="" />
<span v-else>{{ (profileForm.nickname || defaultName).slice(0, 1) }}</span>
</div>
<div>
<strong>{{ profileForm.nickname || defaultName }}</strong>
<p>{{ maskedPhone }}</p>
</div>
</div>
<label class="editor-field">
<span>昵称</span>
<input
v-model="profileForm.nickname"
maxlength="24"
placeholder="请输入昵称"
/>
</label>
<label class="editor-field">
<span>头像地址</span>
<input
v-model="profileForm.avatar_url"
maxlength="512"
placeholder="可填写图片 URL,留空使用文字头像"
/>
</label>
<van-button
type="primary"
block
round
class="profile-save-btn"
:loading="savingProfile"
loading-text="保存中..."
@click="saveProfile"
>
保存资料
</van-button>
</section>
</van-popup>
<!-- 底部导航 - 手写原生,和首页一致 -->
<nav class="bottom-nav">
<RouterLink
to="/m"
class="nav-item"
:class="{ active: isNavActive('/m') }"
>
<van-icon name="home-o" :size="22" />
<span>首页</span>
</RouterLink>
<RouterLink
to="/m/messages"
class="nav-item"
:class="{ active: isNavActive('/m/messages') }"
>
<van-icon name="chat-o" :size="22" />
<span>消息</span>
</RouterLink>
<RouterLink to="/m/seller/listings/create" class="nav-item nav-publish">
<div class="publish-pill">+</div>
<span>发布</span>
</RouterLink>
<RouterLink
to="/m/orders"
class="nav-item"
:class="{ active: isNavActive('/m/orders') }"
>
<van-icon name="orders-o" :size="22" />
<span>订单</span>
</RouterLink>
<RouterLink
to="/m/profile"
class="nav-item"
:class="{ active: isNavActive('/m/profile') }"
>
<van-icon name="manager-o" :size="22" />
<span>我的</span>
</RouterLink>
</nav>
</main>
</template>
<style scoped>
.mobile-profile-shell {
min-height: 100vh;
background: #f5f6f8;
color: #1a202c;
padding: 0 0 60px;
}
/* ========== 已登录区 - 个人信息卡 ========== */
.profile-hero {
position: relative;
padding: 14px 12px 0;
}
.hero-bg {
display: none;
}
.profile-hero-content {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 14px;
border: 1px solid #eef1f5;
border-radius: 18px;
background: #fff;
padding: 16px;
box-shadow: 0 8px 24px rgba(23, 35, 61, 0.06);
}
.avatar-wrap {
flex: 0 0 auto;
}
.avatar-circle {
display: grid;
width: 54px;
height: 54px;
place-items: center;
border-radius: 16px;
overflow: hidden;
background: #eaf2ff;
color: #1477ff;
font-size: 22px;
font-weight: 900;
}
.avatar-circle img {
width: 100%;
height: 100%;
object-fit: cover;
}
.hero-user {
min-width: 0;
flex: 1;
}
.hero-user h1 {
overflow: hidden;
margin: 0;
font-size: 18px;
font-weight: 800;
color: #17233d;
text-overflow: ellipsis;
white-space: nowrap;
}
.hero-user p {
margin: 4px 0 0;
color: #7b8798;
font-size: 12px;
}
.hero-setting-btn {
display: grid;
width: 36px;
height: 36px;
place-items: center;
border: none;
background: #f4f6f8;
border-radius: 12px;
cursor: pointer;
}
/* ========== 余额卡片 - 白色浮层 ========== */
.balance-card {
position: relative;
margin: 12px 12px 0;
padding: 18px 16px 14px;
border-radius: 16px;
background: #fff;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
}
.balance-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.balance-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.balance-label {
color: #8b8f99;
font-size: 12px;
font-weight: 600;
}
.balance-value {
font-size: 26px;
line-height: 1;
color: #1a202c;
font-weight: 900;
}
.balance-footer {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid #f0f0f0;
}
.detail-btn {
border-color: #ff8a42;
color: #ff5f00;
background: #fff5ee;
font-weight: 800;
}
.withdraw-btn {
background: #1477ff;
border-color: transparent;
font-weight: 800;
height: 32px;
padding: 0 20px;
}
/* ========== 设置面板 ========== */
.settings-panel {
display: flex;
flex-direction: column;
height: 100%;
background: #f5f6f8;
}
.settings-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
background: #fff;
border-bottom: 1px solid #eee;
}
.settings-header h2 {
margin: 0;
font-size: 17px;
font-weight: 700;
}
.settings-close {
display: grid;
width: 32px;
height: 32px;
place-items: center;
border: none;
background: none;
color: #999;
cursor: pointer;
}
.settings-group {
margin-top: 12px;
}
.settings-group-title {
margin: 0 0 4px 16px;
font-size: 13px;
font-weight: 600;
color: #999;
}
.settings-group :deep(.van-cell) {
font-size: 15px;
}
.settings-logout {
margin: auto 16px 24px;
padding-top: 20px;
}
/* ========== 资料修改 ========== */
.profile-editor {
padding: 16px 16px max(18px, env(safe-area-inset-bottom));
background: #fff;
}
.profile-editor-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.profile-editor-header h2 {
margin: 0;
color: #17233d;
font-size: 18px;
font-weight: 800;
}
.profile-editor-header button {
display: grid;
width: 34px;
height: 34px;
place-items: center;
border: none;
border-radius: 12px;
background: #f4f6f8;
color: #64748b;
}
.profile-preview {
display: flex;
align-items: center;
gap: 12px;
margin: 18px 0;
padding: 14px;
border-radius: 14px;
background: #f7faff;
}
.profile-preview-avatar {
display: grid;
width: 52px;
height: 52px;
flex: 0 0 auto;
place-items: center;
overflow: hidden;
border-radius: 16px;
background: #1477ff;
color: #fff;
font-size: 22px;
font-weight: 900;
}
.profile-preview-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.profile-preview strong {
display: block;
max-width: 230px;
overflow: hidden;
color: #17233d;
font-size: 16px;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-preview p {
margin: 4px 0 0;
color: #7b8798;
font-size: 12px;
}
.editor-field {
display: block;
margin-bottom: 12px;
}
.editor-field span {
display: block;
margin-bottom: 6px;
color: #697386;
font-size: 13px;
font-weight: 700;
}
.editor-field input {
box-sizing: border-box;
width: 100%;
height: 46px;
border: 1px solid #e1e7ef;
border-radius: 12px;
background: #fff;
color: #17233d;
font-size: 14px;
outline: none;
padding: 0 12px;
}
.editor-field input:focus {
border-color: #1477ff;
box-shadow: 0 0 0 3px rgba(20, 119, 255, 0.1);
}
.profile-save-btn {
height: 44px;
margin-top: 6px;
background: #1477ff;
border-color: transparent;
font-weight: 800;
}
/* ========== 底部导航 - 手写原生 ========== */
.bottom-nav {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 100;
display: flex;
height: 50px;
background: #fff;
border-top: 1px solid #eee;
}
.nav-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
color: #999;
font-size: 10px;
text-decoration: none;
}
.nav-item.active {
color: #1477ff;
}
.nav-item span {
font-weight: 600;
}
.publish-pill {
width: 36px;
height: 26px;
display: grid;
place-items: center;
border-radius: 13px;
background: #ff6a00;
color: #fff;
font-size: 18px;
font-weight: 900;
}
.nav-publish span {
color: #ff6a00;
}
/* ========== 响应式适配 ========== */
@media (min-width: 520px) {
.mobile-profile-shell {
max-width: 430px;
margin: 0 auto;
box-shadow: 0 0 0 1px rgba(23, 35, 61, 0.08);
}
.bottom-nav {
left: calc((100vw - 430px) / 2);
right: calc((100vw - 430px) / 2);
}
}
</style>