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:
yml2213
2026-10-21 22:37:49 +08:00
parent aa052d420a
commit bab522bcb3
29 changed files with 5490 additions and 1170 deletions
+537 -221
View File
@@ -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
View File
@@ -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
+133 -11
View File
@@ -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
+9 -4
View File
@@ -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>
+2 -1
View File
@@ -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
+30
View File
@@ -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