first commit
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
import { STORAGE_KEYS } from './config'
|
||||
|
||||
export interface TokenData {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
expireTime: number
|
||||
}
|
||||
|
||||
export function getAccessToken(): string {
|
||||
return wx.getStorageSync(STORAGE_KEYS.ACCESS_TOKEN) || ''
|
||||
}
|
||||
|
||||
export function getRefreshToken(): string {
|
||||
return wx.getStorageSync(STORAGE_KEYS.REFRESH_TOKEN) || ''
|
||||
}
|
||||
|
||||
export function getExpireTime(): number {
|
||||
return wx.getStorageSync(STORAGE_KEYS.EXPIRE_TIME) || 0
|
||||
}
|
||||
|
||||
export function setTokens(data: TokenData): void {
|
||||
wx.setStorageSync(STORAGE_KEYS.ACCESS_TOKEN, data.accessToken)
|
||||
wx.setStorageSync(STORAGE_KEYS.REFRESH_TOKEN, data.refreshToken)
|
||||
wx.setStorageSync(STORAGE_KEYS.EXPIRE_TIME, data.expireTime)
|
||||
}
|
||||
|
||||
export function clearTokens(): void {
|
||||
wx.removeStorageSync(STORAGE_KEYS.ACCESS_TOKEN)
|
||||
wx.removeStorageSync(STORAGE_KEYS.REFRESH_TOKEN)
|
||||
wx.removeStorageSync(STORAGE_KEYS.EXPIRE_TIME)
|
||||
}
|
||||
|
||||
export function isLoggedIn(): boolean {
|
||||
return !!getAccessToken()
|
||||
}
|
||||
|
||||
export function clearUserInfo(): void {
|
||||
wx.removeStorageSync(STORAGE_KEYS.USER_INFO)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// 全局配置与常量
|
||||
|
||||
// 后端服务域名(不含 /api/v1 前缀、不含结尾斜杠)
|
||||
export const BASE_URL = 'https://zt-closet-backend.lzcwy.cn'
|
||||
|
||||
// API 统一前缀
|
||||
export const API_PREFIX = '/api/v1'
|
||||
|
||||
// 本地存储 key
|
||||
export const STORAGE_KEYS = {
|
||||
ACCESS_TOKEN: 'zt_closet_access_token',
|
||||
REFRESH_TOKEN: 'zt_closet_refresh_token',
|
||||
EXPIRE_TIME: 'zt_closet_expire_time',
|
||||
USER_INFO: 'zt_closet_user_info',
|
||||
}
|
||||
|
||||
// 衣物图片处理状态
|
||||
export enum ProcessStatus {
|
||||
MATTING = 1, // 抠图中
|
||||
CLASSIFYING = 2, // 分类中
|
||||
QUEUED = 3, // 排队中
|
||||
SUCCESS = 4, // 成功
|
||||
FAILED = 5, // 失败
|
||||
}
|
||||
|
||||
// 订单状态
|
||||
export enum OrderStatus {
|
||||
PENDING = 1, // 待支付
|
||||
PAID = 2, // 已支付
|
||||
CANCELLED = 3, // 已取消
|
||||
EXPIRED = 4, // 已过期
|
||||
REFUNDED = 5, // 已退款
|
||||
}
|
||||
|
||||
// 额度类型
|
||||
export enum QuotaType {
|
||||
UPLOAD = 1, // 上传
|
||||
AI = 2, // AI 推荐
|
||||
OUTFIT_SHARE = 3, // 社区分享(搭配分享)
|
||||
CLOSET_SHARE = 4, // 衣柜分享
|
||||
}
|
||||
|
||||
// 通知类型
|
||||
export enum MsgType {
|
||||
LIKE = 1, // 点赞通知
|
||||
ORDER = 2, // 订单通知
|
||||
SYSTEM = 3, // 系统通知
|
||||
}
|
||||
|
||||
// 穿搭来源
|
||||
export enum OutfitSource {
|
||||
USER = 1, // 用户创建
|
||||
AI = 2, // AI 推荐
|
||||
}
|
||||
|
||||
// 公开状态
|
||||
export enum IsPublic {
|
||||
PRIVATE = 1, // 不公开
|
||||
PUBLIC = 2, // 公开
|
||||
}
|
||||
|
||||
// 穿搭审核状态
|
||||
export enum AuditStatus {
|
||||
PENDING = 1, // 待审核
|
||||
PASS = 2, // 审核通过
|
||||
REJECT = 3, // 审核不通过
|
||||
}
|
||||
|
||||
// 衣柜分享状态
|
||||
export enum ShareStatus {
|
||||
NORMAL = 1, // 正常
|
||||
CANCELLED = 2, // 已取消
|
||||
EXPIRED = 3, // 已过期
|
||||
BANNED = 4, // 已封禁
|
||||
}
|
||||
|
||||
// 分享类型
|
||||
export enum ShareType {
|
||||
PARTIAL = 1, // 部分衣物
|
||||
FULL = 2, // 全量快照
|
||||
}
|
||||
|
||||
// 商品类型
|
||||
export enum GoodsType {
|
||||
MEMBER = 1, // 会员包
|
||||
UPLOAD = 2, // 上传包
|
||||
AI = 3, // AI 推荐包
|
||||
CLOSET_SHARE = 4, // 衣柜分享包
|
||||
OUTFIT_SHARE = 5, // 搭配分享包
|
||||
}
|
||||
|
||||
// 额度名称映射
|
||||
export const QUOTA_NAMES: Record<number, string> = {
|
||||
[QuotaType.UPLOAD]: '上传额度',
|
||||
[QuotaType.AI]: 'AI 推荐额度',
|
||||
[QuotaType.OUTFIT_SHARE]: '社区分享额度',
|
||||
[QuotaType.CLOSET_SHARE]: '衣柜分享额度',
|
||||
}
|
||||
|
||||
// 商品类型名称映射
|
||||
export const GOODS_TYPE_NAMES: Record<number, string> = {
|
||||
[GoodsType.MEMBER]: '会员',
|
||||
[GoodsType.UPLOAD]: '上传',
|
||||
[GoodsType.AI]: 'AI 推荐',
|
||||
[GoodsType.CLOSET_SHARE]: '衣柜分享',
|
||||
[GoodsType.OUTFIT_SHARE]: '搭配分享',
|
||||
}
|
||||
|
||||
// 错误码统一文案(兜底,优先展示服务端 details[0])
|
||||
export const ERROR_MESSAGES: Record<number, string> = {
|
||||
80000: '额度不足',
|
||||
80001: '额度记录不存在',
|
||||
90401: 'AI 推荐次数已达上限',
|
||||
90400: 'AI 推荐失败',
|
||||
90300: '运势生成中,请稍后重试',
|
||||
10003: '操作过于频繁,请稍后重试',
|
||||
11000: '请先登录',
|
||||
11001: '登录状态已失效',
|
||||
11002: '登录状态已过期',
|
||||
11004: '请先登录',
|
||||
12000: '无权限操作',
|
||||
21001: '账号已被禁用',
|
||||
22000: '用户不存在',
|
||||
30000: '上传文件为空',
|
||||
30001: '不支持的文件类型',
|
||||
30002: '文件大小超过限制',
|
||||
50000: '衣物不存在',
|
||||
50001: '该衣物已被搭配使用,无法删除',
|
||||
50005: '无权操作该衣物',
|
||||
40000: '穿搭不存在',
|
||||
40004: '无权操作该穿搭',
|
||||
60000: '商品不存在',
|
||||
60001: '商品已下架',
|
||||
70000: '订单不存在',
|
||||
70001: '订单状态不正确',
|
||||
70002: '订单已支付',
|
||||
70005: '虚拟支付未开通',
|
||||
70006: '商品尚未发布为虚拟支付道具',
|
||||
70007: '虚拟支付订单不支持取消',
|
||||
70008: 'iOS 虚拟支付最低 1 元',
|
||||
70009: '订单真实性校验失败',
|
||||
71001: '当前状态不允许退款',
|
||||
71004: 'iOS 订单请通过 App Store 申请退款',
|
||||
79003: '创建虚拟支付订单失败',
|
||||
90100: '分享不存在',
|
||||
90101: '分享已过期',
|
||||
90102: '分享已取消',
|
||||
90200: '目标内容不存在',
|
||||
19000: '服务繁忙,请稍后重试',
|
||||
19001: '服务繁忙,请稍后重试',
|
||||
19002: '服务繁忙,请稍后重试',
|
||||
19003: '服务繁忙,请稍后重试',
|
||||
19999: '服务繁忙,请稍后重试',
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// 金额 / 时间格式化工具
|
||||
|
||||
function pad(n: number): string {
|
||||
return n < 10 ? `0${n}` : `${n}`
|
||||
}
|
||||
|
||||
// 金额(分)转「¥xx.xx」字符串
|
||||
export function fenToYuan(fen: number | null | undefined): string {
|
||||
if (fen === null || fen === undefined || isNaN(fen)) return '¥0.00'
|
||||
return `¥${(fen / 100).toFixed(2)}`
|
||||
}
|
||||
|
||||
// 秒级时间戳转 YYYY-MM-DD
|
||||
export function formatDate(seconds: number | null | undefined): string {
|
||||
if (!seconds) return ''
|
||||
const d = new Date(seconds * 1000)
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
}
|
||||
|
||||
// 秒级时间戳转 YYYY-MM-DD HH:mm
|
||||
export function formatDateTime(seconds: number | null | undefined): string {
|
||||
if (!seconds) return ''
|
||||
const d = new Date(seconds * 1000)
|
||||
return `${formatDate(seconds)} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
// 相对时间:刚刚 / x分钟前 / x小时前 / 今天 / YYYY-MM-DD
|
||||
export function formatRelativeTime(seconds: number | null | undefined): string {
|
||||
if (!seconds) return ''
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const diff = now - seconds
|
||||
if (diff < 0) return formatDate(seconds)
|
||||
if (diff < 60) return '刚刚'
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)} 分钟前`
|
||||
|
||||
const d = new Date(seconds * 1000)
|
||||
const today = new Date()
|
||||
const isToday =
|
||||
d.getFullYear() === today.getFullYear() &&
|
||||
d.getMonth() === today.getMonth() &&
|
||||
d.getDate() === today.getDate()
|
||||
|
||||
if (diff < 86400) {
|
||||
return isToday ? '今天' : `${Math.floor(diff / 3600)} 小时前`
|
||||
}
|
||||
return formatDate(seconds)
|
||||
}
|
||||
|
||||
// 支付倒计时(秒)转 mm:ss
|
||||
export function formatCountdown(totalSeconds: number): string {
|
||||
if (totalSeconds <= 0) return '00:00'
|
||||
const m = Math.floor(totalSeconds / 60)
|
||||
const s = Math.floor(totalSeconds % 60)
|
||||
return `${pad(m)}:${pad(s)}`
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { BASE_URL, API_PREFIX, ERROR_MESSAGES } from './config'
|
||||
import * as auth from './auth'
|
||||
|
||||
// 业务错误对象(携带 code,页面可按 code 定制处理)
|
||||
export class ApiError extends Error {
|
||||
code: number
|
||||
details?: string[]
|
||||
constructor(code: number, msg: string, details?: string[]) {
|
||||
super(msg)
|
||||
this.code = code
|
||||
this.details = details
|
||||
}
|
||||
}
|
||||
|
||||
export interface RequestOptions {
|
||||
url: string
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
|
||||
data?: any
|
||||
header?: Record<string, any>
|
||||
auth?: boolean // 默认 true,需要鉴权
|
||||
showError?: boolean // 默认 true,业务错误自动 toast
|
||||
}
|
||||
|
||||
// 登录失效 / 账号禁用后的跳转处理(由 app.ts 注入,避免循环依赖)
|
||||
let onAuthExpired: (() => void) | null = null
|
||||
export function setAuthExpiredHandler(handler: () => void): void {
|
||||
onAuthExpired = handler
|
||||
}
|
||||
|
||||
// 单例刷新锁,避免并发请求同时刷新
|
||||
let refreshingPromise: Promise<boolean> | null = null
|
||||
|
||||
function doRefresh(): Promise<boolean> {
|
||||
if (!refreshingPromise) {
|
||||
refreshingPromise = new Promise<boolean>((resolve) => {
|
||||
const refreshToken = auth.getRefreshToken()
|
||||
const accessToken = auth.getAccessToken()
|
||||
if (!refreshToken || !accessToken) {
|
||||
resolve(false)
|
||||
return
|
||||
}
|
||||
wx.request({
|
||||
url: `${BASE_URL}${API_PREFIX}/user/refresh-token`,
|
||||
method: 'POST',
|
||||
header: { 'Content-Type': 'application/json' },
|
||||
data: { refresh_token: refreshToken, access_token: accessToken },
|
||||
success: (res) => {
|
||||
const body = res.data as { code: number; data?: LoginTokenResponse }
|
||||
if (body && body.code === 0 && body.data) {
|
||||
auth.setTokens({
|
||||
accessToken: body.data.access_token,
|
||||
refreshToken: body.data.refresh_token,
|
||||
expireTime: body.data.expire_time,
|
||||
})
|
||||
resolve(true)
|
||||
} else {
|
||||
resolve(false)
|
||||
}
|
||||
},
|
||||
fail: () => resolve(false),
|
||||
})
|
||||
}).finally(() => {
|
||||
refreshingPromise = null
|
||||
})
|
||||
}
|
||||
return refreshingPromise
|
||||
}
|
||||
|
||||
interface LoginTokenResponse {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
expire_time: number
|
||||
}
|
||||
|
||||
function showToast(title: string): void {
|
||||
wx.showToast({ title, icon: 'none', duration: 2000 })
|
||||
}
|
||||
|
||||
function redirectToLogin(): void {
|
||||
if (onAuthExpired) {
|
||||
onAuthExpired()
|
||||
} else {
|
||||
wx.reLaunch({ url: '/pages/login/login' })
|
||||
}
|
||||
}
|
||||
|
||||
export function request<T = any>(options: RequestOptions): Promise<T> {
|
||||
const {
|
||||
url,
|
||||
method = 'GET',
|
||||
data,
|
||||
header = {},
|
||||
auth: needAuth = true,
|
||||
showError = true,
|
||||
} = options
|
||||
|
||||
const fullUrl = url.startsWith('http') ? url : `${BASE_URL}${API_PREFIX}${url}`
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const doCall = (): void => {
|
||||
const finalHeader: Record<string, any> = {
|
||||
'Content-Type': 'application/json',
|
||||
...header,
|
||||
}
|
||||
if (needAuth) {
|
||||
const token = auth.getAccessToken()
|
||||
if (token) finalHeader['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
wx.request({
|
||||
url: fullUrl,
|
||||
method,
|
||||
data,
|
||||
header: finalHeader,
|
||||
success: (res) => {
|
||||
const body = res.data as { code: number; msg: string; data?: T; details?: string[] }
|
||||
|
||||
// 网络层异常:响应体无 code
|
||||
if (!body || typeof body.code !== 'number') {
|
||||
if (showError) showToast('网络异常,请稍后重试')
|
||||
reject(new Error('network error'))
|
||||
return
|
||||
}
|
||||
|
||||
const code = body.code
|
||||
|
||||
// 成功
|
||||
if (code === 0) {
|
||||
resolve(body.data as T)
|
||||
return
|
||||
}
|
||||
|
||||
// Token 失效:尝试刷新后重试一次
|
||||
if (
|
||||
needAuth &&
|
||||
(code === 11001 || code === 11002 || code === 11004 || res.statusCode === 401)
|
||||
) {
|
||||
doRefresh().then((ok) => {
|
||||
if (ok) {
|
||||
doCall()
|
||||
} else {
|
||||
auth.clearTokens()
|
||||
redirectToLogin()
|
||||
if (showError) showToast('登录已过期,请重新登录')
|
||||
reject(new ApiError(code, body.msg || '登录已过期'))
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 账号禁用(HTTP 200,需单独处理)
|
||||
if (code === 21001) {
|
||||
auth.clearTokens()
|
||||
redirectToLogin()
|
||||
if (showError) showToast(ERROR_MESSAGES[21001] || '账号已被禁用')
|
||||
reject(new ApiError(code, body.msg || '账号已被禁用'))
|
||||
return
|
||||
}
|
||||
|
||||
// 其余业务错误
|
||||
if (showError) {
|
||||
const msg =
|
||||
(body.details && body.details[0]) || ERROR_MESSAGES[code] || body.msg || '请求失败'
|
||||
showToast(msg)
|
||||
}
|
||||
reject(new ApiError(code, body.msg || '请求失败', body.details))
|
||||
},
|
||||
fail: () => {
|
||||
if (showError) showToast('网络异常,请检查网络后重试')
|
||||
reject(new Error('request fail'))
|
||||
},
|
||||
})
|
||||
}
|
||||
doCall()
|
||||
})
|
||||
}
|
||||
|
||||
// 文件上传(multipart,字段名 file)
|
||||
export function uploadFile<T = any>(filePath: string, name = 'file', url = '/clothes/upload'): Promise<T> {
|
||||
const token = auth.getAccessToken()
|
||||
const header: Record<string, any> = {}
|
||||
if (token) header['Authorization'] = `Bearer ${token}`
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
wx.uploadFile({
|
||||
url: url.startsWith('http') ? url : `${BASE_URL}${API_PREFIX}${url}`,
|
||||
filePath,
|
||||
name,
|
||||
header,
|
||||
success: (res) => {
|
||||
let body: { code: number; msg: string; data?: T; details?: string[] } | null = null
|
||||
try {
|
||||
body = JSON.parse(res.data)
|
||||
} catch (e) {
|
||||
body = null
|
||||
}
|
||||
if (body && typeof body.code === 'number') {
|
||||
if (body.code === 0) {
|
||||
resolve(body.data as T)
|
||||
} else {
|
||||
const msg =
|
||||
(body.details && body.details[0]) || ERROR_MESSAGES[body.code] || body.msg || '上传失败'
|
||||
showToast(msg)
|
||||
reject(new ApiError(body.code, body.msg || '上传失败', body.details))
|
||||
}
|
||||
} else {
|
||||
showToast('上传失败,请稍后重试')
|
||||
reject(new Error('upload fail'))
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
showToast('上传失败,请检查网络后重试')
|
||||
reject(new Error('upload fail'))
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export const formatTime = (date: Date) => {
|
||||
const year = date.getFullYear()
|
||||
const month = date.getMonth() + 1
|
||||
const day = date.getDate()
|
||||
const hour = date.getHours()
|
||||
const minute = date.getMinutes()
|
||||
const second = date.getSeconds()
|
||||
|
||||
return (
|
||||
[year, month, day].map(formatNumber).join('/') +
|
||||
' ' +
|
||||
[hour, minute, second].map(formatNumber).join(':')
|
||||
)
|
||||
}
|
||||
|
||||
const formatNumber = (n: number) => {
|
||||
const s = n.toString()
|
||||
return s[1] ? s : '0' + s
|
||||
}
|
||||
Reference in New Issue
Block a user