feat: 完成前端业务闭环与后端联调
This commit is contained in:
@@ -204,7 +204,7 @@ onUnload(() => {
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 36rpx 32rpx 68rpx;
|
||||
padding: 36rpx 32rpx calc(68rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.state-card,
|
||||
@@ -258,6 +258,10 @@ onUnload(() => {
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.invitation-card__heading {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.invitation-card__heading text:first-child {
|
||||
flex: 1;
|
||||
color: #40291e;
|
||||
@@ -295,7 +299,25 @@ onUnload(() => {
|
||||
}
|
||||
|
||||
.invitation-card__actions {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
padding-top: 8rpx;
|
||||
}
|
||||
|
||||
.invitation-card__actions .app-button {
|
||||
min-width: 152rpx;
|
||||
}
|
||||
|
||||
@media (max-width: 340px) {
|
||||
.page-content {
|
||||
padding-right: 22rpx;
|
||||
padding-left: 22rpx;
|
||||
}
|
||||
|
||||
.state-card,
|
||||
.invitation-card {
|
||||
padding-right: 24rpx;
|
||||
padding-left: 24rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -28,18 +28,22 @@
|
||||
:password="!passwordVisible[field.key]"
|
||||
maxlength="32"
|
||||
:aria-label="field.label"
|
||||
:aria-invalid="Boolean(errors[field.key])"
|
||||
:aria-describedby="errors[field.key] ? `${field.key}-password-error` : undefined"
|
||||
:placeholder="field.placeholder"
|
||||
@input="errors[field.key] = ''"
|
||||
/>
|
||||
<view
|
||||
<button
|
||||
class="password-toggle"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-label="`${passwordVisible[field.key] ? '隐藏' : '显示'}${field.label}`"
|
||||
:aria-pressed="passwordVisible[field.key]"
|
||||
@click="togglePassword(field.key)"
|
||||
>{{ passwordVisible[field.key] ? "隐藏" : "显示" }}</view
|
||||
>{{ passwordVisible[field.key] ? "隐藏" : "显示" }}</button
|
||||
>
|
||||
</view>
|
||||
<text v-if="errors[field.key]" class="field-error">{{
|
||||
<text v-if="errors[field.key]" :id="`${field.key}-password-error`" class="field-error" role="alert">{{
|
||||
errors[field.key]
|
||||
}}</text>
|
||||
</view>
|
||||
@@ -84,7 +88,8 @@ import { getRequestErrorMessage } from "@/services/api/request-error-message.js"
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { calcMD5 } from "@/utils/md5.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import { handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||
import { goRoot, handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||
import { session } from "@/utils/session.js";
|
||||
import {
|
||||
PASSWORD_POLICY_MESSAGE,
|
||||
validatePassword,
|
||||
@@ -161,11 +166,13 @@ const savePassword = async () => {
|
||||
{ requestController: passwordChangeRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
session.clear();
|
||||
await goRoot("A01");
|
||||
if (!pageActive) return;
|
||||
passwordForm.current = "";
|
||||
passwordForm.next = "";
|
||||
passwordForm.confirm = "";
|
||||
baseline.value = formSnapshot.value;
|
||||
showToast("密码修改成功");
|
||||
} catch (error) {
|
||||
if (!pageActive) return;
|
||||
if (passwordChangeGuard.recordFailure(passwordChangeAttempt, error)) {
|
||||
@@ -215,7 +222,7 @@ onUnload(() => {
|
||||
}
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 28rpx 30rpx 72rpx;
|
||||
padding: 28rpx 30rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.security-tip,
|
||||
.form-panel {
|
||||
@@ -264,18 +271,26 @@ onUnload(() => {
|
||||
.form-row input {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
min-height: 68rpx;
|
||||
min-height: 80rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
.password-toggle {
|
||||
display: flex;
|
||||
min-height: 68rpx;
|
||||
min-height: 80rpx;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
width: auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: $brand-red;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
.password-toggle::after {
|
||||
border: 0;
|
||||
}
|
||||
.field-error {
|
||||
display: block;
|
||||
padding-top: 7rpx;
|
||||
|
||||
@@ -16,6 +16,22 @@
|
||||
><text>完成安全验证和短信校验后,即可更新你的登录手机号。</text></view
|
||||
>
|
||||
<view class="form-panel">
|
||||
<view class="field-block">
|
||||
<view class="form-row">
|
||||
<text>当前密码</text>
|
||||
<input
|
||||
v-model="currentPassword"
|
||||
password
|
||||
maxlength="32"
|
||||
aria-label="当前密码"
|
||||
:aria-invalid="Boolean(errors.currentPassword)"
|
||||
:aria-describedby="errors.currentPassword ? 'change-phone-password-error' : undefined"
|
||||
placeholder="请输入当前登录密码"
|
||||
@input="errors.currentPassword = ''"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="errors.currentPassword" id="change-phone-password-error" class="field-error" role="alert">{{ errors.currentPassword }}</text>
|
||||
</view>
|
||||
<view class="field-block">
|
||||
<view class="form-row">
|
||||
<text>新手机号</text>
|
||||
@@ -24,11 +40,13 @@
|
||||
type="number"
|
||||
maxlength="11"
|
||||
aria-label="新手机号"
|
||||
:aria-invalid="Boolean(errors.phone)"
|
||||
:aria-describedby="errors.phone ? 'change-phone-error' : undefined"
|
||||
placeholder="请输入新手机号"
|
||||
@input="handlePhoneInput"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="errors.phone" class="field-error">{{ errors.phone }}</text>
|
||||
<text v-if="errors.phone" id="change-phone-error" class="field-error" role="alert">{{ errors.phone }}</text>
|
||||
</view>
|
||||
<view class="field-block">
|
||||
<view class="form-row form-row--code">
|
||||
@@ -38,6 +56,8 @@
|
||||
type="number"
|
||||
maxlength="4"
|
||||
aria-label="短信验证码"
|
||||
:aria-invalid="Boolean(errors.smsCode)"
|
||||
:aria-describedby="errors.smsCode ? 'change-phone-code-error' : undefined"
|
||||
placeholder="4 位验证码"
|
||||
@input="errors.smsCode = ''"
|
||||
/>
|
||||
@@ -48,7 +68,7 @@
|
||||
@click="prepareGetCode"
|
||||
>{{ cooldownSeconds > 0 ? `${cooldownSeconds}s 后重试` : '获取验证码' }}</button>
|
||||
</view>
|
||||
<text v-if="errors.smsCode" class="field-error">{{ errors.smsCode }}</text>
|
||||
<text v-if="errors.smsCode" id="change-phone-code-error" class="field-error" role="alert">{{ errors.smsCode }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<AppButton
|
||||
@@ -104,16 +124,19 @@ import {
|
||||
} from "@/utils/auth/verification.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import { handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||
import { goRoot, handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||
import { calcMD5 } from "@/utils/md5.js";
|
||||
import { session } from "@/utils/session.js";
|
||||
|
||||
const currentPassword = ref("");
|
||||
const phone = ref("");
|
||||
const smsCode = ref("");
|
||||
const errors = reactive({ phone: "", smsCode: "" });
|
||||
const errors = reactive({ currentPassword: "", phone: "", smsCode: "" });
|
||||
const submittingPhoneChange = ref(false);
|
||||
const discardVisible = ref(false);
|
||||
const toastVisible = ref(false);
|
||||
const toastMessage = ref("");
|
||||
const formSnapshot = computed(() => JSON.stringify({ phone: phone.value, smsCode: smsCode.value }));
|
||||
const formSnapshot = computed(() => JSON.stringify({ currentPassword: currentPassword.value, phone: phone.value, smsCode: smsCode.value }));
|
||||
const baseline = ref(formSnapshot.value);
|
||||
const isDirty = computed(() => formSnapshot.value !== baseline.value);
|
||||
const phoneChangeController = createRequestController();
|
||||
@@ -185,19 +208,24 @@ const prepareGetCode = async () => {
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
errors.currentPassword = currentPassword.value ? "" : "请输入当前密码";
|
||||
errors.phone = isAuthPhone(phone.value) ? "" : "请输入正确手机号";
|
||||
errors.smsCode =
|
||||
sentPhone.value !== phone.value
|
||||
? "请先获取当前手机号的验证码"
|
||||
? "请先获取新手机号的验证码"
|
||||
: /^\d{4}$/.test(smsCode.value)
|
||||
? ""
|
||||
: "请输入 4 位验证码";
|
||||
return !errors.phone && !errors.smsCode;
|
||||
return !errors.currentPassword && !errors.phone && !errors.smsCode;
|
||||
};
|
||||
|
||||
const submitPhoneChange = async () => {
|
||||
if (phoneState.value !== "ready" || !validateForm()) return;
|
||||
const phoneChangePayload = { phone: phone.value, smsCode: smsCode.value };
|
||||
const phoneChangePayload = {
|
||||
phone: phone.value,
|
||||
smsCode: smsCode.value,
|
||||
currentPasswordHash: calcMD5(currentPassword.value),
|
||||
};
|
||||
const phoneChangeAttempt = phoneChangeGuard.begin(phoneChangePayload);
|
||||
if (phoneChangeAttempt === null) {
|
||||
showToast("上次换绑结果暂时无法确认,请重新登录确认手机号,不要重复提交");
|
||||
@@ -210,11 +238,14 @@ const submitPhoneChange = async () => {
|
||||
{ requestController: phoneChangeController },
|
||||
);
|
||||
if (!isPageActive) return;
|
||||
session.clear();
|
||||
await goRoot("A01");
|
||||
if (!isPageActive) return;
|
||||
currentPassword.value = "";
|
||||
phone.value = "";
|
||||
smsCode.value = "";
|
||||
sentPhone.value = "";
|
||||
baseline.value = formSnapshot.value;
|
||||
showToast("手机号换绑成功");
|
||||
} catch (error) {
|
||||
if (!isPageActive) return;
|
||||
if (phoneChangeGuard.recordFailure(phoneChangeAttempt, error)) {
|
||||
@@ -262,7 +293,7 @@ onUnload(() => {
|
||||
}
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 28rpx 30rpx 72rpx;
|
||||
padding: 28rpx 30rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.security-tip,
|
||||
.form-panel {
|
||||
@@ -314,7 +345,7 @@ onUnload(() => {
|
||||
.form-row input {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
min-height: 68rpx;
|
||||
min-height: 80rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
@@ -324,7 +355,7 @@ onUnload(() => {
|
||||
justify-content: center;
|
||||
justify-self: end;
|
||||
width: 198rpx;
|
||||
min-height: 68rpx;
|
||||
min-height: 80rpx;
|
||||
margin: 0;
|
||||
padding: 0 14rpx;
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -131,7 +131,7 @@ onUnload(() => {
|
||||
}
|
||||
.document-content {
|
||||
flex: 1;
|
||||
padding: 24rpx 28rpx 72rpx;
|
||||
padding: 24rpx 28rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.document-sheet,
|
||||
.document-state-card {
|
||||
@@ -219,4 +219,16 @@ onUnload(() => {
|
||||
.document-state-card .app-button {
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
|
||||
@media (max-width: 340px) {
|
||||
.document-content {
|
||||
padding-right: 22rpx;
|
||||
padding-left: 22rpx;
|
||||
}
|
||||
|
||||
.document-sheet {
|
||||
padding-right: 28rpx;
|
||||
padding-left: 28rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -84,7 +84,11 @@
|
||||
<text>{{ recordTimeLabel(withdrawal.createTime) }}</text>
|
||||
</view>
|
||||
<text>{{ withdrawalStatusLabel(withdrawal.withdrawalStatus) }}</text>
|
||||
<text v-if="withdrawal.withdrawalNo">提现单号:{{ withdrawal.withdrawalNo }}</text>
|
||||
<text>收款人:{{ withdrawal.payoutAccountName || '未填写' }}</text>
|
||||
<text v-if="withdrawal.auditRemark">审核备注:{{ withdrawal.auditRemark }}</text>
|
||||
<text v-if="withdrawal.payoutReference">打款参考号:{{ withdrawal.payoutReference }}</text>
|
||||
<text v-if="withdrawal.paidAt">到账时间:{{ recordTimeLabel(withdrawal.paidAt) }}</text>
|
||||
<text v-if="withdrawal.failureReason">原因:{{ withdrawal.failureReason }}</text>
|
||||
<AppButton
|
||||
v-if="withdrawal.withdrawalStatus === 'PENDING'"
|
||||
@@ -121,7 +125,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import { onBackPress, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
@@ -137,6 +141,7 @@ import {
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { earningApi } from "@/services/api/earning-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { handleBackPress } from "@/utils/navigation/gateway.js";
|
||||
import { formatMinuteTimestamp } from "@/utils/display-time.js";
|
||||
import {
|
||||
formatSignedMoney,
|
||||
@@ -257,10 +262,18 @@ const cancelWithdrawal = async () => {
|
||||
if (isPageActive) cancelling.value = false;
|
||||
}
|
||||
};
|
||||
const closeCancellationDialog = () => {
|
||||
if (cancelling.value) return false;
|
||||
cancelTarget.value = null;
|
||||
return true;
|
||||
};
|
||||
|
||||
onShow(() => {
|
||||
void loadAll();
|
||||
});
|
||||
onBackPress((event) =>
|
||||
cancelTarget.value ? handleBackPress(event, closeCancellationDialog) : false,
|
||||
);
|
||||
onUnload(() => {
|
||||
isPageActive = false;
|
||||
summaryController.abort();
|
||||
@@ -275,7 +288,7 @@ onUnload(() => {
|
||||
.earnings-page {
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
padding-bottom: 48rpx;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
background: $paper;
|
||||
}
|
||||
|
||||
@@ -334,6 +347,11 @@ onUnload(() => {
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
|
||||
.summary-row text {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.summary-note {
|
||||
margin: 18rpx 0;
|
||||
line-height: 1.6;
|
||||
@@ -442,4 +460,17 @@ onUnload(() => {
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
|
||||
@media (max-width: 340px) {
|
||||
.earnings-content {
|
||||
padding-right: 20rpx;
|
||||
padding-left: 20rpx;
|
||||
}
|
||||
|
||||
.summary-panel,
|
||||
.record-card {
|
||||
padding-right: 20rpx;
|
||||
padding-left: 20rpx;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -109,14 +109,27 @@
|
||||
</template>
|
||||
</view>
|
||||
<AppToast :visible="feedbackVisible" :message="feedbackMessage" />
|
||||
<AppDialog
|
||||
:visible="discardVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="放弃确认"
|
||||
title="放弃资料修改?"
|
||||
message="当前修改还没有保存。"
|
||||
confirm-text="确认放弃"
|
||||
cancel-text="继续编辑"
|
||||
show-cancel
|
||||
@confirm="confirmDiscard"
|
||||
@cancel="cancelDiscard"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
import AppAvatar from "@/components/AppAvatar.vue";
|
||||
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";
|
||||
@@ -129,11 +142,16 @@ import {
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { profileApi } from "@/services/api/profile-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import {
|
||||
isImagePickCancelled,
|
||||
pickAndUploadImage,
|
||||
} from "@/utils/media-upload.js";
|
||||
import { finishPage, returnTo } from "@/utils/navigation/gateway.js";
|
||||
import {
|
||||
finishPage,
|
||||
handleBackPress,
|
||||
runBackGuard,
|
||||
} from "@/utils/navigation/gateway.js";
|
||||
|
||||
const form = reactive({
|
||||
nickName: "",
|
||||
@@ -162,11 +180,18 @@ const saveError = ref("");
|
||||
const committedProfilePayload = ref("");
|
||||
const feedbackVisible = ref(false);
|
||||
const feedbackMessage = ref("");
|
||||
const discardVisible = ref(false);
|
||||
const profileReadController = createRequestController();
|
||||
const avatarUploadController = createRequestController();
|
||||
const profileSaveController = createRequestController();
|
||||
let feedbackTimer = null;
|
||||
let isPageActive = true;
|
||||
const discardConfirmation = createDiscardConfirmation((visible) => {
|
||||
discardVisible.value = visible;
|
||||
});
|
||||
const requestDiscardConfirmation = discardConfirmation.request;
|
||||
const confirmDiscard = discardConfirmation.confirm;
|
||||
const cancelDiscard = discardConfirmation.cancel;
|
||||
const sexOptions = Object.freeze([
|
||||
Object.freeze({ value: "", label: "请选择" }),
|
||||
...PROFILE_SEX_OPTIONS,
|
||||
@@ -181,6 +206,16 @@ const avatarMessage = computed(() => {
|
||||
if (avatarFileName.value) return `已选择 ${avatarFileName.value}`;
|
||||
return avatarPreviewUrl.value ? "当前头像已设置" : "当前使用默认头像";
|
||||
});
|
||||
const profileSnapshot = computed(() => JSON.stringify({
|
||||
nickName: form.nickName,
|
||||
realName: form.realName,
|
||||
sex: form.sex,
|
||||
birthday: form.birthday,
|
||||
email: form.email,
|
||||
avatar: avatarId.value || original.avatar || null,
|
||||
}));
|
||||
const originalSnapshot = computed(() => JSON.stringify(original));
|
||||
const isDirty = computed(() => profileSnapshot.value !== originalSnapshot.value);
|
||||
|
||||
const showFeedback = (message) => {
|
||||
feedbackMessage.value = message;
|
||||
@@ -291,6 +326,15 @@ const saveProfile = async () => {
|
||||
});
|
||||
if (!isPageActive) return;
|
||||
committedProfilePayload.value = payloadFingerprint;
|
||||
for (const field of ["nickName", "realName", "sex", "birthday", "email"]) {
|
||||
if (Object.prototype.hasOwnProperty.call(payload, field)) {
|
||||
original[field] = payload[field];
|
||||
}
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "avatar")) {
|
||||
original.avatar = payload.avatar;
|
||||
avatarId.value = null;
|
||||
}
|
||||
}
|
||||
await finishPage(
|
||||
"M01",
|
||||
@@ -307,18 +351,24 @@ const saveProfile = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const backToProfile = () => {
|
||||
if (saving.value || uploading.value) return;
|
||||
return returnTo("M01");
|
||||
};
|
||||
const backToProfile = () => runBackGuard({
|
||||
transientOpen: discardVisible.value,
|
||||
dirty: isDirty.value,
|
||||
submitting: saving.value || uploading.value,
|
||||
"close-transient": cancelDiscard,
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": requestDiscardConfirmation,
|
||||
});
|
||||
|
||||
onLoad(loadProfile);
|
||||
onBackPress((event) => handleBackPress(event, backToProfile));
|
||||
onUnload(() => {
|
||||
isPageActive = false;
|
||||
profileReadController.abort();
|
||||
avatarUploadController.abort();
|
||||
profileSaveController.abort();
|
||||
if (feedbackTimer) clearTimeout(feedbackTimer);
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -336,7 +386,7 @@ onUnload(() => {
|
||||
}
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 24rpx 30rpx 72rpx;
|
||||
padding: 24rpx 30rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.state-card,
|
||||
.form-panel {
|
||||
@@ -435,7 +485,7 @@ onUnload(() => {
|
||||
.picker-value {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
min-height: 68rpx;
|
||||
min-height: 80rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
text-align: right;
|
||||
|
||||
+26
-14
@@ -122,7 +122,7 @@
|
||||
class="history-card"
|
||||
>
|
||||
<view class="history-card__meta">
|
||||
<text>{{ feedbackTypeLabel(feedback.feedbackType) }} · {{ feedbackReference(feedback) }}</text>
|
||||
<text>{{ feedbackTypeLabel(feedback) }} · {{ feedbackReference(feedback) }}</text>
|
||||
<text class="history-status">{{ feedbackStatusLabel(feedback.handleStatus) }}</text>
|
||||
</view>
|
||||
<text
|
||||
@@ -171,9 +171,7 @@ 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 {
|
||||
FEEDBACK_TYPE_OPTIONS as feedbackTypes
|
||||
} from "@/services/api/feedback-contract.js";
|
||||
import { businessDictionaryApi } from "@/services/api/business-dictionary-service.js";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
@@ -190,9 +188,11 @@ const feedbackStatusLabels = {
|
||||
2: "已处理",
|
||||
3: "暂不处理",
|
||||
};
|
||||
const feedbackTypeLabel = (feedbackTypeValue) =>
|
||||
feedbackTypes.find(
|
||||
(feedbackType) => feedbackType.value === feedbackTypeValue,
|
||||
const feedbackTypes = ref([]);
|
||||
const feedbackTypeLabel = (feedback) =>
|
||||
String(feedback?.feedbackTypeLabel || "").trim() ||
|
||||
feedbackTypes.value.find(
|
||||
(feedbackType) => feedbackType.value === feedback?.feedbackType,
|
||||
)?.label || "其他";
|
||||
const feedbackStatusLabel = (handleStatus) =>
|
||||
feedbackStatusLabels[String(handleStatus)] || "状态待确认";
|
||||
@@ -234,6 +234,7 @@ const toggleFeedbackExpanded = (feedback) => {
|
||||
let pageActive = true;
|
||||
const feedbackHistoryRequestController = createRequestController();
|
||||
const feedbackSubmissionRequestController = createRequestController();
|
||||
const feedbackTypeRequestController = createRequestController();
|
||||
const submissionSession = createFeedbackSubmissionSession(feedbackForm);
|
||||
const syncSubmissionView = () => {
|
||||
const submissionView = submissionSession.view(feedbackForm);
|
||||
@@ -261,6 +262,13 @@ const loadFeedbackHistory = async () => {
|
||||
);
|
||||
}
|
||||
};
|
||||
const loadFeedbackTypes = async () => {
|
||||
feedbackTypeRequestController.abort();
|
||||
feedbackTypes.value = await businessDictionaryApi.getBusinessDictionaryOptions(
|
||||
"gen_feedback_type",
|
||||
{ requestController: feedbackTypeRequestController },
|
||||
);
|
||||
};
|
||||
const discardConfirmation = createDiscardConfirmation((visible) => {
|
||||
discardVisible.value = visible;
|
||||
});
|
||||
@@ -325,11 +333,15 @@ const requestBack = () =>
|
||||
"confirm-discard": requestDiscardConfirmation,
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onLoad(loadFeedbackHistory);
|
||||
onLoad(() => {
|
||||
void loadFeedbackTypes().catch(() => { feedbackTypes.value = []; });
|
||||
void loadFeedbackHistory();
|
||||
});
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
feedbackHistoryRequestController.abort();
|
||||
feedbackSubmissionRequestController.abort();
|
||||
feedbackTypeRequestController.abort();
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
@@ -347,7 +359,7 @@ onUnload(() => {
|
||||
}
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 26rpx 30rpx 72rpx;
|
||||
padding: 26rpx 30rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.lead {
|
||||
display: block;
|
||||
@@ -477,7 +489,7 @@ onUnload(() => {
|
||||
}
|
||||
.history-retry {
|
||||
min-width: 180rpx;
|
||||
min-height: 72rpx;
|
||||
min-height: 80rpx;
|
||||
margin: 0;
|
||||
padding: 0 28rpx;
|
||||
border: 1px solid rgba(164, 41, 36, 0.55);
|
||||
@@ -485,7 +497,7 @@ onUnload(() => {
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 16px);
|
||||
line-height: 72rpx;
|
||||
line-height: 80rpx;
|
||||
}
|
||||
.history-list {
|
||||
display: grid;
|
||||
@@ -523,14 +535,14 @@ onUnload(() => {
|
||||
-webkit-line-clamp: 4;
|
||||
}
|
||||
.history-expand {
|
||||
min-height: 72rpx;
|
||||
min-height: 80rpx;
|
||||
margin: 4rpx 0 0 auto;
|
||||
padding: 0 8rpx;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: $brand-red;
|
||||
font-size: clamp(13px, 20rpx, 15px);
|
||||
line-height: 72rpx;
|
||||
line-height: 80rpx;
|
||||
}
|
||||
.history-expand::after {
|
||||
border: 0;
|
||||
@@ -559,7 +571,7 @@ onUnload(() => {
|
||||
.contact-row input {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
min-height: 68rpx;
|
||||
min-height: 80rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
text-align: right;
|
||||
|
||||
+17
-10
@@ -170,7 +170,7 @@ onUnload(() => {
|
||||
}
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 24rpx 28rpx 72rpx;
|
||||
padding: 24rpx 28rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.help-hero,
|
||||
.help-state-card,
|
||||
@@ -225,7 +225,7 @@ onUnload(() => {
|
||||
}
|
||||
.help-search {
|
||||
width: 100%;
|
||||
min-height: 72rpx;
|
||||
min-height: 80rpx;
|
||||
box-sizing: border-box;
|
||||
padding: 0 22rpx;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.28);
|
||||
@@ -245,13 +245,13 @@ onUnload(() => {
|
||||
.help-category {
|
||||
display: inline-flex;
|
||||
min-width: 112rpx;
|
||||
min-height: 58rpx;
|
||||
min-height: 80rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 12rpx;
|
||||
padding: 0 20rpx;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.24);
|
||||
border-radius: 29rpx;
|
||||
border-radius: 40rpx;
|
||||
background: rgba(255, 252, 245, 0.54);
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 22rpx, 16px);
|
||||
@@ -263,20 +263,21 @@ onUnload(() => {
|
||||
border: 0;
|
||||
}
|
||||
.help-category--active {
|
||||
border-color: #8d2722;
|
||||
background: #8d2722;
|
||||
border-color: $brand-red;
|
||||
background: $brand-red;
|
||||
color: #fff7e8;
|
||||
}
|
||||
.help-article-list {
|
||||
display: grid;
|
||||
gap: 14rpx;
|
||||
width: 100%;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.help-article {
|
||||
overflow: hidden;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.22);
|
||||
border-radius: 12rpx;
|
||||
background: rgba(255, 252, 245, 0.66);
|
||||
background: rgba(255, 252, 245, 0.88);
|
||||
}
|
||||
.help-article__question {
|
||||
display: flex;
|
||||
@@ -311,12 +312,12 @@ onUnload(() => {
|
||||
}
|
||||
.help-article__indicator {
|
||||
flex: 0 0 auto;
|
||||
color: #9f170f;
|
||||
color: $brand-red-dark;
|
||||
font-size: clamp(12px, 20rpx, 15px);
|
||||
}
|
||||
.help-article__answer {
|
||||
display: block;
|
||||
padding: 0 22rpx 24rpx;
|
||||
padding: 16rpx 22rpx 24rpx;
|
||||
border-top: 1rpx solid rgba(128, 89, 49, 0.16);
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
@@ -336,7 +337,7 @@ onUnload(() => {
|
||||
margin-top: 16rpx;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #9f170f;
|
||||
color: $brand-red-dark;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
.help-feedback-card {
|
||||
@@ -344,6 +345,12 @@ onUnload(() => {
|
||||
padding: 32rpx 34rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.help-feedback-card text {
|
||||
display: block;
|
||||
}
|
||||
.help-feedback-card text:first-child {
|
||||
font-size: clamp(17px, 28rpx, 20px);
|
||||
}
|
||||
@media (max-width: 340px) {
|
||||
.page-content {
|
||||
padding-right: 20rpx;
|
||||
|
||||
+34
-1
@@ -594,7 +594,7 @@ onUnload(() => {
|
||||
top: 337rpx;
|
||||
right: 61rpx;
|
||||
width: 154rpx;
|
||||
min-height: 52rpx;
|
||||
min-height: 72rpx;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
@@ -814,4 +814,37 @@ onUnload(() => {
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
@media (max-width: 340px) {
|
||||
.profile-hero__content {
|
||||
padding-right: 42rpx;
|
||||
padding-left: 42rpx;
|
||||
}
|
||||
|
||||
.profile-hero__emblem {
|
||||
width: 176rpx;
|
||||
height: 176rpx;
|
||||
}
|
||||
|
||||
.profile-metadata {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.profile-metadata__item:first-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.profile-metadata__item + .profile-metadata__item {
|
||||
border-top: 1rpx solid rgba(117, 83, 52, 0.16);
|
||||
}
|
||||
|
||||
.profile-metadata__item--email {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.profile-services {
|
||||
margin-right: 38rpx;
|
||||
margin-left: 38rpx;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -11,6 +11,31 @@
|
||||
<text>家谱服务推荐</text>
|
||||
<text>这里会展示家谱相关服务和活动。</text>
|
||||
</view>
|
||||
<text v-if="operationFeedback" class="promotion-feedback" role="status">{{ operationFeedback }}</text>
|
||||
<view class="referral-card">
|
||||
<view class="referral-card__heading">
|
||||
<text>邀请家人注册</text>
|
||||
<text>推荐关系由注册接口一次性确认,前端不会在注册后补绑。</text>
|
||||
</view>
|
||||
<AppLoading v-if="referralState === 'loading'" text="正在读取我的推荐码" />
|
||||
<view v-else-if="referralState === 'error'" class="referral-card__state">
|
||||
<text>{{ referralError || "推荐码暂时无法读取。" }}</text>
|
||||
<AppButton compact type="secondary" label="重试" @click="loadReferralProfile" />
|
||||
</view>
|
||||
<view v-else-if="referralProfile.enabled" class="referral-card__content">
|
||||
<text class="referral-card__code">{{ referralProfile.referralCode }}</text>
|
||||
<text>已成功邀请 {{ referralProfile.referredUserCount }} 人</text>
|
||||
<text>{{ referralProfile.shareDescription || "家人通过此链接注册后,系统会记录推荐关系。" }}</text>
|
||||
<view class="referral-card__actions">
|
||||
<AppButton compact type="secondary" label="复制推荐码" @click="copyReferralCode" />
|
||||
<AppButton compact type="secondary" label="复制推荐链接" @click="copyReferralLink" />
|
||||
<AppButton compact label="分享给家人" @click="shareReferral" />
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="referral-card__state">
|
||||
<text>{{ referralProfile.disabledReason || "推荐功能暂未开放。" }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="promotionListState === 'loading'" class="promotion-state-card">
|
||||
<AppLoading text="正在读取推广内容" />
|
||||
</view>
|
||||
@@ -24,7 +49,6 @@
|
||||
<text>后续正式发布的家谱服务推荐会在这里展示。</text>
|
||||
</view>
|
||||
<view v-else class="promotion-list">
|
||||
<text v-if="operationFeedback" class="promotion-feedback" role="status">{{ operationFeedback }}</text>
|
||||
<view
|
||||
v-for="item in promotions"
|
||||
:key="item.id"
|
||||
@@ -42,7 +66,7 @@
|
||||
/>
|
||||
<text class="promotion-card__title">{{ item.title }}</text>
|
||||
<text v-if="item.description" class="promotion-card__description">{{ item.description }}</text>
|
||||
<view v-if="item.targetUrl" class="promotion-card__actions">
|
||||
<view v-if="item.targetUrl" class="promotion-card__actions" @click.stop>
|
||||
<AppButton compact type="secondary" label="复制链接" @click.stop="copyPromotionLink(item)" />
|
||||
<AppButton compact label="分享给家人" @click.stop="sharePromotion(item)" />
|
||||
</view>
|
||||
@@ -63,6 +87,7 @@ import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { referralApi } from "@/services/api/referral-service.js";
|
||||
import { siteContentApi } from "@/services/api/site-content-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { goBack, handleBackPress, openSiteContentTarget } from "@/utils/navigation/gateway.js";
|
||||
@@ -71,9 +96,39 @@ const promotions = ref([]);
|
||||
const promotionListState = ref("loading");
|
||||
const promotionListError = ref("");
|
||||
const operationFeedback = ref("");
|
||||
const referralState = ref("loading");
|
||||
const referralError = ref("");
|
||||
const referralProfile = ref({
|
||||
enabled: false,
|
||||
disabledReason: "",
|
||||
referralCode: "",
|
||||
shareTitle: "",
|
||||
shareDescription: "",
|
||||
shareUrl: "",
|
||||
referredUserCount: 0,
|
||||
});
|
||||
const promotionListRequestController = createRequestController();
|
||||
const referralRequestController = createRequestController();
|
||||
let pageActive = true;
|
||||
|
||||
const loadReferralProfile = async () => {
|
||||
referralRequestController.abort();
|
||||
referralState.value = "loading";
|
||||
referralError.value = "";
|
||||
try {
|
||||
const profile = await referralApi.getMyReferralProfile({
|
||||
requestController: referralRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
referralProfile.value = profile;
|
||||
referralState.value = "ready";
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
referralError.value = getRequestErrorMessage(error, "推荐码暂时无法读取,请稍后重试。");
|
||||
referralState.value = "error";
|
||||
}
|
||||
};
|
||||
|
||||
const loadPromotions = async () => {
|
||||
promotionListRequestController.abort();
|
||||
promotionListState.value = "loading";
|
||||
@@ -117,6 +172,39 @@ const copyPromotionLink = (item) => {
|
||||
fail: () => { operationFeedback.value = "复制失败,请稍后再试。"; },
|
||||
});
|
||||
};
|
||||
const copyText = (content, successMessage) => {
|
||||
if (!content || typeof uni?.setClipboardData !== "function") {
|
||||
operationFeedback.value = "当前设备暂时不能复制。";
|
||||
return;
|
||||
}
|
||||
uni.setClipboardData({
|
||||
data: content,
|
||||
success: () => { operationFeedback.value = successMessage; },
|
||||
fail: () => { operationFeedback.value = "复制失败,请稍后再试。"; },
|
||||
});
|
||||
};
|
||||
const copyReferralCode = () =>
|
||||
copyText(referralProfile.value.referralCode, "推荐码已复制,可以发给家人。");
|
||||
const copyReferralLink = () =>
|
||||
copyText(referralProfile.value.shareUrl, "推荐链接已复制,可以发给家人。");
|
||||
const shareReferral = () => {
|
||||
const profile = referralProfile.value;
|
||||
if (!profile.enabled || !profile.shareUrl) return;
|
||||
const content = [profile.shareTitle, profile.shareDescription, profile.shareUrl]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
// #ifdef APP-PLUS
|
||||
if (typeof plus?.share?.sendWithSystem === "function") {
|
||||
plus.share.sendWithSystem(
|
||||
{ type: "text", content },
|
||||
() => { operationFeedback.value = "已打开系统分享。"; },
|
||||
() => { operationFeedback.value = "已取消分享。"; },
|
||||
);
|
||||
return;
|
||||
}
|
||||
// #endif
|
||||
copyReferralLink();
|
||||
};
|
||||
const sharePromotion = (item) => {
|
||||
if (!item?.targetUrl) return;
|
||||
const content = [item.title, item.description, item.targetUrl].filter(Boolean).join("\n");
|
||||
@@ -142,11 +230,15 @@ const sharePromotion = (item) => {
|
||||
};
|
||||
|
||||
const requestBack = () => goBack();
|
||||
onLoad(loadPromotions);
|
||||
onLoad(() => {
|
||||
void loadReferralProfile();
|
||||
void loadPromotions();
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
promotionListRequestController.abort();
|
||||
referralRequestController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -163,9 +255,10 @@ onUnload(() => {
|
||||
}
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 28rpx 30rpx 72rpx;
|
||||
padding: 28rpx 30rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.promotion-hero,
|
||||
.referral-card,
|
||||
.promotion-state-card,
|
||||
.promotion-list {
|
||||
@include adaptive-profile-content;
|
||||
@@ -211,8 +304,25 @@ onUnload(() => {
|
||||
padding: 38rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.referral-card {
|
||||
margin-top: 20rpx;
|
||||
padding: 28rpx;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.26);
|
||||
border-radius: 12rpx;
|
||||
background: rgba(255, 252, 245, 0.82);
|
||||
}
|
||||
.referral-card text { display: block; }
|
||||
.referral-card__heading text:first-child { color: $ink; font-size: clamp(17px, 29rpx, 21px); font-weight: 700; }
|
||||
.referral-card__heading text:last-child,
|
||||
.referral-card__content > text:last-of-type,
|
||||
.referral-card__state > text { margin-top: 8rpx; color: $ink-muted; font-size: clamp(13px, 22rpx, 16px); line-height: 1.55; }
|
||||
.referral-card__content,
|
||||
.referral-card__state { margin-top: 22rpx; }
|
||||
.referral-card__code { color: #9e251b; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: clamp(22px, 40rpx, 30px); font-weight: 700; letter-spacing: 3rpx; }
|
||||
.referral-card__actions { display: flex; flex-wrap: wrap; justify-content: flex-end; margin-top: 22rpx; gap: 12rpx; }
|
||||
.referral-card__actions .app-button { width: auto; min-width: 150rpx; }
|
||||
.promotion-state-card .app-button { margin-top: 28rpx; }
|
||||
.promotion-list { display: grid; gap: 16rpx; }
|
||||
.promotion-list { display: grid; gap: 18rpx; margin-top: 20rpx; }
|
||||
.promotion-card {
|
||||
overflow: hidden;
|
||||
padding: 26rpx 28rpx;
|
||||
@@ -230,7 +340,7 @@ onUnload(() => {
|
||||
.promotion-card text { display: block; }
|
||||
.promotion-card__title { color: $ink; font-size: clamp(16px, 28rpx, 20px); font-weight: 700; }
|
||||
.promotion-card__description { margin-top: 10rpx; color: $ink-muted; font-size: clamp(14px, 23rpx, 17px); line-height: 1.6; }
|
||||
.promotion-feedback { display: block; padding: 16rpx 20rpx; border: 1rpx solid rgba(66, 107, 88, .32); border-radius: 10rpx; background: rgba(66, 107, 88, .08); color: #426b58; font-size: clamp(14px, 22rpx, 17px); }
|
||||
.promotion-feedback { display: block; margin-top: 16rpx; padding: 16rpx 20rpx; border: 1rpx solid rgba(66, 107, 88, .32); border-radius: 10rpx; background: rgba(66, 107, 88, .08); color: #426b58; font-size: clamp(14px, 22rpx, 17px); line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.promotion-card__actions { display: flex; flex-wrap: wrap; justify-content: flex-end; margin-top: 16rpx; gap: 12rpx; }
|
||||
.promotion-card__actions .app-button { width: auto; min-width: 142rpx; }
|
||||
@media (max-width: 340px) {
|
||||
@@ -238,5 +348,10 @@ onUnload(() => {
|
||||
padding-right: 22rpx;
|
||||
padding-left: 22rpx;
|
||||
}
|
||||
|
||||
.promotion-card__actions .app-button {
|
||||
min-width: 0;
|
||||
flex: 1 1 220rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+98
-11
@@ -21,9 +21,6 @@
|
||||
><text>账号编号</text
|
||||
><text>{{ profile.userNo || "未提供" }}</text></view
|
||||
>
|
||||
<text class="security-note"
|
||||
>这里显示可查看的账号信息,其他安全信息暂不支持查看。</text
|
||||
>
|
||||
<AppButton block label="修改密码" @click="openPassword" />
|
||||
<AppButton
|
||||
type="secondary"
|
||||
@@ -31,6 +28,14 @@
|
||||
label="换绑手机号"
|
||||
@click="openPhone"
|
||||
/>
|
||||
<AppButton
|
||||
type="secondary"
|
||||
block
|
||||
:disabled="wechatProviderState !== 'ready' || bindingWechat"
|
||||
:label="bindingWechat ? '正在绑定微信…' : '绑定微信'"
|
||||
@click="bindWechat"
|
||||
/>
|
||||
<text v-if="wechatBindingMessage" class="security-message">{{ wechatBindingMessage }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -48,6 +53,7 @@ import {
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { profileApi } from "@/services/api/profile-service.js";
|
||||
import { authApi } from "@/services/api/auth-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { openPage, returnTo } from "@/utils/navigation/gateway.js";
|
||||
|
||||
@@ -55,6 +61,10 @@ const profile = reactive({ phone: "", userNo: "" });
|
||||
const loading = ref(false);
|
||||
const securityProfileError = ref("");
|
||||
const securityProfileRequestController = createRequestController();
|
||||
const wechatBindingRequestController = createRequestController();
|
||||
const wechatProviderState = ref("unknown");
|
||||
const bindingWechat = ref(false);
|
||||
const wechatBindingMessage = ref("");
|
||||
let pageActive = true;
|
||||
const maskedPhone = computed(() =>
|
||||
/^\d{7,}$/.test(profile.phone)
|
||||
@@ -81,10 +91,72 @@ const loadProfile = async () => {
|
||||
const backToProfile = () => returnTo("M01");
|
||||
const openPassword = () => openPage("M04", {}, "M03");
|
||||
const openPhone = () => openPage("M05", {}, "M03");
|
||||
onShow(loadProfile);
|
||||
const detectWechatProvider = () => {
|
||||
// #ifdef APP-PLUS
|
||||
if (typeof uni?.getProvider !== "function") {
|
||||
wechatProviderState.value = "unavailable";
|
||||
wechatBindingMessage.value = "当前设备不支持微信授权";
|
||||
return;
|
||||
}
|
||||
uni.getProvider({
|
||||
service: "oauth",
|
||||
success: ({ provider = [] } = {}) => {
|
||||
if (!pageActive) return;
|
||||
wechatProviderState.value = provider.includes("weixin") ? "ready" : "unavailable";
|
||||
if (wechatProviderState.value === "unavailable") {
|
||||
wechatBindingMessage.value = "当前设备未安装或未配置微信授权";
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
if (!pageActive) return;
|
||||
wechatProviderState.value = "unavailable";
|
||||
wechatBindingMessage.value = "暂时无法使用微信授权";
|
||||
},
|
||||
});
|
||||
// #endif
|
||||
// #ifndef APP-PLUS
|
||||
wechatProviderState.value = "unavailable";
|
||||
wechatBindingMessage.value = "请在 App 中绑定微信";
|
||||
// #endif
|
||||
};
|
||||
const requestWechatAuthorizationCode = () =>
|
||||
new Promise((resolve, reject) => {
|
||||
uni.login({
|
||||
provider: "weixin",
|
||||
onlyAuthorize: true,
|
||||
success: ({ code } = {}) => resolve(code),
|
||||
fail: reject,
|
||||
});
|
||||
});
|
||||
const bindWechat = async () => {
|
||||
if (bindingWechat.value || wechatProviderState.value !== "ready") return;
|
||||
bindingWechat.value = true;
|
||||
wechatBindingMessage.value = "";
|
||||
try {
|
||||
const code = await requestWechatAuthorizationCode();
|
||||
await authApi.bindWechat(
|
||||
{ code },
|
||||
{ requestController: wechatBindingRequestController },
|
||||
);
|
||||
if (pageActive) wechatBindingMessage.value = "微信绑定成功";
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
const errorText = String(error?.errMsg || error?.message || "");
|
||||
wechatBindingMessage.value = /cancel|取消/i.test(errorText)
|
||||
? "已取消微信绑定"
|
||||
: getRequestErrorMessage(error, "微信绑定失败,请稍后重试");
|
||||
} finally {
|
||||
if (pageActive) bindingWechat.value = false;
|
||||
}
|
||||
};
|
||||
onShow(() => {
|
||||
void loadProfile();
|
||||
detectWechatProvider();
|
||||
});
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
securityProfileRequestController.abort();
|
||||
wechatBindingRequestController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -101,7 +173,8 @@ onUnload(() => {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
flex: 1;
|
||||
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.state-card,
|
||||
.security-card {
|
||||
@@ -155,13 +228,27 @@ onUnload(() => {
|
||||
text-align: right;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.security-note {
|
||||
margin-top: 22rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.security-card .app-button {
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
.security-message {
|
||||
margin-top: 16rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 340px) {
|
||||
.page-content {
|
||||
padding-right: 20rpx;
|
||||
padding-left: 20rpx;
|
||||
}
|
||||
|
||||
.state-card,
|
||||
.security-card {
|
||||
padding-right: 30rpx;
|
||||
padding-left: 30rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -399,7 +399,7 @@ onUnload(() => {
|
||||
}
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 28rpx 30rpx 72rpx;
|
||||
padding: 28rpx 30rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.brand-card {
|
||||
@include adaptive-profile-summary;
|
||||
@@ -513,7 +513,7 @@ onUnload(() => {
|
||||
}
|
||||
.deactivate-link {
|
||||
width: auto;
|
||||
min-height: 54rpx;
|
||||
min-height: 80rpx;
|
||||
margin: 16rpx 0 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
@@ -550,14 +550,14 @@ onUnload(() => {
|
||||
.form-row input {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
min-height: 68rpx;
|
||||
min-height: 80rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
.code-action {
|
||||
justify-self: end;
|
||||
width: 198rpx;
|
||||
min-height: 68rpx;
|
||||
min-height: 80rpx;
|
||||
margin: 0;
|
||||
padding: 0 14rpx;
|
||||
box-sizing: border-box;
|
||||
|
||||
+235
-6
@@ -36,6 +36,27 @@
|
||||
<text>可选套餐</text>
|
||||
<text>{{ capability.enabled ? "购买资格已开放" : "当前仅供查看" }}</text>
|
||||
</view>
|
||||
<view v-if="capability.enabled" class="payment-methods">
|
||||
<text class="payment-methods__title">支付方式</text>
|
||||
<view class="payment-methods__options" role="radiogroup" aria-label="支付方式">
|
||||
<button
|
||||
v-for="method in paymentMethodOptions"
|
||||
:key="method.method"
|
||||
class="payment-method"
|
||||
:class="{ 'payment-method--selected': selectedPaymentMethod === method.method }"
|
||||
:disabled="!method.available || paymentState === 'submitting'"
|
||||
role="radio"
|
||||
:aria-checked="selectedPaymentMethod === method.method"
|
||||
@click="selectPaymentMethod(method)"
|
||||
>
|
||||
<text>{{ method.label }}</text>
|
||||
<text v-if="!method.available">{{ method.unavailableReason }}</text>
|
||||
</button>
|
||||
</view>
|
||||
<text v-if="!selectedPaymentMethod" class="payment-methods__error"
|
||||
>当前安装包没有可用支付通道,请完成打包配置后再购买。</text
|
||||
>
|
||||
</view>
|
||||
<view v-if="packages.length" class="vip-package-list">
|
||||
<view v-for="item in packages" :key="item.key" class="vip-package">
|
||||
<view class="vip-package__copy">
|
||||
@@ -46,6 +67,13 @@
|
||||
<text>¥{{ item.price }}</text>
|
||||
<text v-if="item.originalPrice">原价 ¥{{ item.originalPrice }}</text>
|
||||
</view>
|
||||
<AppButton
|
||||
v-if="capability.enabled"
|
||||
compact
|
||||
label="立即购买"
|
||||
:disabled="paymentState === 'submitting' || !selectedPaymentMethod"
|
||||
@click="purchasePackage(item)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="vip-empty-state"><text>当前没有可展示的套餐</text></view>
|
||||
@@ -60,7 +88,9 @@
|
||||
<view v-for="item in orders" :key="item.key" class="vip-order">
|
||||
<view>
|
||||
<text>{{ item.packageName }}</text>
|
||||
<text v-if="item.paidAt || item.expiresAt">{{ item.paidAt || item.expiresAt }}</text>
|
||||
<text v-if="item.orderNo">订单号:{{ item.orderNo }}</text>
|
||||
<text v-if="item.paidAt">支付时间:{{ item.paidAt }}</text>
|
||||
<text v-if="item.expiresAt">到期时间:{{ item.expiresAt }}</text>
|
||||
</view>
|
||||
<view>
|
||||
<text>¥{{ item.amount }}</text>
|
||||
@@ -98,27 +128,67 @@ import {
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { vipApi } from "@/services/api/vip-service.js";
|
||||
import { VIP_PAYMENT_METHOD } from "@/services/api/vip-contract.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { genealogyContext } from "@/utils/genealogy/context.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import { handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const packages = ref([]);
|
||||
const orders = ref([]);
|
||||
const capability = reactive({ enabled: false, disabledReason: "" });
|
||||
const capability = reactive({ enabled: false, disabledReason: "", paymentMethods: [] });
|
||||
const nativePaymentProviders = ref([]);
|
||||
const selectedPaymentMethod = ref("");
|
||||
const readState = ref("loading");
|
||||
const vipReadError = ref("");
|
||||
const serviceNoticeVisible = ref(false);
|
||||
const paymentState = ref("idle");
|
||||
const packageController = createRequestController();
|
||||
const orderController = createRequestController();
|
||||
const capabilityController = createRequestController();
|
||||
const paymentController = createRequestController();
|
||||
const orderCreationGuard = createNonIdempotentWriteGuard();
|
||||
let active = true;
|
||||
const createVipRequestId = () => {
|
||||
if (typeof globalThis.crypto?.randomUUID === "function") {
|
||||
return `app-vip-${globalThis.crypto.randomUUID()}`;
|
||||
}
|
||||
return `app-vip-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
};
|
||||
const paymentProviderByMethod = Object.freeze({
|
||||
[VIP_PAYMENT_METHOD.WECHAT]: "wxpay",
|
||||
[VIP_PAYMENT_METHOD.ALIPAY]: "alipay",
|
||||
});
|
||||
const paymentMethodOptions = computed(() =>
|
||||
capability.paymentMethods.map((method) => {
|
||||
const requiredProvider = paymentProviderByMethod[method.method];
|
||||
const clientAvailable =
|
||||
!requiredProvider || nativePaymentProviders.value.includes(requiredProvider);
|
||||
return {
|
||||
...method,
|
||||
available: method.enabled && clientAvailable,
|
||||
unavailableReason: method.enabled
|
||||
? "当前安装包未配置此通道"
|
||||
: method.disabledReason || "服务端暂未开放",
|
||||
};
|
||||
}),
|
||||
);
|
||||
const selectedPaymentMethodLabel = computed(
|
||||
() =>
|
||||
paymentMethodOptions.value.find(
|
||||
(method) => method.method === selectedPaymentMethod.value,
|
||||
)?.label || "",
|
||||
);
|
||||
const purchaseSummary = computed(() =>
|
||||
capability.enabled
|
||||
? "当前可以购买会员,支付功能正在完成最后确认。"
|
||||
? selectedPaymentMethodLabel.value
|
||||
? `当前使用${selectedPaymentMethodLabel.value},支付结果以服务端订单状态为准。`
|
||||
: "购买资格已开放,但当前安装包没有可用支付通道。"
|
||||
: capability.disabledReason || "当前可查看套餐和订单,暂时不能在线购买。",
|
||||
);
|
||||
const serviceNoticeCopy = computed(() =>
|
||||
capability.enabled
|
||||
? "你的账号目前可以购买会员。为避免重复扣款,支付按钮将在确认完成后开放;现在可以先查看套餐和订单。"
|
||||
? "支付完成后会向服务端核对订单状态;若渠道已扣款但状态仍在处理中,请勿重复下单。余额支付由服务端在同一事务内完成扣款和开通。"
|
||||
: capability.disabledReason || "当前可查看套餐和已有订单,暂时不能在线购买。",
|
||||
);
|
||||
|
||||
@@ -129,14 +199,22 @@ const loadVipData = async () => {
|
||||
readState.value = "loading";
|
||||
vipReadError.value = "";
|
||||
try {
|
||||
const [capabilityResult, packageRows, orderRows] = await Promise.all([
|
||||
const [capabilityResult, packageRows, orderRows, providerIds] = await Promise.all([
|
||||
vipApi.getVipCapability({ requestController: capabilityController }),
|
||||
vipApi.getVipPackages({ requestController: packageController }),
|
||||
vipApi.getVipOrders({ requestController: orderController }),
|
||||
getNativePaymentProviders(),
|
||||
]);
|
||||
if (!active) return;
|
||||
capability.enabled = capabilityResult.enabled;
|
||||
capability.disabledReason = capabilityResult.disabledReason;
|
||||
capability.paymentMethods = capabilityResult.paymentMethods;
|
||||
nativePaymentProviders.value = providerIds;
|
||||
const currentSelection = paymentMethodOptions.value.find(
|
||||
(method) => method.method === selectedPaymentMethod.value && method.available,
|
||||
);
|
||||
selectedPaymentMethod.value = currentSelection?.method ||
|
||||
paymentMethodOptions.value.find((method) => method.available)?.method || "";
|
||||
packages.value = packageRows;
|
||||
orders.value = orderRows;
|
||||
readState.value = "ready";
|
||||
@@ -146,12 +224,107 @@ const loadVipData = async () => {
|
||||
readState.value = "error";
|
||||
}
|
||||
};
|
||||
const getNativePaymentProviders = () =>
|
||||
new Promise((resolve) => {
|
||||
if (typeof uni?.getProvider !== "function") {
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
uni.getProvider({
|
||||
service: "payment",
|
||||
success: ({ provider = [] } = {}) => resolve(provider),
|
||||
fail: () => resolve([]),
|
||||
});
|
||||
});
|
||||
const selectPaymentMethod = (method) => {
|
||||
if (!method.available || paymentState.value === "submitting") return;
|
||||
selectedPaymentMethod.value = method.method;
|
||||
};
|
||||
const openServiceNotice = () => {
|
||||
serviceNoticeVisible.value = true;
|
||||
};
|
||||
const closeServiceNotice = () => {
|
||||
serviceNoticeVisible.value = false;
|
||||
};
|
||||
const requestNativePayment = (paymentOrder) =>
|
||||
new Promise((resolve, reject) => {
|
||||
if (!paymentOrder.nativePaymentRequired) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
uni.requestPayment({
|
||||
provider: paymentProviderByMethod[paymentOrder.paymentMethod],
|
||||
orderInfo: paymentOrder.orderInfo,
|
||||
success: resolve,
|
||||
fail: reject,
|
||||
});
|
||||
});
|
||||
const isPaymentCancellation = (error) =>
|
||||
/cancel/i.test(String(error?.errMsg || error?.message || ""));
|
||||
const purchasePackage = async (vipPackage) => {
|
||||
if (!capability.enabled || !selectedPaymentMethod.value || paymentState.value === "submitting") return;
|
||||
const genealogyId = genealogyContext.getCurrentGenealogyId();
|
||||
const orderPayload = {
|
||||
packageId: String(vipPackage.id),
|
||||
genealogyId,
|
||||
paymentMethod: selectedPaymentMethod.value,
|
||||
requestId: createVipRequestId(),
|
||||
};
|
||||
const orderAttempt = orderCreationGuard.begin(orderPayload);
|
||||
if (orderAttempt === null) {
|
||||
uni.showToast({
|
||||
title: "上次下单结果待确认,请先查看订单,不要重复购买",
|
||||
icon: "none",
|
||||
});
|
||||
return;
|
||||
}
|
||||
paymentState.value = "submitting";
|
||||
let transactionId = "";
|
||||
try {
|
||||
const paymentOrder = await vipApi.createVipOrder(
|
||||
vipPackage.id,
|
||||
genealogyId,
|
||||
selectedPaymentMethod.value,
|
||||
orderPayload.requestId,
|
||||
{ requestController: paymentController },
|
||||
);
|
||||
transactionId = paymentOrder.transactionId;
|
||||
await requestNativePayment(paymentOrder);
|
||||
const paymentStatus = await vipApi.getVipPaymentStatus(
|
||||
paymentOrder.transactionId,
|
||||
{ requestController: paymentController },
|
||||
);
|
||||
uni.showToast({
|
||||
title: paymentStatus.status === "SUCCESS" ? "购买成功" : "支付结果确认中,请勿重复下单",
|
||||
icon: paymentStatus.status === "SUCCESS" ? "success" : "none",
|
||||
});
|
||||
await loadVipData();
|
||||
} catch (error) {
|
||||
if (!active || isRequestCancelled(error)) return;
|
||||
let feedback = getRequestErrorMessage(error, "支付未完成,请稍后重试。");
|
||||
if (!transactionId && orderCreationGuard.recordFailure(orderAttempt, error)) {
|
||||
feedback = "下单结果暂时无法确认,请先查看订单,不要重复购买";
|
||||
}
|
||||
if (transactionId && isPaymentCancellation(error)) {
|
||||
try {
|
||||
await vipApi.closeVipPayment(transactionId, {
|
||||
requestController: paymentController,
|
||||
});
|
||||
feedback = "已取消支付";
|
||||
await loadVipData();
|
||||
} catch (closeError) {
|
||||
if (isRequestCancelled(closeError)) return;
|
||||
feedback = "支付已取消,订单关闭状态待确认";
|
||||
}
|
||||
}
|
||||
uni.showToast({
|
||||
title: feedback,
|
||||
icon: "none",
|
||||
});
|
||||
} finally {
|
||||
if (active) paymentState.value = "idle";
|
||||
}
|
||||
};
|
||||
const requestBack = () =>
|
||||
runBackGuard({
|
||||
transientOpen: serviceNoticeVisible.value,
|
||||
@@ -165,6 +338,7 @@ onUnload(() => {
|
||||
capabilityController.abort();
|
||||
packageController.abort();
|
||||
orderController.abort();
|
||||
paymentController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -181,7 +355,7 @@ onUnload(() => {
|
||||
}
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 20rpx 30rpx 72rpx;
|
||||
padding: 20rpx 30rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.vip-intro {
|
||||
position: relative;
|
||||
@@ -269,6 +443,11 @@ onUnload(() => {
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.vip-section__heading text {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.vip-section__heading text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
@@ -285,6 +464,36 @@ onUnload(() => {
|
||||
gap: 14rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.payment-methods {
|
||||
margin-bottom: 24rpx;
|
||||
padding: 22rpx;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.2);
|
||||
border-radius: 10rpx;
|
||||
background: rgba(255, 252, 245, 0.7);
|
||||
}
|
||||
.payment-methods__title { display: block; color: $ink; font-size: clamp(14px, 24rpx, 17px); font-weight: 700; }
|
||||
.payment-methods__options { display: flex; flex-wrap: wrap; margin-top: 14rpx; gap: 12rpx; }
|
||||
.payment-method {
|
||||
display: flex;
|
||||
min-height: var(--app-touch-min);
|
||||
flex: 1 1 180rpx;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 12rpx 18rpx;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.28);
|
||||
border-radius: 8rpx;
|
||||
background: rgba(255, 255, 255, 0.58);
|
||||
color: $ink;
|
||||
text-align: left;
|
||||
}
|
||||
.payment-method::after { border: 0; }
|
||||
.payment-method--selected { border-color: #9e251b; background: rgba(158, 37, 27, 0.08); color: #9e251b; }
|
||||
.payment-method[disabled] { opacity: 0.56; }
|
||||
.payment-method text:first-child { font-size: clamp(14px, 23rpx, 17px); font-weight: 700; }
|
||||
.payment-method text:last-child:not(:first-child),
|
||||
.payment-methods__error { margin-top: 6rpx; color: $ink-muted; font-size: clamp(12px, 19rpx, 14px); line-height: 1.4; }
|
||||
.payment-methods__error { display: block; margin-top: 14rpx; color: #9e251b; }
|
||||
.vip-package,
|
||||
.vip-order {
|
||||
display: flex;
|
||||
@@ -321,6 +530,11 @@ onUnload(() => {
|
||||
flex: 0 0 auto;
|
||||
text-align: right;
|
||||
}
|
||||
.vip-package > .app-button {
|
||||
width: auto;
|
||||
min-width: 132rpx;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.vip-package__price text:first-child,
|
||||
.vip-order > view:last-child text:first-child {
|
||||
color: $brand-red;
|
||||
@@ -354,5 +568,20 @@ onUnload(() => {
|
||||
padding-right: 20rpx;
|
||||
padding-left: 20rpx;
|
||||
}
|
||||
|
||||
.vip-package {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 14rpx 18rpx;
|
||||
}
|
||||
|
||||
.vip-package > .app-button {
|
||||
width: 100%;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.vip-order {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user