365 lines
14 KiB
Vue
365 lines
14 KiB
Vue
<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>
|