完成70%

This commit is contained in:
2026-07-24 07:56:22 +08:00
parent bb6431b319
commit c7f278fe79
92 changed files with 4741 additions and 14440 deletions
+4 -422
View File
@@ -1,429 +1,11 @@
<!-- 页面编号M-01用途个人中心首页提醒与服务导航 -->
<!-- 页面编号M-01用途个人中心资料 DTO 未声明时不展示 mock 用户 -->
<template>
<view
class="profile-page"
:class="{
'profile-state--ready': profileState === 'ready',
'profile-state--error': profileState === 'error',
}"
>
<ModulePageBackground module="profile" />
<view class="profile-page__header">
<PageHeader
root
title="我的"
:action="profileState === 'ready' ? '资料' : ''"
@action="toProfile"
/>
</view>
<view class="profile-content">
<template v-if="profileState === 'ready'">
<view class="profile-hero">
<image
class="profile-hero__hall"
src="/static/assets/foundation/transparent/root-header-hall.png"
mode="aspectFit"
/>
<image
class="profile-hero__cloud"
src="/static/assets/foundation/transparent/auth-title-cloud.png"
mode="aspectFit"
/>
<view class="profile-hero__content">
<view class="profile-hero__label"
><text></text><text></text><text></text><text></text></view
>
<view class="profile-identity">
<image
class="profile-identity__seal"
src="/static/assets/foundation/transparent/brand-seal.png"
mode="aspectFit"
/>
<view class="profile-identity__copy">
<text class="profile-identity__name">{{ currentUser.name }}</text>
<text class="profile-identity__role">{{ currentUser.role }}</text>
<text class="profile-identity__phone">{{ currentUser.phone }}</text>
</view>
</view>
</view>
</view>
<view
class="profile-scroll-notice"
role="button"
aria-label="查看待处理消息"
@click="toNotifications"
>
<view class="profile-scroll-notice__copy">
<text>待你处理</text>
<text>{{ unreadCount }} 条家谱提醒与审核通知</text>
</view>
</view>
<view class="profile-services">
<view class="profile-section-heading">
<image
src="/static/assets/foundation/transparent/auth-divider-knot.png"
mode="aspectFit"
/>
<text>服务与设置</text>
<image
src="/static/assets/foundation/transparent/auth-divider-knot.png"
mode="aspectFit"
/>
</view>
<view class="profile-menu-list">
<view
v-for="item in menuItems"
:key="item.label"
class="profile-menu"
role="button"
:aria-label="`查看${item.label}`"
@click="openItem(item)"
>
<image
class="profile-menu__icon"
:src="item.icon"
mode="aspectFit"
/>
<view class="profile-menu__copy">
<text class="profile-menu__label">{{ item.label }}</text>
<text class="profile-menu__note">{{ item.note }}</text>
</view>
<image
class="profile-menu__chevron"
src="/static/assets/foundation/transparent/chevron-right.png"
mode="aspectFit"
/>
</view>
</view>
</view>
</template>
<view v-else class="profile-error">
<view class="profile-error__copy">
<text class="profile-error__title">个人资料暂不可用</text>
<text class="profile-error__description"
>请稍后重新进入账号和家谱资料不会受到影响</text
>
</view>
<AppButton
block
type="secondary"
label="重新查看"
@click="restoreProfile"
/>
</view>
</view>
<AppTabbar active="profile" />
</view>
<view class="profile-page"><ModulePageBackground module="profile" /><view class="page-header"><PageHeader root title="我的" /></view><view class="page-content"><view class="state-card"><text>个人资料待后端字段合同</text><text>当前资料读取接口只返回通用对象未声明昵称角色手机号或脱敏规则页面已停止展示 mock 用户与 fixture 未读数</text><view class="profile-menu"><AppButton block label="编辑资料" @click="open('M02')" /><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></view></view><AppTabbar active="profile" /></view>
</template>
<script setup>
import { computed, ref } from "vue";
import { onLoad } 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 {
currentUser,
listNotificationFixtures,
} from "@/data/mock.js";
import { openPage } from "@/utils/navigation.js";
const profileState = ref("ready");
const unreadCount = computed(
() => listNotificationFixtures().filter((notice) => notice.unread).length,
);
const menuItems = [
{
label: "账号与安全",
note: "密码与手机号",
icon: "/static/assets/modules/auth/transparent/a01-icon-lock-v1.png",
routeKey: "M03",
},
{
label: "帮助与反馈",
note: "使用说明与问题反馈",
icon: "/static/assets/foundation/transparent/notice.png",
routeKey: "M06",
},
{
label: "关于家谱",
note: "协议、隐私与版本",
icon: "/static/assets/foundation/transparent/brand-seal.png",
routeKey: "M10",
},
{
label: "邀请家人",
note: "邀请规则与接入状态",
icon: "/static/assets/foundation/transparent/brand-seal.png",
routeKey: "M08",
},
{
label: "服务与订单",
note: "权益说明与订单记录",
icon: "/static/assets/foundation/transparent/notice.png",
routeKey: "M09",
},
];
onLoad((query) => {
profileState.value = query.state === "error" ? "error" : "ready";
});
const restoreProfile = () => {
profileState.value = "ready";
};
const toProfile = () => openPage("M02", {}, "M01");
const toNotifications = () => openPage("N01", {}, "M01");
const openItem = (item) =>
item.routeKey ? openPage(item.routeKey, {}, "M01") : Promise.resolve(false);
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 { openPage } from "@/utils/navigation.js"; const open=(route)=>openPage(route,{},"M01");
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.profile-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.profile-page__header,
.profile-content {
z-index: 1;
}
.profile-content {
flex: 1;
padding: 26rpx 30rpx 190rpx;
}
.profile-hero {
@include adaptive.adaptive-profile-summary;
display: grid;
width: 100%;
min-height: 252rpx;
}
.profile-hero__content {
z-index: 1;
display: grid;
grid-area: 1 / 1;
grid-template-columns: 42rpx minmax(0, 1fr);
align-items: center;
gap: 30rpx;
padding: 34rpx 52rpx 32rpx 40rpx;
}
.profile-hero__hall {
align-self: end;
justify-self: end;
grid-area: 1 / 1;
width: 330rpx;
height: 122rpx;
margin-right: -18rpx;
margin-bottom: -30rpx;
opacity: 0.12;
pointer-events: none;
}
.profile-hero__cloud {
align-self: start;
justify-self: end;
grid-area: 1 / 1;
width: 96rpx;
height: 52rpx;
margin-top: 22rpx;
margin-right: 30rpx;
opacity: 0.32;
pointer-events: none;
}
.profile-hero__label {
z-index: 2;
display: flex;
width: 42rpx;
min-height: 152rpx;
box-sizing: border-box;
flex-direction: column;
align-items: center;
justify-content: center;
border: 2rpx solid #c9964c;
border-radius: 4rpx;
background: linear-gradient(180deg, #9f241f, #bd3427);
box-shadow: inset 0 0 0 3rpx rgba(255, 222, 147, 0.24);
color: #ffe6a9;
font-family: STKaiti, KaiTi, serif;
font-size: 21rpx;
line-height: 1.12;
}
.profile-identity {
z-index: 1;
display: flex;
min-height: 252rpx;
box-sizing: border-box;
align-items: center;
gap: 24rpx;
}
.profile-identity__seal {
width: 92rpx;
height: 106rpx;
flex: 0 0 auto;
}
.profile-identity__name,
.profile-identity__role,
.profile-identity__phone {
display: block;
}
.profile-identity__name {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 38rpx;
font-weight: 700;
letter-spacing: 3rpx;
}
.profile-identity__role {
margin-top: 10rpx;
color: #6f5942;
font-size: 23rpx;
}
.profile-identity__phone {
margin-top: 7rpx;
color: $ink-muted;
font-size: 21rpx;
letter-spacing: 2rpx;
}
.profile-scroll-notice {
@include adaptive.adaptive-scroll-button(primary);
width: 88%;
min-height: 112rpx;
margin: 22rpx auto 0;
}
.profile-scroll-notice__copy {
display: flex;
min-height: 112rpx;
box-sizing: border-box;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 13rpx 70rpx;
color: #fff4d3;
text-align: center;
}
.profile-scroll-notice__copy text:first-child {
font-family: STKaiti, KaiTi, serif;
font-size: 26rpx;
font-weight: 700;
letter-spacing: 3rpx;
}
.profile-scroll-notice__copy text:last-child {
margin-top: 4rpx;
color: #ffe4a8;
font-size: 18rpx;
}
.profile-services {
margin-top: 26rpx;
padding: 0 12rpx;
}
.profile-section-heading {
display: flex;
align-items: center;
justify-content: center;
gap: 20rpx;
padding-bottom: 12rpx;
}
.profile-section-heading image {
width: 88rpx;
height: 28rpx;
opacity: 0.78;
}
.profile-section-heading image:last-child {
transform: scaleX(-1);
}
.profile-section-heading text {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 31rpx;
font-weight: 700;
letter-spacing: 4rpx;
}
.profile-menu-list {
border-top: 1px solid rgba(181, 137, 63, 0.55);
}
.profile-menu {
display: flex;
min-height: 116rpx;
align-items: center;
padding: 0 8rpx;
border-bottom: 1px solid rgba(181, 137, 63, 0.4);
}
.profile-menu__icon {
width: 52rpx;
height: 52rpx;
margin-right: 22rpx;
flex: 0 0 auto;
}
.profile-menu__copy {
min-width: 0;
flex: 1;
}
.profile-menu__label,
.profile-menu__note {
display: block;
}
.profile-menu__label {
color: $ink;
font-size: 25rpx;
font-weight: 700;
}
.profile-menu__note {
margin-top: 5rpx;
color: $ink-muted;
font-size: 21rpx;
}
.profile-menu__chevron {
width: 30rpx;
height: 30rpx;
margin-left: 18rpx;
opacity: 0.75;
}
.profile-error {
margin-top: 38rpx;
background: url("/static/assets/modules/profile/transparent/m01-profile-summary-card.png") top center / 100% 220rpx no-repeat;
text-align: center;
}
.profile-error__copy {
display: flex;
min-height: 118px;
box-sizing: border-box;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 54rpx;
}
.profile-error__title,
.profile-error__description {
display: block;
}
.profile-error__title {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 31rpx;
font-weight: 700;
}
.profile-error__description {
margin-top: 13rpx;
color: $ink-muted;
font-size: 23rpx;
line-height: 1.5;
}
.profile-error .app-button {
margin: 20rpx auto 0;
}
@media (min-width: 400px) {
.profile-content {
padding-right: 38rpx;
padding-left: 38rpx;
}
}
/* M01 medium-ornate profile folio styles remain page-scoped and extensible. */
.profile-page{display:flex;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{box-sizing:border-box;min-height:340rpx;padding:52rpx 40rpx 50rpx;text-align:center;@include adaptive.adaptive-family-content}.state-card text{display:block}.state-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:35rpx;font-weight:700}.state-card text:nth-child(2){margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.profile-menu{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14rpx;margin-top:28rpx}.profile-menu .app-button{margin:0}
</style>
+4 -117
View File
@@ -1,124 +1,11 @@
<!-- 页面编号M-02用途编辑个人资料 -->
<!-- 页面编号M-02用途编辑资料读取 DTO 与现有字段不一致时保持关闭 -->
<template>
<view class="profile-edit-page" :class="{ 'profile-state--ready': profileState === 'ready', 'profile-state--saving': profileState === 'saving' }">
<ModulePageBackground module="profile" />
<view class="page-layer"><PageHeader title="个人资料" custom-back @back="requestBack" /></view>
<view class="page-content page-layer">
<view class="profile-avatar-card">
<image class="avatar-seal" src="/static/assets/foundation/transparent/brand-seal.png" mode="aspectFit" />
<view class="avatar-copy"><text>{{ profileForm.nickName || "未填写昵称" }}</text><text>头像将在上传接口批次接入</text></view>
<view class="text-action" role="button" aria-label="选择头像" @click="chooseAvatar">选择头像</view>
</view>
<view class="form-panel">
<view class="form-row"><text>昵称</text><input v-model.trim="profileForm.nickName" maxlength="30" aria-label="昵称" placeholder="请输入昵称" @input="errors.nickName = ''" /></view>
<text v-if="errors.nickName" class="field-error">{{ errors.nickName }}</text>
<view class="form-row"><text>真实姓名</text><input v-model.trim="profileForm.realName" maxlength="30" aria-label="真实姓名" placeholder="请输入真实姓名" /></view>
<view class="form-row"><text>邮箱</text><input v-model.trim="profileForm.email" maxlength="100" aria-label="邮箱" placeholder="选填,用于接收通知" @input="errors.email = ''" /></view>
<text v-if="errors.email" class="field-error">{{ errors.email }}</text>
</view>
<AppButton block :disabled="profileState === 'saving'" :label="profileState === 'saving' ? '正在校验' : '生成本地校验预览'" @click="saveProfile" />
</view>
<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>
<view class="profile-edit-page"><ModulePageBackground module="profile" /><view class="page-header"><PageHeader title="编辑资料" custom-back @back="backToProfile" /></view><view class="page-content"><view class="state-card"><text>个人资料编辑待字段合同</text><text>读取接口没有资料 DTO更新接口允许昵称头像性别生日地区和地址但当前页面原有真实姓名邮箱等字段不属于该合同页面不再用 mock 预填或提交未知字段</text><AppButton block label="返回个人中心" @click="backToProfile" /></view></view></view>
</template>
<script setup>
import { computed, reactive, ref } from "vue";
import { onBackPress, 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 { currentUser } from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
handleBackPress,
runBackGuard,
} from "@/utils/navigation.js";
const profileForm = reactive({ nickName: currentUser.name, realName: currentUser.name, email: "" });
const errors = reactive({ nickName: "", email: "" });
const profileState = ref("ready");
const toastVisible = ref(false);
const toastMessage = ref("");
const discardVisible = ref(false);
let timer = null;
const formSnapshot = computed(() => JSON.stringify(profileForm));
const baseline = ref(formSnapshot.value);
const isDirty = computed(() => formSnapshot.value !== baseline.value);
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const showToast = (message) => { toastMessage.value = message; toastVisible.value = true; clearTimeout(timer); timer = setTimeout(() => (toastVisible.value = false), 1800); };
const chooseAvatar = () => showToast("头像选择将在相册权限接入后开放");
const validateProfile = () => {
errors.nickName = profileForm.nickName ? "" : "请填写昵称";
errors.email = !profileForm.email || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(profileForm.email)
? ""
: "请输入正确的邮箱地址";
return !errors.nickName && !errors.email;
};
const saveProfile = () => {
if (!validateProfile() || profileState.value === "saving") return;
profileState.value = "saving";
clearTimeout(timer);
timer = setTimeout(() => {
profileState.value = "ready";
showToast("本地校验通过,尚未提交服务器");
}, 500);
};
const requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: profileState.value === "saving",
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
clearTimeout(timer);
discardConfirmation.dispose();
});
import AppButton from "@/components/AppButton.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue"; import PageHeader from "@/components/PageHeader.vue"; import { returnTo } from "@/utils/navigation.js"; const backToProfile=()=>returnTo("M01");
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.profile-edit-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.page-layer { z-index: 1; }
.page-content { flex: 1; padding: 26rpx 30rpx 70rpx; }
.profile-avatar-card, .form-panel { @include adaptive.adaptive-profile-field; }
.profile-avatar-card { display: grid; grid-template-columns: 92rpx minmax(0,1fr) auto; align-items: center; gap: 20rpx; min-height: 150rpx; padding: 24rpx 34rpx; box-sizing: border-box; }
.avatar-seal { width: 82rpx; height: auto; max-height: 92rpx; aspect-ratio: 82 / 92; }
.avatar-copy { min-width: 0; }
.avatar-copy text { display: block; overflow-wrap: anywhere; }
.avatar-copy text:first-child { color: $ink; font-size: 29rpx; font-weight: 700; }
.avatar-copy text:last-child { margin-top: 6rpx; color: $ink-muted; font-size: 20rpx; line-height: 1.45; }
.text-action { min-height: 72rpx; display: flex; align-items: center; color: $brand-red; font-size: 22rpx; }
.form-panel { margin-top: 22rpx; padding: 20rpx 34rpx 30rpx; box-sizing: border-box; }
.form-row { display: grid; grid-template-columns: 150rpx minmax(0,1fr); min-height: 92rpx; align-items: center; border-bottom: 1px solid rgba(181,137,63,.42); gap: 18rpx; }
.form-row > text { color: $ink; font-size: 24rpx; font-weight: 700; }
.form-row input { width: auto; min-width: 0; min-height: 70rpx; color: $ink; font-size: 24rpx; text-align: right; }
.field-error { display: block; padding-top: 7rpx; color: #b42318; font-size: 21rpx; text-align: right; }
.page-content > .app-button { margin-top: 26rpx; }
@media(max-width:340px){.page-content{padding-right:22rpx;padding-left:22rpx}.profile-avatar-card{grid-template-columns:72rpx minmax(0,1fr);padding-right:26rpx;padding-left:26rpx}.text-action{grid-column:2}.avatar-seal{width:68rpx;max-height:78rpx}.form-row{grid-template-columns:126rpx minmax(0,1fr)}}
.profile-edit-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.state-card{box-sizing:border-box;min-height:340rpx;padding:78rpx 52rpx 50rpx;text-align:center;@include adaptive.adaptive-family-content}.state-card text{display:block}.state-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:35rpx;font-weight:700}.state-card text:nth-child(2){margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx}
</style>
+4 -43
View File
@@ -1,50 +1,11 @@
<!-- 页面编号M-03用途账号安全总览与安全功能入口 -->
<!-- 页面编号M-03用途账号与安全入口安全概览 DTO 未声明 -->
<template>
<view class="security-page device-state--limited">
<ModulePageBackground module="profile" />
<view class="page-layer"><PageHeader title="账号与安全" /></view>
<view class="page-content page-layer">
<view class="security-summary">
<text>账号安全操作</text>
<text>修改密码和换绑手机号目前只提供本地校验预览不会变更服务器账号信息</text>
</view>
<view class="security-list">
<view v-for="item in securityItems" :key="item.key" class="security-row" role="button" :aria-label="item.label" @click="openSecurityItem(item)">
<image :src="item.icon" mode="aspectFit" />
<view><text>{{ item.label }}</text><text>{{ item.note }}</text></view>
<image class="chevron" src="/static/assets/foundation/transparent/chevron-right.png" mode="aspectFit" />
</view>
</view>
<AppButton block type="secondary" label="查看接入状态" @click="checkSecurity" />
</view>
<AppToast :visible="toastVisible" :message="toastMessage" />
</view>
<view class="security-page"><ModulePageBackground module="profile" /><view class="page-header"><PageHeader title="账号与安全" custom-back @back="backToProfile" /></view><view class="page-content"><view class="state-card"><text>安全资料待后端字段合同</text><text>没有独立安全概览设备或登录记录读取 owner页面不再显示 mock 账号摘要可进入已独立核对的修改密码页面换绑手机号仍需人工 TAC/短信窗口</text><AppButton block label="修改密码" @click="openPassword" /><AppButton type="secondary" block label="换绑手机号" @click="openPhone" /></view></view></view>
</template>
<script setup>
import { onUnmounted, ref } from "vue";
import AppButton from "@/components/AppButton.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { currentUser } from "@/data/mock.js";
import { openPage } from "@/utils/navigation.js";
const securityItems = [
{ key: "password", label: "登录密码", note: "建议定期更新密码", icon: "/static/assets/modules/auth/transparent/a01-icon-lock-v1.png", routeKey: "M04" },
{ key: "phone", label: "绑定手机号", note: currentUser.phone, icon: "/static/assets/modules/auth/transparent/a01-icon-phone-v1.png", routeKey: "M05" },
];
const toastVisible = ref(false);
const toastMessage = ref("");
let timer = null;
const showToast = (message) => { toastMessage.value = message; toastVisible.value = true; clearTimeout(timer); timer = setTimeout(() => (toastVisible.value = false), 1800); };
const openSecurityItem = (item) => openPage(item.routeKey, {}, "M03");
const checkSecurity = () => showToast("账号安全接口将在后续独立批次接入");
onUnmounted(() => clearTimeout(timer));
import AppButton from "@/components/AppButton.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue"; import PageHeader from "@/components/PageHeader.vue"; import { openPage,returnTo } from "@/utils/navigation.js"; const backToProfile=()=>returnTo("M01");const openPassword=()=>openPage("M04",{},"M03");const openPhone=()=>openPage("M05",{},"M03");
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.security-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-layer{z-index:1}.page-content{flex:1;padding:28rpx 30rpx 72rpx}.security-summary{@include adaptive.adaptive-profile-summary;min-height:190rpx;padding:42rpx 48rpx;text-align:center}.security-summary text{display:block;overflow-wrap:anywhere}.security-summary text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:34rpx;font-weight:700}.security-summary text:last-child{margin-top:12rpx;color:$ink-muted;font-size:23rpx;line-height:1.55}.security-list{margin-top:22rpx}.security-row{@include adaptive.adaptive-profile-field;display:grid;grid-template-columns:56rpx minmax(0,1fr) 34rpx;min-height:110rpx;align-items:center;gap:18rpx;padding:16rpx 26rpx}.security-row+ .security-row{margin-top:12rpx}.security-row>image{width:50rpx;height:50rpx}.security-row .chevron{width:30rpx;height:30rpx}.security-row view{min-width:0}.security-row text{display:block;overflow-wrap:anywhere}.security-row text:first-child{color:$ink;font-size:25rpx;font-weight:700}.security-row text:last-child{margin-top:6rpx;color:$ink-muted;font-size:21rpx}.page-content>.app-button{margin-top:28rpx}@media(max-width:340px){.page-content{padding-right:22rpx;padding-left:22rpx}}
.security-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.state-card{box-sizing:border-box;min-height:340rpx;padding:78rpx 52rpx 50rpx;text-align:center;@include adaptive.adaptive-family-content}.state-card text{display:block}.state-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:35rpx;font-weight:700}.state-card text:nth-child(2){margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:20rpx}
</style>
+26 -6
View File
@@ -15,7 +15,7 @@
<text v-if="errors[field.key]" class="field-error">{{ errors[field.key] }}</text>
</view>
</view>
<AppButton block :disabled="passwordState === 'saving'" :label="passwordState === 'saving' ? '正在校验' : '校验新密码(不提交)'" @click="savePassword" />
<AppButton block :disabled="passwordState === 'saving'" :label="passwordState === 'saving' ? '正在提交' : '确认修改密码'" @click="savePassword" />
</view>
<AppToast :visible="toastVisible" :message="toastMessage" />
<AppDialog
@@ -41,7 +41,9 @@ 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 { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { calcMD5 } from "@/utils/md5.js";
import { handleBackPress, runBackGuard } from "@/utils/navigation.js";
import {
PASSWORD_POLICY_MESSAGE,
@@ -59,6 +61,8 @@ const passwordFields = [
{ key: "confirm", label: "确认新密码", placeholder: "请再次输入新密码" },
];
const toastVisible = ref(false); const toastMessage = ref(""); let timer = null;
const passwordRequestController = createRequestController();
let pageActive = true;
const formSnapshot = computed(() => JSON.stringify(passwordForm));
const baseline = ref(formSnapshot.value);
const isDirty = computed(() => formSnapshot.value !== baseline.value);
@@ -79,13 +83,27 @@ const validateForm = () => {
errors.confirm = !passwordForm.confirm ? "请再次输入新密码" : passwordForm.confirm !== passwordForm.next ? "两次输入的新密码不一致" : "";
return !Object.values(errors).some(Boolean);
};
const savePassword = () => {
const savePassword = async () => {
if (!validateForm() || passwordState.value === "saving") return;
passwordState.value = "saving";
timer = setTimeout(() => {
passwordState.value = "ready";
showToast("本地校验通过,尚未提交服务器");
}, 500);
try {
await appApi.changePassword({
oldPasswordHash: calcMD5(passwordForm.current),
newPasswordHash: calcMD5(passwordForm.next),
}, { requestController: passwordRequestController });
if (!pageActive) return;
passwordForm.current = "";
passwordForm.next = "";
passwordForm.confirm = "";
baseline.value = formSnapshot.value;
showToast("密码修改成功");
} catch (error) {
if (pageActive && !isRequestCancelled(error)) {
showToast(error?.message || "密码修改失败,请稍后重试");
}
} finally {
if (pageActive) passwordState.value = "ready";
}
};
const requestBack = () =>
runBackGuard({
@@ -98,6 +116,8 @@ const requestBack = () =>
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
pageActive = false;
passwordRequestController.abort();
clearTimeout(timer);
discardConfirmation.dispose();
});
+4 -91
View File
@@ -1,98 +1,11 @@
<!-- 页面编号M-05用途验证并更换绑手机号 -->
<!-- 页面编号M-05用途换绑手机号缺专用发码/资料 DTO 且需人工 TAC/短信时保持关闭 -->
<template>
<view class="phone-page" :class="{ 'phone-state--ready': phoneState === 'ready', 'phone-state--saving': phoneState === 'saving' }">
<ModulePageBackground module="profile" />
<view class="page-layer"><PageHeader title="修改手机号" custom-back @back="requestBack" /></view>
<view class="page-content page-layer">
<view class="current-phone"><text>当前绑定手机号</text><text>{{ phoneForm.currentPhone }}</text><text>更换后新手机号将用于登录与安全验证</text></view>
<view class="form-panel">
<view class="form-row"><text>新手机号</text><input v-model.trim="phoneForm.newPhone" type="number" maxlength="11" aria-label="新手机号" placeholder="请输入新手机号" @input="errors.newPhone = ''" /></view>
<text v-if="errors.newPhone" class="field-error">{{ errors.newPhone }}</text>
<view class="form-row code-row"><text>验证码</text><input v-model.trim="phoneForm.code" type="number" maxlength="4" aria-label="短信验证码" placeholder="4 位验证码" @input="errors.code = ''" /><view class="code-action" role="button" aria-label="检查验证码发送条件" @click="sendCode">发送条件</view></view>
<text v-if="errors.code" class="field-error">{{ errors.code }}</text>
</view>
<AppButton block :disabled="phoneState === 'saving'" :label="phoneState === 'saving' ? '正在校验' : '校验换绑信息(不提交)'" @click="savePhone" />
</view>
<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>
<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>换绑接口要求新手机号四位短信码和 `clientId`但没有可安全消费的当前手机号 DTO也没有已核实的专用受保护发码链页面不显示 mock 手机号不发送验证码不在无人值守时换绑</text><AppButton block label="返回账号与安全" @click="backToSecurity" /></view></view></view>
</template>
<script setup>
import { computed, reactive, ref } from "vue";
import { onBackPress, 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 { currentUser } from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { handleBackPress, runBackGuard } from "@/utils/navigation.js";
const phoneForm = reactive({ currentPhone: currentUser.phone, newPhone: "", code: "" });
const errors = reactive({ newPhone: "", code: "" });
const phoneState = ref("ready");
const discardVisible = ref(false);
const toastVisible = ref(false); const toastMessage = ref("");
let stateTimer = null; let toastTimer = null;
const formSnapshot = computed(() => JSON.stringify({ newPhone: phoneForm.newPhone, code: phoneForm.code }));
const baseline = ref(formSnapshot.value);
const isDirty = computed(() => formSnapshot.value !== baseline.value);
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const showToast = (message) => { toastMessage.value = message; toastVisible.value = true; clearTimeout(toastTimer); toastTimer = setTimeout(() => (toastVisible.value = false), 1800); };
const sendCode = () => {
if (!/^1\d{10}$/.test(phoneForm.newPhone)) { errors.newPhone = "请输入正确的新手机号"; return; }
errors.newPhone = "";
showToast("需先完成滑动行为验证;当前未发送验证码");
};
const validatePhone = () => {
errors.newPhone = /^1\d{10}$/.test(phoneForm.newPhone) ? "" : "请输入正确的新手机号";
errors.code = /^\d{4}$/.test(phoneForm.code) ? "" : "请输入 4 位验证码";
return !errors.newPhone && !errors.code;
};
const savePhone = () => {
if (!validatePhone() || phoneState.value === "saving") return;
phoneState.value = "saving";
stateTimer = setTimeout(() => {
phoneState.value = "ready";
showToast("本地校验通过,尚未提交服务器");
}, 500);
};
const requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: phoneState.value === "saving",
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
clearTimeout(stateTimer);
clearTimeout(toastTimer);
discardConfirmation.dispose();
});
import AppButton from "@/components/AppButton.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue"; import PageHeader from "@/components/PageHeader.vue"; import { returnTo } from "@/utils/navigation.js"; const backToSecurity=()=>returnTo("M03");
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.phone-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-layer{z-index:1}.page-content{flex:1;padding:28rpx 30rpx 72rpx}.current-phone,.form-panel{@include adaptive.adaptive-profile-content}.current-phone{min-height:210rpx;padding:40rpx 48rpx;text-align:center}.current-phone text{display:block}.current-phone text:first-child{color:$ink-muted;font-size:22rpx}.current-phone text:nth-child(2){margin-top:8rpx;color:$ink;font-size:38rpx;font-weight:700;letter-spacing:3rpx}.current-phone text:last-child{margin-top:12rpx;color:$ink-muted;font-size:21rpx;line-height:1.5}.form-panel{margin-top:22rpx;padding:24rpx 34rpx 32rpx}.form-row{display:grid;grid-template-columns:130rpx minmax(0,1fr);min-height:94rpx;align-items:center;gap:16rpx;border-bottom:1px solid rgba(181,137,63,.42)}.code-row{grid-template-columns:130rpx minmax(0,1fr) auto}.form-row>text{color:$ink;font-size:23rpx;font-weight:700}.form-row input{width:auto;min-width:0;min-height:70rpx;color:$ink;font-size:23rpx}.code-action{display:flex;min-width:126rpx;min-height:70rpx;align-items:center;justify-content:flex-end;color:$brand-red;font-size:21rpx}.field-error{display:block;padding-top:7rpx;color:#b42318;font-size:20rpx;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:112rpx minmax(0,1fr)}.code-row{grid-template-columns:112rpx minmax(0,1fr);}.code-action{grid-column:2;justify-content:flex-start}}
.phone-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.state-card{box-sizing:border-box;min-height:340rpx;padding:78rpx 52rpx 50rpx;text-align:center;@include adaptive.adaptive-family-content}.state-card text{display:block}.state-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:35rpx;font-weight:700}.state-card text:nth-child(2){margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx}
</style>
+2 -1
View File
@@ -4,6 +4,7 @@
<ModulePageBackground module="profile" />
<view class="page-layer"><PageHeader title="帮助中心" /></view>
<view class="page-content page-layer">
<text class="help-source-note">当前展示的是本地使用说明不代表已读取服务端帮助文章需要最新支持请提交意见反馈</text>
<view class="search-box"><input v-model.trim="keyword" aria-label="搜索帮助" placeholder="搜索问题关键词" /><text>{{ filteredQuestions.length }} </text></view>
<scroll-view scroll-x class="category-scroll" :show-scrollbar="false"><view class="category-row"><view v-for="category in helpCategories" :key="category" class="category-chip" :class="{ active: activeCategory === category }" role="button" @click="activeCategory = category">{{ category }}</view></view></scroll-view>
<view v-if="filteredQuestions.length" class="question-list">
@@ -46,5 +47,5 @@ const contactSupport = () => openPage("M07", {}, "M06");
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.help-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-layer{z-index:1}.page-content{flex:1;padding:24rpx 28rpx 72rpx}.search-box{@include adaptive.adaptive-profile-field;display:grid;grid-template-columns:minmax(0,1fr) auto;min-height:86rpx;align-items:center;gap:16rpx;padding:0 30rpx}.search-box input{width:auto;min-width:0;min-height:68rpx;color:$ink;font-size:23rpx}.search-box text{color:$ink-muted;font-size:20rpx}.category-scroll{width:100%;margin-top:18rpx}.category-row{display:flex;width:max-content;gap:12rpx;padding:2rpx}.category-chip{display:flex;min-width:100rpx;min-height:64rpx;align-items:center;justify-content:center;padding:0 22rpx;box-sizing:border-box;color:$ink-muted;font-size:22rpx}.category-chip.active{@include adaptive.adaptive-scroll-button(secondary);color:$brand-red;font-weight:700}.question-list{display:flex;flex-direction:column;gap:14rpx;margin-top:18rpx}.question-card,.empty-card{@include adaptive.adaptive-profile-content}.question-card{min-height:104rpx;padding:24rpx 34rpx}.question-heading{display:grid;grid-template-columns:minmax(0,1fr) auto;min-height:58rpx;align-items:center;gap:18rpx}.question-heading text:first-child{color:$ink;font-size:24rpx;font-weight:700;overflow-wrap:anywhere}.question-heading text:last-child{color:$brand-red;font-size:20rpx}.answer{display:block;padding:14rpx 4rpx 6rpx;border-top:1px solid rgba(181,137,63,.32);color:$ink-muted;font-size:22rpx;line-height:1.65;overflow-wrap:anywhere}.empty-card{min-height:220rpx;padding:58rpx 44rpx;text-align:center}.empty-card text{display:block}.empty-card text:first-child{color:$ink;font-size:29rpx;font-weight:700}.empty-card text:last-child{margin-top:12rpx;color:$ink-muted;font-size:22rpx;line-height:1.5}.page-content>.app-button{margin-top:28rpx}@media(max-width:340px){.page-content{padding-right:20rpx;padding-left:20rpx}}
.help-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-layer{z-index:1}.page-content{flex:1;padding:24rpx 28rpx 72rpx}.help-source-note{display:block;margin:0 4rpx 16rpx;color:$ink-muted;font-size:21rpx;line-height:1.55}.search-box{@include adaptive.adaptive-profile-field;display:grid;grid-template-columns:minmax(0,1fr) auto;min-height:86rpx;align-items:center;gap:16rpx;padding:0 30rpx}.search-box input{width:auto;min-width:0;min-height:68rpx;color:$ink;font-size:23rpx}.search-box text{color:$ink-muted;font-size:20rpx}.category-scroll{width:100%;margin-top:18rpx}.category-row{display:flex;width:max-content;gap:12rpx;padding:2rpx}.category-chip{display:flex;min-width:100rpx;min-height:64rpx;align-items:center;justify-content:center;padding:0 22rpx;box-sizing:border-box;color:$ink-muted;font-size:22rpx}.category-chip.active{@include adaptive.adaptive-scroll-button(secondary);color:$brand-red;font-weight:700}.question-list{display:flex;flex-direction:column;gap:14rpx;margin-top:18rpx}.question-card,.empty-card{@include adaptive.adaptive-profile-content}.question-card{min-height:104rpx;padding:24rpx 34rpx}.question-heading{display:grid;grid-template-columns:minmax(0,1fr) auto;min-height:58rpx;align-items:center;gap:18rpx}.question-heading text:first-child{color:$ink;font-size:24rpx;font-weight:700;overflow-wrap:anywhere}.question-heading text:last-child{color:$brand-red;font-size:20rpx}.answer{display:block;padding:14rpx 4rpx 6rpx;border-top:1px solid rgba(181,137,63,.32);color:$ink-muted;font-size:22rpx;line-height:1.65;overflow-wrap:anywhere}.empty-card{min-height:220rpx;padding:58rpx 44rpx;text-align:center}.empty-card text{display:block}.empty-card text:first-child{color:$ink;font-size:29rpx;font-weight:700}.empty-card text:last-child{margin-top:12rpx;color:$ink-muted;font-size:22rpx;line-height:1.5}.page-content>.app-button{margin-top:28rpx}@media(max-width:340px){.page-content{padding-right:20rpx;padding-left:20rpx}}
</style>
+19 -6
View File
@@ -1,6 +1,6 @@
<!-- 页面编号M-10用途协议隐私版本与退出登录 -->
<template>
<view class="about-page">
<view class="about-page" :class="{ 'about-state--logging-out': logoutSubmitting }">
<ModulePageBackground module="profile" />
<view class="page-layer"><PageHeader title="关于家谱" custom-back @back="requestBack" /></view>
<view class="page-content page-layer">
@@ -11,18 +11,19 @@
<AppButton block type="secondary" label="退出登录" @click="logoutVisible = true" />
</view>
<AppDialog :visible="agreementVisible" :close-on-mask="false" eyebrow="协议与说明" :title="activeAgreement.label" :message="activeAgreement.copy" confirm-text="关闭" @confirm="agreementVisible = false" @close="agreementVisible = false" />
<AppDialog :visible="logoutVisible" eyebrow="账号操作" title="确认退出登录?" message="退出后需要重新验证账号;本机保存的密码不会被保留。" confirm-text="确认退出" cancel-text="取消" show-cancel :close-on-mask="false" @confirm="confirmLogout" @cancel="logoutVisible = false" @close="logoutVisible = false" />
<AppDialog :visible="logoutVisible" eyebrow="账号操作" title="确认退出登录?" message="退出后需要重新验证账号;本机保存的密码不会被保留。" :confirm-text="logoutSubmitting ? '正在退出' : '确认退出'" cancel-text="取消" show-cancel :close-on-mask="false" @confirm="confirmLogout" @cancel="logoutVisible = false" @close="logoutVisible = false" />
</view>
</template>
<script setup>
import { reactive, ref } from "vue";
import { onBackPress } from "@dcloudio/uni-app";
import { onBackPress, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import manifest from "@/manifest.json";
import { appApi, createRequestController } from "@/utils/api.js";
import { goRoot, handleBackPress, runBackGuard } from "@/utils/navigation.js";
import { session } from "@/utils/session.js";
@@ -35,10 +36,21 @@ const agreementItems = [
const activeAgreement = reactive({ label: "", copy: "" });
const agreementVisible = ref(false);
const logoutVisible = ref(false);
const logoutSubmitting = ref(false);
const logoutRequestController = createRequestController();
const openAgreement = (item) => { activeAgreement.label = item.label; activeAgreement.copy = item.copy; agreementVisible.value = true; };
const confirmLogout = () => {
session.clear();
logoutVisible.value = false;
const confirmLogout = async () => {
if (logoutSubmitting.value) return false;
logoutSubmitting.value = true;
try {
await appApi.logout({ requestController: logoutRequestController });
} catch {
// 退出请求的结果未知时仍必须撤销本机会话,不能保留旧授权或自动重试。
} finally {
session.clear();
logoutVisible.value = false;
logoutSubmitting.value = false;
}
return goRoot("A01");
};
const closeActiveDialog = () => {
@@ -51,6 +63,7 @@ const requestBack = () =>
"close-transient": closeActiveDialog,
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => logoutRequestController.abort());
</script>
<style scoped lang="scss">