修改未完成

This commit is contained in:
rain
2026-07-29 18:15:59 +08:00
parent 6fbcf21024
commit 8c2355a52c
59 changed files with 7995 additions and 1118 deletions
+503 -151
View File
@@ -1,70 +1,152 @@
<!-- 页面编号M-01用途个人中心显示已核实的当前用户资料 -->
<!-- 页面编号M-01用途个人中心显示当前用户资料与服务入口 -->
<template>
<view class="profile-page">
<ModulePageBackground module="profile" />
<view class="page-header"><PageHeader root title="我的" /></view>
<view class="page-content">
<view v-if="loading" class="state-card"
><text>正在读取个人资料</text></view
>
<view v-else-if="loadError" class="state-card">
<text>个人资料暂时无法读取</text>
<text>{{ loadError }}</text>
<AppButton block label="重新读取" @click="loadProfile" />
</view>
<template v-else>
<view class="profile-summary">
<view class="profile-summary__avatar"
><image
src="/static/assets/foundation/transparent/auth-login-outline.png"
mode="aspectFit"
/></view>
<view class="profile-summary__identity">
<text>{{ profile.nickName || "未设置昵称" }}</text>
<text>{{ profile.realName || "未设置真实姓名" }}</text>
<text>{{ profile.phone || "未提供手机号" }}</text>
</view>
<AppButton
class="profile-summary__edit"
label="编辑资料"
@click="openEdit"
/>
</view>
<view class="profile-fields">
<view class="profile-field"
><text>性别</text><text>{{ sexText }}</text></view
>
<view class="profile-field"
><text>生日</text><text>{{ birthdayText }}</text></view
>
<view class="profile-field"
><text>邮箱</text><text>{{ profile.email || "未设置" }}</text></view
>
</view>
<view class="profile-menu">
<AppButton block label="账号与安全" @click="open('M03')" />
<AppButton block label="消息中心" @click="open('N01')" />
<AppButton block label="帮助中心" @click="open('M06')" />
<AppButton block label="意见反馈" @click="open('M07')" />
<AppButton block label="应用推广" @click="open('M08')" />
<AppButton block label="VIP 服务" @click="open('M09')" />
<AppButton block label="关于与设置" @click="open('M10')" />
</view>
</template>
<view
class="profile-page"
:class="{
'profile-state--ready': !loading && !loadError,
'profile-state--error': Boolean(loadError),
}"
>
<view v-if="compactHeader" class="profile-compact-header" aria-hidden="true">
<text>我的</text>
</view>
<view v-if="loading" class="profile-state-card">
<text>正在读取个人资料</text>
</view>
<view v-else-if="loadError" class="profile-state-card">
<text>个人资料暂时无法读取</text>
<text>{{ loadError }}</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/m01-profile-archive-hero-v2.png"
mode="scaleToFill"
aria-hidden="true"
/>
<view class="profile-hero__content">
<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/m01-icon-settings.png"
mode="aspectFit"
aria-hidden="true"
/></view>
</view>
<view class="profile-hero__identity">
<image
class="profile-hero__emblem"
src="/static/assets/modules/profile/transparent/m01-profile-tree-medallion.png"
mode="aspectFit"
aria-hidden="true"
/>
<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/m01-icon-person.png"
mode="aspectFit"
aria-hidden="true"
/>
<view><text>性别</text><text>{{ sexText }}</text></view>
</view>
<view class="profile-metadata__item">
<image
class="profile-metadata__icon"
src="/static/assets/modules/profile/transparent/m01-icon-calendar.png"
mode="aspectFit"
aria-hidden="true"
/>
<view><text>生日</text><text>{{ birthdayText }}</text></view>
</view>
<view class="profile-metadata__item profile-metadata__item--email">
<image
class="profile-metadata__icon"
src="/static/assets/modules/profile/transparent/m01-icon-envelope.png"
mode="aspectFit"
aria-hidden="true"
/>
<view><text>邮箱</text><text>{{ profile.email || "未设置" }}</text></view>
</view>
</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.routeKey.toLowerCase()}`,
]"
: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>
<AppTabbar active="profile" />
</view>
</template>
<script setup>
import { computed, reactive, ref } from "vue";
import { onShow, onUnload } from "@dcloudio/uni-app";
import { onPageScroll, onShow, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppTabbar from "@/components/AppTabbar.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
appApi,
createRequestController,
@@ -83,11 +165,59 @@ const profile = reactive({
});
const loading = ref(false);
const loadError = ref("");
const compactHeader = ref(false);
const requestController = createRequestController();
let pageActive = true;
const sexText = computed(() => (profile.sex ? "待确认" : "未设置"));
const serviceGroups = [
{
title: "账户与服务",
items: [
{
routeKey: "M03",
label: "账号与安全",
icon: "/static/assets/modules/profile/transparent/m01-icon-security.png",
},
{
routeKey: "N01",
label: "消息中心",
icon: "/static/assets/modules/profile/transparent/m01-icon-message.png",
},
{
routeKey: "M09",
label: "VIP 服务",
icon: "/static/assets/modules/profile/transparent/m01-icon-vip.png",
},
],
},
{
title: "支持与其他",
items: [
{
routeKey: "M06",
label: "帮助中心",
icon: "/static/assets/modules/profile/transparent/m01-icon-help.png",
},
{
routeKey: "M07",
label: "意见反馈",
icon: "/static/assets/modules/profile/transparent/m01-icon-feedback.png",
},
{
routeKey: "M08",
label: "应用推广",
icon: "/static/assets/modules/profile/transparent/m01-icon-promotion.png",
},
{
routeKey: "M10",
label: "关于与设置",
icon: "/static/assets/modules/profile/transparent/m01-icon-settings.png",
},
],
},
];
const sexText = computed(() => (profile.sex ? "待确认" : "未设置"));
const birthdayText = computed(() =>
/^\d{4}-\d{2}-\d{2}/.test(profile.birthday)
? profile.birthday.slice(0, 10)
@@ -111,143 +241,365 @@ const loadProfile = async () => {
}
};
const open = (route) => openPage(route, {}, "M01");
const openEdit = () => open("M02");
const openService = (item) => openPage(item.routeKey, {}, "M01");
const openEdit = () => openPage("M02", {}, "M01");
const openSettings = () => openPage("M10", {}, "M01");
onShow(loadProfile);
onPageScroll(({ scrollTop = 0 }) => {
compactHeader.value = scrollTop > 44;
});
onUnload(() => {
pageActive = false;
compactHeader.value = false;
requestController.abort();
});
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.profile-page {
display: flex;
width: 100%;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.page-header,
.page-content {
z-index: 1;
}
.page-content {
flex: 1;
padding: 18rpx 24rpx 190rpx;
}
.state-card,
.profile-summary,
.profile-fields {
@include adaptive.adaptive-family-content;
}
.state-card {
padding-bottom: 166rpx;
box-sizing: border-box;
min-height: 340rpx;
padding: 78rpx 52rpx 50rpx;
overflow-x: hidden;
background: #f3eee5 url("/static/assets/modules/profile/opaque/m01-profile-paper-mountains-v1.png") center 360rpx / 100% auto no-repeat;
}
.profile-compact-header {
position: fixed;
top: 0;
right: 0;
left: 0;
z-index: 30;
height: 88rpx;
box-sizing: border-box;
padding: 38rpx 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;
font-size: 28rpx;
font-weight: 700;
letter-spacing: 3rpx;
line-height: 1;
pointer-events: none;
}
.profile-state-card {
@include adaptive.adaptive-profile-summary;
margin: 48rpx 30rpx;
padding: 64rpx 44rpx;
color: #382c25;
text-align: center;
}
.state-card text {
.profile-state-card text {
display: block;
font-size: 26rpx;
line-height: 1.6;
}
.state-card text:first-child {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 35rpx;
font-weight: 700;
.profile-state-card text + text {
margin-top: 12rpx;
color: #806e5d;
font-size: 22rpx;
}
.state-card text:nth-child(2) {
margin-top: 18rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.65;
}
.state-card .app-button {
.profile-state-card .app-button {
@include adaptive.adaptive-scroll-button(primary);
margin-top: 28rpx;
}
.profile-summary {
.profile-hero {
display: grid;
grid-template-columns: 96rpx minmax(0, 1fr);
align-items: center;
gap: 18rpx;
padding: 30rpx;
width: 100%;
height: 503rpx;
}
.profile-summary__avatar {
.profile-hero__art,
.profile-hero__content {
grid-area: 1 / 1;
}
.profile-hero__art {
width: 100%;
height: 480rpx;
pointer-events: none;
}
.profile-hero__content {
display: flex;
width: 96rpx;
height: 96rpx;
flex-direction: column;
z-index: 1;
padding: 100rpx 56rpx 50rpx 54rpx;
box-sizing: border-box;
}
.profile-hero__label {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
color: #f0d6a4;
font-family: STKaiti, KaiTi, serif;
font-size: 54rpx;
font-weight: 700;
letter-spacing: 5rpx;
line-height: 1;
}
.profile-hero__setting {
display: flex;
width: 48rpx;
height: 48rpx;
align-items: center;
justify-content: center;
border: 1rpx solid rgba(159, 23, 15, 0.32);
border-radius: 50%;
background: rgba(159, 23, 15, 0.06);
}
.profile-summary__avatar image {
width: 64rpx;
height: 64rpx;
opacity: 0.8;
.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-summary__identity {
.profile-hero__identity {
display: flex;
min-width: 0;
align-items: flex-start;
position: relative;
margin-top: 43rpx;
margin-left: -30rpx;
}
.profile-summary__identity text {
.profile-hero__emblem {
width: 220rpx;
height: 220rpx;
flex: 0 0 auto;
}
.profile-hero__copy {
min-width: 0;
flex: 1;
margin: 46rpx 0 0 -5rpx;
}
.profile-hero__copy text {
display: block;
overflow-wrap: anywhere;
}
.profile-summary__identity text:first-child {
color: $ink;
font-family: STKaiti, KaiTi, serif;
.profile-hero__copy text:first-child {
color: #fff2da;
font-family: STSong, SimSun, serif;
font-size: 32rpx;
font-weight: 700;
letter-spacing: 1rpx;
line-height: 1.25;
white-space: nowrap;
}
.profile-summary__identity text:nth-child(2),
.profile-summary__identity text:last-child {
margin-top: 5rpx;
color: $ink-muted;
font-size: 21rpx;
.profile-hero__copy text:nth-child(2) {
margin-top: 14rpx;
color: #ecd2a4;
font-size: 27rpx;
line-height: 1.35;
}
.profile-summary__edit {
grid-column: 1 / -1;
margin-top: 6rpx;
}
.profile-fields {
display: flex;
flex-direction: column;
.profile-hero__copy text:last-child {
margin-top: 18rpx;
padding: 8rpx 30rpx;
color: #e8bd78;
font-size: 28rpx;
letter-spacing: 1rpx;
}
.profile-field {
.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/m01-profile-edit-button-v2.png") center / 100% 100% no-repeat;
box-shadow: none;
color: #fff6e5;
font-size: 22rpx;
font-weight: 700;
letter-spacing: 1rpx;
}
.profile-hero__edit::after {
border: 0;
}
.profile-metadata {
display: flex;
min-height: 82rpx;
min-height: 146rpx;
z-index: 1;
margin: -36rpx 34rpx 0;
padding: 5rpx 0;
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: center;
flex: 1;
gap: 10rpx;
padding: 0 10rpx;
border-right: 1rpx solid rgba(117, 83, 52, 0.16);
}
.profile-metadata__item:last-child {
border-right: 0;
}
.profile-metadata__item--email {
flex: 2.3;
}
.profile-metadata__icon {
width: 42rpx;
height: 42rpx;
filter: sepia(0.7) saturate(0.8) hue-rotate(330deg) brightness(0.82);
transform: scale(2.1);
}
.profile-metadata__item view {
min-width: 0;
transform: translateY(14rpx);
}
.profile-metadata__item text {
display: block;
}
.profile-metadata__item text:first-child {
color: #857263;
font-size: 20rpx;
}
.profile-metadata__item text:last-child {
margin-top: 0;
color: #382c25;
font-size: 24rpx;
line-height: 1.25;
overflow-wrap: anywhere;
}
.profile-metadata__item--email text:last-child {
font-size: 13rpx;
white-space: nowrap;
}
.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: 34rpx;
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: 20rpx;
border-bottom: 1rpx solid rgba(181, 137, 63, 0.3);
gap: 24rpx;
border-bottom: 1rpx solid rgba(112, 85, 61, 0.2);
color: #45362d;
}
.profile-field:last-child {
border-bottom: 0;
}
.profile-field text:first-child {
flex: 0 0 auto;
color: $ink;
font-size: 24rpx;
font-weight: 700;
}
.profile-field text:last-child {
.profile-service-row__leading {
display: flex;
min-width: 0;
color: $ink-muted;
font-size: 22rpx;
overflow-wrap: anywhere;
text-align: right;
align-items: center;
gap: 30rpx;
padding-left: 7rpx;
}
.profile-menu {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14rpx;
margin-top: 24rpx;
.profile-service-row__icon {
width: 44rpx;
height: 44rpx;
flex: 0 0 auto;
transform: translate(7rpx, 0) scale(1.8);
}
.profile-menu .app-button {
margin: 0;
.profile-service-row__icon--m03 {
transform: translate(9rpx, -9rpx) scale(1.8);
}
.profile-service-row__icon--n01 {
transform: translate(10rpx, -10rpx) scale(1.8);
}
.profile-service-row__icon--m09 {
transform: translate(-5rpx, 5rpx) scale(1.8);
}
.profile-service-row__icon--m06 {
transform: translate(-3rpx, 7rpx) scale(1.8);
}
.profile-service-row__icon--m07 {
transform: translate(4rpx, 8rpx) scale(1.8);
}
.profile-service-row__icon--m08 {
transform: translate(11rpx, 7rpx) scale(1.8);
}
.profile-service-row__icon--m10 {
transform: translate(9rpx, 7rpx) scale(1.8);
}
.profile-service-row text {
font-size: 32rpx;
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>
+395 -35
View File
@@ -1,28 +1,324 @@
<!-- 页面编号M-05用途换绑手机号缺专用发码/资料 DTO 且需人工 TAC/短信时保持关闭 -->
<!-- 页面编号M-05用途换绑已登录账号的手机号 -->
<template>
<view class="phone-page"
><ModulePageBackground module="profile" /><view class="page-header"
><PageHeader
title="换绑手机号"
custom-back
@back="backToSecurity" /></view
><view class="page-content"
><view class="state-card"
><text>暂不能在线换绑手机号</text
><text>为保护账户安全请联系管理员协助办理手机号换绑</text
><AppButton
block
label="返回账号与安全"
@click="backToSecurity" /></view></view
></view>
<view
class="phone-page"
:class="{
'phone-state--sending': phoneState === 'sending',
'phone-state--submitting': phoneState === 'submitting',
}"
>
<ModulePageBackground module="profile" />
<view class="page-layer"
><PageHeader title="换绑手机号" custom-back @back="requestBack"
/></view>
<view class="page-content page-layer">
<view class="security-tip"
><text>验证新手机号</text
><text>完成行为验证和短信校验后立即更新当前账号的登录手机号</text></view
>
<view class="form-panel">
<view class="field-block">
<view class="form-row">
<text>新手机号</text>
<input
v-model.trim="phone"
type="number"
maxlength="11"
aria-label="新手机号"
placeholder="请输入新手机号"
@input="handlePhoneInput"
/>
</view>
<text v-if="errors.phone" class="field-error">{{ errors.phone }}</text>
</view>
<view class="field-block">
<view class="form-row form-row--code">
<text>短信验证码</text>
<input
v-model.trim="smsCode"
type="number"
maxlength="4"
aria-label="短信验证码"
placeholder="4 位验证码"
@input="errors.smsCode = ''"
/>
<AppButton
compact
type="secondary"
:disabled="phoneState !== 'ready' || cooldownSeconds > 0"
:label="cooldownSeconds > 0 ? `${cooldownSeconds}s 后重试` : '获取验证码'"
@click="prepareGetCode"
/>
</view>
<text v-if="errors.smsCode" class="field-error">{{ errors.smsCode }}</text>
</view>
</view>
<AppButton
block
:disabled="phoneState !== 'ready'"
:label="phoneState === 'submitting' ? '正在换绑' : '确认换绑手机号'"
@click="submitPhoneChange"
/>
</view>
<TacVerification
:visible="tacVisible"
:context="tacContext"
@success="completeTac"
@failure="handleTacFailure"
@error="handleTacError"
@cancel="closeTac"
/>
<AppToast :visible="toastVisible" :message="toastMessage" />
<AppDialog
:visible="discardVisible"
:close-on-mask="false"
eyebrow="放弃确认"
title="放弃手机号换绑?"
message="新手机号和验证码尚未提交服务器。"
confirm-text="确认放弃"
cancel-text="继续填写"
show-cancel
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
</view>
</template>
<script setup>
import { computed, reactive, ref } from "vue";
import { onBackPress, onShow, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { returnTo } from "@/utils/navigation.js";
const backToSecurity = () => returnTo("M03");
import TacVerification from "@/components/TacVerification.vue";
import {
appApi,
createRequestController,
isRequestCancelled,
} from "@/utils/api.js";
import {
AUTH_VERIFICATION_OPERATION,
createTacRenderContext,
isAuthPhone,
isSmsDeliveryOutcomeUnknown,
normalizeCaptchaRequirement,
normalizeTacSuccess,
} from "@/utils/auth-verification.js";
import { createAuthSmsCooldown } from "@/utils/auth-sms-cooldown.js";
import { runtimeConfig } from "@/utils/config.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { handleBackPress, runBackGuard } from "@/utils/navigation.js";
const phone = ref("");
const smsCode = ref("");
const sentPhone = ref("");
const errors = reactive({ phone: "", smsCode: "" });
const phoneState = ref("ready");
const cooldownSeconds = ref(0);
const tacVisible = ref(false);
const tacContext = ref(null);
const discardVisible = ref(false);
const toastVisible = ref(false);
const toastMessage = ref("");
const formSnapshot = computed(() => JSON.stringify({ phone: phone.value, smsCode: smsCode.value }));
const baseline = ref(formSnapshot.value);
const isDirty = computed(() => formSnapshot.value !== baseline.value);
const requestController = createRequestController();
const smsCooldown = createAuthSmsCooldown({
operationCode: AUTH_VERIFICATION_OPERATION.PHONE_CHANGE,
onChange: (seconds) => {
cooldownSeconds.value = seconds;
},
});
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
let toastTimer = null;
let tacSequence = 0;
let pageActive = true;
const showToast = (message) => {
toastMessage.value = message;
toastVisible.value = true;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
toastVisible.value = false;
}, 2200);
};
const closeTac = () => {
tacVisible.value = false;
tacContext.value = null;
};
const handlePhoneInput = () => {
errors.phone = "";
if (phone.value === sentPhone.value) return;
smsCode.value = "";
sentPhone.value = "";
errors.smsCode = "";
};
const sendSmsCode = async (requestedPhone, validToken) => {
await appApi.sendSmsCode(
{
operationCode: AUTH_VERIFICATION_OPERATION.PHONE_CHANGE,
phone: requestedPhone,
...(validToken ? { validToken } : {}),
},
{ requestController },
);
if (!pageActive) return;
sentPhone.value = requestedPhone;
smsCooldown.start();
showToast("验证码已发送");
};
const prepareGetCode = async () => {
if (phoneState.value !== "ready" || cooldownSeconds.value > 0) return;
if (!isAuthPhone(phone.value)) {
errors.phone = "请输入正确手机号";
return;
}
errors.phone = "";
phoneState.value = "sending";
try {
const requestedPhone = phone.value;
const response = await appApi.getCaptchaRequirement(
{
operationCode: AUTH_VERIFICATION_OPERATION.PHONE_CHANGE,
subject: requestedPhone,
},
{ requestController },
);
if (!pageActive) return;
if (phone.value !== requestedPhone) {
throw new Error("手机号已变化,请重新获取验证码");
}
const requirement = normalizeCaptchaRequirement(response);
if (!requirement.required) {
await sendSmsCode(requestedPhone);
return;
}
tacSequence += 1;
tacContext.value = createTacRenderContext({
requestId: `m05-phone-change-${tacSequence}`,
baseUrl: runtimeConfig.baseUrl,
clientId: runtimeConfig.clientId,
tenantId: runtimeConfig.tenantId,
operationCode: AUTH_VERIFICATION_OPERATION.PHONE_CHANGE,
subject: requestedPhone,
requirement,
});
tacVisible.value = true;
} catch (error) {
if (pageActive && !isRequestCancelled(error)) {
showToast(error?.message || "安全验证暂不可用");
}
} finally {
if (pageActive) phoneState.value = "ready";
}
};
const completeTac = async (result) => {
const context = tacContext.value;
if (!context || phoneState.value !== "ready") return;
phoneState.value = "sending";
try {
const ticket = normalizeTacSuccess(result, context.requestId);
if (phone.value !== context.subject) {
throw new Error("手机号已变化,请重新验证");
}
closeTac();
await sendSmsCode(context.subject, ticket.validToken);
} catch (error) {
closeTac();
if (pageActive) {
if (isSmsDeliveryOutcomeUnknown(error)) {
sentPhone.value = context.subject;
smsCooldown.start();
showToast("发送结果未知,如收到短信可直接填写;60 秒后可重试");
} else if (!isRequestCancelled(error)) {
showToast(error?.message || "验证码发送失败");
}
}
} finally {
if (pageActive) phoneState.value = "ready";
}
};
const handleTacFailure = ({ message } = {}) => {
showToast(message || "行为验证未通过,请重试");
};
const handleTacError = ({ message } = {}) => {
closeTac();
showToast(message || "安全验证暂不可用");
};
const validateForm = () => {
errors.phone = isAuthPhone(phone.value) ? "" : "请输入正确手机号";
errors.smsCode =
sentPhone.value !== phone.value
? "请先获取当前手机号的验证码"
: /^\d{4}$/.test(smsCode.value)
? ""
: "请输入 4 位验证码";
return !errors.phone && !errors.smsCode;
};
const submitPhoneChange = async () => {
if (phoneState.value !== "ready" || !validateForm()) return;
phoneState.value = "submitting";
try {
await appApi.changePhone(
{ phone: phone.value, smsCode: smsCode.value },
{ requestController },
);
if (!pageActive) return;
phone.value = "";
smsCode.value = "";
sentPhone.value = "";
baseline.value = formSnapshot.value;
showToast("手机号换绑成功");
} catch (error) {
if (pageActive && !isRequestCancelled(error)) {
showToast(error?.message || "换绑结果未知,请重新登录确认手机号");
}
} finally {
if (pageActive) phoneState.value = "ready";
}
};
const requestBack = () =>
runBackGuard({
transientOpen: tacVisible.value || discardVisible.value,
dirty: isDirty.value,
submitting: phoneState.value !== "ready",
"close-transient": tacVisible.value ? closeTac : cancelDiscard,
"block-submitting": () => {
requestController.abort();
return true;
},
"confirm-discard": requestDiscardConfirmation,
});
onBackPress((event) => handleBackPress(event, requestBack));
onShow(() => smsCooldown.sync());
onUnload(() => {
pageActive = false;
requestController.abort();
smsCooldown.dispose();
discardConfirmation.dispose();
clearTimeout(toastTimer);
});
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.phone-page {
@@ -31,36 +327,100 @@ const backToSecurity = () => returnTo("M03");
flex-direction: column;
background: $paper;
}
.page-header,
.page-content {
.page-layer {
z-index: 1;
}
.page-content {
padding: 18rpx 24rpx 72rpx;
flex: 1;
padding: 28rpx 30rpx 72rpx;
}
.state-card {
box-sizing: border-box;
min-height: 340rpx;
padding: 78rpx 52rpx 50rpx;
.security-tip,
.form-panel {
@include adaptive.adaptive-profile-content;
}
.security-tip {
min-height: 170rpx;
padding: 38rpx 44rpx;
text-align: center;
@include adaptive.adaptive-family-content;
}
.state-card text {
.security-tip text {
display: block;
}
.state-card text:first-child {
.security-tip text:first-child {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 35rpx;
font-size: 32rpx;
font-weight: 700;
}
.state-card text:nth-child(2) {
margin-top: 18rpx;
.security-tip text:last-child {
margin-top: 10rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.65;
font-size: 22rpx;
line-height: 1.55;
}
.state-card .app-button {
.form-panel {
margin-top: 20rpx;
padding: 22rpx 34rpx 30rpx;
}
.field-block + .field-block {
margin-top: 6rpx;
}
.form-row {
display: grid;
grid-template-columns: 142rpx minmax(0, 1fr);
min-height: 92rpx;
align-items: center;
gap: 12rpx;
border-bottom: 1px solid rgba(181, 137, 63, 0.42);
}
.form-row--code {
grid-template-columns: 142rpx minmax(0, 1fr) 170rpx;
}
.form-row > text {
color: $ink;
font-size: 23rpx;
font-weight: 700;
}
.form-row input {
width: auto;
min-width: 0;
min-height: 68rpx;
color: $ink;
font-size: 23rpx;
}
.form-row--code .app-button {
justify-self: end;
width: 170rpx;
min-height: 68rpx;
}
.field-error {
display: block;
padding-top: 7rpx;
color: #b42318;
font-size: 20rpx;
line-height: 1.4;
text-align: right;
}
.page-content > .app-button {
margin-top: 28rpx;
}
@media (max-width: 340px) {
.page-content {
padding-right: 22rpx;
padding-left: 22rpx;
}
.form-panel {
padding-right: 26rpx;
padding-left: 26rpx;
}
.form-row {
grid-template-columns: 120rpx minmax(0, 1fr);
gap: 8rpx;
}
.form-row--code {
grid-template-columns: 120rpx minmax(0, 1fr) 150rpx;
}
.form-row--code .app-button {
width: 150rpx;
}
}
</style>
+422 -54
View File
@@ -2,13 +2,26 @@
<view class="orders-page">
<ModulePageBackground module="profile" />
<view class="page-layer"
><PageHeader title="VIP 与订单" custom-back @back="requestBack"
><PageHeader
class="orders-header"
title="VIP 与订单"
custom-back
@back="requestBack"
/></view>
<view class="page-content page-layer">
<view class="service-card"
><text>服务套餐</text
><text>套餐和订单均从服务端读取本页不发起支付或创建订单</text></view
>
<view class="vip-intro">
<image
class="vip-intro__background"
src="/static/assets/modules/profile/opaque/m09-vip-hero-landscape.png"
mode="aspectFill"
aria-hidden="true"
/>
<view class="vip-intro__content">
<text class="vip-intro__eyebrow">家谱传承服务</text>
<text class="vip-intro__title">开通 VIP守护家谱长久传承</text>
<text class="vip-intro__copy">选择适合家谱规模的服务套餐</text>
</view>
</view>
<view v-if="state === 'loading'" class="state-card"
><AppLoading text="正在读取服务套餐"
/></view>
@@ -18,12 +31,33 @@
/></view>
<template v-else>
<view v-if="packages.length" class="package-list"
><view v-for="item in packages" :key="item.id" class="package-card"
><view class="package-heading"
><text>{{ item.name }}</text
><text v-if="item.price">¥{{ item.price }}</text></view
><text v-if="item.description">{{ item.description }}</text
><text v-if="item.duration">{{ item.duration }}</text></view
><view
v-for="item in packages"
:key="item.id"
class="package-card"
><image
class="package-courtyard"
src="/static/assets/modules/profile/opaque/m09-featured-courtyard.png"
mode="aspectFill"
aria-hidden="true"
/>
<view class="package-heading">
<view>
<text>{{ item.name }}</text>
<text v-if="item.description">{{ item.description }}</text>
</view>
</view>
<view class="package-meta"
><text v-if="item.price" class="package-price">¥{{ item.price }}</text
><text class="package-duration">{{ item.duration || "服务期限以订单为准" }}</text
><button
class="purchase-button"
:disabled="creating"
@click="openPurchase(item)"
>立即开通</button
></view
>
</view
></view
>
<view v-else class="state-card"
@@ -32,22 +66,46 @@
<view class="section-heading"
><text>订单记录</text><text>{{ orders.length }} </text></view
>
<text v-if="orderNotice" class="order-notice">{{ orderNotice }}</text>
<view v-if="orders.length" class="order-list"
><view v-for="item in orders" :key="item.id" class="order-card"
><text>{{ item.title }}</text
><text>{{ item.status }}</text></view
><view><text>{{ item.title }}</text><text>订单编号已由服务端生成</text></view
><text>{{ item.status || "状态待服务端确认" }}</text></view
></view
>
<view v-else class="order-empty"><text>暂无订单记录</text></view>
</template>
</view>
<AppDialog
class="payment-sheet"
:visible="Boolean(selectedPackage)"
title="确认下单"
:confirm-text="creating ? '正在创建订单' : '创建订单'"
cancel-text="暂不购买"
show-cancel
:close-on-mask="!creating"
@confirm="createOrder"
@cancel="closePurchase"
>
<view class="sheet-order-summary"
><view
><text>套餐</text><text>{{ selectedPackage?.name }}</text></view
><view v-if="selectedPackage?.duration"
><text>有效期</text><text>{{ selectedPackage.duration }}</text></view
><view><text>应付金额</text><text>¥{{ selectedPackage?.price }}</text></view></view
>
<view class="payment-method"><text>支付方式</text><text>微信支付</text></view>
<text class="payment-copy">将创建微信支付订单支付完成后请以订单状态为准</text>
<text v-if="purchaseError" class="purchase-error">{{ purchaseError }}</text>
</AppDialog>
</view>
</template>
<script setup>
import { ref } from "vue";
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import { onBackPress, onLoad, onReady, onShow, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
@@ -61,8 +119,13 @@ import { handleBackPress, runBackGuard } from "@/utils/navigation.js";
const packages = ref([]);
const orders = ref([]);
const state = ref("loading");
const selectedPackage = ref(null);
const creating = ref(false);
const purchaseError = ref("");
const orderNotice = ref("");
const controller = createRequestController();
let active = true;
let statusBarTimer = null;
const formatPrice = (value) =>
Number.isFinite(Number(value)) ? Number(value).toFixed(2) : "";
const durationUnits = Object.freeze({ year: "年", month: "个月", day: "天" });
@@ -107,14 +170,67 @@ const loadVip = async () => {
state.value = "error";
}
};
const openPurchase = (item) => {
if (creating.value || !item?.id) return;
purchaseError.value = "";
selectedPackage.value = item;
};
const closePurchase = () => {
if (creating.value) return;
selectedPackage.value = null;
purchaseError.value = "";
};
const createOrder = async () => {
const item = selectedPackage.value;
if (!item?.id || creating.value) return;
creating.value = true;
purchaseError.value = "";
try {
await appApi.createVipOrder(
{ packageId: item.id, payType: "wechat" },
{ requestController: controller },
);
if (!active) return;
selectedPackage.value = null;
orderNotice.value = `${item.name}”订单已创建,并已从服务端重新读取订单状态。`;
await loadVip();
} catch (error) {
if (!active || isRequestCancelled(error)) return;
purchaseError.value = error?.message || "订单创建失败,请稍后重试。";
} finally {
creating.value = false;
}
};
const requestBack = () => runBackGuard({});
onLoad(loadVip);
const setVipStatusBar = (style, color) => {
const navigator = globalThis.plus?.navigator;
if (!navigator) return;
const apply = () => {
navigator.setStatusBarStyle(style);
navigator.setStatusBarBackground(color);
};
apply();
if (statusBarTimer) clearTimeout(statusBarTimer);
statusBarTimer = setTimeout(apply, 80);
};
onLoad(() => {
setVipStatusBar("dark", "#f4ebde");
loadVip();
});
onReady(() => setVipStatusBar("dark", "#f4ebde"));
onShow(() => {
setVipStatusBar("dark", "#f4ebde");
if (state.value !== "loading") loadVip();
});
onUnload(() => {
active = false;
controller.abort();
if (statusBarTimer) clearTimeout(statusBarTimer);
const navigator = globalThis.plus?.navigator;
if (navigator) {
navigator.setStatusBarStyle("light");
navigator.setStatusBarBackground("#b52e22");
}
});
onBackPress((event) => handleBackPress(event, requestBack));
</script>
@@ -125,40 +241,61 @@ onBackPress((event) => handleBackPress(event, requestBack));
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
background: #f7f2e9;
}
.page-layer {
z-index: 1;
}
.page-content {
flex: 1;
padding: 28rpx 30rpx 72rpx;
padding: 20rpx 30rpx 72rpx;
}
.service-card,
.state-card,
.package-card,
.order-card,
.order-empty {
@include adaptive.adaptive-profile-content;
}
.service-card {
min-height: 170rpx;
padding: 38rpx 44rpx;
.vip-intro {
position: relative;
min-height: 284rpx;
overflow: hidden;
margin: -20rpx -30rpx 0;
padding: 36rpx 48rpx 30rpx;
text-align: center;
}
.service-card text {
.vip-intro__background {
position: absolute;
z-index: 0;
inset: 0;
width: 100%;
height: 100%;
opacity: 0.78;
pointer-events: none;
}
.vip-intro__content {
position: relative;
z-index: 1;
}
.vip-intro text {
display: block;
}
.service-card text:first-child {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 34rpx;
.vip-intro__eyebrow {
color: $brand-red;
font-size: 20rpx;
font-weight: 700;
letter-spacing: 5rpx;
}
.service-card text:last-child {
margin-top: 12rpx;
.vip-intro__title {
margin-top: 16rpx;
color: #33261d;
font-family: STKaiti, KaiTi, serif;
font-size: 38rpx;
font-weight: 700;
letter-spacing: 1rpx;
}
.vip-intro__copy {
margin-top: 14rpx;
color: $ink-muted;
font-size: 21rpx;
font-size: 23rpx;
line-height: 1.55;
}
.state-card {
@@ -181,55 +318,138 @@ onBackPress((event) => handleBackPress(event, requestBack));
.order-list {
display: flex;
flex-direction: column;
gap: 14rpx;
margin-top: 18rpx;
gap: 18rpx;
}
.package-card {
padding: 28rpx 32rpx;
position: relative;
overflow: hidden;
padding: 38rpx 36rpx 32rpx;
border: 1rpx solid rgba(159, 23, 15, 0.52);
border-radius: 26rpx;
background: rgba(255, 253, 248, 0.95);
box-shadow: 0 16rpx 32rpx rgba(102, 48, 33, 0.12);
}
.package-heading {
.package-courtyard {
position: absolute;
right: 0;
bottom: 0;
z-index: 0;
width: 100%;
height: 100%;
opacity: 0.38;
pointer-events: none;
}
.package-card > :not(.package-courtyard) {
position: relative;
z-index: 1;
}
.package-heading > view {
min-width: 0;
}
.package-heading > view text {
display: block;
}
.package-heading > view text:first-child {
color: #302319;
font-family: STKaiti, KaiTi, serif;
font-size: 36rpx;
font-weight: 700;
}
.package-heading > view text:last-child {
margin-top: 10rpx;
color: #77675a;
font-size: 23rpx;
line-height: 1.55;
}
.package-price {
color: $brand-red;
font-family: Georgia, STKaiti, serif;
font-size: 38rpx;
font-weight: 700;
line-height: 1.1;
}
.package-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
color: $ink;
font-size: 27rpx;
gap: 20rpx;
margin-top: 30rpx;
padding-top: 22rpx;
border-top: 1rpx solid rgba(117, 84, 52, 0.14);
}
.package-duration {
color: #837164;
font-size: 23rpx;
}
.purchase-button {
display: inline-flex;
min-width: 184rpx;
min-height: 70rpx;
align-items: center;
justify-content: center;
box-sizing: border-box;
margin: 0;
padding: 0 28rpx;
border: 0;
border-radius: 999rpx;
background: $brand-red;
color: #fffaf1;
font-size: 24rpx;
font-weight: 700;
letter-spacing: 1rpx;
line-height: normal;
}
.package-heading text:last-child {
flex: 0 0 auto;
color: $brand-red;
.purchase-button::after {
border: 0;
}
.package-card > text {
display: block;
margin-top: 10rpx;
color: $ink-muted;
font-size: 21rpx;
line-height: 1.5;
.purchase-button[disabled] {
opacity: 0.5;
}
.section-heading {
display: flex;
justify-content: space-between;
margin: 28rpx 6rpx 12rpx;
color: $ink;
font-size: 27rpx;
margin: 44rpx 6rpx 16rpx;
color: #38281d;
font-family: STKaiti, KaiTi, serif;
font-size: 32rpx;
font-weight: 700;
}
.section-heading text:last-child {
color: $ink-muted;
color: #8a796b;
font-size: 21rpx;
font-weight: 400;
}
.order-notice {
display: block;
margin: 0 6rpx 14rpx;
color: #786556;
font-size: 21rpx;
line-height: 1.5;
}
.order-card {
display: flex;
justify-content: space-between;
gap: 18rpx;
padding: 26rpx 32rpx;
color: $ink;
padding: 26rpx 4rpx;
border-bottom: 1rpx solid rgba(117, 84, 52, 0.14);
color: #48372a;
font-size: 24rpx;
}
.order-card > view text {
display: block;
}
.order-card > view text:first-child {
font-weight: 700;
}
.order-card > view text:last-child {
margin-top: 8rpx;
color: #8a796b;
font-size: 20rpx;
}
.order-card text:last-child {
color: $ink-muted;
flex: 0 0 auto;
color: #7f6959;
font-size: 21rpx;
}
.order-empty {
padding: 38rpx;
@@ -237,10 +457,158 @@ onBackPress((event) => handleBackPress(event, requestBack));
color: $ink-muted;
font-size: 23rpx;
}
:deep(.orders-header .page-header) {
height: calc(104rpx + var(--status-bar-height, 0px));
border-bottom: 1rpx solid rgba(117, 84, 52, 0.08);
background: #f4ebde;
}
:deep(.orders-header.page-header-slot) {
height: calc(104rpx + var(--status-bar-height, 0px));
}
:deep(.orders-header .header-title) {
color: #302319;
font-family: STKaiti, KaiTi, serif;
font-size: 37rpx;
}
:deep(.orders-header .header-back__icon) {
filter: none;
opacity: 0.78;
}
:deep(.payment-sheet.app-dialog-layer) {
align-items: flex-end;
padding: 0;
background: rgba(37, 26, 18, 0.38);
}
:deep(.payment-sheet .app-dialog) {
width: 100%;
max-width: none;
max-height: 76vh;
border: 0;
border-radius: 38rpx 38rpx 0 0;
background: #fffdf9;
box-shadow: 0 -12rpx 38rpx rgba(50, 33, 20, 0.16);
}
:deep(.payment-sheet .app-dialog__content) {
align-items: stretch;
padding: 34rpx 42rpx calc(30rpx + env(safe-area-inset-bottom));
text-align: left;
}
:deep(.payment-sheet .app-dialog__content::before) {
display: block;
width: 64rpx;
height: 8rpx;
align-self: center;
margin-bottom: 24rpx;
border-radius: 999rpx;
background: rgba(76, 58, 45, 0.22);
content: "";
}
:deep(.payment-sheet .app-dialog__copy) {
align-items: flex-start;
}
:deep(.payment-sheet .app-dialog__title) {
color: #302319;
font-family: STKaiti, KaiTi, serif;
font-size: 42rpx;
letter-spacing: 1rpx;
text-align: left;
}
:deep(.payment-sheet .app-dialog__actions) {
display: flex;
flex-direction: column-reverse;
gap: 4rpx;
margin-top: 28rpx;
}
:deep(.payment-sheet .app-button) {
width: 100%;
min-height: 88rpx;
}
:deep(.payment-sheet .app-button__skin) {
display: none;
}
:deep(.payment-sheet .app-button--primary) {
border-radius: 999rpx;
background: $brand-red;
}
:deep(.payment-sheet .app-button--secondary) {
min-height: 60rpx;
background: transparent;
}
:deep(.payment-sheet .app-button--secondary .app-button__label) {
color: #8a796b;
font-size: 24rpx;
font-weight: 400;
}
.sheet-order-summary {
margin-top: 24rpx;
overflow: hidden;
border: 1rpx solid rgba(117, 84, 52, 0.12);
border-radius: 20rpx;
background: #fffaf4;
}
.sheet-order-summary > view,
.payment-method {
display: flex;
min-height: 82rpx;
align-items: center;
justify-content: space-between;
gap: 24rpx;
padding: 0 24rpx;
border-bottom: 1rpx solid rgba(117, 84, 52, 0.1);
color: #5a4432;
font-size: 24rpx;
}
.sheet-order-summary > view:last-child {
border-bottom: 0;
}
.sheet-order-summary > view text:last-child,
.payment-method text:last-child {
color: #302319;
font-weight: 700;
text-align: right;
}
.sheet-order-summary > view:last-child text:last-child {
color: $brand-red;
font-family: Georgia, STKaiti, serif;
font-size: 34rpx;
}
.payment-method {
width: 100%;
min-height: 88rpx;
box-sizing: border-box;
margin-top: 16rpx;
border: 1rpx solid rgba(117, 84, 52, 0.12);
border-radius: 20rpx;
background: #fffaf4;
}
.payment-method text:last-child {
color: $brand-red;
}
.payment-copy,
.purchase-error {
display: block;
width: 100%;
margin-top: 16rpx;
color: #8a796b;
font-size: 20rpx;
line-height: 1.55;
}
.purchase-error {
color: $brand-red;
}
@media (max-width: 340px) {
.page-content {
padding-right: 22rpx;
padding-left: 22rpx;
}
.package-card {
padding-right: 28rpx;
padding-left: 28rpx;
}
.purchase-button {
min-width: 166rpx;
padding-right: 20rpx;
padding-left: 20rpx;
}
}
</style>