Files
olons_server/web/src/mobile/views/ClaimView.vue
T

365 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>
<section class="m-claim2">
<AsyncState :loading="loading" :error="error" @retry="load">
<div class="m-claim2-hero">
<img :src="mobileAssets.claimMachine" alt="每日参与任务,赚取更多佣金" />
<button
type="button"
:disabled="claiming || !halls.length"
aria-label="自动领取任务"
@click="claim"
>
<img :src="mobileAssets.claimAutoButton" alt="自动领取" />
</button>
</div>
<div class="m-claim2-content">
<section class="m-claim2-stats">
<div v-for="item in statCards" :key="item.label">
<img :src="item.icon" alt="" />
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</section>
<div v-if="balanceInsufficient" class="m-claim2-warning">
<n-icon :component="AlertCircleOutline" />
<span>可用余额不足299元</span>
</div>
<section class="m-claim2-rules">
<h2><span></span>活动规则<span></span></h2>
<ol>
<li>平台运营时间每天上午9:00-晚上21:00点!</li>
<li>每一次储入必须咨询客户获取商家卡号!如有不理解可联系在线客服!</li>
<li>领取后系统将自动分配任务,请按订单要求完成任务内容!</li>
<li>领取订单的时间是根据会员等级的不同!系统随机匹配订单!</li>
<li>会员账户必须要拥有299启动资金才可进行领取!</li>
<li>订单需要在规定时间内完成!请勿超时完成商品!</li>
<li>平台硬性条件提现必须信用分值满足100分,请广大会员珍惜账户信用分值!信用分下降必须联系客服处理恢复100分才可正常提现,信用分提升金额具体根据会员等级规定,详细咨询平台客服。</li>
<li>非本人充值资金需要提供支付人身份证信息验证充值资金,避免出现非必要的资金纠纷。</li>
</ol>
</section>
</div>
</AsyncState>
<n-modal v-model:show="showResult" :mask-closable="false">
<section class="m-claim-result" :class="`is-${resultState}`">
<template v-if="resultState === 'matching'">
<h2>正在匹配订单</h2>
<p>{{ matchingHint }}</p>
<div class="m-match-visual">
<img class="orbit" :src="mobileAssets.claimSpinner" alt="" />
<img class="gift" :src="mobileAssets.claimMatchingGift" alt="" />
</div>
<div v-if="isAutoMatching" class="m-match-progress"><i :style="{ width: `${matchProgress}%` }"></i></div>
<strong v-if="isAutoMatching" class="m-match-percent">{{ matchProgress }}%</strong>
<ol class="m-match-steps m-match-steps--single">
<li class="active"><span>✓</span>正在匹配订单</li>
</ol>
<button class="m-result-secondary" @click="openOrders">稍后去记录查看</button>
</template>
<template v-else>
<img class="m-result-icon" :src="resultIcon" alt="" />
<h2>{{ resultTitle }}</h2>
<p>{{ resultDescription }}</p>
<div v-if="order?.product?.name" class="m-result-product">
<div>
<img v-if="safeImage(order.product.cover_url)" :src="safeImage(order.product.cover_url)" alt="" />
<n-icon v-else :component="ImageOutline" />
</div>
<section><small>任务商品</small><b>{{ order.product.name }}</b><strong>{{ money(order.total_amount, order.currency) }}</strong></section>
</div>
<dl v-if="resultState === 'success'" class="m-result-details">
<div><dt>预计佣金</dt><dd>{{ money(order?.commission_amount, order?.currency) }}</dd></div>
<div><dt>佣金比例</dt><dd>{{ commissionRate }}</dd></div>
<div><dt>任务编号</dt><dd>{{ order?.order_no || '--' }}</dd></div>
<div><dt>任务时间</dt><dd>{{ dateTime(order?.created_at) }}</dd></div>
</dl>
<div v-if="resultState === 'failed'" class="m-result-reason">
<b>失败原因</b><span>{{ failureMessage }}</span>
</div>
<div class="m-result-actions">
<button v-if="resultState !== 'success'" class="m-result-secondary" @click="retryClaim">重新领取</button>
<button class="m-result-primary" @click="primaryResultAction">{{ resultState === 'success' ? '查看记录' : '返回首页' }}</button>
</div>
</template>
</section>
</n-modal>
</section>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { AlertCircleOutline, ImageOutline } from '@vicons/ionicons5';
import AsyncState from '../components/AsyncState.vue';
import { MobileApiError, dateTime, errorText, money, request, safeImage } from '../api';
import { mobileAssets } from '../assets';
type ResultState = 'matching' | 'success' | 'failed' | 'cancelled';
const router = useRouter();
const loading = ref(true);
const claiming = ref(false);
const error = ref('');
const halls = ref<any[]>([]);
const stats = ref<any>();
const eligibility = ref<any>();
const eligibilityByHall = ref<Record<number, any>>({});
const order = ref<any>();
const showResult = ref(false);
const resultState = ref<ResultState>('matching');
const failureMessage = ref('当前任务库存未分配成功,请稍后重试');
const matchProgress = ref(18);
const matchRemainingSeconds = ref(0);
const matchTotalSeconds = ref(0);
const pollFinished = ref(false);
let pollTimer: number | undefined;
let countdownTimer: number | undefined;
let pollCount = 0;
const statCards = computed(() => [
{ label: '总资产', value: money(stats.value?.total_asset_amount), icon: mobileAssets.claimTotal },
{ label: '昨日佣金', value: money(stats.value?.yesterday_commission_amount), icon: mobileAssets.claimYesterday },
{ label: '今日佣金', value: money(stats.value?.today_commission_amount), icon: mobileAssets.claimToday },
{ label: '今日领取数', value: stats.value?.today_claim_count ?? 0, icon: mobileAssets.claimCount },
]);
const eligibleHalls = computed(() =>
halls.value.filter((hall) => eligibilityByHall.value[hall.id]?.eligible)
);
const balanceInsufficient = computed(() =>
Object.values(eligibilityByHall.value).some((item: any) =>
Number(item?.minimum_amount || 0) > 0 &&
Number(item?.available_amount || 0) < Number(item.minimum_amount)
)
);
const isAutoMatching = computed(() => order.value?.dispatch_mode !== 'manual');
const matchingHint = computed(() => {
if (order.value?.dispatch_mode === 'manual') return '订单已领取,正在等待后台匹配...';
if (matchRemainingSeconds.value > 0) return `系统正在派单... ${formatCountdown(matchRemainingSeconds.value)}`;
return pollFinished.value ? '订单仍在匹配,可稍后前往记录查看' : '系统正在为您匹配订单,请稍等...';
});
const resultIcon = computed(() => resultState.value === 'success' ? mobileAssets.claimSuccess : resultState.value === 'failed' ? mobileAssets.claimFailed : mobileAssets.claimCancelled);
const resultTitle = computed(() => resultState.value === 'success' ? '任务获取成功' : resultState.value === 'failed' ? '领取失败' : '已取消任务');
const resultDescription = computed(() => resultState.value === 'success' ? '恭喜你成功领取任务' : resultState.value === 'failed' ? '本次任务领取未成功,请稍后再试' : '本次任务已取消,可重新领取');
const commissionRate = computed(() => {
const total = Number(order.value?.total_amount || 0);
return total > 0 ? `${((Number(order.value?.commission_amount || 0) / total) * 100).toFixed(2)}%` : '--';
});
async function load() {
loading.value = true;
error.value = '';
try {
const [hallData, statData] = await Promise.all([
request<any[]>({ url: '/v1/task-halls' }),
request<any>({ url: '/v1/tasks/stats' }),
]);
halls.value = hallData || [];
stats.value = statData;
if (halls.value.length) {
await loadEligibilities();
await resumeMatchingOrder();
}
} catch (err) {
error.value = errorText(err);
} finally {
loading.value = false;
}
}
async function loadEligibilities() {
const entries = await Promise.all(
halls.value.map(async (hall) => [
hall.id,
await request<any>({ url: '/v1/tasks/eligibility', params: { hall_id: hall.id } }),
] as const)
);
eligibilityByHall.value = Object.fromEntries(entries);
eligibility.value = entries.find(([, value]) => value?.eligible)?.[1] || entries[0]?.[1];
}
async function resumeMatchingOrder() {
const pendingOrderID = Object.values(eligibilityByHall.value)
.map((item: any) => Number(item?.pending_order_id || 0))
.find((id) => id > 0);
if (!pendingOrderID) return;
try {
const pendingOrder = await request<any>({ url: `/v1/task-orders/${pendingOrderID}` });
if (pendingOrder?.status !== 'matching') return;
openMatching();
handleOrder(pendingOrder);
} catch {
// 保留页面上的“存在未完成订单”入口,避免详情请求失败阻断领取页加载。
}
}
async function claim() {
if (claiming.value || !halls.value.length) return;
claiming.value = true;
pollFinished.value = false;
order.value = undefined;
try {
await loadEligibilities();
const candidates = eligibleHalls.value;
if (!candidates.length) {
throw new Error(eligibility.value?.message || '暂不满足领取条件');
}
openMatching();
let stockError: unknown;
for (const hall of candidates) {
eligibility.value = eligibilityByHall.value[hall.id];
try {
order.value = await request<any>({
url: '/v1/task-orders/claim',
method: 'POST',
data: { hall_id: hall.id },
});
handleOrder(order.value);
return;
} catch (err) {
if (err instanceof MobileApiError && err.reason === 'NO_TASK_AVAILABLE') {
stockError = err;
continue;
}
throw err;
}
}
throw stockError || new Error('当前暂无可领取任务');
} catch (err) {
failureMessage.value = errorText(err);
resultState.value = 'failed';
showResult.value = true;
stopPolling();
} finally {
claiming.value = false;
}
}
function openMatching() {
stopPolling();
resultState.value = 'matching';
showResult.value = true;
matchProgress.value = 18;
matchRemainingSeconds.value = 0;
matchTotalSeconds.value = 0;
pollCount = 0;
}
function handleOrder(value: any) {
order.value = value;
if (value?.status === 'matching') {
if (value?.dispatch_mode === 'manual') {
if (countdownTimer) window.clearInterval(countdownTimer);
countdownTimer = undefined;
matchRemainingSeconds.value = 0;
matchTotalSeconds.value = 0;
matchProgress.value = 18;
} else {
startCountdown(value);
}
schedulePoll();
} else if (['pending', 'processing', 'completed'].includes(value?.status)) {
matchProgress.value = 100;
resultState.value = 'success';
stopPolling();
} else if (value?.status === 'cancelled') {
resultState.value = 'cancelled';
stopPolling();
} else {
failureMessage.value = value?.message || failureMessage.value;
resultState.value = 'failed';
stopPolling();
}
}
function schedulePoll() {
if (!order.value?.id) return;
if (pollTimer) window.clearTimeout(pollTimer);
const scheduledAt = Date.parse(String(order.value?.scheduled_match_at || ''));
const wait = order.value?.dispatch_mode === 'manual'
? 10000
: Number.isFinite(scheduledAt) && scheduledAt > Date.now()
? scheduledAt - Date.now() + 300
: 1200;
pollTimer = window.setTimeout(async () => {
pollCount += 1;
try {
handleOrder(await request({ url: `/v1/task-orders/${order.value.id}` }));
} catch (err) {
if (pollCount >= 10) {
pollFinished.value = true;
stopPolling();
} else schedulePoll();
}
if (resultState.value === 'matching' && pollCount >= 10) {
pollFinished.value = true;
stopPolling();
}
}, wait);
}
function startCountdown(value: any) {
if (countdownTimer) window.clearInterval(countdownTimer);
if (value?.dispatch_mode === 'manual') return;
const scheduledAt = Date.parse(String(value?.scheduled_match_at || ''));
if (!Number.isFinite(scheduledAt)) return;
matchTotalSeconds.value = Math.max(1, Number(value?.dispatch_delay_seconds || 0));
const update = () => {
matchRemainingSeconds.value = Math.max(0, Math.ceil((scheduledAt - Date.now()) / 1000));
const elapsed = Math.max(0, matchTotalSeconds.value - matchRemainingSeconds.value);
matchProgress.value = Math.min(88, Math.round(18 + elapsed / matchTotalSeconds.value * 70));
if (matchRemainingSeconds.value === 0 && countdownTimer) {
window.clearInterval(countdownTimer);
countdownTimer = undefined;
}
};
update();
if (matchRemainingSeconds.value > 0) countdownTimer = window.setInterval(update, 1000);
}
function formatCountdown(totalSeconds: number) {
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor(totalSeconds % 3600 / 60);
const seconds = totalSeconds % 60;
return hours > 0
? [hours, minutes, seconds].map((item) => String(item).padStart(2, '0')).join(':')
: [minutes, seconds].map((item) => String(item).padStart(2, '0')).join(':');
}
function stopPolling(clearProgress = true) {
if (pollTimer) window.clearTimeout(pollTimer);
if (clearProgress && countdownTimer) window.clearInterval(countdownTimer);
pollTimer = undefined;
if (clearProgress) {
countdownTimer = undefined;
}
}
function closeResult() {
showResult.value = false;
stopPolling();
}
function openOrders() {
closeResult();
router.push('/app/orders');
}
function retryClaim() {
closeResult();
void claim();
}
function primaryResultAction() {
closeResult();
router.push(resultState.value === 'success' ? `/app/orders/${order.value?.id}` : '/app/home');
}
onMounted(load);
onBeforeUnmount(() => stopPolling());
</script>