feat: migrate app routes and business modules

This commit is contained in:
2026-08-12 18:22:59 +08:00
parent 555aa00043
commit cc706378c2
247 changed files with 28623 additions and 14988 deletions
+817
View File
@@ -0,0 +1,817 @@
<template>
<view
class="profile-page"
:class="{
'profile-state--ready': !loading && !profileHomeError,
'profile-state--error': Boolean(profileHomeError),
}"
>
<view v-if="compactHeader" class="profile-compact-header" aria-hidden="true">
<view class="profile-compact-header__brand-lockup">
<image
src="/static/assets/foundation/transparent/brand-seal.png"
mode="aspectFit"
/>
<text>代代相传</text>
</view>
<text class="profile-compact-header__title">我的</text>
</view>
<view v-if="loading" class="profile-state-card">
<text>正在读取个人资料</text>
</view>
<view v-else-if="profileHomeError" class="profile-state-card">
<text>个人资料暂时无法读取</text>
<text>{{ profileHomeError }}</text>
<AppButton block label="重新读取" @click="loadProfile" />
</view>
<template v-else>
<view class="profile-hero">
<image
class="profile-hero__art"
src="/static/assets/modules/profile/opaque/archive-hero.png"
mode="scaleToFill"
aria-hidden="true"
/>
<view class="profile-hero__content">
<view class="profile-hero__brand-lockup">
<image
src="/static/assets/foundation/transparent/brand-seal.png"
mode="aspectFit"
aria-hidden="true"
/>
<text>代代相传</text>
</view>
<view class="profile-hero__label">
<text>我的</text>
<view
class="profile-hero__setting"
role="button"
aria-label="关于与设置"
@click="openSettings"
><image
class="profile-hero__setting-icon"
src="/static/assets/modules/profile/transparent/icon-settings.png"
mode="aspectFit"
aria-hidden="true"
/></view>
</view>
<view class="profile-hero__identity">
<view class="profile-hero__emblem">
<AppAvatar
:sex="profile.sex"
:src="profile.avatarFile?.accessUrl || ''"
/>
</view>
<view class="profile-hero__copy">
<text>{{ profile.nickName || "未设置昵称" }}</text>
<text>{{ profile.realName || "未设置真实姓名" }}</text>
<text>{{ profile.phone || "未提供手机号" }}</text>
</view>
</view>
<button
class="profile-hero__edit"
@click="openEdit"
>编辑资料</button>
</view>
</view>
<view class="profile-metadata" aria-label="个人资料摘要">
<view class="profile-metadata__item">
<image
class="profile-metadata__icon"
src="/static/assets/modules/profile/transparent/icon-person.png"
mode="aspectFit"
aria-hidden="true"
/>
<view><text>性别</text><text class="app-single-line">{{ sexText }}</text></view>
</view>
<view class="profile-metadata__item">
<image
class="profile-metadata__icon"
src="/static/assets/modules/profile/transparent/icon-calendar.png"
mode="aspectFit"
aria-hidden="true"
/>
<view><text>生日</text><text class="app-single-line">{{ birthdayText }}</text></view>
</view>
<view class="profile-metadata__item profile-metadata__item--email">
<image
class="profile-metadata__icon"
src="/static/assets/modules/profile/transparent/icon-envelope.png"
mode="aspectFit"
aria-hidden="true"
/>
<view><text>邮箱</text><text class="app-long-value">{{ profile.email || "未设置" }}</text></view>
</view>
</view>
<view class="profile-preference" aria-label="个性化推荐设置">
<view>
<text>个性化推荐</text>
<text v-if="preferenceLoading">正在读取设置</text>
<text v-else-if="preferenceError">设置暂不可用</text>
<text v-else>{{ recommendationPreference.enabled ? "已开启将优先展示与家谱互动相关的内容" : "已关闭按常规时间顺序展示内容" }}</text>
</view>
<switch
:checked="recommendationPreference.enabled"
:disabled="preferenceLoading || preferenceSaving || Boolean(preferenceError)"
color="#9f170f"
@change="changeRecommendationPreference"
/>
</view>
<view class="profile-services">
<view
v-for="group in serviceGroups"
:key="group.title"
class="profile-service-group"
>
<view class="profile-section-title">
<view class="profile-section-title__mark" aria-hidden="true"></view>
<text>{{ group.title }}</text>
</view>
<view class="profile-service-list">
<view
v-for="item in group.items"
:key="item.routeKey"
class="profile-service-row"
role="button"
:aria-label="item.label"
@click="openService(item)"
>
<view class="profile-service-row__leading">
<image
:class="[
'profile-service-row__icon',
`profile-service-row__icon--${item.iconVariant}`,
]"
:src="item.icon"
mode="aspectFit"
aria-hidden="true"
/>
<text>{{ item.label }}</text>
</view>
<image
class="profile-service-row__chevron"
src="/static/assets/foundation/transparent/chevron-right.png"
mode="aspectFit"
aria-hidden="true"
/>
</view>
</view>
</view>
</view>
</template>
<AppPromotionStrip v-if="!loading && !profileHomeError" placement="profile_bottom" title="为你推荐" />
<AppTabbar active="profile" />
</view>
</template>
<script setup>
import { computed, reactive, ref } from "vue";
import { onPageScroll, onShow, onUnload } from "@dcloudio/uni-app";
import AppAvatar from "@/components/AppAvatar.vue";
import AppButton from "@/components/AppButton.vue";
import AppPromotionStrip from "@/components/AppPromotionStrip.vue";
import AppTabbar from "@/components/AppTabbar.vue";
import {
PROFILE_SEX_OPTIONS
} from "@/services/api/profile-contract.js";
import {
createRequestController,
isRequestCancelled
} from "@/services/api/request-controller.js";
import { profileApi } from "@/services/api/profile-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { openPage } from "@/utils/navigation/gateway.js";
const profile = reactive({
avatarFile: null,
nickName: "",
realName: "",
phone: "",
sex: "",
birthday: "",
email: "",
});
const loading = ref(false);
const profileHomeError = ref("");
const compactHeader = ref(false);
const profileReadController = createRequestController();
const preferenceReadController = createRequestController();
const preferenceSaveController = createRequestController();
const recommendationPreference = reactive({ enabled: false, version: 0, updatedAt: "" });
const preferenceLoading = ref(false);
const preferenceSaving = ref(false);
const preferenceError = ref("");
let isPageActive = true;
const serviceGroups = [
{
title: "账户与服务",
items: [
{
routeKey: "M03",
label: "账号与安全",
iconVariant: "security",
icon: "/static/assets/modules/profile/transparent/icon-security.png",
},
{
routeKey: "N01",
label: "消息中心",
iconVariant: "notifications",
icon: "/static/assets/modules/profile/transparent/icon-message.png",
},
{
routeKey: "M11",
label: "活动邀请",
iconVariant: "invitations",
icon: "/static/assets/modules/profile/transparent/icon-message.png",
},
{
routeKey: "M09",
label: "VIP 服务",
iconVariant: "vip",
icon: "/static/assets/modules/profile/transparent/icon-vip.png",
},
{
routeKey: "M12",
label: "收益与提现",
iconVariant: "earnings",
icon: "/static/assets/modules/profile/transparent/icon-vip.png",
},
],
},
{
title: "支持与其他",
items: [
{
routeKey: "M06",
label: "帮助中心",
iconVariant: "help",
icon: "/static/assets/modules/profile/transparent/icon-help.png",
},
{
routeKey: "M07",
label: "意见反馈",
iconVariant: "feedback",
icon: "/static/assets/modules/profile/transparent/icon-feedback.png",
},
{
routeKey: "M08",
label: "应用推广",
iconVariant: "promotion",
icon: "/static/assets/modules/profile/transparent/icon-promotion.png",
},
{
routeKey: "M10",
label: "关于与设置",
iconVariant: "settings",
icon: "/static/assets/modules/profile/transparent/icon-settings.png",
},
],
},
];
const profileSexLabels = Object.freeze(
Object.fromEntries(PROFILE_SEX_OPTIONS.map(({ value, label }) => [value, label])),
);
const sexText = computed(
() => profileSexLabels[String(profile.sex)] || "未设置",
);
const birthdayText = computed(() =>
/^\d{4}-\d{2}-\d{2}/.test(profile.birthday)
? profile.birthday.slice(0, 10)
: "未设置",
);
const loadProfile = async () => {
if (loading.value) return;
loading.value = true;
profileHomeError.value = "";
try {
const profileResponse = await profileApi.getProfile({ requestController: profileReadController });
if (!isPageActive) return;
Object.assign(profile, profileResponse);
} catch (error) {
if (isPageActive && !isRequestCancelled(error)) {
profileHomeError.value = getRequestErrorMessage(error, "请稍后重试");
}
} finally {
if (isPageActive) loading.value = false;
}
};
const loadRecommendationPreference = async () => {
if (preferenceLoading.value || preferenceSaving.value) return;
preferenceLoading.value = true;
preferenceError.value = "";
try {
const preferenceResponse = await profileApi.getRecommendationPreference({
requestController: preferenceReadController,
});
if (isPageActive) Object.assign(recommendationPreference, preferenceResponse);
} catch (error) {
if (isPageActive && !isRequestCancelled(error)) {
preferenceError.value = getRequestErrorMessage(error, "请稍后重试");
}
} finally {
if (isPageActive) preferenceLoading.value = false;
}
};
const changeRecommendationPreference = async (event) => {
if (preferenceLoading.value || preferenceSaving.value || preferenceError.value) return;
const enabled = Boolean(event?.detail?.value);
preferenceSaving.value = true;
try {
const savedPreference = await profileApi.updateRecommendationPreference(enabled, {
requestController: preferenceSaveController,
});
if (isPageActive) Object.assign(recommendationPreference, savedPreference);
} catch (error) {
if (isPageActive && !isRequestCancelled(error)) {
preferenceError.value = getRequestErrorMessage(error, "设置未保存,请稍后重试");
}
} finally {
if (isPageActive) preferenceSaving.value = false;
}
};
const openService = (item) => openPage(item.routeKey, {}, "M01");
const openEdit = () => openPage("M02", {}, "M01");
const openSettings = () => openPage("M10", {}, "M01");
onShow(() => {
void loadProfile();
void loadRecommendationPreference();
});
onPageScroll(({ scrollTop = 0 }) => {
compactHeader.value = scrollTop > 44;
});
onUnload(() => {
isPageActive = false;
compactHeader.value = false;
profileReadController.abort();
preferenceReadController.abort();
preferenceSaveController.abort();
});
</script>
<style scoped lang="scss">
@import "../../styles/adaptive-frame-profiles.scss";
.profile-page {
position: relative;
width: 100%;
min-height: 100vh;
padding-bottom: 166rpx;
box-sizing: border-box;
overflow-x: hidden;
background: #f3eee5 url("/static/assets/modules/profile/opaque/paper-mountains.png") center 360rpx / 100% auto no-repeat;
}
.profile-compact-header {
position: fixed;
top: 0;
right: 0;
left: 0;
z-index: 30;
height: calc(104rpx + var(--status-bar-height, 0px));
box-sizing: border-box;
padding: calc(var(--status-bar-height, 0px) + 46rpx) 48rpx 0;
background: linear-gradient(90deg, #6a1d17, #4e120f);
box-shadow: 0 2rpx 12rpx rgba(58, 16, 12, 0.22);
color: #f2d9a8;
font-family: STKaiti, KaiTi, serif;
pointer-events: none;
}
.profile-compact-header__brand-lockup {
position: absolute;
top: calc(var(--status-bar-height, 0px) + 8rpx);
left: 50%;
display: flex;
align-items: center;
gap: 7rpx;
transform: translateX(-50%);
white-space: nowrap;
}
.profile-compact-header__brand-lockup image {
width: 32rpx;
height: 32rpx;
}
.profile-compact-header__brand-lockup text {
color: #f2d9a8;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(11px, 19rpx, 14px);
font-weight: 700;
letter-spacing: 3rpx;
line-height: 1;
}
.profile-compact-header__title {
display: block;
color: #f2d9a8;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(16px, 28rpx, 20px);
font-weight: 700;
letter-spacing: 3rpx;
line-height: 1;
}
.profile-state-card {
@include adaptive-profile-summary;
margin: 48rpx 30rpx;
padding: 64rpx 44rpx;
color: #382c25;
text-align: center;
}
.profile-state-card text {
display: block;
font-size: clamp(16px, 26rpx, 20px);
line-height: 1.6;
}
.profile-state-card text + text {
margin-top: 12rpx;
color: #806e5d;
font-size: clamp(14px, 22rpx, 17px);
}
.profile-state-card .app-button {
@include adaptive-scroll-button(primary);
margin-top: 28rpx;
}
.profile-hero {
display: grid;
width: 100%;
height: 503rpx;
}
.profile-hero__art,
.profile-hero__content {
grid-area: 1 / 1;
}
.profile-hero__art {
width: 100%;
height: 480rpx;
pointer-events: none;
}
.profile-hero__content {
position: relative;
display: flex;
flex-direction: column;
z-index: 1;
padding: 100rpx 56rpx 50rpx 54rpx;
box-sizing: border-box;
}
.profile-hero__brand-lockup {
position: absolute;
top: calc(var(--status-bar-height, 0px) + 4rpx);
left: 50%;
display: flex;
align-items: center;
gap: 7rpx;
transform: translateX(-50%);
white-space: nowrap;
}
.profile-hero__brand-lockup image {
width: 32rpx;
height: 32rpx;
}
.profile-hero__brand-lockup text {
color: #f2d9a8;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(11px, 19rpx, 14px);
font-weight: 700;
letter-spacing: 3rpx;
line-height: 1;
}
.profile-hero__label {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
color: #f0d6a4;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(27px, 54rpx, 34px);
font-weight: 700;
letter-spacing: 5rpx;
line-height: 1;
}
.profile-hero__setting {
display: flex;
width: 48rpx;
height: 48rpx;
align-items: center;
justify-content: center;
}
.profile-hero__setting-icon {
width: 42rpx;
height: 42rpx;
filter: grayscale(1) sepia(0.9) saturate(1.6) hue-rotate(348deg) brightness(1.35);
transform: scale(1.25);
}
.profile-hero__identity {
display: flex;
min-width: 0;
align-items: flex-start;
position: relative;
margin-top: 43rpx;
margin-left: -22rpx;
column-gap: 20rpx;
}
.profile-hero__emblem {
display: block;
width: 196rpx;
height: 196rpx;
flex: 0 0 auto;
box-sizing: border-box;
border: 4rpx solid #d8ad5d;
border-radius: 50%;
background: #fff7e6;
box-shadow: 0 8rpx 18rpx rgba(44, 15, 12, 0.32);
overflow: hidden;
}
.profile-hero__copy {
min-width: 0;
flex: 1;
margin: 40rpx 0 0;
}
.profile-hero__copy text {
display: block;
overflow-wrap: anywhere;
}
.profile-hero__copy text:first-child {
color: #fff2da;
font-family: STSong, SimSun, serif;
font-size: clamp(17px, 32rpx, 22px);
font-weight: 700;
letter-spacing: 1rpx;
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.profile-hero__copy text:nth-child(2) {
margin-top: 14rpx;
color: #ecd2a4;
font-size: clamp(16px, 27rpx, 20px);
line-height: 1.35;
}
.profile-hero__copy text:last-child {
margin-top: 18rpx;
color: #e8bd78;
font-size: clamp(16px, 28rpx, 20px);
letter-spacing: 1rpx;
}
.profile-hero__edit {
position: absolute;
top: 337rpx;
right: 61rpx;
width: 154rpx;
min-height: 52rpx;
margin: 0;
padding: 0;
border: 0;
border-radius: 0;
background: transparent url("/static/assets/modules/profile/transparent/edit-button.png") center / 100% 100% no-repeat;
box-shadow: none;
color: #fff6e5;
font-size: clamp(14px, 22rpx, 17px);
font-weight: 700;
letter-spacing: 1rpx;
}
.profile-hero__edit::after {
border: 0;
}
.profile-metadata {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
z-index: 1;
margin: -36rpx 34rpx 0;
padding: 10rpx 12rpx 12rpx;
border: 1rpx solid rgba(117, 83, 52, 0.09);
border-radius: 18rpx;
background: rgba(239, 234, 224, 0.92);
}
.profile-metadata__item {
display: flex;
min-width: 0;
align-items: flex-start;
gap: 12rpx;
padding: 14rpx 12rpx;
border-right: 0;
}
.profile-metadata__item:first-child {
border-right: 1rpx solid rgba(117, 83, 52, 0.16);
}
.profile-metadata__item--email {
grid-column: 1 / -1;
border-top: 1rpx solid rgba(117, 83, 52, 0.16);
}
.profile-metadata__icon {
width: 56rpx;
height: 56rpx;
flex: 0 0 auto;
filter: sepia(0.7) saturate(0.8) hue-rotate(330deg) brightness(0.82);
}
.profile-metadata__item view {
min-width: 0;
}
.profile-metadata__item text {
display: block;
}
.profile-metadata__item text:first-child {
color: #857263;
font-size: clamp(13px, 20rpx, 16px);
}
.profile-metadata__item text:last-child {
margin-top: 4rpx;
color: #382c25;
font-size: clamp(15px, 24rpx, 18px);
line-height: 1.25;
overflow-wrap: anywhere;
}
.profile-metadata__item--email text:last-child {
font-size: clamp(13px, 20rpx, 16px);
line-height: 1.35;
}
.profile-preference {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
margin: 18rpx 34rpx 0;
padding: 22rpx 24rpx;
border: 1rpx solid rgba(117, 83, 52, 0.12);
border-radius: 18rpx;
background: rgba(239, 234, 224, 0.82);
}
.profile-preference > view {
min-width: 0;
flex: 1;
}
.profile-preference text {
display: block;
}
.profile-preference text:first-child {
color: #382c25;
font-size: clamp(15px, 26rpx, 19px);
font-weight: 700;
}
.profile-preference text:last-child {
margin-top: 6rpx;
color: #857263;
font-size: clamp(12px, 20rpx, 15px);
line-height: 1.4;
}
.profile-preference switch {
flex: 0 0 auto;
transform: scale(0.88);
transform-origin: right center;
}
.profile-services {
margin: 49rpx 58rpx 0 48rpx;
}
.profile-service-group + .profile-service-group {
margin-top: 51rpx;
}
.profile-section-title {
display: flex;
align-items: center;
color: #382c25;
font-family: STSong, SimSun, serif;
font-size: clamp(19px, 34rpx, 24px);
font-weight: 700;
letter-spacing: 2rpx;
}
.profile-section-title__mark {
width: 6rpx;
height: 24rpx;
border-radius: 6rpx;
background: #8d2722;
pointer-events: none;
flex: 0 0 auto;
margin-right: 24rpx;
}
.profile-service-list {
margin-top: 2rpx;
}
.profile-service-row {
display: flex;
min-height: 96rpx;
align-items: center;
justify-content: space-between;
gap: 24rpx;
border-bottom: 1rpx solid rgba(112, 85, 61, 0.2);
color: #45362d;
}
.profile-service-row__leading {
display: flex;
min-width: 0;
align-items: center;
gap: 30rpx;
padding-left: 7rpx;
}
.profile-service-row__icon {
width: 44rpx;
height: 44rpx;
flex: 0 0 auto;
transform: translate(7rpx, 0) scale(1.8);
}
.profile-service-row__icon--security {
transform: translate(9rpx, -9rpx) scale(1.8);
}
.profile-service-row__icon--notifications {
transform: translate(10rpx, -10rpx) scale(1.8);
}
.profile-service-row__icon--vip {
transform: translate(-5rpx, 5rpx) scale(1.8);
}
.profile-service-row__icon--help {
transform: translate(-3rpx, 7rpx) scale(1.8);
}
.profile-service-row__icon--feedback {
transform: translate(4rpx, 8rpx) scale(1.8);
}
.profile-service-row__icon--promotion {
transform: translate(11rpx, 7rpx) scale(1.8);
}
.profile-service-row__icon--settings {
transform: translate(9rpx, 7rpx) scale(1.8);
}
.profile-service-row text {
font-size: clamp(17px, 32rpx, 22px);
line-height: 1.3;
}
.profile-service-row__chevron {
width: 24rpx;
height: 24rpx;
flex: 0 0 auto;
opacity: 0.86;
}
.profile-service-row:active {
opacity: 0.68;
}
</style>