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