feat(auth): 接入 DCAuth v2 登录并完善成员管理
- 登录链路改为用户名 + 密码 + 短信验证码 + DCAuth v2 设备签名 - 迁移 014:新增 login_sms_codes / user_devices / device_challenges 与 users.phone - 新增 /api/dcauth/authorize 与 challenge 状态轮询;旧密码登录仅放行未绑定手机号的账号 - 成员管理支持手机号绑定、设备重置、账号改名、昵称与重置密码 - 移除最后一个商户关系时软删除账号,释放用户名、手机号并撤销设备绑定
This commit is contained in:
+537
-221
@@ -1,4 +1,4 @@
|
||||
import request from './request'
|
||||
import request from "./request";
|
||||
import type {
|
||||
DashboardStats,
|
||||
AlertChannelInput,
|
||||
@@ -17,11 +17,11 @@ import type {
|
||||
LoginResult,
|
||||
LowBalanceAlertConfig,
|
||||
Merchant,
|
||||
MerchantMember,
|
||||
MerchantMemberGroup,
|
||||
MerchantOption,
|
||||
MerchantOwnerAccount,
|
||||
MerchantRole,
|
||||
MerchantMember,
|
||||
MerchantMemberGroup,
|
||||
MerchantOption,
|
||||
MerchantOwnerAccount,
|
||||
MerchantRole,
|
||||
MerchantProduct,
|
||||
PageResult,
|
||||
ProductCatalogItem,
|
||||
@@ -32,328 +32,644 @@ import type {
|
||||
LewanProductListResult,
|
||||
UpdateProductRoutingInput,
|
||||
SyncLewanProductInput,
|
||||
} from '../types'
|
||||
SendLoginSMSCodeResult,
|
||||
PreparedDeviceLogin,
|
||||
DCAuthChallengeStatus,
|
||||
} from "../types";
|
||||
|
||||
export const authApi = {
|
||||
login: async (username: string, password: string) => {
|
||||
const res = await request.post('/auth/login', { username, password })
|
||||
return res.data.data as LoginResult
|
||||
const res = await request.post("/auth/login", { username, password });
|
||||
return res.data.data as LoginResult;
|
||||
},
|
||||
sendLoginSmsCode: async (username: string, password: string) => {
|
||||
const res = await request.post("/auth/login/sms-code", {
|
||||
username,
|
||||
password,
|
||||
});
|
||||
return res.data.data as SendLoginSMSCodeResult;
|
||||
},
|
||||
prepareLogin: async (data: {
|
||||
username: string;
|
||||
password: string;
|
||||
smsCode: string;
|
||||
apiBase?: string;
|
||||
}) => {
|
||||
const res = await request.post("/auth/login/prepare", data);
|
||||
return res.data.data as PreparedDeviceLogin;
|
||||
},
|
||||
completeLogin: async (data: {
|
||||
challengeId: string;
|
||||
pollToken: string;
|
||||
deviceTicket: string;
|
||||
}) => {
|
||||
const res = await request.post("/auth/login/complete", data);
|
||||
return res.data.data as LoginResult;
|
||||
},
|
||||
deviceChallengeStatus: async (challengeId: string, pollToken: string) => {
|
||||
const res = await request.get(
|
||||
`/dcauth/challenge/${encodeURIComponent(challengeId)}/status`,
|
||||
{
|
||||
params: { pollToken },
|
||||
},
|
||||
);
|
||||
return res.data.data as DCAuthChallengeStatus;
|
||||
},
|
||||
profile: async () => {
|
||||
const res = await request.get('/auth/profile')
|
||||
return res.data.data as User
|
||||
const res = await request.get("/auth/profile");
|
||||
return res.data.data as User;
|
||||
},
|
||||
changePassword: (data: { current_password: string; new_password: string }) =>
|
||||
request.put('/auth/password', data).then((r) => r.data.data),
|
||||
updateCurrentAccount: (data: { current_password: string; username: string; nickname?: string; new_password?: string }) =>
|
||||
request.put('/auth/account', data).then((r) => r.data.data as User),
|
||||
}
|
||||
request.put("/auth/password", data).then((r) => r.data.data),
|
||||
updateCurrentAccount: (data: {
|
||||
current_password: string;
|
||||
username: string;
|
||||
nickname?: string;
|
||||
new_password?: string;
|
||||
}) => request.put("/auth/account", data).then((r) => r.data.data as User),
|
||||
};
|
||||
|
||||
export const dashboardApi = {
|
||||
stats: () =>
|
||||
request.get('/dashboard').then((r) => r.data.data as DashboardStats),
|
||||
}
|
||||
request.get("/dashboard").then((r) => r.data.data as DashboardStats),
|
||||
};
|
||||
|
||||
export const userApi = {
|
||||
list: (params?: Record<string, unknown>) =>
|
||||
request.get('/platform/admins', { params }).then((r) => r.data.data as PageResult<User>),
|
||||
request
|
||||
.get("/platform/admins", { params })
|
||||
.then((r) => r.data.data as PageResult<User>),
|
||||
create: (data: { username: string; password: string; nickname?: string }) =>
|
||||
request.post('/platform/admins', data).then((r) => r.data.data as User),
|
||||
update: (id: number, data: { username: string; nickname?: string; password?: string; status: number }) =>
|
||||
request.patch(`/platform/admins/${id}`, data).then((r) => r.data.data as User),
|
||||
request.post("/platform/admins", data).then((r) => r.data.data as User),
|
||||
update: (
|
||||
id: number,
|
||||
data: {
|
||||
username: string;
|
||||
nickname?: string;
|
||||
password?: string;
|
||||
status: number;
|
||||
},
|
||||
) =>
|
||||
request
|
||||
.patch(`/platform/admins/${id}`, data)
|
||||
.then((r) => r.data.data as User),
|
||||
remove: (id: number) =>
|
||||
request.delete(`/platform/admins/${id}`).then((r) => r.data.data),
|
||||
}
|
||||
bindPhone: (id: number, phone: string) =>
|
||||
request
|
||||
.post(`/admin/users/${id}/phone`, { phone })
|
||||
.then((r) => r.data.data as User),
|
||||
resetDevice: (id: number) =>
|
||||
request
|
||||
.post(`/admin/users/${id}/device/reset`)
|
||||
.then((r) => r.data.data as { reset: boolean }),
|
||||
};
|
||||
|
||||
export const merchantApi = {
|
||||
current: () =>
|
||||
request.get('/merchant').then((r) => r.data.data as { merchant: Merchant; role: string; permissions: string[] }),
|
||||
request.get("/merchant").then(
|
||||
(r) =>
|
||||
r.data.data as {
|
||||
merchant: Merchant;
|
||||
role: string;
|
||||
permissions: string[];
|
||||
},
|
||||
),
|
||||
products: (params?: Record<string, unknown>) =>
|
||||
request.get('/merchant/products', { params }).then((r) => r.data.data as PageResult<MerchantProduct>),
|
||||
request
|
||||
.get("/merchant/products", { params })
|
||||
.then((r) => r.data.data as PageResult<MerchantProduct>),
|
||||
updateProduct: (
|
||||
id: number,
|
||||
data: {
|
||||
display_name?: string
|
||||
price_amount?: number
|
||||
cost_amount?: number
|
||||
stock?: number
|
||||
status?: 'active' | 'inactive'
|
||||
}
|
||||
display_name?: string;
|
||||
price_amount?: number;
|
||||
cost_amount?: number;
|
||||
stock?: number;
|
||||
status?: "active" | "inactive";
|
||||
},
|
||||
) => request.patch(`/merchant/products/${id}`, data).then((r) => r.data.data),
|
||||
updateProductStatus: (id: number, status: MerchantProduct['status']) =>
|
||||
request.patch(`/merchant/products/${id}/status`, { status }).then((r) => r.data.data),
|
||||
updateProductStatus: (id: number, status: MerchantProduct["status"]) =>
|
||||
request
|
||||
.patch(`/merchant/products/${id}/status`, { status })
|
||||
.then((r) => r.data.data),
|
||||
deleteProduct: (id: number) =>
|
||||
request.delete(`/merchant/products/${id}`).then((r) => r.data.data),
|
||||
orders: (params?: Record<string, unknown>) =>
|
||||
request.get('/merchant/orders', { params }).then((r) => r.data.data as PageResult<FulfillmentOrder>),
|
||||
request
|
||||
.get("/merchant/orders", { params })
|
||||
.then((r) => r.data.data as PageResult<FulfillmentOrder>),
|
||||
manualOrderProducts: () =>
|
||||
request.get('/merchant/orders/products').then((r) => r.data.data as MerchantProduct[]),
|
||||
request
|
||||
.get("/merchant/orders/products")
|
||||
.then((r) => r.data.data as MerchantProduct[]),
|
||||
createTestOrder: (data: {
|
||||
sku: string
|
||||
buyer_reference?: string
|
||||
note?: string
|
||||
order_status?: 'paid' | 'ship_failed' | 'cancelled'
|
||||
}) => request.post('/merchant/orders/test', data).then((r) => r.data.data as CreateTestOrderResult),
|
||||
sku: string;
|
||||
buyer_reference?: string;
|
||||
note?: string;
|
||||
order_status?: "paid" | "ship_failed" | "cancelled";
|
||||
}) =>
|
||||
request
|
||||
.post("/merchant/orders/test", data)
|
||||
.then((r) => r.data.data as CreateTestOrderResult),
|
||||
createManualOrder: (data: {
|
||||
client_order_no: string
|
||||
sku: string
|
||||
quantity: number
|
||||
buyer_reference?: string
|
||||
game_account?: string
|
||||
note?: string
|
||||
}) => request.post('/merchant/orders/manual', data).then((r) => r.data.data as CreateManualOrderResult),
|
||||
client_order_no: string;
|
||||
sku: string;
|
||||
quantity: number;
|
||||
buyer_reference?: string;
|
||||
game_account?: string;
|
||||
note?: string;
|
||||
}) =>
|
||||
request
|
||||
.post("/merchant/orders/manual", data)
|
||||
.then((r) => r.data.data as CreateManualOrderResult),
|
||||
cancelOrder: (orderNo: string, reason: string) =>
|
||||
request.post(`/merchant/orders/${encodeURIComponent(orderNo)}/cancel`, { reason }).then((r) => r.data.data as { order: FulfillmentOrder; refund_amount: number }),
|
||||
request
|
||||
.post(`/merchant/orders/${encodeURIComponent(orderNo)}/cancel`, {
|
||||
reason,
|
||||
})
|
||||
.then(
|
||||
(r) =>
|
||||
r.data.data as { order: FulfillmentOrder; refund_amount: number },
|
||||
),
|
||||
getDeliveryLink: (orderNo: string) =>
|
||||
request.get(`/merchant/orders/${encodeURIComponent(orderNo)}/delivery-link`).then((r) => r.data.data as DeliveryLinkResult),
|
||||
request
|
||||
.get(`/merchant/orders/${encodeURIComponent(orderNo)}/delivery-link`)
|
||||
.then((r) => r.data.data as DeliveryLinkResult),
|
||||
revokeDeliveryLink: (orderNo: string) =>
|
||||
request.post(`/merchant/orders/${encodeURIComponent(orderNo)}/delivery-link/revoke`).then((r) => r.data.data),
|
||||
request
|
||||
.post(
|
||||
`/merchant/orders/${encodeURIComponent(orderNo)}/delivery-link/revoke`,
|
||||
)
|
||||
.then((r) => r.data.data),
|
||||
restoreDeliveryLink: (orderNo: string) =>
|
||||
request.post(`/merchant/orders/${encodeURIComponent(orderNo)}/delivery-link/restore`).then((r) => r.data.data as DeliveryLinkResult),
|
||||
request
|
||||
.post(
|
||||
`/merchant/orders/${encodeURIComponent(orderNo)}/delivery-link/restore`,
|
||||
)
|
||||
.then((r) => r.data.data as DeliveryLinkResult),
|
||||
wallet: () =>
|
||||
request.get('/merchant/wallet').then((r) => r.data.data as WalletAccount),
|
||||
request.get("/merchant/wallet").then((r) => r.data.data as WalletAccount),
|
||||
ledger: (params?: Record<string, unknown>) =>
|
||||
request.get('/merchant/wallet/ledger', { params }).then((r) => r.data.data as PageResult<WalletLedgerEntry>),
|
||||
request
|
||||
.get("/merchant/wallet/ledger", { params })
|
||||
.then((r) => r.data.data as PageResult<WalletLedgerEntry>),
|
||||
apiClients: () =>
|
||||
request.get('/merchant/api-clients').then((r) => r.data.data as ApiClient[]),
|
||||
request
|
||||
.get("/merchant/api-clients")
|
||||
.then((r) => r.data.data as ApiClient[]),
|
||||
createApiClient: (data: {
|
||||
name: string
|
||||
scopes: string
|
||||
signature_version?: string
|
||||
expires_at?: string
|
||||
}) => request.post('/merchant/api-clients', data).then((r) => r.data.data as ApiCredential),
|
||||
updateApiClientStatus: (id: number, status: ApiClient['status']) =>
|
||||
request.patch(`/merchant/api-clients/${id}/status`, { status }).then((r) => r.data.data),
|
||||
name: string;
|
||||
scopes: string;
|
||||
signature_version?: string;
|
||||
expires_at?: string;
|
||||
}) =>
|
||||
request
|
||||
.post("/merchant/api-clients", data)
|
||||
.then((r) => r.data.data as ApiCredential),
|
||||
updateApiClientStatus: (id: number, status: ApiClient["status"]) =>
|
||||
request
|
||||
.patch(`/merchant/api-clients/${id}/status`, { status })
|
||||
.then((r) => r.data.data),
|
||||
deleteApiClient: (id: number) =>
|
||||
request.delete(`/merchant/api-clients/${id}`).then((r) => r.data.data),
|
||||
callbacks: () =>
|
||||
request.get('/merchant/callbacks').then((r) => r.data.data as CallbackSubscription | null),
|
||||
saveCallback: (data: { url: string; events: string; status: CallbackSubscription['status']; rotate_secret?: boolean }) =>
|
||||
request.post('/merchant/callbacks', data).then((r) => r.data.data as CallbackCredential),
|
||||
request
|
||||
.get("/merchant/callbacks")
|
||||
.then((r) => r.data.data as CallbackSubscription | null),
|
||||
saveCallback: (data: {
|
||||
url: string;
|
||||
events: string;
|
||||
status: CallbackSubscription["status"];
|
||||
rotate_secret?: boolean;
|
||||
}) =>
|
||||
request
|
||||
.post("/merchant/callbacks", data)
|
||||
.then((r) => r.data.data as CallbackCredential),
|
||||
members: () =>
|
||||
request.get('/merchant/members').then((r) => r.data.data as MerchantMember[]),
|
||||
addMember: (data: { user_id?: number; username?: string; password?: string; nickname?: string; role: string; is_default?: boolean }) =>
|
||||
request.post('/merchant/members', data).then((r) => r.data.data as MerchantMember),
|
||||
updateMember: (id: number, data: { role: string; status: number; is_default: boolean }) =>
|
||||
request.patch(`/merchant/members/${id}`, data).then((r) => r.data.data as MerchantMember),
|
||||
request
|
||||
.get("/merchant/members")
|
||||
.then((r) => r.data.data as MerchantMember[]),
|
||||
addMember: (data: {
|
||||
user_id?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
nickname?: string;
|
||||
role: string;
|
||||
is_default?: boolean;
|
||||
}) =>
|
||||
request
|
||||
.post("/merchant/members", data)
|
||||
.then((r) => r.data.data as MerchantMember),
|
||||
updateMember: (
|
||||
id: number,
|
||||
data: {
|
||||
username?: string;
|
||||
password?: string;
|
||||
nickname?: string;
|
||||
role: string;
|
||||
status: number;
|
||||
is_default: boolean;
|
||||
},
|
||||
) =>
|
||||
request
|
||||
.patch(`/merchant/members/${id}`, data)
|
||||
.then((r) => r.data.data as MerchantMember),
|
||||
removeMember: (id: number) =>
|
||||
request.delete(`/merchant/members/${id}`).then((r) => r.data.data),
|
||||
roles: () => request.get('/merchant/roles').then((r) => r.data.data as MerchantRole[]),
|
||||
bindMemberPhone: (id: number, phone: string) =>
|
||||
request
|
||||
.post(`/merchant/members/${id}/phone`, { phone })
|
||||
.then((r) => r.data.data as User),
|
||||
resetMemberDevice: (id: number) =>
|
||||
request
|
||||
.post(`/merchant/members/${id}/device/reset`)
|
||||
.then((r) => r.data.data as { reset: boolean }),
|
||||
roles: () =>
|
||||
request.get("/merchant/roles").then((r) => r.data.data as MerchantRole[]),
|
||||
createRole: (data: { code: string; name: string; permissions: string[] }) =>
|
||||
request.post('/merchant/roles', data).then((r) => r.data.data as MerchantRole),
|
||||
request
|
||||
.post("/merchant/roles", data)
|
||||
.then((r) => r.data.data as MerchantRole),
|
||||
updateRole: (id: number, data: { name: string; permissions: string[] }) =>
|
||||
request.patch(`/merchant/roles/${id}`, data).then((r) => r.data.data as MerchantRole),
|
||||
request
|
||||
.patch(`/merchant/roles/${id}`, data)
|
||||
.then((r) => r.data.data as MerchantRole),
|
||||
rechargeApplications: (params?: Record<string, unknown>) =>
|
||||
request.get('/merchant/recharge/applications', { params }).then((r) => r.data.data as PageResult<RechargeApplication>),
|
||||
createRechargeApplication: (data: { amount_cny: number; vouchers: string[]; note?: string }) =>
|
||||
request
|
||||
.get("/merchant/recharge/applications", { params })
|
||||
.then((r) => r.data.data as PageResult<RechargeApplication>),
|
||||
createRechargeApplication: (data: {
|
||||
amount_cny: number;
|
||||
vouchers: string[];
|
||||
note?: string;
|
||||
}) =>
|
||||
// amount_cny 单位:分(1元 = 100分),积分按 1元 = 100积分 折算
|
||||
request.post('/merchant/recharge/applications', data).then((r) => r.data.data as RechargeApplication),
|
||||
request
|
||||
.post("/merchant/recharge/applications", data)
|
||||
.then((r) => r.data.data as RechargeApplication),
|
||||
alertConfig: () =>
|
||||
request.get('/merchant/recharge/alert-config').then((r) => r.data.data as LowBalanceAlertConfig),
|
||||
saveAlertConfig: (data: { enabled: boolean; threshold_points: number; notify_interval_minutes: number; max_notifications: number }) =>
|
||||
request.put('/merchant/recharge/alert-config', data).then((r) => r.data.data as LowBalanceAlertConfig),
|
||||
request
|
||||
.get("/merchant/recharge/alert-config")
|
||||
.then((r) => r.data.data as LowBalanceAlertConfig),
|
||||
saveAlertConfig: (data: {
|
||||
enabled: boolean;
|
||||
threshold_points: number;
|
||||
notify_interval_minutes: number;
|
||||
max_notifications: number;
|
||||
}) =>
|
||||
request
|
||||
.put("/merchant/recharge/alert-config", data)
|
||||
.then((r) => r.data.data as LowBalanceAlertConfig),
|
||||
alertChannels: () =>
|
||||
request.get('/merchant/alert-channels').then((r) => r.data.data as AlertNotifyChannel[]),
|
||||
request
|
||||
.get("/merchant/alert-channels")
|
||||
.then((r) => r.data.data as AlertNotifyChannel[]),
|
||||
createAlertChannel: (data: AlertChannelInput) =>
|
||||
request.post('/merchant/alert-channels', data).then((r) => r.data.data as AlertNotifyChannel),
|
||||
request
|
||||
.post("/merchant/alert-channels", data)
|
||||
.then((r) => r.data.data as AlertNotifyChannel),
|
||||
updateAlertChannel: (id: number, data: AlertChannelInput) =>
|
||||
request.put(`/merchant/alert-channels/${id}`, data).then((r) => r.data.data as AlertNotifyChannel),
|
||||
request
|
||||
.put(`/merchant/alert-channels/${id}`, data)
|
||||
.then((r) => r.data.data as AlertNotifyChannel),
|
||||
deleteAlertChannel: (id: number) =>
|
||||
request.delete(`/merchant/alert-channels/${id}`).then((r) => r.data.data),
|
||||
testAlertChannel: (id: number) =>
|
||||
request.post(`/merchant/alert-channels/${id}/test`).then((r) => r.data.data),
|
||||
}
|
||||
request
|
||||
.post(`/merchant/alert-channels/${id}/test`)
|
||||
.then((r) => r.data.data),
|
||||
};
|
||||
|
||||
export const uploadApi = {
|
||||
file: (file: File) => {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return request.post('/upload', form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 60000,
|
||||
}).then((r) => r.data.data as { url: string })
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
return request
|
||||
.post("/upload", form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
timeout: 60000,
|
||||
})
|
||||
.then((r) => r.data.data as { url: string });
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export const deliveryApi = {
|
||||
getOrder: (orderNo: string, auth?: { exp?: string; sign?: string }) =>
|
||||
request.get(`/delivery/v1/orders/${encodeURIComponent(orderNo)}`, { params: auth }).then((r) => r.data.data as DeliveryOrderInfo),
|
||||
bind: (orderNo: string, gameAccount: string, auth?: { exp?: string; sign?: string }) =>
|
||||
request.post(
|
||||
`/delivery/v1/orders/${encodeURIComponent(orderNo)}/bind`,
|
||||
{
|
||||
game_account: gameAccount,
|
||||
},
|
||||
{ params: auth },
|
||||
).then((r) => r.data.data as DeliveryBindResult),
|
||||
getBindResult: (orderNo: string, bindUUID: string, auth?: { exp?: string; sign?: string }) =>
|
||||
request.get(`/delivery/v1/orders/${encodeURIComponent(orderNo)}/bind-result`, {
|
||||
params: { ...auth, bind_uuid: bindUUID },
|
||||
}).then((r) => r.data.data as DeliveryBindResult),
|
||||
submit: (orderNo: string, gameAccount: string, bindUUID: string, auth?: { exp?: string; sign?: string }) =>
|
||||
request.post(
|
||||
`/delivery/v1/orders/${encodeURIComponent(orderNo)}/submit`,
|
||||
{
|
||||
game_account: gameAccount,
|
||||
bind_uuid: bindUUID,
|
||||
},
|
||||
{ params: auth },
|
||||
).then((r) => r.data.data as DeliverySubmitResult),
|
||||
}
|
||||
request
|
||||
.get(`/delivery/v1/orders/${encodeURIComponent(orderNo)}`, {
|
||||
params: auth,
|
||||
})
|
||||
.then((r) => r.data.data as DeliveryOrderInfo),
|
||||
bind: (
|
||||
orderNo: string,
|
||||
gameAccount: string,
|
||||
auth?: { exp?: string; sign?: string },
|
||||
) =>
|
||||
request
|
||||
.post(
|
||||
`/delivery/v1/orders/${encodeURIComponent(orderNo)}/bind`,
|
||||
{
|
||||
game_account: gameAccount,
|
||||
},
|
||||
{ params: auth },
|
||||
)
|
||||
.then((r) => r.data.data as DeliveryBindResult),
|
||||
getBindResult: (
|
||||
orderNo: string,
|
||||
bindUUID: string,
|
||||
auth?: { exp?: string; sign?: string },
|
||||
) =>
|
||||
request
|
||||
.get(`/delivery/v1/orders/${encodeURIComponent(orderNo)}/bind-result`, {
|
||||
params: { ...auth, bind_uuid: bindUUID },
|
||||
})
|
||||
.then((r) => r.data.data as DeliveryBindResult),
|
||||
submit: (
|
||||
orderNo: string,
|
||||
gameAccount: string,
|
||||
bindUUID: string,
|
||||
auth?: { exp?: string; sign?: string },
|
||||
) =>
|
||||
request
|
||||
.post(
|
||||
`/delivery/v1/orders/${encodeURIComponent(orderNo)}/submit`,
|
||||
{
|
||||
game_account: gameAccount,
|
||||
bind_uuid: bindUUID,
|
||||
},
|
||||
{ params: auth },
|
||||
)
|
||||
.then((r) => r.data.data as DeliverySubmitResult),
|
||||
};
|
||||
|
||||
export const platformApi = {
|
||||
merchantAccounts: (params?: Record<string, unknown>) =>
|
||||
request.get('/platform/merchant-accounts', { params }).then((r) => r.data.data as PageResult<MerchantMemberGroup>),
|
||||
request
|
||||
.get("/platform/merchant-accounts", { params })
|
||||
.then((r) => r.data.data as PageResult<MerchantMemberGroup>),
|
||||
deleteMerchantAccount: (userId: number) =>
|
||||
request.delete(`/platform/merchant-accounts/${userId}`).then((r) => r.data.data),
|
||||
request
|
||||
.delete(`/platform/merchant-accounts/${userId}`)
|
||||
.then((r) => r.data.data),
|
||||
merchants: (params?: Record<string, unknown>) =>
|
||||
request.get('/platform/merchants', { params }).then((r) => r.data.data as PageResult<Merchant>),
|
||||
request
|
||||
.get("/platform/merchants", { params })
|
||||
.then((r) => r.data.data as PageResult<Merchant>),
|
||||
// 不分页的精简商户列表,供筛选下拉使用(/platform/merchants 的 size 上限是 100)
|
||||
merchantOptions: () =>
|
||||
request.get('/platform/merchant-options').then((r) => r.data.data as MerchantOption[]),
|
||||
request
|
||||
.get("/platform/merchant-options")
|
||||
.then((r) => r.data.data as MerchantOption[]),
|
||||
// 平台侧跨商户订单视图:不传 merchant_id 即全部商户
|
||||
orders: (params?: Record<string, unknown>) =>
|
||||
request.get('/platform/orders', { params }).then((r) => r.data.data as PageResult<FulfillmentOrder>),
|
||||
request
|
||||
.get("/platform/orders", { params })
|
||||
.then((r) => r.data.data as PageResult<FulfillmentOrder>),
|
||||
createMerchant: (data: {
|
||||
code: string
|
||||
name: string
|
||||
contact_name?: string
|
||||
contact_info?: string
|
||||
owner_user_id?: number
|
||||
owner_username?: string
|
||||
owner_password?: string
|
||||
owner_nickname?: string
|
||||
features?: string
|
||||
fee_type?: 'rate' | 'fixed'
|
||||
fee_rate_bp?: number
|
||||
fee_fixed_amount?: number
|
||||
}) => request.post('/platform/merchants', data).then((r) => r.data.data as Merchant),
|
||||
code: string;
|
||||
name: string;
|
||||
contact_name?: string;
|
||||
contact_info?: string;
|
||||
owner_user_id?: number;
|
||||
owner_username?: string;
|
||||
owner_password?: string;
|
||||
owner_nickname?: string;
|
||||
features?: string;
|
||||
fee_type?: "rate" | "fixed";
|
||||
fee_rate_bp?: number;
|
||||
fee_fixed_amount?: number;
|
||||
}) =>
|
||||
request
|
||||
.post("/platform/merchants", data)
|
||||
.then((r) => r.data.data as Merchant),
|
||||
updateMerchant: (id: number, data: Partial<Merchant>) =>
|
||||
request.patch(`/platform/merchants/${id}`, data).then((r) => r.data.data),
|
||||
members: (merchantId: number) =>
|
||||
request.get(`/platform/merchants/${merchantId}/members`).then((r) => r.data.data as MerchantMember[]),
|
||||
request
|
||||
.get(`/platform/merchants/${merchantId}/members`)
|
||||
.then((r) => r.data.data as MerchantMember[]),
|
||||
addMember: (
|
||||
merchantId: number,
|
||||
data: { user_id?: number; username?: string; password?: string; nickname?: string; role: string; is_default?: boolean },
|
||||
) => request.post(`/platform/merchants/${merchantId}/members`, data).then((r) => r.data.data as MerchantMember),
|
||||
data: {
|
||||
user_id?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
nickname?: string;
|
||||
role: string;
|
||||
is_default?: boolean;
|
||||
},
|
||||
) =>
|
||||
request
|
||||
.post(`/platform/merchants/${merchantId}/members`, data)
|
||||
.then((r) => r.data.data as MerchantMember),
|
||||
updateMember: (
|
||||
merchantId: number,
|
||||
memberId: number,
|
||||
data: { role: string; status: number; is_default: boolean },
|
||||
) => request.patch(`/platform/merchants/${merchantId}/members/${memberId}`, data).then((r) => r.data.data as MerchantMember),
|
||||
) =>
|
||||
request
|
||||
.patch(`/platform/merchants/${merchantId}/members/${memberId}`, data)
|
||||
.then((r) => r.data.data as MerchantMember),
|
||||
removeMember: (merchantId: number, memberId: number) =>
|
||||
request.delete(`/platform/merchants/${merchantId}/members/${memberId}`).then((r) => r.data.data),
|
||||
request
|
||||
.delete(`/platform/merchants/${merchantId}/members/${memberId}`)
|
||||
.then((r) => r.data.data),
|
||||
roles: (merchantId: number) =>
|
||||
request.get(`/platform/merchants/${merchantId}/roles`).then((r) => r.data.data as MerchantRole[]),
|
||||
request
|
||||
.get(`/platform/merchants/${merchantId}/roles`)
|
||||
.then((r) => r.data.data as MerchantRole[]),
|
||||
productCatalog: () =>
|
||||
request.get('/platform/product-catalog').then((r) => r.data.data as ProductCatalogItem[]),
|
||||
request
|
||||
.get("/platform/product-catalog")
|
||||
.then((r) => r.data.data as ProductCatalogItem[]),
|
||||
merchantProducts: (merchantId: number) =>
|
||||
request.get(`/platform/merchants/${merchantId}/products`).then((r) => r.data.data as ProductCatalogItem[]),
|
||||
updateMerchantProduct: (merchantId: number, productId: number, data: Partial<MerchantProduct>) =>
|
||||
request.patch(`/platform/merchants/${merchantId}/products/${productId}`, data).then((r) => r.data.data),
|
||||
request
|
||||
.get(`/platform/merchants/${merchantId}/products`)
|
||||
.then((r) => r.data.data as ProductCatalogItem[]),
|
||||
updateMerchantProduct: (
|
||||
merchantId: number,
|
||||
productId: number,
|
||||
data: Partial<MerchantProduct>,
|
||||
) =>
|
||||
request
|
||||
.patch(`/platform/merchants/${merchantId}/products/${productId}`, data)
|
||||
.then((r) => r.data.data),
|
||||
assignProducts: (merchantId: number, catalogIds: number[]) =>
|
||||
request.post(`/platform/merchants/${merchantId}/products/assign`, { catalog_ids: catalogIds }).then((r) => r.data.data as { assigned: number }),
|
||||
request
|
||||
.post(`/platform/merchants/${merchantId}/products/assign`, {
|
||||
catalog_ids: catalogIds,
|
||||
})
|
||||
.then((r) => r.data.data as { assigned: number }),
|
||||
wallet: (merchantId: number) =>
|
||||
request.get(`/platform/merchants/${merchantId}/wallet`).then((r) => r.data.data as WalletAccount),
|
||||
request
|
||||
.get(`/platform/merchants/${merchantId}/wallet`)
|
||||
.then((r) => r.data.data as WalletAccount),
|
||||
merchantAccount: (merchantId: number) =>
|
||||
request.get(`/platform/merchants/${merchantId}/account`).then((r) => r.data.data as MerchantOwnerAccount[]),
|
||||
updateMerchantAccount: (merchantId: number, data: { user_id: number; username: string; nickname?: string; password?: string; status?: number }) =>
|
||||
request.patch(`/platform/merchants/${merchantId}/account`, data).then((r) => r.data.data as MerchantOwnerAccount),
|
||||
request
|
||||
.get(`/platform/merchants/${merchantId}/account`)
|
||||
.then((r) => r.data.data as MerchantOwnerAccount[]),
|
||||
updateMerchantAccount: (
|
||||
merchantId: number,
|
||||
data: {
|
||||
user_id: number;
|
||||
username: string;
|
||||
nickname?: string;
|
||||
password?: string;
|
||||
status?: number;
|
||||
},
|
||||
) =>
|
||||
request
|
||||
.patch(`/platform/merchants/${merchantId}/account`, data)
|
||||
.then((r) => r.data.data as MerchantOwnerAccount),
|
||||
walletLedger: (merchantId: number, params?: Record<string, unknown>) =>
|
||||
request.get(`/platform/merchants/${merchantId}/wallet/ledger`, { params }).then((r) => r.data.data as PageResult<WalletLedgerEntry>),
|
||||
adjustWallet: (merchantId: number, data: { amount: number; idempotency_key: string; note?: string }) =>
|
||||
request.post(`/platform/merchants/${merchantId}/wallet/adjust`, data).then((r) => r.data.data as WalletAccount),
|
||||
request
|
||||
.get(`/platform/merchants/${merchantId}/wallet/ledger`, { params })
|
||||
.then((r) => r.data.data as PageResult<WalletLedgerEntry>),
|
||||
adjustWallet: (
|
||||
merchantId: number,
|
||||
data: { amount: number; idempotency_key: string; note?: string },
|
||||
) =>
|
||||
request
|
||||
.post(`/platform/merchants/${merchantId}/wallet/adjust`, data)
|
||||
.then((r) => r.data.data as WalletAccount),
|
||||
rechargeApplications: (params?: Record<string, unknown>) =>
|
||||
request.get('/platform/recharge/applications', { params }).then((r) => r.data.data as PageResult<RechargeApplication>),
|
||||
reviewRechargeApplication: (id: number, data: { approved: boolean; review_note?: string }) =>
|
||||
request.post(`/platform/recharge/applications/${id}/review`, data).then((r) => r.data.data as RechargeApplication),
|
||||
}
|
||||
request
|
||||
.get("/platform/recharge/applications", { params })
|
||||
.then((r) => r.data.data as PageResult<RechargeApplication>),
|
||||
reviewRechargeApplication: (
|
||||
id: number,
|
||||
data: { approved: boolean; review_note?: string },
|
||||
) =>
|
||||
request
|
||||
.post(`/platform/recharge/applications/${id}/review`, data)
|
||||
.then((r) => r.data.data as RechargeApplication),
|
||||
};
|
||||
|
||||
export const lewanApi = {
|
||||
listProducts: (params?: { page?: number; size?: number; keyword?: string }) =>
|
||||
request.get('/platform/lewan/products', { params }).then((r) => r.data.data as LewanProductListResult),
|
||||
request
|
||||
.get("/platform/lewan/products", { params })
|
||||
.then((r) => r.data.data as LewanProductListResult),
|
||||
syncCatalog: (data: SyncLewanProductInput) =>
|
||||
request.post('/platform/lewan/sync-catalog', data).then((r) => r.data.data as ProductCatalogItem),
|
||||
request
|
||||
.post("/platform/lewan/sync-catalog", data)
|
||||
.then((r) => r.data.data as ProductCatalogItem),
|
||||
batchSyncCatalog: (products: SyncLewanProductInput[]) =>
|
||||
request.post('/platform/lewan/batch-sync-catalog', { products }).then((r) => r.data.data as { synced_count: number }),
|
||||
request
|
||||
.post("/platform/lewan/batch-sync-catalog", { products })
|
||||
.then((r) => r.data.data as { synced_count: number }),
|
||||
updateRouting: (id: number, data: UpdateProductRoutingInput) =>
|
||||
request.put(`/platform/products/${id}/routing`, data).then((r) => r.data.data as { updated: boolean }),
|
||||
}
|
||||
request
|
||||
.put(`/platform/products/${id}/routing`, data)
|
||||
.then((r) => r.data.data as { updated: boolean }),
|
||||
};
|
||||
|
||||
export interface RockConfig {
|
||||
id: number
|
||||
auto_delivery_enabled: boolean
|
||||
base_url: string
|
||||
bearer_token: string
|
||||
username: string
|
||||
points: number
|
||||
min_points_warning: number
|
||||
token_warning_stock: number
|
||||
auto_purchase_enabled: boolean
|
||||
auto_purchase_count: number
|
||||
purchase_cooldown_minutes: number
|
||||
last_purchased_at?: string
|
||||
last_heartbeat_at?: string
|
||||
id: number;
|
||||
auto_delivery_enabled: boolean;
|
||||
base_url: string;
|
||||
bearer_token: string;
|
||||
username: string;
|
||||
points: number;
|
||||
min_points_warning: number;
|
||||
token_warning_stock: number;
|
||||
auto_purchase_enabled: boolean;
|
||||
auto_purchase_count: number;
|
||||
purchase_cooldown_minutes: number;
|
||||
last_purchased_at?: string;
|
||||
last_heartbeat_at?: string;
|
||||
}
|
||||
|
||||
export interface RockStats {
|
||||
available_stock: number
|
||||
using_stock: number
|
||||
bound_stock: number
|
||||
redeemed_stock: number
|
||||
mismatch_stock: number
|
||||
abnormal_stock: number
|
||||
upstream_points: number
|
||||
auto_delivery_enabled: boolean
|
||||
token_warning_stock: number
|
||||
auto_purchase_enabled: boolean
|
||||
username: string
|
||||
last_heartbeat_at?: string
|
||||
available_stock: number;
|
||||
using_stock: number;
|
||||
bound_stock: number;
|
||||
redeemed_stock: number;
|
||||
mismatch_stock: number;
|
||||
abnormal_stock: number;
|
||||
upstream_points: number;
|
||||
auto_delivery_enabled: boolean;
|
||||
token_warning_stock: number;
|
||||
auto_purchase_enabled: boolean;
|
||||
username: string;
|
||||
last_heartbeat_at?: string;
|
||||
}
|
||||
|
||||
export interface RockTokenItem {
|
||||
id: number
|
||||
upstream_token_id: string
|
||||
redeem_code: string
|
||||
color: string
|
||||
region: string
|
||||
status: string
|
||||
assigned_order_no?: string
|
||||
client_order_no?: string
|
||||
expected_uid?: string
|
||||
actual_role_name?: string
|
||||
actual_role_id?: string
|
||||
actual_channel?: string
|
||||
upstream_fulfill_id?: number
|
||||
target_skin?: string
|
||||
target_currency?: string
|
||||
target_currency_quantity?: number
|
||||
mismatch_at?: string
|
||||
mismatch_reason?: string
|
||||
remark?: string
|
||||
claimed_at?: string
|
||||
bound_at?: string
|
||||
redeemed_at?: string
|
||||
created_at: string
|
||||
id: number;
|
||||
upstream_token_id: string;
|
||||
redeem_code: string;
|
||||
color: string;
|
||||
region: string;
|
||||
status: string;
|
||||
assigned_order_no?: string;
|
||||
client_order_no?: string;
|
||||
expected_uid?: string;
|
||||
actual_role_name?: string;
|
||||
actual_role_id?: string;
|
||||
actual_channel?: string;
|
||||
upstream_fulfill_id?: number;
|
||||
target_skin?: string;
|
||||
target_currency?: string;
|
||||
target_currency_quantity?: number;
|
||||
mismatch_at?: string;
|
||||
mismatch_reason?: string;
|
||||
remark?: string;
|
||||
claimed_at?: string;
|
||||
bound_at?: string;
|
||||
redeemed_at?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export const rockApi = {
|
||||
getConfig: () => request.get('/platform/rock/config').then((r) => r.data.data as RockConfig),
|
||||
updateConfig: (data: Partial<RockConfig>) => request.put('/platform/rock/config', data).then((r) => r.data.data as RockConfig),
|
||||
testConnection: (bearer_token?: string) => request.post('/platform/rock/test-conn', { bearer_token }).then((r) => r.data.data),
|
||||
getStats: () => request.get('/platform/rock/stats').then((r) => r.data.data as RockStats),
|
||||
purchaseTokens: (count = 50) => request.post('/platform/rock/purchase', { count }).then((r) => r.data.data),
|
||||
listTokens: (params?: { page?: number; page_size?: number; status?: string; search?: string }) =>
|
||||
request.get('/platform/rock/tokens', { params }).then((r) => r.data.data as { list: RockTokenItem[]; total: number; page: number; page_size: number }),
|
||||
getConfig: () =>
|
||||
request.get("/platform/rock/config").then((r) => r.data.data as RockConfig),
|
||||
updateConfig: (data: Partial<RockConfig>) =>
|
||||
request
|
||||
.put("/platform/rock/config", data)
|
||||
.then((r) => r.data.data as RockConfig),
|
||||
testConnection: (bearer_token?: string) =>
|
||||
request
|
||||
.post("/platform/rock/test-conn", { bearer_token })
|
||||
.then((r) => r.data.data),
|
||||
getStats: () =>
|
||||
request.get("/platform/rock/stats").then((r) => r.data.data as RockStats),
|
||||
purchaseTokens: (count = 50) =>
|
||||
request.post("/platform/rock/purchase", { count }).then((r) => r.data.data),
|
||||
listTokens: (params?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
status?: string;
|
||||
search?: string;
|
||||
}) =>
|
||||
request.get("/platform/rock/tokens", { params }).then(
|
||||
(r) =>
|
||||
r.data.data as {
|
||||
list: RockTokenItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
},
|
||||
),
|
||||
correctExpectedUid: (id: number, expectedUid: string) =>
|
||||
request.post(`/platform/rock/tokens/${id}/expected-uid`, { expected_uid: expectedUid }).then((r) => r.data.data as { message?: string; status?: string }),
|
||||
resetToken: (id: number) => request.post(`/platform/rock/tokens/${id}/reset`).then((r) => r.data.data),
|
||||
releaseToken: (id: number) => request.post(`/platform/rock/tokens/${id}/release`).then((r) => r.data.data),
|
||||
request
|
||||
.post(`/platform/rock/tokens/${id}/expected-uid`, {
|
||||
expected_uid: expectedUid,
|
||||
})
|
||||
.then((r) => r.data.data as { message?: string; status?: string }),
|
||||
resetToken: (id: number) =>
|
||||
request.post(`/platform/rock/tokens/${id}/reset`).then((r) => r.data.data),
|
||||
releaseToken: (id: number) =>
|
||||
request
|
||||
.post(`/platform/rock/tokens/${id}/release`)
|
||||
.then((r) => r.data.data),
|
||||
updateRemark: (id: number, data: { remark: string; status?: string }) =>
|
||||
request.post(`/platform/rock/tokens/${id}/remark`, data).then((r) => r.data.data as { message: string; token: RockTokenItem }),
|
||||
request
|
||||
.post(`/platform/rock/tokens/${id}/remark`, data)
|
||||
.then((r) => r.data.data as { message: string; token: RockTokenItem }),
|
||||
markAbnormal: (id: number, remark?: string) =>
|
||||
request.post(`/platform/rock/tokens/${id}/abnormal`, { remark }).then((r) => r.data.data as { message: string; token: RockTokenItem }),
|
||||
}
|
||||
|
||||
|
||||
|
||||
request
|
||||
.post(`/platform/rock/tokens/${id}/abnormal`, { remark })
|
||||
.then((r) => r.data.data as { message: string; token: RockTokenItem }),
|
||||
};
|
||||
|
||||
+199
-24
@@ -1,24 +1,124 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Form, Input, Button, Typography, message, Space } from 'antd'
|
||||
import { UserOutlined, LockOutlined, RightOutlined } from '@ant-design/icons'
|
||||
import { Alert, Button, Divider, Form, Input, Space, Typography, message } from 'antd'
|
||||
import { LockOutlined, RightOutlined, SafetyCertificateOutlined, UserOutlined } from '@ant-design/icons'
|
||||
import { authApi } from '../api'
|
||||
import type { PreparedDeviceLogin } from '../types'
|
||||
import { useAuth } from '../store/authContext'
|
||||
|
||||
export default function Login() {
|
||||
const { login } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [form] = Form.useForm()
|
||||
type LoginForm = {
|
||||
username: string
|
||||
password: string
|
||||
smsCode: string
|
||||
}
|
||||
|
||||
const onFinish = async (values: { username: string; password: string }) => {
|
||||
export default function Login() {
|
||||
const { login, applyLoginResult } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [form] = Form.useForm<LoginForm>()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [sending, setSending] = useState(false)
|
||||
const [countdown, setCountdown] = useState(0)
|
||||
const [waiting, setWaiting] = useState(false)
|
||||
const [phoneMasked, setPhoneMasked] = useState('')
|
||||
const timerRef = useRef<number | null>(null)
|
||||
const submittingRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) window.clearInterval(timerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startCountdown = (seconds: number) => {
|
||||
setCountdown(seconds)
|
||||
if (timerRef.current) window.clearInterval(timerRef.current)
|
||||
timerRef.current = window.setInterval(() => {
|
||||
setCountdown((current) => {
|
||||
if (current <= 1) {
|
||||
if (timerRef.current) window.clearInterval(timerRef.current)
|
||||
return 0
|
||||
}
|
||||
return current - 1
|
||||
})
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const sendCode = async () => {
|
||||
try {
|
||||
const values = await form.validateFields(['username', 'password'])
|
||||
setSending(true)
|
||||
const result = await authApi.sendLoginSmsCode(values.username.trim(), values.password)
|
||||
setPhoneMasked(result.phoneMasked)
|
||||
startCountdown(60)
|
||||
message.success(`验证码已发送至 ${result.phoneMasked}`)
|
||||
} catch (error) {
|
||||
const text = error instanceof Error ? error.message : '验证码发送失败'
|
||||
if (text.includes('未绑定手机号')) {
|
||||
message.warning(`${text},可使用下方「旧账号仅密码登录」`)
|
||||
} else {
|
||||
message.error(text)
|
||||
}
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onFinish = async (values: LoginForm) => {
|
||||
if (submittingRef.current) return
|
||||
submittingRef.current = true
|
||||
setLoading(true)
|
||||
setWaiting(false)
|
||||
try {
|
||||
const prepared = await authApi.prepareLogin({
|
||||
username: values.username.trim(),
|
||||
password: values.password,
|
||||
smsCode: values.smsCode.trim(),
|
||||
apiBase: window.location.origin,
|
||||
})
|
||||
setPhoneMasked(prepared.phoneMasked)
|
||||
setWaiting(true)
|
||||
message.info('已发起本机验证,请在弹出的 DCAuth 窗口中确认')
|
||||
launchProtocol(prepared.launchUrl)
|
||||
|
||||
const result = await waitForDevice(prepared)
|
||||
applyLoginResult(result)
|
||||
message.success('登录成功,欢迎回来!')
|
||||
navigate('/', { replace: true })
|
||||
} catch (error) {
|
||||
const text = error instanceof Error ? error.message : '登录失败,请检查账号密码'
|
||||
if (text.includes('未绑定手机号')) {
|
||||
message.warning(`${text},可使用下方「旧账号仅密码登录」`)
|
||||
} else if (text.includes('绑定设备不一致')) {
|
||||
message.error('当前设备与账号绑定设备不一致,请联系管理员重置设备')
|
||||
} else {
|
||||
message.error(text)
|
||||
}
|
||||
} finally {
|
||||
submittingRef.current = false
|
||||
setLoading(false)
|
||||
setWaiting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const submitLegacyLogin = async () => {
|
||||
if (submittingRef.current) return
|
||||
let values: LoginForm
|
||||
try {
|
||||
values = await form.validateFields(['username', 'password'])
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
submittingRef.current = true
|
||||
setLoading(true)
|
||||
try {
|
||||
await login(values.username, values.password)
|
||||
await login(values.username.trim(), values.password)
|
||||
message.success('登录成功,欢迎回来!')
|
||||
navigate('/')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '登录失败,请检查账号密码')
|
||||
navigate('/', { replace: true })
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '账号密码登录失败')
|
||||
} finally {
|
||||
submittingRef.current = false
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
@@ -33,16 +133,20 @@ export default function Login() {
|
||||
Skin Hub 游戏皮肤分销平台
|
||||
</Typography.Title>
|
||||
<Typography.Text style={{ color: '#94a3b8', fontSize: 13 }}>
|
||||
基于 Go + React 的高并发皮肤分发管理系统
|
||||
账号密码 + 短信验证码 + DCAuth 本机设备签名
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={onFinish}
|
||||
requiredMark={false}
|
||||
>
|
||||
{waiting ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="等待本机确认"
|
||||
description="请在弹出的 DCAuth 窗口中点击确认。若未弹出,请先安装并运行一次 DCAuth 客户端。"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Form form={form} layout="vertical" onFinish={onFinish} requiredMark={false}>
|
||||
<Form.Item
|
||||
name="username"
|
||||
rules={[{ required: true, message: '请输入用户名' }]}
|
||||
@@ -50,8 +154,9 @@ export default function Login() {
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined style={{ color: '#64748b' }} />}
|
||||
placeholder="用户名 / 手机号"
|
||||
placeholder="用户名(支持中文 / 英文)"
|
||||
size="large"
|
||||
autoComplete="username"
|
||||
style={{ borderRadius: 8 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -59,16 +164,45 @@ export default function Login() {
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
style={{ marginBottom: 24 }}
|
||||
style={{ marginBottom: 18 }}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined style={{ color: '#64748b' }} />}
|
||||
placeholder="密码"
|
||||
size="large"
|
||||
autoComplete="current-password"
|
||||
style={{ borderRadius: 8 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="smsCode"
|
||||
rules={[{ required: true, message: '请输入短信验证码' }]}
|
||||
style={{ marginBottom: 24 }}
|
||||
>
|
||||
<Input
|
||||
prefix={<SafetyCertificateOutlined style={{ color: '#64748b' }} />}
|
||||
placeholder={`6 位短信验证码${phoneMasked ? `(${phoneMasked})` : ''}`}
|
||||
size="large"
|
||||
maxLength={6}
|
||||
autoComplete="one-time-code"
|
||||
style={{ borderRadius: 8 }}
|
||||
addonAfter={
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
htmlType="button"
|
||||
loading={sending}
|
||||
disabled={countdown > 0}
|
||||
onClick={() => void sendCode()}
|
||||
style={{ padding: 0, height: 'auto' }}
|
||||
>
|
||||
{countdown > 0 ? `${countdown}s 后重发` : '获取验证码'}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item style={{ marginBottom: 12 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
@@ -86,18 +220,59 @@ export default function Login() {
|
||||
boxShadow: '0 4px 14px rgba(37, 99, 235, 0.4)',
|
||||
}}
|
||||
>
|
||||
安全登录
|
||||
安全登录 · 拉起本机验证
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Divider plain style={{ margin: '0 0 4px' }}>
|
||||
<Typography.Text style={{ color: '#64748b', fontSize: 12 }}>未绑定手机号的历史账号</Typography.Text>
|
||||
</Divider>
|
||||
<Button type="link" block disabled={loading} onClick={() => void submitLegacyLogin()}>
|
||||
旧账号仅密码登录
|
||||
</Button>
|
||||
|
||||
<Typography.Paragraph
|
||||
style={{ fontSize: 12, margin: 0, textAlign: 'center', color: '#64748b' }}
|
||||
>
|
||||
请使用平台管理员或商户负责人分配的账号登录
|
||||
已绑定手机号的账号必须通过短信验证码与 DCAuth 本机签名登录
|
||||
</Typography.Paragraph>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function waitForDevice(prepared: PreparedDeviceLogin) {
|
||||
for (let attempt = 0; attempt < 120; attempt += 1) {
|
||||
await sleep(1000)
|
||||
const status = await authApi.deviceChallengeStatus(prepared.challengeId, prepared.pollToken)
|
||||
if (status.status === 'authorized' && status.deviceTicket) {
|
||||
return authApi.completeLogin({
|
||||
challengeId: prepared.challengeId,
|
||||
pollToken: prepared.pollToken,
|
||||
deviceTicket: status.deviceTicket,
|
||||
})
|
||||
}
|
||||
if (status.status === 'expired' || status.status === 'rejected') {
|
||||
throw new Error(status.message || '本机验证已过期,请重新登录')
|
||||
}
|
||||
if (attempt === 4) {
|
||||
message.info('如果浏览器没有弹出 DCAuth,请先安装并运行一次 DCAuth 客户端')
|
||||
}
|
||||
}
|
||||
throw new Error('等待本机验证超时,请确认 DCAuth 已安装并允许浏览器唤起')
|
||||
}
|
||||
|
||||
function launchProtocol(url: string) {
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.style.display = 'none'
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
window.setTimeout(() => anchor.remove(), 1000)
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,11 @@ type AdminFormValues = {
|
||||
status: boolean
|
||||
}
|
||||
|
||||
function maskPhone(value?: string) {
|
||||
if (!value) return ''
|
||||
return /^1[3-9]\d{9}$/.test(value) ? `${value.slice(0, 3)}****${value.slice(-4)}` : value
|
||||
}
|
||||
|
||||
export default function PlatformUsers() {
|
||||
const { user: currentUser } = useAuth()
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
@@ -29,7 +34,11 @@ export default function PlatformUsers() {
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [editingAdmin, setEditingAdmin] = useState<User | null>(null)
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [phoneModalOpen, setPhoneModalOpen] = useState(false)
|
||||
const [phoneUser, setPhoneUser] = useState<User | null>(null)
|
||||
const [phoneSubmitting, setPhoneSubmitting] = useState(false)
|
||||
const [form] = Form.useForm<AdminFormValues>()
|
||||
const [phoneForm] = Form.useForm<{ phone: string }>()
|
||||
|
||||
const loadAdmins = useCallback(async (page = admins.page, size = admins.size) => {
|
||||
setLoading(true)
|
||||
@@ -116,14 +125,94 @@ export default function PlatformUsers() {
|
||||
}
|
||||
}, [loadMerchantAccounts])
|
||||
|
||||
const reloadCurrentTab = useCallback(() => {
|
||||
if (tab === 'admins') {
|
||||
void loadAdmins(admins.page, admins.size)
|
||||
} else {
|
||||
void loadMerchantAccounts(merchantAccounts.page, merchantAccounts.size)
|
||||
}
|
||||
}, [admins.page, admins.size, loadAdmins, loadMerchantAccounts, merchantAccounts.page, merchantAccounts.size, tab])
|
||||
|
||||
const openBindPhone = useCallback((user: User) => {
|
||||
setPhoneUser(user)
|
||||
phoneForm.setFieldsValue({ phone: user.phone || '' })
|
||||
setPhoneModalOpen(true)
|
||||
}, [phoneForm])
|
||||
|
||||
const submitBindPhone = async () => {
|
||||
if (!phoneUser) return
|
||||
const values = await phoneForm.validateFields()
|
||||
setPhoneSubmitting(true)
|
||||
try {
|
||||
await userApi.bindPhone(phoneUser.id, values.phone.trim())
|
||||
message.success('登录手机号已绑定,该账号下次登录需使用短信验证码 + DCAuth 设备签名')
|
||||
setPhoneModalOpen(false)
|
||||
setPhoneUser(null)
|
||||
phoneForm.resetFields()
|
||||
reloadCurrentTab()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '手机号绑定失败')
|
||||
} finally {
|
||||
setPhoneSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const resetDevice = useCallback(async (user: User) => {
|
||||
try {
|
||||
const result = await userApi.resetDevice(user.id)
|
||||
message.success(result.reset ? '设备绑定已重置,该账号下次登录会重新绑定当前设备' : '该账号当前没有已绑定设备')
|
||||
reloadCurrentTab()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '设备重置失败')
|
||||
}
|
||||
}, [reloadCurrentTab])
|
||||
|
||||
const phoneCell = useCallback((user?: User) => {
|
||||
if (!user) return '-'
|
||||
if (!user.phone) {
|
||||
return currentUser?.role === 'admin'
|
||||
? <Button type="link" size="small" onClick={() => openBindPhone(user)}>绑定手机号</Button>
|
||||
: <Typography.Text type="secondary">未绑定</Typography.Text>
|
||||
}
|
||||
return (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Text>{maskPhone(user.phone)}</Typography.Text>
|
||||
{currentUser?.role === 'admin'
|
||||
? <Button type="link" size="small" style={{ padding: 0, height: 'auto' }} onClick={() => openBindPhone(user)}>修改</Button>
|
||||
: null}
|
||||
</Space>
|
||||
)
|
||||
}, [currentUser?.role, openBindPhone])
|
||||
|
||||
const deviceCell = useCallback((user?: User) => {
|
||||
if (!user?.device_bound) return <Typography.Text type="secondary">未绑定</Typography.Text>
|
||||
return (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Text code>{user.device_id_masked || user.device_id}</Typography.Text>
|
||||
{currentUser?.role === 'admin' ? (
|
||||
<Popconfirm
|
||||
title="确认重置该账号的设备绑定?"
|
||||
description="重置后该账号下次安全登录会重新绑定当前设备。"
|
||||
onConfirm={() => void resetDevice(user)}
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Button type="link" danger size="small" style={{ padding: 0, height: 'auto' }}>重置设备</Button>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
</Space>
|
||||
)
|
||||
}, [currentUser?.role, resetDevice])
|
||||
|
||||
const columns: ColumnsType<User> = useMemo(() => [
|
||||
{ title: '用户名', dataIndex: 'username', width: 260, render: (value) => <Typography.Text strong>{value}</Typography.Text> },
|
||||
{ title: '昵称', dataIndex: 'nickname', width: 220, render: (value) => value || '-' },
|
||||
{ title: '状态', dataIndex: 'status', width: 120, render: (value) => value === 1 ? <Tag color="green">启用</Tag> : <Tag>停用</Tag> },
|
||||
{ title: '创建时间', dataIndex: 'created_at', width: 190, render: formatDateTime },
|
||||
{ title: '用户名', dataIndex: 'username', width: 220, render: (value) => <Typography.Text strong>{value}</Typography.Text> },
|
||||
{ title: '昵称', dataIndex: 'nickname', width: 180, render: (value) => value || '-' },
|
||||
{ title: '手机号', width: 170, render: (_, record) => phoneCell(record) },
|
||||
{ title: '绑定设备', width: 200, render: (_, record) => deviceCell(record) },
|
||||
{ title: '状态', dataIndex: 'status', width: 100, render: (value) => value === 1 ? <Tag color="green">启用</Tag> : <Tag>停用</Tag> },
|
||||
{ title: '创建时间', dataIndex: 'created_at', width: 180, render: formatDateTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 190,
|
||||
width: 170,
|
||||
render: (_, record) => record.id === currentUser?.id ? <Typography.Text type="secondary">当前账号</Typography.Text> : (
|
||||
<Space size={0}>
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(record)}>编辑</Button>
|
||||
@@ -133,13 +222,15 @@ export default function PlatformUsers() {
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
], [currentUser?.id, openEdit, remove])
|
||||
], [currentUser?.id, deviceCell, openEdit, phoneCell, remove])
|
||||
|
||||
const memberColumns: ColumnsType<MerchantMember> = useMemo(() => [
|
||||
{ title: '员工账号', dataIndex: ['user', 'username'], width: 260, render: (_, record) => <Typography.Text strong>{record.user?.username || `#${record.user_id}`}</Typography.Text> },
|
||||
{ title: '昵称', dataIndex: ['user', 'nickname'], width: 220, render: (_, record) => record.user?.nickname || '-' },
|
||||
{ title: '商户角色', dataIndex: 'role', width: 160, render: (role) => <Tag color="blue">{roleText(role)}</Tag> },
|
||||
{ title: '成员状态', dataIndex: 'status', width: 130, render: (status) => status === 1 ? <Tag color="green">启用</Tag> : <Tag>停用</Tag> },
|
||||
{ title: '员工账号', dataIndex: ['user', 'username'], width: 220, render: (_, record) => <Typography.Text strong>{record.user?.username || `#${record.user_id}`}</Typography.Text> },
|
||||
{ title: '昵称', dataIndex: ['user', 'nickname'], width: 170, render: (_, record) => record.user?.nickname || '-' },
|
||||
{ title: '手机号', width: 170, render: (_, record) => phoneCell(record.user) },
|
||||
{ title: '绑定设备', width: 200, render: (_, record) => deviceCell(record.user) },
|
||||
{ title: '商户角色', dataIndex: 'role', width: 140, render: (role) => <Tag color="blue">{roleText(role)}</Tag> },
|
||||
{ title: '成员状态', dataIndex: 'status', width: 110, render: (status) => status === 1 ? <Tag color="green">启用</Tag> : <Tag>停用</Tag> },
|
||||
{
|
||||
title: '操作',
|
||||
width: 120,
|
||||
@@ -152,7 +243,7 @@ export default function PlatformUsers() {
|
||||
<Button type="link" danger size="small" icon={<DeleteOutlined />}>删除账号</Button>
|
||||
</Popconfirm>,
|
||||
},
|
||||
], [removeMerchantAccount])
|
||||
], [deviceCell, phoneCell, removeMerchantAccount])
|
||||
|
||||
const merchantColumns: ColumnsType<MerchantMemberGroup> = useMemo(() => [
|
||||
{
|
||||
@@ -248,6 +339,37 @@ export default function PlatformUsers() {
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="绑定登录手机号"
|
||||
open={phoneModalOpen}
|
||||
onCancel={() => {
|
||||
setPhoneModalOpen(false)
|
||||
setPhoneUser(null)
|
||||
phoneForm.resetFields()
|
||||
}}
|
||||
onOk={() => void submitBindPhone()}
|
||||
confirmLoading={phoneSubmitting}
|
||||
okText="保存"
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={phoneForm} layout="vertical" preserve={false}>
|
||||
<Form.Item label="账号">
|
||||
<Input value={phoneUser?.username || ''} disabled />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="手机号"
|
||||
name="phone"
|
||||
rules={[
|
||||
{ required: true, message: '请输入手机号' },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的中国大陆手机号' },
|
||||
]}
|
||||
extra="绑定后该账号必须使用「密码 + 短信验证码 + DCAuth 设备签名」登录,手机号全平台唯一。"
|
||||
>
|
||||
<Input maxLength={11} placeholder="例如 13800138000" autoComplete="off" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<MerchantMembersDrawer
|
||||
open={drawerOpen}
|
||||
merchantId={selectedMerchantId}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import type { Merchant, User } from '../types'
|
||||
import type { LoginResult, Merchant, User } from '../types'
|
||||
import { authApi, merchantApi } from '../api'
|
||||
import { AuthContext } from './authContext'
|
||||
|
||||
@@ -58,8 +58,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
void refreshMerchantContext()
|
||||
}, [refreshMerchantContext])
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
const result = await authApi.login(username, password)
|
||||
const applyLoginResult = useCallback((result: LoginResult) => {
|
||||
localStorage.removeItem('merchant_id')
|
||||
localStorage.setItem('token', result.token)
|
||||
localStorage.setItem('user', JSON.stringify(result.user))
|
||||
@@ -68,6 +67,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
setMerchantLoading(true)
|
||||
}, [])
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
const result = await authApi.login(username, password)
|
||||
applyLoginResult(result)
|
||||
}, [applyLoginResult])
|
||||
|
||||
const logout = useCallback(() => {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
@@ -91,6 +95,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
token,
|
||||
user,
|
||||
login,
|
||||
applyLoginResult,
|
||||
logout,
|
||||
refreshProfile,
|
||||
merchant,
|
||||
@@ -100,7 +105,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
refreshMerchantContext,
|
||||
isAdmin: user?.role === 'admin',
|
||||
}),
|
||||
[token, user, login, logout, refreshProfile, merchant, merchantRole, merchantPermissions, merchantLoading, refreshMerchantContext],
|
||||
[token, user, login, applyLoginResult, logout, refreshProfile, merchant, merchantRole, merchantPermissions, merchantLoading, refreshMerchantContext],
|
||||
)
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
import type { Merchant, User } from '../types'
|
||||
import type { LoginResult, Merchant, User } from '../types'
|
||||
|
||||
export interface AuthState {
|
||||
token: string | null
|
||||
user: User | null
|
||||
login: (username: string, password: string) => Promise<void>
|
||||
applyLoginResult: (result: LoginResult) => void
|
||||
logout: () => void
|
||||
refreshProfile: () => Promise<void>
|
||||
merchant: Merchant | null
|
||||
|
||||
@@ -5,6 +5,13 @@ export interface User {
|
||||
role: 'admin' | 'merchant'
|
||||
status: number
|
||||
created_at: string
|
||||
phone?: string
|
||||
phone_masked?: string
|
||||
phone_bound_at?: string | null
|
||||
phone_bound_by?: number | null
|
||||
device_id?: string
|
||||
device_id_masked?: string
|
||||
device_bound?: boolean
|
||||
}
|
||||
|
||||
export interface Merchant {
|
||||
@@ -320,6 +327,29 @@ export interface LoginResult {
|
||||
user: User
|
||||
}
|
||||
|
||||
export interface SendLoginSMSCodeResult {
|
||||
sent: boolean
|
||||
phoneMasked: string
|
||||
expiresInSeconds: number
|
||||
}
|
||||
|
||||
export interface PreparedDeviceLogin {
|
||||
challengeId: string
|
||||
challenge: string
|
||||
expiresAt: string
|
||||
pollToken: string
|
||||
launchUrl: string
|
||||
phoneMasked: string
|
||||
app: string
|
||||
}
|
||||
|
||||
export interface DCAuthChallengeStatus {
|
||||
status: 'pending' | 'authorized' | 'ticket_issued' | 'consumed' | 'expired' | 'rejected' | string
|
||||
deviceTicket?: string
|
||||
deviceId?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface RechargeApplication {
|
||||
id: number
|
||||
application_no: string
|
||||
|
||||
Reference in New Issue
Block a user