feat: 新增客服备用金台账与后台用户中文显示名
- 新增备用金账户、流水、支出、对账与权限 - 后台用户支持中文显示名并同步备用金姓名 - 审计、登录记录、任务及工单操作人优先展示中文名 - 补充备用金与人工支出方案文档
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
-- 100_fund_accounts.sql —— 客服备用金台账(主管池 / 客服余额 / 支出 / 对账)。
|
||||
--
|
||||
-- 业务口径(2026-10-18 确认):
|
||||
-- 公司把钱交给主管,主管发给客服;客服用于充值/采购/赔偿;充值后立即消耗,直接记支出。
|
||||
-- 系统要回答:钱在谁手里、每笔花到哪、每个客服还剩多少、账实是否对平。
|
||||
--
|
||||
-- 设计约束:
|
||||
-- 1. 余额只能由 fund_entries 产生,不允许手工改;
|
||||
-- 2. 发放/缴回必须同时写主管与客服两条流水,金额相反;
|
||||
-- 3. 支出必须同时写客服负数流水和 manual_expenses 明细;
|
||||
-- 4. 已记账流水不修改、不删除,差异走 adjustment。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fund_accounts (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
account_no TEXT NOT NULL,
|
||||
account_type TEXT NOT NULL,
|
||||
owner_admin_user_id BIGINT NOT NULL REFERENCES admin_users(id) ON DELETE RESTRICT,
|
||||
owner_name TEXT NOT NULL DEFAULT '',
|
||||
responsible_supervisor_id BIGINT REFERENCES fund_accounts(id) ON DELETE SET NULL,
|
||||
balance_fen BIGINT NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_fund_accounts_account_no
|
||||
ON fund_accounts (account_no);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_fund_accounts_owner_type
|
||||
ON fund_accounts (owner_admin_user_id, account_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_fund_accounts_type_status
|
||||
ON fund_accounts (account_type, status, owner_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_fund_accounts_supervisor
|
||||
ON fund_accounts (responsible_supervisor_id, status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fund_entries (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
entry_no TEXT NOT NULL,
|
||||
account_id BIGINT NOT NULL REFERENCES fund_accounts(id) ON DELETE RESTRICT,
|
||||
entry_type TEXT NOT NULL,
|
||||
amount_fen BIGINT NOT NULL,
|
||||
balance_after_fen BIGINT NOT NULL,
|
||||
transfer_no TEXT NOT NULL DEFAULT '',
|
||||
counterparty_account_id BIGINT REFERENCES fund_accounts(id) ON DELETE SET NULL,
|
||||
reconciliation_id BIGINT,
|
||||
occurred_at TIMESTAMPTZ NOT NULL,
|
||||
created_by_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL,
|
||||
evidence_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_fund_entries_entry_no
|
||||
ON fund_entries (entry_no);
|
||||
CREATE INDEX IF NOT EXISTS idx_fund_entries_account_time
|
||||
ON fund_entries (account_id, occurred_at DESC, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_fund_entries_type_time
|
||||
ON fund_entries (entry_type, occurred_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_fund_entries_transfer
|
||||
ON fund_entries (transfer_no) WHERE transfer_no <> '';
|
||||
CREATE INDEX IF NOT EXISTS idx_fund_entries_counterparty
|
||||
ON fund_entries (counterparty_account_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS manual_expenses (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
expense_no TEXT NOT NULL,
|
||||
account_id BIGINT NOT NULL REFERENCES fund_accounts(id) ON DELETE RESTRICT,
|
||||
amount_fen BIGINT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
payee_type TEXT NOT NULL,
|
||||
payee_name TEXT NOT NULL,
|
||||
payee_account TEXT NOT NULL DEFAULT '',
|
||||
platform_name TEXT NOT NULL DEFAULT '',
|
||||
platform_account TEXT NOT NULL DEFAULT '',
|
||||
order_id BIGINT REFERENCES orders(id) ON DELETE SET NULL,
|
||||
order_item_id BIGINT REFERENCES order_items(id) ON DELETE SET NULL,
|
||||
paid_at TIMESTAMPTZ NOT NULL,
|
||||
evidence_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
fund_entry_id BIGINT NOT NULL REFERENCES fund_entries(id) ON DELETE RESTRICT,
|
||||
created_by_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_manual_expenses_expense_no
|
||||
ON manual_expenses (expense_no);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_manual_expenses_fund_entry
|
||||
ON manual_expenses (fund_entry_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expenses_account_time
|
||||
ON manual_expenses (account_id, paid_at DESC, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expenses_category_time
|
||||
ON manual_expenses (category, paid_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expenses_order
|
||||
ON manual_expenses (order_id) WHERE order_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expenses_payee
|
||||
ON manual_expenses (payee_name, paid_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fund_reconciliations (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
reconciliation_no TEXT NOT NULL,
|
||||
account_id BIGINT NOT NULL REFERENCES fund_accounts(id) ON DELETE RESTRICT,
|
||||
period_start TIMESTAMPTZ NOT NULL,
|
||||
period_end TIMESTAMPTZ NOT NULL,
|
||||
system_balance_fen BIGINT NOT NULL,
|
||||
actual_balance_fen BIGINT NOT NULL,
|
||||
difference_fen BIGINT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
evidence_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
confirmed_by_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL,
|
||||
confirmed_at TIMESTAMPTZ,
|
||||
created_by_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_fund_reconciliations_no
|
||||
ON fund_reconciliations (reconciliation_no);
|
||||
CREATE INDEX IF NOT EXISTS idx_fund_reconciliations_account_time
|
||||
ON fund_reconciliations (account_id, period_end DESC, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_fund_reconciliations_status_time
|
||||
ON fund_reconciliations (status, period_end DESC);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_fund_accounts_type') THEN
|
||||
ALTER TABLE fund_accounts
|
||||
ADD CONSTRAINT chk_fund_accounts_type
|
||||
CHECK (account_type IN ('supervisor', 'agent'));
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_fund_accounts_status') THEN
|
||||
ALTER TABLE fund_accounts
|
||||
ADD CONSTRAINT chk_fund_accounts_status
|
||||
CHECK (status IN ('active', 'frozen', 'closed'));
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_fund_accounts_balance') THEN
|
||||
ALTER TABLE fund_accounts
|
||||
ADD CONSTRAINT chk_fund_accounts_balance
|
||||
CHECK (balance_fen >= 0);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_fund_entries_type') THEN
|
||||
ALTER TABLE fund_entries
|
||||
ADD CONSTRAINT chk_fund_entries_type
|
||||
CHECK (entry_type IN ('opening', 'funding', 'issue', 'expense', 'return', 'adjustment'));
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_fund_entries_amount') THEN
|
||||
ALTER TABLE fund_entries
|
||||
ADD CONSTRAINT chk_fund_entries_amount
|
||||
CHECK (amount_fen <> 0);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_fund_entries_balance') THEN
|
||||
ALTER TABLE fund_entries
|
||||
ADD CONSTRAINT chk_fund_entries_balance
|
||||
CHECK (balance_after_fen >= 0);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expenses_amount') THEN
|
||||
ALTER TABLE manual_expenses
|
||||
ADD CONSTRAINT chk_manual_expenses_amount
|
||||
CHECK (amount_fen > 0);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expenses_category') THEN
|
||||
ALTER TABLE manual_expenses
|
||||
ADD CONSTRAINT chk_manual_expenses_category
|
||||
CHECK (category IN ('platform_recharge', 'purchase', 'compensation', 'freight', 'other'));
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expenses_payee_type') THEN
|
||||
ALTER TABLE manual_expenses
|
||||
ADD CONSTRAINT chk_manual_expenses_payee_type
|
||||
CHECK (payee_type IN ('platform', 'supplier', 'customer', 'other'));
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_fund_reconciliations_status') THEN
|
||||
ALTER TABLE fund_reconciliations
|
||||
ADD CONSTRAINT chk_fund_reconciliations_status
|
||||
CHECK (status IN ('pending', 'confirmed', 'discrepancy'));
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_fund_reconciliations_difference') THEN
|
||||
ALTER TABLE fund_reconciliations
|
||||
ADD CONSTRAINT chk_fund_reconciliations_difference
|
||||
CHECK (difference_fen = actual_balance_fen - system_balance_fen);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMENT ON TABLE fund_accounts IS '备用金资金账户:主管池与客服备用金账户';
|
||||
COMMENT ON COLUMN fund_accounts.responsible_supervisor_id IS '客服的责任主管账户;对账由该主管确认';
|
||||
COMMENT ON COLUMN fund_accounts.balance_fen IS '当前余额,只能由 fund_entries 变动';
|
||||
COMMENT ON TABLE fund_entries IS '备用金流水:期初/拨付/发放/支出/缴回/调整,只追加';
|
||||
COMMENT ON COLUMN fund_entries.amount_fen IS '有符号金额:正数流入,负数流出';
|
||||
COMMENT ON COLUMN fund_entries.balance_after_fen IS '记账后的账户余额快照';
|
||||
COMMENT ON COLUMN fund_entries.transfer_no IS '发放/缴回双边流水批次号,主管与客服两条共用';
|
||||
COMMENT ON TABLE manual_expenses IS '客服备用金支出明细;充值后立即消耗,直接记支出';
|
||||
COMMENT ON TABLE fund_reconciliations IS '备用金对账:系统余额与实际余额差异,差异通过 adjustment 流水修正';
|
||||
|
||||
INSERT INTO admin_permissions (permission_key, permission_name, permission_group, description)
|
||||
VALUES
|
||||
('fund.view', '查看备用金', '备用金管理', '查看备用金账户、流水和支出明细'),
|
||||
('fund.issue', '发放/缴回备用金', '备用金管理', '主管向客服发放或收回备用金'),
|
||||
('fund.expense', '登记备用金支出', '备用金管理', '客服登记备用金支出'),
|
||||
('fund.reconcile', '备用金对账', '备用金管理', '发起和确认备用金对账'),
|
||||
('fund.adjust', '调整备用金余额', '备用金管理', '对账差异确认后调整账户余额'),
|
||||
('fund.export', '导出备用金数据', '备用金管理', '导出备用金账户、流水和支出')
|
||||
ON CONFLICT (permission_key) DO UPDATE SET
|
||||
permission_name = EXCLUDED.permission_name,
|
||||
permission_group = EXCLUDED.permission_group,
|
||||
description = EXCLUDED.description,
|
||||
updated_at = NOW();
|
||||
|
||||
INSERT INTO admin_role_permissions (role_key, permission_key)
|
||||
SELECT 'admin', permission_key
|
||||
FROM admin_permissions
|
||||
WHERE permission_key LIKE 'fund.%'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO admin_role_permissions (role_key, permission_key)
|
||||
VALUES
|
||||
('finance', 'fund.view'),
|
||||
('finance', 'fund.reconcile'),
|
||||
('finance', 'fund.adjust'),
|
||||
('finance', 'fund.export'),
|
||||
('operator', 'fund.view'),
|
||||
('operator', 'fund.issue'),
|
||||
('operator', 'fund.reconcile'),
|
||||
('support', 'fund.view'),
|
||||
('support', 'fund.expense')
|
||||
ON CONFLICT DO NOTHING;
|
||||
@@ -0,0 +1,45 @@
|
||||
-- 101_admin_display_name.sql —— 后台用户增加中文姓名/显示名。
|
||||
--
|
||||
-- 账号 username 仍然是登录凭证,只能使用字母、数字和 ._-;
|
||||
-- display_name 只用于展示,可输入中文姓名。为空时回退显示 username。
|
||||
|
||||
ALTER TABLE admin_users
|
||||
ADD COLUMN IF NOT EXISTS display_name TEXT NOT NULL DEFAULT '';
|
||||
|
||||
UPDATE admin_users
|
||||
SET display_name = username
|
||||
WHERE display_name = '';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_admin_users_display_name
|
||||
ON admin_users (display_name);
|
||||
|
||||
COMMENT ON COLUMN admin_users.display_name IS '后台用户显示名(中文姓名等),为空时回退 username';
|
||||
|
||||
-- 备用金账户里的 owner_name 是姓名快照;改名后同步,避免还要逐表手工改。
|
||||
CREATE OR REPLACE FUNCTION sync_admin_display_name_to_fund_accounts()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
UPDATE fund_accounts
|
||||
SET
|
||||
owner_name = COALESCE(NULLIF(NEW.display_name, ''), NEW.username),
|
||||
updated_at = NOW()
|
||||
WHERE owner_admin_user_id = NEW.id
|
||||
AND owner_name IS DISTINCT FROM COALESCE(NULLIF(NEW.display_name, ''), NEW.username);
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_admin_users_display_name_fund_accounts ON admin_users;
|
||||
CREATE TRIGGER trg_admin_users_display_name_fund_accounts
|
||||
AFTER UPDATE OF display_name, username ON admin_users
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION sync_admin_display_name_to_fund_accounts();
|
||||
|
||||
UPDATE fund_accounts fa
|
||||
SET
|
||||
owner_name = COALESCE(NULLIF(au.display_name, ''), au.username),
|
||||
updated_at = NOW()
|
||||
FROM admin_users au
|
||||
WHERE fa.owner_admin_user_id = au.id
|
||||
AND fa.owner_name IS DISTINCT FROM COALESCE(NULLIF(au.display_name, ''), au.username);
|
||||
@@ -5,6 +5,7 @@ type AdminAuditLogRow = {
|
||||
id: number
|
||||
actor_user_id: number | null
|
||||
actor_username: string
|
||||
actor_display_name: string | null
|
||||
actor_role: string
|
||||
action: string
|
||||
target_type: string
|
||||
@@ -88,28 +89,30 @@ export async function listAdminAuditLogs(
|
||||
const params: unknown[] = []
|
||||
|
||||
if (queryInput.actorUsername) {
|
||||
params.push(queryInput.actorUsername)
|
||||
conditions.push(`actor_username = $${params.length}`)
|
||||
params.push(`%${queryInput.actorUsername}%`)
|
||||
conditions.push(
|
||||
`(aal.actor_username ILIKE $${params.length} OR au.display_name ILIKE $${params.length})`,
|
||||
)
|
||||
}
|
||||
|
||||
if (queryInput.action) {
|
||||
params.push(queryInput.action)
|
||||
conditions.push(`action = $${params.length}`)
|
||||
conditions.push(`aal.action = $${params.length}`)
|
||||
}
|
||||
|
||||
if (queryInput.targetType) {
|
||||
params.push(queryInput.targetType)
|
||||
conditions.push(`target_type = $${params.length}`)
|
||||
conditions.push(`aal.target_type = $${params.length}`)
|
||||
}
|
||||
|
||||
if (queryInput.dateFrom) {
|
||||
params.push(queryInput.dateFrom)
|
||||
conditions.push(`created_at >= $${params.length}`)
|
||||
conditions.push(`aal.created_at >= $${params.length}`)
|
||||
}
|
||||
|
||||
if (queryInput.dateTo) {
|
||||
params.push(queryInput.dateTo)
|
||||
conditions.push(`created_at <= $${params.length}`)
|
||||
conditions.push(`aal.created_at <= $${params.length}`)
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
||||
@@ -118,7 +121,12 @@ export async function listAdminAuditLogs(
|
||||
const offset = (page - 1) * pageSize
|
||||
|
||||
const totalResult = await query<{ [column: string]: unknown; total: number }>(
|
||||
`SELECT COUNT(*)::int AS total FROM admin_audit_logs ${whereClause}`,
|
||||
`
|
||||
SELECT COUNT(*)::int AS total
|
||||
FROM admin_audit_logs aal
|
||||
LEFT JOIN admin_users au ON au.id = aal.actor_user_id
|
||||
${whereClause}
|
||||
`,
|
||||
params,
|
||||
)
|
||||
|
||||
@@ -126,10 +134,13 @@ export async function listAdminAuditLogs(
|
||||
params.push(offset)
|
||||
const itemsResult = await query<AdminAuditLogRow>(
|
||||
`
|
||||
SELECT *
|
||||
FROM admin_audit_logs
|
||||
SELECT
|
||||
aal.*,
|
||||
COALESCE(NULLIF(au.display_name, ''), au.username) AS actor_display_name
|
||||
FROM admin_audit_logs aal
|
||||
LEFT JOIN admin_users au ON au.id = aal.actor_user_id
|
||||
${whereClause}
|
||||
ORDER BY id DESC
|
||||
ORDER BY aal.id DESC
|
||||
LIMIT $${params.length - 1} OFFSET $${params.length}
|
||||
`,
|
||||
params,
|
||||
|
||||
@@ -5,6 +5,7 @@ export type AdminLoginLogRow = {
|
||||
id: number
|
||||
user_id: number | null
|
||||
username: string
|
||||
display_name: string | null
|
||||
role: string
|
||||
ip: string
|
||||
location: string
|
||||
@@ -75,18 +76,20 @@ export async function listAdminLoginLogs(
|
||||
const params: unknown[] = []
|
||||
|
||||
if (queryInput.username) {
|
||||
params.push(String(queryInput.username).trim())
|
||||
conditions.push(`username = $${params.length}`)
|
||||
params.push(`%${String(queryInput.username).trim()}%`)
|
||||
conditions.push(
|
||||
`(ll.username ILIKE $${params.length} OR au.display_name ILIKE $${params.length})`,
|
||||
)
|
||||
}
|
||||
|
||||
if (queryInput.dateFrom) {
|
||||
params.push(queryInput.dateFrom)
|
||||
conditions.push(`created_at >= $${params.length}`)
|
||||
conditions.push(`ll.created_at >= $${params.length}`)
|
||||
}
|
||||
|
||||
if (queryInput.dateTo) {
|
||||
params.push(queryInput.dateTo)
|
||||
conditions.push(`created_at <= $${params.length}`)
|
||||
conditions.push(`ll.created_at <= $${params.length}`)
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
||||
@@ -95,7 +98,12 @@ export async function listAdminLoginLogs(
|
||||
const offset = (page - 1) * pageSize
|
||||
|
||||
const totalResult = await query<{ total: number }>(
|
||||
`SELECT COUNT(*)::int AS total FROM admin_login_logs ${whereClause}`,
|
||||
`
|
||||
SELECT COUNT(*)::int AS total
|
||||
FROM admin_login_logs ll
|
||||
LEFT JOIN admin_users au ON au.id = ll.user_id
|
||||
${whereClause}
|
||||
`,
|
||||
params,
|
||||
)
|
||||
|
||||
@@ -103,10 +111,13 @@ export async function listAdminLoginLogs(
|
||||
params.push(offset)
|
||||
const itemsResult = await query<AdminLoginLogRow>(
|
||||
`
|
||||
SELECT *
|
||||
FROM admin_login_logs
|
||||
SELECT
|
||||
ll.*,
|
||||
COALESCE(NULLIF(au.display_name, ''), au.username) AS display_name
|
||||
FROM admin_login_logs ll
|
||||
LEFT JOIN admin_users au ON au.id = ll.user_id
|
||||
${whereClause}
|
||||
ORDER BY id DESC
|
||||
ORDER BY ll.id DESC
|
||||
LIMIT $${params.length - 1} OFFSET $${params.length}
|
||||
`,
|
||||
params,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { query } from '../db/client.js'
|
||||
type AdminUserRow = {
|
||||
id: number
|
||||
username: string
|
||||
display_name: string
|
||||
password_hash: string
|
||||
role: string
|
||||
status: string
|
||||
@@ -13,6 +14,7 @@ type AdminUserRow = {
|
||||
|
||||
type AdminUserCreateInput = {
|
||||
username: string
|
||||
displayName: string
|
||||
passwordHash: string
|
||||
role: string
|
||||
status: string
|
||||
@@ -23,14 +25,20 @@ type AdminUserCreateInput = {
|
||||
type AdminUserPatch = Partial<
|
||||
Pick<
|
||||
AdminUserRow,
|
||||
'username' | 'password_hash' | 'role' | 'status' | 'session_version' | 'updated_at'
|
||||
| 'username'
|
||||
| 'display_name'
|
||||
| 'password_hash'
|
||||
| 'role'
|
||||
| 'status'
|
||||
| 'session_version'
|
||||
| 'updated_at'
|
||||
>
|
||||
>
|
||||
|
||||
type AdminUserListInput = {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
username?: string
|
||||
keyword?: string
|
||||
role?: string
|
||||
status?: string
|
||||
}
|
||||
@@ -66,17 +74,19 @@ export async function createAdminUser(input: AdminUserCreateInput): Promise<Admi
|
||||
`
|
||||
INSERT INTO admin_users (
|
||||
username,
|
||||
display_name,
|
||||
password_hash,
|
||||
role,
|
||||
status,
|
||||
session_version,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id
|
||||
`,
|
||||
[
|
||||
input.username,
|
||||
input.displayName,
|
||||
input.passwordHash,
|
||||
input.role,
|
||||
input.status,
|
||||
@@ -104,16 +114,18 @@ export async function updateAdminUser(
|
||||
UPDATE admin_users
|
||||
SET
|
||||
username = $1,
|
||||
password_hash = $2,
|
||||
role = $3,
|
||||
status = $4,
|
||||
session_version = $5,
|
||||
updated_at = $6
|
||||
WHERE id = $7
|
||||
display_name = $2,
|
||||
password_hash = $3,
|
||||
role = $4,
|
||||
status = $5,
|
||||
session_version = $6,
|
||||
updated_at = $7
|
||||
WHERE id = $8
|
||||
RETURNING id
|
||||
`,
|
||||
[
|
||||
next.username,
|
||||
next.display_name,
|
||||
next.password_hash,
|
||||
next.role,
|
||||
next.status,
|
||||
@@ -129,7 +141,7 @@ export async function updateAdminUser(
|
||||
export async function listAdminUsers({
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
username = '',
|
||||
keyword = '',
|
||||
role = '',
|
||||
status = '',
|
||||
}: AdminUserListInput = {}): Promise<AdminUserListResult> {
|
||||
@@ -137,24 +149,24 @@ export async function listAdminUsers({
|
||||
const filters: string[] = []
|
||||
const params: unknown[] = []
|
||||
|
||||
if (username) {
|
||||
params.push(`%${username}%`)
|
||||
filters.push(`username ILIKE $${params.length}`)
|
||||
if (keyword) {
|
||||
params.push(`%${keyword}%`)
|
||||
filters.push(`(au.username ILIKE $${params.length} OR au.display_name ILIKE $${params.length})`)
|
||||
}
|
||||
|
||||
if (role) {
|
||||
params.push(role)
|
||||
filters.push(`role = $${params.length}`)
|
||||
filters.push(`au.role = $${params.length}`)
|
||||
}
|
||||
|
||||
if (status) {
|
||||
params.push(status)
|
||||
filters.push(`status = $${params.length}`)
|
||||
filters.push(`au.status = $${params.length}`)
|
||||
}
|
||||
|
||||
const whereClause = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : ''
|
||||
const totalResult = await query<{ [column: string]: unknown; total: number }>(
|
||||
`SELECT COUNT(*)::int AS total FROM admin_users ${whereClause}`,
|
||||
`SELECT COUNT(*)::int AS total FROM admin_users au ${whereClause}`,
|
||||
params,
|
||||
)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import dashboardRouter from './admin/dashboard.js'
|
||||
import devMockRouter from './admin/dev-mock.js'
|
||||
import douyinMashidaRouter from './admin/douyin-mashida.js'
|
||||
import financeRouter from './admin/finance.js'
|
||||
import fundRouter from './admin/fund.js'
|
||||
import filesRouter from './admin/files.js'
|
||||
import kuaishouIndustryRouter from './admin/kuaishou-industry.js'
|
||||
import ordersRouter from './admin/orders.js'
|
||||
@@ -26,6 +27,7 @@ router.use(requireAdminSession)
|
||||
router.use(filesRouter)
|
||||
router.use(dashboardRouter)
|
||||
router.use(financeRouter)
|
||||
router.use(fundRouter)
|
||||
router.use(usersRouter)
|
||||
router.use(rbacRouter)
|
||||
router.use(auditLogsRouter)
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import type { Request } from 'express'
|
||||
import { Router } from 'express'
|
||||
|
||||
import {
|
||||
confirmAdminFundReconciliation,
|
||||
createAdminFundAccount,
|
||||
createAdminFundExpense,
|
||||
createAdminFundFunding,
|
||||
createAdminFundIssue,
|
||||
createAdminFundReconciliation,
|
||||
createAdminFundReturn,
|
||||
ensureAdminFundAgentAccounts,
|
||||
getAdminFundAccountDetail,
|
||||
getAdminFundExpenseDetail,
|
||||
getAdminFundMyAccount,
|
||||
getAdminFundOverview,
|
||||
getAdminFundSupervisorOptions,
|
||||
listAdminFundAccounts,
|
||||
listAdminFundExpenses,
|
||||
listAdminFundReconciliations,
|
||||
listAdminFundSetupOptions,
|
||||
updateAdminFundAccount,
|
||||
} from '../../services/admin/admin-fund-service.js'
|
||||
import {
|
||||
createJsonHandler,
|
||||
requireAdminPermission,
|
||||
requireAdminRoles,
|
||||
requireAnyAdminPermission,
|
||||
} from './session.js'
|
||||
import type { AdminSession } from '../../services/admin/admin-auth-service.js'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
function sessionOf(req: Request): AdminSession {
|
||||
return req.adminSession as AdminSession
|
||||
}
|
||||
|
||||
function accountIdOf(req: Request): number {
|
||||
return Number(req.params.accountId)
|
||||
}
|
||||
|
||||
function expenseIdOf(req: Request): number {
|
||||
return Number(req.params.expenseId)
|
||||
}
|
||||
|
||||
function reconciliationIdOf(req: Request): number {
|
||||
return Number(req.params.reconciliationId)
|
||||
}
|
||||
|
||||
function bodyWithAccountId(req: Request, key: string): JsonObject {
|
||||
const body = req.body && typeof req.body === 'object' ? (req.body as JsonObject) : {}
|
||||
return { ...body, [key]: accountIdOf(req) }
|
||||
}
|
||||
|
||||
router.use('/fund', requireAdminRoles(['admin', 'operator', 'support', 'finance']))
|
||||
|
||||
router.get(
|
||||
'/fund/overview',
|
||||
requireAdminPermission('fund.view'),
|
||||
createJsonHandler((req) => getAdminFundOverview(sessionOf(req)), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取备用金总览失败',
|
||||
scope: '[admin/fund/overview]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/fund/my',
|
||||
requireAdminPermission('fund.view'),
|
||||
createJsonHandler((req) => getAdminFundMyAccount(sessionOf(req)), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取我的备用金失败',
|
||||
scope: '[admin/fund/my]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/fund/supervisors',
|
||||
requireAdminPermission('fund.view'),
|
||||
createJsonHandler((req) => getAdminFundSupervisorOptions(sessionOf(req)), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取主管资金池失败',
|
||||
scope: '[admin/fund/supervisors]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/fund/options',
|
||||
requireAdminRoles(['admin']),
|
||||
createJsonHandler((req) => listAdminFundSetupOptions(sessionOf(req)), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取备用金初始化选项失败',
|
||||
scope: '[admin/fund/options]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/fund/accounts',
|
||||
requireAdminPermission('fund.view'),
|
||||
createJsonHandler((req) => listAdminFundAccounts(req.query as JsonObject, sessionOf(req)), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取备用金账户失败',
|
||||
scope: '[admin/fund/accounts]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/fund/accounts/:accountId',
|
||||
requireAdminPermission('fund.view'),
|
||||
createJsonHandler(
|
||||
(req) => getAdminFundAccountDetail(accountIdOf(req), req.query as JsonObject, sessionOf(req)),
|
||||
{
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取备用金账户详情失败',
|
||||
scope: '[admin/fund/accounts/:accountId]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/fund/accounts',
|
||||
requireAdminRoles(['admin']),
|
||||
createJsonHandler((req) => createAdminFundAccount(req.body as JsonObject, sessionOf(req)), {
|
||||
successMessage: '备用金账户已创建',
|
||||
errorMessage: '创建备用金账户失败',
|
||||
scope: '[admin/fund/accounts]',
|
||||
audit: (req, data) => ({
|
||||
action: 'fund_account_created',
|
||||
targetType: 'fund_account',
|
||||
targetId: String((data as { account?: { id?: number } })?.account?.id || ''),
|
||||
data: asRecord(data),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/fund/accounts/ensure-agents',
|
||||
requireAdminRoles(['admin']),
|
||||
createJsonHandler((req) => ensureAdminFundAgentAccounts(req.body as JsonObject, sessionOf(req)), {
|
||||
successMessage: '客服备用金账户已初始化',
|
||||
errorMessage: '批量初始化客服备用金账户失败',
|
||||
scope: '[admin/fund/accounts/ensure-agents]',
|
||||
audit: (_req, data) => ({
|
||||
action: 'fund_agent_accounts_ensured',
|
||||
targetType: 'fund_account',
|
||||
targetId: 'batch',
|
||||
data: asRecord(data),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
router.patch(
|
||||
'/fund/accounts/:accountId',
|
||||
requireAdminRoles(['admin']),
|
||||
createJsonHandler(
|
||||
(req) => updateAdminFundAccount(accountIdOf(req), req.body as JsonObject, sessionOf(req)),
|
||||
{
|
||||
successMessage: '备用金账户已更新',
|
||||
errorMessage: '更新备用金账户失败',
|
||||
scope: '[admin/fund/accounts/:accountId]',
|
||||
audit: (req, data) => ({
|
||||
action: 'fund_account_updated',
|
||||
targetType: 'fund_account',
|
||||
targetId: String(req.params.accountId || ''),
|
||||
data: asRecord(data),
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/fund/accounts/:accountId/funding',
|
||||
requireAdminRoles(['admin']),
|
||||
createJsonHandler(
|
||||
(req) => createAdminFundFunding(bodyWithAccountId(req, 'supervisorAccountId'), sessionOf(req)),
|
||||
{
|
||||
successMessage: '主管资金池已拨付',
|
||||
errorMessage: '拨付主管资金池失败',
|
||||
scope: '[admin/fund/accounts/:accountId/funding]',
|
||||
audit: (req, data) => ({
|
||||
action: 'fund_funding',
|
||||
targetType: 'fund_account',
|
||||
targetId: String(req.params.accountId || ''),
|
||||
data: asRecord(data),
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/fund/accounts/:accountId/issue',
|
||||
requireAdminPermission('fund.issue'),
|
||||
createJsonHandler(
|
||||
(req) => createAdminFundIssue(bodyWithAccountId(req, 'agentAccountId'), sessionOf(req)),
|
||||
{
|
||||
successMessage: '备用金已发放',
|
||||
errorMessage: '发放备用金失败',
|
||||
scope: '[admin/fund/accounts/:accountId/issue]',
|
||||
audit: (req, data) => ({
|
||||
action: 'fund_issue',
|
||||
targetType: 'fund_account',
|
||||
targetId: String(req.params.accountId || ''),
|
||||
data: asRecord(data),
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/fund/accounts/:accountId/return',
|
||||
requireAnyAdminPermission(['fund.issue', 'fund.expense']),
|
||||
createJsonHandler(
|
||||
(req) => createAdminFundReturn(bodyWithAccountId(req, 'agentAccountId'), sessionOf(req)),
|
||||
{
|
||||
successMessage: '备用金已缴回',
|
||||
errorMessage: '缴回备用金失败',
|
||||
scope: '[admin/fund/accounts/:accountId/return]',
|
||||
audit: (req, data) => ({
|
||||
action: 'fund_return',
|
||||
targetType: 'fund_account',
|
||||
targetId: String(req.params.accountId || ''),
|
||||
data: asRecord(data),
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/fund/accounts/:accountId/expenses',
|
||||
requireAdminPermission('fund.expense'),
|
||||
createJsonHandler(
|
||||
(req) => createAdminFundExpense(bodyWithAccountId(req, 'agentAccountId'), sessionOf(req)),
|
||||
{
|
||||
successMessage: '备用金支出已登记',
|
||||
errorMessage: '登记备用金支出失败',
|
||||
scope: '[admin/fund/accounts/:accountId/expenses]',
|
||||
audit: (req, data) => ({
|
||||
action: 'fund_expense_created',
|
||||
targetType: 'fund_expense',
|
||||
targetId: String((data as { expense?: { id?: number } })?.expense?.id || ''),
|
||||
data: asRecord(data),
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/fund/expenses',
|
||||
requireAdminPermission('fund.view'),
|
||||
createJsonHandler((req) => listAdminFundExpenses(req.query as JsonObject, sessionOf(req)), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取备用金支出失败',
|
||||
scope: '[admin/fund/expenses]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/fund/expenses/:expenseId',
|
||||
requireAdminPermission('fund.view'),
|
||||
createJsonHandler((req) => getAdminFundExpenseDetail(expenseIdOf(req), sessionOf(req)), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取备用金支出详情失败',
|
||||
scope: '[admin/fund/expenses/:expenseId]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/fund/reconciliations',
|
||||
requireAdminPermission('fund.view'),
|
||||
createJsonHandler(
|
||||
(req) => listAdminFundReconciliations(req.query as JsonObject, sessionOf(req)),
|
||||
{
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取备用金对账失败',
|
||||
scope: '[admin/fund/reconciliations]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/fund/reconciliations',
|
||||
requireAdminPermission('fund.reconcile'),
|
||||
createJsonHandler(
|
||||
(req) => createAdminFundReconciliation(req.body as JsonObject, sessionOf(req)),
|
||||
{
|
||||
successMessage: '对账已发起',
|
||||
errorMessage: '发起备用金对账失败',
|
||||
scope: '[admin/fund/reconciliations]',
|
||||
audit: (_req, data) => ({
|
||||
action: 'fund_reconciliation_created',
|
||||
targetType: 'fund_reconciliation',
|
||||
targetId: String((data as { reconciliation?: { id?: number } })?.reconciliation?.id || ''),
|
||||
data: asRecord(data),
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/fund/reconciliations/:reconciliationId/confirm',
|
||||
requireAdminPermission('fund.reconcile'),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
confirmAdminFundReconciliation(
|
||||
reconciliationIdOf(req),
|
||||
req.body as JsonObject,
|
||||
sessionOf(req),
|
||||
),
|
||||
{
|
||||
successMessage: '对账已确认',
|
||||
errorMessage: '确认备用金对账失败',
|
||||
scope: '[admin/fund/reconciliations/:reconciliationId/confirm]',
|
||||
audit: (req, data) => ({
|
||||
action: 'fund_reconciliation_confirmed',
|
||||
targetType: 'fund_reconciliation',
|
||||
targetId: String(req.params.reconciliationId || ''),
|
||||
data: asRecord(data),
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
function asRecord(data: unknown): Record<string, unknown> {
|
||||
return data && typeof data === 'object' ? (data as Record<string, unknown>) : { a: data }
|
||||
}
|
||||
|
||||
export default router
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
createManagedAdminUser,
|
||||
getAdminUserList,
|
||||
resetManagedAdminUserPassword,
|
||||
updateManagedAdminUserDisplayName,
|
||||
updateManagedAdminUserRole,
|
||||
updateManagedAdminUserStatus,
|
||||
} from '../../services/admin/admin-auth-service.js'
|
||||
@@ -13,6 +14,7 @@ type AdminUserMutationResult = {
|
||||
user: {
|
||||
userId: number
|
||||
username: string
|
||||
displayName: string
|
||||
role: string
|
||||
status?: string
|
||||
}
|
||||
@@ -82,6 +84,30 @@ router.post(
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/users/:userId/display-name',
|
||||
createJsonHandler(
|
||||
(req) => updateManagedAdminUserDisplayName(String(req.params.userId || ''), req.body),
|
||||
{
|
||||
successMessage: '用户姓名已更新',
|
||||
errorMessage: '更新用户姓名失败',
|
||||
scope: '[admin/users/:userId/display-name]',
|
||||
audit: (_req, data) => {
|
||||
const result = data as AdminUserMutationResult
|
||||
return {
|
||||
action: 'admin_user_display_name_updated',
|
||||
targetType: 'admin_user',
|
||||
targetId: String(result.user.userId),
|
||||
data: {
|
||||
username: result.user.username,
|
||||
displayName: result.user.displayName,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/users/:userId/status',
|
||||
createJsonHandler(
|
||||
|
||||
@@ -21,7 +21,7 @@ router.post(
|
||||
markAdminWorkOrderProblem(
|
||||
String(req.params.workOrderId || ''),
|
||||
req.body || {},
|
||||
req.adminSession?.username || '',
|
||||
req.adminSession?.displayName || req.adminSession?.username || '',
|
||||
),
|
||||
{
|
||||
successMessage: '订单已标记为问题单',
|
||||
@@ -45,7 +45,7 @@ router.post(
|
||||
resolveAdminProblemWorkOrder(
|
||||
String(req.params.workOrderId || ''),
|
||||
req.body || {},
|
||||
req.adminSession?.username || '',
|
||||
req.adminSession?.displayName || req.adminSession?.username || '',
|
||||
),
|
||||
{
|
||||
successMessage: '问题单已处置',
|
||||
@@ -66,7 +66,10 @@ router.post(
|
||||
requireAdminPermission('worker_order.accept'),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
acceptAdminWorkOrder(String(req.params.workOrderId || ''), req.adminSession?.username || ''),
|
||||
acceptAdminWorkOrder(
|
||||
String(req.params.workOrderId || ''),
|
||||
req.adminSession?.displayName || req.adminSession?.username || '',
|
||||
),
|
||||
{
|
||||
successMessage: '订单已验收通过',
|
||||
errorMessage: '验收接单订单失败',
|
||||
@@ -89,7 +92,7 @@ router.post(
|
||||
acceptAdminWorkOrderShare(
|
||||
String(req.params.workOrderId || ''),
|
||||
String(req.params.shareId || ''),
|
||||
req.adminSession?.username || '',
|
||||
req.adminSession?.displayName || req.adminSession?.username || '',
|
||||
),
|
||||
{
|
||||
successMessage: '拼单份额已验收通过',
|
||||
@@ -113,7 +116,7 @@ router.post(
|
||||
cancelAdminWorkOrderSharing(
|
||||
String(req.params.workOrderId || ''),
|
||||
req.body || {},
|
||||
req.adminSession?.username || '',
|
||||
req.adminSession?.displayName || req.adminSession?.username || '',
|
||||
req.adminSession?.permissions || [],
|
||||
),
|
||||
{
|
||||
@@ -142,7 +145,7 @@ router.post(
|
||||
String(req.params.workOrderId || ''),
|
||||
String(req.params.shareId || ''),
|
||||
req.body || {},
|
||||
req.adminSession?.username || '',
|
||||
req.adminSession?.displayName || req.adminSession?.username || '',
|
||||
),
|
||||
{
|
||||
successMessage: '拼单参与者已撤单,份数已退回大厅',
|
||||
@@ -166,7 +169,7 @@ router.post(
|
||||
deductAdminWorkOrderPendingDeposit(
|
||||
String(req.params.workOrderId || ''),
|
||||
req.body || {},
|
||||
req.adminSession?.username || '',
|
||||
req.adminSession?.displayName || req.adminSession?.username || '',
|
||||
),
|
||||
{
|
||||
successMessage: '待解冻押金已扣减',
|
||||
|
||||
@@ -39,7 +39,7 @@ router.post(
|
||||
createAdminAfterSalesCase(
|
||||
String(req.params.workOrderId || ''),
|
||||
req.body || {},
|
||||
req.adminSession?.username || '',
|
||||
req.adminSession?.displayName || req.adminSession?.username || '',
|
||||
),
|
||||
{
|
||||
successMessage: '售后问题单已创建',
|
||||
@@ -63,7 +63,7 @@ router.post(
|
||||
resolveAdminAfterSalesCase(
|
||||
String(req.params.caseId || ''),
|
||||
req.body || {},
|
||||
req.adminSession?.username || '',
|
||||
req.adminSession?.displayName || req.adminSession?.username || '',
|
||||
),
|
||||
{
|
||||
successMessage: '售后问题单已处置',
|
||||
@@ -87,7 +87,7 @@ router.post(
|
||||
repayAdminAfterSalesCaseDebt(
|
||||
String(req.params.caseId || ''),
|
||||
req.body || {},
|
||||
req.adminSession?.username || '',
|
||||
req.adminSession?.displayName || req.adminSession?.username || '',
|
||||
),
|
||||
{
|
||||
successMessage: '售后欠款已偿还',
|
||||
|
||||
@@ -167,7 +167,7 @@ router.post(
|
||||
(req) =>
|
||||
syncAdminWorkProductRulePrice(
|
||||
String(req.params.ruleId || ''),
|
||||
req.adminSession?.username || '',
|
||||
req.adminSession?.displayName || req.adminSession?.username || '',
|
||||
),
|
||||
{
|
||||
successMessage: '模板价格已同步',
|
||||
|
||||
@@ -68,7 +68,11 @@ router.post(
|
||||
'/worker-platform/consignment/inventories',
|
||||
requireAdminPermission('consignment.manage'),
|
||||
createJsonHandler(
|
||||
(req) => createAdminConsignmentInventory(req.body, req.adminSession?.username || 'admin'),
|
||||
(req) =>
|
||||
createAdminConsignmentInventory(
|
||||
req.body,
|
||||
req.adminSession?.displayName || req.adminSession?.username || 'admin',
|
||||
),
|
||||
{
|
||||
successMessage: '寄卖物品已入库',
|
||||
errorMessage: '创建寄卖物品失败',
|
||||
@@ -85,7 +89,7 @@ router.put(
|
||||
updateAdminConsignmentInventory(
|
||||
String(req.params.id || ''),
|
||||
req.body,
|
||||
req.adminSession?.username || 'admin',
|
||||
req.adminSession?.displayName || req.adminSession?.username || 'admin',
|
||||
),
|
||||
{
|
||||
successMessage: '寄卖物品已更新',
|
||||
@@ -127,7 +131,7 @@ router.post(
|
||||
returnAdminConsignmentInventory(
|
||||
String(req.params.id || ''),
|
||||
req.body,
|
||||
req.adminSession?.username || 'admin',
|
||||
req.adminSession?.displayName || req.adminSession?.username || 'admin',
|
||||
),
|
||||
{
|
||||
successMessage: '物品已退回复库',
|
||||
@@ -167,7 +171,7 @@ router.post(
|
||||
approveAdminConsignmentSubmission(
|
||||
String(req.params.id || ''),
|
||||
req.body,
|
||||
req.adminSession?.username || 'admin',
|
||||
req.adminSession?.displayName || req.adminSession?.username || 'admin',
|
||||
),
|
||||
{
|
||||
successMessage: '审核通过,已正式入库',
|
||||
@@ -185,7 +189,7 @@ router.post(
|
||||
rejectAdminConsignmentSubmission(
|
||||
String(req.params.id || ''),
|
||||
req.body,
|
||||
req.adminSession?.username || 'admin',
|
||||
req.adminSession?.displayName || req.adminSession?.username || 'admin',
|
||||
),
|
||||
{
|
||||
successMessage: '已驳回该提报',
|
||||
@@ -247,7 +251,7 @@ router.post(
|
||||
fulfillWorkOrderWithConsignment({
|
||||
workOrderId: String(req.body?.workOrderId || ''),
|
||||
inventoryId: String(req.body?.inventoryId || ''),
|
||||
actorName: req.adminSession?.username || 'admin',
|
||||
actorName: req.adminSession?.displayName || req.adminSession?.username || 'admin',
|
||||
allowPartial: req.body?.allowPartial === true,
|
||||
allowSpecMismatch: req.body?.allowSpecMismatch === true,
|
||||
matchNote: String(req.body?.matchNote || ''),
|
||||
@@ -269,7 +273,7 @@ router.post(
|
||||
workOrderId: String(req.body?.workOrderId || ''),
|
||||
inventoryId: String(req.body?.inventoryId || ''),
|
||||
workerId: req.body?.workerId ? Number(req.body.workerId) : null,
|
||||
actorName: req.adminSession?.username || 'admin',
|
||||
actorName: req.adminSession?.displayName || req.adminSession?.username || 'admin',
|
||||
allowPartial: req.body?.allowPartial === true,
|
||||
allowSpecMismatch: req.body?.allowSpecMismatch === true,
|
||||
matchNote: String(req.body?.matchNote || ''),
|
||||
@@ -299,7 +303,7 @@ router.post(
|
||||
// 押金处置由运营显式选择,非法值一律归一成"按规则自动"
|
||||
workerDeposit: normalizeWorkerDepositHandling(req.body?.workerDeposit),
|
||||
consignmentDeposit: normalizeConsignmentDepositHandling(req.body?.consignmentDeposit),
|
||||
actorName: req.adminSession?.username || 'admin',
|
||||
actorName: req.adminSession?.displayName || req.adminSession?.username || 'admin',
|
||||
}),
|
||||
{
|
||||
successMessage: '寄售履约已撤销',
|
||||
|
||||
@@ -26,7 +26,7 @@ router.post(
|
||||
reviewAdminWorkerFinanceRequest(
|
||||
String(req.params.requestId || ''),
|
||||
req.body || {},
|
||||
req.adminSession?.username || '',
|
||||
req.adminSession?.displayName || req.adminSession?.username || '',
|
||||
),
|
||||
{
|
||||
successMessage: '资金申请已处理',
|
||||
|
||||
@@ -153,7 +153,7 @@ router.post(
|
||||
submitAdminWorkOrderMaterial(
|
||||
String(req.params.workOrderId || ''),
|
||||
req.body || {},
|
||||
req.adminSession?.username || '',
|
||||
req.adminSession?.displayName || req.adminSession?.username || '',
|
||||
),
|
||||
{
|
||||
successMessage: '订单资料已保存',
|
||||
@@ -176,7 +176,7 @@ router.put(
|
||||
(req) =>
|
||||
updateAdminWorkOrder(String(req.params.workOrderId || ''), req.body || {}, {
|
||||
canEditRequirementFields: req.adminSession?.role !== 'support',
|
||||
actorName: req.adminSession?.username || '',
|
||||
actorName: req.adminSession?.displayName || req.adminSession?.username || '',
|
||||
}),
|
||||
{
|
||||
successMessage: '工单信息已保存',
|
||||
@@ -200,7 +200,7 @@ router.put(
|
||||
updateAdminWorkOrderNote(
|
||||
String(req.params.workOrderId || ''),
|
||||
req.body?.note,
|
||||
req.adminSession?.username || '',
|
||||
req.adminSession?.displayName || req.adminSession?.username || '',
|
||||
),
|
||||
{
|
||||
successMessage: '工单备注已保存',
|
||||
@@ -224,7 +224,7 @@ router.post(
|
||||
assignAdminWorkOrderToWorker(
|
||||
String(req.params.workOrderId || ''),
|
||||
req.body || {},
|
||||
req.adminSession?.username || '',
|
||||
req.adminSession?.displayName || req.adminSession?.username || '',
|
||||
),
|
||||
{
|
||||
successMessage: '已指派打手',
|
||||
@@ -247,7 +247,7 @@ router.post(
|
||||
(req) =>
|
||||
unassignAdminWorkOrder(
|
||||
String(req.params.workOrderId || ''),
|
||||
req.adminSession?.username || '',
|
||||
req.adminSession?.displayName || req.adminSession?.username || '',
|
||||
),
|
||||
{
|
||||
successMessage: '已取消指派',
|
||||
@@ -271,7 +271,7 @@ router.post(
|
||||
cancelAdminWorkOrder(
|
||||
String(req.params.workOrderId || ''),
|
||||
req.body || {},
|
||||
req.adminSession?.username || '',
|
||||
req.adminSession?.displayName || req.adminSession?.username || '',
|
||||
req.adminSession?.permissions || [],
|
||||
),
|
||||
{
|
||||
@@ -314,7 +314,7 @@ router.post(
|
||||
(req) =>
|
||||
reopenAdminCancelledWorkOrder(
|
||||
String(req.params.workOrderId || ''),
|
||||
req.adminSession?.username || '',
|
||||
req.adminSession?.displayName || req.adminSession?.username || '',
|
||||
),
|
||||
{
|
||||
successMessage: '订单已恢复为未分配状态',
|
||||
@@ -335,7 +335,10 @@ router.post(
|
||||
requireAdminPermission('worker_order.publish'),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
publishAdminWorkOrder(String(req.params.workOrderId || ''), req.adminSession?.username || ''),
|
||||
publishAdminWorkOrder(
|
||||
String(req.params.workOrderId || ''),
|
||||
req.adminSession?.displayName || req.adminSession?.username || '',
|
||||
),
|
||||
{
|
||||
successMessage: '订单已进入大厅队列',
|
||||
errorMessage: '发布接单订单失败',
|
||||
@@ -357,7 +360,7 @@ router.post(
|
||||
(req) =>
|
||||
unpublishAdminWorkOrder(
|
||||
String(req.params.workOrderId || ''),
|
||||
req.adminSession?.username || '',
|
||||
req.adminSession?.displayName || req.adminSession?.username || '',
|
||||
),
|
||||
{
|
||||
successMessage: '订单已撤回到未分配',
|
||||
@@ -381,7 +384,7 @@ router.post(
|
||||
pinAdminWorkOrder(
|
||||
String(req.params.workOrderId || ''),
|
||||
req.body || {},
|
||||
req.adminSession?.username || '',
|
||||
req.adminSession?.displayName || req.adminSession?.username || '',
|
||||
),
|
||||
{
|
||||
successMessage: '置顶状态已更新',
|
||||
@@ -404,7 +407,7 @@ router.post(
|
||||
(req) =>
|
||||
confirmAdminWorkOrderFriendAdded(
|
||||
String(req.params.workOrderId || ''),
|
||||
req.adminSession?.username || '',
|
||||
req.adminSession?.displayName || req.adminSession?.username || '',
|
||||
),
|
||||
{
|
||||
successMessage: '已确认加好友,赠送冷却开始',
|
||||
@@ -428,7 +431,7 @@ router.post(
|
||||
resetAdminWorkOrderGiftMaterial(
|
||||
String(req.params.workOrderId || ''),
|
||||
req.body || {},
|
||||
req.adminSession?.username || '',
|
||||
req.adminSession?.displayName || req.adminSession?.username || '',
|
||||
),
|
||||
{
|
||||
successMessage: '已重置打手资料,等待打手重新提交',
|
||||
@@ -452,7 +455,7 @@ router.post(
|
||||
completeAdminWorkOrderGiftCooldown(
|
||||
String(req.params.workOrderId || ''),
|
||||
req.body || {},
|
||||
req.adminSession?.username || '',
|
||||
req.adminSession?.displayName || req.adminSession?.username || '',
|
||||
),
|
||||
{
|
||||
successMessage: '已手动结束赠送冷却,打手现在可以提交验收',
|
||||
@@ -496,7 +499,7 @@ router.post(
|
||||
updateAdminWorkOrderSharing(
|
||||
String(req.params.workOrderId || ''),
|
||||
req.body || {},
|
||||
req.adminSession?.username || '',
|
||||
req.adminSession?.displayName || req.adminSession?.username || '',
|
||||
),
|
||||
{
|
||||
successMessage: '拼单配置已保存',
|
||||
@@ -516,7 +519,11 @@ router.post(
|
||||
'/worker-platform/orders/accept-batch',
|
||||
requireAdminPermission('worker_order.accept'),
|
||||
createJsonHandler(
|
||||
(req) => acceptAdminWorkOrders(req.body || {}, req.adminSession?.username || ''),
|
||||
(req) =>
|
||||
acceptAdminWorkOrders(
|
||||
req.body || {},
|
||||
req.adminSession?.displayName || req.adminSession?.username || '',
|
||||
),
|
||||
{
|
||||
successMessage: '选中订单已验收通过',
|
||||
errorMessage: '批量验收接单订单失败',
|
||||
|
||||
@@ -38,7 +38,7 @@ router.post(
|
||||
resolveAdminWorkerOrderFeedback(
|
||||
String(req.params.feedbackId || ''),
|
||||
req.body || {},
|
||||
req.adminSession?.username || '',
|
||||
req.adminSession?.displayName || req.adminSession?.username || '',
|
||||
),
|
||||
{
|
||||
successMessage: '问题反馈已处理',
|
||||
@@ -56,7 +56,7 @@ router.post(
|
||||
reviewAdminWorkerCancelRequest(
|
||||
String(req.params.requestId || ''),
|
||||
req.body || {},
|
||||
req.adminSession?.username || '',
|
||||
req.adminSession?.displayName || req.adminSession?.username || '',
|
||||
),
|
||||
{
|
||||
successMessage: '打手退单申请已审核',
|
||||
|
||||
@@ -18,6 +18,7 @@ function actorOf(req: Request) {
|
||||
return {
|
||||
userId: req.adminSession?.userId ?? null,
|
||||
username: req.adminSession?.username || '',
|
||||
displayName: req.adminSession?.displayName || req.adminSession?.username || '',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ router.post(
|
||||
reviewAdminWorkerUser(
|
||||
String(req.params.workerId || ''),
|
||||
req.body || {},
|
||||
req.adminSession?.username || '',
|
||||
req.adminSession?.displayName || req.adminSession?.username || '',
|
||||
),
|
||||
{
|
||||
successMessage: '打手账号状态已更新',
|
||||
@@ -97,7 +97,7 @@ router.post(
|
||||
updateAdminWorkerLevelLock(
|
||||
String(req.params.workerId || ''),
|
||||
req.body || {},
|
||||
req.adminSession?.username || '',
|
||||
req.adminSession?.displayName || req.adminSession?.username || '',
|
||||
),
|
||||
{
|
||||
successMessage: '打手等级锁定状态已更新',
|
||||
|
||||
@@ -46,6 +46,7 @@ export async function getAdminAuditLogs(query: JsonObject = {}) {
|
||||
logId: Number(item.id),
|
||||
actorUserId: Number(item.actor_user_id || 0),
|
||||
actorUsername: item.actor_username,
|
||||
actorDisplayName: String(item.actor_display_name || item.actor_username || ''),
|
||||
actorRole: item.actor_role,
|
||||
action: item.action,
|
||||
targetType: item.target_type,
|
||||
|
||||
@@ -28,6 +28,7 @@ export type AdminSession = {
|
||||
sessionId: string
|
||||
userId: number
|
||||
username: string
|
||||
displayName: string
|
||||
role: AdminRole
|
||||
expiresAt: string
|
||||
sessionVersion: number
|
||||
@@ -48,6 +49,7 @@ export async function ensureAdminUsersBootstrapped(): Promise<void> {
|
||||
const username = String(configuredUser?.username || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
const displayName = normalizeDisplayName(configuredUser?.displayName, username) || username
|
||||
const password = String(configuredUser?.password || '').trim()
|
||||
const role = normalizeAdminRole(configuredUser?.role)
|
||||
|
||||
@@ -62,6 +64,7 @@ export async function ensureAdminUsersBootstrapped(): Promise<void> {
|
||||
const now = nowIso()
|
||||
await createAdminUser({
|
||||
username,
|
||||
displayName,
|
||||
passwordHash: hashAdminPassword(password),
|
||||
role,
|
||||
status: 'active',
|
||||
@@ -229,6 +232,7 @@ export async function verifyAdminSessionToken(token: unknown): Promise<AdminSess
|
||||
sessionId,
|
||||
userId: Number(user.id),
|
||||
username: String(user.username || ''),
|
||||
displayName: String(user.display_name || user.username || ''),
|
||||
role: normalizeAdminRole(user.role),
|
||||
expiresAt,
|
||||
sessionVersion: currentSessionVersion,
|
||||
@@ -251,6 +255,7 @@ export async function getAdminSessionSummary(token: unknown): Promise<JsonObject
|
||||
user: {
|
||||
userId: session.userId,
|
||||
username: session.username,
|
||||
displayName: session.displayName,
|
||||
role: session.role,
|
||||
permissions: session.permissions,
|
||||
},
|
||||
@@ -277,7 +282,7 @@ export async function getAdminUserList(query: JsonObject = {}): Promise<JsonObje
|
||||
const { items, total } = await listAdminUsers({
|
||||
page,
|
||||
pageSize,
|
||||
username: String(query.username || '').trim(),
|
||||
keyword: String(query.keyword || query.username || '').trim(),
|
||||
role: normalizeRoleQuery(query.role),
|
||||
status: normalizeStatusQuery(query.status),
|
||||
})
|
||||
@@ -290,6 +295,7 @@ export async function getAdminUserList(query: JsonObject = {}): Promise<JsonObje
|
||||
|
||||
export async function createManagedAdminUser(payload: JsonObject = {}): Promise<JsonObject> {
|
||||
const username = normalizeUsername(payload.username)
|
||||
const displayName = normalizeDisplayName(payload.displayName, username) || username
|
||||
const password = normalizePassword(payload.password)
|
||||
|
||||
if (!username) {
|
||||
@@ -319,6 +325,7 @@ export async function createManagedAdminUser(payload: JsonObject = {}): Promise<
|
||||
const now = nowIso()
|
||||
const created = await createAdminUser({
|
||||
username,
|
||||
displayName,
|
||||
passwordHash: hashAdminPassword(password),
|
||||
role: normalizeAdminRole(payload.role),
|
||||
status: normalizeAdminUserStatus(payload.status || 'active'),
|
||||
@@ -401,6 +408,28 @@ export async function updateManagedAdminUserStatus(
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateManagedAdminUserDisplayName(
|
||||
userId: number | string,
|
||||
payload: JsonObject = {},
|
||||
): Promise<JsonObject> {
|
||||
const user = await getRequiredAdminUser(userId)
|
||||
const displayName = normalizeDisplayName(payload.displayName, user.username) || user.username
|
||||
const updated = await updateAdminUser(user.id, {
|
||||
display_name: displayName,
|
||||
updated_at: nowIso(),
|
||||
})
|
||||
if (!updated) {
|
||||
throw createHttpError('后台用户更新失败', {
|
||||
statusCode: 500,
|
||||
errorCode: 'admin_user_update_failed',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
user: mapAdminUser(updated),
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetManagedAdminUserPassword(
|
||||
userId: number | string,
|
||||
payload: JsonObject = {},
|
||||
@@ -472,6 +501,7 @@ function createAdminSession(user: AdminUserRow): JsonObject {
|
||||
user: {
|
||||
userId: Number(user.id),
|
||||
username: String(user.username || ''),
|
||||
displayName: String(user.display_name || user.username || ''),
|
||||
role: normalizeAdminRole(user.role),
|
||||
},
|
||||
}
|
||||
@@ -559,6 +589,27 @@ function normalizeUsername(username: unknown): string {
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
function normalizeDisplayName(displayName: unknown, fallback = ''): string {
|
||||
const normalized = String(displayName ?? '')
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ')
|
||||
if (!normalized) {
|
||||
return fallback
|
||||
}
|
||||
const characters = Array.from(normalized)
|
||||
const hasControlCharacter = characters.some((character) => {
|
||||
const codePoint = character.codePointAt(0) || 0
|
||||
return codePoint <= 0x1f || codePoint === 0x7f
|
||||
})
|
||||
if (characters.length > 32 || hasControlCharacter) {
|
||||
throw createHttpError('姓名格式无效,最多 32 个字符且不能包含控制字符', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_user_display_name_invalid',
|
||||
})
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function normalizePassword(password: unknown): string {
|
||||
return String(password || '').trim()
|
||||
}
|
||||
@@ -634,6 +685,7 @@ function mapAdminUser(user: AdminUserRow): JsonObject {
|
||||
return {
|
||||
userId: Number(user.id),
|
||||
username: String(user.username || ''),
|
||||
displayName: String(user.display_name || user.username || ''),
|
||||
role: normalizeAdminRole(user.role),
|
||||
status: normalizeAdminUserStatus(user.status),
|
||||
createdAt: user.created_at,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -67,6 +67,7 @@ export async function getAdminLoginLogs(query: JsonObject = {}) {
|
||||
logId: Number(item.id),
|
||||
userId: Number(item.user_id || 0) || null,
|
||||
username: String(item.username || '').trim(),
|
||||
displayName: String(item.display_name || item.username || '').trim(),
|
||||
role: String(item.role || '').trim(),
|
||||
ip: String(item.ip || '').trim(),
|
||||
location: String(item.location || '').trim(),
|
||||
|
||||
@@ -6,6 +6,12 @@ export const ADMIN_PERMISSION_KEYS = [
|
||||
'finance.review',
|
||||
'finance.config',
|
||||
'finance.export',
|
||||
'fund.view',
|
||||
'fund.issue',
|
||||
'fund.expense',
|
||||
'fund.reconcile',
|
||||
'fund.adjust',
|
||||
'fund.export',
|
||||
'worker_order.view',
|
||||
'worker_order.edit',
|
||||
'worker_order.configure_share',
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
canViewerCloseTask,
|
||||
createAdminViewerContext,
|
||||
mapKuaishouCloudFulfillmentContext,
|
||||
mapManualDispatchContext,
|
||||
resolveAdminTaskScreenshotUrl,
|
||||
resolveOrderItemSkuNick,
|
||||
resolveOrderItemTitle,
|
||||
@@ -68,6 +69,23 @@ test('createAdminViewerContext lets support rebind-assisted actions without full
|
||||
assert.equal(operator.canManageTaskLifecycle, true)
|
||||
})
|
||||
|
||||
test('mapManualDispatchContext prefers display name and falls back to username', () => {
|
||||
const withDisplayName = mapManualDispatchContext({
|
||||
completedBy: { userId: 3, username: 'kf_1', displayName: '客服-1', role: 'support' },
|
||||
})
|
||||
assert.deepEqual(withDisplayName?.completedBy, {
|
||||
userId: 3,
|
||||
username: 'kf_1',
|
||||
displayName: '客服-1',
|
||||
role: 'support',
|
||||
})
|
||||
|
||||
const legacy = mapManualDispatchContext({
|
||||
completedBy: { userId: 3, username: 'kf_1', role: 'support' },
|
||||
})
|
||||
assert.equal(legacy?.completedBy?.displayName, 'kf_1')
|
||||
})
|
||||
|
||||
test('mapKuaishouCloudFulfillmentContext exposes cloud account display labels', () => {
|
||||
const flow = mapKuaishouCloudFulfillmentContext(
|
||||
{
|
||||
|
||||
@@ -64,6 +64,9 @@ export function mapManualDispatchContext(
|
||||
? {
|
||||
userId: Number(record.completedBy.userId || 0) || 0,
|
||||
username: String(record.completedBy.username || '').trim(),
|
||||
displayName: String(
|
||||
record.completedBy.displayName || record.completedBy.username || '',
|
||||
).trim(),
|
||||
role: String(record.completedBy.role || '').trim(),
|
||||
}
|
||||
: null,
|
||||
|
||||
@@ -333,6 +333,7 @@ function buildAdminActionActor(session: AdminViewerSessionInput | null) {
|
||||
? {
|
||||
userId: Number(session.userId || 0) || 0,
|
||||
username: String(session.username || '').trim(),
|
||||
displayName: String(session.displayName || session.username || '').trim(),
|
||||
role: String(session.role || '').trim(),
|
||||
}
|
||||
: null
|
||||
|
||||
@@ -59,6 +59,12 @@ export async function updateAdminOrderClaimStatus(
|
||||
claimStatus: updated.claim_status || payload.status,
|
||||
claimStatusReason: updated.claim_status_reason || reason,
|
||||
claimStatusUpdatedAt: updated.claim_status_updated_at || now,
|
||||
operator: session ? { userId: session.userId, username: session.username } : null,
|
||||
operator: session
|
||||
? {
|
||||
userId: session.userId,
|
||||
username: session.username,
|
||||
displayName: session.displayName || session.username,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@ export async function closeAdminTask(
|
||||
? {
|
||||
userId: session.userId,
|
||||
username: session.username,
|
||||
displayName: session.displayName || session.username,
|
||||
role: session.role,
|
||||
}
|
||||
: null,
|
||||
@@ -140,6 +141,7 @@ export async function markAdminTaskManualReview(
|
||||
? {
|
||||
userId: session.userId,
|
||||
username: session.username,
|
||||
displayName: session.displayName || session.username,
|
||||
role: session.role,
|
||||
}
|
||||
: null,
|
||||
@@ -215,6 +217,7 @@ export async function retryAdminTask(
|
||||
? {
|
||||
userId: session.userId,
|
||||
username: session.username,
|
||||
displayName: session.displayName || session.username,
|
||||
role: session.role,
|
||||
}
|
||||
: null,
|
||||
@@ -335,6 +338,7 @@ export async function regenerateAdminTaskClaimLink(
|
||||
? {
|
||||
userId: session.userId,
|
||||
username: session.username,
|
||||
displayName: session.displayName || session.username,
|
||||
role: session.role,
|
||||
}
|
||||
: null,
|
||||
@@ -394,6 +398,7 @@ export async function completeAdminTaskManualDispatch(
|
||||
? {
|
||||
userId: Number(session.userId || 0),
|
||||
username: String(session.username || ''),
|
||||
displayName: String(session.displayName || session.username || ''),
|
||||
role: String(session.role || ''),
|
||||
}
|
||||
: null,
|
||||
|
||||
@@ -29,7 +29,11 @@ import { getWorkerRiskConfig, saveWorkerRiskConfig } from './worker-risk-config-
|
||||
* - **留痕层**:worker_risk_events(只记显著事件)。持久、可追溯。
|
||||
*/
|
||||
|
||||
export type WorkerRiskActor = { userId?: number | null; username?: string }
|
||||
export type WorkerRiskActor = {
|
||||
userId?: number | null
|
||||
username?: string
|
||||
displayName?: string
|
||||
}
|
||||
|
||||
const OVERVIEW_SUMMARY_WINDOW_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
@@ -123,7 +127,7 @@ export async function banAdminWorkerRisk(
|
||||
reason,
|
||||
action: 'manual',
|
||||
actorUserId: actor.userId ?? null,
|
||||
actorUsername: actor.username || '',
|
||||
actorUsername: actor.displayName || actor.username || '',
|
||||
})
|
||||
|
||||
return { workerId: id, status: 'frozen', reason }
|
||||
@@ -162,7 +166,7 @@ export async function unbanAdminWorkerRisk(
|
||||
reason,
|
||||
action: 'manual',
|
||||
actorUserId: actor.userId ?? null,
|
||||
actorUsername: actor.username || '',
|
||||
actorUsername: actor.displayName || actor.username || '',
|
||||
})
|
||||
|
||||
return { workerId: id, status: 'active', reason }
|
||||
@@ -182,7 +186,7 @@ export async function releaseAdminWorkerRiskTracker(
|
||||
reason: '后台仅释放内存风控状态(账号状态未变更)',
|
||||
action: 'manual',
|
||||
actorUserId: actor.userId ?? null,
|
||||
actorUsername: actor.username || '',
|
||||
actorUsername: actor.displayName || actor.username || '',
|
||||
})
|
||||
return { workerId: id }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export type AdminViewerSessionInput = {
|
||||
userId?: number | string
|
||||
username?: string
|
||||
displayName?: string
|
||||
role?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export type AdminDefaultUser = {
|
||||
username: string
|
||||
displayName?: string
|
||||
password: string
|
||||
role: string
|
||||
status?: string
|
||||
|
||||
@@ -21,6 +21,7 @@ import WorkerLayout from '@/layouts/WorkerLayout'
|
||||
|
||||
const AdminDashboardPage = lazy(() => import('@/pages/admin/AdminDashboardPage'))
|
||||
const AdminFinancePage = lazy(() => import('@/pages/admin/AdminFinancePage'))
|
||||
const AdminFundPage = lazy(() => import('@/pages/admin/AdminFundPage'))
|
||||
const AdminAuditLogsPage = lazy(() => import('@/pages/admin/AdminAuditLogsPage'))
|
||||
const AdminKuaishouIndustryPage = lazy(() => import('@/pages/admin/AdminKuaishouIndustryPage'))
|
||||
const AdminDouyinMashidaCertificatePage = lazy(
|
||||
@@ -145,6 +146,9 @@ function AdminApplication() {
|
||||
<Route element={<RequireRole roles={['admin', 'operator', 'finance']} />}>
|
||||
<Route path="finance" element={<AdminFinancePage />} />
|
||||
</Route>
|
||||
<Route element={<RequireRole roles={['admin', 'operator', 'support', 'finance']} />}>
|
||||
<Route path="fund" element={<AdminFundPage />} />
|
||||
</Route>
|
||||
<Route path="worker-platform" element={<AdminWorkerPlatformPage />} />
|
||||
<Route element={<RequireRole roles={['admin', 'operator', 'support']} />}>
|
||||
<Route path="kuaishou-industry" element={<AdminKuaishouIndustryPage />} />
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
TrophyOutlined,
|
||||
UnorderedListOutlined,
|
||||
UserOutlined,
|
||||
WalletOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import {
|
||||
App,
|
||||
@@ -45,10 +46,12 @@ import {
|
||||
} from '@/services/admin'
|
||||
import {
|
||||
clearAdminSession,
|
||||
getAdminDisplayName,
|
||||
getAdminRole,
|
||||
getAdminToken,
|
||||
getAdminTokenExpiresAt,
|
||||
getAdminUsername,
|
||||
setAdminDisplayName,
|
||||
setAdminPermissions,
|
||||
} from '@/utils/admin-auth'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
@@ -89,9 +92,13 @@ export default function AdminLayout() {
|
||||
const session = adminSessionQuery.data?.data
|
||||
if (session?.authenticated && session.user) {
|
||||
setAdminPermissions(session.user.permissions || [])
|
||||
setAdminDisplayName(session.user.displayName || session.user.username)
|
||||
}
|
||||
}, [adminSessionQuery.data])
|
||||
|
||||
const displayName =
|
||||
adminSessionQuery.data?.data.user?.displayName || getAdminDisplayName() || username
|
||||
|
||||
const devMockStatusQuery = useQuery({
|
||||
queryKey: ['admin-dev-mock-status'],
|
||||
queryFn: () => fetchDevMockStatus(),
|
||||
@@ -124,11 +131,17 @@ export default function AdminLayout() {
|
||||
role === 'finance'
|
||||
? [
|
||||
{ key: '/admin/finance', icon: <AccountBookOutlined />, label: '财务' },
|
||||
{ key: '/admin/fund', icon: <WalletOutlined />, label: '备用金' },
|
||||
{ key: '/admin/worker-platform', icon: <TrophyOutlined />, label: '资金审核' },
|
||||
]
|
||||
: [
|
||||
{ key: '/admin/dashboard', icon: <DashboardOutlined />, label: '概览' },
|
||||
{ key: '/admin/orders', icon: <OrderedListOutlined />, label: '订单' },
|
||||
{
|
||||
key: '/admin/fund',
|
||||
icon: <WalletOutlined />,
|
||||
label: role === 'support' ? '我的备用金' : '备用金',
|
||||
},
|
||||
...(role === 'support'
|
||||
? []
|
||||
: [{ key: '/admin/finance', icon: <AccountBookOutlined />, label: '财务' }]),
|
||||
@@ -291,7 +304,10 @@ export default function AdminLayout() {
|
||||
|
||||
{!collapsed ? (
|
||||
<div className="admin-sidebar-profile">
|
||||
<Typography.Text strong>{username}</Typography.Text>
|
||||
<Typography.Text strong>{displayName}</Typography.Text>
|
||||
{displayName !== username ? (
|
||||
<Typography.Text type="secondary">@{username}</Typography.Text>
|
||||
) : null}
|
||||
<Typography.Text type="secondary">{roleLabel(role)}</Typography.Text>
|
||||
{expiresAt ? (
|
||||
<Typography.Text type="secondary">
|
||||
|
||||
@@ -72,7 +72,12 @@ export default function AdminAuditLogsPage() {
|
||||
minWidth: 140,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text strong>{row.actorUsername || '-'}</Typography.Text>
|
||||
<Typography.Text strong>
|
||||
{row.actorDisplayName || row.actorUsername || '-'}
|
||||
</Typography.Text>
|
||||
{row.actorDisplayName && row.actorDisplayName !== row.actorUsername ? (
|
||||
<Typography.Text type="secondary">@{row.actorUsername}</Typography.Text>
|
||||
) : null}
|
||||
<StatusTag value={row.actorRole} />
|
||||
</div>
|
||||
),
|
||||
@@ -155,7 +160,7 @@ export default function AdminAuditLogsPage() {
|
||||
className="filter-form"
|
||||
>
|
||||
<Form.Item name="actorUsername">
|
||||
<Input allowClear placeholder="操作账号" />
|
||||
<Input allowClear placeholder="姓名/账号" />
|
||||
</Form.Item>
|
||||
<Form.Item name="action">
|
||||
<Select
|
||||
|
||||
@@ -80,10 +80,16 @@ export default function AdminDashboardPage() {
|
||||
render: (value) => formatAdminDateTime(value),
|
||||
},
|
||||
{
|
||||
title: '账号',
|
||||
dataIndex: 'username',
|
||||
minWidth: 120,
|
||||
render: (value) => <Typography.Text strong>{value || '-'}</Typography.Text>,
|
||||
title: '姓名',
|
||||
minWidth: 140,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text strong>{row.displayName || row.username || '-'}</Typography.Text>
|
||||
{row.displayName && row.displayName !== row.username ? (
|
||||
<Typography.Text type="secondary">@{row.username}</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
@@ -186,8 +192,8 @@ export default function AdminDashboardPage() {
|
||||
initialValues={{ username: filters.username }}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<Form.Item name="username" label="账号">
|
||||
<Input allowClear placeholder="用户名" style={{ width: 180 }} />
|
||||
<Form.Item name="username" label="姓名/账号">
|
||||
<Input allowClear placeholder="姓名或账号" style={{ width: 180 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="dateRange" label="登录时段">
|
||||
<AdminRangePicker />
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { getAdminRole } from '@/utils/admin-auth'
|
||||
import FundManagementView from './fund/FundManagementView'
|
||||
import FundSupportView from './fund/FundSupportView'
|
||||
|
||||
export default function AdminFundPage() {
|
||||
const role = getAdminRole()
|
||||
|
||||
if (role === 'support') {
|
||||
return <FundSupportView />
|
||||
}
|
||||
|
||||
return <FundManagementView role={role} />
|
||||
}
|
||||
@@ -1698,8 +1698,10 @@ function ManualDispatchPanel({
|
||||
{formatAdminDateTime(detail.manualDispatch.completedAt)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="处理人">
|
||||
{detail.manualDispatch.completedBy?.username || '-'} /{' '}
|
||||
{detail.manualDispatch.completedBy?.role || '-'}
|
||||
{detail.manualDispatch.completedBy?.displayName ||
|
||||
detail.manualDispatch.completedBy?.username ||
|
||||
'-'}{' '}
|
||||
/ {detail.manualDispatch.completedBy?.role || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="履约单号">
|
||||
{detail.manualDispatch.deliveryReference || '-'}
|
||||
|
||||
@@ -27,12 +27,13 @@ import {
|
||||
fetchAdminRolePermissions,
|
||||
fetchAdminUsers,
|
||||
resetAdminUserPassword,
|
||||
updateAdminUserDisplayName,
|
||||
updateAdminUserRole,
|
||||
updateAdminUserStatus,
|
||||
updateAdminRolePermissions,
|
||||
} from '@/services/admin'
|
||||
import type { AdminRole, AdminUserListItem } from '@/types/admin'
|
||||
import { getAdminUserId, hasAdminRole } from '@/utils/admin-auth'
|
||||
import { getAdminUserId, hasAdminRole, setAdminDisplayName } from '@/utils/admin-auth'
|
||||
import { adminUserRoleOptions, adminUserStatusOptions } from '@/utils/admin-options'
|
||||
import {
|
||||
ADMIN_DEFAULT_PAGE_SIZE,
|
||||
@@ -48,11 +49,16 @@ type UserFilterForm = {
|
||||
}
|
||||
|
||||
type CreateUserForm = {
|
||||
displayName?: string
|
||||
username: string
|
||||
password: string
|
||||
role: AdminRole
|
||||
}
|
||||
|
||||
type DisplayNameForm = {
|
||||
displayName: string
|
||||
}
|
||||
|
||||
const USER_ROLE_OPTIONS = [
|
||||
{ label: '普通运营', value: 'operator' },
|
||||
{ label: '客服', value: 'support' },
|
||||
@@ -119,6 +125,7 @@ export default function AdminUsersPage() {
|
||||
try {
|
||||
await createAdminUser({
|
||||
username: values.username.trim(),
|
||||
displayName: values.displayName?.trim() || '',
|
||||
password: values.password.trim(),
|
||||
role: values.role,
|
||||
status: 'active',
|
||||
@@ -139,7 +146,7 @@ export default function AdminUsersPage() {
|
||||
|
||||
modal.confirm({
|
||||
title: '确认操作',
|
||||
content: `确认将 ${item.username} 调整为${formatRoleLabel(nextRole)}吗?`,
|
||||
content: `确认将 ${displayNameOf(item)} 调整为${formatRoleLabel(nextRole)}吗?`,
|
||||
okText: '继续执行',
|
||||
cancelText: '取消',
|
||||
centered: true,
|
||||
@@ -163,7 +170,7 @@ export default function AdminUsersPage() {
|
||||
|
||||
modal.confirm({
|
||||
title: '确认操作',
|
||||
content: `确认将 ${item.username}${nextStatus === 'active' ? '启用' : '停用'}吗?`,
|
||||
content: `确认将 ${displayNameOf(item)}${nextStatus === 'active' ? '启用' : '停用'}吗?`,
|
||||
okText: '继续执行',
|
||||
cancelText: '取消',
|
||||
centered: true,
|
||||
@@ -182,6 +189,66 @@ export default function AdminUsersPage() {
|
||||
})
|
||||
}
|
||||
|
||||
function openEditDisplayNameModal(item: AdminUserListItem) {
|
||||
const form = ModalFormStore.create<DisplayNameForm>()
|
||||
|
||||
modal.confirm({
|
||||
title: '修改姓名',
|
||||
content: (
|
||||
<Form
|
||||
layout="vertical"
|
||||
preserve={false}
|
||||
initialValues={{ displayName: item.displayName || item.username }}
|
||||
ref={form.bind}
|
||||
className="modal-form"
|
||||
>
|
||||
<Form.Item
|
||||
name="displayName"
|
||||
label="姓名(展示用,可输入中文)"
|
||||
rules={[{ max: 32, message: '姓名最多 32 个字符' }]}
|
||||
extra="留空时回退显示登录账号"
|
||||
>
|
||||
<Input placeholder="例如:张三" maxLength={32} showCount />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
),
|
||||
okText: '保存',
|
||||
cancelText: '取消',
|
||||
centered: true,
|
||||
onOk: async () => {
|
||||
const values = await form.validate()
|
||||
setActionLoadingId(item.userId)
|
||||
try {
|
||||
const response = await updateAdminUserDisplayName(item.userId, {
|
||||
displayName: values.displayName?.trim() || '',
|
||||
})
|
||||
if (item.userId === currentUserId) {
|
||||
setAdminDisplayName(response.data.user.displayName)
|
||||
}
|
||||
message.success('用户姓名已更新')
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-session'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-users'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-audit-logs'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-login-logs'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-fund-overview'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-fund-accounts'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-fund-expenses'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-fund-reconciliations'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-fund-account-detail'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-fund-options'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-fund-my'] }),
|
||||
])
|
||||
await reloadUsers()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '更新用户姓名失败')
|
||||
} finally {
|
||||
setActionLoadingId(null)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function openResetPasswordModal(item: AdminUserListItem) {
|
||||
const form = ModalFormStore.create<{ password: string }>()
|
||||
|
||||
@@ -197,7 +264,7 @@ export default function AdminUsersPage() {
|
||||
>
|
||||
<Form.Item
|
||||
name="password"
|
||||
label={`请输入 ${item.username} 的新密码`}
|
||||
label={`请输入 ${displayNameOf(item)} 的新密码`}
|
||||
rules={[
|
||||
{ required: true, message: '请输入新密码' },
|
||||
{ min: 8, message: '密码至少 8 位' },
|
||||
@@ -251,16 +318,22 @@ export default function AdminUsersPage() {
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '账号',
|
||||
dataIndex: 'username',
|
||||
minWidth: 150,
|
||||
render: (value, row) => (
|
||||
title: '姓名',
|
||||
dataIndex: 'displayName',
|
||||
minWidth: 160,
|
||||
render: (value: string, row) => (
|
||||
<Space>
|
||||
<Typography.Text strong>{value}</Typography.Text>
|
||||
<Typography.Text strong>{value || row.username}</Typography.Text>
|
||||
{row.userId === currentUserId ? <Tag>当前账号</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '账号',
|
||||
dataIndex: 'username',
|
||||
minWidth: 140,
|
||||
render: (value: string) => <Typography.Text type="secondary">{value}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'role',
|
||||
@@ -287,7 +360,7 @@ export default function AdminUsersPage() {
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 310,
|
||||
width: 390,
|
||||
render: (_, row) => {
|
||||
const isCurrent = row.userId === currentUserId
|
||||
const roleMenuItems: MenuProps['items'] = USER_ROLE_OPTIONS.map((item) => ({
|
||||
@@ -298,6 +371,12 @@ export default function AdminUsersPage() {
|
||||
|
||||
return (
|
||||
<Space wrap>
|
||||
<Button
|
||||
loading={actionLoadingId === row.userId}
|
||||
onClick={() => openEditDisplayNameModal(row)}
|
||||
>
|
||||
改名
|
||||
</Button>
|
||||
<Dropdown
|
||||
disabled={isCurrent}
|
||||
menu={{
|
||||
@@ -333,7 +412,7 @@ export default function AdminUsersPage() {
|
||||
<section className="page-stack">
|
||||
<PageHeader
|
||||
title="后台用户"
|
||||
description="管理员可维护后台账号、角色和启停状态,避免继续使用单一口令。"
|
||||
description="管理员可维护后台账号、姓名、角色和启停状态,避免继续使用单一口令。"
|
||||
/>
|
||||
|
||||
<Card className="users-action-card">
|
||||
@@ -347,7 +426,7 @@ export default function AdminUsersPage() {
|
||||
className="filter-form users-action-form"
|
||||
>
|
||||
<Form.Item name="username">
|
||||
<Input allowClear placeholder="账号筛选" className="users-input-md" />
|
||||
<Input allowClear placeholder="姓名/账号筛选" className="users-input-md" />
|
||||
</Form.Item>
|
||||
<Form.Item name="role">
|
||||
<Select
|
||||
@@ -387,8 +466,11 @@ export default function AdminUsersPage() {
|
||||
onFinish={submitCreate}
|
||||
className="filter-form users-action-form"
|
||||
>
|
||||
<Form.Item name="displayName" rules={[{ max: 32, message: '姓名最多 32 个字符' }]}>
|
||||
<Input placeholder="姓名(可中文)" className="users-input-md" maxLength={32} />
|
||||
</Form.Item>
|
||||
<Form.Item name="username" rules={[{ required: true, message: '请输入新账号' }]}>
|
||||
<Input placeholder="新账号" className="users-input-md" />
|
||||
<Input placeholder="登录账号(英文/数字)" className="users-input-md" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
@@ -424,7 +506,7 @@ export default function AdminUsersPage() {
|
||||
title="用户列表"
|
||||
extra={
|
||||
<Typography.Text type="secondary">
|
||||
共 {data?.pagination.total || 0} 个账号
|
||||
共 {data?.pagination.total || 0} 个用户
|
||||
</Typography.Text>
|
||||
}
|
||||
>
|
||||
@@ -434,7 +516,7 @@ export default function AdminUsersPage() {
|
||||
columns={columns}
|
||||
dataSource={data?.items || []}
|
||||
locale={{ emptyText: <Empty description="暂无用户" /> }}
|
||||
scroll={{ x: 1060 }}
|
||||
scroll={{ x: 1220 }}
|
||||
pagination={buildAdminTablePagination({
|
||||
current: data?.pagination.page || page,
|
||||
pageSize: data?.pagination.pageSize || pageSize,
|
||||
@@ -487,6 +569,10 @@ function formatRoleLabel(role: AdminRole) {
|
||||
return '普通运营'
|
||||
}
|
||||
|
||||
function displayNameOf(user: AdminUserListItem) {
|
||||
return user.displayName || user.username
|
||||
}
|
||||
|
||||
function cleanParams(params: Record<string, unknown>) {
|
||||
const next = new URLSearchParams()
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,564 @@
|
||||
import { PlusOutlined, ReloadOutlined, RollbackOutlined } from '@ant-design/icons'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Col,
|
||||
DatePicker,
|
||||
Descriptions,
|
||||
Divider,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Statistic,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { useState } from 'react'
|
||||
|
||||
import ImagePreviewList from '@/components/files/ImagePreviewList'
|
||||
import ImageUpload from '@/components/files/ImageUpload'
|
||||
import PageHeader from '@/components/admin/PageHeader'
|
||||
import {
|
||||
createAdminFundExpense,
|
||||
createAdminFundReturn,
|
||||
fetchAdminFundMyAccount,
|
||||
} from '@/services/admin'
|
||||
import type {
|
||||
AdminFundEntry,
|
||||
AdminFundExpense,
|
||||
AdminFundExpenseCategory,
|
||||
AdminFundPayeeType,
|
||||
} from '@/types/admin'
|
||||
import type { UploadedFile } from '@/types/worker-platform'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
import {
|
||||
FUND_ACCOUNT_STATUS_LABELS,
|
||||
FUND_CATEGORY_LABELS,
|
||||
FUND_ENTRY_COLORS,
|
||||
FUND_ENTRY_LABELS,
|
||||
FUND_PAYEE_LABELS,
|
||||
formatFundAmount,
|
||||
toFen,
|
||||
} from './fund-labels'
|
||||
|
||||
type ExpenseFormValues = {
|
||||
amountYuan: number
|
||||
category: AdminFundExpenseCategory
|
||||
payeeType: AdminFundPayeeType
|
||||
payeeName: string
|
||||
payeeAccount?: string
|
||||
platformName?: string
|
||||
platformAccount?: string
|
||||
orderId?: number
|
||||
paidAt: dayjs.Dayjs
|
||||
evidence: UploadedFile[]
|
||||
note?: string
|
||||
allowDuplicate?: boolean
|
||||
}
|
||||
|
||||
type ReturnFormValues = {
|
||||
amountYuan: number
|
||||
occurredAt: dayjs.Dayjs
|
||||
evidence: UploadedFile[]
|
||||
note?: string
|
||||
}
|
||||
|
||||
export default function FundSupportView() {
|
||||
const { message } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const [expenseOpen, setExpenseOpen] = useState(false)
|
||||
const [returnOpen, setReturnOpen] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [expenseForm] = Form.useForm<ExpenseFormValues>()
|
||||
const [returnForm] = Form.useForm<ReturnFormValues>()
|
||||
|
||||
const myQuery = useQuery({
|
||||
queryKey: ['admin-fund-my'],
|
||||
queryFn: fetchAdminFundMyAccount,
|
||||
})
|
||||
const data = myQuery.data?.data
|
||||
const account = data?.account || null
|
||||
const entries = data?.entries || []
|
||||
const expenses = data?.expenses || []
|
||||
|
||||
function refresh() {
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-fund-my'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-fund-overview'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-fund-accounts'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-fund-expenses'] })
|
||||
}
|
||||
|
||||
function openExpense() {
|
||||
expenseForm.resetFields()
|
||||
expenseForm.setFieldsValue({
|
||||
category: 'platform_recharge',
|
||||
payeeType: 'platform',
|
||||
paidAt: dayjs(),
|
||||
evidence: [],
|
||||
allowDuplicate: false,
|
||||
})
|
||||
setExpenseOpen(true)
|
||||
}
|
||||
|
||||
function openReturn() {
|
||||
returnForm.resetFields()
|
||||
returnForm.setFieldsValue({
|
||||
occurredAt: dayjs(),
|
||||
evidence: [],
|
||||
})
|
||||
setReturnOpen(true)
|
||||
}
|
||||
|
||||
async function submitExpense(values: ExpenseFormValues) {
|
||||
if (!account) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await createAdminFundExpense(account.id, {
|
||||
amountFen: toFen(values.amountYuan),
|
||||
category: values.category,
|
||||
payeeType: values.payeeType,
|
||||
payeeName: values.payeeName,
|
||||
payeeAccount: values.payeeAccount || '',
|
||||
platformName: values.platformName || '',
|
||||
platformAccount: values.platformAccount || '',
|
||||
orderId: values.orderId || null,
|
||||
paidAt: values.paidAt.toISOString(),
|
||||
evidence: values.evidence || [],
|
||||
note: values.note || '',
|
||||
allowDuplicate: values.allowDuplicate === true,
|
||||
})
|
||||
message.success('支出已登记')
|
||||
setExpenseOpen(false)
|
||||
refresh()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '登记支出失败')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function submitReturn(values: ReturnFormValues) {
|
||||
if (!account) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await createAdminFundReturn(account.id, {
|
||||
amountFen: toFen(values.amountYuan),
|
||||
occurredAt: values.occurredAt.toISOString(),
|
||||
evidence: values.evidence || [],
|
||||
note: values.note || '',
|
||||
})
|
||||
message.success('缴回已提交')
|
||||
setReturnOpen(false)
|
||||
refresh()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '缴回失败')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const entryColumns: TableColumnsType<AdminFundEntry> = [
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'occurredAt',
|
||||
width: 170,
|
||||
render: (value: string) => formatAdminDateTime(value),
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'entryType',
|
||||
width: 110,
|
||||
render: (value: AdminFundEntry['entryType']) => (
|
||||
<Tag color={FUND_ENTRY_COLORS[value]}>{FUND_ENTRY_LABELS[value]}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'amountFen',
|
||||
width: 130,
|
||||
render: (value: number) => (
|
||||
<Typography.Text type={value >= 0 ? 'success' : 'danger'}>
|
||||
{formatFundAmount(value, true)}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '余额',
|
||||
dataIndex: 'balanceAfterFen',
|
||||
width: 130,
|
||||
render: (value: number) => formatFundAmount(value),
|
||||
},
|
||||
{
|
||||
title: '凭证',
|
||||
dataIndex: 'evidence',
|
||||
width: 120,
|
||||
render: (value: UploadedFile[]) => (
|
||||
<ImagePreviewList files={value} size={36} maxVisible={2} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '说明',
|
||||
dataIndex: 'note',
|
||||
render: (value: string) => value || '-',
|
||||
},
|
||||
]
|
||||
|
||||
const expenseColumns: TableColumnsType<AdminFundExpense> = [
|
||||
{
|
||||
title: '付款时间',
|
||||
dataIndex: 'paidAt',
|
||||
width: 170,
|
||||
render: (value: string) => formatAdminDateTime(value),
|
||||
},
|
||||
{ title: '支出单号', dataIndex: 'expenseNo', width: 150 },
|
||||
{
|
||||
title: '分类',
|
||||
dataIndex: 'category',
|
||||
width: 120,
|
||||
render: (value: AdminFundExpenseCategory) => FUND_CATEGORY_LABELS[value] || value,
|
||||
},
|
||||
{
|
||||
title: '收款方',
|
||||
dataIndex: 'payeeName',
|
||||
width: 180,
|
||||
render: (_value: string, row) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<span>{row.payeeName}</span>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{FUND_PAYEE_LABELS[row.payeeType]}
|
||||
{row.payeeAccount ? ` · ${row.payeeAccount}` : ''}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '平台/账号',
|
||||
dataIndex: 'platformName',
|
||||
width: 180,
|
||||
render: (_value: string, row) =>
|
||||
row.platformName || row.platformAccount
|
||||
? `${row.platformName || '-'}${row.platformAccount ? ` / ${row.platformAccount}` : ''}`
|
||||
: '-',
|
||||
},
|
||||
{
|
||||
title: '关联订单',
|
||||
dataIndex: 'orderId',
|
||||
width: 110,
|
||||
render: (value: number | null) => (value ? `#${value}` : '-'),
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'amountFen',
|
||||
width: 130,
|
||||
render: (value: number) => (
|
||||
<Typography.Text type="danger">{formatFundAmount(value)}</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '凭证',
|
||||
dataIndex: 'evidence',
|
||||
width: 120,
|
||||
render: (value: UploadedFile[]) => (
|
||||
<ImagePreviewList files={value} size={36} maxVisible={2} />
|
||||
),
|
||||
},
|
||||
{ title: '说明', dataIndex: 'note', render: (value: string) => value || '-' },
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<PageHeader
|
||||
title="我的备用金"
|
||||
description="查看当前余额、登记每一笔支出;余额由流水自动计算,不能手工修改。"
|
||||
extra={
|
||||
<Button icon={<ReloadOutlined />} loading={myQuery.isFetching} onClick={refresh}>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{myQuery.error ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={myQuery.error instanceof Error ? myQuery.error.message : '读取我的备用金失败'}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!account && !myQuery.isLoading ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="还没有备用金账户"
|
||||
description="请联系主管或管理员为你开通客服备用金账户。"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{account ? (
|
||||
<>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="当前余额"
|
||||
value={account.balanceFen / 100}
|
||||
precision={2}
|
||||
prefix="¥"
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="本月发放"
|
||||
value={account.monthInFen / 100}
|
||||
precision={2}
|
||||
prefix="¥"
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="本月支出/缴回"
|
||||
value={account.monthOutFen / 100}
|
||||
precision={2}
|
||||
prefix="¥"
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="最近对账"
|
||||
value={
|
||||
account.lastReconciledAt
|
||||
? formatAdminDateTime(account.lastReconciledAt)
|
||||
: '未对账'
|
||||
}
|
||||
valueStyle={{ fontSize: 18 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card>
|
||||
<Descriptions column={2} size="small">
|
||||
<Descriptions.Item label="账户编号">{account.accountNo}</Descriptions.Item>
|
||||
<Descriptions.Item label="责任主管">
|
||||
{account.responsibleSupervisorName || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="账户状态">
|
||||
{FUND_ACCOUNT_STATUS_LABELS[account.status] || account.status}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="最近流水">
|
||||
{account.lastEntryAt ? formatAdminDateTime(account.lastEntryAt) : '-'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Divider />
|
||||
<Space wrap>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openExpense}>
|
||||
登记支出
|
||||
</Button>
|
||||
<Button icon={<RollbackOutlined />} onClick={openReturn}>
|
||||
缴回备用金
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'entries',
|
||||
label: '资金流水',
|
||||
children: (
|
||||
<Table<AdminFundEntry>
|
||||
rowKey="id"
|
||||
size="small"
|
||||
columns={entryColumns}
|
||||
dataSource={entries}
|
||||
pagination={false}
|
||||
scroll={{ x: 900 }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'expenses',
|
||||
label: '我的支出',
|
||||
children: (
|
||||
<Table<AdminFundExpense>
|
||||
rowKey="id"
|
||||
size="small"
|
||||
columns={expenseColumns}
|
||||
dataSource={expenses}
|
||||
pagination={false}
|
||||
scroll={{ x: 1100 }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Modal
|
||||
title="登记备用金支出"
|
||||
open={expenseOpen}
|
||||
onCancel={() => setExpenseOpen(false)}
|
||||
onOk={() => expenseForm.submit()}
|
||||
confirmLoading={submitting}
|
||||
destroyOnHidden
|
||||
width={720}
|
||||
>
|
||||
<Form<ExpenseFormValues> form={expenseForm} layout="vertical" onFinish={submitExpense}>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="amountYuan"
|
||||
label="支出金额(元)"
|
||||
rules={[{ required: true, message: '请输入支出金额' }]}
|
||||
>
|
||||
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="category"
|
||||
label="支出分类"
|
||||
rules={[{ required: true, message: '请选择分类' }]}
|
||||
>
|
||||
<Select
|
||||
options={Object.entries(FUND_CATEGORY_LABELS).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="payeeType"
|
||||
label="收款方类型"
|
||||
rules={[{ required: true, message: '请选择收款方类型' }]}
|
||||
>
|
||||
<Select
|
||||
options={Object.entries(FUND_PAYEE_LABELS).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="payeeName"
|
||||
label="收款方名称"
|
||||
rules={[{ required: true, message: '请填写收款方' }]}
|
||||
>
|
||||
<Input placeholder="平台/供应商/客户名称" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="payeeAccount" label="收款账号">
|
||||
<Input placeholder="微信号/支付宝/银行卡/平台账号" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="platformName" label="充值/消费平台">
|
||||
<Input placeholder="例如:91卡券、快手、游戏平台" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="platformAccount" label="平台账号">
|
||||
<Input placeholder="充值时填写" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="orderId" label="关联订单 ID(可选)">
|
||||
<InputNumber min={1} precision={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="paidAt"
|
||||
label="付款时间"
|
||||
rules={[{ required: true, message: '请选择付款时间' }]}
|
||||
>
|
||||
<DatePicker showTime style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Item
|
||||
name="evidence"
|
||||
label="付款凭证"
|
||||
rules={[{ required: true, message: '请上传付款截图' }]}
|
||||
>
|
||||
<ImageUpload scene="admin_fund_expense" scope="admin" maxCount={6} showPasteHint />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Item name="note" label="用途/原因">
|
||||
<Input.TextArea rows={2} placeholder="例如:给订单 123 充值、赔偿客户、补差价" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Item name="allowDuplicate" valuePropName="checked">
|
||||
<Checkbox>我已确认这不是重复支出(同一客服、金额、收款方 24 小时内)</Checkbox>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="缴回备用金"
|
||||
open={returnOpen}
|
||||
onCancel={() => setReturnOpen(false)}
|
||||
onOk={() => returnForm.submit()}
|
||||
confirmLoading={submitting}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form<ReturnFormValues> form={returnForm} layout="vertical" onFinish={submitReturn}>
|
||||
<Form.Item
|
||||
name="amountYuan"
|
||||
label="缴回金额(元)"
|
||||
rules={[{ required: true, message: '请输入缴回金额' }]}
|
||||
>
|
||||
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="occurredAt"
|
||||
label="缴回时间"
|
||||
rules={[{ required: true, message: '请选择时间' }]}
|
||||
>
|
||||
<DatePicker showTime style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="evidence"
|
||||
label="转账/交接凭证"
|
||||
rules={[{ required: true, message: '请上传凭证' }]}
|
||||
>
|
||||
<ImageUpload scene="admin_fund_return" scope="admin" maxCount={4} />
|
||||
</Form.Item>
|
||||
<Form.Item name="note" label="备注">
|
||||
<Input.TextArea rows={2} placeholder="缴回原因或交接说明" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type {
|
||||
AdminFundEntryType,
|
||||
AdminFundExpenseCategory,
|
||||
AdminFundPayeeType,
|
||||
AdminFundReconciliationStatus,
|
||||
} from '@/types/admin'
|
||||
|
||||
export const FUND_ENTRY_LABELS: Record<AdminFundEntryType, string> = {
|
||||
opening: '期初',
|
||||
funding: '公司拨付',
|
||||
issue: '发放',
|
||||
expense: '支出',
|
||||
return: '缴回',
|
||||
adjustment: '调整',
|
||||
}
|
||||
|
||||
export const FUND_ENTRY_COLORS: Record<AdminFundEntryType, string> = {
|
||||
opening: 'default',
|
||||
funding: 'blue',
|
||||
issue: 'green',
|
||||
expense: 'red',
|
||||
return: 'orange',
|
||||
adjustment: 'purple',
|
||||
}
|
||||
|
||||
export const FUND_CATEGORY_LABELS: Record<AdminFundExpenseCategory, string> = {
|
||||
platform_recharge: '平台充值',
|
||||
purchase: '采购/拿货',
|
||||
compensation: '客户赔偿',
|
||||
freight: '运费',
|
||||
other: '其他',
|
||||
}
|
||||
|
||||
export const FUND_PAYEE_LABELS: Record<AdminFundPayeeType, string> = {
|
||||
platform: '平台',
|
||||
supplier: '供应商',
|
||||
customer: '客户',
|
||||
other: '其他',
|
||||
}
|
||||
|
||||
export const FUND_ACCOUNT_STATUS_LABELS: Record<string, string> = {
|
||||
active: '正常',
|
||||
frozen: '冻结',
|
||||
closed: '关闭',
|
||||
}
|
||||
|
||||
export const FUND_RECONCILIATION_STATUS_LABELS: Record<AdminFundReconciliationStatus, string> = {
|
||||
pending: '待确认',
|
||||
confirmed: '已对平',
|
||||
discrepancy: '有差异',
|
||||
}
|
||||
|
||||
export const FUND_RECONCILIATION_STATUS_COLORS: Record<AdminFundReconciliationStatus, string> = {
|
||||
pending: 'gold',
|
||||
confirmed: 'green',
|
||||
discrepancy: 'red',
|
||||
}
|
||||
|
||||
export function formatFundAmount(amountFen: number, withSign = false) {
|
||||
const value = Number(amountFen || 0) / 100
|
||||
const absolute = Math.abs(value).toFixed(2)
|
||||
if (withSign) {
|
||||
if (value > 0) return `+¥${absolute}`
|
||||
if (value < 0) return `-¥${absolute}`
|
||||
}
|
||||
return value < 0 ? `-¥${absolute}` : `¥${absolute}`
|
||||
}
|
||||
|
||||
export function toFen(value: unknown) {
|
||||
const yuan = Number(value)
|
||||
return Number.isFinite(yuan) ? Math.round(yuan * 100) : 0
|
||||
}
|
||||
|
||||
export function fromFen(value: unknown) {
|
||||
const fen = Number(value)
|
||||
return Number.isFinite(fen) ? fen / 100 : 0
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { apiGet, apiPatch, apiPost } from '@/lib/http'
|
||||
import type {
|
||||
AdminFundAccountDetailResult,
|
||||
AdminFundAccountListResult,
|
||||
AdminFundExpenseListResult,
|
||||
AdminFundMyAccountResult,
|
||||
AdminFundOverview,
|
||||
AdminFundReconciliationListResult,
|
||||
AdminFundSetupOptions,
|
||||
} from '@/types/admin'
|
||||
|
||||
export function fetchAdminFundOverview() {
|
||||
return apiGet<AdminFundOverview>('/api/v1/admin/fund/overview')
|
||||
}
|
||||
|
||||
export function fetchAdminFundMyAccount() {
|
||||
return apiGet<AdminFundMyAccountResult>('/api/v1/admin/fund/my')
|
||||
}
|
||||
|
||||
export function fetchAdminFundAccounts(params?: Record<string, unknown>) {
|
||||
return apiGet<AdminFundAccountListResult>('/api/v1/admin/fund/accounts', params)
|
||||
}
|
||||
|
||||
export function fetchAdminFundAccountDetail(accountId: number, params?: Record<string, unknown>) {
|
||||
return apiGet<AdminFundAccountDetailResult>(`/api/v1/admin/fund/accounts/${accountId}`, params)
|
||||
}
|
||||
|
||||
export function fetchAdminFundSupervisors() {
|
||||
return apiGet<{ items: AdminFundAccountListResult['items'] }>('/api/v1/admin/fund/supervisors')
|
||||
}
|
||||
|
||||
export function fetchAdminFundSetupOptions() {
|
||||
return apiGet<AdminFundSetupOptions>('/api/v1/admin/fund/options')
|
||||
}
|
||||
|
||||
export function createAdminFundAccount(payload: Record<string, unknown>) {
|
||||
return apiPost<{ account: AdminFundAccountListResult['items'][number] }>(
|
||||
'/api/v1/admin/fund/accounts',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function ensureAdminFundAgentAccounts(payload: Record<string, unknown>) {
|
||||
return apiPost<{
|
||||
createdCount: number
|
||||
items: Array<{ id: number; accountNo: string; ownerName: string }>
|
||||
}>('/api/v1/admin/fund/accounts/ensure-agents', payload)
|
||||
}
|
||||
|
||||
export function updateAdminFundAccount(accountId: number, payload: Record<string, unknown>) {
|
||||
return apiPatch<{ account: AdminFundAccountListResult['items'][number] }>(
|
||||
`/api/v1/admin/fund/accounts/${accountId}`,
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function createAdminFundFunding(accountId: number, payload: Record<string, unknown>) {
|
||||
return apiPost<Record<string, unknown>>(
|
||||
`/api/v1/admin/fund/accounts/${accountId}/funding`,
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function createAdminFundIssue(accountId: number, payload: Record<string, unknown>) {
|
||||
return apiPost<Record<string, unknown>>(`/api/v1/admin/fund/accounts/${accountId}/issue`, payload)
|
||||
}
|
||||
|
||||
export function createAdminFundReturn(accountId: number, payload: Record<string, unknown>) {
|
||||
return apiPost<Record<string, unknown>>(
|
||||
`/api/v1/admin/fund/accounts/${accountId}/return`,
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function createAdminFundExpense(accountId: number, payload: Record<string, unknown>) {
|
||||
return apiPost<{ expense: AdminFundExpenseListResult['items'][number] }>(
|
||||
`/api/v1/admin/fund/accounts/${accountId}/expenses`,
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchAdminFundExpenses(params?: Record<string, unknown>) {
|
||||
return apiGet<AdminFundExpenseListResult>('/api/v1/admin/fund/expenses', params)
|
||||
}
|
||||
|
||||
export function fetchAdminFundReconciliations(params?: Record<string, unknown>) {
|
||||
return apiGet<AdminFundReconciliationListResult>('/api/v1/admin/fund/reconciliations', params)
|
||||
}
|
||||
|
||||
export function createAdminFundReconciliation(payload: Record<string, unknown>) {
|
||||
return apiPost<{ reconciliation: AdminFundReconciliationListResult['items'][number] }>(
|
||||
'/api/v1/admin/fund/reconciliations',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function confirmAdminFundReconciliation(
|
||||
reconciliationId: number,
|
||||
payload: Record<string, unknown>,
|
||||
) {
|
||||
return apiPost<{ reconciliation: AdminFundReconciliationListResult['items'][number] }>(
|
||||
`/api/v1/admin/fund/reconciliations/${reconciliationId}/confirm`,
|
||||
payload,
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from './auth'
|
||||
export * from './dashboard'
|
||||
export * from './finance'
|
||||
export * from './fund'
|
||||
export * from './users'
|
||||
export * from './audit-logs'
|
||||
export * from './platform-config'
|
||||
|
||||
@@ -35,6 +35,7 @@ export function fetchAdminUsers(params?: Record<string, unknown>) {
|
||||
|
||||
export function createAdminUser(payload: {
|
||||
username: string
|
||||
displayName?: string
|
||||
password: string
|
||||
role: string
|
||||
status?: string
|
||||
@@ -42,6 +43,13 @@ export function createAdminUser(payload: {
|
||||
return apiPost<{ user: AdminUserListItem }>('/api/v1/admin/users', payload)
|
||||
}
|
||||
|
||||
export function updateAdminUserDisplayName(
|
||||
userId: number | string,
|
||||
payload: { displayName: string },
|
||||
) {
|
||||
return apiPost<{ user: AdminUserListItem }>(`/api/v1/admin/users/${userId}/display-name`, payload)
|
||||
}
|
||||
|
||||
export function updateAdminUserRole(userId: number | string, payload: { role: string }) {
|
||||
return apiPost<{ user: AdminUserListItem }>(`/api/v1/admin/users/${userId}/role`, payload)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface AdminAuditLogItem {
|
||||
logId: number
|
||||
actorUserId: number
|
||||
actorUsername: string
|
||||
actorDisplayName: string
|
||||
actorRole: AdminRole
|
||||
action: string
|
||||
targetType: string
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface AdminLoginResponse {
|
||||
user: {
|
||||
userId: number
|
||||
username: string
|
||||
displayName: string
|
||||
role: AdminRole
|
||||
permissions?: string[]
|
||||
}
|
||||
@@ -18,6 +19,7 @@ export interface AdminSessionSummary {
|
||||
user: {
|
||||
userId: number
|
||||
username: string
|
||||
displayName: string
|
||||
role: AdminRole
|
||||
permissions?: string[]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import type { UploadedFile } from '@/types/worker-platform'
|
||||
|
||||
export type AdminFundAccountType = 'supervisor' | 'agent'
|
||||
export type AdminFundAccountStatus = 'active' | 'frozen' | 'closed'
|
||||
export type AdminFundEntryType =
|
||||
| 'opening'
|
||||
| 'funding'
|
||||
| 'issue'
|
||||
| 'expense'
|
||||
| 'return'
|
||||
| 'adjustment'
|
||||
export type AdminFundExpenseCategory =
|
||||
| 'platform_recharge'
|
||||
| 'purchase'
|
||||
| 'compensation'
|
||||
| 'freight'
|
||||
| 'other'
|
||||
export type AdminFundPayeeType = 'platform' | 'supplier' | 'customer' | 'other'
|
||||
export type AdminFundReconciliationStatus = 'pending' | 'confirmed' | 'discrepancy'
|
||||
|
||||
export type AdminFundAccount = {
|
||||
id: number
|
||||
accountNo: string
|
||||
accountType: AdminFundAccountType
|
||||
ownerAdminUserId: number
|
||||
ownerName: string
|
||||
responsibleSupervisorId: number | null
|
||||
responsibleSupervisorName: string
|
||||
balanceFen: number
|
||||
status: AdminFundAccountStatus
|
||||
monthInFen: number
|
||||
monthOutFen: number
|
||||
lastEntryAt: string | null
|
||||
lastReconciledAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type AdminFundEntry = {
|
||||
id: number
|
||||
entryNo: string
|
||||
accountId: number
|
||||
entryType: AdminFundEntryType
|
||||
amountFen: number
|
||||
balanceAfterFen: number
|
||||
transferNo: string
|
||||
counterpartyAccountId: number | null
|
||||
counterpartyName: string
|
||||
reconciliationId: number | null
|
||||
occurredAt: string
|
||||
createdByAdminUserId: number | null
|
||||
createdByUsername: string
|
||||
evidence: UploadedFile[]
|
||||
note: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type AdminFundExpense = {
|
||||
id: number
|
||||
expenseNo: string
|
||||
accountId: number
|
||||
accountNo: string
|
||||
agentName: string
|
||||
amountFen: number
|
||||
category: AdminFundExpenseCategory
|
||||
payeeType: AdminFundPayeeType
|
||||
payeeName: string
|
||||
payeeAccount: string
|
||||
platformName: string
|
||||
platformAccount: string
|
||||
orderId: number | null
|
||||
orderItemId: number | null
|
||||
paidAt: string
|
||||
evidence: UploadedFile[]
|
||||
note: string
|
||||
fundEntryId: number
|
||||
createdByAdminUserId: number | null
|
||||
createdByUsername: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type AdminFundReconciliation = {
|
||||
id: number
|
||||
reconciliationNo: string
|
||||
accountId: number
|
||||
accountNo: string
|
||||
agentName: string
|
||||
periodStart: string
|
||||
periodEnd: string
|
||||
systemBalanceFen: number
|
||||
actualBalanceFen: number
|
||||
differenceFen: number
|
||||
status: AdminFundReconciliationStatus
|
||||
reason: string
|
||||
evidence: UploadedFile[]
|
||||
confirmedByAdminUserId: number | null
|
||||
confirmedByUsername: string
|
||||
confirmedAt: string | null
|
||||
createdByAdminUserId: number | null
|
||||
createdByUsername: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type AdminFundOverview = {
|
||||
supervisorBalanceFen: number
|
||||
agentBalanceFen: number
|
||||
totalBalanceFen: number
|
||||
activeSupervisorCount: number
|
||||
activeAgentCount: number
|
||||
monthIssuedFen: number
|
||||
monthExpenseFen: number
|
||||
monthReturnedFen: number
|
||||
pendingReconciliationCount: number
|
||||
discrepancyCount: number
|
||||
unreconciledAgentCount: number
|
||||
}
|
||||
|
||||
export type AdminFundAccountListResult = {
|
||||
items: AdminFundAccount[]
|
||||
pagination: { page: number; pageSize: number; total: number }
|
||||
}
|
||||
|
||||
export type AdminFundAccountDetailResult = {
|
||||
account: AdminFundAccount
|
||||
items: AdminFundEntry[]
|
||||
pagination: { page: number; pageSize: number; total: number }
|
||||
}
|
||||
|
||||
export type AdminFundMyAccountResult = {
|
||||
account: AdminFundAccount | null
|
||||
entries: AdminFundEntry[]
|
||||
expenses: AdminFundExpense[]
|
||||
pagination: { page: number; pageSize: number; total: number }
|
||||
}
|
||||
|
||||
export type AdminFundExpenseListResult = {
|
||||
items: AdminFundExpense[]
|
||||
pagination: { page: number; pageSize: number; total: number }
|
||||
}
|
||||
|
||||
export type AdminFundReconciliationListResult = {
|
||||
items: AdminFundReconciliation[]
|
||||
pagination: { page: number; pageSize: number; total: number }
|
||||
}
|
||||
|
||||
export type AdminFundSetupUser = {
|
||||
id: number
|
||||
username: string
|
||||
displayName: string
|
||||
role: 'admin' | 'operator' | 'support' | 'finance'
|
||||
status: string
|
||||
}
|
||||
|
||||
export type AdminFundSetupOptions = {
|
||||
users: AdminFundSetupUser[]
|
||||
supervisorAccounts: AdminFundAccount[]
|
||||
agentAccounts: AdminFundAccount[]
|
||||
}
|
||||
@@ -23,6 +23,28 @@ export type {
|
||||
AdminFinanceTransactionResult,
|
||||
} from './finance'
|
||||
|
||||
// Fund / imprest account types
|
||||
export type {
|
||||
AdminFundAccount,
|
||||
AdminFundAccountDetailResult,
|
||||
AdminFundAccountListResult,
|
||||
AdminFundAccountStatus,
|
||||
AdminFundAccountType,
|
||||
AdminFundEntry,
|
||||
AdminFundEntryType,
|
||||
AdminFundExpense,
|
||||
AdminFundExpenseCategory,
|
||||
AdminFundExpenseListResult,
|
||||
AdminFundMyAccountResult,
|
||||
AdminFundOverview,
|
||||
AdminFundPayeeType,
|
||||
AdminFundReconciliation,
|
||||
AdminFundReconciliationListResult,
|
||||
AdminFundReconciliationStatus,
|
||||
AdminFundSetupOptions,
|
||||
AdminFundSetupUser,
|
||||
} from './fund'
|
||||
|
||||
// Login logs
|
||||
export type { AdminLoginLogItem, AdminLoginLogListResult } from './login-logs'
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ export interface AdminLoginLogItem {
|
||||
logId: number
|
||||
userId: number | null
|
||||
username: string
|
||||
displayName: string
|
||||
role: string
|
||||
ip: string
|
||||
location: string
|
||||
|
||||
@@ -250,6 +250,7 @@ export interface AdminTaskDetail {
|
||||
completedBy: null | {
|
||||
userId: number
|
||||
username: string
|
||||
displayName: string
|
||||
role: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AdminRole, AdminUserStatus } from './auth'
|
||||
export interface AdminUserListItem {
|
||||
userId: number
|
||||
username: string
|
||||
displayName: string
|
||||
role: AdminRole
|
||||
status: AdminUserStatus
|
||||
createdAt: string
|
||||
|
||||
@@ -2,6 +2,7 @@ const ADMIN_TOKEN_KEY = 'order-site-admin-token'
|
||||
const ADMIN_EXPIRES_AT_KEY = 'order-site-admin-token-expires-at'
|
||||
const ADMIN_USER_ID_KEY = 'order-site-admin-user-id'
|
||||
const ADMIN_USERNAME_KEY = 'order-site-admin-username'
|
||||
const ADMIN_DISPLAY_NAME_KEY = 'order-site-admin-display-name'
|
||||
const ADMIN_ROLE_KEY = 'order-site-admin-role'
|
||||
const ADMIN_PERMISSIONS_KEY = 'order-site-admin-permissions'
|
||||
|
||||
@@ -29,6 +30,19 @@ export function getAdminUsername() {
|
||||
return localStorage.getItem(ADMIN_USERNAME_KEY) || ''
|
||||
}
|
||||
|
||||
export function getAdminDisplayName() {
|
||||
return localStorage.getItem(ADMIN_DISPLAY_NAME_KEY) || ''
|
||||
}
|
||||
|
||||
export function setAdminDisplayName(displayName: string) {
|
||||
const normalized = String(displayName || '').trim()
|
||||
if (normalized) {
|
||||
localStorage.setItem(ADMIN_DISPLAY_NAME_KEY, normalized)
|
||||
} else {
|
||||
localStorage.removeItem(ADMIN_DISPLAY_NAME_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
export function getAdminRole() {
|
||||
const role = localStorage.getItem(ADMIN_ROLE_KEY)
|
||||
if (role === 'admin' || role === 'operator' || role === 'support' || role === 'finance') {
|
||||
@@ -94,6 +108,7 @@ export function setAdminSession(
|
||||
user?: {
|
||||
userId: number
|
||||
username: string
|
||||
displayName?: string
|
||||
role: 'admin' | 'operator' | 'support' | 'finance'
|
||||
permissions?: string[]
|
||||
},
|
||||
@@ -109,6 +124,8 @@ export function setAdminSession(
|
||||
localStorage.setItem(ADMIN_USERNAME_KEY, user.username)
|
||||
}
|
||||
|
||||
setAdminDisplayName(user?.displayName || user?.username || '')
|
||||
|
||||
if (user?.role) {
|
||||
localStorage.setItem(ADMIN_ROLE_KEY, user.role)
|
||||
}
|
||||
@@ -121,6 +138,7 @@ export function clearAdminSession() {
|
||||
localStorage.removeItem(ADMIN_EXPIRES_AT_KEY)
|
||||
localStorage.removeItem(ADMIN_USER_ID_KEY)
|
||||
localStorage.removeItem(ADMIN_USERNAME_KEY)
|
||||
localStorage.removeItem(ADMIN_DISPLAY_NAME_KEY)
|
||||
localStorage.removeItem(ADMIN_ROLE_KEY)
|
||||
localStorage.removeItem(ADMIN_PERMISSIONS_KEY)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user