diff --git a/cmd/migrate-member-mobile/main.go b/cmd/migrate-member-mobile/main.go
new file mode 100644
index 0000000..6923968
--- /dev/null
+++ b/cmd/migrate-member-mobile/main.go
@@ -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
+}
diff --git a/internal/portal/service.go b/internal/portal/service.go
index 8aff228..3cc6b45 100644
--- a/internal/portal/service.go
+++ b/internal/portal/service.go
@@ -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 {
diff --git a/internal/portal/service_test.go b/internal/portal/service_test.go
index 8b8fc1f..d2aa286 100644
--- a/internal/portal/service_test.go
+++ b/internal/portal/service_test.go
@@ -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"},
diff --git a/internal/store/recharges.go b/internal/store/recharges.go
new file mode 100644
index 0000000..dc92409
--- /dev/null
+++ b/internal/store/recharges.go
@@ -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`
+}
diff --git a/web/src/env.d.ts b/web/src/env.d.ts
new file mode 100644
index 0000000..578fd66
--- /dev/null
+++ b/web/src/env.d.ts
@@ -0,0 +1,11 @@
+///
+
+import type { MessageApi } from 'naive-ui';
+
+declare global {
+ interface Window {
+ $message: MessageApi;
+ }
+}
+
+export {};
diff --git a/web/src/mobile/MobileShell.vue b/web/src/mobile/MobileShell.vue
new file mode 100644
index 0000000..bd91d9f
--- /dev/null
+++ b/web/src/mobile/MobileShell.vue
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+
diff --git a/web/src/mobile/api.ts b/web/src/mobile/api.ts
new file mode 100644
index 0000000..a815d22
--- /dev/null
+++ b/web/src/mobile/api.ts
@@ -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 = {
+ 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 {
+ 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>) => {
+ 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(config: AxiosRequestConfig): Promise {
+ 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>(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 : '操作失败,请稍后重试';
+}
diff --git a/web/src/mobile/assets.ts b/web/src/mobile/assets.ts
new file mode 100644
index 0000000..c7d79ff
--- /dev/null
+++ b/web/src/mobile/assets.ts
@@ -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,
+};
diff --git a/web/src/mobile/assets/agency-bg.png b/web/src/mobile/assets/agency-bg.png
new file mode 100644
index 0000000..72b6a66
Binary files /dev/null and b/web/src/mobile/assets/agency-bg.png differ