first commit

This commit is contained in:
2026-09-11 01:45:04 +08:00
commit 0a8d60d87f
145 changed files with 40978 additions and 0 deletions
+39
View File
@@ -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)
}
+154
View File
@@ -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: '服务繁忙,请稍后重试',
}
+55
View File
@@ -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)}`
}
+217
View File
@@ -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'))
},
})
})
}
+19
View File
@@ -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
}