60%
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user