商城c端前端
@@ -0,0 +1,11 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
import type { MessageApi } from 'naive-ui';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
$message: MessageApi;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<main class="m-app" :class="{ 'm-app--with-nav': showNav }">
|
||||
<router-view />
|
||||
<nav v-if="showNav" class="m-bottom-nav" aria-label="主要导航">
|
||||
<router-link v-for="item in nav" :key="item.path" :to="item.path" :aria-label="item.label">
|
||||
<img :src="isActive(item.path) ? item.activeIcon : item.icon" alt="" />
|
||||
<span>{{ item.label }}</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { hasSession } from './api';
|
||||
import { mobileAssets } from './assets';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const showNav = computed(() => Boolean(route.meta.mobileNav));
|
||||
const nav = [
|
||||
{
|
||||
label: '首页',
|
||||
path: '/app/home',
|
||||
icon: mobileAssets.navHome,
|
||||
activeIcon: mobileAssets.navHomeActive,
|
||||
},
|
||||
{
|
||||
label: '记录',
|
||||
path: '/app/orders',
|
||||
icon: mobileAssets.navOrders,
|
||||
activeIcon: mobileAssets.navOrdersActive,
|
||||
},
|
||||
{
|
||||
label: '领取',
|
||||
path: '/app/claim',
|
||||
icon: mobileAssets.navClaimActive,
|
||||
activeIcon: mobileAssets.navClaimActive,
|
||||
},
|
||||
{
|
||||
label: '客服',
|
||||
path: '/app/support',
|
||||
icon: mobileAssets.navSupport,
|
||||
activeIcon: mobileAssets.navSupportActive,
|
||||
},
|
||||
{
|
||||
label: '我的',
|
||||
path: '/app/profile',
|
||||
icon: mobileAssets.navProfile,
|
||||
activeIcon: mobileAssets.navProfileActive,
|
||||
},
|
||||
];
|
||||
const isActive = (path: string) => route.path === path || route.path.startsWith(`${path}/`);
|
||||
|
||||
function ensureAccess() {
|
||||
const publicContent = [
|
||||
'/app/content/user_agreement',
|
||||
'/app/content/privacy_policy',
|
||||
'/app/content/platform_intro',
|
||||
];
|
||||
const publicPassword = ['/app/password/verify', '/app/password/reset'];
|
||||
if (
|
||||
!hasSession() &&
|
||||
!publicContent.includes(route.path) &&
|
||||
!publicPassword.includes(route.path)
|
||||
) {
|
||||
router.replace({ path: '/app/auth', query: { redirect: route.fullPath } });
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => route.path, ensureAccess, { immediate: true });
|
||||
</script>
|
||||
@@ -0,0 +1,188 @@
|
||||
import axios, { AxiosError, AxiosRequestConfig } from 'axios';
|
||||
|
||||
const TOKEN_KEY = 'olons_web_session';
|
||||
const DEVICE_KEY = 'olons_web_device';
|
||||
|
||||
const reasonMessages: Record<string, string> = {
|
||||
AUTH_REQUIRED: '登录已失效,请重新登录',
|
||||
FORBIDDEN: '当前账户无权执行此操作',
|
||||
INVALID_CREDENTIALS: '手机号或密码错误',
|
||||
ACCOUNT_ALREADY_REGISTERED: '该账户或手机号已经注册',
|
||||
RATE_LIMITED: '操作过于频繁,请稍后再试',
|
||||
TASK_DISABLED: '领取功能暂未开放',
|
||||
OUTSIDE_BUSINESS_HOURS: '当前不在营业时间内',
|
||||
INSUFFICIENT_BALANCE: '可用余额不足',
|
||||
DAILY_LIMIT_REACHED: '今日领取次数已用完',
|
||||
PENDING_ORDER_EXISTS: '存在未完成的任务,请先处理',
|
||||
NO_TASK_AVAILABLE: '当前暂无可领取任务',
|
||||
ORDER_EXPIRED: '该任务已超时',
|
||||
ORDER_STATE_INVALID: '当前任务状态不允许此操作',
|
||||
TRADE_PASSWORD_NOT_SET: '请先设置支付密码',
|
||||
TRADE_PASSWORD_INVALID: '支付密码不正确',
|
||||
PAYMENT_ACCOUNT_INVALID: '收款账户未核验或不可用',
|
||||
WITHDRAWAL_DISABLED: '取出功能暂未开放',
|
||||
WITHDRAWAL_AMOUNT_INVALID: '取出金额不符合当前规则',
|
||||
WITHDRAWAL_DAILY_LIMIT_REACHED: '今日取出次数已用完',
|
||||
WITHDRAWAL_CHANNEL_UNAVAILABLE: '当前没有可用的取出通道',
|
||||
RECHARGE_AMOUNT_INVALID: '储入金额不符合当前通道规则',
|
||||
RECHARGE_CHANNEL_UNAVAILABLE: '当前没有可用的储入通道',
|
||||
RECHARGE_VOUCHER_INVALID: '储入凭证无效',
|
||||
SERVICE_UNAVAILABLE: '服务暂不可用,请稍后重试',
|
||||
INTERNAL: '服务异常,请稍后重试',
|
||||
};
|
||||
|
||||
export interface ApiEnvelope<T> {
|
||||
code: number;
|
||||
data: T;
|
||||
message?: string;
|
||||
reason?: string;
|
||||
request_id?: string;
|
||||
server_time?: string;
|
||||
}
|
||||
|
||||
export class MobileApiError extends Error {
|
||||
reason: string;
|
||||
status: number;
|
||||
|
||||
constructor(message: string, reason = '', status = 0) {
|
||||
super(message);
|
||||
this.reason = reason;
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: '',
|
||||
timeout: 15000,
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
client.interceptors.request.use((config) => {
|
||||
const token = sessionStorage.getItem(TOKEN_KEY);
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
config.headers['X-Request-ID'] = crypto.randomUUID?.() || `${Date.now()}-${Math.random()}`;
|
||||
config.headers['X-Device-ID'] = getDeviceID();
|
||||
return config;
|
||||
});
|
||||
|
||||
client.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: AxiosError<ApiEnvelope<unknown>>) => {
|
||||
const status = error.response?.status || 0;
|
||||
const payload = error.response?.data;
|
||||
if (status === 401) {
|
||||
clearSession();
|
||||
const currentPath = window.location.hash.replace(/^#/, '') || '/app/home';
|
||||
if (!currentPath.startsWith('/app/auth')) {
|
||||
window.location.hash = `/app/auth?redirect=${encodeURIComponent(currentPath)}`;
|
||||
}
|
||||
}
|
||||
const reason = payload?.reason || '';
|
||||
return Promise.reject(
|
||||
new MobileApiError(
|
||||
reasonMessages[reason] ||
|
||||
payload?.message ||
|
||||
(status ? '请求失败,请稍后重试' : '网络连接失败'),
|
||||
reason,
|
||||
status
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export async function request<T>(config: AxiosRequestConfig): Promise<T> {
|
||||
const method = String(config.method || 'GET').toUpperCase();
|
||||
const attempts = method === 'GET' ? 3 : 1;
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
try {
|
||||
const response = await client.request<ApiEnvelope<T>>(config);
|
||||
if (response.data.code !== 0) {
|
||||
const reason = response.data.reason || '';
|
||||
throw new MobileApiError(
|
||||
reasonMessages[reason] || response.data.message || '请求失败',
|
||||
reason,
|
||||
response.status
|
||||
);
|
||||
}
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (!shouldRetry(error, attempt, attempts)) throw error;
|
||||
await new Promise((resolve) => window.setTimeout(resolve, attempt === 0 ? 250 : 600));
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
function shouldRetry(error: unknown, attempt: number, attempts: number) {
|
||||
if (attempt >= attempts - 1) return false;
|
||||
if (axios.isCancel(error)) return false;
|
||||
return error instanceof MobileApiError && (error.status === 0 || error.status === 503);
|
||||
}
|
||||
|
||||
function getDeviceID() {
|
||||
let value = localStorage.getItem(DEVICE_KEY);
|
||||
if (!value) {
|
||||
value = crypto.randomUUID?.() || `web-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
localStorage.setItem(DEVICE_KEY, value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function saveSession(token: string) {
|
||||
sessionStorage.setItem(TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
export function clearSession() {
|
||||
sessionStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function hasSession() {
|
||||
return Boolean(sessionStorage.getItem(TOKEN_KEY));
|
||||
}
|
||||
|
||||
export function idempotencyKey() {
|
||||
return crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
export function money(value?: number, currency = 'CNY') {
|
||||
const amount = Number.isFinite(value) ? Number(value) / 100 : 0;
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 2,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
export function dateTime(value?: string) {
|
||||
if (!value) return '--';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '--';
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
.format(date)
|
||||
.replaceAll('/', '-');
|
||||
}
|
||||
|
||||
export function safeImage(url?: string) {
|
||||
if (!url) return '';
|
||||
try {
|
||||
const parsed = new URL(url, window.location.origin);
|
||||
return parsed.protocol === 'https:' || parsed.origin === window.location.origin
|
||||
? parsed.href
|
||||
: '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function errorText(error: unknown) {
|
||||
return error instanceof Error ? error.message : '操作失败,请稍后重试';
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import homeHero from './assets/home-hero.png';
|
||||
import homeBalance from './assets/home-balance.png';
|
||||
import shortcutClaim from './assets/shortcut-claim.png';
|
||||
import shortcutRecharge from './assets/shortcut-recharge.png';
|
||||
import shortcutWithdraw from './assets/shortcut-withdraw.png';
|
||||
import shortcutMall from './assets/shortcut-mall.png';
|
||||
import navHome from './assets/nav-home.png';
|
||||
import navHomeActive from './assets/nav-home-active.png';
|
||||
import navOrders from './assets/nav-orders.png';
|
||||
import navOrdersActive from './assets/nav-orders-active.png';
|
||||
import navClaimActive from './assets/nav-claim-active.png';
|
||||
import navSupport from './assets/nav-support.png';
|
||||
import navSupportActive from './assets/nav-support-active.png';
|
||||
import navProfile from './assets/nav-profile.png';
|
||||
import navProfileActive from './assets/nav-profile-active.png';
|
||||
import profileBackground from './assets/profile-bg.png';
|
||||
import profileWallet from './assets/profile-wallet.png';
|
||||
import menuProfile from './assets/menu-profile.png';
|
||||
import menuRecharge from './assets/menu-recharge.png';
|
||||
import menuWithdraw from './assets/menu-withdraw.png';
|
||||
import menuLedger from './assets/menu-ledger.png';
|
||||
import menuNotices from './assets/menu-notices.png';
|
||||
import menuLogout from './assets/menu-logout.png';
|
||||
import copy from './assets/copy.png';
|
||||
import supportHero from './assets/support-hero.png';
|
||||
import back from './assets/back.png';
|
||||
import claimOrbit from './assets/claim-orbit.png';
|
||||
import claimGift from './assets/claim-gift.png';
|
||||
import claimButton from './assets/claim-button.png';
|
||||
import processSimple from './assets/process-simple.png';
|
||||
import processMaterial from './assets/process-material.png';
|
||||
import processTime from './assets/process-time.png';
|
||||
import processReturn from './assets/process-return.png';
|
||||
import chatVoice from './assets/chat-voice.png';
|
||||
import chatMore from './assets/chat-more.png';
|
||||
import companyPlatform from './assets/company-platform.png';
|
||||
import companyRules from './assets/company-rules.png';
|
||||
import companyQualification from './assets/company-qualification.png';
|
||||
import companyAgency from './assets/company-agency.png';
|
||||
import avatarEdit from './assets/avatar-edit.png';
|
||||
import rechargeRecord from './assets/recharge-record.png';
|
||||
import withdrawalRecord from './assets/withdrawal-record.png';
|
||||
import loadingOrbit from './assets/loading-orbit.png';
|
||||
import agencyHero from './assets/agency-hero.png';
|
||||
import agencyBackground from './assets/agency-bg.png';
|
||||
import agencyPersonPrimary from './assets/agency-person-primary.png';
|
||||
import agencyPersonSecondary from './assets/agency-person-secondary.png';
|
||||
import agencyPersonTertiary from './assets/agency-person-tertiary.png';
|
||||
import rulesBackground from './assets/rules-bg.png';
|
||||
import rulesBanner from './assets/rules-banner.png';
|
||||
import levelSilver from './assets/level-silver.png';
|
||||
import levelGold from './assets/level-gold.png';
|
||||
import levelPlatinum from './assets/level-platinum.png';
|
||||
import levelDiamond from './assets/level-diamond.png';
|
||||
import levelStar from './assets/level-star.png';
|
||||
import levelCrown from './assets/level-crown.png';
|
||||
import claimMachine from './assets/claim-machine.png';
|
||||
import claimAutoButton from './assets/claim-auto-button.png';
|
||||
import claimTotal from './assets/claim-total.png';
|
||||
import claimYesterday from './assets/claim-yesterday.png';
|
||||
import claimToday from './assets/claim-today.png';
|
||||
import claimCount from './assets/claim-count.png';
|
||||
import claimSpinner from './assets/claim-spinner.png';
|
||||
import claimMatchingGift from './assets/claim-matching-gift.png';
|
||||
import claimSuccess from './assets/claim-success.png';
|
||||
import claimFailed from './assets/claim-failed.png';
|
||||
import claimCancelled from './assets/claim-cancelled.png';
|
||||
import mallBanner from './assets/mall-banner.png';
|
||||
import mallBackground from './assets/mall-bg.png';
|
||||
import rankFirst from './assets/rank-first.png';
|
||||
import rankSecond from './assets/rank-second.png';
|
||||
import rankThird from './assets/rank-third.png';
|
||||
import rankBackground from './assets/rank-bg.png';
|
||||
import qualificationOperation from './assets/qualification-operation.png';
|
||||
import qualificationDevelopment from './assets/qualification-development.png';
|
||||
|
||||
export const mobileAssets = {
|
||||
homeHero,
|
||||
homeBalance,
|
||||
shortcutClaim,
|
||||
shortcutRecharge,
|
||||
shortcutWithdraw,
|
||||
shortcutMall,
|
||||
navHome,
|
||||
navHomeActive,
|
||||
navOrders,
|
||||
navOrdersActive,
|
||||
navClaimActive,
|
||||
navSupport,
|
||||
navSupportActive,
|
||||
navProfile,
|
||||
navProfileActive,
|
||||
profileBackground,
|
||||
profileWallet,
|
||||
menuProfile,
|
||||
menuRecharge,
|
||||
menuWithdraw,
|
||||
menuLedger,
|
||||
menuNotices,
|
||||
menuLogout,
|
||||
copy,
|
||||
supportHero,
|
||||
back,
|
||||
claimOrbit,
|
||||
claimGift,
|
||||
claimButton,
|
||||
processSimple,
|
||||
processMaterial,
|
||||
processTime,
|
||||
processReturn,
|
||||
chatVoice,
|
||||
chatMore,
|
||||
companyPlatform,
|
||||
companyRules,
|
||||
companyQualification,
|
||||
companyAgency,
|
||||
avatarEdit,
|
||||
rechargeRecord,
|
||||
withdrawalRecord,
|
||||
loadingOrbit,
|
||||
agencyHero,
|
||||
agencyBackground,
|
||||
agencyPersonPrimary,
|
||||
agencyPersonSecondary,
|
||||
agencyPersonTertiary,
|
||||
rulesBackground,
|
||||
rulesBanner,
|
||||
levelSilver,
|
||||
levelGold,
|
||||
levelPlatinum,
|
||||
levelDiamond,
|
||||
levelStar,
|
||||
levelCrown,
|
||||
claimMachine,
|
||||
claimAutoButton,
|
||||
claimTotal,
|
||||
claimYesterday,
|
||||
claimToday,
|
||||
claimCount,
|
||||
claimSpinner,
|
||||
claimMatchingGift,
|
||||
claimSuccess,
|
||||
claimFailed,
|
||||
claimCancelled,
|
||||
mallBanner,
|
||||
mallBackground,
|
||||
rankFirst,
|
||||
rankSecond,
|
||||
rankThird,
|
||||
rankBackground,
|
||||
qualificationOperation,
|
||||
qualificationDevelopment,
|
||||
};
|
||||
|
After Width: | Height: | Size: 939 KiB |
|
After Width: | Height: | Size: 291 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 810 B |
|
After Width: | Height: | Size: 346 B |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 842 B |
|
After Width: | Height: | Size: 1018 B |
|
After Width: | Height: | Size: 569 B |
|
After Width: | Height: | Size: 427 B |
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 603 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 434 KiB |
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 976 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 785 B |
|
After Width: | Height: | Size: 772 B |
|
After Width: | Height: | Size: 730 B |
|
After Width: | Height: | Size: 850 B |
|
After Width: | Height: | Size: 860 B |
|
After Width: | Height: | Size: 689 B |
|
After Width: | Height: | Size: 685 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 787 KiB |
|
After Width: | Height: | Size: 230 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 939 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 787 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 468 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div v-if="loading" class="m-state" role="status"><n-spin size="small" /><span>加载中</span></div>
|
||||
<div v-else-if="error" class="m-state m-state--error">
|
||||
<n-icon :component="CloudOfflineOutline" />
|
||||
<p>{{ error }}</p>
|
||||
<button class="m-link-btn" @click="$emit('retry')">重新加载</button>
|
||||
</div>
|
||||
<div v-else-if="empty" class="m-state">
|
||||
<n-icon :component="FileTrayOutline" />
|
||||
<p>{{ emptyText }}</p>
|
||||
</div>
|
||||
<slot v-else></slot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CloudOfflineOutline, FileTrayOutline } from '@vicons/ionicons5';
|
||||
withDefaults(
|
||||
defineProps<{ loading?: boolean; error?: string; empty?: boolean; emptyText?: string }>(),
|
||||
{
|
||||
loading: false,
|
||||
error: '',
|
||||
empty: false,
|
||||
emptyText: '暂无数据',
|
||||
}
|
||||
);
|
||||
defineEmits<{ retry: [] }>();
|
||||
</script>
|
||||
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<header class="m-page-header">
|
||||
<button v-if="back" class="m-icon-btn" aria-label="返回" @click="$router.back()">
|
||||
<img :src="mobileAssets.back" alt="" />
|
||||
</button>
|
||||
<span v-else class="m-header-space"></span>
|
||||
<h1>{{ title }}</h1>
|
||||
<slot name="action"><span class="m-header-space"></span></slot>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { mobileAssets } from '../assets';
|
||||
withDefaults(defineProps<{ title: string; back?: boolean }>(), { back: true });
|
||||
</script>
|
||||
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<section
|
||||
class="m-page m-agency-page"
|
||||
:style="{ backgroundImage: `url(${mobileAssets.agencyBackground})` }"
|
||||
>
|
||||
<PageHeader title="" />
|
||||
<img class="m-agency-hero" :src="mobileAssets.agencyHero" alt="邀请好友成为平台代理" />
|
||||
|
||||
<section class="m-agency-block">
|
||||
<h2><i>1</i>什么是平台代理?</h2>
|
||||
<p>平台用户可通过推荐新人成为平台代理,代理能够获得额外动态奖励。</p>
|
||||
<p>被推荐用户获得每日佣金后,代理按对应层级比例获得奖励。</p>
|
||||
</section>
|
||||
|
||||
<section class="m-agency-block">
|
||||
<h2><i>2</i>代理奖励规则</h2>
|
||||
<div class="m-agency-rewards">
|
||||
<article v-for="item in rewards" :key="item.level" :class="item.className">
|
||||
<img :src="item.icon" alt="" />
|
||||
<div><b>{{ item.level }}</b><strong>{{ item.rate }}</strong></div>
|
||||
<p>按该层用户<br />每日佣金计算</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="m-agency-block m-agency-relation">
|
||||
<h2><i>3</i>代理关系示意图</h2>
|
||||
<div class="m-agent-root">
|
||||
<img :src="mobileAssets.agencyPersonPrimary" alt="" /><span>您(代理)</span>
|
||||
</div>
|
||||
<div class="m-agent-tree">
|
||||
<div v-for="group in 3" :key="group" class="m-agent-branch">
|
||||
<div class="m-agent-first">
|
||||
<img :src="mobileAssets.agencyPersonPrimary" alt="" /><span>一级用户</span><b>奖励 16%</b>
|
||||
</div>
|
||||
<div class="m-agent-children">
|
||||
<div v-for="child in 2" :key="child">
|
||||
<img :src="mobileAssets.agencyPersonSecondary" alt="" /><span>二级</span><b>8%</b>
|
||||
<i></i>
|
||||
<img :src="mobileAssets.agencyPersonTertiary" alt="" /><strong>4%</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="m-agent-note">一级为您直接推荐的用户;二级、三级为其后续推荐用户。</p>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import { mobileAssets } from '../assets';
|
||||
|
||||
const rewards = [
|
||||
{ level: '一级用户', rate: '16%', className: 'is-primary', icon: mobileAssets.agencyPersonPrimary },
|
||||
{ level: '二级用户', rate: '8%', className: 'is-secondary', icon: mobileAssets.agencyPersonSecondary },
|
||||
{ level: '三级用户', rate: '4%', className: 'is-tertiary', icon: mobileAssets.agencyPersonTertiary },
|
||||
];
|
||||
</script>
|
||||
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<main class="m-app m-auth">
|
||||
<section class="m-auth-hero" aria-hidden="true">
|
||||
<div class="m-auth-mark"><span></span><span></span><span></span></div>
|
||||
<p>你好</p>
|
||||
<h1>欢迎你的到来</h1>
|
||||
</section>
|
||||
|
||||
<section class="m-auth-panel">
|
||||
<div class="m-auth-tabs" role="tablist">
|
||||
<button :class="{ active: mode === 'login' }" @click="mode = 'login'">账号登录</button>
|
||||
<button :class="{ active: mode === 'register' }" @click="mode = 'register'"
|
||||
>注册账号</button
|
||||
>
|
||||
</div>
|
||||
|
||||
<form class="m-form" @submit.prevent="submit">
|
||||
<label v-if="mode === 'register'" class="m-input-row">
|
||||
<n-icon :component="PersonOutline" />
|
||||
<input
|
||||
v-model.trim="form.username"
|
||||
autocomplete="username"
|
||||
maxlength="10"
|
||||
placeholder="请输入账户名"
|
||||
/>
|
||||
</label>
|
||||
<label class="m-input-row">
|
||||
<n-icon :component="PhonePortraitOutline" />
|
||||
<input
|
||||
v-model.trim="form.phone"
|
||||
inputmode="numeric"
|
||||
autocomplete="tel"
|
||||
maxlength="11"
|
||||
placeholder="请输入手机号"
|
||||
/>
|
||||
</label>
|
||||
<label class="m-input-row">
|
||||
<n-icon :component="LockClosedOutline" />
|
||||
<input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
:autocomplete="mode === 'login' ? 'current-password' : 'new-password'"
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
</label>
|
||||
<label v-if="mode === 'register'" class="m-input-row">
|
||||
<n-icon :component="MailOpenOutline" />
|
||||
<input
|
||||
v-model.trim="form.referral_code"
|
||||
maxlength="32"
|
||||
placeholder="请输入推荐码(选填)"
|
||||
/>
|
||||
</label>
|
||||
<router-link v-if="mode === 'login'" class="m-forgot-link" to="/app/password/verify"
|
||||
>忘记密码?</router-link
|
||||
>
|
||||
|
||||
<p v-if="error" class="m-form-error" role="alert">{{ error }}</p>
|
||||
<button class="m-primary-btn" type="submit" :disabled="submitting">
|
||||
{{ submitting ? '请稍候...' : mode === 'login' ? '登录' : '注册' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="mode === 'register'"
|
||||
class="m-outline-btn"
|
||||
type="button"
|
||||
@click="mode = 'login'"
|
||||
>已有账户,马上登录</button
|
||||
>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<label class="m-auth-agreement">
|
||||
<input v-model="accepted" type="checkbox" />
|
||||
<span>{{ mode === 'login' ? '登录即代表我已阅读并同意' : '我已阅读并同意' }}</span>
|
||||
<router-link to="/app/content/user_agreement">《用户协议》</router-link>
|
||||
<span>和</span>
|
||||
<router-link to="/app/content/privacy_policy">《隐私政策》</router-link>
|
||||
</label>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import {
|
||||
LockClosedOutline,
|
||||
MailOpenOutline,
|
||||
PersonOutline,
|
||||
PhonePortraitOutline,
|
||||
} from '@vicons/ionicons5';
|
||||
import { errorText, request, saveSession } from '../api';
|
||||
|
||||
type AuthResponse = { token: string };
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const mode = ref<'login' | 'register'>('login');
|
||||
const accepted = ref(false);
|
||||
const submitting = ref(false);
|
||||
const error = ref('');
|
||||
const form = reactive({ username: '', phone: '', password: '', referral_code: '' });
|
||||
|
||||
watch(mode, () => {
|
||||
form.username = '';
|
||||
form.phone = '';
|
||||
form.password = '';
|
||||
form.referral_code = '';
|
||||
accepted.value = false;
|
||||
error.value = '';
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
error.value = '';
|
||||
if (!/^1\d{10}$/.test(form.phone)) return void (error.value = '请输入正确的手机号码');
|
||||
if (form.password.length < 6) return void (error.value = '密码至少为 6 位');
|
||||
if (!accepted.value) return void (error.value = '请先阅读并同意用户协议和隐私政策');
|
||||
if (mode.value === 'register' && !/^[a-z0-9]{1,10}$/.test(form.username)) {
|
||||
return void (error.value = '账户名须为 1 至 10 位小写字母或数字');
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
const data = await request<AuthResponse>({
|
||||
url: `/v1/auth/${mode.value}`,
|
||||
method: 'POST',
|
||||
data:
|
||||
mode.value === 'login'
|
||||
? { phone: form.phone, password: form.password }
|
||||
: { ...form, privacy_accepted: true },
|
||||
});
|
||||
saveSession(data.token);
|
||||
const redirect =
|
||||
typeof route.query.redirect === 'string' && route.query.redirect.startsWith('/app/')
|
||||
? route.query.redirect
|
||||
: '/app/home';
|
||||
await router.replace(redirect);
|
||||
} catch (err) {
|
||||
error.value = errorText(err);
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,274 @@
|
||||
<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 || eligibility?.eligible === false"
|
||||
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="eligibility?.eligible === false" class="m-claim2-warning">
|
||||
<n-icon :component="AlertCircleOutline" />
|
||||
<span>{{ eligibility.message || '暂不满足领取条件' }}</span>
|
||||
<button v-if="eligibility.pending_order_id" type="button" @click="openPendingOrder">查看任务</button>
|
||||
</div>
|
||||
|
||||
<section class="m-claim2-rules">
|
||||
<h2><span></span>活动规则<span></span></h2>
|
||||
<ol>
|
||||
<li>平台运营时间每天 {{ businessHours }}。</li>
|
||||
<li>每一次储入请先咨询客服获取商家卡号,如有疑问请联系在线客服。</li>
|
||||
<li>领取后系统将自动分配任务,请按订单要求完成任务内容。</li>
|
||||
<li>领取时间根据会员等级不同,系统随机匹配订单。</li>
|
||||
<li>账户可用余额需达到 {{ money(eligibility?.minimum_amount) }} 才可领取。</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 class="m-match-progress"><i :style="{ width: `${matchProgress}%` }"></i></div>
|
||||
<strong class="m-match-percent">{{ matchProgress }}%</strong>
|
||||
<ol class="m-match-steps">
|
||||
<li v-for="(step, index) in matchSteps" :key="step" :class="{ active: index <= matchStep }">
|
||||
<span>✓</span>{{ step }}
|
||||
</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 { 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 order = ref<any>();
|
||||
const showResult = ref(false);
|
||||
const resultState = ref<ResultState>('matching');
|
||||
const failureMessage = ref('当前任务库存未分配成功,请稍后重试');
|
||||
const matchProgress = ref(18);
|
||||
const pollFinished = ref(false);
|
||||
let pollTimer: number | undefined;
|
||||
let progressTimer: 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 businessHours = computed(() => {
|
||||
const start = String(eligibility.value?.business_start_at || '09:00').slice(0, 5);
|
||||
const end = String(eligibility.value?.business_end_at || '21:00').slice(0, 5);
|
||||
return `${start}-${end}`;
|
||||
});
|
||||
const matchStep = computed(() => (matchProgress.value < 35 ? 0 : matchProgress.value < 60 ? 1 : matchProgress.value < 85 ? 2 : 3));
|
||||
const matchSteps = ['正在匹配任务', '获取商品信息', '确认任务内容', '发放任务结果'];
|
||||
const matchingHint = computed(() => 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 loadEligibility(halls.value[0].id);
|
||||
} catch (err) {
|
||||
error.value = errorText(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEligibility(hallID: number) {
|
||||
eligibility.value = await request({ url: '/v1/tasks/eligibility', params: { hall_id: hallID } });
|
||||
}
|
||||
|
||||
async function claim() {
|
||||
if (claiming.value || !halls.value.length) return;
|
||||
claiming.value = true;
|
||||
pollFinished.value = false;
|
||||
try {
|
||||
await loadEligibility(halls.value[0].id);
|
||||
if (!eligibility.value?.eligible) {
|
||||
window.$message.warning(eligibility.value?.message || '暂不满足领取条件');
|
||||
return;
|
||||
}
|
||||
openMatching();
|
||||
order.value = await request<any>({
|
||||
url: '/v1/task-orders/claim',
|
||||
method: 'POST',
|
||||
data: { hall_id: halls.value[0].id },
|
||||
});
|
||||
handleOrder(order.value);
|
||||
} 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;
|
||||
pollCount = 0;
|
||||
progressTimer = window.setInterval(() => {
|
||||
matchProgress.value = Math.min(88, matchProgress.value + Math.ceil(Math.random() * 5));
|
||||
}, 700);
|
||||
}
|
||||
|
||||
function handleOrder(value: any) {
|
||||
order.value = value;
|
||||
if (value?.status === 'matching') {
|
||||
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;
|
||||
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();
|
||||
}
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
function stopPolling(clearProgress = true) {
|
||||
if (pollTimer) window.clearTimeout(pollTimer);
|
||||
if (clearProgress && progressTimer) window.clearInterval(progressTimer);
|
||||
pollTimer = undefined;
|
||||
if (clearProgress) progressTimer = undefined;
|
||||
}
|
||||
|
||||
function closeResult() {
|
||||
showResult.value = false;
|
||||
stopPolling();
|
||||
}
|
||||
function openOrders() {
|
||||
closeResult();
|
||||
router.push('/app/orders');
|
||||
}
|
||||
function openPendingOrder() {
|
||||
const orderID = Number(eligibility.value?.pending_order_id || 0);
|
||||
router.push(orderID > 0 ? `/app/orders/${orderID}` : '/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>
|
||||
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<section class="m-page m-qualification">
|
||||
<PageHeader :title="content?.title || '公司资质'" />
|
||||
|
||||
<AsyncState :loading="loading">
|
||||
<div class="m-qualification-card">
|
||||
<header><span>01</span><h2>平台经营理念</h2></header>
|
||||
<strong>“多实惠,多乐趣”</strong>
|
||||
<p>{{ operationText }}</p>
|
||||
<img :src="mobileAssets.qualificationOperation" alt="平台经营理念" />
|
||||
</div>
|
||||
|
||||
<div class="m-qualification-card">
|
||||
<header><span>02</span><h2>平台发展理念</h2></header>
|
||||
<strong>“新国潮,新消费”</strong>
|
||||
<p>{{ developmentText }}</p>
|
||||
<img :src="mobileAssets.qualificationDevelopment" alt="平台发展理念" />
|
||||
</div>
|
||||
|
||||
<section class="m-qualification-brands">
|
||||
<header><span>03</span><h2>合作品牌</h2></header>
|
||||
<div>
|
||||
<b>HUAWEI<small>华为</small></b><b class="iqiyi">iQIYI<small>爱奇艺</small></b><b class="tencent">腾讯视频</b>
|
||||
<b class="midea">Midea<small>美的</small></b><b class="dly">达利园</b><b class="supor">SUPOR<small>苏泊尔</small></b>
|
||||
<b class="ctf">周大福</b><b class="xtep">X 特步</b><b class="anta">ANTA</b>
|
||||
</div>
|
||||
</section>
|
||||
</AsyncState>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import AsyncState from '../components/AsyncState.vue';
|
||||
import { errorText, request } from '../api';
|
||||
import { mobileAssets } from '../assets';
|
||||
|
||||
interface ContentData {
|
||||
title: string;
|
||||
summary: string;
|
||||
body_text: string;
|
||||
}
|
||||
|
||||
const content = ref<ContentData>();
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
const fallback = '我们致力于通过高效的供应链和优质的服务为用户带来更多实惠与更美好的生活乐趣,让品质好物走进更多人的生活。';
|
||||
const paragraphs = computed(() =>
|
||||
String(content.value?.body_text || '').split(/\n+/).map((item) => item.trim()).filter(Boolean),
|
||||
);
|
||||
const operationText = computed(() => content.value?.summary || paragraphs.value[0] || fallback);
|
||||
const developmentText = computed(() => paragraphs.value[1] || paragraphs.value[0] || fallback);
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
content.value = await request<ContentData>({ url: '/v1/contents/company_qualification' });
|
||||
} catch (err) {
|
||||
// The page remains usable with the approved copy while content is being configured.
|
||||
console.warn(errorText(err));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<section class="m-page m-page--white m-finance-records">
|
||||
<PageHeader title="记录" />
|
||||
<div class="m-finance-tabs" role="tablist">
|
||||
<button
|
||||
v-for="item in tabs"
|
||||
:key="item.value"
|
||||
:class="{ active: tab === item.value }"
|
||||
@click="changeTab(item.value)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</div>
|
||||
<AsyncState
|
||||
:loading="loading"
|
||||
:error="error"
|
||||
:empty="!visibleRecords.length"
|
||||
empty-text="暂无相关记录"
|
||||
@retry="load"
|
||||
>
|
||||
<div class="m-finance-list">
|
||||
<button v-for="item in visibleRecords" :key="`${item.type}-${item.id}`" type="button">
|
||||
<span class="m-finance-icon" :class="`is-${item.type}`">
|
||||
<img
|
||||
:src="item.type === 'recharge' ? mobileAssets.rechargeRecord : mobileAssets.withdrawalRecord"
|
||||
alt=""
|
||||
/>
|
||||
</span>
|
||||
<span class="m-finance-main">
|
||||
<b>{{ item.no }}</b>
|
||||
<time>{{ dateTime(item.created_at) }}</time>
|
||||
</span>
|
||||
<span class="m-finance-result">
|
||||
<b>{{ item.type === 'recharge' ? '+' : '-' }}{{ plainMoney(item.amount) }}</b>
|
||||
<em :class="item.success ? 'is-success' : 'is-failed'">{{ item.status_text }}</em>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</AsyncState>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import AsyncState from '../components/AsyncState.vue';
|
||||
import { dateTime, errorText, request } from '../api';
|
||||
import { mobileAssets } from '../assets';
|
||||
|
||||
type RecordType = 'all' | 'recharge' | 'withdrawal';
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const tabs: { label: string; value: RecordType }[] = [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '储入记录', value: 'recharge' },
|
||||
{ label: '取出记录', value: 'withdrawal' },
|
||||
];
|
||||
const requestedTab = String(route.query.tab || 'all');
|
||||
const tab = ref<RecordType>(
|
||||
requestedTab === 'recharge' || requestedTab === 'withdrawal' ? requestedTab : 'all'
|
||||
);
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
const records = ref<any[]>([]);
|
||||
const visibleRecords = computed(() =>
|
||||
tab.value === 'all' ? records.value : records.value.filter((item) => item.type === tab.value)
|
||||
);
|
||||
|
||||
const successful = new Set(['succeeded', 'completed', 'paid']);
|
||||
const plainMoney = (value: number) => (Number(value || 0) / 100).toFixed(2);
|
||||
function normalize(item: any, type: 'recharge' | 'withdrawal') {
|
||||
const success = successful.has(item.status);
|
||||
return {
|
||||
id: item.id,
|
||||
type,
|
||||
no: item.recharge_no || item.withdrawal_no || `SW${item.id}`,
|
||||
amount: item.request_amount || item.amount || 0,
|
||||
created_at: item.created_at,
|
||||
status_text:
|
||||
item.status_text || `${type === 'recharge' ? '储入' : '取出'}${success ? '成功' : '失败'}`,
|
||||
success,
|
||||
};
|
||||
}
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const [rechargeResult, withdrawalResult] = await Promise.allSettled([
|
||||
request<any>({
|
||||
url: '/v1/recharge-orders',
|
||||
params: { page: 1, page_size: 50, status: 'all' },
|
||||
}),
|
||||
request<any>({
|
||||
url: '/v1/withdrawal-orders',
|
||||
params: { page: 1, page_size: 50, status: 'all' },
|
||||
}),
|
||||
]);
|
||||
if (rechargeResult.status === 'rejected' && withdrawalResult.status === 'rejected') {
|
||||
throw withdrawalResult.reason;
|
||||
}
|
||||
const recharge = rechargeResult.status === 'fulfilled' ? rechargeResult.value.items || [] : [];
|
||||
const withdrawals =
|
||||
withdrawalResult.status === 'fulfilled' ? withdrawalResult.value.items || [] : [];
|
||||
records.value = [
|
||||
...recharge.map((item: any) => normalize(item, 'recharge')),
|
||||
...withdrawals.map((item: any) => normalize(item, 'withdrawal')),
|
||||
].sort((a, b) => String(b.created_at).localeCompare(String(a.created_at)));
|
||||
} catch (err) {
|
||||
error.value = errorText(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
async function changeTab(value: RecordType) {
|
||||
tab.value = value;
|
||||
await router.replace({ query: value === 'all' ? {} : { tab: value } });
|
||||
}
|
||||
onMounted(load);
|
||||
</script>
|
||||
@@ -0,0 +1,382 @@
|
||||
<template>
|
||||
<section
|
||||
class="m-page m-page--white m-subpage"
|
||||
:class="{ 'm-subpage--profile': formType === 'profile' }"
|
||||
:style="
|
||||
formType === 'profile'
|
||||
? { backgroundImage: `url(${mobileAssets.profileBackground})` }
|
||||
: undefined
|
||||
"
|
||||
>
|
||||
<PageHeader :title="title" />
|
||||
<AsyncState
|
||||
v-if="formType === 'profile'"
|
||||
:loading="loading"
|
||||
:error="error"
|
||||
@retry="loadProfile"
|
||||
>
|
||||
<div class="m-profile-edit">
|
||||
<div class="m-avatar m-avatar--large"
|
||||
><img
|
||||
v-if="safeImage(me?.avatar_url)"
|
||||
:src="safeImage(me.avatar_url)"
|
||||
alt="用户头像" /><n-icon v-else :component="PersonCircleOutline" /><img
|
||||
class="m-avatar-edit"
|
||||
:src="mobileAssets.avatarEdit"
|
||||
alt=""
|
||||
/></div>
|
||||
<div class="m-info-card m-card">
|
||||
<div
|
||||
><span>昵称</span><b>{{ me?.username }}</b></div
|
||||
><div
|
||||
><span>手机号码</span><b>{{ me?.phone }}</b></div
|
||||
><div
|
||||
><span>邀请码</span><b>{{ me?.invite_code }}</b></div
|
||||
>
|
||||
<router-link to="/app/profile/password"
|
||||
><span>密码管理</span><n-icon :component="ChevronForwardOutline"
|
||||
/></router-link>
|
||||
<router-link to="/app/profile/trade-password"
|
||||
><span>交易密码管理</span><n-icon :component="ChevronForwardOutline"
|
||||
/></router-link>
|
||||
</div>
|
||||
</div>
|
||||
</AsyncState>
|
||||
|
||||
<form v-else-if="formType === 'bank'" class="m-plain-form" @submit.prevent="saveBank">
|
||||
<label
|
||||
><span>姓名</span><input v-model.trim="bank.holder_name" placeholder="请输入姓名"
|
||||
/></label>
|
||||
<label
|
||||
><span>银行卡号</span
|
||||
><input v-model.trim="bank.account_no" inputmode="numeric" placeholder="请输入银行卡号"
|
||||
/></label>
|
||||
<label
|
||||
><span>开户行</span><input v-model.trim="bank.bank_name" placeholder="请输入开户行"
|
||||
/></label>
|
||||
<label
|
||||
><span>银行编码</span><input v-model.trim="bank.bank_code" placeholder="例如 ICBC"
|
||||
/></label>
|
||||
<label
|
||||
><span>预留手机号</span
|
||||
><input
|
||||
v-model.trim="bank.reserved_phone"
|
||||
inputmode="numeric"
|
||||
maxlength="11"
|
||||
placeholder="请输入预留手机号(选填)"
|
||||
/></label>
|
||||
<label
|
||||
><span>交易密码</span
|
||||
><input
|
||||
v-model="bank.trade_password"
|
||||
type="password"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
placeholder="请输入 6 位交易密码"
|
||||
/></label>
|
||||
<p class="m-form-hint">微信、支付宝二维码上传能力将在上传接口开放后提供。</p>
|
||||
<p v-if="error" class="m-form-error">{{ error }}</p
|
||||
><button class="m-primary-btn" :disabled="submitting">{{
|
||||
submitting ? '提交中...' : '确认绑定'
|
||||
}}</button>
|
||||
</form>
|
||||
|
||||
<form
|
||||
v-else-if="formType === 'tradePassword'"
|
||||
class="m-password-form"
|
||||
@submit.prevent="saveTradePassword"
|
||||
>
|
||||
<h2>设置支付密码</h2><p>支付密码用于收款账户绑定和取出确认</p>
|
||||
<div class="m-code-input"
|
||||
><input
|
||||
v-model="trade.new_trade_password"
|
||||
type="password"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
placeholder="请输入 6 位数字"
|
||||
/></div>
|
||||
<label class="m-input-row"
|
||||
><n-icon :component="LockClosedOutline" /><input
|
||||
v-model="trade.login_password"
|
||||
type="password"
|
||||
placeholder="请输入登录密码确认"
|
||||
/></label>
|
||||
<label class="m-input-row"
|
||||
><n-icon :component="KeyOutline" /><input
|
||||
v-model="trade.old_trade_password"
|
||||
type="password"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
placeholder="已设置过时请输入原支付密码"
|
||||
/></label>
|
||||
<p v-if="error" class="m-form-error">{{ error }}</p
|
||||
><button class="m-primary-btn" :disabled="submitting">{{
|
||||
submitting ? '提交中...' : '确定'
|
||||
}}</button>
|
||||
</form>
|
||||
|
||||
<form
|
||||
v-else-if="formType === 'loginPassword'"
|
||||
class="m-password-form"
|
||||
@submit.prevent="saveLoginPassword"
|
||||
>
|
||||
<h2>修改登录密码</h2><p>修改成功后需要重新登录</p>
|
||||
<label class="m-input-row"
|
||||
><n-icon :component="LockClosedOutline" /><input
|
||||
v-model="loginPassword.old_password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
placeholder="请输入当前密码"
|
||||
/></label>
|
||||
<label class="m-input-row"
|
||||
><n-icon :component="KeyOutline" /><input
|
||||
v-model="loginPassword.new_password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
placeholder="请输入新密码"
|
||||
/></label>
|
||||
<label class="m-input-row"
|
||||
><n-icon :component="KeyOutline" /><input
|
||||
v-model="loginPassword.confirm_password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
placeholder="请再次输入新密码"
|
||||
/></label>
|
||||
<p v-if="error" class="m-form-error">{{ error }}</p>
|
||||
<button class="m-primary-btn" :disabled="submitting">{{
|
||||
submitting ? '提交中...' : '确定修改'
|
||||
}}</button>
|
||||
</form>
|
||||
|
||||
<form v-else-if="formType === 'verify'" class="m-plain-form m-verify" @submit.prevent="goReset">
|
||||
<label
|
||||
><span>手机号</span
|
||||
><input
|
||||
v-model.trim="verify.phone"
|
||||
inputmode="numeric"
|
||||
maxlength="11"
|
||||
placeholder="请输入手机号"
|
||||
/></label>
|
||||
<label
|
||||
><span>验证码</span
|
||||
><input
|
||||
v-model.trim="verify.code"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
placeholder="请输入验证码"
|
||||
/><button type="button" @click="notifyUnavailable">获取验证码</button></label
|
||||
>
|
||||
<p v-if="error" class="m-form-error">{{ error }}</p
|
||||
><button class="m-primary-btn">确定</button>
|
||||
</form>
|
||||
|
||||
<form
|
||||
v-else-if="formType === 'reset'"
|
||||
class="m-password-form"
|
||||
@submit.prevent="notifyUnavailable"
|
||||
>
|
||||
<h2>重设密码</h2><p>请输入新的登录密码</p
|
||||
><label class="m-input-row"
|
||||
><n-icon :component="LockClosedOutline" /><input
|
||||
v-model="resetPassword"
|
||||
type="password"
|
||||
placeholder="请输入新密码" /></label
|
||||
><button class="m-primary-btn">确定</button>
|
||||
</form>
|
||||
|
||||
<AsyncState
|
||||
v-else
|
||||
:loading="loading"
|
||||
:error="error"
|
||||
:empty="!content"
|
||||
empty-text="暂无内容"
|
||||
@retry="loadContent"
|
||||
>
|
||||
<article v-if="content" class="m-content"
|
||||
><h1>{{ content.title }}</h1
|
||||
><p class="m-content-summary">{{ content.summary }}</p
|
||||
><div class="m-content-body">{{ content.body_text || '暂无正文' }}</div
|
||||
><time v-if="content.published_at"
|
||||
>发布时间:{{ dateTime(content.published_at) }}</time
|
||||
></article
|
||||
>
|
||||
</AsyncState>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import {
|
||||
ChevronForwardOutline,
|
||||
KeyOutline,
|
||||
LockClosedOutline,
|
||||
PersonCircleOutline,
|
||||
} from '@vicons/ionicons5';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import AsyncState from '../components/AsyncState.vue';
|
||||
import { clearSession, dateTime, errorText, request, safeImage } from '../api';
|
||||
import { mobileAssets } from '../assets';
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const formType = computed(() => String(route.meta.form || ''));
|
||||
const contentTitles: Record<string, string> = {
|
||||
recharge_records: '储入记录',
|
||||
withdrawal_records: '取出记录',
|
||||
wallet_ledgers: '账户明细',
|
||||
notices: '弹窗明细',
|
||||
};
|
||||
const title = computed(
|
||||
() =>
|
||||
content.value?.title ||
|
||||
contentTitles[String(route.params.key || '')] ||
|
||||
String(route.meta.title || '')
|
||||
);
|
||||
const loading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const error = ref('');
|
||||
const me = ref<any>();
|
||||
const content = ref<any>();
|
||||
const resetPassword = ref('');
|
||||
const bank = reactive({
|
||||
account_type: 'bank_card',
|
||||
bank_code: '',
|
||||
bank_name: '',
|
||||
holder_name: '',
|
||||
account_no: '',
|
||||
reserved_phone: '',
|
||||
trade_password: '',
|
||||
qr_attachment_id: 0,
|
||||
});
|
||||
const trade = reactive({ login_password: '', old_trade_password: '', new_trade_password: '' });
|
||||
const loginPassword = reactive({ old_password: '', new_password: '', confirm_password: '' });
|
||||
const verify = reactive({ phone: '', code: '' });
|
||||
async function loadProfile() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
me.value = await request({ url: '/v1/me' });
|
||||
} catch (err) {
|
||||
error.value = errorText(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
async function saveBank() {
|
||||
error.value = '';
|
||||
if (
|
||||
!bank.holder_name ||
|
||||
!/^[0-9 -]{8,36}$/.test(bank.account_no) ||
|
||||
!bank.bank_name ||
|
||||
!/^[0-9]{6}$/.test(bank.trade_password)
|
||||
) {
|
||||
error.value = '请完整填写姓名、银行卡、开户行和 6 位交易密码';
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
await request({ url: '/v1/payment-accounts', method: 'POST', data: bank });
|
||||
window.$message?.success('提交成功,等待后台核验');
|
||||
router.back();
|
||||
} catch (err) {
|
||||
error.value = errorText(err);
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
async function saveTradePassword() {
|
||||
error.value = '';
|
||||
if (!/^\d{6}$/.test(trade.new_trade_password) || trade.login_password.length < 6) {
|
||||
error.value = '请输入 6 位支付密码并填写登录密码';
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
await request({
|
||||
url: '/v1/me/trade-password',
|
||||
method: 'PUT',
|
||||
data: { ...trade, old_trade_password: trade.old_trade_password || undefined },
|
||||
});
|
||||
window.$message?.success('支付密码设置成功');
|
||||
router.back();
|
||||
} catch (err) {
|
||||
error.value = errorText(err);
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
async function saveLoginPassword() {
|
||||
error.value = '';
|
||||
if (loginPassword.old_password.length < 6 || loginPassword.new_password.length < 6) {
|
||||
error.value = '登录密码至少为 6 位';
|
||||
return;
|
||||
}
|
||||
if (loginPassword.new_password !== loginPassword.confirm_password) {
|
||||
error.value = '两次输入的新密码不一致';
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
await request({
|
||||
url: '/v1/me/password',
|
||||
method: 'PUT',
|
||||
data: {
|
||||
old_password: loginPassword.old_password,
|
||||
new_password: loginPassword.new_password,
|
||||
},
|
||||
});
|
||||
clearSession();
|
||||
window.$message?.success('密码修改成功,请重新登录');
|
||||
await router.replace('/app/auth');
|
||||
} catch (err) {
|
||||
error.value = errorText(err);
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
function notifyUnavailable() {
|
||||
window.$message?.warning('短信验证接口尚未开放');
|
||||
}
|
||||
function goReset() {
|
||||
if (!/^1\d{10}$/.test(verify.phone) || !/^\d{4,6}$/.test(verify.code)) {
|
||||
error.value = '请输入正确的手机号和验证码';
|
||||
return;
|
||||
}
|
||||
notifyUnavailable();
|
||||
}
|
||||
async function loadContent() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
const key = String(route.params.key || '');
|
||||
if (['recharge_records', 'wallet_ledgers', 'notices'].includes(key)) {
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (key === 'withdrawal_records') {
|
||||
const data = await request<any>({
|
||||
url: '/v1/withdrawal-orders',
|
||||
params: { page: 1, page_size: 20, status: 'all' },
|
||||
});
|
||||
content.value = {
|
||||
title: '取出记录',
|
||||
body_text: data.items?.length
|
||||
? data.items
|
||||
.map((x: any) => `${dateTime(x.created_at)} ${x.status_text || x.status}`)
|
||||
.join('\n')
|
||||
: '暂无取出记录',
|
||||
};
|
||||
} else {
|
||||
content.value = await request({ url: `/v1/contents/${key}` });
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = errorText(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
onMounted(() => {
|
||||
if (formType.value === 'profile') loadProfile();
|
||||
else if (formType.value === 'content') loadContent();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<section class="m-home">
|
||||
<div class="m-home-hero" :style="bannerStyle">
|
||||
<label class="m-search">
|
||||
<n-icon :component="SearchOutline" />
|
||||
<input readonly placeholder="搜索活动、商品、问题等" @click="router.push('/app/mall')" />
|
||||
</label>
|
||||
<div class="m-greeting">
|
||||
<h1>Hi,{{ home?.profile?.username || '会员' }}</h1>
|
||||
<p>一起创造最美好的生活</p>
|
||||
</div>
|
||||
<div class="m-balance-card">
|
||||
<div
|
||||
><span>我的积分账户</span
|
||||
><strong>{{ money(home?.wallet?.available_amount, home?.wallet?.currency) }}</strong></div
|
||||
>
|
||||
<div class="m-coin" aria-hidden="true">¥</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="m-home-body">
|
||||
<AsyncState :loading="loading" :error="error" @retry="load">
|
||||
<div class="m-shortcuts m-card">
|
||||
<button
|
||||
v-for="item in shortcuts"
|
||||
:key="item.label"
|
||||
:disabled="!item.enabled"
|
||||
@click="openShortcut(item)"
|
||||
>
|
||||
<span><img :src="item.icon" alt="" /></span><b>{{ item.label }}</b>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="home?.notices?.length" class="m-notice m-card">
|
||||
<n-icon :component="MegaphoneOutline" />
|
||||
<p>{{ home.notices[0].title || home.notices[0].content }}</p>
|
||||
</div>
|
||||
|
||||
<section class="m-section m-card m-home-ranking">
|
||||
<div class="m-section-title"
|
||||
><h2>荣誉榜</h2><router-link to="/app/leaderboard">更多 ›</router-link></div
|
||||
>
|
||||
<div v-if="leaderboard.length" class="m-home-rank-list">
|
||||
<router-link
|
||||
v-for="entry in leaderboard.slice(0, 4)"
|
||||
:key="entry.rank"
|
||||
to="/app/leaderboard"
|
||||
>
|
||||
<b :class="`rank-${entry.rank}`">{{ entry.rank === 1 ? '♛' : entry.rank }}</b>
|
||||
<span class="m-home-rank-avatar">
|
||||
<img v-if="safeImage(entry.avatar_url)" :src="safeImage(entry.avatar_url)" alt="" />
|
||||
<i v-else>{{ entry.username.slice(0, 1) }}</i>
|
||||
</span>
|
||||
<strong>{{ entry.username }}</strong>
|
||||
<em>{{ formatCount(entry.completed_count) }}</em>
|
||||
<small>{{ entry.status || '领取成功' }}</small>
|
||||
</router-link>
|
||||
</div>
|
||||
<p v-else class="m-home-rank-empty">暂无排行数据</p>
|
||||
</section>
|
||||
|
||||
<section class="m-section m-card">
|
||||
<div class="m-section-title"><h2>公司简介</h2></div>
|
||||
<div class="m-company-links">
|
||||
<router-link
|
||||
v-for="item in companyLinks"
|
||||
:key="item.label"
|
||||
:to="item.path"
|
||||
>
|
||||
<img :src="item.icon" alt="" /><span>{{ item.label }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</section>
|
||||
</AsyncState>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { SearchOutline, MegaphoneOutline } from '@vicons/ionicons5';
|
||||
import AsyncState from '../components/AsyncState.vue';
|
||||
import { errorText, money, request, safeImage } from '../api';
|
||||
import { mobileAssets } from '../assets';
|
||||
|
||||
interface HomeData {
|
||||
profile: { username: string; avatar_url: string; level_name: string };
|
||||
wallet: { available_amount: number; frozen_amount: number; currency: string };
|
||||
features: Record<string, boolean>;
|
||||
banners: Array<{ image_url?: string; title?: string }>;
|
||||
notices: Array<{ title?: string; content?: string }>;
|
||||
task_summary: {
|
||||
today_count: number;
|
||||
today_commission_amount: number;
|
||||
yesterday_commission_amount: number;
|
||||
};
|
||||
}
|
||||
interface LeaderboardEntry {
|
||||
rank: number;
|
||||
username: string;
|
||||
avatar_url: string;
|
||||
completed_count: number;
|
||||
status: string;
|
||||
}
|
||||
const router = useRouter();
|
||||
const home = ref<HomeData>();
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
const leaderboard = ref<LeaderboardEntry[]>([]);
|
||||
const formatCount = (value: number) => new Intl.NumberFormat('zh-CN').format(value || 0);
|
||||
const bannerStyle = computed(() => {
|
||||
const url = safeImage(home.value?.banners?.[0]?.image_url);
|
||||
return {
|
||||
backgroundImage: url
|
||||
? `linear-gradient(180deg, rgba(16,13,8,.18), rgba(16,13,8,.54)), url(${url})`
|
||||
: `url(${mobileAssets.homeHero})`,
|
||||
};
|
||||
});
|
||||
const shortcuts = computed(() => [
|
||||
{
|
||||
label: '领取',
|
||||
path: '/app/claim',
|
||||
icon: mobileAssets.shortcutClaim,
|
||||
enabled: home.value?.features?.task_enabled !== false,
|
||||
},
|
||||
{
|
||||
label: '储入',
|
||||
path: '/app/recharge',
|
||||
icon: mobileAssets.shortcutRecharge,
|
||||
enabled: home.value?.features?.recharge_enabled !== false,
|
||||
},
|
||||
{
|
||||
label: '取出',
|
||||
path: '/app/profile/withdrawal',
|
||||
icon: mobileAssets.shortcutWithdraw,
|
||||
enabled: home.value?.features?.withdrawal_enabled !== false,
|
||||
},
|
||||
{
|
||||
label: '商城',
|
||||
path: '/app/mall',
|
||||
icon: mobileAssets.shortcutMall,
|
||||
enabled: home.value?.features?.manual_mall_enabled !== false,
|
||||
},
|
||||
]);
|
||||
const companyLinks = [
|
||||
{
|
||||
label: '平台介绍',
|
||||
path: '/app/content/platform_intro',
|
||||
icon: mobileAssets.companyPlatform,
|
||||
},
|
||||
{ label: '规则说明', path: '/app/rules', icon: mobileAssets.companyRules },
|
||||
{
|
||||
label: '公司资质',
|
||||
path: '/app/qualification',
|
||||
icon: mobileAssets.companyQualification,
|
||||
},
|
||||
{ label: '代理合作', path: '/app/agency', icon: mobileAssets.companyAgency },
|
||||
];
|
||||
|
||||
function openShortcut(item: { enabled: boolean; path: string }) {
|
||||
if (item.enabled) router.push(item.path);
|
||||
else window.$message?.warning('该功能暂未开放');
|
||||
}
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const [homeData, rankData] = await Promise.all([
|
||||
request<HomeData>({ url: '/v1/home' }),
|
||||
request<LeaderboardEntry[]>({ url: '/v1/leaderboard' }).catch(() => []),
|
||||
]);
|
||||
home.value = homeData;
|
||||
leaderboard.value = rankData || [];
|
||||
} catch (err) {
|
||||
error.value = errorText(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
onMounted(load);
|
||||
</script>
|
||||
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<section class="m-leaderboard" :style="{ backgroundImage: `url(${mobileAssets.rankBackground})` }">
|
||||
<PageHeader title="荣誉榜" />
|
||||
<AsyncState
|
||||
:loading="loading"
|
||||
:error="error"
|
||||
:empty="!entries.length"
|
||||
empty-text="暂无排行数据"
|
||||
@retry="load"
|
||||
>
|
||||
<div class="m-rank-podium" aria-label="前三名">
|
||||
<article
|
||||
v-for="entry in podium"
|
||||
:key="entry.rank"
|
||||
class="m-rank-winner"
|
||||
:class="`rank-${entry.rank}`"
|
||||
:style="{ backgroundImage: `url(${podiumBackground(entry.rank)})` }"
|
||||
>
|
||||
<div class="m-rank-avatar">
|
||||
<img v-if="safeImage(entry.avatar_url)" :src="safeImage(entry.avatar_url)" alt="" />
|
||||
<span v-else>{{ entry.username.slice(0, 1) }}</span>
|
||||
</div>
|
||||
<b>{{ entry.rank }}</b>
|
||||
<strong>{{ entry.username }}</strong>
|
||||
<em>{{ number(entry.completed_count) }}</em>
|
||||
<small>{{ entry.status }}</small>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<section v-if="remaining.length" class="m-rank-table">
|
||||
<header><span>排名</span><span>用户昵称</span><span>领取数量</span><span>状态</span></header>
|
||||
<div v-for="entry in remaining" :key="entry.rank" class="m-rank-row">
|
||||
<b>{{ entry.rank }}</b>
|
||||
<div class="m-rank-user">
|
||||
<span class="m-rank-user-avatar">
|
||||
<img v-if="safeImage(entry.avatar_url)" :src="safeImage(entry.avatar_url)" alt="" />
|
||||
<i v-else>{{ entry.username.slice(0, 1) }}</i>
|
||||
</span>
|
||||
<span>{{ entry.username }}</span>
|
||||
</div>
|
||||
<strong>{{ number(entry.completed_count) }}</strong>
|
||||
<em>{{ entry.status.replace('成功', '') }}</em>
|
||||
</div>
|
||||
</section>
|
||||
</AsyncState>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import AsyncState from '../components/AsyncState.vue';
|
||||
import { errorText, request, safeImage } from '../api';
|
||||
import { mobileAssets } from '../assets';
|
||||
|
||||
interface LeaderboardEntry {
|
||||
rank: number;
|
||||
username: string;
|
||||
avatar_url: string;
|
||||
completed_count: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
const entries = ref<LeaderboardEntry[]>([]);
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
const podium = computed(() => [entries.value[2], entries.value[0], entries.value[1]].filter(Boolean));
|
||||
const remaining = computed(() => entries.value.slice(3));
|
||||
const number = (value: number) => new Intl.NumberFormat('zh-CN').format(value || 0);
|
||||
const podiumBackground = (rank: number) =>
|
||||
rank === 1 ? mobileAssets.rankFirst : rank === 2 ? mobileAssets.rankSecond : mobileAssets.rankThird;
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
entries.value = (await request<LeaderboardEntry[]>({ url: '/v1/leaderboard' })) || [];
|
||||
} catch (err) {
|
||||
error.value = errorText(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<section v-if="!isDetail" class="m-task-mall" :style="{ backgroundImage: `url(${mobileAssets.mallBackground})` }">
|
||||
<header class="m-mall-search">
|
||||
<h1>商城</h1>
|
||||
<label><n-icon :component="SearchOutline" /><input v-model.trim="keyword" placeholder="搜索商品" /></label>
|
||||
<button type="button" @click="applySearch">搜索</button>
|
||||
</header>
|
||||
|
||||
<img class="m-mall-banner" :src="mobileAssets.mallBanner" alt="精选任务,轻松赚收益" />
|
||||
|
||||
<div class="m-mall-tabs">
|
||||
<button :class="{ active: tab === 'recommended' }" @click="tab = 'recommended'">推荐任务</button>
|
||||
<button :class="{ active: tab === 'limited' }" @click="tab = 'limited'">限时任务</button>
|
||||
<n-icon :component="ChevronForwardOutline" />
|
||||
</div>
|
||||
|
||||
<AsyncState
|
||||
:loading="loading"
|
||||
:error="error"
|
||||
:empty="!visibleProducts.length"
|
||||
:empty-text="tab === 'limited' ? '暂无限时任务' : '暂无推荐任务'"
|
||||
@retry="loadList"
|
||||
>
|
||||
<div class="m-mall-list">
|
||||
<article v-for="item in visibleProducts" :key="item.id" class="m-mall-task">
|
||||
<button class="m-mall-cover" @click="openProduct(item.id)">
|
||||
<img v-if="safeImage(item.cover_url)" :src="safeImage(item.cover_url)" :alt="item.name" />
|
||||
<n-icon v-else :component="ImageOutline" />
|
||||
<span v-if="item.is_limited">限时展示</span>
|
||||
</button>
|
||||
<div>
|
||||
<h2>{{ item.name }}</h2>
|
||||
<strong>{{ money(item.price_amount, item.currency) }}<small>/件</small></strong>
|
||||
<p>已领取{{ item.claimed_count || 0 }}</p>
|
||||
</div>
|
||||
<button class="m-mall-claim" @click="openProduct(item.id)">{{ tab === 'limited' ? '抢单' : '领取' }}</button>
|
||||
</article>
|
||||
</div>
|
||||
</AsyncState>
|
||||
</section>
|
||||
|
||||
<section v-else class="m-page m-page--white m-mall-detail-page">
|
||||
<PageHeader title="商品详情" />
|
||||
<AsyncState
|
||||
:loading="loading"
|
||||
:error="error"
|
||||
:empty="!product"
|
||||
empty-text="商品不存在"
|
||||
@retry="loadDetail"
|
||||
>
|
||||
<article v-if="product" class="m-product-detail">
|
||||
<div class="m-product-hero">
|
||||
<img v-if="safeImage(product.cover_url)" :src="safeImage(product.cover_url)" :alt="product.name" />
|
||||
<n-icon v-else :component="ImageOutline" />
|
||||
</div>
|
||||
<section>
|
||||
<h1>{{ product.name }}</h1>
|
||||
<div class="m-price"><strong>{{ money(product.price_amount, product.currency) }}</strong><span>已领取 {{ product.claimed_count || 0 }} 份</span></div>
|
||||
</section>
|
||||
<section class="m-product-intro"><h2>项目介绍</h2><p>{{ product.detail_text || product.summary || '暂无详细介绍' }}</p></section>
|
||||
<section class="m-process">
|
||||
<h2>加工说明</h2>
|
||||
<div>
|
||||
<span><img :src="mobileAssets.processSimple" alt="" />操作简单</span>
|
||||
<span><img :src="mobileAssets.processMaterial" alt="" />材料配发</span>
|
||||
<span><img :src="mobileAssets.processTime" alt="" />时间自由</span>
|
||||
<span><img :src="mobileAssets.processReturn" alt="" />完成回收</span>
|
||||
</div>
|
||||
</section>
|
||||
<div class="m-buy-bar"><span>合计 <b>{{ money(product.price_amount, product.currency) }}</b></span><router-link to="/app/claim">立即领取</router-link></div>
|
||||
</article>
|
||||
</AsyncState>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { ChevronForwardOutline, ImageOutline, SearchOutline } from '@vicons/ionicons5';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import AsyncState from '../components/AsyncState.vue';
|
||||
import { errorText, money, request, safeImage } from '../api';
|
||||
import { mobileAssets } from '../assets';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const isDetail = computed(() => Boolean(route.params.id));
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
const products = ref<any[]>([]);
|
||||
const product = ref<any>();
|
||||
const keyword = ref('');
|
||||
const submittedKeyword = ref('');
|
||||
const tab = ref<'recommended' | 'limited'>('recommended');
|
||||
let listController: AbortController | undefined;
|
||||
|
||||
const isLimited = (item: any) => item.is_limited === true;
|
||||
const visibleProducts = computed(() => {
|
||||
let items = products.value;
|
||||
if (tab.value === 'limited') {
|
||||
items = items.filter(isLimited);
|
||||
} else {
|
||||
const recommended = items.filter((item) => item.is_recommended && !isLimited(item));
|
||||
items = recommended.length ? recommended : items.filter((item) => !isLimited(item));
|
||||
}
|
||||
const query = submittedKeyword.value.toLocaleLowerCase('zh-CN');
|
||||
return query ? items.filter((item) => String(item.name || '').toLocaleLowerCase('zh-CN').includes(query)) : items;
|
||||
});
|
||||
|
||||
function applySearch() {
|
||||
submittedKeyword.value = keyword.value;
|
||||
}
|
||||
function openProduct(id: number) {
|
||||
router.push(`/app/mall/${id}`);
|
||||
}
|
||||
async function loadList() {
|
||||
listController?.abort();
|
||||
const controller = new AbortController();
|
||||
listController = controller;
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const data = await request<any>({ url: '/v1/manual-products', params: { page: 1, page_size: 100 }, signal: controller.signal });
|
||||
products.value = data.items || [];
|
||||
} catch (err) {
|
||||
if (!controller.signal.aborted) error.value = errorText(err);
|
||||
} finally {
|
||||
if (listController === controller) loading.value = false;
|
||||
}
|
||||
}
|
||||
async function loadDetail() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
product.value = await request({ url: `/v1/manual-products/${route.params.id}` });
|
||||
} catch (err) {
|
||||
error.value = errorText(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
function load() {
|
||||
if (isDetail.value) void loadDetail();
|
||||
else void loadList();
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
onBeforeUnmount(() => listController?.abort());
|
||||
watch(() => route.params.id, load);
|
||||
</script>
|
||||
@@ -0,0 +1,255 @@
|
||||
<template>
|
||||
<section class="m-page m-page--white m-orders">
|
||||
<PageHeader :title="isDetail ? '详情页' : '记录'" :back="isDetail" />
|
||||
<template v-if="!isDetail">
|
||||
<div class="m-asset-banner">
|
||||
<div
|
||||
><span>余额可用资产</span><strong>{{ money(stats?.available_amount) }}</strong
|
||||
><small>数据来源:平台结算</small></div
|
||||
>
|
||||
<div class="m-yuan">¥</div>
|
||||
</div>
|
||||
<div class="m-tabs">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value"
|
||||
:class="{ active: status === tab.value }"
|
||||
@click="changeTab(tab.value)"
|
||||
>{{ tab.label }}</button
|
||||
>
|
||||
</div>
|
||||
<AsyncState
|
||||
:loading="loading"
|
||||
:error="error"
|
||||
:empty="!visibleOrders.length"
|
||||
empty-text="暂无相关记录"
|
||||
@retry="loadList"
|
||||
>
|
||||
<div class="m-order-list">
|
||||
<button
|
||||
v-for="item in visibleOrders"
|
||||
:key="item.id"
|
||||
class="m-order-card"
|
||||
@click="$router.push(`/app/orders/${item.id}`)"
|
||||
>
|
||||
<div class="m-order-meta"
|
||||
><time>{{ dateTime(item.created_at) }}</time
|
||||
><span :class="`status-${item.status}`">{{
|
||||
item.status_text || statusText(item.status)
|
||||
}}</span></div
|
||||
>
|
||||
<div class="m-order-main">
|
||||
<div class="m-product-image">
|
||||
<img
|
||||
v-if="safeImage(item.product?.cover_url)"
|
||||
:src="safeImage(item.product.cover_url)"
|
||||
:alt="item.product?.name"
|
||||
/>
|
||||
<n-icon v-else :component="ImageOutline" />
|
||||
</div>
|
||||
<div
|
||||
><h2>{{ item.product?.name || '待匹配商品' }}</h2
|
||||
><p>{{ money(item.total_amount, item.currency) }}</p
|
||||
><strong>佣金:{{ money(item.commission_amount, item.currency) }}</strong></div
|
||||
>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</AsyncState>
|
||||
</template>
|
||||
|
||||
<AsyncState
|
||||
v-else
|
||||
:loading="loading"
|
||||
:error="error"
|
||||
:empty="!detail"
|
||||
empty-text="记录不存在"
|
||||
@retry="loadDetail"
|
||||
>
|
||||
<div v-if="detail" class="m-detail">
|
||||
<section class="m-order-card">
|
||||
<div class="m-order-meta"
|
||||
><time>{{ dateTime(detail.created_at) }}</time
|
||||
><span :class="`status-${detail.status}`">{{
|
||||
detail.status_text || statusText(detail.status)
|
||||
}}</span></div
|
||||
>
|
||||
<div class="m-order-main">
|
||||
<div class="m-product-image"
|
||||
><img
|
||||
v-if="safeImage(detail.product?.cover_url)"
|
||||
:src="safeImage(detail.product.cover_url)"
|
||||
:alt="detail.product?.name" /><n-icon v-else :component="ImageOutline"
|
||||
/></div>
|
||||
<div
|
||||
><h2>{{ detail.product?.name || '待匹配商品' }}</h2
|
||||
><p>{{ money(detail.total_amount, detail.currency) }}</p
|
||||
><strong>佣金:{{ money(detail.commission_amount, detail.currency) }}</strong></div
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
<section class="m-detail-fields m-card">
|
||||
<div
|
||||
><span>记录编号</span><b>{{ detail.order_no }}</b></div
|
||||
>
|
||||
<div
|
||||
><span>创建时间</span><b>{{ dateTime(detail.created_at) }}</b></div
|
||||
>
|
||||
<div
|
||||
><span>数量</span><b>×{{ detail.quantity || 1 }}</b></div
|
||||
>
|
||||
<div
|
||||
><span>截止时间</span><b>{{ dateTime(detail.deadline_at) }}</b></div
|
||||
>
|
||||
</section>
|
||||
<section v-if="detail.task?.description || detail.message" class="m-rule-box m-card"
|
||||
><h2>任务说明</h2><p>{{ detail.task?.description || detail.message }}</p></section
|
||||
>
|
||||
<section v-if="detail.submission" class="m-detail-fields m-card">
|
||||
<div
|
||||
><span>提交状态</span
|
||||
><b>{{ detail.submission.status_text || detail.submission.status }}</b></div
|
||||
>
|
||||
<div v-if="detail.submission.review_reason"
|
||||
><span>处理说明</span><b>{{ detail.submission.review_reason }}</b></div
|
||||
>
|
||||
<div
|
||||
><span>提交时间</span><b>{{ dateTime(detail.submission.submitted_at) }}</b></div
|
||||
>
|
||||
</section>
|
||||
<form
|
||||
v-if="detail.allowed_actions?.includes('submit')"
|
||||
class="m-submit-form"
|
||||
@submit.prevent="submitTask"
|
||||
>
|
||||
<h2>提交任务凭证</h2>
|
||||
<textarea
|
||||
v-model.trim="evidence"
|
||||
maxlength="2000"
|
||||
placeholder="请填写任务完成说明"
|
||||
></textarea>
|
||||
<small>{{ evidence.length }}/2000,附件上传接口开放后可补充图片或 PDF</small>
|
||||
<button class="m-primary-btn" :disabled="submitting || !evidence">{{
|
||||
submitting ? '提交中...' : '提交审核'
|
||||
}}</button>
|
||||
</form>
|
||||
<router-link
|
||||
v-if="detail.allowed_actions?.includes('contact_support')"
|
||||
class="m-primary-btn m-btn-link"
|
||||
to="/app/support"
|
||||
>联系客服</router-link
|
||||
>
|
||||
</div>
|
||||
</AsyncState>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { ImageOutline } from '@vicons/ionicons5';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import AsyncState from '../components/AsyncState.vue';
|
||||
import { dateTime, errorText, money, request, safeImage } from '../api';
|
||||
|
||||
const route = useRoute();
|
||||
const isDetail = computed(() => Boolean(route.params.id));
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
const status = ref('all');
|
||||
const orders = ref<any[]>([]);
|
||||
const detail = ref<any>();
|
||||
const stats = ref<any>();
|
||||
const evidence = ref('');
|
||||
const submitting = ref(false);
|
||||
let listController: AbortController | undefined;
|
||||
const tabs = [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '待处理', value: 'pending' },
|
||||
{ label: '已完成', value: 'completed' },
|
||||
];
|
||||
const pendingStatuses = new Set(['matching', 'pending', 'processing', 'frozen']);
|
||||
const visibleOrders = computed(() => {
|
||||
if (status.value === 'all') return orders.value;
|
||||
if (status.value === 'pending') {
|
||||
return orders.value.filter((item) => pendingStatuses.has(item.status));
|
||||
}
|
||||
return orders.value.filter((item) => item.status === status.value);
|
||||
});
|
||||
const labels: Record<string, string> = {
|
||||
matching: '匹配中',
|
||||
pending: '待处理',
|
||||
processing: '处理中',
|
||||
completed: '已完成',
|
||||
expired: '已超时',
|
||||
frozen: '已冻结',
|
||||
cancelled: '已取消',
|
||||
failed: '失败',
|
||||
};
|
||||
const statusText = (value: string) => labels[value] || value;
|
||||
async function loadList() {
|
||||
listController?.abort();
|
||||
const controller = new AbortController();
|
||||
listController = controller;
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const [list, stat] = await Promise.all([
|
||||
request<any>({
|
||||
url: '/v1/task-orders',
|
||||
params: { page: 1, page_size: 100, status: 'all' },
|
||||
signal: controller.signal,
|
||||
}),
|
||||
request<any>({ url: '/v1/tasks/stats' }),
|
||||
]);
|
||||
orders.value = list.items || [];
|
||||
stats.value = stat;
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) return;
|
||||
error.value = errorText(err);
|
||||
orders.value = [];
|
||||
} finally {
|
||||
if (listController === controller) loading.value = false;
|
||||
}
|
||||
}
|
||||
async function submitTask() {
|
||||
if (!evidence.value || submitting.value) return;
|
||||
submitting.value = true;
|
||||
try {
|
||||
await request({
|
||||
url: `/v1/task-orders/${route.params.id}/submissions`,
|
||||
method: 'POST',
|
||||
data: { evidence_text: evidence.value, evidence_attachment_ids: [] },
|
||||
});
|
||||
window.$message?.success('提交成功,等待审核');
|
||||
evidence.value = '';
|
||||
await loadDetail();
|
||||
} catch (err) {
|
||||
window.$message?.error(errorText(err));
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
async function loadDetail() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
detail.value = await request({ url: `/v1/task-orders/${route.params.id}` });
|
||||
} catch (err) {
|
||||
error.value = errorText(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
function changeTab(value: string) {
|
||||
status.value = value;
|
||||
loadList();
|
||||
}
|
||||
function load() {
|
||||
if (isDetail.value) loadDetail();
|
||||
else loadList();
|
||||
}
|
||||
onMounted(load);
|
||||
onBeforeUnmount(() => listController?.abort());
|
||||
watch(() => route.params.id, load);
|
||||
</script>
|
||||
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<section class="m-platform-intro">
|
||||
<PageHeader title="平台介绍" />
|
||||
|
||||
<header class="m-platform-intro-head">
|
||||
<span>OLONS PLATFORM</span>
|
||||
<h2>平台简介</h2>
|
||||
<p>{{ platformIntroLead }}</p>
|
||||
<div><b>{{ platformIntroSections.length }}</b><small>服务条款</small><i></i><b>{{ platformFAQs.length }}</b><small>常见问题</small></div>
|
||||
</header>
|
||||
|
||||
<nav class="m-platform-intro-nav" aria-label="页面目录">
|
||||
<button :class="{ active: activeSection === 'service-terms' }" @click="scrollToSection('service-terms')">服务条款</button>
|
||||
<button :class="{ active: activeSection === 'common-questions' }" @click="scrollToSection('common-questions')">常见问题</button>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<section id="service-terms" class="m-platform-document">
|
||||
<div class="m-platform-section-title"><span>01</span><div><small>TERMS</small><h2>服务条款</h2></div></div>
|
||||
<article v-for="section in platformIntroSections" :key="section.title">
|
||||
<h3>{{ section.title }}</h3>
|
||||
<p v-for="paragraph in section.paragraphs" :key="paragraph">{{ paragraph }}</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section id="common-questions" class="m-platform-document m-platform-faq">
|
||||
<div class="m-platform-section-title"><span>02</span><div><small>FAQ</small><h2>常见问题</h2></div></div>
|
||||
<article v-for="item in platformFAQs" :key="item.title">
|
||||
<h3>{{ item.title }}</h3>
|
||||
<p v-for="paragraph in item.paragraphs" :key="paragraph">{{ paragraph }}</p>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import { platformFAQs, platformIntroLead, platformIntroSections } from './platformIntroContent';
|
||||
|
||||
const activeSection = ref('service-terms');
|
||||
|
||||
function scrollToSection(id: string) {
|
||||
activeSection.value = id;
|
||||
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<section class="m-profile" :style="{ backgroundImage: `url(${mobileAssets.profileBackground})` }">
|
||||
<PageHeader title="我的" :back="false" />
|
||||
<AsyncState :loading="loading" :error="error" @retry="load">
|
||||
<div class="m-profile-head">
|
||||
<div class="m-avatar">
|
||||
<img v-if="safeImage(me?.avatar_url)" :src="safeImage(me?.avatar_url)" alt="用户头像" />
|
||||
<n-icon v-else :component="PersonCircleOutline" />
|
||||
</div>
|
||||
<div
|
||||
><h2>{{ me?.username }}</h2
|
||||
><button @click="copyInvite"
|
||||
>邀请码:{{ me?.invite_code }} <img :src="mobileAssets.copy" alt="复制" /></button
|
||||
></div>
|
||||
</div>
|
||||
<div class="m-wallet-card" :style="{ backgroundImage: `url(${mobileAssets.profileWallet})` }">
|
||||
<div
|
||||
><span>账户余额</span
|
||||
><strong>{{ money(me?.wallet?.available_amount, me?.wallet?.currency) }}</strong></div
|
||||
>
|
||||
<div class="m-wallet-actions">
|
||||
<router-link to="/app/profile/withdrawal">立即取出</router-link>
|
||||
<router-link to="/app/recharge">立即储入</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<div class="m-menu m-card">
|
||||
<router-link v-for="item in menu" :key="item.label" :to="item.path">
|
||||
<img :src="item.icon" alt="" /><span>{{ item.label }}</span
|
||||
><n-icon class="m-menu-arrow" :component="ChevronForwardOutline" />
|
||||
</router-link>
|
||||
<button @click="showLogout = true"
|
||||
><img :src="mobileAssets.menuLogout" alt="" /><span>退出登录</span
|
||||
><n-icon class="m-menu-arrow" :component="ChevronForwardOutline"
|
||||
/></button>
|
||||
</div>
|
||||
</AsyncState>
|
||||
|
||||
<n-modal v-model:show="showLogout">
|
||||
<div class="m-confirm">
|
||||
<h3>提示</h3><p>确定退出登录?</p>
|
||||
<div
|
||||
><button @click="showLogout = false">取消</button
|
||||
><button class="danger" @click="logout">确定</button></div
|
||||
>
|
||||
</div>
|
||||
</n-modal>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { PersonCircleOutline, ChevronForwardOutline } from '@vicons/ionicons5';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import AsyncState from '../components/AsyncState.vue';
|
||||
import { clearSession, errorText, money, request, safeImage } from '../api';
|
||||
import { mobileAssets } from '../assets';
|
||||
|
||||
const router = useRouter();
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
const me = ref<any>();
|
||||
const showLogout = ref(false);
|
||||
const menu = [
|
||||
{ label: '个人信息', path: '/app/profile/info', icon: mobileAssets.menuProfile },
|
||||
{
|
||||
label: '储入记录',
|
||||
path: '/app/profile/records?tab=recharge',
|
||||
icon: mobileAssets.menuRecharge,
|
||||
},
|
||||
{
|
||||
label: '取出记录',
|
||||
path: '/app/profile/records?tab=withdrawal',
|
||||
icon: mobileAssets.menuWithdraw,
|
||||
},
|
||||
{ label: '账户明细', path: '/app/content/wallet_ledgers', icon: mobileAssets.menuLedger },
|
||||
{ label: '弹窗明细', path: '/app/content/notices', icon: mobileAssets.menuNotices },
|
||||
];
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
me.value = await request({ url: '/v1/me' });
|
||||
} catch (err) {
|
||||
error.value = errorText(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
async function copyInvite() {
|
||||
if (!me.value?.invite_code) return;
|
||||
await navigator.clipboard.writeText(me.value.invite_code);
|
||||
window.$message?.success('邀请码已复制');
|
||||
}
|
||||
async function logout() {
|
||||
try {
|
||||
await request({ url: '/v1/auth/logout', method: 'POST' });
|
||||
} catch {}
|
||||
clearSession();
|
||||
router.replace('/app/auth');
|
||||
}
|
||||
onMounted(load);
|
||||
</script>
|
||||
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<section class="m-page m-page--white m-recharge-page">
|
||||
<PageHeader title="储入" />
|
||||
<AsyncState :loading="loading" :error="error" @retry="load">
|
||||
<div class="m-level-table">
|
||||
<div class="m-level-row m-level-head">
|
||||
<b>等级名称</b><b>金额</b><b>赠送</b><b>单数</b><b>佣金比例</b>
|
||||
</div>
|
||||
<div v-for="level in levels" :key="level.id" class="m-level-row">
|
||||
<span>{{ level.name }}</span>
|
||||
<span>{{ yuan(level.upgrade_amount) }}</span>
|
||||
<span>{{ yuan(level.gift_amount) }}</span>
|
||||
<span>{{ level.daily_order_limit }}</span>
|
||||
<span>{{ rate(level.commission_rate) }}</span>
|
||||
</div>
|
||||
<div v-if="!levels.length" class="m-level-empty">暂无可用等级</div>
|
||||
</div>
|
||||
|
||||
<form class="m-recharge-form" @submit.prevent="submit">
|
||||
<label>
|
||||
<span>储入方式:</span>
|
||||
<select v-model.number="form.channelId" :disabled="submitting">
|
||||
<option :value="0" disabled>请选择储入方式</option>
|
||||
<option v-for="channel in channels" :key="channel.id" :value="channel.id">
|
||||
{{ channel.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>输入金额:</span>
|
||||
<input v-model.trim="form.amount" :disabled="submitting" inputmode="decimal" placeholder="请输入金额" />
|
||||
</label>
|
||||
<label>
|
||||
<span>姓名:</span>
|
||||
<input v-model.trim="form.name" :disabled="submitting" maxlength="32" placeholder="请输入姓名" />
|
||||
</label>
|
||||
<p v-if="formError" class="m-form-error">{{ formError }}</p>
|
||||
<button class="m-primary-btn" :disabled="submitting || !channels.length">
|
||||
{{ submitting ? '提交中...' : '提交' }}
|
||||
</button>
|
||||
</form>
|
||||
</AsyncState>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import AsyncState from '../components/AsyncState.vue';
|
||||
import { errorText, request } from '../api';
|
||||
|
||||
const levels = ref<any[]>([]);
|
||||
const channels = ref<any[]>([]);
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
const formError = ref('');
|
||||
const submitting = ref(false);
|
||||
const form = reactive({ channelId: 0, amount: '', name: '' });
|
||||
|
||||
const yuan = (value: number) => `${Number(value || 0) / 100}元`;
|
||||
const rate = (value: number) => String(Number(value || 0) / 10000);
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const [levelRows, channelRows] = await Promise.all([
|
||||
request<any[]>({ url: '/v1/member-levels' }),
|
||||
request<any[]>({ url: '/v1/recharge/channels' }),
|
||||
]);
|
||||
levels.value = levelRows;
|
||||
channels.value = channelRows;
|
||||
if (!form.channelId && channelRows.length) form.channelId = channelRows[0].id;
|
||||
} catch (err) {
|
||||
error.value = errorText(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
async function submit() {
|
||||
formError.value = '';
|
||||
if (!form.channelId) {
|
||||
formError.value = '请选择储入方式';
|
||||
return;
|
||||
}
|
||||
if (!/^\d+(?:\.\d{1,2})?$/.test(form.amount) || Number(form.amount) <= 0) {
|
||||
formError.value = '请输入正确的储入金额';
|
||||
return;
|
||||
}
|
||||
if (!form.name) {
|
||||
formError.value = '请输入姓名';
|
||||
return;
|
||||
}
|
||||
const amount = Math.round(Number(form.amount) * 100);
|
||||
submitting.value = true;
|
||||
try {
|
||||
const order = await request<any>({
|
||||
url: '/v1/recharge-orders',
|
||||
method: 'POST',
|
||||
data: {
|
||||
channel_id: form.channelId,
|
||||
amount,
|
||||
currency: 'CNY',
|
||||
voucher_attachment_ids: [],
|
||||
remark: `姓名:${form.name}`,
|
||||
},
|
||||
});
|
||||
window.$message?.success(`储入订单 ${order.recharge_no} 已创建`);
|
||||
form.amount = '';
|
||||
form.name = '';
|
||||
} catch (err) {
|
||||
formError.value = errorText(err);
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
onMounted(load);
|
||||
</script>
|
||||
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<section
|
||||
class="m-page m-rules-page"
|
||||
:style="{ backgroundImage: `url(${mobileAssets.rulesBackground})` }"
|
||||
>
|
||||
<PageHeader title="规则说明" />
|
||||
|
||||
<div class="m-rules-banner" :style="{ backgroundImage: `url(${mobileAssets.rulesBanner})` }">
|
||||
<span>充值赠送金</span>
|
||||
<p><b>充值<strong>500元</strong></b><b>赠送<strong>20元</strong></b></p>
|
||||
</div>
|
||||
|
||||
<div class="m-rules-table" role="table" aria-label="会员规则">
|
||||
<div class="m-rules-row m-rules-head" role="row">
|
||||
<b>充值金额</b><b>会员等级</b><b>佣金比例</b><b>每日代购</b><b>额外奖励</b><b>单次提现</b>
|
||||
</div>
|
||||
<div v-for="level in levels" :key="level.name" class="m-rules-row" role="row">
|
||||
<span>{{ level.amount }}</span>
|
||||
<span class="m-rule-level"><img :src="level.icon" alt="" />{{ level.name }}</span>
|
||||
<span>{{ level.rate }}</span>
|
||||
<span>{{ level.orders }}</span>
|
||||
<span>{{ level.bonus }}</span>
|
||||
<span>{{ level.withdrawal }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="m-rules-notes">
|
||||
<h2><span>03</span> 规则补充说明</h2>
|
||||
<ul>
|
||||
<li>达到对应单次充值门槛,赠送所列永久会员等级。</li>
|
||||
<li>每日代购数量、额外奖励及提现额度以对应档位为准。</li>
|
||||
<li>星耀、至尊档位原文佣金比例前后不一致,需核实。</li>
|
||||
<li>奖励领取条件、实际结算及提现限制请以正式规则为准。</li>
|
||||
</ul>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import { mobileAssets } from '../assets';
|
||||
|
||||
const levels = [
|
||||
{ amount: '≥ 1888元', name: '白银', rate: '0.02', orders: '20单', bonus: '88元', withdrawal: '5000以下', icon: mobileAssets.levelSilver },
|
||||
{ amount: '≥ 10000元', name: '黄金', rate: '0.022', orders: '30单', bonus: '188元', withdrawal: '20000以下', icon: mobileAssets.levelGold },
|
||||
{ amount: '≥ 30000元', name: '铂金', rate: '0.03', orders: '40单', bonus: '388元', withdrawal: '30000以下', icon: mobileAssets.levelPlatinum },
|
||||
{ amount: '≥ 50000元', name: '钻石', rate: '0.035', orders: '50单', bonus: '588元', withdrawal: '50000以下', icon: mobileAssets.levelDiamond },
|
||||
{ amount: '≥ 80000元', name: '星耀', rate: '0.04', orders: '80单', bonus: '1888元', withdrawal: '100000以下', icon: mobileAssets.levelStar },
|
||||
{ amount: '≥ 180000元', name: '至尊', rate: '0.05', orders: '100单', bonus: '2888元', withdrawal: '无限制', icon: mobileAssets.levelCrown },
|
||||
];
|
||||
</script>
|
||||
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<section v-if="!isChat" class="m-support">
|
||||
<div class="m-support-hero" :style="{ backgroundImage: `url(${mobileAssets.supportHero})` }">
|
||||
<PageHeader title="客服中心" :back="false" />
|
||||
<div><h1>在线客服</h1><p>账户与业务问题请联系在线客服,全程跟进处理</p></div>
|
||||
</div>
|
||||
<div class="m-support-body">
|
||||
<AsyncState
|
||||
:loading="loading"
|
||||
:error="error"
|
||||
:empty="!services.length"
|
||||
empty-text="在线客服暂未开放"
|
||||
@retry="load"
|
||||
>
|
||||
<button
|
||||
v-for="item in services"
|
||||
:key="item.id"
|
||||
class="m-service-card"
|
||||
@click="$router.push(`/app/support/${item.id}`)"
|
||||
>
|
||||
<div class="m-service-avatar"
|
||||
><img
|
||||
v-if="safeImage(item.avatar_url)"
|
||||
:src="safeImage(item.avatar_url)"
|
||||
:alt="item.name" /><n-icon v-else :component="HeadsetOutline"
|
||||
/></div>
|
||||
<div
|
||||
><h2>{{ item.name }}</h2
|
||||
><p>工作时间:{{ item.work_time || '以客服在线状态为准' }}</p
|
||||
><small v-if="item.backup_contact">{{ item.backup_contact }}</small></div
|
||||
>
|
||||
<span :class="`is-${item.online_status}`">{{ statusText(item.online_status) }}</span>
|
||||
</button>
|
||||
</AsyncState>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else class="m-chat">
|
||||
<PageHeader :title="current?.name || '在线客服'" />
|
||||
<div class="m-chat-time">会话内容由客服系统提供</div>
|
||||
<div class="m-chat-empty">
|
||||
<n-icon :component="ChatbubbleEllipsesOutline" />
|
||||
<h2>正在连接客服</h2>
|
||||
<p>{{ current?.privacy_notice || '请勿在聊天中发送登录密码或支付密码' }}</p>
|
||||
<a
|
||||
v-if="safeServiceUrl"
|
||||
:href="safeServiceUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="m-primary-btn m-btn-link"
|
||||
>进入客服会话</a
|
||||
>
|
||||
<p v-else class="m-form-error">客服会话地址尚未配置</p>
|
||||
</div>
|
||||
<div class="m-chat-input"
|
||||
><button aria-label="语音"><img :src="mobileAssets.chatVoice" alt="" /></button
|
||||
><input disabled placeholder="请先连接客服会话" /><button aria-label="更多"
|
||||
><img :src="mobileAssets.chatMore" alt="" /></button
|
||||
></div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { ChatbubbleEllipsesOutline, HeadsetOutline } from '@vicons/ionicons5';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import AsyncState from '../components/AsyncState.vue';
|
||||
import { errorText, request, safeImage } from '../api';
|
||||
import { mobileAssets } from '../assets';
|
||||
const route = useRoute();
|
||||
const isChat = computed(() => Boolean(route.params.id));
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
const services = ref<any[]>([]);
|
||||
const current = computed(
|
||||
() =>
|
||||
services.value.find((item) => String(item.id) === String(route.params.id)) ||
|
||||
services.value[0]
|
||||
);
|
||||
const safeServiceUrl = computed(() => {
|
||||
const value = current.value?.service_url;
|
||||
if (!value) return '';
|
||||
try {
|
||||
const u = new URL(value);
|
||||
return u.protocol === 'https:' ? u.href : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
});
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
services.value = await request<any[]>({ url: '/v1/customer-services' });
|
||||
} catch (err) {
|
||||
error.value = errorText(err);
|
||||
services.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
function statusText(status: string) {
|
||||
if (status === 'online') return '在线咨询';
|
||||
if (status === 'busy') return '忙碌';
|
||||
return '离线留言';
|
||||
}
|
||||
onMounted(load);
|
||||
</script>
|
||||
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<section class="m-page m-page--white m-withdrawal-page">
|
||||
<div class="m-withdrawal-hero">
|
||||
<PageHeader title="取出" />
|
||||
<p>可提现金额(元)</p>
|
||||
<strong>{{ money(me?.wallet?.available_amount, me?.wallet?.currency) }}</strong>
|
||||
</div>
|
||||
|
||||
<AsyncState :loading="loading" :error="loadError" @retry="load">
|
||||
<form class="m-withdrawal-panel" @submit.prevent="startWithdrawal">
|
||||
<label for="withdrawal-amount">提现金额</label>
|
||||
<div class="m-withdrawal-amount">
|
||||
<b>¥</b>
|
||||
<input
|
||||
id="withdrawal-amount"
|
||||
v-model.trim="form.amountYuan"
|
||||
inputmode="decimal"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
<button type="button" @click="fillAll">全部提现</button>
|
||||
</div>
|
||||
<button class="m-withdrawal-account" type="button" @click="openAccountPicker">
|
||||
<span>提现至</span>
|
||||
<span v-if="selectedAccount" class="m-bank-choice">
|
||||
<i>{{ bankMark(selectedAccount.bank_name) }}</i>
|
||||
{{ selectedAccount.bank_name || selectedAccount.account_type }}
|
||||
<small>{{ selectedAccount.account_no_mask }}</small>
|
||||
</span>
|
||||
<span v-else>去绑定银行卡</span>
|
||||
<n-icon :component="ChevronForwardOutline" />
|
||||
</button>
|
||||
<p v-if="actionError" class="m-form-error">{{ actionError }}</p>
|
||||
<button class="m-primary-btn" :disabled="submitting">
|
||||
{{ submitting ? '加载中...' : '申请提现' }}
|
||||
</button>
|
||||
</form>
|
||||
</AsyncState>
|
||||
|
||||
<div v-if="submitting" class="m-withdrawal-loading" role="status">
|
||||
<img :src="mobileAssets.loadingOrbit" alt="" />
|
||||
<span>加载中</span>
|
||||
</div>
|
||||
|
||||
<n-modal v-model:show="showConfirm">
|
||||
<form class="m-withdraw-confirm m-card" @submit.prevent="submitWithdrawal">
|
||||
<h2>确认取出</h2>
|
||||
<div><span>申请金额</span><b>{{ money(quote?.request_amount) }}</b></div>
|
||||
<div><span>手续费</span><b>{{ money(quote?.fee_amount) }}</b></div>
|
||||
<div><span>预计到账</span><strong>{{ money(quote?.actual_amount) }}</strong></div>
|
||||
<label>
|
||||
<span>支付密码</span>
|
||||
<input
|
||||
v-model="form.tradePassword"
|
||||
type="password"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
placeholder="请输入 6 位支付密码"
|
||||
/>
|
||||
</label>
|
||||
<p v-if="actionError" class="m-form-error">{{ actionError }}</p>
|
||||
<button class="m-primary-btn" :disabled="submitting">确认申请</button>
|
||||
</form>
|
||||
</n-modal>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ChevronForwardOutline } from '@vicons/ionicons5';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import AsyncState from '../components/AsyncState.vue';
|
||||
import { errorText, idempotencyKey, money, request } from '../api';
|
||||
import { mobileAssets } from '../assets';
|
||||
|
||||
const router = useRouter();
|
||||
const loading = ref(true);
|
||||
const submitting = ref(false);
|
||||
const loadError = ref('');
|
||||
const actionError = ref('');
|
||||
const me = ref<any>();
|
||||
const accounts = ref<any[]>([]);
|
||||
const quote = ref<any>();
|
||||
const showConfirm = ref(false);
|
||||
const withdrawalKey = ref('');
|
||||
const form = reactive({ paymentAccountId: 0, amountYuan: '', tradePassword: '' });
|
||||
const selectedAccount = computed(() =>
|
||||
accounts.value.find((item) => item.id === form.paymentAccountId)
|
||||
);
|
||||
|
||||
function amountInCents(value: string) {
|
||||
const match = value.match(/^(\d{1,9})(?:\.(\d{1,2}))?$/);
|
||||
return match ? Number(match[1]) * 100 + Number((match[2] || '').padEnd(2, '0')) : 0;
|
||||
}
|
||||
function bankMark(name?: string) {
|
||||
return String(name || '银').slice(0, 1);
|
||||
}
|
||||
function fillAll() {
|
||||
form.amountYuan = ((me.value?.wallet?.available_amount || 0) / 100).toFixed(2);
|
||||
}
|
||||
function openAccountPicker() {
|
||||
if (!selectedAccount.value) router.push('/app/profile/bank-card');
|
||||
}
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
loadError.value = '';
|
||||
try {
|
||||
const [profile, paymentAccounts] = await Promise.all([
|
||||
request<any>({ url: '/v1/me' }),
|
||||
request<any[]>({ url: '/v1/payment-accounts' }),
|
||||
]);
|
||||
me.value = profile;
|
||||
accounts.value = paymentAccounts;
|
||||
const verified = accounts.value.find((item) => item.verify_status === 'verified');
|
||||
form.paymentAccountId = verified?.id || 0;
|
||||
} catch (err) {
|
||||
loadError.value = errorText(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
async function startWithdrawal() {
|
||||
actionError.value = '';
|
||||
const amount = amountInCents(form.amountYuan);
|
||||
if (!selectedAccount.value) {
|
||||
actionError.value = accounts.value.length ? '请选择已核验的银行卡' : '请先绑定银行卡';
|
||||
return;
|
||||
}
|
||||
if (amount <= 0) {
|
||||
actionError.value = '请输入正确的提现金额';
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
quote.value = await request({
|
||||
url: '/v1/withdrawal-orders/quote',
|
||||
method: 'POST',
|
||||
data: { payment_account_id: form.paymentAccountId, amount, currency: 'CNY' },
|
||||
});
|
||||
withdrawalKey.value = idempotencyKey();
|
||||
showConfirm.value = true;
|
||||
} catch (err) {
|
||||
actionError.value = errorText(err);
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
async function submitWithdrawal() {
|
||||
if (!quote.value || submitting.value) return;
|
||||
if (!/^\d{6}$/.test(form.tradePassword)) {
|
||||
actionError.value = '请输入 6 位支付密码';
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
actionError.value = '';
|
||||
try {
|
||||
await request({
|
||||
url: '/v1/withdrawal-orders',
|
||||
method: 'POST',
|
||||
headers: { 'Idempotency-Key': withdrawalKey.value },
|
||||
data: {
|
||||
payment_account_id: quote.value.payment_account_id,
|
||||
amount: quote.value.request_amount,
|
||||
currency: 'CNY',
|
||||
trade_password: form.tradePassword,
|
||||
},
|
||||
});
|
||||
window.$message?.success('提现中,预计 1-3 个工作日到账');
|
||||
showConfirm.value = false;
|
||||
await router.replace('/app/profile/records?tab=withdrawal');
|
||||
} catch (err) {
|
||||
actionError.value = errorText(err);
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
onMounted(load);
|
||||
</script>
|
||||
@@ -0,0 +1,248 @@
|
||||
export interface PlatformIntroSection {
|
||||
title: string;
|
||||
paragraphs: string[];
|
||||
}
|
||||
|
||||
export const platformIntroLead =
|
||||
'本协议阐述之条款和条件适用于您使用本网站所提供的各种工具和服务(下称“服务”)。';
|
||||
|
||||
export const platformIntroSections: PlatformIntroSection[] = [
|
||||
{
|
||||
title: '一、接受条款',
|
||||
paragraphs: [
|
||||
'通过《涛声》或 APP 进入即表示您同意自己已经与《涛声》(简称公司)订立本协议,且您将受本协议的条款和条件(“条款”)约束。公司可随时自行全权决定更改“条款”。如您不同意相关变更,必须停止使用“服务”。经修订的“条款”一经网站公布,立即自动生效。您应在第一次登录后仔细阅读修订后的“条款”,并有权选择停止继续使用“服务”;一旦您继续使用“服务”,则表示您已接受经修订的“条款”。当您与公司发生争议时,应以最新的服务协议为准。除另行明确声明外,任何使“服务”范围扩大或功能增强的新内容均受本协议约束。除非经公司的授权高层管理人员签订书面协议,本协议不得另行作出修订。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '二、谁可使用本网站',
|
||||
paragraphs: [
|
||||
'“服务”仅供能够根据相关法律订立具有法律约束力的合约的个人或公司使用。因此,您的年龄必须在十八周岁或以上,才可使用本公司服务。如不符合本项条件,请勿使用“服务”。公司可随时自行全权决定拒绝向任何人士提供“服务”。“服务”不会提供给被暂时或永久中止资格的会员。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '三、收费',
|
||||
paragraphs: [
|
||||
'本公司承诺不会收取平台用户的任何费用,无需押金,无需保证金,并且不会以任何方式联系用户索要任何费用。本公司联系方式请认准在线客服。用户需要自行充值到平台里面,方可进行代购。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '四、您的资料和提供买卖的物品',
|
||||
paragraphs: [
|
||||
'“您的资料”包括您在注册、交易或列举物品过程中,在任何公开信息场合或通过任何电子邮件形式,向本公司或其他用户提供的任何资料,包括数据、文本、软件、音乐、声响、照片、图画、影像、词句或其他材料。您应对“您的资料”真实性负全部责任,而本公司仅作为您在网上发布和刊登“您的资料”的被动渠道。',
|
||||
'但是,倘若本公司认为您的信息可能使本公司承担任何法律或道义上的责任,或可能使本公司(全部或部分地)失去本公司的互联网服务供应商或其他供应商的服务,或您未在公司规定的期限内登录或再次登录网站,则本公司可自行全权决定对您的信息采取本公司认为必要或适当的任何行动,包括但不限于删除该类资料。您特此保证,您对提交的资料拥有全部权利,包括全部版权。您确认,网站没有责任去认定或决定您提交给本公司的信息哪些是应当受到保护的;对享有“服务”的其他用户使用您的信息,本公司也不必负责。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '五、注册义务',
|
||||
paragraphs: [
|
||||
'如您在公司网站注册,您必须同意:',
|
||||
'(一)根据公司网址所刊载的会员信息表格的要求,提供关于您或贵公司的真实、准确、完整和反映当前情况的信息;',
|
||||
'(二)维持并及时更新会员资料,使其保持真实、准确、完整和反映当前情况。倘若您提供任何不真实、不准确、不完整或不能反映当前情况的信息,或公司有合理理由怀疑该类资料不真实、不准确、不完整或不能反映当前情况的,公司有权暂停或终止您的注册身份及信息,并拒绝您在目前或将来对“服务”(或其任何部分)任何形式的使用。如您代表一家公司或其他法律主体在本公司登记,则您必须声明和保证,您有权使该公司或其他法律主体受本协议“条款”约束。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '六、会员注册名、密码和保密',
|
||||
paragraphs: [
|
||||
'登记过程中,您将选择会员注册名和密码。您须自行负责对您的会员注册名和密码保密,且须对您在会员注册名和密码下发生的所有活动承担责任。您同意:',
|
||||
'(一)如发现任何人未经授权使用您的会员注册名或密码,或发生违反保密规定的任何其他情况,您会立即通知公司;',
|
||||
'(二)确保您在每个上网时段结束时,以正确步骤离开网站。网站不能也不会对因您未能遵守本款规定而发生的任何损失或损毁负责。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '七、关于您的信息的规则',
|
||||
paragraphs: [
|
||||
'您同意,您的个人信息和您提供在公司网站上交易的任何“资料”(泛指一切可供依法交易的、有形的或无形的、以各种形态存在的某种具体物品、权利、利益、票据、证券、服务或行为。本协议中“物品”一词均含此义):',
|
||||
'(一)不会有欺诈成分,与售卖伪造或盗窃无涉;',
|
||||
'(二)不会侵犯任何第三者对该物品享有的物权,或版权、专利、商标、商业秘密或其他知识产权、隐私权、名誉权;',
|
||||
'(三)不会违反任何法律、法规、条例或规章,包括但不限于关于规范出口管理、贸易配额、保护消费者、不正当竞争或虚假广告的法律、法规、条例或规章;',
|
||||
'(四)不会含有诽谤(包括商业诽谤)、非法恐吓或非法骚扰的内容;',
|
||||
'(五)不会含有淫秽或任何儿童色情内容;',
|
||||
'(六)不会含有蓄意毁坏、恶意干扰、秘密截取或侵占任何系统、数据或个人资料的病毒、伪装破坏程序、电脑蠕虫、定时程序炸弹或其他电脑程序;',
|
||||
'(七)不会直接或间接与下述各项货物或服务连接,或包含对下述各项货物或服务的描述:1. 本协议项下禁止的货物或服务;2. 您无权连接或包含的货物或服务;',
|
||||
'(八)不会含有诽谤(包括商业诽谤)、非法恐吓或非法骚扰的内容;',
|
||||
'(九)不会在与任何连锁信件、大量胡乱邮寄的电子邮件、滥发电子邮件或任何复制或多余的信息有关的方面使用“服务”;',
|
||||
'(十)不会未经其他人士同意,利用“服务”收集其他人士的电子邮件地址及其他信息;',
|
||||
'(十一)不会利用“服务”制作虚假的电子邮件地址,或以其他形式试图在发送人的身份或信息来源方面误导其他人士。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '八、被禁止物品',
|
||||
paragraphs: [
|
||||
'您不得在本公司网站公布或通过本公司网站买卖:',
|
||||
'(一)可能使本公司违反任何相关法律、法规、条例或规章的任何物品;',
|
||||
'(二)公司认为应禁止或不适合通过本网站买卖的任何物品。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '九、您授予本公司的许可使用权',
|
||||
paragraphs: [
|
||||
'您授予本公司独家的、全球通用的、永久的、免费的许可使用权利(并有权在多个层面对该权利进行再授权),使本公司有权全部或部分地使用、复制、修订、改写、发布、翻译、分发、执行和展示“您的资料”或制作其派生作品,和/或以现在已知或日后开发的任何形式、媒体或技术,将“您的资料”纳入其他作品内。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '十、隐私',
|
||||
paragraphs: [
|
||||
'尽管有第九条所规定的许可使用权,我们将仅根据本公司的隐私声明使用您提供的信息。本公司隐私声明的全部条款属于本协议的一部分,因此,您必须仔细阅读。请注意,您一旦自愿地在公司网站披露您的信息,该类资料即可能被其他人士获取和使用。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '十一、终止或访问限制',
|
||||
paragraphs: [
|
||||
'您同意,在公司未向您收费的情况下,本公司可自行全权决定以任何理由,包括但不限于公司认为您已违反本协议的字面意义和精神,或您以不符合本协议字面意义和精神的方式行事,或您在超过 90 天的时间内未以您的账号及密码登录网站,终止您的“服务”密码、账户(或其任何部分)或您对“服务”的使用,并删除和丢弃您在使用“服务”中提交的信息。',
|
||||
'您同意,在公司向您收费的情况下,公司应基于合理的怀疑且经电子邮件通知的情况下实施上述终止服务的行为。公司同时可自行全权决定,在发出通知或不发出通知的情况下,随时停止提供“服务”或其任何部分。您同意,根据本协议的任何规定终止您使用“服务”之措施可在不发出事先通知的情况下实施,并承认和同意,公司可立即使您的账户无效,或撤销您的账户以及在您的账户内的所有相关资料和档案,和/或禁止您进一步接入该等档案或“服务”。',
|
||||
'账号终止后,公司没有义务为您保留原账号中或与之相关的任何信息,或转发任何未曾阅读或发送的信息给您或第三方。此外,您同意,公司不会就终止您接入“服务”而对您或任何第三者承担任何责任。第 12、13、14 和 22 条应在本协议终止后继续有效。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '十二、违反规则会有什么后果?',
|
||||
paragraphs: [
|
||||
'在不限制其他补救措施的前提下,发生下述任一情况,本公司可立即发出警告,暂时终止、永久终止或终止您的会员资格,删除您的任何现有产品信息,以及您在网站上展示的任何其他资料:',
|
||||
'(一)您违反本协议;',
|
||||
'(二)本公司无法核实或鉴定您向本公司提供的任何资料;',
|
||||
'(三)本公司相信您的行为可能会使您、本公司用户或通过本公司或本公司网站提供服务的第三者服务供应商发生任何法律责任。在不限制任何其他补救措施的前提下,倘若发现您从事涉及本公司网站的诈骗活动,公司可暂停或终止您的账户。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '十三、服务“按现状”提供',
|
||||
paragraphs: [
|
||||
'本公司会尽一切努力使您在使用公司网站的过程中得到良好体验。遗憾的是,本公司不能随时预见到任何技术上的问题或其他困难。该等困难可能会导致数据损失或其他服务中断。为此,您明确理解和同意,您使用“服务”的风险由您自行承担。“服务”以“按现状”和“按可得到”的基础提供。公司明确声明不作出任何种类的所有明示或暗示的保证,包括但不限于关于适销性、适用于某一特定用途和无侵权行为等方面的保证。',
|
||||
'公司对下述内容不作保证:(一)“服务”会符合您的要求;(二)“服务”不会中断,且适时、安全和不带任何错误;(三)通过使用“服务”而可能获取的结果将是准确或可信赖的;(四)您通过“服务”而购买或获取的任何产品、服务、资料或其他材料的质量将符合您的预期。',
|
||||
'通过使用“服务”而下载或以其他形式获取任何材料是由您自行全权决定进行的,且与此有关的风险由您自行承担。对于因您下载任何该等材料而发生的电脑系统损毁或数据损失,您将自行承担责任。您从公司或通过“服务”获取的任何口头或书面意见或资料,均不产生未在本协议内明确载明的任何保证。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '十四、责任范围',
|
||||
paragraphs: [
|
||||
'您明确理解和同意,公司不对因下述任一情况而发生的任何损害赔偿承担责任,包括但不限于利润、商誉、使用、数据等方面的损失或其他无形损失的损害赔偿(无论公司是否已被告知该等损害赔偿的可能性):',
|
||||
'(一)使用或未能使用“服务”;',
|
||||
'(二)因通过或从“服务”购买或获取任何货物、样品、数据、资料或服务,或通过或从“服务”接收任何信息或缔结任何交易所产生的获取替代货物和服务的费用;',
|
||||
'(三)未经批准接入或更改您的传输资料或数据;',
|
||||
'(四)任何第三者对“服务”的声明或关于“服务”的行为;',
|
||||
'(五)因任何原因而引起的与“服务”有关的其他事宜,包括疏忽。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '十五、订单规则',
|
||||
paragraphs: [
|
||||
'(一)订单有多个类型,每一类型针对人群都有所不同。',
|
||||
'(二)普通订单为常规订单,普通订单偶尔有超出会员账户本金余额。平台最多 2 个订单统一结算,客户接取此类订单时需要注意规则。以上所有订单类型适用于个人与团队。(注:团队任务需要团队成员所有人完成任务,否则视为未完成任务。)',
|
||||
'(三)违规者自行承担后果,包括但不限于违约金 50%、风险金等。',
|
||||
'(四)促销联单和福利订单是统一完成后返款。',
|
||||
'(五)福利单需要在规定的三个小时内完成。',
|
||||
'(六)出现违规操作、超时等,可能导致信用分扣除。信用分需要在 25 小时之内补齐,如超过 25 小时没有补齐,会导致用户信用分再次下降。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '十六、赔偿',
|
||||
paragraphs: [
|
||||
'您同意,因您违反本协议或经在此提及而纳入本协议的其他文件,或因您违反法律或侵害第三方权利,而使第三方对本公司及其子公司、分公司、董事、职员、代理人提出索赔要求(包括司法费用和其他专业人士的费用),您必须赔偿本公司及其子公司、分公司、董事、职员、代理人,使其免遭损失。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '十七、遵守法律',
|
||||
paragraphs: ['您应遵守与您使用“服务条款”以及与您提供商贸信息有关的所有相关法律、法规、条例和规章。'],
|
||||
},
|
||||
{
|
||||
title: '十八、代理关系',
|
||||
paragraphs: [
|
||||
'您与本公司仅为独立订约人关系。本协议无意结成或创设任何代理、合伙、合营、雇用与被雇用或特许权授予与被授予关系。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '十九、广告和金融服务',
|
||||
paragraphs: [
|
||||
'您与在“服务”上或通过“服务”物色的刊登广告人士通讯、进行业务往来或参与其推广活动,包括就相关货物或服务付款和交付,以及与该等业务往来相关的任何其他条款、条件、保证或声明,仅限于在您和该刊登广告人士之间发生。您同意,对于因任何该等业务往来或因在“服务”上出现该等刊登广告人士而发生的任何种类的损失或损毁,本公司无需负责或承担任何责任。',
|
||||
'您如打算通过“服务”创设或参与与任何公司、股票行情、投资或证券有关的任何服务,或通过“服务”收取或要求与任何公司、股票行情、投资或证券有关的任何新闻信息、警戒性信息或其他资料,敬请注意,本公司不会就通过“服务”传送的任何该等资料的准确性、有用性、可用性或可获利性负责或承担任何责任,且不会对根据该等资料而作出的任何交易或投资决策负责或承担任何责任。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '二十、链接',
|
||||
paragraphs: [
|
||||
'“服务”或第三者均可提供与其他万维网网站或资源的链接。由于本网站并不控制该等网站和资源,您承认并同意,本公司并不对该等外在网站或资源的可用性负责,且不认可该等网站或资源上或可从该等网站或资源获取的任何内容、宣传、产品、服务或其他材料,也不对其负责或承担任何责任。',
|
||||
'您进一步承认和同意,对于任何因使用或信赖从此类网站或资源上获取的内容、宣传、产品、服务或其他材料而造成或声称造成的任何直接或间接损失,本公司均不承担责任。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '二十一、其他规定',
|
||||
paragraphs: [
|
||||
'本协议构成您和公司之间的全部协议,规定了您对“服务”的使用,并取代您和公司先前订立的任何书面或口头协议。本协议各方面应受中华人民共和国大陆地区法律的管辖。倘若本协议任何规定被裁定为无效或不可强制执行,该项规定应被撤销,而其余规定应予执行。条款标题仅为方便参阅而设,并不以任何方式界定、限制、解释或描述该条款的范围或限度。本公司未就您或其他人士的某项违约行为采取行动,并不表明本公司撤回就任何继后或类似的违约事件采取行动的权利。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '二十二、通知',
|
||||
paragraphs: [
|
||||
'除非另有明确规定,任何通知应以电子邮件形式发送。就本公司而言,电子邮件地址为 dovekf@acca.com.cn;就您而言,发送到您在登记过程中向本公司提供的电子邮件地址,或有关方指明的其他地址。在电子邮件发出二十四(24)小时后,通知应被视为已送达,除非发送人被告知相关电子邮件地址已作废。',
|
||||
'或者,本公司可通过邮资预付挂号邮件并要求回执的方式,将通知发到您在登记过程中向本公司提供的地址。在该情况下,在付邮当日三(3)天后通知被视为已送达。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '二十三、不可抗力',
|
||||
paragraphs: [
|
||||
'对于因本公司合理控制范围以外的原因,包括但不限于自然灾害、罢工或骚乱、物质短缺或定量配给、暴动、战争行为、政府行为、通讯或其他设施故障或严重伤亡事故等,致使本公司延迟或未能履约的,本公司不对您承担任何责任。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '二十四、转让',
|
||||
paragraphs: ['本公司转让本协议无需经您同意。'],
|
||||
},
|
||||
{
|
||||
title: '二十五、其他规定',
|
||||
paragraphs: [
|
||||
'本协议构成您和本公司之间的全部协议,规定了您对“服务”的使用,并取代您和本公司先前订立的任何书面或口头协议。本协议各方面应受中华人民共和国大陆地区法律的管辖。倘若本协议任何规定被裁定为无效或不可强制执行,该项规定应被撤销,而其余规定应予执行。条款标题仅为方便参阅而设,并不以任何方式界定、限制、解释或描述该条款的范围或限度。本公司未就您或其他人士的某项违约行为采取行动,并不表明本公司撤回就任何继后或类似的违约事件采取行动的权利。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '二十六、平台提现要求',
|
||||
paragraphs: [
|
||||
'平台提现硬性要求信用分值为 100 分。',
|
||||
'温馨提示:同一个人银行卡信息请勿重复注册抢单,经发现将会冻结账号 ID。',
|
||||
'个人多账户代购会导致商家封店,影响商家信誉度以及销量单作废。本平台禁止一卡绑定多账户;一卡绑定多账户将给予资金冻结 90 天、账号永久封号处理。本平台为防止恶意洗钱或套现等不正当行为,兼职会员账户完成账户金额相对应的流水即可全额提款。',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const platformFAQs: PlatformIntroSection[] = [
|
||||
{
|
||||
title: '1. 关于充值',
|
||||
paragraphs: [
|
||||
'充值在“我的”—“我的钱包”界面进行。点击“充值”按钮,然后填写转账人的姓名和需转账的金额,点击去转账进行汇款充值。按照平台提供的账户汇款后,务必提交转账到账成功截图,然后点击“提交”即可。',
|
||||
'为保证充值快速到账,烦请您所填转账人姓名和汇款金额一定要和汇款时一致。如在充值过程中遇到无法解决的问题,请及时联系人工客服。由于充值信息量过大,充值前请务必仔细核对本平台的账户卡号。本平台不定期更换账号,每一次充值都需要跟客服获取商家卡号;充错卡号不予在平台上充值,后果自行负责。有不明白的问题可以咨询在线客服。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '2. 关于提现',
|
||||
paragraphs: [
|
||||
'在提现之前,烦请您先在 APP 绑定提现信息。在平台工作时间内,可在“我的”—“我的钱包”界面进行提现。点击“提现”按钮,输入您想要提现的金额和提现密码即可进行提现。21 点之前提现都是 0—5 分钟到账,繁忙高峰期 30 分钟内到账;21 点以后提现 T+1 到账。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '3. 关于领取',
|
||||
paragraphs: [
|
||||
'在 APP 内完善好个人真实资料和真实收货地址,因为系统审核必须填写。客户需绑定自己可以接受短信、开通手机银行的银行卡,方便查收款项和对商家卡号进行转账。已经充值到账后,即可进行代购。在代购成功后,系统会根据客户会员等级的不同分配不同的拍单时间和金额,具体参考会员等级。正常会员系统会在 15—20 分钟内派单。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '4. 关于订单冻结',
|
||||
paragraphs: [
|
||||
'平台根据客户的账户金额和云数据派单,偶尔订单会出现大过账户本金的情况。客户只需补全账户负数,系统即可第一时间结算。订单未完成将被冻结,30 天自动解冻并扣除 30% 的违约金;继续完成未完成的商品或者订单,返本金到您绑定的账户银行卡内或平台账户内。未能理解的可以咨询在线客服。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '5. 关于代理',
|
||||
paragraphs: [
|
||||
'平台用户推荐新人即可成为平台代理,同时可以获得额外的动态奖励。直推一级用户奖励为一级用户每天所得佣金的 16%,二级用户奖励为二级用户每天所得佣金的 8%,三级用户奖励为三级用户每天所得佣金的 4%。(如您邀请的用户当日佣金是 100 元,您可以获取其佣金 16% 的奖励,即 16 元;二级为 8%。)详情可咨询在线客服。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '6. 大额返款',
|
||||
paragraphs: ['平台金额大于十五万时,统一返款至平台账户,方便会员自由运用资金。'],
|
||||
},
|
||||
{
|
||||
title: '7. 领取时间',
|
||||
paragraphs: ['平台运营时间为每天 9:00—21:00,用户在平台运营时间内均可进行代购。'],
|
||||
},
|
||||
{
|
||||
title: '8. 订单超时',
|
||||
paragraphs: ['领取订单超出一个小时未能完成,系统即可认定超时,需通过超时冲单取代。'],
|
||||
},
|
||||
];
|
||||
@@ -112,6 +112,12 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('../mobile/views/AgencyView.vue'),
|
||||
meta: { title: '代理合作' },
|
||||
},
|
||||
{
|
||||
path: 'qualification',
|
||||
name: 'MobileQualification',
|
||||
component: () => import('../mobile/views/CompanyQualificationView.vue'),
|
||||
meta: { title: '公司资质' },
|
||||
},
|
||||
{
|
||||
path: 'profile/records',
|
||||
name: 'MobileFinanceRecords',
|
||||
|
||||