Files
device-voice-h5/components/PopupCandidate.vue
luo d672dec0d0
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 2m16s
fix: some
2026-10-14 09:12:34 +08:00

510 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view v-if='visible' class='popup-candidate' @tap.stop>
<view class='popup-candidate-card'>
<template v-if='isCandidateStep'>
<view class='popup-header'>
<view class='popup-heading'>{{ candidate?.title || '提示' }}</view>
<view class='popup-close' role='button' aria-label='关闭' @tap='dismiss'>×</view>
</view>
<scroll-view scroll-y class='popup-body'>
<text>{{ candidate?.body || '' }}</text>
</scroll-view>
<view class='popup-actions'>
<up-button v-if='isRiskExchange' type='primary' @tap='goToAddress'>申请换卡</up-button>
<up-button v-else-if='actionLabel' type='primary' @tap='handleAction'>{{ actionLabel }}</up-button>
<up-button plain @tap='dismiss'>稍后处理</up-button>
</view>
</template>
<template v-else-if='isAddressStep'>
<view class='popup-header'>
<view class='popup-heading'>换货收货信息</view>
<view class='popup-close' role='button' aria-label='关闭' @tap='dismiss'>×</view>
</view>
<scroll-view scroll-y class='popup-address-form'>
<view class='form-item'>
<view class='form-label'>粘贴收货信息</view>
<up-textarea
v-model='addressForm.raw_shipping_text'
placeholder='可粘贴包含收件人、电话、地址的一整段文字'
autoHeight
:maxlength='-1'
border='surround'
/>
<view class='form-helper'>识别结果可继续手动修改</view>
<view class='paste-action-row'>
<button class='btn-apple btn-secondary paste-action-btn' @tap='clearPastedText'>清除</button>
<button class='btn-apple btn-secondary paste-action-btn' @tap='fillFromPastedText'>识别并填充</button>
</view>
</view>
<view class='form-item'>
<view class='form-label'>收件人姓名</view>
<up-input v-model='addressForm.recipient_name' placeholder='请输入收件人姓名' border='surround' maxlength='50'/>
</view>
<view class='form-item'>
<view class='form-label'>收件人电话</view>
<up-input v-model='addressForm.recipient_phone' type='number' placeholder='请输入11位手机号' border='surround' maxlength='11'/>
</view>
<view class='form-item'>
<view class='form-label'>所在地区</view>
<view class='region-trigger' @tap='openAreaPicker'>
<text class='region-trigger-text' :class='{ placeholder: !regionDisplayText }'>
{{ regionDisplayText || '请选择省 / 市 / 区' }}
</text>
</view>
</view>
<view class='form-item'>
<view class='form-label'>详细地址</view>
<up-textarea
v-model='addressForm.recipient_address_detail'
placeholder='请输入详细收货地址'
autoHeight
:maxlength='-1'
border='surround'
/>
</view>
</scroll-view>
<view class='popup-address-actions'>
<up-button plain @tap='dismiss'>取消</up-button>
<up-button type='primary' :loading='submitting' :disabled='submitting' @tap='submitAddress'>提交</up-button>
</view>
</template>
<template v-else-if='isDoneStep'>
<view class='popup-header'>
<view class='popup-heading'>申请已提交</view>
</view>
<view class='result-item'>
<view class='result-label'>换货单号</view>
<view class='result-value'>{{ exchange?.exchange_no || '-' }}</view>
</view>
<view class='result-item'>
<view class='result-label'>状态</view>
<view class='result-value'>{{ exchange?.status_name || '-' }}</view>
</view>
<view v-if='exchange?.recipient_address' class='result-item'>
<view class='result-label'>收货地址</view>
<view class='result-value'>{{ exchange.recipient_address }}</view>
</view>
<view class='popup-actions'>
<up-button type='primary' @tap='finish'>完成</up-button>
</view>
</template>
</view>
</view>
<up-picker
:show='showAreaPicker'
:columns='areaPickerColumns'
keyName='label'
title='选择所在地区'
:defaultIndex='areaPickerDefaultIndex'
closeOnClickOverlay
@confirm='confirmAreaPicker'
@change='handleAreaPickerChange'
@cancel='showAreaPicker = false'
@close='showAreaPicker = false'
/>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue';
import { popupApi, notificationApi } from '@/api/index.js';
import { handledPopupNotificationIds } from '@/utils/popup.js';
import {
buildRegionColumns,
composeRecipientAddress,
getRegionDisplayText,
parseShippingText
} from '@/utils/shippingAddress.js';
const PHONE_REG = /^1[3-9]\d{9}$/;
const props = defineProps({
page: {
type: String,
required: true
},
identifier: {
type: String,
default: ''
}
});
const emit = defineEmits(['close']);
const visible = ref(false);
const step = ref('candidate');
const candidate = ref(null);
const exchange = ref(null);
const submitting = ref(false);
const showAreaPicker = ref(false);
const areaPickerColumns = ref([]);
const areaPickerDefaultIndex = ref([0, 0, 0]);
const addressForm = reactive({
raw_shipping_text: '',
recipient_name: '',
recipient_phone: '',
recipient_region_codes: [],
recipient_address_detail: ''
});
const isCandidateStep = computed(() => step.value === 'candidate');
const isAddressStep = computed(() => step.value === 'address');
const isDoneStep = computed(() => step.value === 'done');
const isRiskExchange = computed(() => candidate.value?.popup_type === 'risk_exchange');
const regionDisplayText = computed(() => getRegionDisplayText(addressForm.recipient_region_codes));
const actionLabelMap = {
package_purchase: '立即购买',
asset_wallet_recharge: '立即充值'
};
const actionRouteMap = {
package_purchase: '/pages/package-order/package-order',
asset_wallet_recharge: '/pages/my-wallet/my-wallet'
};
const actionLabel = computed(() => actionLabelMap[candidate.value?.action_type] || '');
const goToAddress = () => {
step.value = 'address';
};
const resetForm = () => {
addressForm.raw_shipping_text = '';
addressForm.recipient_name = '';
addressForm.recipient_phone = '';
addressForm.recipient_region_codes = [];
addressForm.recipient_address_detail = '';
};
const syncAreaPickerState = (regionCodes = []) => {
const { columns, defaultIndex } = buildRegionColumns(regionCodes);
areaPickerColumns.value = columns;
areaPickerDefaultIndex.value = defaultIndex.map((index) => (index < 0 ? 0 : index));
};
const openAreaPicker = () => {
syncAreaPickerState(addressForm.recipient_region_codes);
showAreaPicker.value = true;
};
const handleAreaPickerChange = (event) => {
const selectedValues = event.value || [];
const selectedProvinceCode = selectedValues[0]?.value || '';
const selectedCityCode = event.columnIndex === 0 ? '' : selectedValues[1]?.value || '';
const selectedDistrictCode = event.columnIndex === 2 ? selectedValues[2]?.value || '' : '';
const { columns, defaultIndex } = buildRegionColumns([
selectedProvinceCode,
selectedCityCode,
selectedDistrictCode
]);
areaPickerColumns.value = columns;
areaPickerDefaultIndex.value = defaultIndex.map((index) => (index < 0 ? 0 : index));
};
const confirmAreaPicker = ({ value }) => {
const selectedCodes = (value || []).map((item) => item?.value).filter(Boolean);
if (selectedCodes.length === 3) {
addressForm.recipient_region_codes = selectedCodes;
syncAreaPickerState(selectedCodes);
}
showAreaPicker.value = false;
};
const fillFromPastedText = () => {
if (!addressForm.raw_shipping_text.trim()) {
uni.showToast({ title: '请先粘贴收货信息', icon: 'none' });
return;
}
const parsed = parseShippingText(addressForm.raw_shipping_text);
let updatedCount = 0;
if (parsed.recipient_name) {
addressForm.recipient_name = parsed.recipient_name;
updatedCount += 1;
}
if (parsed.recipient_phone) {
addressForm.recipient_phone = parsed.recipient_phone;
updatedCount += 1;
}
if (parsed.regionCodes.length === 3) {
addressForm.recipient_region_codes = parsed.regionCodes;
syncAreaPickerState(parsed.regionCodes);
updatedCount += 1;
}
if (parsed.detailAddress) {
addressForm.recipient_address_detail = parsed.detailAddress;
updatedCount += 1;
}
if (!updatedCount) {
uni.showToast({ title: '未识别到有效收货信息', icon: 'none' });
return;
}
uni.showToast({ title: '已识别并填充', icon: 'success' });
};
const clearPastedText = () => {
addressForm.raw_shipping_text = '';
};
const markPopupRead = async () => {
const id = candidate.value?.notification_id;
if (!id) return;
try {
await notificationApi.markRead(id);
} catch (e) {
console.error('标记弹窗通知已读失败', e);
}
};
const dismiss = () => {
visible.value = false;
showAreaPicker.value = false;
markPopupRead();
emit('close', 'dismissed');
};
const handleAction = () => {
const url = actionRouteMap[candidate.value?.action_type];
visible.value = false;
markPopupRead();
emit('close', 'action');
if (url) {
uni.navigateTo({ url });
}
};
const submitAddress = async () => {
if (submitting.value) return;
const recipientName = addressForm.recipient_name.trim();
const recipientPhone = addressForm.recipient_phone.trim();
const detailAddress = addressForm.recipient_address_detail.trim();
if (!recipientName) {
uni.showToast({ title: '请输入收件人姓名', icon: 'none' });
return;
}
if (!recipientPhone) {
uni.showToast({ title: '请输入收件人电话', icon: 'none' });
return;
}
if (!PHONE_REG.test(recipientPhone)) {
uni.showToast({ title: '请输入正确的手机号', icon: 'none' });
return;
}
if (addressForm.recipient_region_codes.length !== 3) {
uni.showToast({ title: '请选择省市区', icon: 'none' });
return;
}
if (!detailAddress) {
uni.showToast({ title: '请输入详细收货地址', icon: 'none' });
return;
}
const recipientAddress = composeRecipientAddress(addressForm.recipient_region_codes, detailAddress);
const assetId = Number(candidate.value?.asset_id);
if (!Number.isInteger(assetId) || assetId <= 0) {
uni.showToast({ title: '资产信息无效', icon: 'none' });
return;
}
submitting.value = true;
try {
exchange.value = await popupApi.submitRiskExchangeAddress(assetId, {
recipient_name: recipientName,
recipient_phone: recipientPhone,
recipient_address: recipientAddress
});
await markPopupRead();
step.value = 'done';
} catch (e) {
console.error('提交风险换卡收货地址失败', e);
} finally {
submitting.value = false;
}
};
const finish = () => {
visible.value = false;
showAreaPicker.value = false;
emit('close', 'done');
};
let mounted = true;
onUnmounted(() => { mounted = false; });
onMounted(async () => {
if (!props.identifier) {
emit('close', 'empty');
return;
}
try {
const data = await popupApi.getCandidate(props.page, props.identifier);
if (!mounted) return;
const item = (data && data.candidate) || null;
if (!item || !item.notification_id || handledPopupNotificationIds.has(item.notification_id)) {
emit('close', 'empty');
return;
}
handledPopupNotificationIds.add(item.notification_id);
candidate.value = item;
step.value = 'candidate';
resetForm();
visible.value = true;
} catch (e) {
if (!mounted) return;
// 资源不可见(400/code=1180)与接口异常统一静默关闭,不做 UI 分支
console.error('查询弹窗候选失败', e);
emit('close', 'invisible');
}
});
</script>
<style scoped lang='scss'>
.popup-candidate {
position: fixed;
z-index: 1100;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 40rpx;
background: rgba(0, 0, 0, 0.48);
}
.popup-candidate-card {
width: 100%;
max-width: 640rpx;
max-height: calc(100vh - 80rpx);
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 24rpx;
background: #fff;
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.18);
}
.popup-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 30rpx 32rpx 18rpx;
}
.popup-heading {
color: var(--text-primary);
font-size: 34rpx;
font-weight: 600;
}
.popup-close {
width: 52rpx;
height: 52rpx;
color: var(--text-tertiary);
font-size: 48rpx;
font-weight: 300;
line-height: 46rpx;
text-align: center;
}
.popup-body {
box-sizing: border-box;
max-height: 420rpx;
padding: 12rpx 32rpx 24rpx;
color: var(--text-secondary);
font-size: 28rpx;
line-height: 1.7;
white-space: pre-wrap;
}
.popup-actions {
display: flex;
flex-direction: column;
gap: 16rpx;
padding: 20rpx 32rpx 32rpx;
}
.form-item {
margin-bottom: 24rpx;
}
.form-label {
margin-bottom: 10rpx;
color: var(--text-primary);
font-size: 26rpx;
}
.form-helper {
margin-top: 8rpx;
font-size: 22rpx;
color: var(--text-tertiary);
}
.popup-address-form {
box-sizing: border-box;
max-height: 60vh;
padding: 0 32rpx;
}
.paste-action-row,
.popup-address-actions {
display: flex;
gap: 16rpx;
}
.paste-action-row {
margin-top: 16rpx;
.paste-action-btn {
flex: 1;
}
}
.region-trigger {
display: flex;
align-items: center;
min-height: 84rpx;
padding: 0 24rpx;
border: 2rpx solid var(--border-light);
border-radius: 12rpx;
background: #fff;
}
.region-trigger-text {
font-size: 28rpx;
color: var(--text-primary);
&.placeholder {
color: var(--text-tertiary);
}
}
.popup-address-actions {
padding: 16rpx 32rpx 32rpx;
:deep(.u-button) {
flex: 1;
}
}
.result-item {
padding: 8rpx 32rpx;
}
.result-label {
color: var(--text-tertiary);
font-size: 24rpx;
}
.result-value {
margin-top: 4rpx;
color: var(--text-primary);
font-size: 28rpx;
word-break: break-all;
}
</style>