商城c端前端

This commit is contained in:
richjohnrambo
2026-10-12 23:40:02 +08:00
parent dd729cb679
commit 96d1de4a95
105 changed files with 6762 additions and 6 deletions
+268
View File
@@ -0,0 +1,268 @@
package main
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"log"
"os"
"regexp"
"strings"
"git.118im.com/im_team/im_workbench/internal/config"
_ "github.com/go-sql-driver/mysql"
)
var phonePattern = regexp.MustCompile(`^1[3-9][0-9]{9}$`)
type memberPhone struct {
id int64
tenantID int64
ciphertext string
hash string
mask string
phone string
}
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatal(err)
}
db, err := sql.Open("mysql", strings.TrimPrefix(cfg.MySQL.DSN, "mysql://"))
if err != nil {
log.Fatal(err)
}
defer db.Close()
ctx := context.Background()
hasMobile, err := columnExists(ctx, db, "mobile")
if err != nil {
log.Fatal(err)
}
hasCipher, err := columnExists(ctx, db, "mobile_cipher")
if err != nil {
log.Fatal(err)
}
if hasMobile && !hasCipher {
log.Print("hg_app_member already uses the mobile column")
return
}
if !hasCipher {
log.Fatal("mobile_cipher is missing before migration")
}
aeads := newAEADs(append(splitKeys(os.Getenv("WORKBENCH_MEMBER_MIGRATION_DATA_KEYS")), cfg.Auth.DataEncryptionKey))
lookupKeys := decodeKeys(append(splitKeys(os.Getenv("WORKBENCH_MEMBER_MIGRATION_LOOKUP_KEYS")), cfg.Auth.LookupHMACKey))
overrides, err := parseOverrides(os.Getenv("WORKBENCH_MEMBER_MIGRATION_OVERRIDES"))
if err != nil {
log.Fatal(err)
}
if len(aeads) == 0 && len(lookupKeys) == 0 {
log.Fatal("no valid historical phone keys were provided")
}
phones, err := loadPhones(ctx, db, aeads, lookupKeys, overrides)
if err != nil {
log.Fatal(err)
}
if err := validateUnique(phones); err != nil {
log.Fatal(err)
}
if !hasMobile {
if _, err := db.ExecContext(ctx, `ALTER TABLE hg_app_member ADD COLUMN mobile varchar(20) NULL AFTER username`); err != nil {
log.Fatal(err)
}
}
if err := updatePhones(ctx, db, phones); err != nil {
log.Fatal(err)
}
if _, err := db.ExecContext(ctx, `ALTER TABLE hg_app_member
MODIFY COLUMN mobile varchar(20) NOT NULL,
DROP INDEX uk_app_member_mobile_hash,
ADD UNIQUE KEY uk_app_member_mobile (tenant_id,mobile),
DROP COLUMN mobile_cipher,
DROP COLUMN mobile_hash,
DROP COLUMN mobile_mask`); err != nil {
log.Fatal(err)
}
log.Printf("migrated %d member phone numbers", len(phones))
}
func columnExists(ctx context.Context, db *sql.DB, column string) (bool, error) {
var count int
err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='hg_app_member' AND COLUMN_NAME=?`, column).Scan(&count)
return count == 1, err
}
func loadPhones(ctx context.Context, db *sql.DB, aeads []cipher.AEAD, lookupKeys [][]byte, overrides map[int64]string) ([]memberPhone, error) {
rows, err := db.QueryContext(ctx, `SELECT id,tenant_id,mobile_cipher,mobile_hash,mobile_mask FROM hg_app_member ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
phones := make([]memberPhone, 0)
failed := make([]string, 0)
for rows.Next() {
var item memberPhone
if err := rows.Scan(&item.id, &item.tenantID, &item.ciphertext, &item.hash, &item.mask); err != nil {
return nil, err
}
item.phone = recoverPhone(item, aeads, lookupKeys)
if item.phone == "" {
item.phone = overrides[item.id]
}
if !phonePattern.MatchString(item.phone) {
failed = append(failed, fmt.Sprintf("%d", item.id))
} else if len(item.mask) == 11 && item.phone[:3]+"****"+item.phone[7:] != item.mask {
return nil, fmt.Errorf("member %d override does not match its phone mask", item.id)
}
phones = append(phones, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
if len(failed) > 0 {
return nil, fmt.Errorf("phone recovery failed for member IDs: %s", strings.Join(failed, ","))
}
return phones, nil
}
func validateUnique(phones []memberPhone) error {
seen := make(map[string]int64, len(phones))
for _, item := range phones {
key := fmt.Sprintf("%d:%s", item.tenantID, item.phone)
if otherID, ok := seen[key]; ok {
return fmt.Errorf("members %d and %d have duplicate phone numbers", otherID, item.id)
}
seen[key] = item.id
}
return nil
}
func updatePhones(ctx context.Context, db *sql.DB, phones []memberPhone) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
for _, item := range phones {
result, err := tx.ExecContext(ctx, `UPDATE hg_app_member SET mobile=? WHERE id=? AND tenant_id=?`, item.phone, item.id, item.tenantID)
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected != 1 {
return fmt.Errorf("member %d update affected %d rows", item.id, affected)
}
}
return tx.Commit()
}
func splitKeys(value string) []string {
return strings.FieldsFunc(value, func(r rune) bool { return r == ',' || r == '\n' })
}
func parseOverrides(value string) (map[int64]string, error) {
result := make(map[int64]string)
for _, pair := range splitKeys(value) {
var id int64
var phone string
if _, err := fmt.Sscanf(pair, "%d=%s", &id, &phone); err != nil || id <= 0 || !phonePattern.MatchString(phone) {
return nil, fmt.Errorf("invalid member phone override")
}
result[id] = phone
}
return result, nil
}
func newAEADs(encodedKeys []string) []cipher.AEAD {
result := make([]cipher.AEAD, 0, len(encodedKeys))
for _, encodedKey := range encodedKeys {
key, err := decodeKey(encodedKey)
if err != nil || len(key) != 32 {
continue
}
block, err := aes.NewCipher(key)
if err != nil {
continue
}
aead, err := cipher.NewGCM(block)
if err == nil {
result = append(result, aead)
}
}
return result
}
func decodeKeys(encodedKeys []string) [][]byte {
result := make([][]byte, 0, len(encodedKeys))
for _, encodedKey := range encodedKeys {
key, err := decodeKey(encodedKey)
if err == nil && len(key) >= 32 {
result = append(result, key)
}
}
return result
}
func decodeKey(value string) ([]byte, error) {
value = strings.TrimSpace(value)
if decoded, err := base64.StdEncoding.DecodeString(value); err == nil {
return decoded, nil
}
return base64.RawStdEncoding.DecodeString(value)
}
func recoverPhone(item memberPhone, aeads []cipher.AEAD, lookupKeys [][]byte) string {
for _, aead := range aeads {
for _, encoding := range []*base64.Encoding{base64.RawStdEncoding, base64.RawURLEncoding} {
plain, err := decrypt(aead, encoding, item.ciphertext)
phone := strings.TrimPrefix(plain, "+86")
if err == nil && phonePattern.MatchString(phone) {
return phone
}
}
}
if len(item.mask) != 11 || item.mask[3:7] != "****" {
return ""
}
for middle := 0; middle < 10000; middle++ {
phone := fmt.Sprintf("%s%04d%s", item.mask[:3], middle, item.mask[7:])
for _, value := range []string{phone, "+86" + phone} {
digest := sha256.Sum256([]byte(value))
if hmac.Equal([]byte(strings.ToLower(item.hash)), []byte(hex.EncodeToString(digest[:]))) {
return phone
}
}
for _, key := range lookupKeys {
for _, value := range []string{phone, "+86" + phone} {
mac := hmac.New(sha256.New, key)
_, _ = mac.Write([]byte(value))
if hmac.Equal([]byte(strings.ToLower(item.hash)), []byte(hex.EncodeToString(mac.Sum(nil)))) {
return phone
}
}
}
}
return ""
}
func decrypt(aead cipher.AEAD, encoding *base64.Encoding, value string) (string, error) {
raw, err := encoding.DecodeString(value)
if err != nil || len(raw) < aead.NonceSize() {
return "", errors.New("invalid ciphertext")
}
plain, err := aead.Open(nil, raw[:aead.NonceSize()], raw[aead.NonceSize():], nil)
return string(plain), err
}
+6 -6
View File
@@ -256,14 +256,14 @@ func parseFeatures(raw json.RawMessage) Features {
return Features{}
}
return Features{
TaskEnabled: findBool(config, "task_enabled", "taskEnabled", "TASK"),
RechargeEnabled: findBool(config, "recharge_enabled", "rechargeEnabled", "RECHARGE"),
WithdrawalEnabled: findBool(config, "withdrawal_enabled", "withdrawalEnabled", "WITHDRAWAL"),
ManualMallEnabled: findBool(config, "manual_mall_enabled", "manualMallEnabled", "SHOP_STATUS"),
TaskEnabled: findBool(config, true, "task_enabled", "taskEnabled", "TASK"),
RechargeEnabled: findBool(config, true, "recharge_enabled", "rechargeEnabled", "RECHARGE"),
WithdrawalEnabled: findBool(config, true, "withdrawal_enabled", "withdrawalEnabled", "WITHDRAWAL"),
ManualMallEnabled: findBool(config, true, "manual_mall_enabled", "manualMallEnabled", "SHOP_STATUS"),
}
}
func findBool(config map[string]any, keys ...string) bool {
func findBool(config map[string]any, fallback bool, keys ...string) bool {
for _, key := range keys {
if value, ok := config[key]; ok {
return boolValue(value)
@@ -276,7 +276,7 @@ func findBool(config map[string]any, keys ...string) bool {
}
}
}
return false
return fallback
}
func boolValue(value any) bool {
+7
View File
@@ -61,6 +61,13 @@ func TestParseFeaturesSupportsNestedAndLegacyKeys(t *testing.T) {
}
}
func TestParseFeaturesDefaultsToEnabled(t *testing.T) {
features := parseFeatures(json.RawMessage(`{"siteName":"demo"}`))
if !features.TaskEnabled || !features.RechargeEnabled || !features.WithdrawalEnabled || !features.ManualMallEnabled {
t.Fatalf("unexpected feature defaults: %+v", features)
}
}
func TestBannersSanitizeTargets(t *testing.T) {
service := New(fakeRepository{banners: []store.Banner{
{ID: 1, TargetType: 2, TargetValue: "/receive"},
+167
View File
@@ -0,0 +1,167 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
)
type RechargeChannel struct {
ID int64 `db:"id"`
Code string `db:"channel_code"`
Name string `db:"name"`
ChannelType int `db:"channel_type"`
MinAmount int64 `db:"min_amount"`
MaxAmount int64 `db:"max_amount"`
FeeType int `db:"fee_type"`
FeeFixedAmount int64 `db:"fee_fixed_amount"`
FeeRate int `db:"fee_rate"`
}
type RechargeCreation struct {
TenantID int64
MemberID int64
RechargeNo string
ChannelID int64
RequestAmount int64
Currency string
VoucherAttachmentIDs []int64
Remark string
}
type RechargeOrder struct {
ID int64 `db:"id"`
RechargeNo string `db:"recharge_no"`
ChannelID int64 `db:"channel_id"`
ChannelName string `db:"channel_name"`
RequestAmount int64 `db:"request_amount"`
PaidAmount int64 `db:"paid_amount"`
Currency string `db:"currency"`
VoucherAttachmentIDs json.RawMessage `db:"voucher_attachment_ids"`
Status int `db:"recharge_status"`
Remark string `db:"remark"`
ReviewReasonCode string `db:"review_reason_code"`
ReviewReason string `db:"review_reason"`
ExternalNo string `db:"external_no"`
PaidAt string `db:"paid_at"`
ReviewedAt string `db:"reviewed_at"`
CreditedAt string `db:"credited_at"`
CreatedAt string `db:"created_at"`
UpdatedAt string `db:"updated_at"`
}
type RechargePage struct {
Items []RechargeOrder
Total int64
}
func (s *Store) RechargeChannels(ctx context.Context, tenantID int64) ([]RechargeChannel, error) {
items := make([]RechargeChannel, 0)
err := s.db.SelectContext(ctx, &items, `SELECT id,channel_code,name,channel_type,min_amount,max_amount,
fee_type,fee_fixed_amount,fee_rate FROM hg_payment_channel
WHERE tenant_id=? AND direction IN (1,3) AND status=1 AND deleted_at IS NULL ORDER BY sort,id`, tenantID)
return items, err
}
func (s *Store) CreateRecharge(ctx context.Context, input RechargeCreation) (*RechargeOrder, error) {
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
var memberID int64
err = tx.GetContext(ctx, &memberID, `SELECT id FROM hg_app_member WHERE tenant_id=? AND id=?
AND member_status=1 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, input.TenantID, input.MemberID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrAccountRestricted
}
if err != nil {
return nil, err
}
var channel RechargeChannel
err = tx.GetContext(ctx, &channel, `SELECT id,channel_code,name,channel_type,min_amount,max_amount,
fee_type,fee_fixed_amount,fee_rate FROM hg_payment_channel WHERE tenant_id=? AND id=?
AND direction IN (1,3) AND status=1 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, input.TenantID, input.ChannelID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrRechargeChannel
}
if err != nil {
return nil, err
}
if input.RequestAmount < maxInt64(1, channel.MinAmount) || (channel.MaxAmount > 0 && input.RequestAmount > channel.MaxAmount) {
return nil, ErrRechargeAmount
}
for _, attachmentID := range input.VoucherAttachmentIDs {
var count int
if err = tx.GetContext(ctx, &count, `SELECT COUNT(*) FROM hg_sys_attachment WHERE tenant_id=? AND id=?
AND app_id='workbench' AND member_id=? AND status=1`, input.TenantID, attachmentID, input.MemberID); err != nil {
return nil, err
}
if count != 1 {
return nil, ErrRechargeVoucher
}
}
vouchers, err := json.Marshal(input.VoucherAttachmentIDs)
if err != nil {
return nil, err
}
result, err := tx.ExecContext(ctx, `INSERT INTO hg_recharge_order
(tenant_id,recharge_no,member_id,channel_id,request_amount,paid_amount,currency,
voucher_attachment_ids,recharge_type,recharge_status,remark,created_by,updated_by)
VALUES(?,?,?,?,?,0,?,?,1,1,?,?,?)`, input.TenantID, input.RechargeNo, input.MemberID,
input.ChannelID, input.RequestAmount, input.Currency, vouchers, input.Remark, input.MemberID, input.MemberID)
if err != nil {
return nil, err
}
id, err := result.LastInsertId()
if err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
return &RechargeOrder{ID: id, RechargeNo: input.RechargeNo, ChannelID: channel.ID,
ChannelName: channel.Name, RequestAmount: input.RequestAmount, Currency: input.Currency,
VoucherAttachmentIDs: vouchers, Status: 1, Remark: input.Remark}, nil
}
func (s *Store) RechargeOrders(ctx context.Context, tenantID, memberID int64, status, offset, limit int) (*RechargePage, error) {
where := ` WHERE o.tenant_id=? AND o.member_id=?`
args := []any{tenantID, memberID}
if status > 0 {
where += ` AND o.recharge_status=?`
args = append(args, status)
}
var total int64
if err := s.db.GetContext(ctx, &total, `SELECT COUNT(*) FROM hg_recharge_order o`+where, args...); err != nil {
return nil, err
}
args = append(args, limit, offset)
items := make([]RechargeOrder, 0)
err := s.db.SelectContext(ctx, &items, rechargeSelectSQL()+where+
` ORDER BY o.created_at DESC,o.id DESC LIMIT ? OFFSET ?`, args...)
return &RechargePage{Items: items, Total: total}, err
}
func (s *Store) RechargeOrder(ctx context.Context, tenantID, memberID, orderID int64) (*RechargeOrder, error) {
var row RechargeOrder
err := s.db.GetContext(ctx, &row, rechargeSelectSQL()+
` WHERE o.tenant_id=? AND o.member_id=? AND o.id=? LIMIT 1`, tenantID, memberID, orderID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrRechargeNotFound
}
return &row, err
}
func rechargeSelectSQL() string {
return `SELECT o.id,o.recharge_no,o.channel_id,COALESCE(c.name,'') AS channel_name,o.request_amount,
o.paid_amount,o.currency,COALESCE(o.voucher_attachment_ids,JSON_ARRAY()) AS voucher_attachment_ids,
o.recharge_status,o.remark,o.review_reason_code,o.review_reason,o.external_no,
COALESCE(DATE_FORMAT(o.paid_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS paid_at,
COALESCE(DATE_FORMAT(o.reviewed_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS reviewed_at,
COALESCE(DATE_FORMAT(o.credited_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS credited_at,
DATE_FORMAT(o.created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at,
DATE_FORMAT(o.updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at
FROM hg_recharge_order o LEFT JOIN hg_payment_channel c ON c.tenant_id=o.tenant_id AND c.id=o.channel_id`
}
+11
View File
@@ -0,0 +1,11 @@
/// <reference types="vite/client" />
import type { MessageApi } from 'naive-ui';
declare global {
interface Window {
$message: MessageApi;
}
}
export {};
+73
View File
@@ -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>
+188
View File
@@ -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 : '操作失败,请稍后重试';
}
+153
View File
@@ -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,
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 939 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 810 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 842 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1018 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 569 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 603 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 976 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 785 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 772 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 730 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 850 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 860 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 689 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 685 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 787 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 939 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 787 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

+27
View File
@@ -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>
+15
View File
@@ -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>
File diff suppressed because it is too large Load Diff
+59
View File
@@ -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>
+141
View File
@@ -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>
+274
View File
@@ -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>
+120
View File
@@ -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>
+382
View File
@@ -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>
+182
View File
@@ -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>
+86
View File
@@ -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>
+150
View File
@@ -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>
+255
View File
@@ -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>
+103
View File
@@ -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>
+117
View File
@@ -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>
+51
View File
@@ -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>

Some files were not shown because too many files have changed in this diff Show More