完成50%

This commit is contained in:
2026-07-23 08:23:59 +08:00
parent 9b0ad62df4
commit f1edc6b533
218 changed files with 24318 additions and 5514 deletions
+25 -17
View File
@@ -10,6 +10,7 @@
<ModulePageBackground module="profile" />
<view class="profile-page__header">
<PageHeader
root
title="我的"
:action="profileState === 'ready' ? '资料' : ''"
@action="toProfile"
@@ -40,9 +41,9 @@
mode="aspectFit"
/>
<view class="profile-identity__copy">
<text class="profile-identity__name">汤文清</text>
<text class="profile-identity__role">汤氏家谱 · 家谱成员</text>
<text class="profile-identity__phone">139****6421</text>
<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>
@@ -56,7 +57,7 @@
>
<view class="profile-scroll-notice__copy">
<text>待你处理</text>
<text>2 条家谱提醒与审核通知</text>
<text>{{ unreadCount }} 条家谱提醒与审核通知</text>
</view>
</view>
@@ -121,44 +122,52 @@
</template>
<script setup>
import { ref } from "vue";
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: "密码手机与设备",
note: "密码手机",
icon: "/static/assets/modules/auth/transparent/a01-icon-lock-v1.png",
url: "/pages/profile/m03-security-settings",
routeKey: "M03",
},
{
label: "帮助与反馈",
note: "使用说明与问题反馈",
icon: "/static/assets/foundation/transparent/notice.png",
url: "/pages/profile/m06-help-center",
routeKey: "M06",
},
{
label: "关于家谱",
note: "协议、隐私与版本",
icon: "/static/assets/foundation/transparent/brand-seal.png",
url: "/pages/profile/m10-about-settings",
routeKey: "M10",
},
{
label: "邀请家人",
note: "邀请码与家谱推广",
note: "邀请规则与接入状态",
icon: "/static/assets/foundation/transparent/brand-seal.png",
url: "/pages/profile/m08-promotion",
routeKey: "M08",
},
{
label: "服务与订单",
note: "权益说明与订单记录",
icon: "/static/assets/foundation/transparent/notice.png",
url: "/pages/profile/m09-vip-orders",
routeKey: "M09",
},
];
@@ -169,11 +178,10 @@ onLoad((query) => {
const restoreProfile = () => {
profileState.value = "ready";
};
const toProfile = () =>
uni.navigateTo({ url: "/pages/profile/m02-edit-profile" });
const toNotifications = () =>
uni.navigateTo({ url: "/pages/notification/n01-message-center" });
const openItem = (item) => uni.navigateTo({ url: item.url });
const toProfile = () => openPage("M02", {}, "M01");
const toNotifications = () => openPage("N01", {}, "M01");
const openItem = (item) =>
item.routeKey ? openPage(item.routeKey, {}, "M01") : Promise.resolve(false);
</script>
<style scoped lang="scss">
+65 -22
View File
@@ -2,56 +2,102 @@
<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="个人资料" /></view>
<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="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="20" aria-label="昵称" placeholder="请输入昵称" /></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="20" aria-label="真实姓名" placeholder="请输入真实姓名" /></view>
<view class="form-row"><text>常住地区</text><input v-model.trim="profileForm.region" maxlength="40" aria-label="常住地区" placeholder="省 / 市 / 区县" /></view>
<view class="textarea-row"><text>个人简介</text><textarea v-model.trim="profileForm.bio" auto-height maxlength="300" aria-label="个人简介" placeholder="介绍你的家族身份或经历" /></view>
<text class="counter">{{ profileForm.bio.length }}/300</text>
<text v-if="errors.bio" class="field-error">{{ errors.bio }}</text>
<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" />
<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>
</template>
<script setup>
import { onUnmounted, reactive, ref } from "vue";
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: "汤文清", realName: "汤文清", region: "河南省 周口市", bio: "热心参与家谱资料整理。" });
const errors = reactive({ nickname: "", bio: "" });
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.bio = profileForm.bio.length > 300 ? "个人简介不能超过 300 字" : "";
return !errors.nickname && !errors.bio;
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);
timer = setTimeout(() => {
profileState.value = "ready";
showToast("本地校验通过,尚未提交服务器");
}, 500);
};
onUnmounted(() => clearTimeout(timer));
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();
});
</script>
<style scoped lang="scss">
@@ -70,11 +116,8 @@ onUnmounted(() => clearTimeout(timer));
.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, .textarea-row > text { color: $ink; font-size: 24rpx; font-weight: 700; }
.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; }
.textarea-row { padding-top: 24rpx; }
.textarea-row textarea { display: block; width: auto; min-width: 0; min-height: 150rpx; margin-top: 14rpx; color: $ink; font-size: 24rpx; line-height: 1.6; }
.counter { display: block; color: $ink-muted; font-size: 20rpx; 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)}}
+10 -9
View File
@@ -1,12 +1,12 @@
<!-- 页面编号M-03用途账号安全总览与安全功能入口 -->
<template>
<view class="security-page device-state--safe">
<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>
<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)">
@@ -15,7 +15,7 @@
<image class="chevron" src="/static/assets/foundation/transparent/chevron-right.png" mode="aspectFit" />
</view>
</view>
<AppButton block type="secondary" label="检查账号安全" @click="checkSecurity" />
<AppButton block type="secondary" label="查看接入状态" @click="checkSecurity" />
</view>
<AppToast :visible="toastVisible" :message="toastMessage" />
</view>
@@ -27,18 +27,19 @@ 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", url: "/pages/profile/m04-change-password" },
{ key: "phone", label: "绑定手机号", note: "139****6421", icon: "/static/assets/modules/auth/transparent/a01-icon-phone-v1.png", url: "/pages/profile/m05-change-phone" },
{ key: "device", label: "登录设备", note: "当前设备 · 今天登录", icon: "/static/assets/foundation/transparent/auth-login-outline.png" },
{ 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) => item.url ? uni.navigateTo({ url: item.url }) : showToast("当前设备登录正常,未发现异常记录");
const checkSecurity = () => showToast("安全检查完成,当前账号状态正常");
const openSecurityItem = (item) => openPage(item.routeKey, {}, "M03");
const checkSecurity = () => showToast("账号安全接口将在后续独立批次接入");
onUnmounted(() => clearTimeout(timer));
</script>
+47 -5
View File
@@ -2,7 +2,7 @@
<template>
<view class="password-page" :class="{ 'password-state--ready': passwordState === 'ready', 'password-state--saving': passwordState === 'saving' }">
<ModulePageBackground module="profile" />
<view class="page-layer"><PageHeader title="修改密码" /></view>
<view class="page-layer"><PageHeader title="修改密码" custom-back @back="requestBack" /></view>
<view class="page-content page-layer">
<view class="security-tip"><text>设置安全密码</text><text>建议使用 832 位字母与数字组合不要与其他应用共用</text></view>
<view class="form-panel">
@@ -15,18 +15,34 @@
<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
:visible="discardVisible"
:close-on-mask="false"
eyebrow="放弃确认"
title="放弃密码填写?"
message="当前密码和新密码尚未提交服务器。"
confirm-text="确认放弃"
cancel-text="继续填写"
show-cancel
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
</view>
</template>
<script setup>
import { onUnmounted, reactive, ref } from "vue";
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 { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { handleBackPress, runBackGuard } from "@/utils/navigation.js";
import {
PASSWORD_POLICY_MESSAGE,
validatePassword,
@@ -36,12 +52,22 @@ const passwordForm = reactive({ current: "", next: "", confirm: "" });
const passwordVisible = reactive({ current: false, next: false, confirm: false });
const errors = reactive({ current: "", next: "", confirm: "" });
const passwordState = ref("ready");
const discardVisible = ref(false);
const passwordFields = [
{ key: "current", label: "当前密码", placeholder: "请输入当前密码" },
{ key: "next", label: "新密码", placeholder: "832 位字母与数字" },
{ key: "confirm", label: "确认新密码", placeholder: "请再次输入新密码" },
];
const toastVisible = ref(false); const toastMessage = ref(""); let timer = null;
const formSnapshot = computed(() => JSON.stringify(passwordForm));
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 togglePassword = (key) => { passwordVisible[key] = !passwordVisible[key]; };
const validateForm = () => {
@@ -56,9 +82,25 @@ const validateForm = () => {
const savePassword = () => {
if (!validateForm() || passwordState.value === "saving") return;
passwordState.value = "saving";
timer = setTimeout(() => { passwordState.value = "ready"; passwordForm.current = ""; passwordForm.next = ""; passwordForm.confirm = ""; showToast("密码已修改,请妥善保管新密码"); }, 500);
timer = setTimeout(() => {
passwordState.value = "ready";
showToast("本地校验通过,尚未提交服务器");
}, 500);
};
onUnmounted(() => clearTimeout(timer));
const requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: passwordState.value === "saving",
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
clearTimeout(timer);
discardConfirmation.dispose();
});
</script>
<style scoped lang="scss">
+55 -13
View File
@@ -2,52 +2,94 @@
<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="修改手机号" /></view>
<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="6" aria-label="短信验证码" placeholder="6 位验证码" @input="errors.code = ''" /><view class="code-action" role="button" :aria-label="codeCountdown ? `${codeCountdown}秒后可重新发送` : '发送验证码'" @click="sendCode">{{ codeCountdown ? `${codeCountdown}s` : "发送验证码" }}</view></view>
<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" />
<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>
</template>
<script setup>
import { onUnmounted, reactive, ref } from "vue";
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: "139****6421", newPhone: "", code: "" });
const phoneForm = reactive({ currentPhone: currentUser.phone, newPhone: "", code: "" });
const errors = reactive({ newPhone: "", code: "" });
const phoneState = ref("ready");
const codeCountdown = ref(0);
const discardVisible = ref(false);
const toastVisible = ref(false); const toastMessage = ref("");
let countdownTimer = null; let stateTimer = null; let toastTimer = null;
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 (codeCountdown.value) return;
if (!/^1\d{10}$/.test(phoneForm.newPhone)) { errors.newPhone = "请输入正确的新手机号"; return; }
errors.newPhone = ""; codeCountdown.value = 60; showToast("演示验证码已发送,请输入任意 6 位数字");
countdownTimer = setInterval(() => { codeCountdown.value -= 1; if (!codeCountdown.value) { clearInterval(countdownTimer); countdownTimer = null; } }, 1000);
errors.newPhone = "";
showToast("需先完成滑动行为验证;当前未发送验证码");
};
const validatePhone = () => {
errors.newPhone = /^1\d{10}$/.test(phoneForm.newPhone) ? "" : "请输入正确的新手机号";
errors.code = /^\d{6}$/.test(phoneForm.code) ? "" : "请输入 6 位验证码";
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"; phoneForm.currentPhone = `${phoneForm.newPhone.slice(0,3)}****${phoneForm.newPhone.slice(-4)}`; phoneForm.newPhone = ""; phoneForm.code = ""; showToast("绑定手机号已更新"); }, 500);
stateTimer = setTimeout(() => {
phoneState.value = "ready";
showToast("本地校验通过,尚未提交服务器");
}, 500);
};
onUnmounted(() => { clearInterval(countdownTimer); clearTimeout(stateTimer); clearTimeout(toastTimer); });
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();
});
</script>
<style scoped lang="scss">
+3 -2
View File
@@ -23,6 +23,7 @@ import { computed, ref } from "vue";
import AppButton from "@/components/AppButton.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { openPage } from "@/utils/navigation.js";
const helpCategories = ["全部", "家谱", "成员", "隐私", "账号"];
const activeCategory = ref("全部");
@@ -30,7 +31,7 @@ const keyword = ref("");
const expandedIds = ref([]);
const questions = [
{ id: 1, category: "家谱", question: "如何创建一部新家谱?", answer: "进入“我的家谱”,选择新建家谱,按步骤填写姓氏、堂号和地区等基础资料。" },
{ id: 2, category: "成员", question: "如何邀请家人共同完善家谱?", answer: "家人可搜索家谱后提交加入申请,管理员核实亲属关系后完成审核。" },
{ id: 2, category: "成员", question: "如何邀请家人共同完善家谱?", answer: "家人可搜索家谱后提交加入申请,管理员核实关系;邀请码尚未接入,未来校验成功后将直接加入,不产生审核记录。" },
{ id: 3, category: "隐私", question: "哪些个人资料会展示给其他成员?", answer: "资料按家谱角色与权限展示;敏感联系方式默认脱敏,后续可在家谱设置中管理权限。" },
{ id: 4, category: "账号", question: "忘记密码后怎样恢复账号?", answer: "在登录页选择“忘记密码”,通过绑定手机号验证后设置新密码。" },
{ id: 5, category: "家谱", question: "家谱资料填写错了怎么办?", answer: "有编辑权限的成员可以进入对应资料页修改;关键世系关系建议核对后再保存。" },
@@ -40,7 +41,7 @@ const filteredQuestions = computed(() => {
return questions.filter((item) => (activeCategory.value === "全部" || item.category === activeCategory.value) && (!query || `${item.question}${item.answer}`.toLowerCase().includes(query)));
});
const toggleQuestion = (id) => { expandedIds.value = expandedIds.value.includes(id) ? expandedIds.value.filter((item) => item !== id) : [...expandedIds.value, id]; };
const contactSupport = () => uni.navigateTo({ url: "/pages/profile/m07-feedback" });
const contactSupport = () => openPage("M07", {}, "M06");
</script>
<style scoped lang="scss">
+234 -24
View File
@@ -1,53 +1,263 @@
<!-- 页面编号M-07用途提交意见反馈 -->
<template>
<view class="feedback-page" :class="{ 'feedback-state--ready': feedbackState === 'ready', 'feedback-state--submitting': feedbackState === 'submitting' }">
<view
class="feedback-page"
:class="{
'feedback-state--ready': feedbackState === 'ready',
'feedback-state--submitting': feedbackState === 'submitting',
'feedback-state--success': feedbackState === 'success',
'feedback-state--error': feedbackState === 'error',
'feedback-state--uncertain': feedbackState === 'uncertain',
}"
>
<ModulePageBackground module="profile" />
<view class="page-layer"><PageHeader title="意见反馈" /></view>
<view class="page-layer"><PageHeader title="意见反馈" custom-back @back="requestBack" /></view>
<view class="page-content page-layer">
<text class="lead">你的建议会帮助我们把家谱做得更好</text>
<view class="type-grid">
<view v-for="type in feedbackTypes" :key="type" class="type-chip" :class="{ active: feedbackForm.type === type }" role="button" @click="feedbackForm.type = type; errors.type = ''">{{ type }}</view>
<view class="type-field" role="group" aria-label="反馈类型选填" aria-describedby="feedback-type-note">
<text id="feedback-type-note" class="field-note">反馈类型为选填可再次点击取消选择</text>
<view class="type-grid">
<button
v-for="type in feedbackTypes"
:key="type"
class="type-chip"
:class="{ active: feedbackForm.feedbackType === type }"
:aria-pressed="feedbackForm.feedbackType === type"
:disabled="feedbackState === 'submitting'"
@click="selectFeedbackType(type)"
>{{ type }}</button>
</view>
</view>
<text v-if="errors.type" class="field-error">{{ errors.type }}</text>
<view class="form-panel">
<textarea v-model.trim="feedbackForm.description" auto-height maxlength="500" aria-label="问题描述" placeholder="请说明遇到的问题、操作步骤或建议" @input="errors.description = ''" />
<text class="counter">{{ feedbackForm.description.length }}/500</text>
<text v-if="errors.description" class="field-error">{{ errors.description }}</text>
<view class="contact-row"><text>联系方式</text><input v-model.trim="feedbackForm.contact" maxlength="50" aria-label="联系方式" placeholder="手机号或邮箱(选填)" /></view>
<textarea
v-model.trim="feedbackForm.feedbackContent"
auto-height
maxlength="500"
aria-label="问题描述"
aria-required="true"
:aria-describedby="errors.feedbackContent ? 'feedback-content-help feedback-content-error' : 'feedback-content-help'"
:aria-invalid="Boolean(errors.feedbackContent)"
:disabled="feedbackState === 'submitting'"
:focus="feedbackContentFocused"
placeholder="请说明遇到的问题、操作步骤或建议"
@input="errors.feedbackContent = ''"
@blur="feedbackContentFocused = false"
/>
<text class="counter">{{ feedbackForm.feedbackContent.length }}/500</text>
<text id="feedback-content-help" class="field-note">请勿填写密码验证码等敏感信息</text>
<text v-if="errors.feedbackContent" id="feedback-content-error" class="field-error">{{ errors.feedbackContent }}</text>
<view class="contact-row">
<text>联系方式</text>
<input
v-model.trim="feedbackForm.contactInfo"
maxlength="50"
aria-label="联系方式选填"
:disabled="feedbackState === 'submitting'"
placeholder="手机号或邮箱(选填)"
/>
</view>
</view>
<text class="privacy-note">仅用于跟进本次反馈不会在家谱中公开</text>
<AppButton block :disabled="feedbackState === 'submitting'" :label="feedbackState === 'submitting' ? '正在提交' : '提交反馈'" @click="submitFeedback" />
<text class="privacy-note">联系方式选填便于需要时核实本次反馈</text>
<view
v-if="feedbackResult"
class="feedback-result"
:class="{
'feedback-result--success': feedbackResultTone === 'success',
'feedback-result--error': feedbackResultTone === 'error',
'feedback-result--uncertain': feedbackResultTone === 'uncertain',
}"
:role="feedbackResultTone === 'error' ? 'alert' : 'status'"
:aria-live="feedbackResultTone === 'error' ? 'assertive' : 'polite'"
>{{ feedbackResult }}</view>
<AppButton block :disabled="submitDisabled" :label="submitLabel" @click="submitFeedback" />
</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>
</template>
<script setup>
import { onUnmounted, reactive, ref } from "vue";
import { computed, nextTick, reactive, ref, watch } from "vue";
import { onBackPress, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppToast from "@/components/AppToast.vue";
import AppDialog from "@/components/AppDialog.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 { handleBackPress, runBackGuard } from "@/utils/navigation.js";
const feedbackTypes = ["功能问题", "使用建议", "内容纠错", "其他"];
const feedbackForm = reactive({ type: "", description: "", contact: "" });
const errors = reactive({ type: "", description: "" });
const feedbackForm = reactive({ feedbackType: "", feedbackContent: "", contactInfo: "" });
const errors = reactive({ feedbackContent: "" });
const feedbackState = ref("ready");
const toastVisible = ref(false); const toastMessage = ref(""); let timer = null;
const feedbackResult = ref("");
const feedbackResultTone = ref("");
const feedbackContentFocused = ref(false);
const discardVisible = ref(false);
const lastSubmittedSnapshot = ref("");
const lastUncertainSnapshot = ref("");
let pageActive = true;
const requestController = createRequestController();
const normalizedForm = computed(() => ({
feedbackType: feedbackForm.feedbackType.trim(),
feedbackContent: feedbackForm.feedbackContent.trim(),
contactInfo: feedbackForm.contactInfo.trim(),
}));
const formSnapshot = computed(() => JSON.stringify(normalizedForm.value));
const baseline = ref(formSnapshot.value);
const isDirty = computed(() => formSnapshot.value !== baseline.value);
const submitDisabled = computed(() =>
feedbackState.value === "submitting" ||
(Boolean(lastSubmittedSnapshot.value) && formSnapshot.value === lastSubmittedSnapshot.value) ||
(Boolean(lastUncertainSnapshot.value) && formSnapshot.value === lastUncertainSnapshot.value),
);
const submitLabel = computed(() => {
if (feedbackState.value === "submitting") return "正在提交";
if (feedbackState.value === "success") return "反馈已提交";
if (feedbackState.value === "uncertain") return "提交结果待确认";
if (feedbackState.value === "error") return "重新提交";
return "提交反馈";
});
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const validateFeedback = () => {
errors.type = feedbackForm.type ? "" : "请选择反馈类型";
errors.description = feedbackForm.description.length < 5 ? "请至少填写 5 个字的问题描述" : "";
return !errors.type && !errors.description;
errors.feedbackContent = feedbackForm.feedbackContent.trim() ? "" : "请填写问题描述";
if (errors.feedbackContent) {
feedbackContentFocused.value = false;
nextTick(() => {
if (pageActive) feedbackContentFocused.value = true;
});
}
return !errors.feedbackContent;
};
const submitFeedback = () => {
const selectFeedbackType = (type) => {
if (feedbackState.value === "submitting") return;
feedbackForm.feedbackType = feedbackForm.feedbackType === type ? "" : type;
};
const feedbackErrorMessage = (error) => {
if (error?.code === "WRITE_UNAVAILABLE") {
return "当前为本地预览模式,反馈未提交服务器。";
}
if (error?.httpStatus === 401) {
return "登录状态已失效,服务器未确认提交成功。请重新登录后再处理。";
}
return error?.message ? `服务器未确认提交成功:${error.message}` : "服务器未确认提交成功,请稍后再试。";
};
const submittedFeedbackMessage = "反馈已提交。以下内容为本次提交记录;修改任一项后可提交新反馈。";
const submittedWithPendingEditsMessage = "上一份反馈已提交,当前修改尚未提交。";
const uncertainFeedbackMessage = "提交结果可能未知。为避免重复记录,当前内容不能直接重提;请稍后确认或修改后提交新反馈。";
const uncertainWithPendingEditsMessage = "上一份反馈的提交结果仍待确认,当前修改尚未提交。";
const isFeedbackResultUncertain = (error) => {
if (["REQUEST_TIMEOUT", "NETWORK_ERROR", "RESPONSE_INVALID"].includes(error?.code)) {
return true;
}
if (error?.code !== "HTTP_ERROR") return false;
const status = error.httpStatus;
return (status >= 200 && status < 400) || status === 408 || status >= 500;
};
const submitFeedback = async () => {
if (!validateFeedback() || feedbackState.value === "submitting") return;
if (
(lastSubmittedSnapshot.value && formSnapshot.value === lastSubmittedSnapshot.value) ||
(lastUncertainSnapshot.value && formSnapshot.value === lastUncertainSnapshot.value)
) return;
const submittedSnapshot = formSnapshot.value;
const submittedPayload = JSON.parse(submittedSnapshot);
feedbackState.value = "submitting";
timer = setTimeout(() => { feedbackState.value = "ready"; feedbackForm.type = ""; feedbackForm.description = ""; feedbackForm.contact = ""; toastMessage.value = "反馈已保存,服务接入后将提交给家谱助手"; toastVisible.value = true; timer = setTimeout(() => (toastVisible.value = false), 2200); }, 500);
feedbackResult.value = "";
feedbackResultTone.value = "";
try {
await appApi.submitFeedback(submittedPayload, { requestController });
if (!pageActive) return;
baseline.value = submittedSnapshot;
lastSubmittedSnapshot.value = submittedSnapshot;
feedbackResultTone.value = "success";
if (formSnapshot.value === submittedSnapshot) {
feedbackState.value = "success";
feedbackResult.value = submittedFeedbackMessage;
} else {
feedbackState.value = "ready";
feedbackResult.value = submittedWithPendingEditsMessage;
}
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
if (isFeedbackResultUncertain(error)) {
lastUncertainSnapshot.value = submittedSnapshot;
feedbackResultTone.value = "uncertain";
if (formSnapshot.value === submittedSnapshot) {
feedbackState.value = "uncertain";
feedbackResult.value = uncertainFeedbackMessage;
} else {
feedbackState.value = "ready";
feedbackResult.value = uncertainWithPendingEditsMessage;
}
return;
}
feedbackState.value = "error";
feedbackResult.value = feedbackErrorMessage(error);
feedbackResultTone.value = "error";
}
};
onUnmounted(() => clearTimeout(timer));
watch(formSnapshot, (snapshot) => {
if (feedbackState.value === "submitting") return;
errors.feedbackContent = "";
if (lastSubmittedSnapshot.value && snapshot === lastSubmittedSnapshot.value) {
feedbackState.value = "success";
feedbackResult.value = submittedFeedbackMessage;
feedbackResultTone.value = "success";
return;
}
if (lastUncertainSnapshot.value && snapshot === lastUncertainSnapshot.value) {
feedbackState.value = "uncertain";
feedbackResult.value = uncertainFeedbackMessage;
feedbackResultTone.value = "uncertain";
return;
}
feedbackState.value = "ready";
if (lastUncertainSnapshot.value) {
feedbackResult.value = uncertainWithPendingEditsMessage;
feedbackResultTone.value = "uncertain";
} else if (lastSubmittedSnapshot.value) {
feedbackResult.value = submittedWithPendingEditsMessage;
feedbackResultTone.value = "success";
} else {
feedbackResult.value = "";
feedbackResultTone.value = "";
}
});
const requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: feedbackState.value === "submitting",
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
pageActive = false;
requestController.abort();
discardConfirmation.dispose();
});
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.feedback-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-layer{z-index:1}.page-content{flex:1;padding:26rpx 30rpx 72rpx}.lead{display:block;color:$ink-muted;font-size:23rpx;line-height:1.5;text-align:center}.type-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14rpx;margin-top:20rpx}.type-chip{@include adaptive.adaptive-profile-field;display:flex;min-height:76rpx;align-items:center;justify-content:center;padding:10rpx 16rpx;color:$ink-muted;font-size:22rpx;text-align:center}.type-chip.active{color:$brand-red;font-weight:700;filter:saturate(1.2)}.form-panel{@include adaptive.adaptive-profile-content;margin-top:20rpx;padding:30rpx 34rpx}.form-panel textarea{display:block;width:auto;min-width:0;min-height:210rpx;color:$ink;font-size:24rpx;line-height:1.65}.counter{display:block;color:$ink-muted;font-size:20rpx;text-align:right}.contact-row{display:grid;grid-template-columns:130rpx minmax(0,1fr);min-height:88rpx;align-items:center;gap:16rpx;margin-top:16rpx;border-top:1px solid rgba(181,137,63,.38)}.contact-row text{color:$ink;font-size:23rpx;font-weight:700}.contact-row input{width:auto;min-width:0;min-height:68rpx;color:$ink;font-size:22rpx;text-align:right}.field-error{display:block;margin-top:7rpx;color:#b42318;font-size:20rpx;line-height:1.4;text-align:right}.privacy-note{display:block;margin-top:15rpx;color:$ink-muted;font-size:20rpx;line-height:1.5;text-align:center}.page-content>.app-button{margin-top:24rpx}@media(max-width:340px){.page-content{padding-right:22rpx;padding-left:22rpx}.form-panel{padding-right:26rpx;padding-left:26rpx}}
.feedback-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-layer{z-index:1}.page-content{flex:1;padding:26rpx 30rpx 72rpx}.lead{display:block;color:$ink-muted;font-size:23rpx;line-height:1.5;text-align:center}.type-field{margin-top:20rpx}.field-note{display:block;color:$ink-muted;font-size:20rpx;line-height:1.5}.type-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14rpx;margin-top:10rpx}.type-chip{margin:0;padding:0;border:0;background:transparent;line-height:normal;@include adaptive.adaptive-profile-field;display:flex;min-height:88rpx;align-items:center;justify-content:center;color:$ink-muted;font-size:22rpx;text-align:center}.type-chip::after{border:0}.type-chip.active{color:$brand-red;font-weight:700;filter:saturate(1.2)}.type-chip[disabled]{opacity:.55}.form-panel{@include adaptive.adaptive-profile-content;margin-top:20rpx;padding:30rpx 34rpx}.form-panel textarea{display:block;width:auto;min-width:0;min-height:210rpx;color:$ink;font-size:24rpx;line-height:1.65}.form-panel textarea[disabled],.contact-row input[disabled]{opacity:.65}.counter{display:block;color:$ink-muted;font-size:20rpx;text-align:right}.contact-row{display:grid;grid-template-columns:130rpx minmax(0,1fr);min-height:88rpx;align-items:center;gap:16rpx;margin-top:16rpx;border-top:1px solid rgba(181,137,63,.38)}.contact-row text{color:$ink;font-size:23rpx;font-weight:700}.contact-row input{width:auto;min-width:0;min-height:68rpx;color:$ink;font-size:22rpx;text-align:right}.field-error{display:block;margin-top:7rpx;color:#b42318;font-size:20rpx;line-height:1.4;text-align:right}.privacy-note{display:block;margin-top:15rpx;color:$ink-muted;font-size:20rpx;line-height:1.5;text-align:center}.feedback-result{margin-top:18rpx;padding:18rpx 20rpx;border:1px solid rgba(122,91,46,.24);border-radius:12rpx;font-size:21rpx;line-height:1.55}.feedback-result--success{border-color:rgba(41,112,66,.34);background:rgba(234,246,237,.88);color:#245f39}.feedback-result--error{border-color:rgba(180,35,24,.3);background:rgba(255,241,239,.9);color:#8f241c}.feedback-result--uncertain{border-color:rgba(155,101,22,.34);background:rgba(255,248,229,.92);color:#76500f}.page-content>.app-button{margin-top:24rpx}@media(max-width:340px){.page-content{padding-right:22rpx;padding-left:22rpx}.form-panel{padding-right:26rpx;padding-left:26rpx}}
</style>
+22 -23
View File
@@ -1,47 +1,46 @@
<!-- 页面编号M-08用途邀请家人共建家谱 -->
<template>
<view class="promotion-page share-state--ready">
<view class="promotion-page share-state--unavailable">
<ModulePageBackground module="profile" />
<view class="page-layer"><PageHeader title="邀请家人" /></view>
<view class="page-layer"><PageHeader title="邀请家人" custom-back @back="requestBack" /></view>
<view class="page-content page-layer">
<view class="invite-hero">
<image src="/static/assets/foundation/transparent/brand-seal.png" mode="aspectFit" />
<text>邀请亲友共建家族记忆</text>
<text>家人搜索家谱或使用邀请码后仍需管理员审核才能加入</text>
<text>邀请码校验成功后直接加入不会生成入谱审核记录</text>
</view>
<view class="invite-code-card">
<text>汝南汤氏家谱邀请码</text>
<text class="invite-code">{{ inviteCode }}</text>
<view class="copy-action" role="button" aria-label="复制邀请码" @click="copyInviteCode">复制邀请码</view>
<view v-if="inviteState === 'unavailable'" class="unavailable-card">
<text>当前没有可用邀请码</text>
<text>当前尚未接入邀请码校验与签发接口页面不会生成复制或展示虚假邀请码</text>
</view>
<view class="steps-card"><text>邀请步骤</text><text>1. 生成邀请信息</text><text>2. 发送给家人</text><text>3. 家人提交加入申请</text><text>4. 管理员核实并审核</text></view>
<AppButton block label="生成邀请海报" @click="generatePoster" />
<view class="steps-card"><text>开放条件</text><text>1. 后端提供邀请码签发接口</text><text>2. 明确有效期使用次数和失效规则</text><text>3. 校验家谱权限与直接加入结果</text></view>
<AppButton block type="secondary" label="查看邀请说明" @click="openInviteExplanation" />
</view>
<AppDialog :visible="posterVisible" eyebrow="家谱邀请" title="汝南汤氏家谱" :message="`邀请码 ${inviteCode}。长按或复制邀请码发送给家人。`" confirm-text="完成" @confirm="posterVisible = false" @close="posterVisible = false">
<image class="poster-seal" src="/static/assets/foundation/transparent/brand-seal.png" mode="aspectFit" />
</AppDialog>
<AppToast :visible="toastVisible" :message="toastMessage" />
<AppDialog :visible="explanationVisible" :close-on-mask="false" eyebrow="邀请说明" title="功能尚未接入" message="当前后端接口文档没有邀请码签发与校验合同。完成接口接入前,本页保持不可用,避免家人拿到无法验证的邀请码。" confirm-text="我知道了" @confirm="explanationVisible = false" @close="explanationVisible = false" />
</view>
</template>
<script setup>
import { onUnmounted, ref } from "vue";
import { ref } from "vue";
import { onBackPress } 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 { handleBackPress, runBackGuard } from "@/utils/navigation.js";
const inviteCode = ref("TN20260720");
const posterVisible = ref(false);
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 copyInviteCode = () => uni.setClipboardData({ data: inviteCode.value, showToast: false, success: () => showToast("邀请码已复制"), fail: () => showToast("复制失败,请长按邀请码复制") });
const generatePoster = () => { posterVisible.value = true; };
onUnmounted(() => clearTimeout(timer));
const inviteState = ref("unavailable");
const explanationVisible = ref(false);
const openInviteExplanation = () => { explanationVisible.value = true; };
const requestBack = () =>
runBackGuard({
transientOpen: explanationVisible.value,
"close-transient": () => { explanationVisible.value = false; },
});
onBackPress((event) => handleBackPress(event, requestBack));
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.promotion-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-layer{z-index:1}.page-content{flex:1;padding:28rpx 30rpx 72rpx}.invite-hero,.invite-code-card,.steps-card{@include adaptive.adaptive-profile-content}.invite-hero{display:flex;min-height:300rpx;flex-direction:column;align-items:center;justify-content:center;padding:38rpx 48rpx;text-align:center}.invite-hero image{width:90rpx;height:auto;max-height:102rpx;aspect-ratio:90/102}.invite-hero text{display:block}.invite-hero text:nth-child(2){margin-top:12rpx;color:$ink;font-family:STKaiti,KaiTi,serif;font-size:34rpx;font-weight:700}.invite-hero text:last-child{margin-top:12rpx;color:$ink-muted;font-size:22rpx;line-height:1.55}.invite-code-card{min-height:220rpx;margin-top:20rpx;padding:38rpx 44rpx;text-align:center}.invite-code-card>text:first-child{display:block;color:$ink-muted;font-size:21rpx}.invite-code{display:block;margin-top:10rpx;color:$brand-red;font-size:42rpx;font-weight:700;letter-spacing:5rpx;overflow-wrap:anywhere}.copy-action{display:flex;min-height:62rpx;align-items:center;justify-content:center;color:$brand-red;font-size:22rpx}.steps-card{display:flex;min-height:260rpx;flex-direction:column;gap:10rpx;margin-top:20rpx;padding:34rpx 44rpx;color:$ink-muted;font-size:22rpx}.steps-card text:first-child{margin-bottom:4rpx;color:$ink;font-size:27rpx;font-weight:700}.page-content>.app-button{margin-top:26rpx}.poster-seal{width:96rpx;height:auto;max-height:110rpx;aspect-ratio:96/110;margin:18rpx auto 24rpx}@media(max-width:340px){.page-content{padding-right:22rpx;padding-left:22rpx}.invite-code{font-size:35rpx;letter-spacing:3rpx}}
.promotion-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-layer{z-index:1}.page-content{flex:1;padding:28rpx 30rpx 72rpx}.invite-hero,.unavailable-card,.steps-card{@include adaptive.adaptive-profile-content}.invite-hero{display:flex;min-height:300rpx;flex-direction:column;align-items:center;justify-content:center;padding:38rpx 48rpx;text-align:center}.invite-hero image{width:90rpx;height:auto;max-height:102rpx;aspect-ratio:90/102}.invite-hero text{display:block}.invite-hero text:nth-child(2){margin-top:12rpx;color:$ink;font-family:STKaiti,KaiTi,serif;font-size:34rpx;font-weight:700}.invite-hero text:last-child{margin-top:12rpx;color:$ink-muted;font-size:22rpx;line-height:1.55}.unavailable-card{min-height:190rpx;margin-top:20rpx;padding:40rpx 44rpx;text-align:center}.unavailable-card text{display:block}.unavailable-card text:first-child{color:$brand-red;font-size:28rpx;font-weight:700}.unavailable-card text:last-child{margin-top:12rpx;color:$ink-muted;font-size:21rpx;line-height:1.55}.steps-card{display:flex;min-height:220rpx;flex-direction:column;gap:10rpx;margin-top:20rpx;padding:34rpx 44rpx;color:$ink-muted;font-size:22rpx}.steps-card text:first-child{margin-bottom:4rpx;color:$ink;font-size:27rpx;font-weight:700}.page-content>.app-button{margin-top:26rpx}@media(max-width:340px){.page-content{padding-right:22rpx;padding-left:22rpx}}
</style>
+15 -10
View File
@@ -1,40 +1,45 @@
<!-- 页面编号M-09用途服务权益与订单记录 -->
<template>
<view class="orders-page" :class="orders.length ? 'order-state--ready' : 'order-state--empty'">
<view class="orders-page order-state--unavailable">
<ModulePageBackground module="profile" />
<view class="page-layer"><PageHeader title="VIP 与订单" /></view>
<view class="page-layer"><PageHeader title="VIP 与订单" custom-back @back="requestBack" /></view>
<view class="page-content page-layer">
<view class="service-card"><text>家谱基础服务</text><text>当前未开通付费服务</text><text>基础家谱浏览成员资料与家族记录可正常使用</text></view>
<view class="benefit-grid"><view v-for="benefit in serviceBenefits" :key="benefit.title" class="benefit-card"><text>{{ benefit.title }}</text><text>{{ benefit.copy }}</text></view></view>
<view class="section-heading"><text>订单记录</text><text>{{ orders.length }} </text></view>
<view v-if="orders.length" class="order-list"><view v-for="order in orders" :key="order.id" class="order-card"><view><text>{{ order.name }}</text><text>{{ order.createdAt }}</text></view><view><text>{{ order.amount }}</text><text>{{ order.status }}</text></view></view></view>
<view v-else class="empty-card"><text>暂无订单记录</text><text>服务开放并完成购买后订单状态会在这里展示</text></view>
<view class="section-heading"><text>订单记录</text><text>暂不可用</text></view>
<view v-if="orderState === 'unavailable'" class="empty-card"><text>订单接口尚未接入</text><text>当前页面不读取查询参数也不会虚构订单完成套餐支付和订单状态合同核对后再开放</text></view>
<AppButton block type="secondary" label="查看服务说明" @click="openServiceNotice" />
</view>
<AppDialog :visible="serviceNoticeVisible" eyebrow="服务说明" title="付费服务尚未开放" message="当前版本不会产生扣费或订单。未来开放前会明确展示价格、权益、续费与退款规则。" confirm-text="我知道了" @confirm="serviceNoticeVisible = false" @close="serviceNoticeVisible = false" />
<AppDialog :visible="serviceNoticeVisible" :close-on-mask="false" eyebrow="服务说明" title="付费服务尚未开放" message="当前版本不会产生扣费或订单。未来开放前会明确展示价格、权益、续费与退款规则。" confirm-text="我知道了" @confirm="serviceNoticeVisible = false" @close="serviceNoticeVisible = false" />
</view>
</template>
<script setup>
import { ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { onBackPress } 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 { handleBackPress, runBackGuard } from "@/utils/navigation.js";
const serviceBenefits = [
{ title: "更大存储", copy: "为家族影像和资料提供更多空间" },
{ title: "资料导出", copy: "按规则整理并导出家谱资料" },
{ title: "专属服务", copy: "获得家谱整理与使用支持" },
];
const orders = ref([]);
const orderState = ref("unavailable");
const serviceNoticeVisible = ref(false);
onLoad((query) => { if (query.state === "ready") orders.value = [{ id: "O20260720001", name: "家谱服务演示订单", createdAt: "2026-07-20 08:30", amount: "¥0.00", status: "演示记录" }]; });
const openServiceNotice = () => { serviceNoticeVisible.value = true; };
const requestBack = () =>
runBackGuard({
transientOpen: serviceNoticeVisible.value,
"close-transient": () => { serviceNoticeVisible.value = false; },
});
onBackPress((event) => handleBackPress(event, requestBack));
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.orders-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-layer{z-index:1}.page-content{flex:1;padding:28rpx 30rpx 72rpx}.service-card,.benefit-card,.order-card,.empty-card{@include adaptive.adaptive-profile-content}.service-card{min-height:220rpx;padding:42rpx 48rpx;text-align:center}.service-card text{display:block}.service-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:34rpx;font-weight:700}.service-card text:nth-child(2){margin-top:10rpx;color:$brand-red;font-size:23rpx;font-weight:700}.service-card text:last-child{margin-top:12rpx;color:$ink-muted;font-size:21rpx;line-height:1.55}.benefit-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12rpx;margin-top:18rpx}.benefit-card{min-height:150rpx;padding:28rpx 18rpx;text-align:center}.benefit-card text{display:block;overflow-wrap:anywhere}.benefit-card text:first-child{color:$ink;font-size:23rpx;font-weight:700}.benefit-card text:last-child{margin-top:8rpx;color:$ink-muted;font-size:19rpx;line-height:1.45}.section-heading{display:flex;flex-wrap:wrap;justify-content:space-between;gap:8rpx 20rpx;margin:28rpx 6rpx 14rpx}.section-heading text:first-child{color:$ink;font-size:27rpx;font-weight:700}.section-heading text:last-child{color:$ink-muted;font-size:21rpx}.order-list{display:flex;flex-direction:column;gap:14rpx;margin-top:18rpx}.order-card{display:grid;grid-template-columns:minmax(0,1fr) auto;min-height:130rpx;align-items:center;gap:20rpx;padding:26rpx 34rpx}.order-card text{display:block;overflow-wrap:anywhere}.order-card view:first-child text:first-child{color:$ink;font-size:23rpx;font-weight:700}.order-card text:last-child{margin-top:6rpx;color:$ink-muted;font-size:20rpx}.order-card view:last-child{text-align:right}.order-card view:last-child text:first-child{color:$brand-red;font-size:23rpx;font-weight:700}.empty-card{min-height:190rpx;padding:48rpx 42rpx;text-align:center}.empty-card text{display:block}.empty-card text:first-child{color:$ink;font-size:28rpx;font-weight:700}.empty-card text:last-child{margin-top:12rpx;color:$ink-muted;font-size:21rpx;line-height:1.55}.page-content>.app-button{margin-top:26rpx}@media(max-width:340px){.page-content{padding-right:22rpx;padding-left:22rpx}.benefit-grid{grid-template-columns:1fr}.benefit-card{min-height:100rpx}.order-card{grid-template-columns:1fr}.order-card view:last-child{text-align:left}}
.orders-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-layer{z-index:1}.page-content{flex:1;padding:28rpx 30rpx 72rpx}.service-card,.benefit-card,.empty-card{@include adaptive.adaptive-profile-content}.service-card{min-height:220rpx;padding:42rpx 48rpx;text-align:center}.service-card text{display:block}.service-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:34rpx;font-weight:700}.service-card text:nth-child(2){margin-top:10rpx;color:$brand-red;font-size:23rpx;font-weight:700}.service-card text:last-child{margin-top:12rpx;color:$ink-muted;font-size:21rpx;line-height:1.55}.benefit-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12rpx;margin-top:18rpx}.benefit-card{min-height:150rpx;padding:28rpx 18rpx;text-align:center}.benefit-card text{display:block;overflow-wrap:anywhere}.benefit-card text:first-child{color:$ink;font-size:23rpx;font-weight:700}.benefit-card text:last-child{margin-top:8rpx;color:$ink-muted;font-size:19rpx;line-height:1.45}.section-heading{display:flex;flex-wrap:wrap;justify-content:space-between;gap:8rpx 20rpx;margin:28rpx 6rpx 14rpx}.section-heading text:first-child{color:$ink;font-size:27rpx;font-weight:700}.section-heading text:last-child{color:$ink-muted;font-size:21rpx}.empty-card{min-height:190rpx;padding:48rpx 42rpx;text-align:center}.empty-card text{display:block}.empty-card text:first-child{color:$ink;font-size:28rpx;font-weight:700}.empty-card text:last-child{margin-top:12rpx;color:$ink-muted;font-size:21rpx;line-height:1.55}.page-content>.app-button{margin-top:26rpx}@media(max-width:340px){.page-content{padding-right:22rpx;padding-left:22rpx}.benefit-grid{grid-template-columns:1fr}.benefit-card{min-height:100rpx}}
</style>
+26 -10
View File
@@ -2,39 +2,55 @@
<template>
<view class="about-page">
<ModulePageBackground module="profile" />
<view class="page-layer"><PageHeader title="关于家谱" /></view>
<view class="page-layer"><PageHeader title="关于家谱" custom-back @back="requestBack" /></view>
<view class="page-content page-layer">
<view class="brand-card"><image src="/static/assets/foundation/transparent/brand-seal.png" mode="aspectFit" /><text>家谱</text><text>传承每一段值得珍藏的家族记忆</text><text>版本 {{ appVersion }}</text></view>
<view class="settings-list">
<view v-for="item in agreementItems" :key="item.key" class="settings-row" role="button" :aria-label="item.label" @click="openAgreement(item)"><view><text>{{ item.label }}</text><text>{{ item.note }}</text></view><image src="/static/assets/foundation/transparent/chevron-right.png" mode="aspectFit" /></view>
</view>
<AppButton block type="secondary" :label="loggedOut ? '已退出登录' : '退出登录'" :disabled="loggedOut" @click="logoutVisible = true" />
<AppButton block type="secondary" label="退出登录" @click="logoutVisible = true" />
</view>
<AppDialog :visible="agreementVisible" eyebrow="协议与说明" :title="activeAgreement.label" :message="activeAgreement.copy" confirm-text="关闭" @confirm="agreementVisible = false" @close="agreementVisible = false" />
<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" />
<AppToast :visible="toastVisible" message="已退出当前账号" />
</view>
</template>
<script setup>
import { onUnmounted, reactive, ref } from "vue";
import { reactive, ref } from "vue";
import { onBackPress } 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 manifest from "@/manifest.json";
import { goRoot, handleBackPress, runBackGuard } from "@/utils/navigation.js";
import { session } from "@/utils/session.js";
const appVersion = "1.0.0";
const appVersion = manifest.versionName;
const agreementItems = [
{ key: "terms", label: "用户协议", note: "了解账号与服务使用规则", copy: "用户协议正文将在正式协议服务接入后展示。当前页面不代表最终法律文本。" },
{ key: "privacy", label: "隐私政策", note: "了解个人资料如何使用与保护", copy: "隐私政策正文将在正式协议服务接入后展示。敏感资料默认按权限与脱敏规则展示。" },
{ key: "version", label: "版本说明", note: `当前版本 ${appVersion}`, copy: `当前安装版本为 ${appVersion}。基础版本采用浅色国风主题。` },
];
const activeAgreement = reactive({ label: "", copy: "" });
const agreementVisible = ref(false); const logoutVisible = ref(false); const toastVisible = ref(false); const loggedOut = ref(false); let timer = null;
const agreementVisible = ref(false);
const logoutVisible = ref(false);
const openAgreement = (item) => { activeAgreement.label = item.label; activeAgreement.copy = item.copy; agreementVisible.value = true; };
const confirmLogout = () => { logoutVisible.value = false; loggedOut.value = true; toastVisible.value = true; timer = setTimeout(() => (toastVisible.value = false), 1800); };
onUnmounted(() => clearTimeout(timer));
const confirmLogout = () => {
session.clear();
logoutVisible.value = false;
return goRoot("A01");
};
const closeActiveDialog = () => {
if (logoutVisible.value) logoutVisible.value = false;
else agreementVisible.value = false;
};
const requestBack = () =>
runBackGuard({
transientOpen: agreementVisible.value || logoutVisible.value,
"close-transient": closeActiveDialog,
});
onBackPress((event) => handleBackPress(event, requestBack));
</script>
<style scoped lang="scss">