更新登录功能

This commit is contained in:
Eamon
2026-08-13 18:28:20 +08:00
parent 115ee3bfeb
commit 4235458bea
25 changed files with 1900 additions and 873 deletions

View File

@@ -1,103 +1,63 @@
import type { ApiResponse } from "@/lib/api"
import { callByDevice, callByUser } from "./base"
import { callPublic } from "./base"
import { clearSession, readCookie, setSession } from "./token"
export interface User {
id: number
username: string
loginCode: string
phone: string
name: string
wx: string
qq: string
taobao: string
email: string
restAmount: number
}
export type LoginMode = "phone_code" | "password"
export async function login(props: {
username: string
export async function apiLogin(props: {
logincode: string
password: string
remember: boolean
mode: LoginMode
code?: string
wx?: string
qq?: string
}): Promise<ApiResponse> {
const result = await callByDevice<{
access_token: string
refresh_token: string
expires_in: number
}>("/api/auth/token", {
...props,
grant_type: "password",
login_type: props.mode,
}): Promise<{ success: boolean; message?: string }> {
const result = await callPublic("/user/ApiLogin", {
Logincode: props.logincode,
Password: props.password,
})
if (!result.success) return result
if (!result.success) {
return { success: false, message: result.message || "用户名或者密码不正确" }
}
const { access_token, refresh_token, expires_in } = result.data
localStorage.setItem("auth_token", access_token)
localStorage.setItem("auth_refresh", refresh_token)
localStorage.setItem(
"auth_expires_in",
String(Date.now() + expires_in * 1000),
)
const token = readCookie("token")
const userInfoRaw = readCookie("userInfo")
return { success: true, data: undefined }
if (!token) {
return { success: false, message: "登录成功但未获取到 token" }
}
let userInfo: unknown = userInfoRaw
if (userInfoRaw) {
try {
userInfo = JSON.parse(userInfoRaw)
} catch {
userInfo = userInfoRaw
}
}
setSession(token, userInfo)
return { success: true }
}
export async function apiRegister(props: {
phone: string
password: string
code: string
wx?: string
qq?: string
}): Promise<{ success: boolean; message?: string }> {
const result = await callPublic("/user/ApiRegist", {
Code: props.code,
Phone: props.phone,
Pwd: props.password,
QQ: props.qq ?? "",
Wx: props.wx ?? "",
})
if (!result.success) {
return { success: false, message: result.message }
}
return { success: true }
}
export async function logout() {
const accessToken = localStorage.getItem("auth_token")
const refreshToken = localStorage.getItem("auth_refresh")
if (accessToken && refreshToken) {
await callByUser("/api/auth/revoke", {
access_token: accessToken,
refresh_token: refreshToken,
})
}
localStorage.removeItem("auth_token")
localStorage.removeItem("auth_refresh")
localStorage.removeItem("auth_expires_in")
clearSession()
window.location.href = "/"
}
export async function getProfile() {
return await callByUser<User>("/api/auth/introspect")
}
export async function refreshAuth(): Promise<{
access_token: string
refresh_token: string
} | null> {
const refreshToken = localStorage.getItem("auth_refresh")
if (!refreshToken) return null
const resp = await callByDevice<{
access_token: string
refresh_token: string
expires_in: number
}>(`/api/auth/token`, {
grant_type: "refresh_token",
refresh_token: refreshToken,
})
if (!resp.success) {
localStorage.removeItem("auth_refresh")
return null
}
const { access_token, refresh_token, expires_in } = resp.data
localStorage.setItem("auth_token", access_token)
localStorage.setItem("auth_refresh", refresh_token)
localStorage.setItem(
"auth_expires_in",
String(Date.now() + expires_in * 1000),
)
return { access_token, refresh_token }
}

View File

@@ -3,53 +3,32 @@ import {
type ApiResponse,
CLIENT_ID,
CLIENT_SECRET,
UnauthorizedError,
} from "@/lib/api"
import { clearSession, getUserToken } from "./token"
let deviceToken: string | null = null
let deviceTokenExpire: Date | null = null
type CallMode = "public" | "device" | "user"
async function call<T = undefined>(
async function request<R = undefined>(
endpoint: string,
body?: string,
auth?: string,
): Promise<ApiResponse<T>> {
init: {
method: "GET" | "POST"
body?: string
auth?: string
},
): Promise<ApiResponse<R>> {
try {
const headers: HeadersInit = {
"Content-Type": "application/json",
const headers: HeadersInit = {}
if (init.body !== undefined) {
headers["Content-Type"] = "application/json"
}
if (auth) headers["Authorization"] = auth
if (init.auth) headers.Authorization = init.auth
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
method: "POST",
method: init.method,
headers,
body,
body: init.body,
})
if (response.status === 401) {
// 如果是 刷新Token 的接口本身报 401说明 RefreshToken 彻底失效,直接抛出错误
if (
endpoint.includes("/auth/token") ||
endpoint.includes("/auth/revoke")
) {
throw UnauthorizedError
}
// 动态引入 auth.ts 里的刷新函数,避免循环引用报错
const { refreshAuth } = await import("./auth")
// 尝试刷新 Token
const newTokens = await refreshAuth()
if (newTokens) {
// 刷新成功,用新 Token 重试当前请求(递归调用)
return call(endpoint, body, `Bearer ${newTokens.access_token}`)
} else {
// 刷新失败,抛出 401 错误,让上层处理跳转
throw UnauthorizedError
}
}
const contentType = response.headers.get("Content-Type") ?? "text/plain"
if (contentType.includes("text/plain")) {
@@ -61,11 +40,18 @@ async function call<T = undefined>(
message: text || "请求失败",
}
}
return { success: true, data: undefined as T }
return { success: true, data: undefined as R }
}
if (contentType.includes("application/json")) {
const json = await response.json()
const json = (await response.json()) as R & {
message?: string
error_description?: string
Code?: number
Message?: string
Data?: unknown
data?: unknown
}
if (!response.ok) {
return {
success: false,
@@ -73,6 +59,20 @@ async function call<T = undefined>(
message: json.message || json.error_description || "请求失败",
}
}
if (typeof json.Code === "number") {
if (json.Code === 10000) {
return {
success: true,
data: (json.Data ?? json.data ?? undefined) as R,
}
}
return {
success: false,
message: json.Message || "请求失败",
}
}
return { success: true, data: json }
}
@@ -87,72 +87,124 @@ async function call<T = undefined>(
}
}
// ======================
// Public
// ======================
let deviceToken: string | null = null
let deviceTokenExpire = 0
let deviceTokenPromise: Promise<string | null> | null = null
async function acquireDeviceToken(): Promise<string | null> {
if (deviceToken && Date.now() < deviceTokenExpire) return deviceToken
if (!deviceTokenPromise) {
const basic = btoa(`${CLIENT_ID}:${CLIENT_SECRET}`)
deviceTokenPromise = request<{
access_token: string
expires_in: number
}>("/api/auth/token", {
method: "POST",
body: JSON.stringify({ grant_type: "client_credentials" }),
auth: `Basic ${basic}`,
})
.then(resp => {
if (!resp.success) {
deviceToken = null
return null
}
deviceToken = resp.data.access_token
deviceTokenExpire = Date.now() + (resp.data.expires_in - 60) * 1000
return resp.data.access_token
})
.finally(() => {
deviceTokenPromise = null
})
}
return deviceTokenPromise
}
const inflight = new Map<string, Promise<ApiResponse<unknown>>>()
function dedupGet<R>(
endpoint: string,
run: () => Promise<ApiResponse<R>>,
): Promise<ApiResponse<R>> {
const existing = inflight.get(endpoint)
if (existing) return existing as Promise<ApiResponse<R>>
const promise = run().finally(() => inflight.delete(endpoint))
inflight.set(endpoint, promise)
return promise
}
async function call<R = undefined>(
mode: CallMode,
endpoint: string,
data?: unknown,
method: "GET" | "POST" = "POST",
): Promise<ApiResponse<R>> {
const body = data === undefined ? undefined : JSON.stringify(data)
const run = async (): Promise<ApiResponse<R>> => {
let auth: string | undefined
if (mode === "user") {
const token = getUserToken()
if (!token) {
return {
success: false,
status: 401,
message: "未登录或会话已过期",
}
}
auth = `Bearer ${token}`
} else if (mode === "device") {
const token = await acquireDeviceToken()
if (!token) {
return {
success: false,
status: 401,
message: "设备凭证获取失败",
}
}
auth = `Bearer ${token}`
}
const resp = await request<R>(endpoint, { method, body, auth })
if (!resp.success && resp.status === 401) {
if (mode === "user") {
clearSession()
} else if (mode === "device") {
deviceToken = null
deviceTokenExpire = 0
}
}
return resp
}
if (method === "GET" && data === undefined) {
return dedupGet<R>(endpoint, run)
}
return run()
}
async function callPublic<R = undefined>(
endpoint: string,
data?: unknown,
method: "GET" | "POST" = "POST",
): Promise<ApiResponse<R>> {
return call(endpoint, data ? JSON.stringify(data) : undefined)
return call<R>("public", endpoint, data, method)
}
// ======================
// Device
// ======================
async function callByDevice<R = undefined>(
endpoint: string,
data?: unknown,
method: "GET" | "POST" = "POST",
): Promise<ApiResponse<R>> {
// 检查内存中的 Token 是否过期
if (!deviceToken || !deviceTokenExpire || new Date() >= deviceTokenExpire) {
// 用 btoa 生成 Basic 认证头
const basic = btoa(`${CLIENT_ID}:${CLIENT_SECRET}`)
const resp = await call<{
access_token: string
expires_in: number
}>(
"/api/auth/token",
JSON.stringify({ grant_type: "client_credentials" }),
`Basic ${basic}`,
)
if (!resp.success) {
return resp
}
deviceToken = resp.data.access_token
// 提前 60 秒过期,防止临界点失效
deviceTokenExpire = new Date(
Date.now() + (resp.data.expires_in - 60) * 1000,
)
}
return call(
endpoint,
data ? JSON.stringify(data) : undefined,
`Bearer ${deviceToken}`,
)
return call<R>("device", endpoint, data, method)
}
// ======================
// User
// ======================
async function callByUser<R = undefined>(
endpoint: string,
data?: unknown,
method: "GET" | "POST" = "POST",
): Promise<ApiResponse<R>> {
// 这里我们假设用户登录后的 Token 存在 localStorage 里
// 如果你是用 Cookie 存 Token这里用 js-cookie 的 get 方法替换即可
const token = localStorage.getItem("auth_token")
if (!token) {
throw UnauthorizedError
}
return call(
endpoint,
data ? JSON.stringify(data) : undefined,
`Bearer ${token}`,
)
return call<R>("user", endpoint, data, method)
}
export { callByDevice, callByUser, callPublic }

9
src/api/captcha.ts Normal file
View File

@@ -0,0 +1,9 @@
import { callPublic } from "./base"
export async function getCaptcha() {
return await callPublic<{ token: string; base64: string }>("/api/captcha/get")
}
export async function verifyCaptcha(token: string, answer: string) {
return await callPublic<boolean>("/api/captcha/verify", { token, answer })
}

64
src/api/order.ts Normal file
View File

@@ -0,0 +1,64 @@
import type { ApiResponse } from "@/lib/api"
import { callByUser } from "./base"
export type CreateOrderRequest = {
PackageId: number
OrderType: number
Account: string
Pwd: string
ConnectCount: number
MinPostfix?: number
MaxPostfix?: number
CouponId?: number
UseAccountAmount?: number
OPayType: number
PayChannel?: number
Price?: number
}
export type CreateTestAccountRequest = {
ProductId: number
PackageId: number
Account: string
Pwd: string
}
export type OrderInfo = {
OrderNo: string
OtherPayAmount: number
CreateTime: string
PayType: number
}
export type CreateOrderData = {
OrderInfo: OrderInfo
PayData: string
}
export async function createOrder(
data: CreateOrderRequest,
): Promise<ApiResponse<CreateOrderData | string>> {
return await callByUser<CreateOrderData | string>(
"/product/CreateOrder",
data,
)
}
export async function createTestAccount(
data: CreateTestAccountRequest,
): Promise<ApiResponse<string>> {
return await callByUser<string>(
"/api/course/v1/productaccount/CreateTestAccount",
data,
)
}
export async function checkPayStatus(
orderNo: string,
): Promise<ApiResponse<number>> {
return await callByUser<number>(
`/product/IsPay?orderNo=${orderNo}`,
undefined,
"GET",
)
}

11
src/api/product.ts Normal file
View File

@@ -0,0 +1,11 @@
import type { ApiResponse } from "@/lib/api"
import type { ProductItem } from "@/lib/models/product"
import { callPublic } from "./base"
export async function fetchProducts(): Promise<ApiResponse<ProductItem[]>> {
return await callPublic<ProductItem[]>(
"/product/ApiProductActive",
undefined,
"GET",
)
}

39
src/api/token.ts Normal file
View File

@@ -0,0 +1,39 @@
const AUTH_EVENT = "auth-change"
const TOKEN_KEY = "auth_token"
const USER_KEY = "auth_user"
export function readCookie(name: string): string | null {
const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`))
return match ? decodeURIComponent(match[1]) : null
}
export function getUserToken(): string | null {
return localStorage.getItem(TOKEN_KEY)
}
export function getUserInfo<T = unknown>(): T | null {
const raw = localStorage.getItem(USER_KEY)
if (!raw) return null
try {
return JSON.parse(raw) as T
} catch {
return null
}
}
export function isAuthed(): boolean {
return getUserToken() !== null
}
export function setSession(token: string, userInfo: unknown) {
localStorage.setItem(TOKEN_KEY, token)
localStorage.setItem(USER_KEY, JSON.stringify(userInfo))
window.dispatchEvent(new Event(AUTH_EVENT))
}
export function clearSession() {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_KEY)
window.dispatchEvent(new Event(AUTH_EVENT))
}

View File

@@ -1,15 +1,14 @@
import type { ApiResponse } from "@/lib/api"
import { callByDevice } from "./base"
import { callPublic } from "./base"
export type SMSPurpose = "User_Code" | "FindUser_Code" | "User_Login"
export async function sendSMS(
phone: string,
purpose: SMSPurpose = "User_Code",
): Promise<ApiResponse> {
return await callByDevice("/api/verify/sms", {
phone,
purpose,
): Promise<ApiResponse<undefined>> {
return await callPublic("/user/SendPhonesCodevefy", {
key: purpose,
phone,
})
}