This commit is contained in:
rain
2026-07-24 18:03:02 +08:00
parent c7f278fe79
commit ec8486b035
86 changed files with 7943 additions and 1841 deletions
+108 -4
View File
@@ -1,11 +1,115 @@
<!-- 页面编号M-01用途个人中心资料 DTO 未声明时不展示 mock 用户 -->
<!-- 页面编号M-01用途个人中心显示已核实的当前用户资料 -->
<template>
<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>
<view class="profile-page">
<ModulePageBackground module="profile" />
<view class="page-header"><PageHeader root title="我的" /></view>
<view class="page-content">
<view v-if="loading" class="state-card"><text>正在读取个人资料</text></view>
<view v-else-if="loadError" class="state-card">
<text>个人资料暂时无法读取</text>
<text>{{ loadError }}</text>
<AppButton block label="重新读取" @click="loadProfile" />
</view>
<template v-else>
<view class="profile-summary">
<view class="profile-summary__avatar"><text>{{ profile.avatar ? "已设置" : "未设置" }}</text></view>
<view class="profile-summary__identity">
<text>{{ profile.nickName || "未设置昵称" }}</text>
<text>{{ profile.realName || "未设置真实姓名" }}</text>
<text>{{ profile.phone || "未提供手机号" }}</text>
</view>
<AppButton class="profile-summary__edit" label="编辑资料" @click="openEdit" />
</view>
<view class="profile-fields">
<view class="profile-field"><text>性别</text><text>{{ profile.sex || "未设置" }}</text></view>
<view class="profile-field"><text>生日</text><text>{{ birthdayText }}</text></view>
<view class="profile-field"><text>邮箱</text><text>{{ profile.email || "未设置" }}</text></view>
</view>
<view class="profile-menu">
<AppButton block label="账号与安全" @click="open('M03')" />
<AppButton block label="消息中心" @click="open('N01')" />
<AppButton block label="帮助中心" @click="open('M06')" />
<AppButton block label="意见反馈" @click="open('M07')" />
<AppButton block label="应用推广" @click="open('M08')" />
<AppButton block label="VIP 服务" @click="open('M09')" />
<AppButton block label="关于与设置" @click="open('M10')" />
</view>
</template>
</view>
<AppTabbar active="profile" />
</view>
</template>
<script setup>
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");
import { computed, reactive, ref } from "vue";
import { onShow, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppTabbar from "@/components/AppTabbar.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { openPage } from "@/utils/navigation.js";
const profile = reactive({ avatar: null, nickName: "", realName: "", phone: "", sex: "", birthday: "", email: "" });
const loading = ref(false);
const loadError = ref("");
const requestController = createRequestController();
let pageActive = true;
const birthdayText = computed(() => /^\d{4}-\d{2}-\d{2}/.test(profile.birthday) ? profile.birthday.slice(0, 10) : "未设置");
const loadProfile = async () => {
if (loading.value) return;
loading.value = true;
loadError.value = "";
try {
const data = await appApi.getProfile({ requestController });
if (!pageActive) return;
Object.assign(profile, data);
} catch (error) {
if (pageActive && !isRequestCancelled(error)) {
loadError.value = error?.message || "请稍后重试";
}
} finally {
if (pageActive) loading.value = false;
}
};
const open = (route) => openPage(route, {}, "M01");
const openEdit = () => open("M02");
onShow(loadProfile);
onUnload(() => {
pageActive = false;
requestController.abort();
});
</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}.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}
.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, .profile-summary, .profile-fields { @include adaptive.adaptive-family-content; }
.state-card { box-sizing: border-box; min-height: 340rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
.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; }
.profile-summary { display: grid; grid-template-columns: 96rpx minmax(0, 1fr); align-items: center; gap: 18rpx; padding: 30rpx; }
.profile-summary__avatar { display: flex; width: 96rpx; height: 96rpx; align-items: center; justify-content: center; border: 1rpx solid rgba(159, 23, 15, .32); border-radius: 50%; background: rgba(159, 23, 15, .06); color: $brand-red; font-size: 19rpx; text-align: center; }
.profile-summary__identity { min-width: 0; }
.profile-summary__identity text { display: block; overflow-wrap: anywhere; }
.profile-summary__identity text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 32rpx; font-weight: 700; }
.profile-summary__identity text:nth-child(2), .profile-summary__identity text:last-child { margin-top: 5rpx; color: $ink-muted; font-size: 21rpx; }
.profile-summary__edit { grid-column: 1 / -1; margin-top: 6rpx; }
.profile-fields { display: flex; flex-direction: column; margin-top: 18rpx; padding: 8rpx 30rpx; }
.profile-field { display: flex; min-height: 82rpx; align-items: center; justify-content: space-between; gap: 20rpx; border-bottom: 1rpx solid rgba(181, 137, 63, .3); }
.profile-field:last-child { border-bottom: 0; }
.profile-field text:first-child { flex: 0 0 auto; color: $ink; font-size: 24rpx; font-weight: 700; }
.profile-field text:last-child { min-width: 0; color: $ink-muted; font-size: 22rpx; overflow-wrap: anywhere; text-align: right; }
.profile-menu { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14rpx; margin-top: 24rpx; }
.profile-menu .app-button { margin: 0; }
</style>
+192 -4
View File
@@ -1,11 +1,199 @@
<!-- 页面编号M-02用途编辑资料读取 DTO 与现有字段不一致时保持关闭 -->
<!-- 页面编号M-02用途编辑已由 Apifox 声明的个人资料字段 -->
<template>
<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>
<view class="profile-edit-page">
<ModulePageBackground module="profile" />
<view class="page-header"><PageHeader title="编辑资料" custom-back @back="backToProfile" /></view>
<view class="page-content">
<view v-if="loading" class="state-card"><text>正在读取个人资料</text></view>
<view v-else-if="loadError" class="state-card">
<text>个人资料暂时无法读取</text>
<text>{{ loadError }}</text>
<AppButton block label="重新读取" @click="loadProfile" />
</view>
<template v-else>
<view class="form-panel">
<view class="avatar-row">
<view><text>头像</text><text>{{ avatarMessage }}</text></view>
<button class="upload-button" :disabled="uploading || saving" @click="uploadAvatar">{{ uploading ? "上传中" : "选择图片" }}</button>
</view>
<text v-if="avatarError" class="field-error">{{ avatarError }}</text>
<view class="form-row"><text>昵称</text><input v-model.trim="form.nickName" maxlength="30" placeholder="请输入昵称" placeholder-class="placeholder" :disabled="saving || uploading" /></view>
<view class="form-row"><text>真实姓名</text><input v-model.trim="form.realName" maxlength="30" placeholder="请输入真实姓名" placeholder-class="placeholder" :disabled="saving || uploading" /></view>
<view class="form-row"><text>性别</text><input v-model.trim="form.sex" placeholder="请输入性别" placeholder-class="placeholder" :disabled="saving || uploading" /></view>
<view class="form-row">
<text>生日</text>
<picker mode="date" :value="form.birthday" :disabled="saving || uploading" @change="changeBirthday"><view class="picker-value" :class="{ 'picker-value--placeholder': !form.birthday }">{{ form.birthday || "请选择生日" }}</view></picker>
</view>
<view class="form-row"><text>邮箱</text><input v-model.trim="form.email" maxlength="100" placeholder="请输入邮箱" placeholder-class="placeholder" :disabled="saving || uploading" /></view>
</view>
<text class="form-note">资料字段均可按需填写留空不会提交为修改</text>
<text v-if="saveError" class="save-error">{{ saveError }}</text>
<AppButton block :disabled="saving || uploading" :label="saving ? '保存中…' : '保存资料'" @click="saveProfile" />
</template>
</view>
<AppToast :visible="feedbackVisible" :message="feedbackMessage" />
</view>
</template>
<script setup>
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");
import { computed, reactive, ref } from "vue";
import { onLoad, onUnload } from "@dcloudio/uni-app";
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 { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { isImagePickCancelled, pickAndUploadImage, toConsumerOssId } from "@/utils/resumable-image-upload.js";
import { finishPage, returnTo } from "@/utils/navigation.js";
const form = reactive({ nickName: "", realName: "", sex: "", birthday: "", email: "" });
const original = reactive({ nickName: "", realName: "", sex: "", birthday: "", email: "", avatar: null });
const avatarId = ref(null);
const avatarFileName = ref("");
const loading = ref(true);
const loadError = ref("");
const uploading = ref(false);
const avatarError = ref("");
const saving = ref(false);
const saveError = ref("");
const feedbackVisible = ref(false);
const feedbackMessage = ref("");
const requestController = createRequestController();
let feedbackTimer = null;
let pageActive = true;
const avatarMessage = computed(() => {
if (avatarFileName.value) return `已选择 ${avatarFileName.value}`;
return avatarId.value ? "当前头像已设置" : "尚未设置头像";
});
const showFeedback = (message) => {
feedbackMessage.value = message;
feedbackVisible.value = true;
if (feedbackTimer) clearTimeout(feedbackTimer);
feedbackTimer = setTimeout(() => {
feedbackVisible.value = false;
feedbackTimer = null;
}, 2200);
};
const toBirthdayDate = (value) => /^\d{4}-\d{2}-\d{2}/.test(value) ? value.slice(0, 10) : "";
const loadProfile = async () => {
if (loading.value === false && (uploading.value || saving.value)) return;
loading.value = true;
loadError.value = "";
try {
const profile = await appApi.getProfile({ requestController });
if (!pageActive) return;
Object.assign(form, {
nickName: profile.nickName,
realName: profile.realName,
sex: profile.sex,
birthday: toBirthdayDate(profile.birthday),
email: profile.email,
});
Object.assign(original, { ...form, avatar: profile.avatar });
avatarId.value = profile.avatar;
avatarFileName.value = "";
} catch (error) {
if (pageActive && !isRequestCancelled(error)) loadError.value = error?.message || "请稍后重试";
} finally {
if (pageActive) loading.value = false;
}
};
const changeBirthday = (event) => {
form.birthday = event?.detail?.value || "";
};
const uploadAvatar = async () => {
if (uploading.value || saving.value) return;
uploading.value = true;
avatarError.value = "";
try {
const receipt = await pickAndUploadImage({ requestController });
if (!pageActive) return;
avatarId.value = toConsumerOssId(receipt.ossId);
avatarFileName.value = receipt.fileName || "新头像";
} catch (error) {
if (pageActive && !isImagePickCancelled(error) && !isRequestCancelled(error)) {
avatarError.value = error?.message || "头像上传失败,请稍后重试";
}
} finally {
if (pageActive) uploading.value = false;
}
};
const buildUpdatePayload = () => {
const data = {};
for (const field of ["nickName", "realName", "sex", "email"]) {
if (form[field] && form[field] !== original[field]) data[field] = form[field];
}
if (form.birthday && form.birthday !== original.birthday) data.birthday = `${form.birthday}T00:00:00`;
if (avatarId.value && avatarId.value !== original.avatar) data.avatar = avatarId.value;
return data;
};
const saveProfile = async () => {
if (saving.value || uploading.value) return;
const payload = buildUpdatePayload();
if (Object.keys(payload).length === 0) {
showFeedback("没有需要保存的修改");
return;
}
saving.value = true;
saveError.value = "";
try {
await appApi.updateProfile(payload, { requestController });
if (!pageActive) return;
await finishPage("M01", {}, { operation: "profile-updated", refresh: true });
} catch (error) {
if (pageActive && !isRequestCancelled(error)) saveError.value = error?.message || "保存失败,请稍后重试";
} finally {
if (pageActive) saving.value = false;
}
};
const backToProfile = () => {
if (saving.value || uploading.value) return;
return returnTo("M01");
};
onLoad(loadProfile);
onUnload(() => {
pageActive = false;
requestController.abort();
if (feedbackTimer) clearTimeout(feedbackTimer);
});
</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-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}
.profile-edit-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.page-header, .page-content { z-index: 1; }
.page-content { flex: 1; padding: 24rpx 30rpx 72rpx; }
.state-card, .form-panel { @include adaptive.adaptive-profile-content; }
.state-card { box-sizing: border-box; min-height: 340rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
.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; }
.form-panel { padding: 16rpx 30rpx 22rpx; }
.avatar-row, .form-row { display: grid; grid-template-columns: 142rpx minmax(0, 1fr); min-height: 92rpx; align-items: center; gap: 16rpx; border-bottom: 1rpx solid rgba(181, 137, 63, .42); }
.avatar-row > view text { display: block; }
.avatar-row > view text:first-child, .form-row > text { color: $ink; font-size: 23rpx; font-weight: 700; }
.avatar-row > view text:last-child { margin-top: 4rpx; color: $ink-muted; font-size: 19rpx; overflow-wrap: anywhere; }
.upload-button { display: flex; min-height: 64rpx; align-items: center; justify-content: center; border: 0; background: transparent; color: $brand-red; font-size: 22rpx; text-align: right; }
.form-row input, .picker-value { width: auto; min-width: 0; min-height: 68rpx; color: $ink; font-size: 23rpx; text-align: right; }
.picker-value { display: flex; align-items: center; justify-content: flex-end; }
.picker-value--placeholder, .placeholder { color: #ab9a86; }
.field-error, .save-error, .form-note { display: block; color: $ink-muted; font-size: 20rpx; line-height: 1.5; }
.field-error, .save-error { color: #b42318; }
.field-error { margin-top: 8rpx; text-align: right; }
.form-note { margin: 16rpx 4rpx 0; }
.save-error { margin: 12rpx 4rpx 0; }
.page-content > .app-button { margin-top: 24rpx; }
@media (max-width: 340px) { .page-content { padding-right: 22rpx; padding-left: 22rpx; } .form-panel { padding-right: 24rpx; padding-left: 24rpx; } .avatar-row, .form-row { grid-template-columns: 116rpx minmax(0, 1fr); gap: 12rpx; } }
</style>
+37 -12
View File
@@ -4,9 +4,9 @@
<ModulePageBackground module="profile" />
<view class="page-layer"><PageHeader title="帮助中心" /></view>
<view class="page-content page-layer">
<text class="help-source-note">当前展示的是本地使用说明不代表已读取服务端帮助文章需要最新支持请提交意见反馈</text>
<text class="help-source-note">{{ sourceNote }}</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>
<scroll-view scroll-x class="category-scroll" :show-scrollbar="false"><view class="category-row"><view v-for="category in helpCategories" :key="category.value" class="category-chip" :class="{ active: activeCategory === category.value }" role="button" @click="activeCategory = category.value">{{ category.label }}</view></view></scroll-view>
<view v-if="filteredQuestions.length" class="question-list">
<view v-for="item in filteredQuestions" :key="item.id" class="question-card">
<view class="question-heading" role="button" :aria-label="item.question" @click="toggleQuestion(item.id)"><text>{{ item.question }}</text><text>{{ expandedIds.includes(item.id) ? "收起" : "查看" }}</text></view>
@@ -21,28 +21,53 @@
<script setup>
import { computed, ref } from "vue";
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { openPage } from "@/utils/navigation.js";
const helpCategories = ["全部", "家谱", "成员", "隐私", "账号"];
const activeCategory = ref("全部");
const questions = ref([]);
const helpState = ref("loading");
const controller = createRequestController();
let active = true;
const categoryLabels = Object.freeze({ common: "常见问题", member: "成员管理", lineage: "世系关系" });
const helpCategories = computed(() => [
{ value: "", label: "全部" },
...[...new Set(questions.value.map((item) => item.category).filter(Boolean))].map((value) => ({ value, label: categoryLabels[value.toLowerCase()] || value })),
]);
const activeCategory = ref("");
const keyword = ref("");
const expandedIds = ref([]);
const questions = [
{ id: 1, category: "家谱", question: "如何创建一部新家谱?", answer: "进入“我的家谱”,选择新建家谱,按步骤填写姓氏、堂号和地区等基础资料。" },
{ id: 2, category: "成员", question: "如何邀请家人共同完善家谱?", answer: "家人可搜索家谱后提交加入申请,由管理员核实关系;邀请码尚未接入,未来校验成功后将直接加入,不产生审核记录。" },
{ id: 3, category: "隐私", question: "哪些个人资料会展示给其他成员?", answer: "资料按家谱角色与权限展示;敏感联系方式默认脱敏,后续可在家谱设置中管理权限。" },
{ id: 4, category: "账号", question: "忘记密码后怎样恢复账号?", answer: "在登录页选择“忘记密码”,通过绑定手机号验证后设置新密码。" },
{ id: 5, category: "家谱", question: "家谱资料填写错了怎么办?", answer: "有编辑权限的成员可以进入对应资料页修改;关键世系关系建议核对后再保存。" },
];
const sourceNote = computed(() => ({ loading: "正在读取帮助文章…", error: "帮助文章暂时无法读取,请稍后重试。", empty: "暂未收到可展示的帮助文章。", ready: "帮助内容来自服务端,可按分类或关键词查找。" })[helpState.value]);
const filteredQuestions = computed(() => {
const query = keyword.value.toLowerCase();
return questions.filter((item) => (activeCategory.value === "全部" || item.category === activeCategory.value) && (!query || `${item.question}${item.answer}`.toLowerCase().includes(query)));
return questions.value.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 = () => openPage("M07", {}, "M06");
const loadHelpArticles = async () => {
controller.abort();
helpState.value = "loading";
try {
const rows = await appApi.getHelpArticles({ requestController: controller });
if (!active) return;
questions.value = rows.map((item) => ({
id: String(item.helpId),
category: String(item.helpCategory || "其他"),
question: String(item.helpTitle || "未命名帮助文章"),
answer: String(item.helpContent || "暂无正文")
}));
helpState.value = questions.value.length ? "ready" : "empty";
} catch (error) {
if (!active || isRequestCancelled(error)) return;
helpState.value = "error";
}
};
onLoad(loadHelpArticles);
onShow(() => { if (helpState.value !== "loading") loadHelpArticles(); });
onUnload(() => { active = false; controller.abort(); });
</script>
<style scoped lang="scss">
+47 -22
View File
@@ -1,46 +1,71 @@
<!-- 页面编号M-08用途邀请家人共建家谱 -->
<template>
<view class="promotion-page share-state--unavailable">
<view class="promotion-page">
<ModulePageBackground module="profile" />
<view class="page-layer"><PageHeader title="邀请家人" custom-back @back="requestBack" /></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>
<text>以下内容由服务端配置不生成邀请码不会伪造推广信息</text>
</view>
<view v-if="inviteState === 'unavailable'" class="unavailable-card">
<text>当前没有可用邀请码</text>
<text>当前尚未接入邀请码校验与签发接口页面不会生成复制或展示虚假邀请码</text>
<view v-if="state === 'loading'" class="state-card"><AppLoading text="正在读取推广内容" /></view>
<view v-else-if="state === 'error'" class="state-card">
<text>暂时无法读取推广内容</text><AppButton block type="secondary" label="重新加载" @click="loadPromotions" />
</view>
<view v-else-if="state === 'empty'" class="state-card"><text>当前没有可展示的推广内容</text></view>
<view v-else class="promotion-list">
<view v-for="item in promotions" :key="item.id" class="promotion-card">
<text class="promotion-title">{{ item.title }}</text>
<text v-if="item.platform" class="promotion-platform">{{ item.platform }}</text>
<text v-if="item.description" class="promotion-copy">{{ item.description }}</text>
</view>
</view>
<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="explanationVisible" :close-on-mask="false" eyebrow="邀请说明" title="功能尚未接入" message="当前后端接口文档没有邀请码签发与校验合同。完成接口接入前,本页保持不可用,避免家人拿到无法验证的邀请码。" confirm-text="我知道了" @confirm="explanationVisible = false" @close="explanationVisible = false" />
</view>
</template>
<script setup>
import { ref } from "vue";
import { onBackPress } from "@dcloudio/uni-app";
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { handleBackPress, runBackGuard } from "@/utils/navigation.js";
const inviteState = ref("unavailable");
const explanationVisible = ref(false);
const openInviteExplanation = () => { explanationVisible.value = true; };
const requestBack = () =>
runBackGuard({
transientOpen: explanationVisible.value,
"close-transient": () => { explanationVisible.value = false; },
});
const promotions = ref([]);
const state = ref("loading");
const controller = createRequestController();
let active = true;
const platformLabels = Object.freeze({ app: "APP 应用", pc: "管理后台", wechat: "微信小程序" });
const formatPlatform = (value) => platformLabels[String(value || "").trim().toLowerCase()] || String(value || "").trim();
const loadPromotions = async () => {
controller.abort();
state.value = "loading";
try {
const rows = await appApi.getPromotions({ requestController: controller });
if (!active) return;
promotions.value = rows.map((item) => ({
id: String(item.promotionId),
title: String(item.promotionTitle || "未命名推广"),
description: String(item.promotionDesc || ""),
platform: formatPlatform(item.platform)
}));
state.value = promotions.value.length ? "ready" : "empty";
} catch (error) {
if (!active || isRequestCancelled(error)) return;
state.value = "error";
}
};
const requestBack = () => runBackGuard({});
onLoad(loadPromotions);
onShow(() => { if (state.value !== "loading") loadPromotions(); });
onUnload(() => { active = false; controller.abort(); });
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,.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}}
.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,.state-card,.promotion-card{@include adaptive.adaptive-profile-content}.invite-hero{display:flex;min-height:250rpx;flex-direction:column;align-items:center;justify-content:center;padding:38rpx 48rpx;text-align:center}.invite-hero image{width:90rpx;height:102rpx}.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}.state-card{display:flex;min-height:210rpx;flex-direction:column;align-items:center;justify-content:center;margin-top:20rpx;padding:38rpx;text-align:center;color:$ink;font-size:26rpx}.state-card .app-button{width:100%;margin-top:22rpx}.promotion-list{display:flex;flex-direction:column;gap:16rpx;margin-top:20rpx}.promotion-card{position:relative;display:flex;min-height:142rpx;flex-direction:column;padding:30rpx 34rpx}.promotion-title{color:$ink;font-size:28rpx;font-weight:700;overflow-wrap:anywhere}.promotion-platform{align-self:flex-start;margin-top:10rpx;padding:3rpx 12rpx;border:1rpx solid rgba(181,46,34,.26);border-radius:999rpx;color:$brand-red;font-size:20rpx;line-height:1.3}.promotion-copy{margin-top:12rpx;color:$ink-muted;font-size:22rpx;line-height:1.55;overflow-wrap:anywhere}@media(max-width:340px){.page-content{padding-right:22rpx;padding-left:22rpx}}
</style>
+52 -24
View File
@@ -1,45 +1,73 @@
<!-- 页面编号M-09用途服务权益与订单记录 -->
<template>
<view class="orders-page order-state--unavailable">
<view class="orders-page">
<ModulePageBackground module="profile" />
<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>暂不可用</text></view>
<view v-if="orderState === 'unavailable'" class="empty-card"><text>订单接口尚未接入</text><text>当前页面不读取查询参数也不会虚构订单完成套餐支付和订单状态合同核对后再开放</text></view>
<AppButton block type="secondary" label="查看服务说明" @click="openServiceNotice" />
<view class="service-card"><text>服务套餐</text><text>套餐和订单均从服务端读取本页不发起支付或创建订单</text></view>
<view v-if="state === 'loading'" class="state-card"><AppLoading text="正在读取服务套餐" /></view>
<view v-else-if="state === 'error'" class="state-card"><text>暂时无法读取套餐或订单</text><AppButton block type="secondary" label="重新加载" @click="loadVip" /></view>
<template v-else>
<view v-if="packages.length" class="package-list"><view v-for="item in packages" :key="item.id" class="package-card"><view class="package-heading"><text>{{ item.name }}</text><text v-if="item.price">¥{{ item.price }}</text></view><text v-if="item.description">{{ item.description }}</text><text v-if="item.duration">{{ item.duration }}</text></view></view>
<view v-else class="state-card"><text>当前没有可展示的服务套餐</text></view>
<view class="section-heading"><text>订单记录</text><text>{{ orders.length }} </text></view>
<view v-if="orders.length" class="order-list"><view v-for="item in orders" :key="item.id" class="order-card"><text>{{ item.title }}</text><text>{{ item.status }}</text></view></view>
<view v-else class="order-empty"><text>暂无订单记录</text></view>
</template>
</view>
<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 { onBackPress } from "@dcloudio/uni-app";
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { handleBackPress, runBackGuard } from "@/utils/navigation.js";
const serviceBenefits = [
{ title: "更大存储", copy: "为家族影像和资料提供更多空间" },
{ title: "资料导出", copy: "按规则整理并导出家谱资料" },
{ title: "专属服务", copy: "获得家谱整理与使用支持" },
];
const orderState = ref("unavailable");
const serviceNoticeVisible = ref(false);
const openServiceNotice = () => { serviceNoticeVisible.value = true; };
const requestBack = () =>
runBackGuard({
transientOpen: serviceNoticeVisible.value,
"close-transient": () => { serviceNoticeVisible.value = false; },
});
const packages = ref([]);
const orders = ref([]);
const state = ref("loading");
const controller = createRequestController();
let active = true;
const formatPrice = (value) => Number.isFinite(Number(value)) ? Number(value).toFixed(2) : "";
const durationUnits = Object.freeze({ year: "年", month: "个月", day: "天" });
const formatDuration = (value, unit) => {
if (value === undefined || value === null || value === "") return "";
if (Number.isFinite(Number(value)) && Number(value) <= 0) return "永久有效";
const normalizedUnit = String(unit || "").trim().toLowerCase();
return durationUnits[normalizedUnit] ? `${value} ${durationUnits[normalizedUnit]}` : String(value);
};
const loadVip = async () => {
controller.abort();
state.value = "loading";
try {
const packageRows = await appApi.getVipPackages({ requestController: controller });
const orderRows = await appApi.getVipOrders({ requestController: controller });
if (!active) return;
packages.value = packageRows.map((item) => ({
id: String(item.packageId), name: String(item.packageName || "未命名套餐"), description: String(item.packageDesc || ""),
price: formatPrice(item.price), duration: formatDuration(item.durationValue, item.durationUnit)
}));
orders.value = orderRows.map((item) => ({
id: String(item.orderId || item.id || ""), title: String(item.packageName || item.orderNo || "VIP 订单"), status: String(item.orderStatus || item.status || "" )
})).filter((item) => item.id);
state.value = "ready";
} catch (error) {
if (!active || isRequestCancelled(error)) return;
state.value = "error";
}
};
const requestBack = () => runBackGuard({});
onLoad(loadVip);
onShow(() => { if (state.value !== "loading") loadVip(); });
onUnload(() => { active = false; controller.abort(); });
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,.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}}
.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,.state-card,.package-card,.order-card,.order-empty{@include adaptive.adaptive-profile-content}.service-card{min-height:170rpx;padding:38rpx 44rpx;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:last-child{margin-top:12rpx;color:$ink-muted;font-size:21rpx;line-height:1.55}.state-card{display:flex;min-height:190rpx;flex-direction:column;align-items:center;justify-content:center;margin-top:18rpx;padding:36rpx;text-align:center;color:$ink;font-size:26rpx}.state-card .app-button{width:100%;margin-top:22rpx}.package-list,.order-list{display:flex;flex-direction:column;gap:14rpx;margin-top:18rpx}.package-card{padding:28rpx 32rpx}.package-heading{display:flex;align-items:center;justify-content:space-between;gap:16rpx;color:$ink;font-size:27rpx;font-weight:700}.package-heading text:last-child{flex:0 0 auto;color:$brand-red}.package-card>text{display:block;margin-top:10rpx;color:$ink-muted;font-size:21rpx;line-height:1.5}.section-heading{display:flex;justify-content:space-between;margin:28rpx 6rpx 12rpx;color:$ink;font-size:27rpx;font-weight:700}.section-heading text:last-child{color:$ink-muted;font-size:21rpx;font-weight:400}.order-card{display:flex;justify-content:space-between;gap:18rpx;padding:26rpx 32rpx;color:$ink;font-size:24rpx}.order-card text:last-child{color:$ink-muted}.order-empty{padding:38rpx;text-align:center;color:$ink-muted;font-size:23rpx}@media(max-width:340px){.page-content{padding-right:22rpx;padding-left:22rpx}}
</style>