接单平台新增 VIP 等级体系与邀请绑定
- 等级按累计验收通过工单数自动升级(阈值后台可配),VIP1-5 默认配置 10/30/50/100 - 存量打手统一归 VIP1,原 regular 等级停用 - 注册支持邀请码绑定邀请人,每人生成唯一邀请码 - 新增 worker_level_logs 升级流水表 - 个人中心展示邀请码与升级进度,管理端等级表单支持升级阈值配置
This commit is contained in:
@@ -0,0 +1,55 @@
|
|||||||
|
-- 007_worker_vip_levels_and_invite.sql —— VIP 等级体系与邀请绑定。
|
||||||
|
--
|
||||||
|
-- 说明:
|
||||||
|
-- 1. 等级升级按累计验收通过(accepted)工单数,阈值存 permission_json.upgradeThreshold,后台可配;
|
||||||
|
-- 2. 每个打手拥有唯一邀请码,注册时可填邀请码绑定邀请人(inviter_id);
|
||||||
|
-- 3. 存量打手统一归 VIP1,原 regular 等级停用;
|
||||||
|
-- 4. 佣金结算暂缓(规则未定),本迁移只落邀请关系,后续按规则补佣金。
|
||||||
|
|
||||||
|
-- 1) worker_users 增加邀请字段
|
||||||
|
ALTER TABLE worker_users ADD COLUMN IF NOT EXISTS inviter_id BIGINT REFERENCES worker_users(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE worker_users ADD COLUMN IF NOT EXISTS invite_code TEXT;
|
||||||
|
|
||||||
|
-- 2) 存量用户填充唯一邀请码(8 位大写字母数字)
|
||||||
|
UPDATE worker_users
|
||||||
|
SET invite_code = UPPER(SUBSTRING(MD5(random()::text || id::text) FROM 1 FOR 8))
|
||||||
|
WHERE invite_code IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE worker_users ALTER COLUMN invite_code SET NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_worker_users_invite_code ON worker_users(invite_code);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_worker_users_inviter ON worker_users(inviter_id);
|
||||||
|
|
||||||
|
-- 3) seed VIP1-5 等级(level_key 唯一,重复执行不覆盖)
|
||||||
|
INSERT INTO worker_levels (level_key, name, sort_order, status, permission_json, created_at, updated_at) VALUES
|
||||||
|
('vip1', 'VIP1', 10, 'active', '{"depositFreeAmount":0,"maxActiveOrders":1,"upgradeThreshold":0}', NOW(), NOW()),
|
||||||
|
('vip2', 'VIP2', 20, 'active', '{"depositFreeAmount":0,"maxActiveOrders":3,"upgradeThreshold":10}', NOW(), NOW()),
|
||||||
|
('vip3', 'VIP3', 30, 'active', '{"depositFreeAmount":0,"maxActiveOrders":10,"upgradeThreshold":30}', NOW(), NOW()),
|
||||||
|
('vip4', 'VIP4', 40, 'active', '{"depositFreeAmount":0,"maxActiveOrders":50,"upgradeThreshold":50}', NOW(), NOW()),
|
||||||
|
('vip5', 'VIP5', 50, 'active', '{"depositFreeAmount":0,"maxActiveOrders":100,"upgradeThreshold":100}', NOW(), NOW())
|
||||||
|
ON CONFLICT (level_key) DO NOTHING;
|
||||||
|
|
||||||
|
-- 4) 存量打手统一归 VIP1,原 regular 等级停用
|
||||||
|
UPDATE worker_levels SET status = 'disabled' WHERE level_key = 'regular';
|
||||||
|
UPDATE worker_users
|
||||||
|
SET level_id = (SELECT id FROM worker_levels WHERE level_key = 'vip1')
|
||||||
|
WHERE level_id IS NULL
|
||||||
|
OR level_id = (SELECT id FROM worker_levels WHERE level_key = 'regular');
|
||||||
|
|
||||||
|
-- 5) 打手等级升级记录
|
||||||
|
CREATE TABLE IF NOT EXISTS worker_level_logs (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
worker_id BIGINT NOT NULL REFERENCES worker_users(id) ON DELETE CASCADE,
|
||||||
|
from_level_id BIGINT REFERENCES worker_levels(id) ON DELETE SET NULL,
|
||||||
|
to_level_id BIGINT NOT NULL REFERENCES worker_levels(id) ON DELETE SET NULL,
|
||||||
|
reason TEXT NOT NULL DEFAULT 'auto',
|
||||||
|
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_worker_level_logs_worker_created
|
||||||
|
ON worker_level_logs(worker_id, created_at DESC);
|
||||||
|
|
||||||
|
COMMENT ON COLUMN worker_users.inviter_id IS '邀请人(注册时通过邀请码绑定)';
|
||||||
|
COMMENT ON COLUMN worker_users.invite_code IS '本人专属邀请码,注册/邀请他人时使用';
|
||||||
|
COMMENT ON TABLE worker_level_logs IS '打手等级升级流水';
|
||||||
|
COMMENT ON COLUMN worker_level_logs.reason IS '升级原因:auto 自动升级 / admin 后台调整';
|
||||||
@@ -26,6 +26,10 @@ export type WorkerUserRow = {
|
|||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
reviewed_at: string | null
|
reviewed_at: string | null
|
||||||
|
inviter_id: number | null
|
||||||
|
invite_code: string
|
||||||
|
inviter_username?: string
|
||||||
|
inviter_display_name?: string
|
||||||
level_key?: string
|
level_key?: string
|
||||||
level_name?: string
|
level_name?: string
|
||||||
level_permission_json?: string | Record<string, unknown>
|
level_permission_json?: string | Record<string, unknown>
|
||||||
@@ -192,6 +196,8 @@ type CreateWorkerInput = {
|
|||||||
phone: string
|
phone: string
|
||||||
levelId: number | null
|
levelId: number | null
|
||||||
status: string
|
status: string
|
||||||
|
inviterId?: number | null
|
||||||
|
inviteCode: string
|
||||||
now: string
|
now: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,12 +224,15 @@ const WORKER_USER_SELECT = `
|
|||||||
wl.level_key,
|
wl.level_key,
|
||||||
wl.name AS level_name,
|
wl.name AS level_name,
|
||||||
wl.permission_json AS level_permission_json,
|
wl.permission_json AS level_permission_json,
|
||||||
|
inv.username AS inviter_username,
|
||||||
|
inv.display_name AS inviter_display_name,
|
||||||
ww.available_amount,
|
ww.available_amount,
|
||||||
ww.frozen_deposit_amount,
|
ww.frozen_deposit_amount,
|
||||||
ww.total_credited_amount,
|
ww.total_credited_amount,
|
||||||
ww.total_settled_amount
|
ww.total_settled_amount
|
||||||
FROM worker_users wu
|
FROM worker_users wu
|
||||||
LEFT JOIN worker_levels wl ON wl.id = wu.level_id
|
LEFT JOIN worker_levels wl ON wl.id = wu.level_id
|
||||||
|
LEFT JOIN worker_users inv ON inv.id = wu.inviter_id
|
||||||
LEFT JOIN worker_wallets ww ON ww.worker_id = wu.id
|
LEFT JOIN worker_wallets ww ON ww.worker_id = wu.id
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -325,6 +334,16 @@ export async function getWorkerUserByUsername(username: string): Promise<WorkerU
|
|||||||
return result.rows[0] || null
|
return result.rows[0] || null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getWorkerUserByInviteCode(
|
||||||
|
inviteCode: string,
|
||||||
|
): Promise<WorkerUserRow | null> {
|
||||||
|
const result = await query<WorkerUserRow>(
|
||||||
|
`${WORKER_USER_SELECT} WHERE wu.invite_code = $1 LIMIT 1`,
|
||||||
|
[String(inviteCode || '').trim().toUpperCase()],
|
||||||
|
)
|
||||||
|
return result.rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
export async function getWorkerUserById(workerId: number | string): Promise<WorkerUserRow | null> {
|
export async function getWorkerUserById(workerId: number | string): Promise<WorkerUserRow | null> {
|
||||||
const result = await query<WorkerUserRow>(`${WORKER_USER_SELECT} WHERE wu.id = $1 LIMIT 1`, [
|
const result = await query<WorkerUserRow>(`${WORKER_USER_SELECT} WHERE wu.id = $1 LIMIT 1`, [
|
||||||
Number(workerId),
|
Number(workerId),
|
||||||
@@ -338,8 +357,9 @@ export async function createWorkerUser(input: CreateWorkerInput): Promise<Worker
|
|||||||
`
|
`
|
||||||
INSERT INTO worker_users (
|
INSERT INTO worker_users (
|
||||||
username, password_hash, display_name, phone, level_id, status,
|
username, password_hash, display_name, phone, level_id, status,
|
||||||
|
inviter_id, invite_code,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
@@ -349,6 +369,8 @@ export async function createWorkerUser(input: CreateWorkerInput): Promise<Worker
|
|||||||
input.phone,
|
input.phone,
|
||||||
input.levelId,
|
input.levelId,
|
||||||
input.status,
|
input.status,
|
||||||
|
input.inviterId || null,
|
||||||
|
input.inviteCode,
|
||||||
input.now,
|
input.now,
|
||||||
input.now,
|
input.now,
|
||||||
],
|
],
|
||||||
@@ -824,6 +846,64 @@ export async function countWorkerAcceptedOrders(workerId: number | string): Prom
|
|||||||
return Number(result.rows[0]?.total || 0)
|
return Number(result.rows[0]?.total || 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function maybeUpgradeWorkerLevelWithClient(
|
||||||
|
client: PoolClient,
|
||||||
|
workerId: number,
|
||||||
|
now: string,
|
||||||
|
): Promise<WorkerUserRow | null> {
|
||||||
|
const acceptedResult = await client.query<{ total: number }>(
|
||||||
|
`
|
||||||
|
SELECT COUNT(*)::int AS total
|
||||||
|
FROM work_orders
|
||||||
|
WHERE assigned_worker_id = $1
|
||||||
|
AND status = 'accepted'
|
||||||
|
`,
|
||||||
|
[workerId],
|
||||||
|
)
|
||||||
|
const acceptedCount = Number(acceptedResult.rows[0]?.total || 0)
|
||||||
|
|
||||||
|
const nextLevelResult = await client.query<{ id: number }>(
|
||||||
|
`
|
||||||
|
SELECT id
|
||||||
|
FROM worker_levels
|
||||||
|
WHERE status = 'active'
|
||||||
|
AND COALESCE((permission_json->>'upgradeThreshold')::int, 0) <= $1
|
||||||
|
ORDER BY COALESCE((permission_json->>'upgradeThreshold')::int, 0) DESC, sort_order ASC
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
[acceptedCount],
|
||||||
|
)
|
||||||
|
const nextLevelId = Number(nextLevelResult.rows[0]?.id || 0)
|
||||||
|
if (!nextLevelId) return null
|
||||||
|
|
||||||
|
const current = await client.query<{ id: number; level_id: number | null }>(
|
||||||
|
'SELECT id, level_id FROM worker_users WHERE id = $1 LIMIT 1',
|
||||||
|
[workerId],
|
||||||
|
)
|
||||||
|
const currentRow = current.rows[0]
|
||||||
|
if (!currentRow || Number(currentRow.level_id || 0) === nextLevelId) return null
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
'UPDATE worker_users SET level_id = $1, updated_at = $2 WHERE id = $3',
|
||||||
|
[nextLevelId, now, workerId],
|
||||||
|
)
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
INSERT INTO worker_level_logs (
|
||||||
|
worker_id, from_level_id, to_level_id, reason, payload_json, created_at
|
||||||
|
) VALUES ($1, $2, $3, 'auto', $4::jsonb, $5)
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
workerId,
|
||||||
|
currentRow.level_id || null,
|
||||||
|
nextLevelId,
|
||||||
|
JSON.stringify({ acceptedCount }),
|
||||||
|
now,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return getWorkerUserByIdWithClient(client, workerId)
|
||||||
|
}
|
||||||
|
|
||||||
export async function getWorkCategoryByKey(categoryKey: string): Promise<WorkCategoryRow | null> {
|
export async function getWorkCategoryByKey(categoryKey: string): Promise<WorkCategoryRow | null> {
|
||||||
const result = await query<WorkCategoryRow>(
|
const result = await query<WorkCategoryRow>(
|
||||||
'SELECT * FROM work_categories WHERE category_key = $1 LIMIT 1',
|
'SELECT * FROM work_categories WHERE category_key = $1 LIMIT 1',
|
||||||
@@ -1304,6 +1384,10 @@ export async function acceptWorkOrderAndSettle(input: {
|
|||||||
now: input.now,
|
now: input.now,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (workerId > 0) {
|
||||||
|
await maybeUpgradeWorkerLevelWithClient(client, workerId, input.now)
|
||||||
|
}
|
||||||
|
|
||||||
return getWorkOrderByIdWithClient(client, input.workOrderId)
|
return getWorkOrderByIdWithClient(client, input.workOrderId)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
getWorkerFinanceRequestById,
|
getWorkerFinanceRequestById,
|
||||||
getWorkerFinanceRequestSummary,
|
getWorkerFinanceRequestSummary,
|
||||||
getWorkerLevelById,
|
getWorkerLevelById,
|
||||||
|
getWorkerLevelByKey,
|
||||||
|
getWorkerUserByInviteCode,
|
||||||
incrementWorkerSessionVersion,
|
incrementWorkerSessionVersion,
|
||||||
getWorkOrderById,
|
getWorkOrderById,
|
||||||
getWorkOrderByOrderItemId,
|
getWorkOrderByOrderItemId,
|
||||||
@@ -73,9 +75,12 @@ export type WorkerSession = {
|
|||||||
sessionVersion: number
|
sessionVersion: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_LEVEL_KEY = 'regular'
|
const DEFAULT_LEVEL_KEY = 'vip1'
|
||||||
|
const DEFAULT_LEVEL_NAME = 'VIP1'
|
||||||
const DEFAULT_CATEGORY_KEY = 'default'
|
const DEFAULT_CATEGORY_KEY = 'default'
|
||||||
const DEFAULT_DEPOSIT_THRESHOLD_AMOUNT = 20_000
|
const DEFAULT_DEPOSIT_THRESHOLD_AMOUNT = 20_000
|
||||||
|
const INVITE_CODE_LENGTH = 8
|
||||||
|
const INVITE_CODE_RETRY_LIMIT = 5
|
||||||
const IGNORABLE_WORKER_AUTH_ERROR_CODES = new Set([
|
const IGNORABLE_WORKER_AUTH_ERROR_CODES = new Set([
|
||||||
'worker_auth_required',
|
'worker_auth_required',
|
||||||
'worker_auth_invalid',
|
'worker_auth_invalid',
|
||||||
@@ -86,17 +91,21 @@ const IGNORABLE_WORKER_AUTH_ERROR_CODES = new Set([
|
|||||||
|
|
||||||
export async function ensureWorkerPlatformDefaults() {
|
export async function ensureWorkerPlatformDefaults() {
|
||||||
const now = nowIso()
|
const now = nowIso()
|
||||||
const level = await upsertWorkerLevel({
|
const existingLevel = await getWorkerLevelByKey(DEFAULT_LEVEL_KEY)
|
||||||
levelKey: DEFAULT_LEVEL_KEY,
|
const level =
|
||||||
name: '普通打手',
|
existingLevel ||
|
||||||
sortOrder: 100,
|
(await upsertWorkerLevel({
|
||||||
status: 'active',
|
levelKey: DEFAULT_LEVEL_KEY,
|
||||||
permissionJson: JSON.stringify({
|
name: DEFAULT_LEVEL_NAME,
|
||||||
depositFreeAmount: 0,
|
sortOrder: 10,
|
||||||
maxActiveOrders: 3,
|
status: 'active',
|
||||||
}),
|
permissionJson: JSON.stringify({
|
||||||
now,
|
depositFreeAmount: 0,
|
||||||
})
|
maxActiveOrders: 1,
|
||||||
|
upgradeThreshold: 0,
|
||||||
|
}),
|
||||||
|
now,
|
||||||
|
}))
|
||||||
const category = await upsertWorkCategory({
|
const category = await upsertWorkCategory({
|
||||||
categoryKey: DEFAULT_CATEGORY_KEY,
|
categoryKey: DEFAULT_CATEGORY_KEY,
|
||||||
name: '默认分类',
|
name: '默认分类',
|
||||||
@@ -113,6 +122,7 @@ export async function registerWorker(payload: JsonObject = {}) {
|
|||||||
const password = normalizePassword(payload.password)
|
const password = normalizePassword(payload.password)
|
||||||
const displayName = String(payload.displayName || payload.nickname || username).trim()
|
const displayName = String(payload.displayName || payload.nickname || username).trim()
|
||||||
const phone = String(payload.phone || '').trim()
|
const phone = String(payload.phone || '').trim()
|
||||||
|
const inviteCode = String(payload.inviteCode || '').trim().toUpperCase()
|
||||||
|
|
||||||
validateWorkerUsername(username)
|
validateWorkerUsername(username)
|
||||||
validateWorkerPassword(password)
|
validateWorkerPassword(password)
|
||||||
@@ -124,6 +134,20 @@ export async function registerWorker(payload: JsonObject = {}) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inviter = inviteCode ? await getWorkerUserByInviteCode(inviteCode) : null
|
||||||
|
if (inviteCode && !inviter) {
|
||||||
|
throw createHttpError('邀请码无效', {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'worker_invite_code_invalid',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (inviter && inviter.status !== 'active') {
|
||||||
|
throw createHttpError('邀请人账号不可用', {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'worker_inviter_invalid',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const defaults = await ensureWorkerPlatformDefaults()
|
const defaults = await ensureWorkerPlatformDefaults()
|
||||||
const now = nowIso()
|
const now = nowIso()
|
||||||
const created = await createWorkerUser({
|
const created = await createWorkerUser({
|
||||||
@@ -133,6 +157,8 @@ export async function registerWorker(payload: JsonObject = {}) {
|
|||||||
phone,
|
phone,
|
||||||
levelId: defaults.level?.id || null,
|
levelId: defaults.level?.id || null,
|
||||||
status: 'pending_review',
|
status: 'pending_review',
|
||||||
|
inviterId: inviter ? Number(inviter.id) : null,
|
||||||
|
inviteCode: await generateInviteCode(now),
|
||||||
now,
|
now,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -149,6 +175,23 @@ export async function registerWorker(payload: JsonObject = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function generateInviteCode(now: string): Promise<string> {
|
||||||
|
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||||||
|
for (let attempt = 0; attempt < INVITE_CODE_RETRY_LIMIT; attempt++) {
|
||||||
|
const candidate = Array.from(
|
||||||
|
{ length: INVITE_CODE_LENGTH },
|
||||||
|
() => chars[Math.floor(crypto.randomInt(chars.length))],
|
||||||
|
).join('')
|
||||||
|
if (!(await getWorkerUserByInviteCode(candidate))) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw createHttpError('邀请码生成失败,请重试', {
|
||||||
|
statusCode: 500,
|
||||||
|
errorCode: 'worker_invite_code_generation_failed',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function loginWorker(username: unknown, password: unknown) {
|
export async function loginWorker(username: unknown, password: unknown) {
|
||||||
ensureWorkerAuthConfigured()
|
ensureWorkerAuthConfigured()
|
||||||
const normalizedUsername = normalizeUsername(username)
|
const normalizedUsername = normalizeUsername(username)
|
||||||
@@ -298,19 +341,61 @@ export async function getWorkerProfile(session: WorkerSession) {
|
|||||||
countWorkerAcceptedOrders(session.workerId),
|
countWorkerAcceptedOrders(session.workerId),
|
||||||
Promise.resolve(getWorkerFinanceConfig()),
|
Promise.resolve(getWorkerFinanceConfig()),
|
||||||
])
|
])
|
||||||
|
const permissions = resolveWorkerPermissions(worker)
|
||||||
|
const levelProgress = await resolveLevelProgress(worker, acceptedOrderCount)
|
||||||
return {
|
return {
|
||||||
worker: mapWorkerUser(worker),
|
worker: mapWorkerUser(worker),
|
||||||
permissions: resolveWorkerPermissions(worker),
|
permissions,
|
||||||
summary: {
|
summary: {
|
||||||
acceptedOrderCount,
|
acceptedOrderCount,
|
||||||
pendingWithdrawAmount: financeSummary.pendingWithdrawAmount,
|
pendingWithdrawAmount: financeSummary.pendingWithdrawAmount,
|
||||||
approvedWithdrawAmount: financeSummary.approvedWithdrawAmount,
|
approvedWithdrawAmount: financeSummary.approvedWithdrawAmount,
|
||||||
pendingRechargeAmount: financeSummary.pendingRechargeAmount,
|
pendingRechargeAmount: financeSummary.pendingRechargeAmount,
|
||||||
},
|
},
|
||||||
|
levelProgress,
|
||||||
financeConfig,
|
financeConfig,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveLevelProgress(
|
||||||
|
worker: WorkerUserRow,
|
||||||
|
acceptedOrderCount: number,
|
||||||
|
): Promise<{
|
||||||
|
currentThreshold: number
|
||||||
|
nextThreshold: number | null
|
||||||
|
progressPercent: number
|
||||||
|
} | null> {
|
||||||
|
if (!worker.level_id) return null
|
||||||
|
const levels = await listWorkerLevels()
|
||||||
|
const active = levels.filter((level) => level.status === 'active')
|
||||||
|
const current = active.find((level) => Number(level.id) === Number(worker.level_id))
|
||||||
|
if (!current) return null
|
||||||
|
const currentThreshold = normalizeInteger(safeParseJson(current.permission_json).upgradeThreshold, 0)
|
||||||
|
const next = active
|
||||||
|
.filter((level) => normalizeInteger(safeParseJson(level.permission_json).upgradeThreshold, 0) > currentThreshold)
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
normalizeInteger(safeParseJson(a.permission_json).upgradeThreshold, 0) -
|
||||||
|
normalizeInteger(safeParseJson(b.permission_json).upgradeThreshold, 0),
|
||||||
|
)[0]
|
||||||
|
const nextThreshold = next
|
||||||
|
? normalizeInteger(safeParseJson(next.permission_json).upgradeThreshold, 0)
|
||||||
|
: null
|
||||||
|
return {
|
||||||
|
currentThreshold,
|
||||||
|
nextThreshold,
|
||||||
|
progressPercent:
|
||||||
|
nextThreshold && nextThreshold > currentThreshold
|
||||||
|
? Math.min(
|
||||||
|
100,
|
||||||
|
Math.round(
|
||||||
|
((acceptedOrderCount - currentThreshold) / (nextThreshold - currentThreshold)) * 100,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: 100,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function listWorkerProfileWalletLedgers(
|
export async function listWorkerProfileWalletLedgers(
|
||||||
query: JsonObject = {},
|
query: JsonObject = {},
|
||||||
session: WorkerSession,
|
session: WorkerSession,
|
||||||
@@ -798,13 +883,14 @@ export async function saveAdminWorkCategory(payload: JsonObject = {}) {
|
|||||||
export async function saveAdminWorkerLevel(payload: JsonObject = {}) {
|
export async function saveAdminWorkerLevel(payload: JsonObject = {}) {
|
||||||
const now = nowIso()
|
const now = nowIso()
|
||||||
const levelKey = String(payload.levelKey || payload.level_key || '').trim() || DEFAULT_LEVEL_KEY
|
const levelKey = String(payload.levelKey || payload.level_key || '').trim() || DEFAULT_LEVEL_KEY
|
||||||
const name = String(payload.name || '').trim() || '普通打手'
|
const name = String(payload.name || '').trim() || DEFAULT_LEVEL_NAME
|
||||||
const permissions = {
|
const permissions = {
|
||||||
depositFreeAmount: normalizeAmountFen(
|
depositFreeAmount: normalizeAmountFen(
|
||||||
payload.depositFreeAmount ?? payload.depositFreeAmountYuan,
|
payload.depositFreeAmount ?? payload.depositFreeAmountYuan,
|
||||||
0,
|
0,
|
||||||
),
|
),
|
||||||
maxActiveOrders: normalizePositiveInteger(payload.maxActiveOrders, 3),
|
maxActiveOrders: normalizePositiveInteger(payload.maxActiveOrders, 1),
|
||||||
|
upgradeThreshold: normalizeInteger(payload.upgradeThreshold, 0),
|
||||||
}
|
}
|
||||||
const level = await upsertWorkerLevel({
|
const level = await upsertWorkerLevel({
|
||||||
levelKey,
|
levelKey,
|
||||||
@@ -1564,7 +1650,8 @@ function resolveWorkerPermissions(worker: WorkerUserRow) {
|
|||||||
const permission = safeParseJson(worker.level_permission_json)
|
const permission = safeParseJson(worker.level_permission_json)
|
||||||
return {
|
return {
|
||||||
depositFreeAmount: normalizeInteger(permission.depositFreeAmount, 0),
|
depositFreeAmount: normalizeInteger(permission.depositFreeAmount, 0),
|
||||||
maxActiveOrders: normalizePositiveInteger(permission.maxActiveOrders, 3),
|
maxActiveOrders: normalizePositiveInteger(permission.maxActiveOrders, 1),
|
||||||
|
upgradeThreshold: normalizeInteger(permission.upgradeThreshold, 0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1588,7 +1675,8 @@ function mapWorkerLevel(level: WorkerLevelRow | null | undefined) {
|
|||||||
sortOrder: Number(level.sort_order || 100),
|
sortOrder: Number(level.sort_order || 100),
|
||||||
status: level.status,
|
status: level.status,
|
||||||
depositFreeAmount: normalizeInteger(permission.depositFreeAmount, 0),
|
depositFreeAmount: normalizeInteger(permission.depositFreeAmount, 0),
|
||||||
maxActiveOrders: normalizePositiveInteger(permission.maxActiveOrders, 3),
|
maxActiveOrders: normalizePositiveInteger(permission.maxActiveOrders, 1),
|
||||||
|
upgradeThreshold: normalizeInteger(permission.upgradeThreshold, 0),
|
||||||
createdAt: level.created_at,
|
createdAt: level.created_at,
|
||||||
updatedAt: level.updated_at,
|
updatedAt: level.updated_at,
|
||||||
}
|
}
|
||||||
@@ -1645,6 +1733,14 @@ function mapWorkerUser(worker: WorkerUserRow) {
|
|||||||
phone: worker.phone,
|
phone: worker.phone,
|
||||||
status: worker.status,
|
status: worker.status,
|
||||||
reviewNote: worker.review_note || '',
|
reviewNote: worker.review_note || '',
|
||||||
|
inviteCode: worker.invite_code || '',
|
||||||
|
inviter: worker.inviter_id
|
||||||
|
? {
|
||||||
|
workerId: Number(worker.inviter_id),
|
||||||
|
username: worker.inviter_username || '',
|
||||||
|
displayName: worker.inviter_display_name || worker.inviter_username || '',
|
||||||
|
}
|
||||||
|
: null,
|
||||||
level: worker.level_id
|
level: worker.level_id
|
||||||
? {
|
? {
|
||||||
levelId: Number(worker.level_id),
|
levelId: Number(worker.level_id),
|
||||||
|
|||||||
@@ -1790,6 +1790,7 @@ function LevelsPanel() {
|
|||||||
name: string
|
name: string
|
||||||
depositFreeAmount?: number
|
depositFreeAmount?: number
|
||||||
maxActiveOrders?: number
|
maxActiveOrders?: number
|
||||||
|
upgradeThreshold?: number
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
await saveAdminWorkerLevel(values)
|
await saveAdminWorkerLevel(values)
|
||||||
@@ -1810,6 +1811,7 @@ function LevelsPanel() {
|
|||||||
render: (_, row) => formatMoney(row.depositFreeAmount),
|
render: (_, row) => formatMoney(row.depositFreeAmount),
|
||||||
},
|
},
|
||||||
{ title: '最大同时接单', dataIndex: 'maxActiveOrders' },
|
{ title: '最大同时接单', dataIndex: 'maxActiveOrders' },
|
||||||
|
{ title: '升级所需累计完成', dataIndex: 'upgradeThreshold' },
|
||||||
{ title: '状态', dataIndex: 'status' },
|
{ title: '状态', dataIndex: 'status' },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1820,10 +1822,11 @@ function LevelsPanel() {
|
|||||||
form={form}
|
form={form}
|
||||||
layout="inline"
|
layout="inline"
|
||||||
initialValues={{
|
initialValues={{
|
||||||
levelKey: 'regular',
|
levelKey: 'vip1',
|
||||||
name: '普通打手',
|
name: 'VIP1',
|
||||||
depositFreeAmount: 0,
|
depositFreeAmount: 0,
|
||||||
maxActiveOrders: 3,
|
maxActiveOrders: 1,
|
||||||
|
upgradeThreshold: 0,
|
||||||
}}
|
}}
|
||||||
onFinish={saveLevel}
|
onFinish={saveLevel}
|
||||||
>
|
>
|
||||||
@@ -1847,6 +1850,13 @@ function LevelsPanel() {
|
|||||||
<Form.Item label="最大同时接单" name="maxActiveOrders">
|
<Form.Item label="最大同时接单" name="maxActiveOrders">
|
||||||
<InputNumber min={1} max={999} />
|
<InputNumber min={1} max={999} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="升级所需累计完成单数"
|
||||||
|
name="upgradeThreshold"
|
||||||
|
tooltip="打手累计验收通过该单数后自动升级到本等级"
|
||||||
|
>
|
||||||
|
<InputNumber min={0} max={100000} />
|
||||||
|
</Form.Item>
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Button type="primary" htmlType="submit">
|
<Button type="primary" htmlType="submit">
|
||||||
保存
|
保存
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export default function WorkerLoginPage() {
|
|||||||
password: string
|
password: string
|
||||||
displayName?: string
|
displayName?: string
|
||||||
phone?: string
|
phone?: string
|
||||||
|
inviteCode?: string
|
||||||
}) {
|
}) {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
@@ -98,6 +99,9 @@ export default function WorkerLoginPage() {
|
|||||||
<Form.Item label="手机号" name="phone">
|
<Form.Item label="手机号" name="phone">
|
||||||
<Input />
|
<Input />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item label="邀请码(选填)" name="inviteCode">
|
||||||
|
<Input maxLength={8} placeholder="填写邀请人邀请码" />
|
||||||
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="密码"
|
label="密码"
|
||||||
name="password"
|
name="password"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
BankOutlined,
|
BankOutlined,
|
||||||
|
CopyOutlined,
|
||||||
CustomerServiceOutlined,
|
CustomerServiceOutlined,
|
||||||
HistoryOutlined,
|
HistoryOutlined,
|
||||||
LockOutlined,
|
LockOutlined,
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
Modal,
|
Modal,
|
||||||
|
Progress,
|
||||||
Row,
|
Row,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
@@ -114,9 +116,20 @@ export default function WorkerProfilePage() {
|
|||||||
|
|
||||||
const worker = profileQuery.data?.data.worker
|
const worker = profileQuery.data?.data.worker
|
||||||
const summary = profileQuery.data?.data.summary
|
const summary = profileQuery.data?.data.summary
|
||||||
|
const levelProgress = profileQuery.data?.data.levelProgress
|
||||||
const financeConfig = profileQuery.data?.data.financeConfig
|
const financeConfig = profileQuery.data?.data.financeConfig
|
||||||
const canUseFinanceActions = worker?.status === 'active'
|
const canUseFinanceActions = worker?.status === 'active'
|
||||||
|
|
||||||
|
async function copyInviteCode() {
|
||||||
|
if (!worker?.inviteCode) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(worker.inviteCode)
|
||||||
|
message.success('邀请码已复制')
|
||||||
|
} catch {
|
||||||
|
message.error('复制失败,请手动复制')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const ledgersQuery = useQuery({
|
const ledgersQuery = useQuery({
|
||||||
queryKey: ['worker-wallet-ledgers', ledgerType, ledgerPage, ledgerPageSize],
|
queryKey: ['worker-wallet-ledgers', ledgerType, ledgerPage, ledgerPageSize],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
@@ -453,6 +466,40 @@ export default function WorkerProfilePage() {
|
|||||||
<Tag>{worker.level?.name || '未分级'}</Tag>
|
<Tag>{worker.level?.name || '未分级'}</Tag>
|
||||||
</Space>
|
</Space>
|
||||||
</Space>
|
</Space>
|
||||||
|
<Space direction="vertical" size={4} style={{ marginTop: 12, width: '100%' }}>
|
||||||
|
<Space wrap size={[8, 8]}>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
我的邀请码:
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text strong>{worker.inviteCode || '-'}</Typography.Text>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="link"
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
disabled={!worker.inviteCode}
|
||||||
|
onClick={copyInviteCode}
|
||||||
|
>
|
||||||
|
复制
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
{levelProgress && levelProgress.nextThreshold !== null ? (
|
||||||
|
<Space direction="vertical" size={0} style={{ width: '100%' }}>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
已累计完成 {summary?.acceptedOrderCount || 0} 单,
|
||||||
|
距下一等级还需 {Math.max(0, levelProgress.nextThreshold - (summary?.acceptedOrderCount || 0))} 单
|
||||||
|
</Typography.Text>
|
||||||
|
<Progress
|
||||||
|
percent={levelProgress.progressPercent}
|
||||||
|
size="small"
|
||||||
|
format={(percent) => `${percent}%`}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
已累计完成 {summary?.acceptedOrderCount || 0} 单,当前已是最高等级
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>
|
||||||
@@ -542,6 +589,25 @@ export default function WorkerProfilePage() {
|
|||||||
<Descriptions.Item label="当前等级">
|
<Descriptions.Item label="当前等级">
|
||||||
{worker.level?.name || '-'}
|
{worker.level?.name || '-'}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="我的邀请码">
|
||||||
|
<Space size={4}>
|
||||||
|
{worker.inviteCode || '-'}
|
||||||
|
{worker.inviteCode ? (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="link"
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
onClick={copyInviteCode}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="邀请人">
|
||||||
|
{worker.inviter ? worker.inviter.displayName || worker.inviter.username : '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="已累计完成">
|
||||||
|
{summary?.acceptedOrderCount || 0} 单
|
||||||
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="免押额度">
|
<Descriptions.Item label="免押额度">
|
||||||
{formatMoney(worker.level?.permissions.depositFreeAmount || 0)}
|
{formatMoney(worker.level?.permissions.depositFreeAmount || 0)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export function saveAdminWorkerLevel(payload: {
|
|||||||
name: string
|
name: string
|
||||||
depositFreeAmount?: number
|
depositFreeAmount?: number
|
||||||
maxActiveOrders?: number
|
maxActiveOrders?: number
|
||||||
|
upgradeThreshold?: number
|
||||||
}) {
|
}) {
|
||||||
return apiPost<{ level: WorkerLevel }>(
|
return apiPost<{ level: WorkerLevel }>(
|
||||||
'/api/v1/admin/worker-platform/levels',
|
'/api/v1/admin/worker-platform/levels',
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export function registerWorker(payload: {
|
|||||||
password: string
|
password: string
|
||||||
displayName?: string
|
displayName?: string
|
||||||
phone?: string
|
phone?: string
|
||||||
|
inviteCode?: string
|
||||||
}) {
|
}) {
|
||||||
return apiPost<{ worker: WorkerUser; reviewRequired: boolean }>(
|
return apiPost<{ worker: WorkerUser; reviewRequired: boolean }>(
|
||||||
'/api/v1/worker/auth/register',
|
'/api/v1/worker/auth/register',
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ export type WorkerUser = {
|
|||||||
phone: string
|
phone: string
|
||||||
status: string
|
status: string
|
||||||
reviewNote: string
|
reviewNote: string
|
||||||
|
inviteCode: string
|
||||||
|
inviter: null | {
|
||||||
|
workerId: number
|
||||||
|
username: string
|
||||||
|
displayName: string
|
||||||
|
}
|
||||||
level: null | {
|
level: null | {
|
||||||
levelId: number
|
levelId: number
|
||||||
levelKey: string
|
levelKey: string
|
||||||
@@ -12,6 +18,7 @@ export type WorkerUser = {
|
|||||||
permissions: {
|
permissions: {
|
||||||
depositFreeAmount: number
|
depositFreeAmount: number
|
||||||
maxActiveOrders: number
|
maxActiveOrders: number
|
||||||
|
upgradeThreshold: number
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
wallet: {
|
wallet: {
|
||||||
@@ -32,6 +39,12 @@ export type WorkerProfileSummary = {
|
|||||||
pendingRechargeAmount: number
|
pendingRechargeAmount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type WorkerLevelProgress = {
|
||||||
|
currentThreshold: number
|
||||||
|
nextThreshold: number | null
|
||||||
|
progressPercent: number
|
||||||
|
}
|
||||||
|
|
||||||
export type WorkerWalletLedger = {
|
export type WorkerWalletLedger = {
|
||||||
ledgerId: number
|
ledgerId: number
|
||||||
ledgerType: string
|
ledgerType: string
|
||||||
@@ -86,6 +99,7 @@ export type WorkerProfileResponse = {
|
|||||||
worker: WorkerUser
|
worker: WorkerUser
|
||||||
permissions: Record<string, unknown>
|
permissions: Record<string, unknown>
|
||||||
summary: WorkerProfileSummary
|
summary: WorkerProfileSummary
|
||||||
|
levelProgress: WorkerLevelProgress | null
|
||||||
financeConfig: WorkerFinanceConfig
|
financeConfig: WorkerFinanceConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +111,7 @@ export type WorkerLevel = {
|
|||||||
status: string
|
status: string
|
||||||
depositFreeAmount: number
|
depositFreeAmount: number
|
||||||
maxActiveOrders: number
|
maxActiveOrders: number
|
||||||
|
upgradeThreshold: number
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user