完善后台管理功能

This commit is contained in:
zhx 2026-07-11 02:26:23 +08:00
parent 9065382192
commit c206c72612
54 changed files with 10026 additions and 3399 deletions

File diff suppressed because it is too large Load Diff

View File

@ -334,7 +334,7 @@ test("routes databi social page to Social BI and loads real overview rows", asyn
expect(fetchSocialBiMaster).toHaveBeenCalledTimes(1);
expect(fetchSocialBiOverview).toHaveBeenCalledWith(expect.objectContaining({ statTz: "Asia/Shanghai" }));
expect(fetchSocialBiFunnel).toHaveBeenCalledWith(expect.objectContaining({ statTz: "Asia/Shanghai" }));
expect(fetchSocialBiFunnel).not.toHaveBeenCalled();
expect(fetchSocialBiKpi).toHaveBeenCalledWith(expect.objectContaining({ statTz: "Asia/Shanghai" }));
// App Hero 12,300 + 5,000 USD = $173
@ -433,7 +433,7 @@ test("renders social BI data requirements table with section filters and CSV exp
section: "new_users",
userRole: "all"
}));
expect(screen.getByRole("tab", { name: "P0新用户" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("tab", { name: "新用户" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByText("下载APP人数")).toBeTruthy();
expect(screen.getByText("注册成功率")).toBeTruthy();
expect(screen.getAllByText("12").length).toBeGreaterThan(0);
@ -442,7 +442,7 @@ test("renders social BI data requirements table with section filters and CSV exp
expect(screen.queryByRole("radiogroup", { name: "付费身份" })).toBeNull();
await act(async () => {
screen.getByRole("tab", { name: "P1营收" }).click();
screen.getByRole("tab", { name: "营收" }).click();
});
await flushEffects();
expect(screen.getByRole("radiogroup", { name: "用户身份" })).toBeTruthy();

View File

@ -180,7 +180,7 @@ export async function fetchSocialBiOverview({ appCodes, endMs, regionId, startMs
return fetchDatabiData(apiEndpointPath(API_OPERATIONS.getSocialBiOverview), { query });
}
export async function fetchSocialBiRequirements({ appCodes, endMs, payerType, regionId, regionIds, section, startMs, statTz, userRole }) {
export async function fetchSocialBiRequirements({ appCodes, endMs, newUserType, payerType, regionId, regionIds, section, startMs, statTz, userRole }) {
const query = {};
if (statTz) {
query.stat_tz = statTz;
@ -208,6 +208,9 @@ export async function fetchSocialBiRequirements({ appCodes, endMs, payerType, re
if (payerType) {
query.payer_type = payerType;
}
if (newUserType) {
query.new_user_type = newUserType;
}
return fetchDatabiData(apiEndpointPath(API_OPERATIONS.getSocialBiRequirements), { query });
}

View File

@ -53,7 +53,7 @@ export function SocialBiApp() {
const initial = useMemo(() => readStateFromURL(), []);
const [filters, setFilters] = useState(initial.filters);
const [viewKey, setViewKey] = useState(() => (VIEWS.some((view) => view.key === initial.view) ? initial.view : "overview"));
const data = useSocialBiData(filters);
const data = useSocialBiData(filters, { includeFunnel: viewKey === "funnel" });
useEffect(() => {
writeStateToURL(filters, viewKey);

View File

@ -249,6 +249,24 @@ export function aggregateMetricMaps(rows, { acrossTime = false } = {}) {
return out;
}
// averageMetricMaps跨天"平均值"行。可加字段取日均(总量/覆盖天数),余额快照保持末日快照,
// 比例/均值类分子分母同除天数后不变,直接沿用汇总行的派生值。
export function averageMetricMaps(rows, dayCount) {
if (!rows.length || !dayCount || dayCount <= 0) {
return null;
}
const out = aggregateMetricMaps(rows, { acrossTime: true });
ADDITIVE_METRIC_KEYS.forEach((key) => {
if (SNAPSHOT_METRIC_KEYS.has(key)) {
return;
}
if (out[key] !== null && out[key] !== undefined) {
out[key] = out[key] / dayCount;
}
});
return out;
}
// App 品牌色chips、图表系列、行标识统一取色保证同一 App 全站同色。
// 已知 App 用固定映射(与筛选状态无关);未知 App 按 code 哈希取色,避免"选中子集后颜色漂移"。
const APP_COLOR_PALETTE = ["#2563eb", "#0e9488", "#d97706", "#7c3aed", "#db2777", "#059669", "#dc2626", "#4f46e5"];

View File

@ -9,7 +9,7 @@ import { ALL, appSelectionMatches, regionSelectionMatches, resolveDateRange } fr
export const SOCIAL_BI_TZ = DEFAULT_TIME_ZONE;
export const SOCIAL_BI_FUNNEL_APP_CODES = ["lalu", "huwaa", "fami"];
export function useSocialBiData(filters) {
export function useSocialBiData(filters, { includeFunnel = false } = {}) {
const [master, setMaster] = useState(null);
// 数据请求等 master 结果落地后再发:否则会先用兜底 App 列表打一轮master 到达后立刻重打一轮(整轮浪费)。
const [isMasterSettled, setIsMasterSettled] = useState(false);
@ -78,9 +78,13 @@ export function useSocialBiData(filters) {
const startMs = rangeStartMs(range, SOCIAL_BI_TZ);
const endMs = rangeEndMs(range, SOCIAL_BI_TZ);
setIsLoading(true);
// 漏斗会执行 cohort 聚合,只在用户进入漏斗视图时请求;其他视图不能为不可见数据承担查询成本或错误提示。
const funnelRequest = includeFunnel
? fetchSocialBiFunnel({ appCodes: funnelAppCodes, endMs, startMs, statTz: SOCIAL_BI_TZ })
: Promise.resolve(null);
Promise.allSettled([
fetchSocialBiOverview({ appCodes, endMs, startMs, statTz: SOCIAL_BI_TZ }),
fetchSocialBiFunnel({ appCodes: funnelAppCodes, endMs, startMs, statTz: SOCIAL_BI_TZ }),
funnelRequest,
fetchSocialBiKpi({ appCodes, endMs, startMs, statTz: SOCIAL_BI_TZ })
]).then(([overviewResult, funnelResult, kpiResult]) => {
if (sequence !== requestSeq.current) {
@ -98,7 +102,9 @@ export function useSocialBiData(filters) {
setOverview(null);
nextErrors.push(`统计数据加载失败: ${overviewResult.reason?.message || "未知错误"}`);
}
if (funnelResult.status === "fulfilled") {
if (!includeFunnel) {
setFunnel(null);
} else if (funnelResult.status === "fulfilled") {
setFunnel(funnelResult.value);
(funnelResult.value?.apps || []).forEach((app) => {
if (app.error) {
@ -121,10 +127,10 @@ export function useSocialBiData(filters) {
setErrors(nextErrors);
setIsLoading(false);
});
}, [appCodes, funnelAppCodes, isMasterSettled, range, refreshToken]);
}, [appCodes, funnelAppCodes, includeFunnel, isMasterSettled, range, refreshToken]);
const loadRequirements = useCallback(
({ payerType, section, userRole } = {}) => {
({ newUserType, payerType, section, userRole } = {}) => {
if (!isMasterSettled) {
return Promise.resolve(null);
}
@ -145,6 +151,7 @@ export function useSocialBiData(filters) {
return Promise.all(requestScopes.map((scope) => fetchSocialBiRequirements({
...scope,
endMs,
newUserType,
payerType,
section,
startMs,

View File

@ -8,6 +8,7 @@ import { useSocialBi } from "../SocialBiApp.jsx";
import {
METRIC_GROUPS,
aggregateMetricMaps,
averageMetricMaps,
formatMetric,
metricChartValue,
metricLabel,
@ -54,7 +55,7 @@ const REQUIREMENT_SECTIONS = [
{ key: "first_room_join_users", label: "首次进房", type: "count" }
],
key: "new_users",
label: "P0新用户"
label: "新用户"
},
{
csvName: "p1-revenue",
@ -66,21 +67,23 @@ const REQUIREMENT_SECTIONS = [
{ key: "arppu_usd_minor", label: "ARPPU", type: "money_minor" }
],
key: "revenue",
label: "P1营收",
label: "营收",
newUserFilter: true,
payerFilter: true,
roleFilter: true
},
{
csvName: "p2-retention",
fallbackColumns: [
{ key: "cohort_users", label: "留存基数", type: "count" },
{ key: "d1_retention_users", label: "次留用户", type: "count" },
{ key: "d1_retention_rate", label: "次留", type: "ratio" },
{ key: "d7_retention_rate", label: "7日留", type: "ratio" },
{ key: "d30_retention_rate", label: "30日留", type: "ratio" }
{ key: "active_d1_active_base_users", label: "活跃用户基数", type: "count" },
{ key: "active_d1_active_users", label: "次留人数", type: "count" },
{ key: "active_d1_active_rate", label: "用户次留", type: "ratio" },
{ key: "active_d7_active_rate", label: "用户7日留", type: "ratio" },
{ key: "active_d30_active_rate", label: "用户30日留", type: "ratio" }
],
key: "retention",
label: "P2留存",
label: "留存",
newUserFilter: true,
payerFilter: true,
roleFilter: true
},
@ -94,7 +97,8 @@ const REQUIREMENT_SECTIONS = [
{ key: "avg_session_ms", label: "人均在线", type: "duration_ms" }
],
key: "active",
label: "P2活跃",
label: "活跃",
newUserFilter: true,
payerFilter: true,
roleFilter: true
},
@ -107,7 +111,8 @@ const REQUIREMENT_SECTIONS = [
{ key: "gift_paid_users", label: "付费送礼用户", type: "count" }
],
key: "gift",
label: "P4送礼",
label: "送礼",
newUserFilter: true,
roleFilter: true
},
{
@ -120,7 +125,8 @@ const REQUIREMENT_SECTIONS = [
{ key: "game_profit_rate", label: "游戏利润率", type: "ratio" }
],
key: "game",
label: "P5游戏",
label: "游戏",
newUserFilter: true,
roleFilter: true
}
];
@ -137,6 +143,23 @@ const PAYER_TYPE_OPTIONS = [
{ label: "未付费", value: "unpaid" }
];
// = =
const NEW_USER_OPTIONS = [
{ label: "全部用户", value: "all" },
{ label: "新用户", value: "new" },
{ label: "非新用户", value: "old" }
];
// P5 game_columns
const FALLBACK_GAME_COLUMNS = [
{ key: "game_bet_users", label: "押注用户数", type: "count" },
{ key: "game_d1_retention_rate", label: "游戏次留", type: "ratio" },
{ key: "game_arppu_coin", label: "ARPPU(金币)", type: "coin" },
{ key: "game_bet_coin", label: "押注金币", type: "coin" }
];
const GAME_SOURCE_LABELS = { self: "自研", vendor: "厂商" };
function readClosedGroups() {
try {
const raw = window.localStorage.getItem(GROUPS_STORAGE_KEY);
@ -236,6 +259,18 @@ function normalizedRequirementColumns(sectionItem, fallbackColumns) {
return columns.length ? columns : fallbackColumns;
}
// ""
function sortRequirementDailyRows(rows) {
return [...rows].sort((a, b) => {
const left = String(a?.stat_day || "");
const right = String(b?.stat_day || "");
if (left === right) {
return 0;
}
return left < right ? 1 : -1;
});
}
function normalizeRequirementPayload(payload, sectionKey) {
const fallbackColumns = requirementSectionConfig(sectionKey).fallbackColumns;
const appSections = asArray(payload?.apps).flatMap((app) => {
@ -251,21 +286,27 @@ function normalizeRequirementPayload(payload, sectionKey) {
if (appSections.length === 1) {
const { context, section } = appSections[0];
return {
average: flattenRequirementRow(section.average, context),
columns,
rows: asArray(section.daily_rows || section.dailyRows || section.daily_series || section.rows || section.items)
.map((row) => flattenRequirementRow(row, context))
.filter(Boolean),
rows: sortRequirementDailyRows(
asArray(section.daily_rows || section.dailyRows || section.daily_series || section.rows || section.items)
.map((row) => flattenRequirementRow(row, context))
.filter(Boolean)
),
total: flattenRequirementRow(section.total || section.totals || section.summary, context)
};
}
const rows = appSections.flatMap(({ context, section }) => {
const total = flattenRequirementRow(section.total || section.totals || section.summary, { ...context, stat_day: "汇总" });
const dailyRows = asArray(section.daily_rows || section.dailyRows || section.daily_series || section.rows || section.items)
.map((row) => flattenRequirementRow(row, context))
.filter(Boolean);
return total ? [total, ...dailyRows] : dailyRows;
const average = flattenRequirementRow(section.average, { ...context, stat_day: "平均值" });
const dailyRows = sortRequirementDailyRows(
asArray(section.daily_rows || section.dailyRows || section.daily_series || section.rows || section.items)
.map((row) => flattenRequirementRow(row, context))
.filter(Boolean)
);
return [total, average, ...dailyRows].filter(Boolean);
});
return { columns, rows, total: null };
return { average: null, columns, rows, total: null };
}
const sectionItem = asArray(payload?.sections).find((item) => sectionMatches(item, sectionKey))
@ -273,17 +314,64 @@ function normalizeRequirementPayload(payload, sectionKey) {
|| payload
|| {};
const columns = normalizedRequirementColumns(sectionItem, fallbackColumns);
const rows = asArray(sectionItem.daily_rows || sectionItem.dailyRows || sectionItem.daily_series || sectionItem.rows || sectionItem.items)
.map((row) => flattenRequirementRow(row))
.filter(Boolean);
const rows = sortRequirementDailyRows(
asArray(sectionItem.daily_rows || sectionItem.dailyRows || sectionItem.daily_series || sectionItem.rows || sectionItem.items)
.map((row) => flattenRequirementRow(row))
.filter(Boolean)
);
const total = flattenRequirementRow(sectionItem.total || sectionItem.totals || sectionItem.summary);
return {
average: flattenRequirementRow(sectionItem.average),
columns,
rows,
total
};
}
// P5 [ + ()] app/game game_columns
// //
function normalizeRequirementGamePayload(payload) {
const appSections = asArray(payload?.apps).flatMap((app) => {
const context = requirementAppContext(app);
return asArray(app?.sections)
.filter((section) => sectionMatches(section, "game"))
.map((section) => ({ context, section }));
});
const sections = appSections.length
? appSections
: asArray(payload?.sections)
.filter((section) => sectionMatches(section, "game"))
.map((section) => ({ context: {}, section }));
const columns =
sections
.map(({ section }) => asArray(section.game_columns).map(normalizeRequirementColumn).filter(Boolean))
.find((cols) => cols.length) || FALLBACK_GAME_COLUMNS;
const rows = sections.flatMap(({ context, section }) =>
asArray(section.games).flatMap((game) => {
const gameContext = {
...context,
game_key: game.game_key || "",
game_name: game.game_name || game.game_key || "--",
game_source: game.game_source || ""
};
const total = flattenRequirementRow(game.total, { ...gameContext, stat_day: "汇总" });
const daily = sortRequirementDailyRows(
asArray(game.daily_series)
.map((row) => flattenRequirementRow(row, gameContext))
.filter(Boolean)
);
return total ? [total, ...daily] : daily;
})
);
return { columns, rows };
}
function requirementGameLabel(row) {
const source = GAME_SOURCE_LABELS[String(row?.game_source || "")] || "";
const name = row?.game_name || row?.game_key || "--";
return source ? `${source}·${name}` : name;
}
function inferRequirementType(column) {
const type = String(column.type || "").toLowerCase();
const key = String(column.key || "").toLowerCase();
@ -302,13 +390,17 @@ function inferRequirementType(column) {
return type || "number";
}
function truncateTwoDecimals(value) {
// 2 2
function truncateTwoDecimals(value, { pad = false } = {}) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return "--";
}
const truncated = Math.trunc(numeric * 100) / 100;
return truncated.toLocaleString("en-US", { maximumFractionDigits: 2 });
return truncated.toLocaleString("en-US", {
maximumFractionDigits: 2,
minimumFractionDigits: pad ? 2 : 0
});
}
function formatRequirementRatio(value) {
@ -316,7 +408,7 @@ function formatRequirementRatio(value) {
if (!Number.isFinite(numeric)) {
return "--";
}
return `${truncateTwoDecimals(numeric * 100)}%`;
return `${truncateTwoDecimals(numeric * 100, { pad: true })}%`;
}
function formatRequirementValue(column, value) {
@ -325,7 +417,11 @@ function formatRequirementValue(column, value) {
}
const type = inferRequirementType(column);
if (type === "money_minor") {
return formatMoneyMinor(value);
const dollars = Number(value) / 100;
if (!Number.isFinite(dollars)) {
return "--";
}
return Number.isInteger(dollars) ? formatMoneyMinor(value) : `$${truncateTwoDecimals(dollars, { pad: true })}`;
}
if (type === "ratio") {
return formatRequirementRatio(value);
@ -334,11 +430,16 @@ function formatRequirementValue(column, value) {
return formatDurationMs(value);
}
if (type === "coin" || type === "count" || type === "integer") {
const numeric = Number(value);
// /"2"
if (Number.isFinite(numeric) && !Number.isInteger(numeric)) {
return truncateTwoDecimals(numeric, { pad: true });
}
return formatCount(value);
}
const numeric = Number(value);
if (Number.isFinite(numeric)) {
return Number.isInteger(numeric) ? formatCount(numeric) : truncateTwoDecimals(numeric);
return Number.isInteger(numeric) ? formatCount(numeric) : truncateTwoDecimals(numeric, { pad: true });
}
return String(value);
}
@ -384,6 +485,7 @@ export function DataTableView() {
const [requirementSection, setRequirementSection] = useState(REQUIREMENT_SECTIONS[0].key);
const [requirementRole, setRequirementRole] = useState("all");
const [requirementPayer, setRequirementPayer] = useState("all");
const [requirementNewUser, setRequirementNewUser] = useState("all");
const dim = DIMENSIONS.find((item) => item.key === dimKey) || DIMENSIONS[0];
const dimColumns = dim.dims.map((name) => DIM_COLUMNS[name]);
@ -392,10 +494,16 @@ export function DataTableView() {
const activeRequirementSection = requirementSectionConfig(requirementSection);
const effectiveRequirementRole = activeRequirementSection.roleFilter ? requirementRole : "all";
const effectiveRequirementPayer = activeRequirementSection.payerFilter ? requirementPayer : "all";
const effectiveRequirementNewUser = activeRequirementSection.newUserFilter ? requirementNewUser : "all";
const requirementData = useMemo(() => normalizeRequirementPayload(requirements, requirementSection), [requirements, requirementSection]);
const requirementColumns = requirementData.columns;
const requirementRows = requirementData.rows;
const requirementTotal = requirementData.total;
const requirementAverage = requirementData.average;
const requirementGameData = useMemo(
() => (requirementSection === "game" ? normalizeRequirementGamePayload(requirements) : { columns: [], rows: [] }),
[requirements, requirementSection]
);
const visibleGroups = useMemo(() => METRIC_GROUPS.filter((group) => !closedGroups.includes(group.key)), [closedGroups]);
const visibleMetrics = useMemo(() => visibleGroups.flatMap((group) => group.metrics), [visibleGroups]);
@ -421,6 +529,9 @@ export function DataTableView() {
// /
const totalRow = sortedRows.length ? aggregateMetricMaps(sortedRows, { acrossTime: hasDay }) : null;
//
const wideDayCount = hasDay ? new Set(sortedRows.map((row) => String(row.stat_day || "")).filter(Boolean)).size : 0;
const averageRow = totalRow && hasDay && wideDayCount > 0 ? averageMetricMaps(sortedRows, wideDayCount) : null;
const renderRows = sortedRows.slice(0, MAX_RENDER_ROWS);
const isTruncated = sortedRows.length > MAX_RENDER_ROWS;
const hasRestricted = baseRows.some((row) => row.restricted);
@ -431,11 +542,12 @@ export function DataTableView() {
return;
}
loadRequirements({
newUserType: effectiveRequirementNewUser,
payerType: effectiveRequirementPayer,
section: requirementSection,
userRole: effectiveRequirementRole
});
}, [effectiveRequirementPayer, effectiveRequirementRole, loadRequirements, mode, refreshToken, requirementSection]);
}, [effectiveRequirementNewUser, effectiveRequirementPayer, effectiveRequirementRole, loadRequirements, mode, refreshToken, requirementSection]);
const handleDimChange = (key) => {
setDimKey(key);
@ -451,6 +563,9 @@ export function DataTableView() {
if (!nextSection.payerFilter) {
setRequirementPayer("all");
}
if (!nextSection.newUserFilter) {
setRequirementNewUser("all");
}
};
const toggleGroup = (key) => {
@ -480,6 +595,7 @@ export function DataTableView() {
const header = ["日期", "App", ...requirementColumns.map((column) => column.label)];
const rows = [
...(requirementTotal ? [{ ...requirementTotal, app_name: "全部", stat_day: "汇总" }] : []),
...(requirementAverage ? [{ ...requirementAverage, app_name: "全部", stat_day: "平均值" }] : []),
...requirementRows
].map((row) => [
row.stat_day || "--",
@ -489,6 +605,17 @@ export function DataTableView() {
downloadCsv(`social-bi-requirements-${activeRequirementSection.csvName}-${rangeLabel(range).replace(/\s+/g, "")}.csv`, header, rows);
};
const handleGameBreakdownExport = () => {
const header = ["游戏", "日期", "App", ...requirementGameData.columns.map((column) => column.label)];
const rows = requirementGameData.rows.map((row) => [
requirementGameLabel(row),
row.stat_day || "--",
requirementAppName(row),
...requirementGameData.columns.map((column) => csvRequirementValue(column, row[column.key]))
]);
downloadCsv(`social-bi-game-breakdown-${rangeLabel(range).replace(/\s+/g, "")}.csv`, header, rows);
};
let wideContent;
if (isLoading && !baseRows.length) {
wideContent = (
@ -559,6 +686,18 @@ export function DataTableView() {
))}
</tr>
) : null}
{averageRow ? (
<tr className="is-total">
{dimColumns.map((column, index) => (
<td className={index === 0 ? "is-left sbi-dt-sticky" : "is-left"} key={column.label}>
{index === 0 ? "平均值" : column.totalText}
</td>
))}
{visibleMetrics.map((key) => (
<td key={key}>{formatMetric(key, averageRow[key])}</td>
))}
</tr>
) : null}
{renderRows.map((row, rowIndex) => (
<tr key={rowIdentity(row, dim.key, rowIndex)}>
{dimColumns.map((column, index) => (
@ -607,6 +746,7 @@ export function DataTableView() {
);
} else {
requirementsContent = (
<>
<div className="sbi-table-scroll sbi-dt-scroll">
<table className="sbi-table sbi-dt-req-table">
<thead>
@ -632,6 +772,15 @@ export function DataTableView() {
))}
</tr>
) : null}
{requirementAverage ? (
<tr className="is-total">
<td className="is-left sbi-dt-sticky">平均值</td>
<td className="is-left">全部</td>
{requirementColumns.map((column) => (
<td key={column.key}>{formatRequirementValue(column, requirementAverage[column.key])}</td>
))}
</tr>
) : null}
{requirementRows.map((row, rowIndex) => (
<tr key={requirementRowIdentity(row, activeRequirementSection.key, rowIndex)}>
<td className="is-left sbi-dt-sticky">{row.stat_day || "--"}</td>
@ -644,6 +793,48 @@ export function DataTableView() {
</tbody>
</table>
</div>
{requirementGameData.rows.length ? (
<>
<div className="sbi-card-header">
<strong>各游戏明细</strong>
<small>押注用户/同游戏次留/ARPPU · 不受身份与新用户筛选影响</small>
</div>
<div className="sbi-table-scroll sbi-dt-scroll">
<table className="sbi-table sbi-dt-req-table">
<thead>
<tr>
<th className="is-left sbi-dt-sticky">游戏</th>
<th className="is-left">日期</th>
<th className="is-left">App</th>
{requirementGameData.columns.map((column) => (
<th key={column.key}>
<span className={column.tooltip ? "sbi-metric-hint" : ""} title={column.tooltip || undefined}>
{column.label}
</span>
</th>
))}
</tr>
</thead>
<tbody>
{requirementGameData.rows.map((row, rowIndex) => (
<tr
className={row.stat_day === "汇总" ? "is-total" : undefined}
key={`${row.app_code || ""}|${row.game_key || rowIndex}|${row.stat_day || rowIndex}`}
>
<td className="is-left sbi-dt-sticky">{requirementGameLabel(row)}</td>
<td className="is-left">{row.stat_day || "--"}</td>
<td className="is-left">{requirementAppName(row)}</td>
{requirementGameData.columns.map((column) => (
<td key={column.key}>{formatRequirementValue(column, row[column.key])}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</>
) : null}
</>
);
}
@ -746,9 +937,28 @@ export function DataTableView() {
onChange={setRequirementPayer}
/>
) : null}
{activeRequirementSection.newUserFilter ? (
<RequirementFilterGroup
ariaLabel="是否新用户"
label="是否新用户"
options={NEW_USER_OPTIONS}
value={effectiveRequirementNewUser}
onChange={setRequirementNewUser}
/>
) : null}
</>
)}
<div className="sbi-dt-export">
{mode === "requirements" && requirementGameData.rows.length ? (
<Button
onClick={handleGameBreakdownExport}
size="small"
startIcon={<FileDownloadOutlined />}
variant="outlined"
>
导出游戏明细
</Button>
) : null}
<Button
disabled={exportDisabled}
onClick={mode === "requirements" ? handleRequirementsExport : handleExport}

View File

@ -467,15 +467,8 @@ function coinText(value) {
}
function billAmountText(item) {
if (item.billAmountText) {
return item.billAmountText;
}
const amount = hasNumber(item.billAmountMinor)
? item.billAmountMinor
: hasPositiveNumber(item.providerAmountMinor)
? item.providerAmountMinor
: item.usdMinorAmount;
return formatUsdMinor(amount, item.currencyCode || "USD");
// USDT currencyCode/providerAmountMinor
return formatUsdMinor(item.usdMinorAmount, "USDT");
}
function userPaidText(item) {

View File

@ -76,10 +76,41 @@ test("renders app recharge coin columns and values when coin currency is selecte
expect(screen.getAllByText("谷歌充值").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("tx-google")).toBeInTheDocument();
expect(screen.getByText("cmd-1 / GPA.1")).toBeInTheDocument();
expect(screen.getAllByText("SAR 12.99")).toHaveLength(2);
expect(screen.getByText("USDT 9.99")).toBeInTheDocument();
expect(screen.getByText("SAR 12.99")).toBeInTheDocument();
expect(screen.getByText("SAR 1.95 / 15%")).toBeInTheDocument();
});
test("keeps bill amount in USDT while rendering third-party paid amount in local currency", () => {
render(
<FinanceRechargeDetailList
{...baseProps}
bills={{
...baseProps.bills,
items: [
{
...rechargeBillFixture(),
currencyCode: "EGP",
id: "tx-mifapay",
providerAmountMinor: 472600,
rechargeType: "third_party_recharge",
transactionId: "tx-mifapay",
usdMinorAmount: 10000,
userPaidAmountMicro: 4726000000,
userPaidCurrencyCode: "EGP",
userPaidText: "",
},
],
total: 1,
}}
/>,
);
expect(screen.getByText("USDT 100.00")).toBeInTheDocument();
expect(screen.getByText("EGP 4,726.00")).toBeInTheDocument();
expect(screen.queryByText("EGP 100.00")).not.toBeInTheDocument();
});
test("keeps empty recharge detail table colspan aligned with visible columns", () => {
const { container } = render(<FinanceRechargeDetailList {...baseProps} />);

View File

@ -113,6 +113,8 @@ export const PERMISSIONS = {
appUserUpdate: "app-user:update",
appUserStatus: "app-user:status",
appUserPassword: "app-user:password",
appUserLevel: "app-user:level",
appUserExport: "app-user:export",
prettyIdView: "pretty-id:view",
prettyIdUpdate: "pretty-id:update",
prettyIdGenerate: "pretty-id:generate",

View File

@ -1,6 +1,15 @@
import { afterEach, expect, test, vi } from "vitest";
import { setAccessToken } from "@/shared/api/request";
import { getAppUser, listAppUsers, listPrettyDisplayIDs, recyclePrettyDisplayID } from "./api";
import {
adjustAppUserLevels,
banAppUser,
createAppUserExport,
getAppUser,
listAppUsers,
listPrettyDisplayIDs,
recyclePrettyDisplayID,
unbanAppUser,
} from "./api";
afterEach(() => {
setAccessToken("");
@ -351,3 +360,147 @@ test("getAppUser uses detail endpoint and normalizes assets", async () => {
resourceCode: "frame_gold",
});
});
test("app user projection normalizes levels roles ban and successful login fields", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(
JSON.stringify({
code: 0,
data: {
age: 26,
ban: {
active: true,
expires_at_ms: 1790000000000,
id: "ban-1",
permanent: false,
reason: "risk",
source: "admin",
},
birth: "2000-02-29",
cumulative_recharge_usd_minor: 12345,
last_login_at_ms: 1780000000000,
last_operated_at_ms: 1781000000000,
last_operator: { action: "ban", admin_id: "7", name: "运营" },
levels: {
wealth: {
display_level: 20,
expires_at_ms: 1790000000000,
real_level: 8,
temporary_level_id: "temporary-1",
temporary_target_level: 20,
track: "wealth",
},
},
register_device: "iPhone17,2",
roles: ["主播", "BD"],
user_id: "10001",
},
}),
),
),
);
const result = await getAppUser("10001");
expect(result).toMatchObject({
age: 26,
ban: { active: true, expiresAtMs: 1790000000000, id: "ban-1", permanent: false },
birth: "2000-02-29",
cumulativeRechargeUsdMinor: 12345,
lastLoginAtMs: 1780000000000,
lastOperatedAtMs: 1781000000000,
lastOperator: { action: "ban", adminId: "7", name: "运营" },
levels: {
wealth: expect.objectContaining({
displayLevel: 20,
realLevel: 8,
temporaryLevelId: "temporary-1",
temporaryTargetLevel: 20,
}),
},
registerDevice: "iPhone17,2",
roles: ["主播", "BD"],
});
});
test("temporary level adjustment sends wealth and charm in one request", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(JSON.stringify({ code: 0, data: { user_id: "10001" } }))),
);
const payload = {
adjustments: [
{ duration_days: 7, level: 20, track: "wealth" as const },
{ duration_days: 3, level: 18, track: "charm" as const },
],
reason: "campaign",
};
const result = await adjustAppUserLevels("10001", payload);
const [url, init] = vi.mocked(fetch).mock.calls[0];
expect(String(url)).toContain("/api/v1/app/users/10001/levels");
expect(init?.method).toBe("PUT");
expect(JSON.parse(String(init?.body))).toEqual(payload);
expect(result.userId).toBe("10001");
});
test("ban and unban normalize the status mutation user wrapper", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(
JSON.stringify({
code: 0,
data: {
user: {
ban: { active: true, expires_at_ms: 1790000000000, permanent: false },
status: "banned",
user_id: "10001",
},
},
}),
),
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({ code: 0, data: { user: { ban: { active: false }, status: "active", user_id: "10001" } } }),
),
);
vi.stubGlobal("fetch", fetchMock);
const banned = await banAppUser("10001", { expires_at_ms: 1790000000000, reason: "risk" });
const unbanned = await unbanAppUser("10001", { reason: "cleared" });
expect(banned).toMatchObject({ ban: { active: true, expiresAtMs: 1790000000000 }, status: "banned" });
expect(unbanned).toMatchObject({ ban: { active: false }, status: "active" });
expect(JSON.parse(String(fetchMock.mock.calls[0][1]?.body))).toEqual({
expires_at_ms: 1790000000000,
reason: "risk",
});
expect(JSON.parse(String(fetchMock.mock.calls[1][1]?.body))).toEqual({ reason: "cleared" });
});
test("app user export creates a task from the current filter snapshot", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(
JSON.stringify({ code: 0, data: { jobId: 77, snapshotAtMs: 1781000000000, status: "pending" } }),
),
),
);
const result = await createAppUserExport({ country: "AE", status: "banned" });
const [url, init] = vi.mocked(fetch).mock.calls[0];
expect(String(url)).toContain("/api/v1/exports/app-users?");
expect(String(url)).toContain("country=AE");
expect(String(url)).toContain("status=banned");
expect(init?.method).toBe("POST");
expect(result).toEqual({ jobId: 77, snapshotAtMs: 1781000000000, status: "pending" });
});

View File

@ -1,11 +1,16 @@
import { apiRequest } from "@/shared/api/request";
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
import type {
AdminJobDto,
ApiPage,
AppUserBanPayload,
AppUserBanRecordDto,
AppUserExportJobDto,
AppUserLevelAdjustmentPayload,
AppUserLoginLogDto,
AppUserDto,
AppUserPasswordPayload,
AppUserUnbanPayload,
AppUserUpdatePayload,
AdminGrantPrettyDisplayIDResultDto,
EntityId,
@ -76,20 +81,54 @@ export async function updateAppUser(userId: EntityId, payload: AppUserUpdatePayl
return normalizeAppUser(data);
}
export async function banAppUser(userId: EntityId): Promise<AppUserDto> {
const endpoint = API_ENDPOINTS.appBanUser;
const data = await apiRequest<RawAppUser>(apiEndpointPath(API_OPERATIONS.appBanUser, { id: userId }), {
method: endpoint.method,
});
export async function adjustAppUserLevels(
userId: EntityId,
payload: AppUserLevelAdjustmentPayload,
): Promise<AppUserDto> {
const endpoint = API_ENDPOINTS.appAdjustUserLevels;
const data = await apiRequest<RawAppUser, AppUserLevelAdjustmentPayload>(
apiEndpointPath(API_OPERATIONS.appAdjustUserLevels, { id: userId }),
{ body: payload, method: endpoint.method },
);
return normalizeAppUser(data);
}
export async function unbanAppUser(userId: EntityId): Promise<AppUserDto> {
export async function banAppUser(userId: EntityId, payload: AppUserBanPayload): Promise<AppUserDto> {
const endpoint = API_ENDPOINTS.appBanUser;
const data = await apiRequest<RawAppUserStatusMutation, AppUserBanPayload>(
apiEndpointPath(API_OPERATIONS.appBanUser, { id: userId }),
{ body: payload, method: endpoint.method },
);
// 新接口返回 SetUserStatusResult兼容旧环境直接返回 AppUser避免滚动发布期间列表补丁丢失 userId。
return normalizeAppUser(data.user ?? data);
}
export async function unbanAppUser(userId: EntityId, payload: AppUserUnbanPayload = {}): Promise<AppUserDto> {
const endpoint = API_ENDPOINTS.appUnbanUser;
const data = await apiRequest<RawAppUser>(apiEndpointPath(API_OPERATIONS.appUnbanUser, { id: userId }), {
const data = await apiRequest<RawAppUserStatusMutation, AppUserUnbanPayload>(
apiEndpointPath(API_OPERATIONS.appUnbanUser, { id: userId }),
{ body: payload, method: endpoint.method },
);
return normalizeAppUser(data.user ?? data);
}
export function createAppUserExport(query: PageQuery = {}): Promise<AppUserExportJobDto> {
const endpoint = API_ENDPOINTS.appExportUsers;
return apiRequest<AppUserExportJobDto>(apiEndpointPath(API_OPERATIONS.appExportUsers), {
method: endpoint.method,
query,
});
}
export function getAppUserExportJob(jobId: EntityId): Promise<AdminJobDto> {
const endpoint = API_ENDPOINTS.getJob;
return apiRequest<AdminJobDto>(apiEndpointPath(API_OPERATIONS.getJob, { id: jobId }), {
method: endpoint.method,
});
return normalizeAppUser(data);
}
export function downloadAppUserExport(jobId: EntityId): Promise<Response> {
return apiRequest(apiEndpointPath(API_OPERATIONS.downloadJobArtifact, { id: jobId }), { raw: true });
}
export function setAppUserPassword(
@ -213,8 +252,11 @@ export async function setPrettyDisplayIDStatus(
// 用户相关接口历史上既返回过 snake_case也返回过 camelCase在 API 边界归一化后,共享用户组件只消费稳定字段。
interface RawAppUser {
age?: number | null;
avatar?: string;
balances?: RawAppUserAssetBalance[];
ban?: RawAppUserBan;
birth?: string;
coin?: number;
country?: string;
country_display_name?: string;
@ -233,6 +275,13 @@ interface RawAppUser {
gender?: string;
last_active_at_ms?: number;
lastActiveAtMs?: number;
last_login_at_ms?: number;
lastLoginAtMs?: number;
last_operated_at_ms?: number;
lastOperatedAtMs?: number;
last_operator?: RawAppUserOperator;
lastOperator?: RawAppUserOperator;
levels?: RawAppUserLevels;
pretty_display_user_id?: string;
prettyDisplayUserId?: string;
pretty_id?: string;
@ -241,7 +290,10 @@ interface RawAppUser {
regionId?: number;
region_name?: string;
regionName?: string;
register_device?: string;
registerDevice?: string;
resources?: RawAppUserResource[];
roles?: string[];
status?: string;
updated_at_ms?: number;
updatedAtMs?: number;
@ -249,6 +301,55 @@ interface RawAppUser {
userId?: EntityId;
username?: string;
vip?: RawAppUserVIP;
cumulative_recharge_usd_minor?: number;
cumulativeRechargeUsdMinor?: number;
}
interface RawAppUserStatusMutation extends RawAppUser {
user?: RawAppUser;
}
interface RawAppUserLevel {
display_level?: number;
displayLevel?: number;
display_value?: number;
displayValue?: number;
expires_at_ms?: number;
expiresAtMs?: number;
real_level?: number;
realLevel?: number;
real_total_value?: number;
realTotalValue?: number;
started_at_ms?: number;
startedAtMs?: number;
temporary_level_id?: string;
temporaryLevelId?: string;
temporary_target_level?: number;
temporaryTargetLevel?: number;
track?: string;
}
interface RawAppUserLevels {
charm?: RawAppUserLevel;
game?: RawAppUserLevel;
wealth?: RawAppUserLevel;
}
interface RawAppUserBan {
active?: boolean;
expires_at_ms?: number;
expiresAtMs?: number;
id?: string;
permanent?: boolean;
reason?: string;
source?: string;
}
interface RawAppUserOperator {
action?: string;
admin_id?: EntityId;
adminId?: EntityId;
name?: string;
}
type RawAppUserBrief = RawAppUser;
@ -503,8 +604,11 @@ function normalizePage<TRaw, TItem>(
function normalizeAppUser(item: RawAppUser = {}): AppUserDto {
return {
age: optionalNumberValue(item.age),
avatar: stringValue(item.avatar),
balances: (item.balances || []).map(normalizeAppUserAssetBalance),
ban: normalizeAppUserBan(item.ban),
birth: stringValue(item.birth),
coin: numberValue(item.coin),
country: stringValue(item.country),
countryDisplayName: stringValue(item.countryDisplayName ?? item.country_display_name),
@ -516,16 +620,69 @@ function normalizeAppUser(item: RawAppUser = {}): AppUserDto {
equippedResources: (item.equippedResources ?? item.equipped_resources ?? []).map(normalizeAppUserResource),
gender: stringValue(item.gender),
lastActiveAtMs: numberValue(item.lastActiveAtMs ?? item.last_active_at_ms),
lastLoginAtMs: numberValue(item.lastLoginAtMs ?? item.last_login_at_ms),
lastOperatedAtMs: numberValue(item.lastOperatedAtMs ?? item.last_operated_at_ms),
lastOperator: normalizeAppUserOperator(item.lastOperator ?? item.last_operator),
levels: normalizeAppUserLevels(item.levels),
prettyDisplayUserId: stringValue(item.prettyDisplayUserId ?? item.pretty_display_user_id),
prettyId: stringValue(item.prettyId ?? item.pretty_id),
regionId: numberValue(item.regionId ?? item.region_id),
regionName: stringValue(item.regionName ?? item.region_name),
registerDevice: stringValue(item.registerDevice ?? item.register_device),
resources: (item.resources || []).map(normalizeAppUserResource),
roles: Array.isArray(item.roles) ? item.roles.map(stringValue).filter(Boolean) : [],
status: stringValue(item.status),
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
userId: stringValue(item.userId ?? item.user_id),
username: stringValue(item.username),
vip: normalizeAppUserVIP(item.vip),
cumulativeRechargeUsdMinor: numberValue(
item.cumulativeRechargeUsdMinor ?? item.cumulative_recharge_usd_minor,
),
};
}
function normalizeAppUserLevels(item: RawAppUserLevels = {}) {
return {
charm: normalizeAppUserLevel(item.charm, "charm"),
game: normalizeAppUserLevel(item.game, "game"),
wealth: normalizeAppUserLevel(item.wealth, "wealth"),
};
}
function normalizeAppUserLevel(item: RawAppUserLevel = {}, fallbackTrack: string) {
return {
displayLevel: numberValue(item.displayLevel ?? item.display_level),
displayValue: numberValue(item.displayValue ?? item.display_value),
expiresAtMs: numberValue(item.expiresAtMs ?? item.expires_at_ms),
realLevel: numberValue(item.realLevel ?? item.real_level),
realTotalValue: numberValue(item.realTotalValue ?? item.real_total_value),
startedAtMs: numberValue(item.startedAtMs ?? item.started_at_ms),
temporaryLevelId: stringValue(item.temporaryLevelId ?? item.temporary_level_id),
temporaryTargetLevel: numberValue(item.temporaryTargetLevel ?? item.temporary_target_level),
track: stringValue(item.track || fallbackTrack),
};
}
function normalizeAppUserBan(item: RawAppUserBan = {}) {
return {
active: Boolean(item.active),
expiresAtMs: numberValue(item.expiresAtMs ?? item.expires_at_ms),
id: stringValue(item.id),
permanent: Boolean(item.permanent),
reason: stringValue(item.reason),
source: stringValue(item.source),
};
}
function normalizeAppUserOperator(item?: RawAppUserOperator) {
if (!item) {
return undefined;
}
return {
action: stringValue(item.action),
adminId: stringValue(item.adminId ?? item.admin_id),
name: stringValue(item.name),
};
}
@ -699,6 +856,14 @@ function numberValue(value: unknown): number {
return Number.isFinite(parsed) ? parsed : 0;
}
function optionalNumberValue(value: unknown): number | undefined {
if (value === undefined || value === null || value === "") {
return undefined;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
// 靓号明细包含池号和后台发放两类来源pool 可能为空,归一化后页面只判断 source/status 即可。
function normalizePrettyDisplayID(item: RawPrettyDisplayID): PrettyDisplayIDDto {
const createdByAdminId = item.createdByAdminId ?? item.created_by_admin_id ?? "";

View File

@ -413,6 +413,28 @@
line-height: 1.3;
}
.levelPrimary {
color: var(--text-primary);
font-weight: 800;
white-space: nowrap;
}
.rolesValue {
display: -webkit-box;
overflow: hidden;
color: var(--text-primary);
font-weight: 650;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.banCountdown {
color: var(--danger);
font-size: 12px;
font-weight: 750;
line-height: 1.3;
}
.detailHero {
display: flex;
min-width: 0;
@ -669,6 +691,20 @@
gap: var(--space-3);
}
.levelForm {
display: grid;
gap: var(--space-3);
}
.levelTrackForm {
display: grid;
gap: var(--space-2);
padding: var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-subtle);
}
@media (max-width: 760px) {
.prettyTabsBar {
padding: 0 var(--space-3);

View File

@ -6,6 +6,14 @@ import { formatMillis } from "@/shared/utils/time.js";
import { getAppUser } from "@/features/app-users/api";
import { appUserStatusLabels } from "@/features/app-users/constants.js";
import { AppUserDetailContext } from "@/features/app-users/components/AppUserDetailContext.jsx";
import {
BanCountdown,
GenderValue,
LevelValue,
OperatorValue,
RolesValue,
formatUsdMinor,
} from "@/features/app-users/components/AppUserValues.jsx";
import styles from "@/features/app-users/app-users.module.css";
export function AppUserDetailProvider({ children }) {
@ -89,22 +97,43 @@ function AppUserDetailDrawer({ loading, onClose, open, user }) {
<DetailItem label="靓号" value={<PrettyValue user={user} />} />
<DetailItem label="靓号记录 ID" value={user.prettyId} />
<DetailItem label="昵称" value={user.username} />
<DetailItem label="性别" value={formatGender(user.gender)} />
<DetailItem label="性别" value={<GenderValue gender={user.gender} />} />
<DetailItem label="生日" value={user.birth} />
<DetailItem label="年龄" value={formatAge(user.age)} />
<DetailItem label="注册设备" value={user.registerDevice} />
<DetailItem label="身份" value={<RolesValue roles={user.roles} />} />
</div>
</section>
<section className={styles.detailSection}>
<h3 className={styles.detailSectionTitle}>等级信息</h3>
<div className={styles.detailGrid}>
<DetailItem label="富豪等级" value={<LevelValue level={user.levels?.wealth} showReal />} />
<DetailItem label="魅力等级" value={<LevelValue level={user.levels?.charm} showReal />} />
<DetailItem label="游戏等级" value={<LevelValue level={user.levels?.game} showReal />} />
<DetailItem label="VIP 等级" value={<VipDetailValue vip={user.vip} />} />
<DetailItem label="VIP 到期时间" value={formatVipExpire(user.vip)} />
</div>
</section>
<section className={styles.detailSection}>
<h3 className={styles.detailSectionTitle}>账号状态</h3>
<div className={styles.detailGrid}>
<DetailItem label="状态" value={appUserStatusLabels[user.status] || user.status || "-"} />
<DetailItem label="VIP 等级" value={<VipDetailValue vip={user.vip} />} />
<DetailItem label="封禁剩余" value={<BanCountdown ban={user.ban} status={user.status} />} />
<DetailItem label="封禁原因" value={user.ban?.reason} />
<DetailItem label="金币" value={formatNumber(user.coin)} />
<DetailItem label="钻石" value={formatNumber(user.diamond)} />
<DetailItem label="累计充值" value={formatUsdMinor(user.cumulativeRechargeUsdMinor)} />
<DetailItem label="国家" value={formatCountry(user)} />
<DetailItem
label="区域"
value={user.regionName || (user.regionId ? `区域 ${user.regionId}` : "-")}
/>
<DetailItem label="最新成功登录" value={formatMillis(user.lastLoginAtMs)} />
<DetailItem label="最近活跃" value={formatMillis(user.lastActiveAtMs)} />
<DetailItem
label="最新操作"
value={<OperatorValue operatedAtMs={user.lastOperatedAtMs} operator={user.lastOperator} />}
/>
<DetailItem label="创建时间" value={formatMillis(user.createdAtMs)} />
<DetailItem label="更新时间" value={formatMillis(user.updatedAtMs)} />
</div>
@ -248,25 +277,26 @@ function formatCountry(user) {
return user.countryDisplayName || user.countryName || user.country || "-";
}
function formatGender(gender) {
const value = String(gender || "").toLowerCase();
if (value === "male" || value === "m" || value === "男") {
return "男";
}
if (value === "female" || value === "f" || value === "女") {
return "女";
}
if (value === "non_binary") {
return "非二元";
}
return gender || "-";
}
function formatNumber(value) {
const number = Number(value || 0);
return Number.isFinite(number) ? number.toLocaleString() : "-";
}
function formatAge(value) {
if (value === undefined || value === null || value === "") {
return "-";
}
const age = Number(value);
return Number.isFinite(age) && age >= 0 ? `${Math.trunc(age)}` : "-";
}
function formatVipExpire(vip) {
if (Number(vip?.level || 0) <= 0) {
return "-";
}
return formatExpireTime(vip?.expiresAtMs);
}
function formatAssetType(value) {
const normalized = String(value || "").toUpperCase();
const labels = {

View File

@ -0,0 +1,75 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, expect, test, vi } from "vitest";
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
import { getAppUser } from "@/features/app-users/api";
import { useAppUserDetail } from "@/features/app-users/components/AppUserDetailContext.jsx";
import { AppUserDetailProvider } from "@/features/app-users/components/AppUserDetailProvider.jsx";
vi.mock("@/features/app-users/api", () => ({ getAppUser: vi.fn() }));
beforeEach(() => {
vi.clearAllMocks();
});
test("detail drawer keeps the identity hero and shows the expanded admin projection", async () => {
vi.mocked(getAppUser).mockResolvedValue({
age: 26,
avatar: "",
ban: { active: true, permanent: true, reason: "risk" },
birth: "2000-02-29",
coin: 1200,
countryDisplayName: "阿联酋",
createdAtMs: 1780000000000,
cumulativeRechargeUsdMinor: 12345,
defaultDisplayUserId: "163337",
diamond: 88,
displayUserId: "163337",
gender: "female",
lastLoginAtMs: 1781000000000,
lastOperatedAtMs: 1782000000000,
lastOperator: { action: "ban", adminId: "7", name: "运营" },
levels: {
charm: { displayLevel: 18, realLevel: 7, temporaryTargetLevel: 18, expiresAtMs: 1790000000000 },
game: { displayLevel: 9, realLevel: 9 },
wealth: { displayLevel: 20, realLevel: 8, temporaryTargetLevel: 20, expiresAtMs: 1790000000000 },
},
prettyDisplayUserId: "VIP888",
prettyId: "pretty-888",
regionName: "中东区",
registerDevice: "iPhone17,2",
roles: ["主播", "BD"],
status: "banned",
updatedAtMs: 1782000000000,
userId: "10001",
username: "tester",
vip: { active: true, expiresAtMs: 1795000000000, level: 3, name: "黄金会员" },
});
render(
<ToastProvider>
<AppUserDetailProvider>
<DetailTrigger />
</AppUserDetailProvider>
</ToastProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "打开详情" }));
expect((await screen.findAllByText("VIP888")).length).toBeGreaterThan(0);
expect(screen.getByText("注册设备")).toBeInTheDocument();
expect(screen.getByText("iPhone17,2")).toBeInTheDocument();
expect(screen.getByText("主播、BD")).toBeInTheDocument();
expect(screen.getByText("累计充值")).toBeInTheDocument();
expect(screen.getByText(/123\.45/)).toBeInTheDocument();
expect(screen.getByText("永久")).toBeInTheDocument();
expect(screen.getAllByText(/真实 Lv8/).length).toBeGreaterThan(0);
expect(screen.getByText("VIP 3 · 黄金会员")).toBeInTheDocument();
});
function DetailTrigger() {
const { openAppUserDetail } = useAppUserDetail();
return (
<button type="button" onClick={() => openAppUserDetail({ userId: "10001", username: "tester" })}>
打开详情
</button>
);
}

View File

@ -0,0 +1,152 @@
import { useEffect, useMemo, useState } from "react";
import { formatMillis } from "@/shared/utils/time.js";
import styles from "@/features/app-users/app-users.module.css";
const roleLabels = {
admin: "管理员",
bd: "BD",
bd_leader: "BD Leader",
bdleader: "BD Leader",
coin_seller: "币商",
guild_leader: "公会长",
host: "主播",
manager: "经理",
normal: "普通用户",
ordinary: "普通用户",
};
export function GenderValue({ gender }) {
return formatGender(gender);
}
export function LevelValue({ level, showReal = false }) {
if (!level) {
return "-";
}
const displayLevel = numericLevel(level.displayLevel ?? level.realLevel);
const realLevel = numericLevel(level.realLevel);
const temporary = Number(level.expiresAtMs || 0) > 0 && Number(level.temporaryTargetLevel || 0) > 0;
return (
<div className={styles.stack}>
<span className={styles.levelPrimary}>Lv{displayLevel}</span>
{showReal || temporary ? (
<span className={styles.meta}>
{showReal ? `真实 Lv${realLevel}` : ""}
{showReal && temporary ? " · " : ""}
{temporary ? `临时至 ${formatMillis(level.expiresAtMs)}` : ""}
</span>
) : null}
</div>
);
}
export function RolesValue({ roles }) {
const labels = normalizeRoles(roles);
return <span className={styles.rolesValue}>{labels.join("、")}</span>;
}
export function OperatorValue({ operatedAtMs, operator }) {
if (!operator?.name && !operator?.adminId && !operatedAtMs) {
return "-";
}
const name = operator?.name || (operator?.adminId ? `管理员 ${operator.adminId}` : "-");
return (
<div className={styles.stack}>
<span>{name}</span>
<span className={styles.meta}>
{[operator?.action, formatMillis(operatedAtMs)].filter((value) => value && value !== "-").join(" · ") || "-"}
</span>
</div>
);
}
export function BanCountdown({ ban, status }) {
const [now, setNow] = useState(0);
const banned = status === "banned" || status === "disabled" || Boolean(ban?.active);
const expiresAtMs = Number(ban?.expiresAtMs || 0);
useEffect(() => {
if (!banned || ban?.permanent || expiresAtMs <= 0) {
return undefined;
}
const startupTimer = window.setTimeout(() => setNow(Date.now()), 0);
const timer = window.setInterval(() => setNow(Date.now()), 1000);
return () => {
window.clearTimeout(startupTimer);
window.clearInterval(timer);
};
}, [ban?.permanent, banned, expiresAtMs]);
const label = useMemo(() => {
if (!banned) {
return "-";
}
// banned/disabled
if (ban?.permanent || expiresAtMs <= 0) {
return "永久";
}
if (now <= 0) {
return `${formatMillis(expiresAtMs)}`;
}
const remainingMs = expiresAtMs - now;
if (remainingMs <= 0) {
return "等待自动解封";
}
return formatDuration(remainingMs);
}, [ban?.permanent, banned, expiresAtMs, now]);
return <span className={banned ? styles.banCountdown : styles.meta}>{label}</span>;
}
export function formatGender(gender) {
const value = String(gender || "").toLowerCase();
if (value === "male" || value === "m" || value === "男") {
return "男";
}
if (value === "female" || value === "f" || value === "女") {
return "女";
}
if (value === "non_binary") {
return "非二元";
}
return gender || "-";
}
export function formatUsdMinor(value) {
const amount = Number(value);
if (!Number.isFinite(amount)) {
return "-";
}
return new Intl.NumberFormat("zh-CN", { currency: "USD", style: "currency" }).format(amount / 100);
}
export function normalizeRoles(roles) {
const values = Array.isArray(roles) ? roles.filter(Boolean) : [];
if (!values.length) {
return ["普通用户"];
}
return Array.from(new Set(values.map((role) => roleLabels[String(role).toLowerCase()] || String(role))));
}
function numericLevel(value) {
const level = Number(value || 0);
return Number.isFinite(level) ? Math.max(0, Math.trunc(level)) : 0;
}
function formatDuration(milliseconds) {
const totalSeconds = Math.max(0, Math.ceil(milliseconds / 1000));
const days = Math.floor(totalSeconds / 86400);
const hours = Math.floor((totalSeconds % 86400) / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (days > 0) {
return `${days}${hours}小时`;
}
if (hours > 0) {
return `${hours}小时 ${minutes}`;
}
if (minutes > 0) {
return `${minutes}${seconds}`;
}
return `${seconds}`;
}

View File

@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { downloadResponse } from "@/shared/api/download";
import { parseForm } from "@/shared/forms/validation";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
@ -6,14 +7,24 @@ import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { emptyUserIdentityFilter, userIdentityFilterParams } from "@/shared/ui/userIdentityFilter.js";
import {
adjustAppUserLevels,
banAppUser,
createAppUserExport,
downloadAppUserExport,
getAppUserExportJob,
listAppUsers,
setAppUserPassword,
unbanAppUser,
updateAppUser,
} from "@/features/app-users/api";
import { useAppUserAbilities } from "@/features/app-users/permissions.js";
import { appUserCountrySchema, appUserPasswordSchema, appUserUpdateSchema } from "@/features/app-users/schema";
import {
appUserBanSchema,
appUserCountrySchema,
appUserLevelAdjustmentSchema,
appUserPasswordSchema,
appUserUpdateSchema,
} from "@/features/app-users/schema";
import { useCountryOptions } from "@/features/host-org/hooks/useCountryOptions.js";
const pageSize = 50;
@ -21,6 +32,7 @@ const emptyData = { items: [], page: 1, pageSize, total: 0 };
const emptyEditForm = () => ({ avatar: "", gender: "", username: "" });
const emptyCountryForm = () => ({ country: "" });
const emptyPasswordForm = () => ({ password: "" });
const emptyBanForm = () => ({ expiresAtLocal: futureDateTimeLocal(7), mode: "permanent", reason: "" });
export function useAppUsersPage() {
const abilities = useAppUserAbilities();
@ -41,6 +53,8 @@ export function useAppUsersPage() {
const [editForm, setEditForm] = useState(emptyEditForm);
const [countryForm, setCountryForm] = useState(emptyCountryForm);
const [passwordForm, setPasswordForm] = useState(emptyPasswordForm);
const [levelForm, setLevelForm] = useState(() => levelFormFromUser(null));
const [banForm, setBanForm] = useState(emptyBanForm);
const [loadingAction, setLoadingAction] = useState("");
const [selectedUserIds, setSelectedUserIds] = useState([]);
const [patchedUsers, setPatchedUsers] = useState({});
@ -141,6 +155,8 @@ export function useAppUsersPage() {
setRegionId("");
setStatus("");
setTimeRange({ endMs: "", startMs: "" });
setSortBy("created_at");
setSortDirection("desc");
setPage(1);
};
@ -151,6 +167,7 @@ export function useAppUsersPage() {
gender: user.gender || "",
username: user.username || "",
});
setLevelForm(levelFormFromUser(user));
setActiveAction("edit");
};
@ -183,9 +200,30 @@ export function useAppUsersPage() {
}
await runAction("edit", "用户已更新", async () => {
const payload = parseForm(appUserUpdateSchema, editForm);
await updateAppUser(activeUser.userId, payload);
closeAction();
await reload();
const result = await updateAppUser(activeUser.userId, payload);
patchUsers([result]);
setActiveUser((current) => (current ? { ...current, ...result } : result));
});
};
const submitLevels = async () => {
if (!activeUser) {
return;
}
await runAction("levels", "等级调整已提交", async () => {
const form = parseForm(appUserLevelAdjustmentSchema, levelForm);
// 两条轨道只发一个 adjustments 数组,保证跨轨道由 activity-service 同事务提交或整体失败。
const adjustments = ["wealth", "charm"]
.filter((track) => form[track].enabled)
.map((track) => ({
duration_days: Number(form[track].durationDays),
level: Number(form[track].level),
track,
}));
const result = await adjustAppUserLevels(activeUser.userId, { adjustments, reason: form.reason });
patchUsers([result]);
setActiveUser((current) => (current ? { ...current, ...result } : result));
setLevelForm(levelFormFromUser(result));
});
};
@ -216,13 +254,45 @@ export function useAppUsersPage() {
const toggleBan = async (user) => {
const banned = isBanned(user);
await runAction(`status-${user.userId}`, banned ? "用户已解封" : "用户已封禁", async () => {
const result = banned ? await unbanAppUser(user.userId) : await banAppUser(user.userId);
patchUsers([mergeUserStatus(user, result, banned ? "active" : "banned")]);
if (!banned) {
setActiveUser(user);
setBanForm(emptyBanForm());
setActiveAction("ban");
return;
}
const ok = await confirm({
confirmText: "解封",
message: "仅当该用户不存在其他有效管理员或经理封禁时,账号状态才会恢复。",
title: "解封用户",
});
if (!ok) {
return;
}
await runAction(`status-${user.userId}`, "用户已解封", async () => {
const result = await unbanAppUser(user.userId, { reason: "admin_manual_unban" });
patchUsers([mergeUserStatus(user, result, "active")]);
setSelectedUserIds((current) => current.filter((userId) => userId !== user.userId));
});
};
const submitBan = async (event) => {
event.preventDefault();
if (!activeUser) {
return;
}
await runAction(`status-${activeUser.userId}`, "用户已封禁", async () => {
const form = parseForm(appUserBanSchema, banForm);
const expiresAtMs = form.mode === "permanent" ? 0 : new Date(form.expiresAtLocal).getTime();
const result = await banAppUser(activeUser.userId, {
expires_at_ms: expiresAtMs,
reason: form.reason,
});
patchUsers([mergeUserStatus(activeUser, result, "banned")]);
setSelectedUserIds((current) => current.filter((userId) => userId !== activeUser.userId));
closeAction();
});
};
const batchBan = async () => {
const users = (data.items || []).filter((user) => selectedUserIds.includes(user.userId) && !isBanned(user));
if (!users.length) {
@ -231,7 +301,7 @@ export function useAppUsersPage() {
}
const ok = await confirm({
confirmText: "封禁",
message: `封禁 ${users.length} 个用户。`,
message: `永久封禁 ${users.length} 个用户。`,
title: "批量封禁用户",
tone: "danger",
});
@ -241,7 +311,11 @@ export function useAppUsersPage() {
setLoadingAction("batch-ban");
try {
const results = await Promise.allSettled(users.map((user) => banAppUser(user.userId)));
const results = await Promise.allSettled(
users.map((user) =>
banAppUser(user.userId, { expires_at_ms: 0, reason: "admin_batch_permanent_ban" }),
),
);
const updatedUsers = results
.map((result, index) =>
result.status === "fulfilled" ? mergeUserStatus(users[index], result.value, "banned") : null,
@ -270,6 +344,23 @@ export function useAppUsersPage() {
}
};
const exportUsers = async () => {
setLoadingAction("export");
try {
// 创建任务时仅传当前筛选快照;页码由后端固定为首批并逐页导出,避免只导出当前可见页。
const created = await createAppUserExport(filters);
showToast(`导出任务 #${created.jobId} 已创建`, "success");
const job = await waitAppUserExportJob(created.jobId);
const response = await downloadAppUserExport(created.jobId);
await downloadResponse(response, appUserExportFilename(created.snapshotAtMs || job.createdAtMs));
showToast("用户 CSV 已生成", "success");
} catch (err) {
showToast(err.message || "导出用户失败", "error");
} finally {
setLoadingAction("");
}
};
const runAction = async (action, successMessage, submitter) => {
setLoadingAction(action);
try {
@ -299,6 +390,7 @@ export function useAppUsersPage() {
abilities,
activeAction,
activeUser,
banForm,
batchBan,
changeUserFilter,
changeStatus,
@ -313,6 +405,7 @@ export function useAppUsersPage() {
loadingAction,
loadingCountries,
loadingRegions,
levelForm,
locationFilterOptions,
locationFilterValue,
openCountry,
@ -327,7 +420,9 @@ export function useAppUsersPage() {
resetFilters,
selectedUserIds,
setCountryForm,
setBanForm,
setEditForm,
setLevelForm,
setPage,
setPasswordForm,
setSelectedUserIds,
@ -339,9 +434,12 @@ export function useAppUsersPage() {
changeSort,
changeTimeRange,
submitCountry,
submitBan,
submitEdit,
submitLevels,
submitPassword,
toggleBan,
exportUsers,
};
}
@ -356,3 +454,56 @@ function mergeUserStatus(currentUser, result, fallbackStatus) {
status: result?.status || fallbackStatus,
};
}
function levelFormFromUser(user) {
return {
charm: levelTrackForm(user?.levels?.charm),
reason: "",
wealth: levelTrackForm(user?.levels?.wealth),
};
}
function levelTrackForm(level) {
const realLevel = Number(level?.realLevel || 0);
const currentTarget = Number(level?.temporaryTargetLevel || level?.displayLevel || realLevel + 1);
const expiresAtMs = Number(level?.expiresAtMs || 0);
const remainingDays = expiresAtMs > Date.now() ? Math.max(1, Math.ceil((expiresAtMs - Date.now()) / 86400000)) : 7;
return {
durationDays: String(remainingDays),
enabled: false,
level: String(Math.min(50, Math.max(1, currentTarget))),
};
}
async function waitAppUserExportJob(jobId) {
const deadline = Date.now() + 120000;
while (Date.now() < deadline) {
const job = await getAppUserExportJob(jobId);
if (job.status === "succeeded") {
return job;
}
if (["failed", "canceled", "cancelled"].includes(job.status)) {
throw new Error(job.error || "用户导出任务失败");
}
await delay(1500);
}
throw new Error("用户导出任务仍在处理中,请稍后重试");
}
function delay(ms) {
return new Promise((resolve) => {
window.setTimeout(resolve, ms);
});
}
function futureDateTimeLocal(days) {
const date = new Date(Date.now() + days * 86400000);
const offsetMs = date.getTimezoneOffset() * 60000;
return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16);
}
function appUserExportFilename(snapshotAtMs) {
const date = new Date(Number(snapshotAtMs || Date.now()));
const stamp = Number.isFinite(date.getTime()) ? date.toISOString().slice(0, 10) : "snapshot";
return `app-users-${stamp}.csv`;
}

View File

@ -1,12 +1,15 @@
import BlockOutlined from "@mui/icons-material/BlockOutlined";
import EditOutlined from "@mui/icons-material/EditOutlined";
import FileDownloadOutlined from "@mui/icons-material/FileDownloadOutlined";
import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined";
import KeyboardArrowUpOutlined from "@mui/icons-material/KeyboardArrowUpOutlined";
import LockOpenOutlined from "@mui/icons-material/LockOpenOutlined";
import PasswordOutlined from "@mui/icons-material/PasswordOutlined";
import RestartAltOutlined from "@mui/icons-material/RestartAltOutlined";
import UnfoldMoreOutlined from "@mui/icons-material/UnfoldMoreOutlined";
import Autocomplete from "@mui/material/Autocomplete";
import Checkbox from "@mui/material/Checkbox";
import FormControlLabel from "@mui/material/FormControlLabel";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
@ -22,6 +25,13 @@ import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
import { UploadField } from "@/shared/ui/UploadField.jsx";
import { formatMillis } from "@/shared/utils/time.js";
import { appUserStatusFilters, appUserStatusLabels, genderOptions } from "@/features/app-users/constants.js";
import {
BanCountdown,
GenderValue,
LevelValue,
OperatorValue,
RolesValue,
} from "@/features/app-users/components/AppUserValues.jsx";
import { useAppUsersPage } from "@/features/app-users/hooks/useAppUsersPage.js";
import { CoinLedgerTable } from "@/features/operations/components/CoinLedgerTable.jsx";
import styles from "@/features/app-users/app-users.module.css";
@ -89,6 +99,42 @@ export function AppUserListPage() {
}),
render: (user) => <AdminUserIdentity openInAppUserDetail user={user} />,
},
{
key: "gender",
label: "性别",
width: "minmax(72px, 0.4fr)",
render: (user) => <GenderValue gender={user.gender} />,
},
{
key: "age",
label: "年龄",
width: "minmax(72px, 0.4fr)",
render: (user) => (user.age === undefined || user.age === null ? "-" : `${user.age}`),
},
{
key: "wealthLevel",
label: "富豪等级",
width: "minmax(118px, 0.7fr)",
render: (user) => <LevelValue level={user.levels?.wealth} />,
},
{
key: "charmLevel",
label: "魅力等级",
width: "minmax(118px, 0.7fr)",
render: (user) => <LevelValue level={user.levels?.charm} />,
},
{
key: "gameLevel",
label: "游戏等级",
width: "minmax(96px, 0.55fr)",
render: (user) => <LevelValue level={user.levels?.game} />,
},
{
key: "roles",
label: "身份",
width: "minmax(150px, 0.9fr)",
render: (user) => <RolesValue roles={user.roles} />,
},
{
key: "vip",
label: "VIP",
@ -134,7 +180,30 @@ export function AppUserListPage() {
value: page.status,
onChange: page.changeStatus,
}),
render: (user) => <UserStatus status={user.status} />,
render: (user) => (
<div className={styles.stack}>
<UserStatus status={user.status} />
<BanCountdown ban={user.ban} status={user.status} />
</div>
),
},
{
key: "lastLoginAtMs",
label: "最新成功登录",
width: "minmax(160px, 0.9fr)",
render: (user) => formatMillis(user.lastLoginAtMs),
},
{
key: "lastOperator",
label: "最新操作人",
width: "minmax(142px, 0.8fr)",
render: (user) => <OperatorValue operator={user.lastOperator} />,
},
{
key: "lastOperatedAtMs",
label: "最新操作时间",
width: "minmax(160px, 0.9fr)",
render: (user) => formatMillis(user.lastOperatedAtMs),
},
{
key: "time",
@ -181,21 +250,35 @@ export function AppUserListPage() {
onChange={page.changeTimeRange}
/>
</div>
{page.abilities.canStatus ? (
<Button
disabled={!page.selectedUserIds.length || page.loadingAction === "batch-ban"}
startIcon={<BlockOutlined fontSize="small" />}
variant="danger"
onClick={page.batchBan}
>
批量封禁
<div className={styles.toolbarActions}>
<Button startIcon={<RestartAltOutlined fontSize="small" />} onClick={page.resetFilters}>
重置筛选
</Button>
) : null}
{page.abilities.canExport ? (
<Button
disabled={page.loadingAction === "export"}
startIcon={<FileDownloadOutlined fontSize="small" />}
onClick={page.exportUsers}
>
{page.loadingAction === "export" ? "导出中" : "导出 CSV"}
</Button>
) : null}
{page.abilities.canStatus ? (
<Button
disabled={!page.selectedUserIds.length || page.loadingAction === "batch-ban"}
startIcon={<BlockOutlined fontSize="small" />}
variant="danger"
onClick={page.batchBan}
>
批量封禁
</Button>
) : null}
</div>
</div>
<DataTable
columns={tableColumns}
items={items}
minWidth="1220px"
minWidth="2380px"
pagination={
total > 0
? {
@ -212,40 +295,96 @@ export function AppUserListPage() {
</DataState>
</div>
<ActionModal
disabled={!page.abilities.canUpdate}
<AdminFormDialog
loading={page.loadingAction === "edit"}
open={page.activeAction === "edit"}
sectionTitle="用户资料"
size="standard"
submitDisabled={!page.abilities.canUpdate || page.loadingAction === "edit"}
submitLabel="保存资料"
title="编辑用户"
onClose={page.closeAction}
onSubmit={page.submitEdit}
onSubmit={page.abilities.canUpdate ? page.submitEdit : (event) => event.preventDefault()}
>
<AdminFormSection title="用户资料">
<TextField
disabled={!page.abilities.canUpdate}
label="用户名称"
value={page.editForm.username}
onChange={(event) => page.setEditForm({ ...page.editForm, username: event.target.value })}
/>
<UploadField
disabled={!page.abilities.canUpdate || !page.abilities.canUpload}
label="头像"
value={page.editForm.avatar}
onChange={(avatar) => page.setEditForm({ ...page.editForm, avatar })}
/>
<TextField
disabled={!page.abilities.canUpdate}
label="性别"
select
value={page.editForm.gender}
onChange={(event) => page.setEditForm({ ...page.editForm, gender: event.target.value })}
>
{genderOptions.map(([value, label]) => (
<MenuItem key={value || "empty"} value={value}>
{label}
</MenuItem>
))}
</TextField>
</AdminFormSection>
<AdminFormSection
actions={
<Button
disabled={!page.abilities.canLevel || page.loadingAction === "levels"}
variant="primary"
onClick={page.submitLevels}
>
{page.loadingAction === "levels" ? "保存中" : "保存等级"}
</Button>
}
title="等级调整"
>
<LevelAdjustmentFields page={page} />
</AdminFormSection>
</AdminFormDialog>
<ActionModal
disabled={!page.abilities.canStatus}
loading={page.loadingAction === `status-${page.activeUser?.userId}`}
open={page.activeAction === "ban"}
sectionTitle="封禁信息"
title="封禁用户"
onClose={page.closeAction}
onSubmit={page.submitBan}
>
<TextField
disabled={!page.abilities.canUpdate}
label="用户名称"
value={page.editForm.username}
onChange={(event) => page.setEditForm({ ...page.editForm, username: event.target.value })}
/>
<UploadField
disabled={!page.abilities.canUpdate || !page.abilities.canUpload}
label="头像"
value={page.editForm.avatar}
onChange={(avatar) => page.setEditForm({ ...page.editForm, avatar })}
/>
<TextField
disabled={!page.abilities.canUpdate}
label="性别"
disabled={!page.abilities.canStatus}
label="封禁期限"
select
value={page.editForm.gender}
onChange={(event) => page.setEditForm({ ...page.editForm, gender: event.target.value })}
value={page.banForm.mode}
onChange={(event) => page.setBanForm({ ...page.banForm, mode: event.target.value })}
>
{genderOptions.map(([value, label]) => (
<MenuItem key={value || "empty"} value={value}>
{label}
</MenuItem>
))}
<MenuItem value="permanent">永久</MenuItem>
<MenuItem value="timed">指定截止时间</MenuItem>
</TextField>
{page.banForm.mode === "timed" ? (
<TextField
disabled={!page.abilities.canStatus}
label="封禁截止时间"
slotProps={{ inputLabel: { shrink: true } }}
type="datetime-local"
value={page.banForm.expiresAtLocal}
onChange={(event) => page.setBanForm({ ...page.banForm, expiresAtLocal: event.target.value })}
/>
) : null}
<TextField
disabled={!page.abilities.canStatus}
label="封禁原因"
multiline
minRows={2}
value={page.banForm.reason}
onChange={(event) => page.setBanForm({ ...page.banForm, reason: event.target.value })}
/>
</ActionModal>
<ActionModal
@ -321,6 +460,70 @@ function CoinValue({ page, user }) {
);
}
function LevelAdjustmentFields({ page }) {
const tracks = [
["wealth", "富豪等级"],
["charm", "魅力等级"],
];
const updateTrack = (track, patch) => {
page.setLevelForm((current) => ({
...current,
[track]: { ...current[track], ...patch },
}));
};
return (
<div className={styles.levelForm}>
{tracks.map(([track, label]) => {
const field = page.levelForm[track];
const current = page.activeUser?.levels?.[track];
return (
<div className={styles.levelTrackForm} key={track}>
<FormControlLabel
control={
<Checkbox
checked={field.enabled}
disabled={!page.abilities.canLevel}
onChange={(event) => updateTrack(track, { enabled: event.target.checked })}
/>
}
label={`${label}(真实 Lv${Number(current?.realLevel || 0)},展示 Lv${Number(current?.displayLevel || 0)}`}
/>
<div className={styles.formGridTwo}>
<TextField
disabled={!page.abilities.canLevel || !field.enabled}
label="目标等级"
slotProps={{ htmlInput: { max: 50, min: 1, step: 1 } }}
type="number"
value={field.level}
onChange={(event) => updateTrack(track, { level: event.target.value })}
/>
<TextField
disabled={!page.abilities.canLevel || !field.enabled}
label="有效天数"
slotProps={{ htmlInput: { max: 36500, min: 1, step: 1 } }}
type="number"
value={field.durationDays}
onChange={(event) => updateTrack(track, { durationDays: event.target.value })}
/>
</div>
</div>
);
})}
<TextField
disabled
label="游戏等级(只读)"
value={`Lv${Number(page.activeUser?.levels?.game?.displayLevel || 0)}`}
/>
<TextField
disabled={!page.abilities.canLevel}
label="调整原因"
value={page.levelForm.reason}
onChange={(event) => page.setLevelForm((current) => ({ ...current, reason: event.target.value }))}
/>
</div>
);
}
function VipValue({ vip }) {
const level = Number(vip?.level || 0);
if (level <= 0 || !vip?.active) {
@ -350,10 +553,13 @@ function UserActions({ page, user }) {
const banned = user.status === "banned" || user.status === "disabled";
return (
<div className={styles.rowActions}>
{page.abilities.canUpdate ? (
<Tooltip arrow title="编辑">
{page.abilities.canUpdate || page.abilities.canLevel ? (
<Tooltip arrow title={page.abilities.canUpdate ? "编辑" : "等级调整"}>
<span>
<IconButton label="编辑" onClick={() => page.openEdit(user)}>
<IconButton
label={page.abilities.canUpdate ? "编辑" : "等级调整"}
onClick={() => page.openEdit(user)}
>
<EditOutlined fontSize="small" />
</IconButton>
</span>

View File

@ -1,6 +1,7 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, expect, test, vi } from "vitest";
import { AppUserListPage } from "./AppUserListPage.jsx";
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
import { useAppUsersPage } from "@/features/app-users/hooks/useAppUsersPage.js";
const mocks = vi.hoisted(() => ({
@ -26,9 +27,17 @@ afterEach(() => {
test("clicking an app user's coin opens the coin ledger drawer", () => {
vi.mocked(useAppUsersPage).mockReturnValue(pageFixture());
render(<AppUserListPage />);
render(
<ToastProvider>
<AppUserListPage />
</ToastProvider>,
);
expect(screen.getByText("VIP 2")).toBeInTheDocument();
expect(screen.getByText("VIP888")).toBeInTheDocument();
expect(screen.getByText("公会长、BD")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "调整富豪等级列宽" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "调整最新成功登录列宽" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "查看 d 的金币流水" }));
expect(mocks.openCoinLedger).toHaveBeenCalledWith(expect.objectContaining({ userId: "10001" }));
@ -43,7 +52,11 @@ test("coin ledger drawer passes the active app user to the shared ledger table",
}),
);
render(<AppUserListPage />);
render(
<ToastProvider>
<AppUserListPage />
</ToastProvider>,
);
expect(screen.getByTestId("coin-ledger-table")).toHaveTextContent("10001");
expect(mocks.ledgerTable).toHaveBeenCalledWith(
@ -51,10 +64,49 @@ test("coin ledger drawer passes the active app user to the shared ledger table",
);
});
test("edit dialog saves profile and temporary levels independently", () => {
const page = pageFixture({ activeAction: "edit", activeUser: userFixture() });
vi.mocked(useAppUsersPage).mockReturnValue(page);
render(
<ToastProvider>
<AppUserListPage />
</ToastProvider>,
);
expect(screen.getByText("用户资料")).toBeInTheDocument();
expect(screen.getByText("等级调整")).toBeInTheDocument();
expect(screen.getByLabelText("游戏等级(只读)")).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "保存等级" }));
expect(page.submitLevels).toHaveBeenCalledTimes(1);
fireEvent.submit(screen.getByRole("button", { name: "保存资料" }).closest("form"));
expect(page.submitEdit).toHaveBeenCalledTimes(1);
});
test("level-only permission can open level adjustment without exposing country edit", () => {
const page = pageFixture({
abilities: { ...pageFixture().abilities, canLevel: true, canUpdate: false },
});
vi.mocked(useAppUsersPage).mockReturnValue(page);
render(
<ToastProvider>
<AppUserListPage />
</ToastProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "等级调整" }));
expect(page.openEdit).toHaveBeenCalledWith(expect.objectContaining({ userId: "10001" }));
expect(screen.queryByRole("button", { name: "阿拉伯联合酋长国" })).not.toBeInTheDocument();
});
function pageFixture(patch = {}) {
return {
abilities: {
canCoinLedger: true,
canExport: true,
canLevel: true,
canPassword: true,
canStatus: true,
canUpdate: true,
@ -63,6 +115,7 @@ function pageFixture(patch = {}) {
},
activeAction: "",
activeUser: null,
banForm: { expiresAtLocal: "", mode: "permanent", reason: "" },
batchBan: vi.fn(),
changeLocationFilter: vi.fn(),
changeQuery: vi.fn(),
@ -74,11 +127,17 @@ function pageFixture(patch = {}) {
countryOptions: [],
data: { items: [userFixture()], page: 1, pageSize: 50, total: 1 },
editForm: { avatar: "", gender: "", username: "" },
exportUsers: vi.fn(),
error: null,
loading: false,
loadingAction: "",
loadingCountries: false,
loadingRegions: false,
levelForm: {
charm: { durationDays: "7", enabled: false, level: "4" },
reason: "",
wealth: { durationDays: "7", enabled: false, level: "5" },
},
locationFilterOptions: [],
locationFilterValue: "",
openCoinLedger: mocks.openCoinLedger,
@ -90,17 +149,22 @@ function pageFixture(patch = {}) {
query: "",
regionOptions: [],
reload: vi.fn(),
resetFilters: vi.fn(),
selectedUserIds: [],
setBanForm: vi.fn(),
setCountryForm: vi.fn(),
setEditForm: vi.fn(),
setLevelForm: vi.fn(),
setPage: vi.fn(),
setPasswordForm: vi.fn(),
setSelectedUserIds: vi.fn(),
sortBy: "created_at",
sortDirection: "desc",
status: "",
submitBan: vi.fn(),
submitCountry: vi.fn(),
submitEdit: vi.fn(),
submitLevels: vi.fn(),
submitPassword: vi.fn(),
timeRange: { endMs: "", startMs: "" },
toggleBan: vi.fn(),
@ -110,15 +174,30 @@ function pageFixture(patch = {}) {
function userFixture() {
return {
age: 24,
avatar: "",
ban: { active: false, expiresAtMs: 0, permanent: false },
coin: 223437,
country: "AE",
countryDisplayName: "阿拉伯联合酋长国",
createdAtMs: 1779322523000,
defaultDisplayUserId: "163337",
displayUserId: "163337",
gender: "female",
lastActiveAtMs: 1781325798000,
lastLoginAtMs: 1781325700000,
lastOperatedAtMs: 1781325750000,
lastOperator: { action: "update", adminId: "7", name: "雷乐" },
levels: {
charm: { displayLevel: 3, realLevel: 3, track: "charm" },
game: { displayLevel: 6, realLevel: 6, track: "game" },
wealth: { displayLevel: 4, realLevel: 4, track: "wealth" },
},
prettyDisplayUserId: "VIP888",
prettyId: "pretty-888",
regionId: 2,
regionName: "中东区",
roles: ["公会长", "BD"],
status: "active",
userId: "10001",
username: "d",

View File

@ -15,5 +15,7 @@ export function useAppUserAbilities() {
canUpdate: can(PERMISSIONS.appUserUpdate),
canUpload: can(PERMISSIONS.uploadCreate),
canView: can(PERMISSIONS.appUserView),
canLevel: can(PERMISSIONS.appUserLevel),
canExport: can(PERMISSIONS.appUserExport),
};
}

View File

@ -0,0 +1,28 @@
import { describe, expect, test } from "vitest";
import { parseForm } from "@/shared/forms/validation";
import { prettyDisplayIDGrantSchema } from "./schema";
describe("pretty display ID grant schema", () => {
test("accepts arbitrary Unicode display content", () => {
// 管理端只保留展示文本的非空和长度约束,避免 Unicode 字体、符号和 emoji 被客户端正则错误拦截。
const payload = parseForm(prettyDisplayIDGrantSchema, {
displayUserId: " 𝕸⃝é中⚡️™ ",
durationDays: "1",
reason: "Unicode 靓号",
targetUserId: "163001",
});
expect(payload.displayUserId).toBe("𝕸⃝é中⚡️™");
});
test("counts supplementary Unicode characters as one character", () => {
const payload = parseForm(prettyDisplayIDGrantSchema, {
displayUserId: "😀".repeat(64),
durationDays: "",
reason: "",
targetUserId: "163001",
});
expect(Array.from(payload.displayUserId)).toHaveLength(64);
});
});

View File

@ -14,8 +14,64 @@ export const appUserPasswordSchema = z.object({
password: z.string().trim().min(6, "密码至少 6 位").max(128, "密码不能超过 128 个字符"),
});
// 与服务端后台发放规则保持一致:只允许 Unicode 字母、数字和组合标记空格、标点、emoji 都在前端先拦截。
const prettyContentPattern = /^[\p{L}\p{N}\p{M}]+$/u;
const temporaryLevelTrackSchema = z.object({
durationDays: z.string().trim().optional().default("7"),
enabled: z.boolean().optional().default(false),
level: z.string().trim().optional().default(""),
});
// 财富和魅力必须作为一次命令提交;表单在送出前校验双轨边界,后端继续负责真实等级、规则启用状态和事务原子性。
export const appUserLevelAdjustmentSchema = z
.object({
charm: temporaryLevelTrackSchema,
reason: z.string().trim().max(128, "调整原因不能超过 128 个字符").optional().default(""),
wealth: temporaryLevelTrackSchema,
})
.superRefine((value, context) => {
const enabledTracks = [
["wealth", value.wealth],
["charm", value.charm],
] as const;
if (!enabledTracks.some(([, track]) => track.enabled)) {
context.addIssue({ code: "custom", message: "至少选择一个等级轨道", path: ["wealth", "enabled"] });
return;
}
enabledTracks.forEach(([name, track]) => {
if (!track.enabled) {
return;
}
const level = Number(track.level);
if (!Number.isInteger(level) || level < 1 || level > 50) {
context.addIssue({ code: "custom", message: "目标等级必须是 1 到 50 的整数", path: [name, "level"] });
}
const durationDays = Number(track.durationDays);
if (!Number.isInteger(durationDays) || durationDays < 1 || durationDays > 36500) {
context.addIssue({
code: "custom",
message: "有效天数必须是 1 到 36500 的整数",
path: [name, "durationDays"],
});
}
});
});
// expires_at_ms=0 仅由“永久”模式产生;指定截止时间必须在浏览器当前时刻之后,服务端仍以 UTC 再次校验。
export const appUserBanSchema = z
.object({
expiresAtLocal: z.string().trim().optional().default(""),
mode: z.enum(["permanent", "timed"], "请选择封禁期限"),
reason: z.string().trim().max(512, "封禁原因不能超过 512 个字符").optional().default(""),
})
.superRefine((value, context) => {
if (value.mode !== "timed") {
return;
}
const expiresAtMs = new Date(value.expiresAtLocal).getTime();
if (!value.expiresAtLocal || !Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) {
context.addIssue({ code: "custom", message: "截止时间必须晚于当前时间", path: ["expiresAtLocal"] });
}
});
const prettyRuleValues = [
"aa",
"aaa",
@ -77,14 +133,16 @@ export const prettyDisplayIDGenerateSchema = z.object({
ruleType: z.enum(prettyRuleValues, "请选择生成规则"),
});
// 后台发放允许管理员输入非数字靓号durationDays 为空或 0 都会转成 duration_ms=0表示长期持有
// 靓号展示内容必须原样支持任意 Unicode 字符,不能以字母数字正则过滤字体字形、表情或符号;仅去除首尾空白、限制长度并拦截控制字符,服务端继续负责唯一性和持久化安全校验
export const prettyDisplayIDGrantSchema = z.object({
displayUserId: z
.string()
.trim()
.min(1, "请输入靓号内容")
.max(64, "靓号不能超过 64 个字符")
.regex(prettyContentPattern, "靓号只支持字母、数字和组合标记"),
// String.length 会把补充平面 Unicode 字符计为两个 UTF-16 单元,必须按 code point 计数,才能和服务端的 rune 上限一致。
.refine((value) => Array.from(value).length <= 64, "靓号不能超过 64 个字符")
// 控制字符不是展示字形保留会破坏列表、日志和协议载荷符号、emoji 和格式化字形仍可通过。
.refine((value) => !/\p{Cc}/u.test(value), ""),
durationDays: z
.string()
.trim()
@ -111,6 +169,8 @@ export const prettyDisplayIDStatusSchema = z.object({
export type AppUserUpdateForm = z.infer<typeof appUserUpdateSchema>;
export type AppUserCountryForm = z.infer<typeof appUserCountrySchema>;
export type AppUserPasswordForm = z.infer<typeof appUserPasswordSchema>;
export type AppUserLevelAdjustmentForm = z.infer<typeof appUserLevelAdjustmentSchema>;
export type AppUserBanForm = z.infer<typeof appUserBanSchema>;
export type PrettyDisplayIDPoolForm = z.infer<typeof prettyDisplayIDPoolSchema>;
export type PrettyDisplayIDGenerateForm = z.infer<typeof prettyDisplayIDGenerateSchema>;
export type PrettyDisplayIDGrantForm = z.infer<typeof prettyDisplayIDGrantSchema>;

View File

@ -1,46 +0,0 @@
import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "@/app/auth/AuthProvider.jsx";
import { getFrequentSecondLevelMenus } from "@/app/navigation/menuUsage.js";
import { TimeText } from "@/shared/ui/TimeText.jsx";
export function DashboardFrequentMenus({ menus = [] }) {
const { user } = useAuth();
const navigate = useNavigate();
const frequentMenus = useMemo(() => getFrequentSecondLevelMenus({ menus, user }), [menus, user]);
return (
<section className="frequent-menu-section" aria-label="常用二级菜单">
<div className="frequent-menu-head">
<div>
<h2>常用页面</h2>
<p>本地缓存的二级菜单访问次数</p>
</div>
</div>
{frequentMenus.length ? (
<div className="frequent-menu-grid">
{frequentMenus.map((item) => {
const Icon = item.icon;
return (
<button
className="frequent-menu-card"
key={item.code}
type="button"
onClick={() => navigate(item.path)}
>
<span className="frequent-menu-card__icon">{Icon ? <Icon fontSize="small" /> : null}</span>
<span className="frequent-menu-card__group">{item.parentLabel}</span>
<strong>{item.label}</strong>
<span className="frequent-menu-card__meta">
进入 {item.count || 0} · <TimeText value={item.lastVisitedAtMs} />
</span>
</button>
);
})}
</div>
) : (
<div className="frequent-menu-empty">暂无本地访问记录</div>
)}
</section>
);
}

View File

@ -8,7 +8,7 @@ export function DashboardToolbar({ onRefresh, onRangeChange, overview, range })
const { formatMillis } = useTimeZone();
return (
<PageHead title="系统概览" meta={`刷新时间 ${formatMillis(overview?.updatedAtMs ?? overview?.updatedAt)}`}>
<PageHead title="工作台" meta={`刷新时间 ${formatMillis(overview?.updatedAtMs ?? overview?.updatedAt)}`}>
<TimeRangeSelect value={range} onChange={onRangeChange} />
<Button onClick={onRefresh}>
<Refresh fontSize="small" />

View File

@ -0,0 +1,58 @@
import AccountBalanceOutlined from "@mui/icons-material/AccountBalanceOutlined";
import InsightsOutlined from "@mui/icons-material/InsightsOutlined";
import { useAuth } from "@/app/auth/AuthProvider.jsx";
import { PERMISSIONS } from "@/app/permissions";
export const WORKSPACE_ENTRIES = [
{
description: "财务管理",
href: "/finance/",
icon: AccountBalanceOutlined,
label: "财务工作台",
permission: PERMISSIONS.financeView,
},
{
description: "运营管理",
href: "/databi/social/",
icon: InsightsOutlined,
label: "运营工作台",
permission: PERMISSIONS.overviewView,
},
];
export function getPermittedWorkspaceEntries(can) {
// HTML
return WORKSPACE_ENTRIES.filter((entry) => can(entry.permission));
}
export function DashboardWorkspaces() {
const { can } = useAuth();
const entries = getPermittedWorkspaceEntries(can);
return (
<section className="workspace-entry-section" aria-label="工作台入口">
<div className="workspace-entry-head">
<h2>工作台</h2>
</div>
{entries.length ? (
<div className="workspace-entry-grid">
{entries.map((entry) => {
const Icon = entry.icon;
return (
// 使 Vite HTML
<a className="workspace-entry-card" href={entry.href} key={entry.href}>
<span className="workspace-entry-card__icon">
<Icon fontSize="small" />
</span>
<span className="workspace-entry-card__group">{entry.description}</span>
<strong>{entry.label}</strong>
</a>
);
})}
</div>
) : (
<div className="workspace-entry-empty">当前无可访问工作台</div>
)}
</section>
);
}

View File

@ -0,0 +1,34 @@
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { PERMISSIONS } from "@/app/permissions";
import { DashboardWorkspaces } from "./DashboardWorkspaces.jsx";
const can = vi.fn();
vi.mock("@/app/auth/AuthProvider.jsx", () => ({
useAuth: () => ({ can }),
}));
describe("DashboardWorkspaces", () => {
beforeEach(() => {
can.mockReset();
});
test("shows each workspace only when its existing permission is granted", () => {
can.mockImplementation((permission) => permission === PERMISSIONS.financeView);
render(<DashboardWorkspaces />);
expect(screen.getByRole("link", { name: /财务工作台/ })).toHaveAttribute("href", "/finance/");
expect(screen.queryByRole("link", { name: /运营工作台/ })).not.toBeInTheDocument();
});
test("routes the operations workspace to the Social BI document entry", () => {
can.mockImplementation((permission) => permission === PERMISSIONS.overviewView);
render(<DashboardWorkspaces />);
expect(screen.getByRole("link", { name: /运营工作台/ })).toHaveAttribute("href", "/databi/social/");
expect(screen.queryByRole("link", { name: /财务工作台/ })).not.toBeInTheDocument();
});
});

View File

@ -5,40 +5,31 @@
margin-top: var(--space-4);
}
.frequent-menu-section {
.workspace-entry-section {
margin-top: var(--space-4);
}
.frequent-menu-head {
.workspace-entry-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: var(--space-4);
margin-bottom: var(--space-3);
}
.frequent-menu-head h2 {
.workspace-entry-head h2 {
margin: 0;
color: var(--text-primary);
font-size: var(--admin-font-size-lg);
font-weight: 750;
}
.frequent-menu-head p {
margin: var(--space-1) 0 0;
color: var(--text-tertiary);
font-size: var(--admin-font-size);
}
.frequent-menu-grid {
.workspace-entry-grid {
display: grid;
grid-template-columns: repeat(4, minmax(180px, 1fr));
grid-template-columns: repeat(2, minmax(240px, 1fr));
gap: var(--space-3);
}
.frequent-menu-card {
.workspace-entry-card {
display: grid;
min-height: 112px;
min-height: 128px;
align-content: start;
gap: var(--space-1);
padding: var(--space-4);
@ -48,6 +39,7 @@
color: var(--text-secondary);
cursor: pointer;
font: inherit;
text-decoration: none;
text-align: left;
transition:
border-color var(--motion-base) var(--ease-standard),
@ -55,13 +47,19 @@
transform var(--motion-base) var(--ease-emphasized);
}
.frequent-menu-card:hover {
.workspace-entry-card:hover,
.workspace-entry-card:focus-visible {
border-color: var(--primary-border-strong);
box-shadow: var(--shadow-panel);
transform: translateY(-2px);
}
.frequent-menu-card__icon {
.workspace-entry-card:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.workspace-entry-card__icon {
display: inline-flex;
width: 32px;
height: 32px;
@ -72,13 +70,13 @@
color: var(--primary);
}
.frequent-menu-card__group {
.workspace-entry-card__group {
margin-top: var(--space-1);
color: var(--text-tertiary);
font-size: var(--admin-font-size-sm);
}
.frequent-menu-card strong {
.workspace-entry-card strong {
min-width: 0;
overflow: hidden;
color: var(--text-primary);
@ -88,12 +86,7 @@
white-space: nowrap;
}
.frequent-menu-card__meta {
color: var(--text-tertiary);
font-size: var(--admin-font-size-sm);
}
.frequent-menu-empty {
.workspace-entry-empty {
display: flex;
min-height: 88px;
align-items: center;

View File

@ -1,8 +1,5 @@
import { useOutletContext } from "react-router-dom";
import { DashboardFrequentMenus } from "@/features/dashboard/components/DashboardFrequentMenus.jsx";
import { DashboardWorkspaces } from "@/features/dashboard/components/DashboardWorkspaces.jsx";
export function OverviewPage() {
const { menus = [] } = useOutletContext() || {};
return <DashboardFrequentMenus menus={menus} />;
return <DashboardWorkspaces />;
}

View File

@ -2,7 +2,7 @@ import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
export const dashboardRoutes = [
{
label: "系统概览",
label: "工作台",
loader: () => import("./pages/OverviewPage.jsx").then((module) => module.OverviewPage),
menuCode: MENU_CODES.overview,
pageKey: "overview",

View File

@ -1,5 +1,7 @@
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
import CloseOutlined from "@mui/icons-material/CloseOutlined";
import SearchOutlined from "@mui/icons-material/SearchOutlined";
import IconButton from "@mui/material/IconButton";
import InputAdornment from "@mui/material/InputAdornment";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
@ -18,6 +20,7 @@ export function ResourceSelectField({
loading = false,
onChange = () => {},
placeholder = "请选择资源",
required = true,
resources = [],
value,
}) {
@ -74,6 +77,12 @@ export function ResourceSelectField({
setOpen(false);
};
const clearResource = (event) => {
event.preventDefault();
event.stopPropagation();
onChange("", null);
};
return (
<>
<TextField
@ -82,8 +91,20 @@ export function ResourceSelectField({
disabled={disabled || loading}
label={label}
placeholder={loading ? "资源加载中" : placeholder}
required
slotProps={{ htmlInput: { "aria-haspopup": "dialog", readOnly: true, role: "button" } }}
required={required}
slotProps={{
htmlInput: { "aria-haspopup": "dialog", readOnly: true, role: "button" },
input:
selectedResource && !required
? {
endAdornment: (
<IconButton aria-label="清除资源" edge="end" size="small" onClick={clearResource}>
<CloseOutlined fontSize="small" />
</IconButton>
),
}
: undefined,
}}
value={displayValue}
onClick={openDrawer}
onKeyDown={(event) => {

View File

@ -53,3 +53,13 @@ test("resource select drawer stays above dialogs and filters by category", () =>
expect(screen.queryByText("Rose Gift")).not.toBeInTheDocument();
expect(screen.getByText("VIP Vehicle")).toBeInTheDocument();
});
test("optional resource field can clear an existing binding without opening drawer", () => {
const onChange = vi.fn();
render(<ResourceSelectField onChange={onChange} required={false} resources={resources} value="2" />);
fireEvent.click(screen.getByRole("button", { name: "清除资源" }));
expect(onChange).toHaveBeenCalledWith("", null);
expect(screen.queryByRole("dialog", { name: "选择资源" })).not.toBeInTheDocument();
});

View File

@ -46,6 +46,7 @@ export const resourceGrantSubjectLabels = {
resource_group: "资源组",
vip: "VIP",
vip_level: "VIP等级",
vip_trial_card: "VIP体验卡",
};
export const emojiPackPricingLabels = {
@ -107,6 +108,7 @@ export const resourceTypeFilters = [
["mic_seat_icon", "麦位图标"],
["mic_seat_animation", "麦位动效"],
["emoji_pack", "表情包"],
["vip_trial_card", "VIP体验卡"],
];
export const resourceTypeLabels = Object.fromEntries(resourceTypeFilters.filter(([value]) => value));

View File

@ -5,9 +5,11 @@ import { useResourceGrantListPage } from "./useResourcePages.js";
const mocks = vi.hoisted(() => ({
can: vi.fn(),
getVipConfig: vi.fn(),
getVipProgram: vi.fn(),
grantResource: vi.fn(),
grantResourceGroup: vi.fn(),
grantVip: vi.fn(),
grantVipTrialCard: vi.fn(),
listGiftTypes: vi.fn(),
listGifts: vi.fn(),
listResourceGroups: vi.fn(),
@ -47,15 +49,19 @@ vi.mock("@/features/resources/api", async (importOriginal) => ({
vi.mock("@/features/vip-config/api", () => ({
getVipConfig: mocks.getVipConfig,
getVipProgram: mocks.getVipProgram,
grantVip: mocks.grantVip,
grantVipTrialCard: mocks.grantVipTrialCard,
}));
beforeEach(() => {
mocks.can.mockReturnValue(true);
mocks.getVipConfig.mockResolvedValue({ levels: [{ level: 5, name: "VIP5", status: "active" }] });
mocks.getVipProgram.mockResolvedValue({ programType: "legacy_timed", trialCardEnabled: false });
mocks.grantResource.mockResolvedValue({});
mocks.grantResourceGroup.mockResolvedValue({});
mocks.grantVip.mockResolvedValue({ vip: { level: 5, name: "VIP5" } });
mocks.grantVipTrialCard.mockResolvedValue({});
mocks.listGiftTypes.mockResolvedValue([]);
mocks.listGifts.mockResolvedValue({ items: [], page: 1, pageSize: 100, total: 0 });
mocks.listResourceGroups.mockResolvedValue({ items: [], page: 1, pageSize: 100, total: 0 });
@ -161,7 +167,7 @@ test("resource group grant branch still calls resource group grant api", async (
expect(mocks.grantResource).not.toHaveBeenCalled();
});
test("create dialog loads only vip levels when user only has vip grant permission", async () => {
test("create dialog loads only vip program and levels when user only has vip grant permission", async () => {
mocks.can.mockImplementation((permission) => permission === "vip-config:grant");
const { result } = renderHook(() => useResourceGrantListPage());
@ -171,6 +177,7 @@ test("create dialog loads only vip levels when user only has vip grant permissio
await waitFor(() => expect(mocks.getVipConfig).toHaveBeenCalledTimes(1));
expect(result.current.form.subjectType).toBe("vip");
expect(mocks.getVipProgram).toHaveBeenCalledTimes(1);
expect(mocks.listResources).not.toHaveBeenCalled();
expect(mocks.listGifts).not.toHaveBeenCalled();
expect(mocks.listGiftTypes).not.toHaveBeenCalled();
@ -188,6 +195,39 @@ test("create dialog skips vip config when user lacks vip grant permission", asyn
await waitFor(() => expect(mocks.listResources).toHaveBeenCalled());
expect(result.current.form.subjectType).toBe("resource");
expect(mocks.getVipConfig).not.toHaveBeenCalled();
expect(mocks.getVipProgram).not.toHaveBeenCalled();
});
test("fami vip trial card grant resolves resource by level and sends duration", async () => {
mocks.getVipProgram.mockResolvedValue({ programType: "tiered_privilege_v1", trialCardEnabled: true });
const { result } = renderHook(() => useResourceGrantListPage());
await act(async () => {
result.current.openCreateGrant();
});
await waitFor(() => expect(result.current.vipProgram.programType).toBe("tiered_privilege_v1"));
await act(async () => {
result.current.setForm(
grantFormFixture({
durationDays: "20",
subjectType: "vip_trial_card",
vipLevel: "5",
}),
);
});
await act(async () => {
await result.current.submitGrant({ preventDefault: vi.fn() });
});
expect(mocks.grantVipTrialCard).toHaveBeenCalledWith(
expect.objectContaining({
durationMs: 20 * 24 * 60 * 60 * 1000,
level: 5,
reason: "manual",
targetUserId: "318705991371722752",
}),
);
expect(mocks.grantVip).not.toHaveBeenCalled();
});
function grantFormFixture(patch = {}) {

View File

@ -43,7 +43,7 @@ import {
updateResourceGroup,
upsertResourceShopItems,
} from "@/features/resources/api";
import { getVipConfig, grantVip } from "@/features/vip-config/api";
import { getVipConfig, getVipProgram, grantVip, grantVipTrialCard } from "@/features/vip-config/api";
import {
cpRelationTypeLabels,
cpRelationTypeOptions,
@ -1851,6 +1851,7 @@ export function useResourceGrantListPage() {
const [giftTypeOptions, setGiftTypeOptions] = useState(defaultGiftTypeOptions);
const [groupOptions, setGroupOptions] = useState([]);
const [resourceOptions, setResourceOptions] = useState([]);
const [vipProgram, setVipProgram] = useState({ programType: "legacy_timed", trialCardEnabled: false });
const [vipLevelOptions, setVipLevelOptions] = useState([]);
const [optionsLoading, setOptionsLoading] = useState(false);
const [loadingAction, setLoadingAction] = useState("");
@ -1890,8 +1891,14 @@ export function useResourceGrantListPage() {
setGroupOptions([]);
}
if (abilities.canGrantVip) {
optionRequests.push(getVipConfig().then((data) => ({ data, type: "vip" })));
optionRequests.push(
Promise.all([
getVipProgram(),
getVipConfig(),
]).then(([program, config]) => ({ data: { config, program }, type: "vip" })),
);
} else {
setVipProgram({ programType: "legacy_timed", trialCardEnabled: false });
setVipLevelOptions([]);
}
Promise.allSettled(optionRequests)
@ -1908,7 +1915,11 @@ export function useResourceGrantListPage() {
const { gifts, giftTypes, groups, resources } = result.value.data;
setResourceOptions(
(resources.items || []).filter(
(resource) => resource.status !== "disabled" && resource.grantable !== false,
(resource) =>
resource.status !== "disabled" &&
resource.grantable !== false &&
// Fami 体验卡必须走专用发卡命令,不能从通用资源分支绕过 level/duration 校验。
resource.resourceType !== "vip_trial_card",
),
);
setGiftOptions(
@ -1918,13 +1929,22 @@ export function useResourceGrantListPage() {
setGroupOptions(groups.items || []);
return;
}
const activeVipLevels = (result.value.data.levels || []).filter((level) => level.status === "active");
const { config, program } = result.value.data;
const activeVipLevels = (config.levels || []).filter((level) => level.status === "active");
const isTrialCardProgram = usesVipTrialCardGrant(program);
setVipProgram(program);
setVipLevelOptions(activeVipLevels);
setForm((current) => {
if (current.subjectType !== "vip" || current.vipLevel || !activeVipLevels.length) {
if (!isVipGrantSubject(current.subjectType)) {
return current;
}
return { ...current, vipLevel: String(activeVipLevels[0].level) };
const subjectType = isTrialCardProgram ? "vip_trial_card" : "vip";
const vipLevel = current.vipLevel || String(activeVipLevels[0]?.level || "");
return {
...current,
subjectType,
vipLevel,
};
});
});
})
@ -1967,10 +1987,19 @@ export function useResourceGrantListPage() {
}
} else if (payload.subjectType === "resource_group") {
await grantResourceGroup(payload.group);
} else {
} else if (payload.subjectType === "vip") {
await grantVip(payload.vip);
} else {
await grantVipTrialCard(payload.vipTrialCard);
}
showToast(payload.subjectType === "vip" ? "VIP已赠送" : "资源已赠送", "success");
showToast(
payload.subjectType === "vip"
? "VIP已赠送"
: payload.subjectType === "vip_trial_card"
? "VIP体验卡已赠送"
: "资源已赠送",
"success",
);
closeAction();
setForm(emptyGrantForm());
await result.reload();
@ -2044,6 +2073,7 @@ export function useResourceGrantListPage() {
status,
submitGrant,
targetFilter,
vipProgram,
vipLevelOptions,
};
}
@ -2401,6 +2431,18 @@ function buildGrantPayload(form, resolvedTargetUserId) {
},
};
}
if (form.subjectType === "vip_trial_card") {
return {
subjectType: "vip_trial_card",
vipTrialCard: {
commandId: makeCommandId("vip-trial-card-grant"),
durationMs: Math.round(Number(form.durationDays || 0) * dayMillis),
level: Number(form.vipLevel),
reason,
targetUserId,
},
};
}
return {
group: {
commandId: makeCommandId("resource-group-grant"),
@ -2420,6 +2462,9 @@ function grantResourceIds(form) {
}
function isRevocableGrant(grant) {
if (isVipTrialCardRecord(grant)) {
return true;
}
if (isVipGrantRecord(grant)) {
return false;
}
@ -2427,18 +2472,38 @@ function isRevocableGrant(grant) {
}
function isVipGrantRecord(grant) {
if (isVipTrialCardRecord(grant)) {
return false;
}
const source = String(grant?.grantSource || "").trim();
const commandId = String(grant?.commandId || "").trim();
return source === "admin_grant" || source === "activity_grant" || source === "vip_purchase" || commandId.startsWith("vip_reward:");
}
function isVipTrialCardRecord(grant) {
return (
grant?.grantSubjectType === "vip_trial_card" || String(grant?.grantSource || "").trim() === "vip_trial_card_grant"
);
}
function canSubmitGrantSubject(subjectType, abilities) {
if (subjectType === "vip") {
if (isVipGrantSubject(subjectType)) {
return Boolean(abilities.canGrantVip);
}
return Boolean(abilities.canCreateGrant);
}
function isVipGrantSubject(subjectType) {
return subjectType === "vip" || subjectType === "vip_trial_card";
}
function usesVipTrialCardGrant(program) {
if (program?.grantMode) {
return program.grantMode === "trial_card" && program.trialCardEnabled !== false;
}
return program?.programType === "tiered_privilege_v1" && program?.trialCardEnabled !== false;
}
function defaultGrantSubjectType(abilities) {
if (abilities.canCreateGrant) {
return "resource";

View File

@ -166,6 +166,7 @@ export function ResourceGrantListPage() {
optionsLoading={page.optionsLoading}
resourceOptions={page.resourceOptions}
setForm={page.setForm}
vipProgram={page.vipProgram}
vipLevelOptions={page.vipLevelOptions}
onClose={page.closeAction}
onSubmit={page.submitGrant}
@ -212,6 +213,7 @@ function ResourceGrantDialog({
optionsLoading,
resourceOptions,
setForm,
vipProgram,
vipLevelOptions,
}) {
const resourceGrantAllowed = Boolean(abilities?.canCreateGrant);
@ -222,7 +224,13 @@ function ResourceGrantDialog({
const vipLevelValue = (vipLevelOptions || []).some((level) => String(level.level) === currentVipLevel)
? currentVipLevel
: "";
const vipUnavailable = form.subjectType === "vip" && (!vipGrantAllowed || !hasVipLevels);
const isTrialCardProgram = vipProgram?.grantMode
? vipProgram.grantMode === "trial_card" && vipProgram.trialCardEnabled !== false
: vipProgram?.programType === "tiered_privilege_v1" && vipProgram?.trialCardEnabled !== false;
const vipProgramEnabled = vipProgram?.status !== "disabled";
const vipUnavailable =
(form.subjectType === "vip" || form.subjectType === "vip_trial_card") &&
(!vipGrantAllowed || !vipProgramEnabled || !hasVipLevels);
const submitDisabled = disabled || loading || optionsLoading || !subjectAllowed || vipUnavailable;
const handleSubjectChange = (event) => {
const subjectType = event.target.value;
@ -232,7 +240,17 @@ function ResourceGrantDialog({
resourceId: "",
resourceIds: [],
subjectType,
vipLevel: subjectType === "vip" ? String((vipLevelOptions || [])[0]?.level || "") : "",
vipLevel:
subjectType === "vip" || subjectType === "vip_trial_card"
? String((vipLevelOptions || [])[0]?.level || "")
: "",
});
};
const handleVipLevelChange = (event) => {
const vipLevel = event.target.value;
setForm({
...form,
vipLevel,
});
};
@ -267,7 +285,11 @@ function ResourceGrantDialog({
>
{resourceGrantAllowed ? <MenuItem value="resource">资源</MenuItem> : null}
{resourceGrantAllowed ? <MenuItem value="resource_group">资源组</MenuItem> : null}
{vipGrantAllowed ? <MenuItem value="vip">VIP</MenuItem> : null}
{vipGrantAllowed ? (
<MenuItem value={isTrialCardProgram ? "vip_trial_card" : "vip"}>
{isTrialCardProgram ? "VIP体验卡" : "VIP"}
</MenuItem>
) : null}
</TextField>
{form.subjectType === "resource" ? (
<>
@ -318,28 +340,46 @@ function ResourceGrantDialog({
))}
</TextField>
) : null}
{form.subjectType === "vip" ? (
<TextField
disabled={disabled || optionsLoading || !vipGrantAllowed || !hasVipLevels}
helperText={!hasVipLevels && !optionsLoading ? "暂无启用VIP等级" : undefined}
label="VIP等级"
required
select
value={vipLevelValue}
onChange={(event) => setForm({ ...form, vipLevel: event.target.value })}
>
{hasVipLevels ? (
(vipLevelOptions || []).map((level) => (
<MenuItem key={level.level} value={String(level.level)}>
VIP{level.level} · {level.name || `VIP${level.level}`}
{form.subjectType === "vip" || form.subjectType === "vip_trial_card" ? (
<>
<TextField
disabled={disabled || optionsLoading || !vipGrantAllowed || !hasVipLevels}
helperText={
!vipProgramEnabled
? "VIP体系已停用"
: !hasVipLevels && !optionsLoading
? "暂无启用VIP等级"
: undefined
}
label="VIP等级"
required
select
value={vipLevelValue}
onChange={handleVipLevelChange}
>
{hasVipLevels ? (
(vipLevelOptions || []).map((level) => (
<MenuItem key={level.level} value={String(level.level)}>
VIP{level.level} · {level.name || `VIP${level.level}`}
</MenuItem>
))
) : (
<MenuItem disabled value="">
暂无启用VIP等级
</MenuItem>
))
) : (
<MenuItem disabled value="">
暂无启用VIP等级
</MenuItem>
)}
</TextField>
)}
</TextField>
{form.subjectType === "vip_trial_card" ? (
<TextField
disabled={disabled || optionsLoading}
label="有效天数"
required
type="number"
value={form.durationDays}
onChange={(event) => setForm({ ...form, durationDays: event.target.value })}
/>
) : null}
</>
) : null}
</AdminFormFieldGrid>
</AdminFormSection>
@ -417,9 +457,11 @@ function GrantItemMedia({ item }) {
}
function GrantSubject({ grant }) {
const subjectLabel = isVipGrantRecord(grant)
? "VIP奖励资源组"
: resourceGrantSubjectLabels[grant.grantSubjectType] || grant.grantSubjectType || "发放";
const subjectLabel = isVipTrialCardRecord(grant)
? "VIP体验卡"
: isVipGrantRecord(grant)
? "VIP奖励资源组"
: resourceGrantSubjectLabels[grant.grantSubjectType] || grant.grantSubjectType || "发放";
const sourceLabel = grantSourceLabel(grant.grantSource, grant);
return (
<div className={styles.stack}>
@ -486,6 +528,7 @@ const grantSourceLabels = {
room_rocket: "房间火箭",
seven_day_checkin: "七日签到",
vip_purchase: "VIP购买",
vip_trial_card_grant: "后台VIP体验卡赠送",
weekly_star: "周星奖励",
wheel_reward: "转盘奖励",
};
@ -510,6 +553,9 @@ function reasonLabel(reason) {
}
function subjectMeta(grant) {
if (isVipTrialCardRecord(grant)) {
return grant.grantSubjectId ? `体验卡资源 ID ${grant.grantSubjectId}` : "-";
}
if (isVipGrantRecord(grant)) {
return grant.grantSubjectId ? `奖励资源组 ID ${grant.grantSubjectId}` : "-";
}
@ -521,6 +567,9 @@ function subjectMeta(grant) {
}
function isRevocableGrant(grant) {
if (isVipTrialCardRecord(grant)) {
return true;
}
if (isVipGrantRecord(grant)) {
return false;
}
@ -528,6 +577,9 @@ function isRevocableGrant(grant) {
}
function isVipGrantRecord(grant) {
if (isVipTrialCardRecord(grant)) {
return false;
}
const source = String(grant?.grantSource || "").trim();
return (
source === "admin_grant" ||
@ -537,6 +589,12 @@ function isVipGrantRecord(grant) {
);
}
function isVipTrialCardRecord(grant) {
return (
grant?.grantSubjectType === "vip_trial_card" || String(grant?.grantSource || "").trim() === "vip_trial_card_grant"
);
}
function isVipRewardCommand(grant) {
return String(grant?.commandId || "")
.trim()

View File

@ -176,6 +176,27 @@ test("resource grant dialog shows vip subject and level selector", () => {
expect(screen.getByText("VIP5 · 黄金VIP")).toBeInTheDocument();
});
test("fami resource grant dialog exposes vip trial card level and duration", () => {
vi.mocked(useResourceGrantListPage).mockReturnValue(
pageFixture({
activeAction: "create",
form: grantFormFixture({
durationDays: "20",
subjectType: "vip_trial_card",
vipLevel: "5",
}),
vipLevelOptions: [{ level: 5, name: "黄金VIP", status: "active" }],
vipProgram: { programType: "tiered_privilege_v1", trialCardEnabled: true },
}),
);
render(<ResourceGrantListPage />);
expect(screen.getByRole("combobox", { name: "对象类型" })).toHaveTextContent("VIP体验卡");
expect(screen.getByText("VIP5 · 黄金VIP")).toBeInTheDocument();
expect(screen.getByRole("spinbutton", { name: "有效天数" })).toHaveValue(20);
});
test("resource grant dialog hides vip subject without vip grant permission", () => {
vi.mocked(useResourceGrantListPage).mockReturnValue(
pageFixture({
@ -259,6 +280,7 @@ function pageFixture(patch = {}) {
status: "",
submitGrant: vi.fn(),
vipLevelOptions: [],
vipProgram: { programType: "legacy_timed", trialCardEnabled: false },
...restPatch,
};
}

View File

@ -14,6 +14,7 @@ const resourceTypes = [
"mic_seat_icon",
"mic_seat_animation",
"emoji_pack",
"vip_trial_card",
];
const walletAssetTypes = ["COIN"];
const resourcePriceTypes = ["coin", "free"];
@ -446,7 +447,7 @@ export const resourceGrantFormSchema = z
reason: z.string().trim().min(1, "请输入原因").max(240, "原因不能超过 240 个字符"),
resourceId: z.union([z.string(), z.number()]).optional(),
resourceIds: z.array(z.union([z.string(), z.number()])).optional(),
subjectType: z.enum(["resource", "resource_group", "vip"]),
subjectType: z.enum(["resource", "resource_group", "vip", "vip_trial_card"]),
targetUserId: z.union([z.string(), z.number()]),
vipLevel: z.union([z.string(), z.number()]).optional(),
})
@ -505,13 +506,22 @@ export const resourceGrantFormSchema = z
path: ["groupId"],
});
}
if (value.subjectType === "vip" && (!Number.isInteger(vipLevel) || vipLevel <= 0)) {
if ((value.subjectType === "vip" || value.subjectType === "vip_trial_card") && (!Number.isInteger(vipLevel) || vipLevel <= 0)) {
context.addIssue({
code: "custom",
message: "请选择VIP等级",
path: ["vipLevel"],
});
}
if (value.subjectType === "vip_trial_card") {
if (!Number.isInteger(durationDays) || durationDays <= 0) {
context.addIssue({
code: "custom",
message: "VIP体验卡有效天数必须大于 0",
path: ["durationDays"],
});
}
}
});
function grantResourceIds(value) {

View File

@ -327,6 +327,19 @@ describe("resource form schema", () => {
expect(() => parseForm(resourceGrantFormSchema, { ...basePayload, reason: "" })).toThrow(FormValidationError);
});
test("validates vip trial card level and duration", () => {
const payload = parseForm(resourceGrantFormSchema, {
durationDays: "20",
reason: "manual",
subjectType: "vip_trial_card",
targetUserId: "1001",
vipLevel: "5",
});
expect(payload.subjectType).toBe("vip_trial_card");
expect(() => parseForm(resourceGrantFormSchema, { ...payload, durationDays: "0" })).toThrow(FormValidationError);
});
test("validates resource shop durations", () => {
const payload = parseForm(resourceShopItemsFormSchema, {
items: [

View File

@ -0,0 +1,257 @@
import { afterEach, expect, test, vi } from "vitest";
import { setAccessToken, setSelectedAppCode } from "@/shared/api/request";
import { getVipConfig, getVipProgram, grantVipTrialCard, updateVipConfig, updateVipProgram } from "./api";
afterEach(() => {
setAccessToken("");
setSelectedAppCode("lalu");
vi.unstubAllGlobals();
});
test("vip program and level APIs normalize snake case responses", async () => {
setSelectedAppCode("fami");
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValueOnce(
envelope({
config: {
app_code: "fami",
config_version: 3,
grant_mode: "trial_card",
level_count: 9,
program_type: "tiered_privilege_v1",
status: "active",
trial_card_enabled: true,
upgrade_expiry_policy: "replace_from_now",
},
benefits: [
{
auto_equip: false,
benefit_code: "anti_kick",
benefit_type: "function",
execution_scope: "room",
metadata_json: "{}",
name: "防踢",
resource_id: 0,
resource_type: "",
sort_order: 320,
status: "active",
trial_enabled: true,
unlock_level: 9,
},
],
}),
)
.mockResolvedValueOnce(
envelope({
levels: [
{
benefits: [
{
benefit_code: "anti_kick",
benefit_type: "function",
unlock_level: 9,
name: "防踢",
resource_id: 0,
resource_type: "",
execution_scope: "room",
auto_equip: false,
metadata_json: "{}",
sort_order: 10,
status: "active",
trial_enabled: true,
},
{
auto_equip: false,
benefit_code: "daily_coin_rebate",
benefit_type: "function",
execution_scope: "wallet",
metadata_json: "{}",
name: "金币返现",
resource_id: 0,
resource_type: "",
sort_order: 20,
status: "active",
trial_enabled: true,
unlock_level: 6,
},
],
duration_ms: 2592000000,
level: 9,
name: "VIP9",
price_coin: 9000,
reward_resource_group_id: 99,
sort_order: 90,
status: "active",
},
],
server_time_ms: 1780000000000,
}),
),
);
const program = await getVipProgram();
const config = await getVipConfig();
expect(program).toMatchObject({
appCode: "fami",
benefitInheritancePolicy: "target_only",
configVersion: 3,
grantMode: "trial_card",
levelCount: 9,
programType: "tiered_privilege_v1",
trialCardEnabled: true,
upgradeExpiryPolicy: "replace_from_now",
});
expect(program.benefits).toEqual([
expect.objectContaining({ benefitCode: "anti_kick", executionScope: "room", unlockLevel: 9 }),
]);
expect(config.levels[0]).toMatchObject({
durationMs: 2592000000,
level: 9,
priceCoin: 9000,
rewardResourceGroupId: 99,
});
expect(config.levels[0].benefits[0]).toEqual({
benefitCode: "anti_kick",
benefitType: "function",
unlockLevel: 9,
name: "防踢",
resourceId: 0,
resourceType: "",
executionScope: "room",
autoEquip: false,
metadataJson: "{}",
sortOrder: 10,
status: "active",
trialEnabled: true,
});
expect(config.levels[0].benefits[1]).toMatchObject({
benefitCode: "daily_coin_rebate",
trialEnabled: false,
});
expect(requestHeaders(vi.mocked(fetch).mock.calls[0][1])["X-App-Code"]).toBe("fami");
});
test("vip updates and trial grant use generated contract paths", async () => {
setSelectedAppCode("fami");
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValueOnce(
envelope({ appCode: "fami", levelCount: 9, programType: "tiered_privilege_v1", status: "active" }),
)
.mockResolvedValueOnce(envelope({ levels: [] }))
.mockResolvedValueOnce(envelope({ transactionId: "trial_1" }, 201)),
);
await updateVipProgram({
benefitInheritancePolicy: "target_only",
downgradePurchasePolicy: "reject",
grantMode: "trial_card",
levelCount: 9,
programType: "tiered_privilege_v1",
sameLevelExpiryPolicy: "extend_remaining",
status: "active",
trialCardEnabled: true,
upgradeExpiryPolicy: "replace_from_now",
});
await updateVipConfig({
levels: [
{
benefits: [
{
benefitCode: "room_entry_notice",
benefitType: "function",
unlockLevel: 2,
name: "VIP进房通知",
resourceId: 0,
resourceType: "",
executionScope: "room",
autoEquip: false,
metadataJson: "{}",
sortOrder: 10,
status: "active",
trialEnabled: true,
},
{
benefitCode: "daily_coin_rebate",
benefitType: "function",
unlockLevel: 6,
name: "金币返现",
resourceId: 0,
resourceType: "",
executionScope: "wallet",
autoEquip: false,
metadataJson: "{}",
sortOrder: 20,
status: "active",
trialEnabled: true,
},
],
durationMs: 2592000000,
level: 2,
name: "VIP2",
priceCoin: 2000,
requiredRechargeCoinAmount: 0,
rewardResourceGroupId: 22,
sortOrder: 20,
status: "active",
},
],
});
await grantVipTrialCard({
commandId: "vip-trial-card-grant-test",
durationMs: 20 * 24 * 60 * 60 * 1000,
level: 5,
reason: "manual",
targetUserId: "318705991371722752",
});
const [programUrl, programInit] = vi.mocked(fetch).mock.calls[0];
const [configUrl, configInit] = vi.mocked(fetch).mock.calls[1];
const [grantUrl, grantInit] = vi.mocked(fetch).mock.calls[2];
expect(String(programUrl)).toContain("/api/v1/admin/activity/vip-program");
expect(programInit?.method).toBe("PUT");
expect(requestBody(programInit)).toMatchObject({
config: {
levelCount: 9,
programType: "tiered_privilege_v1",
status: "active",
grantMode: "trial_card",
benefitInheritancePolicy: "target_only",
upgradeExpiryPolicy: "replace_from_now",
},
});
expect(String(configUrl)).toContain("/api/v1/admin/activity/vip-levels");
expect(configInit?.method).toBe("PUT");
expect(requestBody(configInit)).toMatchObject({
levels: [
expect.objectContaining({
benefits: [
expect.objectContaining({ benefitCode: "room_entry_notice" }),
expect.objectContaining({ benefitCode: "daily_coin_rebate", trialEnabled: false }),
],
}),
],
});
expect(String(grantUrl)).toContain("/api/v1/admin/activity/vip-trial-card-grants");
expect(grantInit?.method).toBe("POST");
expect(requestBody(grantInit)).toMatchObject({ durationMs: 1728000000, level: 5 });
expect(requestBody(grantInit)).not.toHaveProperty("resourceId");
});
function envelope(data: unknown, status = 200) {
return new Response(JSON.stringify({ code: 0, data }), { status });
}
function requestHeaders(init: RequestInit | undefined): Record<string, string> {
return (init?.headers || {}) as Record<string, string>;
}
function requestBody(init: RequestInit | undefined): Record<string, unknown> {
return JSON.parse(String(init?.body || "{}")) as Record<string, unknown>;
}

View File

@ -1,4 +1,56 @@
import { apiRequest } from "@/shared/api/request";
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
export type VipProgramType = "legacy_timed" | "tiered_privilege_v1";
export type VipBenefitInheritancePolicy = "target_only";
export interface VipProgramDto {
appCode: string;
programType: VipProgramType;
levelCount: number;
status: string;
configVersion?: number;
sameLevelExpiryPolicy?: string;
upgradeExpiryPolicy?: string;
downgradePurchasePolicy?: string;
benefitInheritancePolicy?: VipBenefitInheritancePolicy;
grantMode?: string;
trialCardEnabled?: boolean;
createdAtMs?: number;
updatedAtMs?: number;
benefits?: VipBenefitDto[];
}
export interface VipProgramPayload {
programType: VipProgramType;
levelCount: number;
sameLevelExpiryPolicy: string;
upgradeExpiryPolicy: string;
downgradePurchasePolicy: string;
benefitInheritancePolicy: VipBenefitInheritancePolicy;
grantMode: string;
trialCardEnabled: boolean;
status: string;
}
interface VipProgramUpdatePayload {
config: VipProgramPayload;
}
export interface VipBenefitDto {
benefitCode: string;
name: string;
benefitType: string;
unlockLevel: number;
status: string;
trialEnabled: boolean;
resourceId: number;
resourceType: string;
executionScope: string;
autoEquip: boolean;
sortOrder: number;
metadataJson: string;
}
export interface VipLevelDto {
level: number;
@ -8,6 +60,7 @@ export interface VipLevelDto {
durationMs: number;
rewardResourceGroupId: number;
sortOrder: number;
benefits: VipBenefitDto[];
rechargeGateRequired: boolean;
requiredRechargeCoinAmount: number;
userRechargeCoinAmount?: number;
@ -31,6 +84,7 @@ export interface VipConfigPayload {
rewardResourceGroupId: number;
sortOrder: number;
requiredRechargeCoinAmount: number;
benefits: VipBenefitDto[];
}>;
}
@ -67,29 +121,107 @@ export interface VipGrantPayload {
reason: string;
}
const path = "/v1/admin/activity/vip-levels";
const grantPath = "/v1/admin/activity/vip-grants";
export interface VipTrialCardGrantPayload {
commandId?: string;
targetUserId: string;
level: number;
resourceId?: number;
durationMs: number;
reason: string;
}
export function getVipProgram(): Promise<VipProgramDto> {
const endpoint = API_ENDPOINTS.getVipProgram;
return apiRequest<RawVipProgramResponse>(apiEndpointPath(API_OPERATIONS.getVipProgram), {
method: endpoint.method,
}).then(normalizeVipProgramResponse);
}
export function updateVipProgram(payload: VipProgramPayload): Promise<VipProgramDto> {
const endpoint = API_ENDPOINTS.updateVipProgram;
return apiRequest<RawVipProgramResponse, VipProgramUpdatePayload>(
apiEndpointPath(API_OPERATIONS.updateVipProgram),
{
body: { config: payload },
method: endpoint.method,
},
).then(normalizeVipProgramResponse);
}
export function getVipConfig(): Promise<VipConfigDto> {
return apiRequest<RawConfig>(path).then(normalizeConfig);
const endpoint = API_ENDPOINTS.getVipConfig;
return apiRequest<RawConfig>(apiEndpointPath(API_OPERATIONS.getVipConfig), {
method: endpoint.method,
}).then(normalizeConfig);
}
export function updateVipConfig(payload: VipConfigPayload): Promise<VipConfigDto> {
return apiRequest<RawConfig, VipConfigPayload>(path, { body: payload, method: "PUT" }).then(normalizeConfig);
const endpoint = API_ENDPOINTS.updateVipConfig;
return apiRequest<RawConfig, VipConfigPayload>(apiEndpointPath(API_OPERATIONS.updateVipConfig), {
// 金币返现只属于付费 VIPAPI 边界再收敛一次,避免未来调用方绕过页面控件恢复体验卡返现。
body: enforceVipBenefitBoundaries(payload),
method: endpoint.method,
}).then(normalizeConfig);
}
export function grantVip(payload: VipGrantPayload): Promise<VipGrantDto> {
return apiRequest<RawVipGrant, VipGrantPayload>(grantPath, { body: payload, method: "POST" }).then(
normalizeVipGrant,
);
const endpoint = API_ENDPOINTS.grantVip;
return apiRequest<RawVipGrant, VipGrantPayload>(apiEndpointPath(API_OPERATIONS.grantVip), {
body: payload,
method: endpoint.method,
}).then(normalizeVipGrant);
}
export function grantVipTrialCard(payload: VipTrialCardGrantPayload): Promise<unknown> {
const endpoint = API_ENDPOINTS.grantVipTrialCard;
return apiRequest<unknown, VipTrialCardGrantPayload>(apiEndpointPath(API_OPERATIONS.grantVipTrialCard), {
body: payload,
method: endpoint.method,
});
}
type RawVipProgram = VipProgramDto & Record<string, unknown>;
type RawVipProgramResponse = RawVipProgram & {
benefits?: RawBenefit[];
config?: RawVipProgram;
program_config?: RawVipProgram;
};
type RawConfig = VipConfigDto & Record<string, unknown>;
type RawLevel = VipLevelDto & Record<string, unknown>;
type RawBenefit = VipBenefitDto & Record<string, unknown>;
type RawVipGrant = VipGrantDto & Record<string, unknown>;
type RawUserVip = UserVipDto & Record<string, unknown>;
type RawReward = VipRewardItemDto & Record<string, unknown>;
function normalizeVipProgram(item: RawVipProgram): VipProgramDto {
const programType = stringValue(item.programType ?? item.program_type);
return {
appCode: stringValue(item.appCode ?? item.app_code),
programType: programType === "tiered_privilege_v1" ? "tiered_privilege_v1" : "legacy_timed",
levelCount: numberValue(item.levelCount ?? item.level_count),
status: stringValue(item.status) || "active",
configVersion: numberValue(item.configVersion ?? item.config_version),
sameLevelExpiryPolicy: stringValue(item.sameLevelExpiryPolicy ?? item.same_level_expiry_policy),
upgradeExpiryPolicy: stringValue(item.upgradeExpiryPolicy ?? item.upgrade_expiry_policy),
downgradePurchasePolicy: stringValue(item.downgradePurchasePolicy ?? item.downgrade_purchase_policy),
// 滚动发布期间即使旧服务仍回 cumulative前端也只暴露并回传当前唯一合法策略。
benefitInheritancePolicy: "target_only",
grantMode: stringValue(item.grantMode ?? item.grant_mode),
trialCardEnabled: booleanValue(item.trialCardEnabled ?? item.trial_card_enabled),
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
};
}
function normalizeVipProgramResponse(item: RawVipProgramResponse): VipProgramDto {
const config = item.config ?? item.program_config;
const program = normalizeVipProgram((config && typeof config === "object" ? config : item) as RawVipProgram);
return {
...program,
benefits: Array.isArray(item.benefits) ? item.benefits.map(normalizeBenefit) : [],
};
}
function normalizeConfig(item: RawConfig): VipConfigDto {
const rawLevels = Array.isArray(item.levels) ? (item.levels as RawLevel[]) : [];
return {
@ -100,6 +232,7 @@ function normalizeConfig(item: RawConfig): VipConfigDto {
function normalizeLevel(item: RawLevel): VipLevelDto {
const level = numberValue(item.level);
const rawBenefits = Array.isArray(item.benefits) ? (item.benefits as RawBenefit[]) : [];
return {
level,
name: stringValue(item.name) || `VIP${level}`,
@ -108,6 +241,7 @@ function normalizeLevel(item: RawLevel): VipLevelDto {
durationMs: numberValue(item.durationMs ?? item.duration_ms),
rewardResourceGroupId: numberValue(item.rewardResourceGroupId ?? item.reward_resource_group_id),
sortOrder: numberValue(item.sortOrder ?? item.sort_order),
benefits: rawBenefits.map(normalizeBenefit),
rechargeGateRequired: false,
requiredRechargeCoinAmount: 0,
userRechargeCoinAmount: 0,
@ -117,6 +251,38 @@ function normalizeLevel(item: RawLevel): VipLevelDto {
};
}
function normalizeBenefit(item: RawBenefit): VipBenefitDto {
const benefitCode = stringValue(item.benefitCode ?? item.benefit_code);
return {
benefitCode,
name: stringValue(item.name),
benefitType: stringValue(item.benefitType ?? item.benefit_type) || "function",
unlockLevel: numberValue(item.unlockLevel ?? item.unlock_level),
status: stringValue(item.status) || "disabled",
// 历史脏数据可能把金币返现写成体验可用;读取时立即纠正,避免错误状态进入编辑表单。
trialEnabled:
benefitCode === "daily_coin_rebate" ? false : booleanValue(item.trialEnabled ?? item.trial_enabled),
resourceId: numberValue(item.resourceId ?? item.resource_id),
resourceType: stringValue(item.resourceType ?? item.resource_type),
executionScope: stringValue(item.executionScope ?? item.execution_scope) || "wallet",
autoEquip: booleanValue(item.autoEquip ?? item.auto_equip),
sortOrder: numberValue(item.sortOrder ?? item.sort_order),
metadataJson: stringValue(item.metadataJson ?? item.metadata_json) || "{}",
};
}
function enforceVipBenefitBoundaries(payload: VipConfigPayload): VipConfigPayload {
return {
...payload,
levels: payload.levels.map((level) => ({
...level,
benefits: level.benefits.map((benefit) =>
benefit.benefitCode === "daily_coin_rebate" ? { ...benefit, trialEnabled: false } : benefit,
),
})),
};
}
function normalizeVipGrant(item: RawVipGrant): VipGrantDto {
const rewardItems = Array.isArray(item.rewardItems) ? (item.rewardItems as RawReward[]) : [];
return {
@ -132,7 +298,7 @@ function normalizeUserVip(item: RawUserVip): UserVipDto {
userId: stringValue(item.userId ?? item.user_id),
level: numberValue(item.level),
name: stringValue(item.name),
active: Boolean(item.active),
active: booleanValue(item.active),
startedAtMs: numberValue(item.startedAtMs ?? item.started_at_ms),
expiresAtMs: numberValue(item.expiresAtMs ?? item.expires_at_ms),
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
@ -158,3 +324,10 @@ function numberValue(value: unknown) {
const number = Number(value || 0);
return Number.isFinite(number) ? number : 0;
}
function booleanValue(value: unknown) {
if (typeof value === "string") {
return value === "true" || value === "1";
}
return Boolean(value);
}

View File

@ -0,0 +1,88 @@
export const vipProgramOptions = [
["legacy_timed", "Lalu 经典VIP"],
["tiered_privilege_v1", "Fami P1 VIP"],
];
export const vipProgramLabels = Object.fromEntries(vipProgramOptions);
// 两套体系都按等级保存最终权益快照;隐藏策略不能再回退到 cumulative否则会重新引入自动继承语义。
export const vipProgramDefaults = {
legacy_timed: {
levelCount: 10,
sameLevelExpiryPolicy: "extend_remaining",
upgradeExpiryPolicy: "extend_remaining",
downgradePurchasePolicy: "reject",
benefitInheritancePolicy: "target_only",
grantMode: "direct_membership",
trialCardEnabled: false,
},
tiered_privilege_v1: {
levelCount: 9,
sameLevelExpiryPolicy: "extend_remaining",
upgradeExpiryPolicy: "replace_from_now",
downgradePurchasePolicy: "reject",
benefitInheritancePolicy: "target_only",
grantMode: "trial_card",
trialCardEnabled: true,
},
};
export const vipUpgradePolicyLabels = {
extend_remaining: "保留剩余时间并续期",
replace_from_now: "高等级从购买时重新计时",
};
export const vipGrantModeLabels = {
direct_membership: "真实VIP",
trial_card: "VIP体验卡",
};
// P1 的 34 项权益用于目录展示和兼容服务端契约unlockLevel 只是历史字段,后台不据此补齐、开启或继承权益。
export const p1BenefitCatalog = [
benefit("vip_badge_identity", "VIP标识", 1, "function", "user", false),
benefit("vip_medal", "VIP勋章", 1, "decoration", "user", true),
benefit("avatar_frame", "VIP头像框", 1, "decoration", "user", true),
benefit("vip_title", "VIP称号", 1, "function", "user", false),
benefit("chat_bubble", "VIP聊天气泡", 2, "decoration", "room", true),
benefit("vip_gift", "VIP礼物", 2, "function", "room", false),
benefit("entry_effect", "VIP进场通知", 2, "function", "room", false),
benefit("visitor_history", "查看访客", 2, "function", "user", false),
benefit("voice_wave", "VIP声波纹", 3, "decoration", "room", true),
benefit("profile_card", "VIP资料卡皮肤", 3, "decoration", "user", true),
benefit("custom_room_background", "自定义房间背景", 3, "function", "room", false),
benefit("room_image_message", "房间发图", 3, "function", "room", false),
benefit("animated_avatar", "动态头像", 4, "function", "user", false),
benefit("animated_room_cover", "动态房间封面", 4, "function", "room", false),
benefit("mic_skin", "麦克风皮肤", 4, "decoration", "room", true),
benefit("room_border", "房间边框", 4, "decoration", "room", true),
benefit("vehicle", "VIP座驾", 4, "decoration", "room", true),
benefit("colored_room_name", "VIP彩色房间名称", 5, "function", "room", false),
benefit("colored_nickname", "VIP彩色昵称", 5, "function", "user", false),
benefit("colored_id", "VIP彩色ID", 5, "function", "user", false),
benefit("gift_tray_skin", "送礼托盘皮肤", 5, "function", "room", false),
benefit("hide_profile_data", "隐藏个人数据", 6, "function", "user", false),
benefit("custom_avatar_frame", "定制头框", 6, "function", "user", false),
benefit("leaderboard_invisible", "榜单隐身", 6, "function", "user", false),
benefit("daily_coin_rebate", "金币返现", 6, "function", "wallet", false, false),
benefit("custom_profile_card", "定制资料卡", 7, "function", "user", false),
benefit("anonymous_profile_visit", "匿名访问主页", 7, "function", "user", false),
benefit("custom_gift", "定制礼物", 7, "function", "room", false),
benefit("room_entry_notice", "进房高亮通知", 8, "function", "room", false),
benefit("custom_pretty_id", "定制靓号", 8, "function", "user", false),
benefit("custom_vehicle", "定制座驾", 8, "function", "room", false),
benefit("anti_kick", "防踢", 9, "function", "room", false),
benefit("anti_mute", "防禁言", 9, "function", "room", false),
benefit("online_global_notice", "上线全服通知", 9, "function", "notice", false),
];
function benefit(benefitCode, name, unlockLevel, benefitType, executionScope, autoEquip, trialEnabled = true) {
return {
autoEquip,
benefitCode,
benefitType,
executionScope,
name,
trialEnabled,
unlockLevel,
};
}

View File

@ -0,0 +1,32 @@
import { describe, expect, test } from "vitest";
import { p1BenefitCatalog, vipProgramDefaults } from "./constants.js";
describe("P1 VIP defaults", () => {
test("keeps the confirmed target-only program policies", () => {
expect(vipProgramDefaults.tiered_privilege_v1).toMatchObject({
benefitInheritancePolicy: "target_only",
grantMode: "trial_card",
levelCount: 9,
trialCardEnabled: true,
upgradeExpiryPolicy: "replace_from_now",
});
expect(vipProgramDefaults.legacy_timed).toMatchObject({
benefitInheritancePolicy: "target_only",
grantMode: "direct_membership",
levelCount: 10,
upgradeExpiryPolicy: "extend_remaining",
});
});
test("keeps the product catalog without using unlock level as inheritance policy", () => {
expect(p1BenefitCatalog).toHaveLength(34);
expect(p1BenefitCatalog.find((benefit) => benefit.benefitCode === "daily_coin_rebate")?.trialEnabled).toBe(
false,
);
expect(p1BenefitCatalog.find((benefit) => benefit.benefitCode === "anti_kick")).toMatchObject({
benefitType: "function",
executionScope: "room",
unlockLevel: 9,
});
});
});

View File

@ -1,20 +1,37 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { getVipConfig, updateVipConfig } from "@/features/vip-config/api";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAppScope } from "@/app/app-scope/AppScopeProvider.jsx";
import { getVipConfig, getVipProgram, updateVipConfig, updateVipProgram } from "@/features/vip-config/api";
import { p1BenefitCatalog, vipProgramDefaults } from "@/features/vip-config/constants.js";
import { normalizeVipBenefitMetadata } from "@/features/vip-config/metadata.js";
import { useVipConfigAbilities } from "@/features/vip-config/permissions.js";
import { listResourceGroups } from "@/features/resources/api";
import { vipConfigFormSchema, vipProgramFormSchema } from "@/features/vip-config/schema.js";
import { listResourceGroups, listResources } from "@/features/resources/api";
import { parseForm } from "@/shared/forms/validation";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
const defaultDurationMs = 30 * 24 * 60 * 60 * 1000;
const dayMillis = 24 * 60 * 60 * 1000;
export function useVipConfigPage() {
const abilities = useVipConfigAbilities();
const { appCode } = useAppScope();
const { showToast } = useToast();
const [config, setConfig] = useState({ levels: defaultLevels() });
const [form, setForm] = useState({ levels: defaultLevels().map(levelToForm) });
const initialProgram = defaultProgram(appCode);
const [program, setProgram] = useState(initialProgram);
const [config, setConfig] = useState({ levels: defaultLevels(initialProgram) });
const [programForm, setProgramForm] = useState(programToForm(initialProgram));
const [form, setForm] = useState({
levels: defaultLevels(initialProgram).map((level) => levelToForm(level, initialProgram)),
});
const [resourceGroups, setResourceGroups] = useState([]);
const [drawerOpen, setDrawerOpen] = useState(false);
const [resources, setResources] = useState([]);
const [resourcesLoading, setResourcesLoading] = useState(false);
const [editorMode, setEditorMode] = useState("");
const [error, setError] = useState(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const reloadSequence = useRef(0);
const resourceLoadSequence = useRef(0);
const resourcesApp = useRef("");
const activeCount = useMemo(
() => (config.levels || []).filter((level) => level.status === "active").length,
@ -22,38 +39,113 @@ export function useVipConfigPage() {
);
const reload = useCallback(async () => {
const sequence = ++reloadSequence.current;
setLoading(true);
setError(null);
try {
const [remoteConfig, groups] = await Promise.all([
const [remoteProgram, remoteConfig, groups] = await Promise.all([
getVipProgram(),
getVipConfig(),
listResourceGroups({ page: 1, page_size: 100, status: "active" }),
]);
const levels = completeLevels(remoteConfig.levels || []);
if (sequence !== reloadSequence.current) {
return;
}
const nextProgram = normalizeProgram(remoteProgram, appCode);
const levels = completeVipLevels(remoteConfig.levels || [], nextProgram);
setProgram(nextProgram);
setProgramForm(programToForm(nextProgram));
setConfig({ ...remoteConfig, levels });
setForm({ levels: levels.map(levelToForm) });
setForm({ levels: levels.map((level) => levelToForm(level, nextProgram)) });
setResourceGroups(groups.items || []);
} catch (err) {
if (sequence !== reloadSequence.current) {
return;
}
setError(err);
showToast(err.message || "加载 VIP 配置失败", "error");
} finally {
setLoading(false);
if (sequence === reloadSequence.current) {
setLoading(false);
}
}
}, [showToast]);
}, [appCode, showToast]);
const loadResources = useCallback(async () => {
if (resourcesApp.current === appCode) {
return;
}
const sequence = ++resourceLoadSequence.current;
setResourcesLoading(true);
try {
const resourceItems = await listAllVipResources();
if (sequence !== resourceLoadSequence.current) {
return;
}
setResources(resourceItems.filter((resource) => resource.resourceType !== "vip_trial_card"));
resourcesApp.current = appCode;
} catch (err) {
if (sequence === resourceLoadSequence.current) {
// 资源目录只影响装扮权益绑定;读取失败不关闭等级编辑,运营可退出后重进并重试。
setResources([]);
showToast(err.message || "加载权益资源失败", "error");
}
} finally {
if (sequence === resourceLoadSequence.current) {
setResourcesLoading(false);
}
}
}, [appCode, showToast]);
useEffect(() => {
// X-App-Code 是隔离边界;切换 App 时必须丢弃上一 App 的编辑态并重新读取完整配置,避免跨 App 误保存。
const fallback = defaultProgram(appCode);
setProgram(fallback);
setProgramForm(programToForm(fallback));
setConfig({ levels: defaultLevels(fallback) });
setForm({ levels: defaultLevels(fallback).map((level) => levelToForm(level, fallback)) });
setResources([]);
setResourcesLoading(false);
resourcesApp.current = "";
resourceLoadSequence.current += 1;
setEditorMode("");
void reload();
}, [reload]);
}, [appCode, reload]);
const openDrawer = () => {
setForm({ levels: completeLevels(config.levels || []).map(levelToForm) });
setDrawerOpen(true);
const openProgramEditor = () => {
setProgramForm(programToForm(program));
setEditorMode("program");
};
const closeDrawer = () => {
const openLevelsEditor = () => {
setForm({
levels: completeVipLevels(config.levels || [], program).map((level) => levelToForm(level, program)),
});
setEditorMode("levels");
// 总览不依赖几百页资源目录;仅进入权益编辑时异步加载,避免阻塞 VIP 表格首屏。
void loadResources();
};
const closeEditor = () => {
if (saving) {
return;
}
setForm({ levels: completeLevels(config.levels || []).map(levelToForm) });
setDrawerOpen(false);
setProgramForm(programToForm(program));
setForm({
levels: completeVipLevels(config.levels || [], program).map((level) => levelToForm(level, program)),
});
setEditorMode("");
};
const updateProgramField = (patch) => {
setProgramForm((current) => {
const next = { ...current, ...patch };
if (patch.programType && patch.programType !== current.programType) {
// 体系决定产品已确认的等级数量;不允许切换体系后遗留另一体系的 9/10 级配置。
next.levelCount = String(vipProgramDefaults[patch.programType].levelCount);
}
return next;
});
};
const updateLevel = (levelNumber, patch) => {
@ -63,28 +155,98 @@ export function useVipConfigPage() {
}));
};
const submit = async (event) => {
const updateBenefit = (levelNumber, benefitCode, patch) => {
setForm((current) => ({
...current,
levels: (current.levels || []).map((level) =>
level.level !== levelNumber
? level
: {
...level,
benefits: (level.benefits || []).map((benefit) =>
benefit.benefitCode === benefitCode
? {
...benefit,
...patch,
// 目录占位项只有在运营实际修改后才转成显式等级配置,避免打开并保存就补齐整张表。
catalogPlaceholder: false,
}
: benefit,
),
},
),
}));
};
const updateAllBenefits = (levelNumber, status) => {
setForm((current) => ({
...current,
levels: (current.levels || []).map((level) =>
level.level !== levelNumber
? level
: {
...level,
// “全选”只改变当前等级已有权益行,不按目录补齐缺项,保持 target_only 的最终快照语义。
benefits: (level.benefits || []).map((benefit) => ({
...benefit,
status,
// 全选开启属于明确配置;取消全选时仍不把从未配置过的目录占位项写入数据库。
catalogPlaceholder: status === "active" ? false : benefit.catalogPlaceholder,
})),
},
),
}));
};
const submitProgram = async (event) => {
event.preventDefault();
if (!abilities.canUpdate) {
return;
}
let payload;
try {
payload = payloadFromForm(form);
payload = programPayloadFromForm(programForm);
} catch (err) {
showToast(err.message || "VIP 配置参数不正确", "error");
showToast(err.message || "VIP 体系参数不正确", "error");
return;
}
setSaving(true);
try {
const saved = normalizeProgram(await updateVipProgram(payload), appCode);
setProgram(saved);
setProgramForm(programToForm(saved));
setEditorMode("");
showToast("VIP 体系已保存", "success");
await reload();
} catch (err) {
showToast(err.message || "保存 VIP 体系失败", "error");
} finally {
setSaving(false);
}
};
const submitLevels = async (event) => {
event.preventDefault();
if (!abilities.canUpdate) {
return;
}
let payload;
try {
payload = vipConfigPayloadFromForm(form, program);
} catch (err) {
showToast(err.message || "VIP 等级参数不正确", "error");
return;
}
setSaving(true);
try {
const saved = await updateVipConfig(payload);
const levels = completeLevels(saved.levels || []);
const levels = completeVipLevels(saved.levels || [], program);
setConfig({ ...saved, levels });
setForm({ levels: levels.map(levelToForm) });
setDrawerOpen(false);
showToast("VIP 配置已保存", "success");
setForm({ levels: levels.map((level) => levelToForm(level, program)) });
setEditorMode("");
showToast("VIP 等级与权益已保存", "success");
} catch (err) {
showToast(err.message || "保存 VIP 配置失败", "error");
showToast(err.message || "保存 VIP 等级失败", "error");
} finally {
setSaving(false);
}
@ -93,95 +255,321 @@ export function useVipConfigPage() {
return {
abilities,
activeCount,
closeDrawer,
appCode,
closeEditor,
config,
drawerOpen,
editorMode,
error,
form,
loading,
openDrawer,
openLevelsEditor,
openProgramEditor,
program,
programForm,
reload,
resourceGroups,
resources,
resourcesLoading,
saving,
submit,
submitLevels,
submitProgram,
updateAllBenefits,
updateBenefit,
updateLevel,
updateProgramField,
};
}
function defaultLevels() {
return Array.from({ length: 10 }, (_, index) => {
const level = index + 1;
return {
level,
name: `VIP${level}`,
status: "disabled",
priceCoin: 0,
durationMs: defaultDurationMs,
rewardResourceGroupId: 0,
requiredRechargeCoinAmount: 0,
rechargeGateRequired: false,
sortOrder: level * 10,
};
async function listAllVipResources() {
const items = [];
const pageSize = 100;
for (let page = 1; page <= 200; page += 1) {
const result = await listResources({ page, page_size: pageSize, status: "active" });
items.push(...(result.items || []));
if (items.length >= Number(result.total || 0) || (result.items || []).length < pageSize) {
return items;
}
}
throw new Error("权益资源数量超过后台可加载上限");
}
function defaultProgram(appCode) {
const programType = String(appCode || "").toLowerCase() === "fami" ? "tiered_privilege_v1" : "legacy_timed";
const defaults = vipProgramDefaults[programType];
return {
appCode: String(appCode || "").toLowerCase(),
programType,
levelCount: defaults.levelCount,
status: "active",
configVersion: 0,
sameLevelExpiryPolicy: defaults.sameLevelExpiryPolicy,
upgradeExpiryPolicy: defaults.upgradeExpiryPolicy,
downgradePurchasePolicy: defaults.downgradePurchasePolicy,
benefitInheritancePolicy: defaults.benefitInheritancePolicy,
grantMode: defaults.grantMode,
trialCardEnabled: defaults.trialCardEnabled,
};
}
function normalizeProgram(program, appCode) {
const fallback = defaultProgram(appCode);
const programType = program?.programType === "tiered_privilege_v1" ? "tiered_privilege_v1" : "legacy_timed";
const defaults = vipProgramDefaults[programType];
const levelCount = Number(program?.levelCount || defaults.levelCount);
return {
...fallback,
...program,
appCode: program?.appCode || fallback.appCode,
programType,
levelCount: Number.isInteger(levelCount) && levelCount > 0 ? levelCount : defaults.levelCount,
status: program?.status === "disabled" ? "disabled" : "active",
sameLevelExpiryPolicy: program?.sameLevelExpiryPolicy || defaults.sameLevelExpiryPolicy,
upgradeExpiryPolicy: program?.upgradeExpiryPolicy || defaults.upgradeExpiryPolicy,
downgradePurchasePolicy: program?.downgradePurchasePolicy || defaults.downgradePurchasePolicy,
benefitInheritancePolicy: program?.benefitInheritancePolicy || defaults.benefitInheritancePolicy,
grantMode: program?.grantMode || defaults.grantMode,
trialCardEnabled:
typeof program?.trialCardEnabled === "boolean" ? program.trialCardEnabled : defaults.trialCardEnabled,
};
}
function programToForm(program) {
return {
enabled: program.status !== "disabled",
levelCount: String(program.levelCount || vipProgramDefaults[program.programType].levelCount),
programType: program.programType,
};
}
function programPayloadFromForm(form) {
const parsed = parseForm(vipProgramFormSchema, form);
const defaults = vipProgramDefaults[parsed.programType];
const levelCount = Number(parsed.levelCount || 0);
if (!Number.isInteger(levelCount) || levelCount <= 0 || levelCount > 20) {
throw new Error("VIP 等级数量必须为 1 至 20");
}
return {
programType: parsed.programType,
levelCount,
sameLevelExpiryPolicy: defaults.sameLevelExpiryPolicy,
upgradeExpiryPolicy: defaults.upgradeExpiryPolicy,
downgradePurchasePolicy: defaults.downgradePurchasePolicy,
benefitInheritancePolicy: defaults.benefitInheritancePolicy,
grantMode: defaults.grantMode,
trialCardEnabled: defaults.trialCardEnabled,
status: parsed.enabled ? "active" : "disabled",
};
}
function defaultLevels(program) {
return Array.from({ length: program.levelCount }, (_, index) => defaultLevel(index + 1));
}
function defaultLevel(level) {
return {
level,
name: `VIP${level}`,
status: "disabled",
priceCoin: 0,
durationMs: 30 * dayMillis,
rewardResourceGroupId: 0,
requiredRechargeCoinAmount: 0,
rechargeGateRequired: false,
sortOrder: level * 10,
// target_only 下新等级从空权益快照开始;后台不能依据历史 unlockLevel 猜测并开启权益。
benefits: [],
};
}
export function completeVipLevels(levels, program) {
const byLevel = new Map((levels || []).map((level) => [Number(level.level), level]));
return defaultLevels(program).map((fallback) => {
const existing = byLevel.get(fallback.level);
if (!existing) {
return fallback;
}
const merged = { ...fallback, ...existing };
merged.benefits =
program.programType === "tiered_privilege_v1"
? explicitP1Benefits(existing.benefits || [])
: existing.benefits || [];
return merged;
});
}
function completeLevels(levels) {
const byLevel = new Map((levels || []).map((level) => [Number(level.level), level]));
return defaultLevels().map((fallback) => ({ ...fallback, ...(byLevel.get(fallback.level) || {}) }));
function explicitP1Benefits(benefits) {
// 每级权益是最终快照:只保留该等级真实返回的行,缺项既不按 unlockLevel 补齐,也不从体系目录继承。
return (benefits || []).map((benefit) =>
benefit.benefitCode === "daily_coin_rebate" ? { ...benefit, trialEnabled: false } : benefit,
);
}
function levelToForm(level) {
export function levelToForm(level, program) {
return {
level: Number(level.level || 0),
name: level.name || `VIP${level.level}`,
status: level.status || "disabled",
priceCoin: level.priceCoin ? String(level.priceCoin) : "",
durationDays: level.durationMs ? String(Math.max(1, Math.round(Number(level.durationMs) / 86400000))) : "30",
durationDays: level.durationMs ? String(Math.max(1, Math.round(Number(level.durationMs) / dayMillis))) : "30",
rewardResourceGroupId: level.rewardResourceGroupId ? String(level.rewardResourceGroupId) : "",
requiredRechargeCoinAmount: level.requiredRechargeCoinAmount ? String(level.requiredRechargeCoinAmount) : "",
sortOrder: String(level.sortOrder || Number(level.level || 0) * 10),
benefits: benefitFormsForLevel(level, program),
};
}
function payloadFromForm(form) {
const levels = completeFormLevels(form.levels || []).map((level) => {
const priceCoin = Number(level.priceCoin || 0);
const durationDays = Number(level.durationDays || 0);
const rewardResourceGroupId = Number(level.rewardResourceGroupId || 0);
const sortOrder = Number(level.sortOrder || level.level * 10);
const status = level.status === "active" ? "active" : "disabled";
if (!String(level.name || "").trim()) {
throw new Error(`VIP${level.level} 名称不能为空`);
}
if (!Number.isInteger(priceCoin) || priceCoin < 0) {
throw new Error(`VIP${level.level} 购买金币数不正确`);
}
if (!Number.isInteger(durationDays) || durationDays <= 0) {
throw new Error(`VIP${level.level} 有效天数必须大于 0`);
}
if (status === "active" && priceCoin <= 0) {
throw new Error(`VIP${level.level} 启用时购买金币数必须大于 0`);
}
if (status === "active" && rewardResourceGroupId <= 0) {
throw new Error(`VIP${level.level} 启用时必须选择奖励资源组`);
}
return {
level: Number(level.level),
name: String(level.name || "").trim(),
status,
priceCoin,
durationMs: durationDays * 86400000,
rewardResourceGroupId,
requiredRechargeCoinAmount: 0,
sortOrder,
};
function benefitFormsForLevel(level, program) {
const explicitBenefits = level.benefits || [];
if (program?.programType !== "tiered_privilege_v1") {
return explicitBenefits.map((benefit, index) => benefitToForm(benefit, level.level, index, false));
}
const catalog = completeP1BenefitCatalog(program?.benefits || []);
const explicitByCode = new Map(explicitBenefits.map((benefit) => [benefit.benefitCode, benefit]));
const catalogCodes = new Set(catalog.map((benefit) => benefit.benefitCode));
const rows = catalog.map((template, index) => {
const explicit = explicitByCode.get(template.benefitCode);
return benefitToForm(explicit || template, level.level, index, !explicit);
});
// 服务端可能先上线新权益而当前前端目录尚未更新;显式行必须保留,不能因目录版本差异在保存时被删除。
explicitBenefits.forEach((benefit, index) => {
if (!catalogCodes.has(benefit.benefitCode)) {
rows.push(benefitToForm(benefit, level.level, catalog.length + index, false));
}
});
return rows;
}
function completeP1BenefitCatalog(remoteCatalog) {
const remoteByCode = new Map(remoteCatalog.map((benefit) => [benefit.benefitCode, benefit]));
const builtInCodes = new Set(p1BenefitCatalog.map((benefit) => benefit.benefitCode));
const catalog = p1BenefitCatalog.map((template) => ({
...template,
// 服务端最高等级行含最新名称、类型与执行域;内置目录只负责保证被删除的已知权益仍可重新配置。
...(remoteByCode.get(template.benefitCode) || {}),
}));
remoteCatalog.forEach((benefit) => {
if (!builtInCodes.has(benefit.benefitCode)) {
catalog.push(benefit);
}
});
return catalog;
}
function benefitToForm(benefit, level, index, catalogPlaceholder) {
const benefitCode = benefit.benefitCode || "";
return {
benefitCode,
name: benefit.name || benefitCode,
benefitType: benefit.benefitType === "decoration" ? "decoration" : "function",
unlockLevel: Number(benefit.unlockLevel || level || 1),
status: catalogPlaceholder ? "disabled" : benefit.status === "active" ? "active" : "disabled",
trialEnabled: benefitCode === "daily_coin_rebate" ? false : Boolean(benefit.trialEnabled),
resourceId: catalogPlaceholder ? "" : benefit.resourceId ? String(benefit.resourceId) : "",
resourceType: catalogPlaceholder ? "" : benefit.resourceType || "",
executionScope: ["wallet", "room", "user", "notice"].includes(benefit.executionScope)
? benefit.executionScope
: "wallet",
autoEquip: Boolean(benefit.autoEquip),
sortOrder: String(benefit.sortOrder || (index + 1) * 10),
// 高等级目录只提供字段模板;等级未配置的参数必须从空对象开始,不能复制高等级返现金额、文案或资源。
metadataJson: catalogPlaceholder ? "{}" : benefit.metadataJson || "{}",
catalogPlaceholder,
};
}
export function vipConfigPayloadFromForm(form, program) {
const parsed = parseForm(vipConfigFormSchema, form);
if (parsed.levels.length !== program.levelCount) {
throw new Error(`当前体系必须完整配置 VIP1 至 VIP${program.levelCount}`);
}
const levels = parsed.levels.map((level) => levelPayload(level, program));
return { levels };
}
function completeFormLevels(levels) {
const byLevel = new Map((levels || []).map((level) => [Number(level.level), level]));
return defaultLevels().map((fallback) => ({
...levelToForm(fallback),
...(byLevel.get(fallback.level) || {}),
}));
function levelPayload(level, program) {
const priceCoin = Number(level.priceCoin || 0);
const durationDays = Number(level.durationDays || 0);
const rewardResourceGroupId = Number(level.rewardResourceGroupId || 0);
const sortOrder = Number(level.sortOrder || level.level * 10);
const status = level.status === "active" ? "active" : "disabled";
if (!Number.isInteger(priceCoin) || priceCoin < 0) {
throw new Error(`VIP${level.level} 购买金币数不正确`);
}
if (!Number.isInteger(durationDays) || durationDays <= 0) {
throw new Error(`VIP${level.level} 有效天数必须大于 0`);
}
if (!Number.isInteger(sortOrder) || sortOrder < 0) {
throw new Error(`VIP${level.level} 排序不能小于 0`);
}
if (status === "active" && priceCoin <= 0) {
throw new Error(`VIP${level.level} 启用时购买金币数必须大于 0`);
}
if (!Number.isInteger(rewardResourceGroupId) || rewardResourceGroupId < 0) {
throw new Error(`VIP${level.level} 奖励资源组不正确`);
}
// Fami 的等级礼包是可选表现资源;经典 Lalu VIP 仍沿用启用等级必须配置礼包的既有约束。
if (status === "active" && program.programType !== "tiered_privilege_v1" && rewardResourceGroupId <= 0) {
throw new Error(`VIP${level.level} 启用时必须选择奖励资源组`);
}
const benefits = (level.benefits || [])
// 未触碰的目录占位项只服务复选 UI显式禁用行和运营修改后的占位项仍需提交保持最终快照可审计。
.filter((benefit) => !benefit.catalogPlaceholder || benefit.status === "active")
.map((benefit) => benefitPayload(benefit, level.level));
if (program.programType === "tiered_privilege_v1") {
const benefitCodes = new Set(benefits.map((benefit) => benefit.benefitCode));
if (benefitCodes.size !== benefits.length) {
throw new Error(`VIP${level.level} 权益编码不能重复`);
}
}
return {
level: Number(level.level),
name: String(level.name || "").trim(),
status,
priceCoin,
durationMs: durationDays * dayMillis,
rewardResourceGroupId,
requiredRechargeCoinAmount: 0,
sortOrder,
benefits,
};
}
function benefitPayload(benefit, level) {
const rawMetadataJson = String(benefit.metadataJson || "{}").trim() || "{}";
const sortOrder = Number(benefit.sortOrder || 0);
const resourceId = Number(benefit.resourceId || 0);
if (!Number.isInteger(sortOrder) || sortOrder < 0) {
throw new Error(`VIP${level}${benefit.name || benefit.benefitCode} 排序不能小于 0`);
}
try {
const parsedConfig = JSON.parse(rawMetadataJson);
if (!parsedConfig || Array.isArray(parsedConfig) || typeof parsedConfig !== "object") {
throw new Error("config must be an object");
}
} catch {
throw new Error(`VIP${level}${benefit.name || benefit.benefitCode} 元数据必须是 JSON 对象`);
}
if (!Number.isInteger(resourceId) || resourceId < 0) {
throw new Error(`VIP${level}${benefit.name || benefit.benefitCode} 资源不正确`);
}
const status = benefit.status === "active" ? "active" : "disabled";
const metadataJson = normalizeVipBenefitMetadata({
benefitCode: benefit.benefitCode,
label: `VIP${level}${benefit.name || benefit.benefitCode}`,
metadataJson: rawMetadataJson,
status,
});
return {
benefitCode: String(benefit.benefitCode || "").trim(),
name: String(benefit.name || "").trim(),
benefitType: benefit.benefitType === "decoration" ? "decoration" : "function",
unlockLevel: Number(benefit.unlockLevel || level),
status,
trialEnabled: benefit.benefitCode === "daily_coin_rebate" ? false : Boolean(benefit.trialEnabled),
resourceId,
resourceType: resourceId > 0 ? String(benefit.resourceType || "").trim() : "",
executionScope: benefit.executionScope,
autoEquip: Boolean(benefit.autoEquip),
sortOrder,
metadataJson,
};
}

View File

@ -0,0 +1,145 @@
import { describe, expect, test } from "vitest";
import { completeVipLevels, levelToForm, vipConfigPayloadFromForm } from "./useVipConfigPage.js";
describe("VIP config form semantics", () => {
test("keeps only explicit target-level benefits and never restores catalog omissions", () => {
const program = tieredProgram({
benefits: [
benefit({ benefitCode: "vip_badge_identity", name: "VIP标识", status: "active", unlockLevel: 1 }),
benefit({ benefitCode: "daily_coin_rebate", name: "金币返现", status: "active", unlockLevel: 6 }),
],
});
const levels = completeVipLevels(
[
{
...level(),
benefits: [benefit({ benefitCode: "vip_badge_identity", name: "VIP标识", status: "disabled" })],
},
],
program,
);
expect(levels[0].benefits).toHaveLength(1);
expect(levels[0].benefits[0]).toMatchObject({ benefitCode: "vip_badge_identity", status: "disabled" });
expect(levels[0].benefits.some((item) => item.benefitCode === "daily_coin_rebate")).toBe(false);
});
test("allows an active Fami level without a reward group and saves typed benefit metadata", () => {
const payload = vipConfigPayloadFromForm(
{
levels: [
level({
benefits: [
benefit({
benefitCode: "daily_coin_rebate",
metadataJson: '{"source":"daily","coin_amount":"88"}',
name: "金币返现",
status: "active",
trialEnabled: true,
unlockLevel: 6,
}),
benefit({
benefitCode: "online_global_notice",
executionScope: "notice",
metadataJson: '{"message":" 欢迎上线 ","theme":"gold"}',
name: "上线全服通知",
status: "active",
unlockLevel: 9,
}),
],
rewardResourceGroupId: "",
}),
],
},
tieredProgram(),
);
expect(payload.levels[0].rewardResourceGroupId).toBe(0);
expect(JSON.parse(payload.levels[0].benefits[0].metadataJson)).toEqual({
source: "daily",
coin_amount: 88,
});
expect(payload.levels[0].benefits[0].trialEnabled).toBe(false);
expect(JSON.parse(payload.levels[0].benefits[1].metadataJson)).toEqual({
message: "欢迎上线",
theme: "gold",
});
});
test("shows missing catalog benefits as disabled placeholders without persisting untouched rows", () => {
const program = tieredProgram({
benefits: [
benefit({ benefitCode: "vip_badge_identity", name: "VIP标识", status: "active" }),
benefit({ benefitCode: "visitor_history", name: "查看访客", status: "active" }),
],
});
const formLevel = levelToForm(
level({
benefits: [benefit({ benefitCode: "vip_badge_identity", name: "VIP标识", status: "active" })],
}),
program,
);
expect(formLevel.benefits).toHaveLength(34);
const visitorIndex = formLevel.benefits.findIndex((item) => item.benefitCode === "visitor_history");
expect(formLevel.benefits[visitorIndex]).toMatchObject({
benefitCode: "visitor_history",
catalogPlaceholder: true,
status: "disabled",
});
expect(vipConfigPayloadFromForm({ levels: [formLevel] }, program).levels[0].benefits).toHaveLength(1);
formLevel.benefits[visitorIndex] = { ...formLevel.benefits[visitorIndex], status: "active" };
expect(vipConfigPayloadFromForm({ levels: [formLevel] }, program).levels[0].benefits).toHaveLength(2);
});
test("keeps the reward group requirement for an active classic VIP level", () => {
expect(() =>
vipConfigPayloadFromForm(
{ levels: [level({ benefits: [], rewardResourceGroupId: "" })] },
{ ...tieredProgram(), programType: "legacy_timed" },
),
).toThrow("启用时必须选择奖励资源组");
});
});
function tieredProgram(patch = {}) {
return {
levelCount: 1,
programType: "tiered_privilege_v1",
...patch,
};
}
function level(patch = {}) {
return {
benefits: [],
durationDays: "30",
durationMs: 30 * 24 * 60 * 60 * 1000,
level: 1,
name: "VIP1",
priceCoin: "100",
rewardResourceGroupId: "",
sortOrder: "10",
status: "active",
...patch,
};
}
function benefit(patch = {}) {
return {
autoEquip: false,
benefitCode: "room_entry_notice",
benefitType: "function",
executionScope: "room",
metadataJson: "{}",
name: "VIP进房通知",
resourceId: "",
resourceType: "",
sortOrder: "10",
status: "active",
trialEnabled: true,
unlockLevel: 1,
...patch,
};
}

View File

@ -0,0 +1,79 @@
const coinRebateCode = "daily_coin_rebate";
const onlineNoticeCode = "online_global_notice";
export function readVipBenefitMetadata(metadataJson) {
try {
const metadata = JSON.parse(String(metadataJson || "{}").trim() || "{}");
return metadata && !Array.isArray(metadata) && typeof metadata === "object" ? metadata : null;
} catch {
return null;
}
}
export function vipBenefitMetadataField(metadataJson, field) {
const metadata = readVipBenefitMetadata(metadataJson);
const value = metadata?.[field];
return value === undefined || value === null ? "" : String(value);
}
export function updateVipBenefitMetadataField(metadataJson, field, value) {
const metadata = readVipBenefitMetadata(metadataJson);
if (!metadata) {
// 原始 JSON 非法时不能擅自覆盖并丢失其它字段;运营需先在元数据框修正 JSON再使用结构化字段。
return String(metadataJson || "{}");
}
const next = { ...metadata };
if (value === "" || value === undefined || value === null) {
delete next[field];
} else {
next[field] = value;
}
return JSON.stringify(next);
}
export function normalizeVipBenefitMetadata({ benefitCode, label, metadataJson, status }) {
const metadata = readVipBenefitMetadata(metadataJson);
if (!metadata) {
// 调用方会先给出统一的“必须是 JSON 对象”错误;这里保留防御,避免未来绕过表单校验。
throw new Error(`${label} 元数据必须是 JSON 对象`);
}
if (benefitCode === coinRebateCode) {
normalizeCoinAmount(metadata, label, status);
}
if (benefitCode === onlineNoticeCode) {
normalizeNoticeMessage(metadata, label);
}
return benefitCode === coinRebateCode || benefitCode === onlineNoticeCode
? JSON.stringify(metadata)
: String(metadataJson || "{}").trim() || "{}";
}
function normalizeCoinAmount(metadata, label, status) {
const rawAmount = metadata.coin_amount;
const hasAmount = rawAmount !== undefined && rawAmount !== null && rawAmount !== "";
if (!hasAmount) {
delete metadata.coin_amount;
if (status === "active") {
throw new Error(`${label} 启用时每日返现金币必须为正整数`);
}
return;
}
const amount = Number(rawAmount);
if (!Number.isSafeInteger(amount) || amount <= 0) {
throw new Error(`${label} 每日返现金币必须为正整数`);
}
// 后端消费固定使用 JSON number兼容读取历史字符串后在保存时收敛类型。
metadata.coin_amount = amount;
}
function normalizeNoticeMessage(metadata, label) {
const message = String(metadata.message ?? "").trim();
if (!message) {
delete metadata.message;
return;
}
if (message.length > 256) {
throw new Error(`${label} 上线飘屏文案不能超过 256 个字符`);
}
metadata.message = message;
}

View File

@ -0,0 +1,57 @@
import { describe, expect, test } from "vitest";
import { normalizeVipBenefitMetadata, updateVipBenefitMetadataField, vipBenefitMetadataField } from "./metadata.js";
describe("VIP benefit metadata", () => {
test("updates structured fields without dropping unrelated metadata", () => {
const rebate = updateVipBenefitMetadataField('{"channel":"vip","coin_amount":20}', "coin_amount", 88);
const notice = updateVipBenefitMetadataField('{"theme":"gold","message":"旧文案"}', "message", "欢迎上线");
expect(JSON.parse(rebate)).toEqual({ channel: "vip", coin_amount: 88 });
expect(JSON.parse(notice)).toEqual({ theme: "gold", message: "欢迎上线" });
expect(vipBenefitMetadataField(rebate, "coin_amount")).toBe("88");
});
test("requires a positive coin amount only when the rebate benefit is active", () => {
expect(() =>
normalizeVipBenefitMetadata({
benefitCode: "daily_coin_rebate",
label: "VIP6 的金币返现",
metadataJson: '{"source":"daily"}',
status: "active",
}),
).toThrow("启用时每日返现金币必须为正整数");
expect(
JSON.parse(
normalizeVipBenefitMetadata({
benefitCode: "daily_coin_rebate",
label: "VIP6 的金币返现",
metadataJson: '{"source":"daily"}',
status: "disabled",
}),
),
).toEqual({ source: "daily" });
});
test("normalizes coin amount and limits the online notice message", () => {
expect(
JSON.parse(
normalizeVipBenefitMetadata({
benefitCode: "daily_coin_rebate",
label: "VIP6 的金币返现",
metadataJson: '{"source":"daily","coin_amount":"120"}',
status: "active",
}),
),
).toEqual({ source: "daily", coin_amount: 120 });
expect(() =>
normalizeVipBenefitMetadata({
benefitCode: "online_global_notice",
label: "VIP9 的上线全服通知",
metadataJson: JSON.stringify({ message: "欢".repeat(257), theme: "gold" }),
status: "active",
}),
).toThrow("上线飘屏文案不能超过 256 个字符");
});
});

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,241 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, expect, test, vi } from "vitest";
import { VipConfigPage } from "./VipConfigPage.jsx";
import { useVipConfigPage } from "@/features/vip-config/hooks/useVipConfigPage.js";
vi.mock("@/features/vip-config/hooks/useVipConfigPage.js", () => ({
useVipConfigPage: vi.fn(),
}));
afterEach(() => {
vi.clearAllMocks();
});
test("vip config overview follows the compact five-column design and opens level editing", () => {
const page = pageFixture();
vi.mocked(useVipConfigPage).mockReturnValue(page);
render(<VipConfigPage />);
expect(screen.getByRole("heading", { name: "VIP配置" })).toBeInTheDocument();
expect(screen.getByText("fami · Fami P1 VIP")).toBeInTheDocument();
expect(screen.getByText("高等级从购买时重新计时")).toBeInTheDocument();
expect(Array.from(document.querySelectorAll("[data-table-header-cell]")).map((cell) => cell.textContent)).toEqual([
"等级",
"状态",
"购买金币",
"有效期",
"更新时间",
]);
expect(screen.queryByText("操作", { selector: "[data-table-header-cell] *" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "编辑配置" }));
expect(page.openLevelsEditor).toHaveBeenCalledTimes(1);
});
test("P1 level editing uses a real dialog and only renders the selected level editor", () => {
const page = pageFixture({ editorMode: "levels" });
vi.mocked(useVipConfigPage).mockReturnValue(page);
render(<VipConfigPage />);
const dialog = screen.getByRole("dialog", { name: /^VIP等级配置/ });
const levelNavigation = within(dialog).getByRole("navigation", { name: "VIP等级" });
expect(screen.getByTestId("vip-level-1")).toBeInTheDocument();
expect(screen.queryByTestId("vip-level-2")).not.toBeInTheDocument();
expect(within(screen.getByTestId("vip-level-1")).getByLabelText("奖励资源组(可选)")).toBeInTheDocument();
fireEvent.click(within(levelNavigation).getByRole("button", { name: /VIP2/ }));
expect(screen.getByTestId("vip-level-2")).toBeInTheDocument();
expect(screen.queryByTestId("vip-level-1")).not.toBeInTheDocument();
fireEvent.change(within(screen.getByTestId("vip-level-2")).getByLabelText("购买金币"), {
target: { value: "260" },
});
expect(page.updateLevel).toHaveBeenCalledWith(2, { priceCoin: "260" });
});
test("benefit search and status filters only change the visible matrix", () => {
const page = pageFixture({ editorMode: "levels" });
vi.mocked(useVipConfigPage).mockReturnValue(page);
render(<VipConfigPage />);
const benefitPanel = screen.getByRole("region", { name: "VIP1权益配置" });
fireEvent.change(within(benefitPanel).getByLabelText("搜索权益"), { target: { value: "返现" } });
expect(within(benefitPanel).getByRole("button", { name: /金币返现/ })).toBeInTheDocument();
expect(within(benefitPanel).queryByRole("button", { name: /VIP标识/ })).not.toBeInTheDocument();
fireEvent.change(within(benefitPanel).getByLabelText("搜索权益"), { target: { value: "" } });
fireEvent.click(within(benefitPanel).getByRole("button", { name: "未启用" }));
expect(within(benefitPanel).getByRole("button", { name: /金币返现/ })).toBeInTheDocument();
expect(within(benefitPanel).getByRole("button", { name: /上线全服通知/ })).toBeInTheDocument();
expect(within(benefitPanel).queryByRole("button", { name: /VIP标识/ })).not.toBeInTheDocument();
expect(page.updateBenefit).not.toHaveBeenCalled();
expect(page.updateAllBenefits).not.toHaveBeenCalled();
});
test("benefit inspector writes typed rebate and online notice parameters", () => {
const page = pageFixture({ editorMode: "levels" });
vi.mocked(useVipConfigPage).mockReturnValue(page);
render(<VipConfigPage />);
const benefitPanel = screen.getByRole("region", { name: "VIP1权益配置" });
const inspector = screen.getByRole("complementary", { name: "权益详细设置" });
fireEvent.click(within(benefitPanel).getByRole("button", { name: /金币返现/ }));
expect(within(inspector).getByLabelText("每日返现金币")).toHaveValue(88);
fireEvent.change(within(inspector).getByLabelText("每日返现金币"), { target: { value: "99" } });
fireEvent.click(within(benefitPanel).getByRole("button", { name: /上线全服通知/ }));
expect(within(inspector).getByLabelText("上线飘屏文案")).toHaveValue("欢迎上线");
fireEvent.change(within(inspector).getByLabelText("上线飘屏文案"), {
target: { value: "新的上线文案" },
});
const rebatePatch = page.updateBenefit.mock.calls.find((call) => call[1] === "daily_coin_rebate")?.[2];
const noticePatch = page.updateBenefit.mock.calls.find((call) => call[1] === "online_global_notice")?.[2];
expect(page.updateBenefit.mock.calls.find((call) => call[1] === "daily_coin_rebate")?.[0]).toBe(1);
expect(page.updateBenefit.mock.calls.find((call) => call[1] === "online_global_notice")?.[0]).toBe(1);
expect(JSON.parse(rebatePatch.metadataJson)).toEqual({ channel: "vip", coin_amount: 99 });
expect(JSON.parse(noticePatch.metadataJson)).toEqual({ message: "新的上线文案", theme: "gold" });
});
test("benefit select-all targets only the current level", () => {
const page = pageFixture({ editorMode: "levels" });
vi.mocked(useVipConfigPage).mockReturnValue(page);
render(<VipConfigPage />);
const selectAll = screen.getByRole("checkbox", { name: "VIP1权益全选" });
expect(selectAll).toHaveAttribute("data-indeterminate", "true");
expect(screen.getByRole("checkbox", { name: "VIP标识权益" })).toBeChecked();
fireEvent.click(selectAll);
expect(page.updateAllBenefits).toHaveBeenCalledTimes(1);
expect(page.updateAllBenefits).toHaveBeenCalledWith(1, "active");
});
test("level dialog exposes one footer cancel and one whole-package save action", () => {
const page = pageFixture({ editorMode: "levels" });
vi.mocked(useVipConfigPage).mockReturnValue(page);
render(<VipConfigPage />);
const dialog = screen.getByRole("dialog", { name: /^VIP等级配置/ });
const cancelButtons = within(dialog).getAllByRole("button", { name: "取消" });
const saveButtons = within(dialog).getAllByRole("button", { name: "保存全部配置" });
expect(cancelButtons).toHaveLength(1);
expect(saveButtons).toHaveLength(1);
fireEvent.click(cancelButtons[0]);
fireEvent.click(saveButtons[0]);
expect(page.closeEditor).toHaveBeenCalledTimes(1);
expect(page.submitLevels).toHaveBeenCalledTimes(1);
});
function pageFixture(patch = {}) {
const benefits = [
benefitFixture({ benefitCode: "vip_badge_identity", name: "VIP标识", status: "active" }),
benefitFixture({
benefitCode: "daily_coin_rebate",
metadataJson: '{"channel":"vip","coin_amount":88}',
name: "金币返现",
trialEnabled: false,
unlockLevel: 6,
}),
benefitFixture({
benefitCode: "online_global_notice",
executionScope: "notice",
metadataJson: '{"message":"欢迎上线","theme":"gold"}',
name: "上线全服通知",
unlockLevel: 9,
}),
];
const firstLevel = levelFixture({ benefits, level: 1, name: "VIP1", status: "active" });
const secondLevel = levelFixture({
benefits: benefits.map((benefit) => ({ ...benefit })),
level: 2,
name: "VIP2",
priceCoin: "200",
sortOrder: "20",
});
return {
abilities: { canUpdate: true, canView: true },
activeCount: 1,
appCode: "fami",
closeEditor: vi.fn(),
config: {
levels: [
{ ...firstLevel, durationMs: 30 * 24 * 60 * 60 * 1000, priceCoin: 100 },
{ ...secondLevel, durationMs: 30 * 24 * 60 * 60 * 1000, priceCoin: 200 },
],
},
editorMode: "",
error: null,
form: { levels: [firstLevel, secondLevel] },
loading: false,
openLevelsEditor: vi.fn(),
openProgramEditor: vi.fn(),
program: {
grantMode: "trial_card",
levelCount: 2,
programType: "tiered_privilege_v1",
status: "active",
trialCardEnabled: true,
upgradeExpiryPolicy: "replace_from_now",
},
programForm: { enabled: true, levelCount: "2", programType: "tiered_privilege_v1" },
reload: vi.fn(),
resourceGroups: [{ groupId: 11, name: "VIP1资源组" }],
resources: [],
resourcesLoading: false,
saving: false,
submitLevels: vi.fn((event) => event.preventDefault()),
submitProgram: vi.fn((event) => event.preventDefault()),
updateAllBenefits: vi.fn(),
updateBenefit: vi.fn(),
updateLevel: vi.fn(),
updateProgramField: vi.fn(),
...patch,
};
}
function levelFixture(patch = {}) {
return {
benefits: [],
durationDays: "30",
level: 1,
name: "VIP1",
priceCoin: "100",
rewardResourceGroupId: "11",
sortOrder: "10",
status: "disabled",
updatedAtMs: 1780000000000,
...patch,
};
}
function benefitFixture(patch = {}) {
return {
autoEquip: false,
benefitCode: "room_entry_notice",
benefitType: "function",
executionScope: "room",
metadataJson: "{}",
name: "VIP进房通知",
resourceId: "",
resourceType: "",
sortOrder: "10",
status: "disabled",
trialEnabled: true,
unlockLevel: 2,
...patch,
};
}

View File

@ -0,0 +1,36 @@
import { z } from "zod";
const benefitSchema = z.object({
catalogPlaceholder: z.boolean().optional(),
benefitCode: z.string().trim().min(1, "权益编码不能为空").max(96, "权益编码不能超过 96 个字符"),
benefitType: z.enum(["decoration", "function"]),
autoEquip: z.boolean(),
executionScope: z.enum(["wallet", "room", "user", "notice"]),
metadataJson: z.string().trim().max(4096, "权益元数据不能超过 4096 个字符"),
name: z.string().trim().min(1, "权益名称不能为空").max(128, "权益名称不能超过 128 个字符"),
resourceId: z.union([z.string(), z.number()]).optional(),
resourceType: z.string().optional(),
sortOrder: z.union([z.string(), z.number()]),
status: z.enum(["active", "disabled"]),
trialEnabled: z.boolean(),
unlockLevel: z.number().int().positive(),
});
const levelSchema = z.object({
benefits: z.array(benefitSchema),
durationDays: z.union([z.string(), z.number()]),
level: z.number().int().positive(),
name: z.string().trim().min(1, "VIP名称不能为空").max(128, "VIP名称不能超过 128 个字符"),
priceCoin: z.union([z.string(), z.number()]),
rewardResourceGroupId: z.union([z.string(), z.number()]).optional(),
sortOrder: z.union([z.string(), z.number()]),
status: z.enum(["active", "disabled"]),
});
export const vipProgramFormSchema = z.object({
enabled: z.boolean(),
levelCount: z.union([z.string(), z.number()]),
programType: z.enum(["legacy_timed", "tiered_privilege_v1"]),
});
export const vipConfigFormSchema = z.object({ levels: z.array(levelSchema).min(1, "请至少配置一个VIP等级") });

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -612,6 +612,70 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/activity/vip-program": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getVipProgram"];
put: operations["updateVipProgram"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/activity/vip-levels": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getVipConfig"];
put: operations["updateVipConfig"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/activity/vip-grants": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["grantVip"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/activity/vip-trial-card-grants": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["grantVipTrialCard"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/activity/room-rocket/config": {
parameters: {
query?: never;
@ -3304,6 +3368,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/app/users/{id}/levels": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put: operations["appAdjustUserLevels"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/app/users/{id}/login-logs": {
parameters: {
query?: never;
@ -3384,6 +3464,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/exports/app-users": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["appExportUsers"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/auth/change-password": {
parameters: {
query?: never;
@ -3960,6 +4056,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/jobs/{id}/artifact": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["downloadJobArtifact"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/jobs/{id}/cancel": {
parameters: {
query?: never;
@ -4172,6 +4284,386 @@ export interface components {
H5LinkDeleteResult: {
deleted: boolean;
};
ApiResponseAppUserPage: components["schemas"]["Envelope"] & {
data?: components["schemas"]["ApiPageAppUser"];
};
ApiResponseAppUser: components["schemas"]["Envelope"] & {
data?: components["schemas"]["AppUser"];
};
ApiResponseAppUserStatusMutation: components["schemas"]["Envelope"] & {
data?: components["schemas"]["AppUserStatusMutationResult"];
};
ApiResponseAppUserBanRecordPage: components["schemas"]["Envelope"] & {
data?: components["schemas"]["ApiPageAppUserBanRecord"];
};
ApiResponseAppUserLoginLogPage: components["schemas"]["Envelope"] & {
data?: components["schemas"]["ApiPageAppUserLoginLog"];
};
ApiResponseAppUserExportJob: components["schemas"]["Envelope"] & {
data?: components["schemas"]["AppUserExportJob"];
};
ApiPageAppUser: {
items: components["schemas"]["AppUser"][];
page: number;
pageSize: number;
/** Format: int64 */
total: number;
};
AppUser: {
userId: string;
defaultDisplayUserId?: string;
displayUserId?: string;
prettyDisplayUserId?: string;
prettyId?: string;
username?: string;
avatar?: string;
gender?: string;
birth?: string;
age?: number | null;
country?: string;
countryName?: string;
countryDisplayName?: string;
/** Format: int64 */
regionId?: number;
regionName?: string;
registerDevice?: string;
roles: string[];
levels: components["schemas"]["AppUserLevels"];
ban: components["schemas"]["AppUserBan"];
lastOperator?: components["schemas"]["AppUserOperator"];
/** Format: int64 */
lastOperatedAtMs?: number;
/** Format: int64 */
lastLoginAtMs?: number;
/** Format: int64 */
lastActiveAtMs?: number;
/** Format: int64 */
createdAtMs?: number;
/** Format: int64 */
updatedAtMs?: number;
/** Format: int64 */
coin?: number;
/** Format: int64 */
diamond?: number;
/** Format: int64 */
cumulativeRechargeUsdMinor?: number;
vip?: components["schemas"]["AppUserVIP"];
balances?: components["schemas"]["AppUserAssetBalance"][];
equippedResources?: components["schemas"]["AppUserResource"][];
resources?: components["schemas"]["AppUserResource"][];
};
AppUserLevels: {
wealth: components["schemas"]["AppUserLevel"];
game: components["schemas"]["AppUserLevel"];
charm: components["schemas"]["AppUserLevel"];
};
AppUserLevel: {
/** @enum {string} */
track: "wealth" | "game" | "charm";
realLevel: number;
/** Format: int64 */
realTotalValue: number;
displayLevel: number;
/** Format: int64 */
displayValue: number;
temporaryLevelId?: string;
temporaryTargetLevel?: number;
/** Format: int64 */
startedAtMs?: number;
/** Format: int64 */
expiresAtMs?: number;
};
AppUserBan: {
id?: string;
active: boolean;
permanent: boolean;
/** Format: int64 */
expiresAtMs: number;
/** @enum {string} */
source?: "admin" | "manager" | "legacy" | "";
reason?: string;
};
AppUserOperator: {
adminId?: string;
name?: string;
action?: string;
};
AppUserVIP: {
level: number;
name?: string;
active: boolean;
/** Format: int64 */
startedAtMs: number;
/** Format: int64 */
expiresAtMs: number;
/** Format: int64 */
updatedAtMs: number;
};
AppUserAssetBalance: {
assetType: string;
/** Format: int64 */
availableAmount: number;
/** Format: int64 */
frozenAmount: number;
/** Format: int64 */
totalAmount: number;
/** Format: int64 */
version: number;
/** Format: int64 */
updatedAtMs: number;
};
AppUserResource: {
entitlementId?: string;
/** Format: int64 */
resourceId?: number;
resourceCode?: string;
resourceType?: string;
name?: string;
status?: string;
/** Format: int64 */
quantity?: number;
/** Format: int64 */
remainingQuantity?: number;
/** Format: int64 */
effectiveAtMs?: number;
/** Format: int64 */
expiresAtMs?: number;
sourceGrantId?: string;
assetUrl?: string;
previewUrl?: string;
animationUrl?: string;
equipped?: boolean;
/** Format: int64 */
createdAtMs?: number;
/** Format: int64 */
updatedAtMs?: number;
};
AppUserUpdateInput: {
username?: string;
avatar?: string;
gender?: string;
country?: string;
};
AppUserBanInput: {
/** Format: int64 */
expires_at_ms: number;
reason?: string;
};
AppUserUnbanInput: {
reason?: string;
};
AppUserLevelAdjustmentInput: {
adjustments: {
/** @enum {string} */
track: "wealth" | "charm";
level: number;
duration_days: number;
}[];
reason?: string;
};
AppUserPasswordInput: {
password: string;
};
AppUserStatusMutationResult: {
user: components["schemas"]["AppUser"];
/** Format: int64 */
revokedSessionCount?: number;
accessTokenRevoked?: boolean;
accessTokenRevokeError?: string;
imKicked?: boolean;
imKickError?: string;
roomEvicted?: boolean;
roomId?: string;
rtcKicked?: boolean;
rtcKickError?: string;
roomEvictError?: string;
};
AppUserExportJob: {
jobId: number;
status: string;
/** Format: int64 */
snapshotAtMs: number;
};
AppUserBrief: {
userId: string;
defaultDisplayUserId?: string;
displayUserId?: string;
prettyDisplayUserId?: string;
prettyId?: string;
username?: string;
avatar?: string;
};
AppUserBanOperator: components["schemas"]["AppUserBrief"] & {
adminId?: string;
account?: string;
name?: string;
type?: string;
};
AppUserBanRecord: {
/** Format: int64 */
id: number;
target: components["schemas"]["AppUserBrief"];
operator?: components["schemas"]["AppUserBanOperator"];
oldStatus?: string;
newStatus?: string;
reason?: string;
requestId?: string;
/** Format: int64 */
createdAtMs?: number;
};
ApiPageAppUserBanRecord: {
items: components["schemas"]["AppUserBanRecord"][];
page: number;
pageSize: number;
/** Format: int64 */
total: number;
};
AppUserLoginLog: components["schemas"]["AppUserBrief"] & {
/** Format: int64 */
id?: number;
loginType?: string;
provider?: string;
channel?: string;
platform?: string;
result?: string;
failureCode?: string;
loginIp?: string;
ipCountryCode?: string;
country?: string;
/** Format: int64 */
regionId?: number;
regionName?: string;
blocked?: boolean;
blockReason?: string;
riskHighlighted?: boolean;
requestId?: string;
/** Format: int64 */
createdAtMs?: number;
};
ApiPageAppUserLoginLog: {
items: components["schemas"]["AppUserLoginLog"][];
page: number;
pageSize: number;
/** Format: int64 */
total: number;
};
ApiResponseVipProgram: components["schemas"]["Envelope"] & {
data?: components["schemas"]["VipProgramData"];
};
ApiResponseVipConfig: components["schemas"]["Envelope"] & {
data?: components["schemas"]["VipConfig"];
};
VipProgram: {
appCode: string;
/** @enum {string} */
programType: "legacy_timed" | "tiered_privilege_v1";
levelCount: number;
/** @enum {string} */
status: "active" | "disabled";
configVersion?: number;
/** @enum {string} */
sameLevelExpiryPolicy?: "extend_remaining";
/** @enum {string} */
upgradeExpiryPolicy?: "extend_remaining" | "replace_from_now";
/** @enum {string} */
downgradePurchasePolicy?: "reject";
/** @enum {string} */
benefitInheritancePolicy?: "target_only";
/** @enum {string} */
grantMode?: "direct_membership" | "trial_card";
trialCardEnabled?: boolean;
/** Format: int64 */
createdAtMs?: number;
/** Format: int64 */
updatedAtMs?: number;
};
VipProgramData: {
config: components["schemas"]["VipProgram"];
benefits?: components["schemas"]["VipBenefit"][];
/** Format: int64 */
serverTimeMs?: number;
};
VipProgramInput: {
/** @enum {string} */
programType: "legacy_timed" | "tiered_privilege_v1";
levelCount: number;
/** @enum {string} */
sameLevelExpiryPolicy: "extend_remaining";
/** @enum {string} */
upgradeExpiryPolicy: "extend_remaining" | "replace_from_now";
/** @enum {string} */
downgradePurchasePolicy: "reject";
/** @enum {string} */
benefitInheritancePolicy: "target_only";
/** @enum {string} */
grantMode: "direct_membership" | "trial_card";
trialCardEnabled: boolean;
/** @enum {string} */
status: "active" | "disabled";
};
VipProgramUpdateInput: {
config: components["schemas"]["VipProgramInput"];
};
VipBenefit: {
benefitCode: string;
name: string;
/** @enum {string} */
benefitType: "decoration" | "function";
unlockLevel: number;
/** @enum {string} */
status: "active" | "disabled";
trialEnabled: boolean;
/** Format: int64 */
resourceId: number;
resourceType: string;
/** @enum {string} */
executionScope: "wallet" | "room" | "user" | "notice";
autoEquip: boolean;
sortOrder: number;
metadataJson: string;
};
VipLevel: {
level: number;
name: string;
/** @enum {string} */
status: "active" | "disabled";
priceCoin: number;
/** Format: int64 */
durationMs: number;
/** Format: int64 */
rewardResourceGroupId: number;
sortOrder: number;
benefits: components["schemas"]["VipBenefit"][];
/** Format: int64 */
createdAtMs?: number;
/** Format: int64 */
updatedAtMs?: number;
};
VipConfig: {
levels: components["schemas"]["VipLevel"][];
/** Format: int64 */
serverTimeMs?: number;
programConfig?: components["schemas"]["VipProgram"];
};
VipConfigInput: {
levels: components["schemas"]["VipLevel"][];
};
VipGrantInput: {
commandId?: string;
targetUserId: string;
level: number;
reason: string;
};
VipTrialCardGrantInput: {
commandId?: string;
targetUserId: string;
level: number;
/** Format: int64 */
resourceId?: number;
/** Format: int64 */
durationMs: number;
reason: string;
};
ApiPageAgency: {
items: components["schemas"]["Agency"][];
page: number;
@ -4689,15 +5181,7 @@ export interface components {
/** Format: int64 */
availableDelta: number;
/** @enum {string} */
bizType:
| "coin_seller_stock_purchase"
| "coin_seller_stock_deduction"
| "coin_seller_recharge"
| "coin_seller_coin_compensation"
| "coin_seller_transfer"
| "coin_seller_sub_transfer"
| "salary_transfer_to_coin_seller"
| "manual_credit";
bizType: "coin_seller_stock_purchase" | "coin_seller_stock_deduction" | "coin_seller_recharge" | "coin_seller_coin_compensation" | "coin_seller_transfer" | "coin_seller_sub_transfer" | "salary_transfer_to_coin_seller" | "manual_credit";
commandId?: string;
counterpartyUserId?: string;
/** Format: int64 */
@ -5029,13 +5513,7 @@ export interface components {
appCode: string;
appName?: string;
/** @enum {string} */
operation:
| "user_coin_credit"
| "user_coin_debit"
| "user_wallet_debit"
| "user_wallet_credit"
| "coin_seller_coin_credit"
| "coin_seller_coin_debit";
operation: "user_coin_credit" | "user_coin_debit" | "user_wallet_debit" | "user_wallet_credit" | "coin_seller_coin_credit" | "coin_seller_coin_debit";
walletIdentity?: string;
targetUserId: string;
coinAmount: number;
@ -5117,13 +5595,7 @@ export interface components {
FinanceApplicationInput: {
appCode: string;
/** @enum {string} */
operation:
| "user_coin_credit"
| "user_coin_debit"
| "user_wallet_debit"
| "user_wallet_credit"
| "coin_seller_coin_credit"
| "coin_seller_coin_debit";
operation: "user_coin_credit" | "user_coin_debit" | "user_wallet_debit" | "user_wallet_credit" | "coin_seller_coin_credit" | "coin_seller_coin_debit";
/** @enum {string} */
walletIdentity?: "host" | "agency" | "bd";
targetUserId: string;
@ -5207,6 +5679,60 @@ export interface components {
};
};
/** @description OK */
AppUserPageResponse: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiResponseAppUserPage"];
};
};
/** @description OK */
AppUserResponse: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiResponseAppUser"];
};
};
/** @description OK */
AppUserStatusMutationResponse: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiResponseAppUserStatusMutation"];
};
};
/** @description OK */
AppUserBanRecordPageResponse: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiResponseAppUserBanRecordPage"];
};
};
/** @description OK */
AppUserLoginLogPageResponse: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiResponseAppUserLoginLogPage"];
};
};
/** @description OK */
AppUserExportJobResponse: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiResponseAppUserExportJob"];
};
};
/** @description OK */
AgencyPageResponse: {
headers: {
[name: string]: unknown;
@ -5351,6 +5877,24 @@ export interface components {
};
};
/** @description OK */
VipProgramResponse: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiResponseVipProgram"];
};
};
/** @description OK */
VipConfigResponse: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiResponseVipConfig"];
};
};
/** @description OK */
EmptyResponse: {
headers: {
[name: string]: unknown;
@ -6458,6 +7002,94 @@ export interface operations {
200: components["responses"]["EmptyResponse"];
};
};
getVipProgram: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["VipProgramResponse"];
};
};
updateVipProgram: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["VipProgramUpdateInput"];
};
};
responses: {
200: components["responses"]["VipProgramResponse"];
};
};
getVipConfig: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["VipConfigResponse"];
};
};
updateVipConfig: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["VipConfigInput"];
};
};
responses: {
200: components["responses"]["VipConfigResponse"];
};
};
grantVip: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["VipGrantInput"];
};
};
responses: {
200: components["responses"]["EmptyResponse"];
};
};
grantVipTrialCard: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["VipTrialCardGrantInput"];
};
};
responses: {
200: components["responses"]["EmptyResponse"];
};
};
getRoomRocketConfig: {
parameters: {
query?: never;
@ -8111,11 +8743,7 @@ export interface operations {
seller_filter_user_id?: string;
seller_display_user_id?: string;
seller_username?: string;
ledger_type?:
| "admin_stock_credit"
| "seller_transfer"
| "sub_seller_transfer"
| "salary_transfer_received";
ledger_type?: "admin_stock_credit" | "seller_transfer" | "sub_seller_transfer" | "salary_transfer_received";
start_at_ms?: number;
end_at_ms?: number;
};
@ -8138,11 +8766,7 @@ export interface operations {
seller_filter_user_id?: string;
seller_display_user_id?: string;
seller_username?: string;
ledger_type?:
| "admin_stock_credit"
| "seller_transfer"
| "sub_seller_transfer"
| "salary_transfer_received";
ledger_type?: "admin_stock_credit" | "seller_transfer" | "sub_seller_transfer" | "salary_transfer_received";
start_at_ms?: number;
end_at_ms?: number;
};
@ -8196,13 +8820,7 @@ export interface operations {
sent_at_ms?: number;
summary: string;
/** @enum {string} */
target_scope:
| "all_registered_users"
| "all_active_users"
| "single_user"
| "user_ids"
| "region"
| "country";
target_scope: "all_registered_users" | "all_active_users" | "single_user" | "user_ids" | "region" | "country";
/** Format: int64 */
target_user_id?: number;
title: string;
@ -9787,14 +10405,28 @@ export interface operations {
};
appListUsers: {
parameters: {
query?: never;
query?: {
page?: components["parameters"]["Page"];
page_size?: components["parameters"]["PageSize"];
keyword?: components["parameters"]["Keyword"];
user_id?: string;
display_user_id?: string;
username?: string;
country?: string;
region_id?: number;
status?: string;
start_ms?: number;
end_ms?: number;
sort_by?: "created_at" | "coin";
sort_direction?: "asc" | "desc";
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
200: components["responses"]["AppUserPageResponse"];
};
};
appGetUser: {
@ -9808,7 +10440,7 @@ export interface operations {
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
200: components["responses"]["AppUserResponse"];
};
};
appUpdateUser: {
@ -9820,9 +10452,13 @@ export interface operations {
};
cookie?: never;
};
requestBody?: never;
requestBody: {
content: {
"application/json": components["schemas"]["AppUserUpdateInput"];
};
};
responses: {
200: components["responses"]["EmptyResponse"];
200: components["responses"]["AppUserResponse"];
};
};
appBanUser: {
@ -9834,9 +10470,31 @@ export interface operations {
};
cookie?: never;
};
requestBody?: never;
requestBody: {
content: {
"application/json": components["schemas"]["AppUserBanInput"];
};
};
responses: {
200: components["responses"]["EmptyResponse"];
200: components["responses"]["AppUserStatusMutationResponse"];
};
};
appAdjustUserLevels: {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["Id"];
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["AppUserLevelAdjustmentInput"];
};
};
responses: {
200: components["responses"]["AppUserResponse"];
};
};
appListUserLoginLogs: {
@ -9850,7 +10508,7 @@ export interface operations {
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
200: components["responses"]["AppUserLoginLogPageResponse"];
};
};
appSetPassword: {
@ -9862,7 +10520,11 @@ export interface operations {
};
cookie?: never;
};
requestBody?: never;
requestBody: {
content: {
"application/json": components["schemas"]["AppUserPasswordInput"];
};
};
responses: {
200: components["responses"]["EmptyResponse"];
};
@ -9876,9 +10538,13 @@ export interface operations {
};
cookie?: never;
};
requestBody?: never;
requestBody: {
content: {
"application/json": components["schemas"]["AppUserUnbanInput"];
};
};
responses: {
200: components["responses"]["EmptyResponse"];
200: components["responses"]["AppUserStatusMutationResponse"];
};
};
appListBannedUsers: {
@ -9890,7 +10556,7 @@ export interface operations {
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
200: components["responses"]["AppUserBanRecordPageResponse"];
};
};
appListLoginLogs: {
@ -9902,7 +10568,31 @@ export interface operations {
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
200: components["responses"]["AppUserLoginLogPageResponse"];
};
};
appExportUsers: {
parameters: {
query?: {
keyword?: components["parameters"]["Keyword"];
user_id?: string;
display_user_id?: string;
username?: string;
country?: string;
region_id?: number;
status?: string;
start_ms?: number;
end_ms?: number;
sort_by?: "created_at" | "coin";
sort_direction?: "asc" | "desc";
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["AppUserExportJobResponse"];
};
};
changePassword: {
@ -10572,6 +11262,28 @@ export interface operations {
200: components["responses"]["AdminJobResponse"];
};
};
downloadJobArtifact: {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["Id"];
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Export artifact */
200: {
headers: {
[name: string]: unknown;
};
content: {
"text/csv": string;
};
};
};
};
cancelJob: {
parameters: {
query?: never;

View File

@ -1000,8 +1000,11 @@ export interface CoinSellerSalaryRatesPayload {
}
export interface AppUserDto {
age?: number;
avatar?: string;
balances?: AppUserAssetBalanceDto[];
ban?: AppUserBanDto;
birth?: string;
coin?: number;
country?: string;
countryDisplayName?: string;
@ -1013,16 +1016,56 @@ export interface AppUserDto {
equippedResources?: AppUserResourceDto[];
gender?: string;
lastActiveAtMs?: number;
lastLoginAtMs?: number;
lastOperatedAtMs?: number;
lastOperator?: AppUserOperatorDto;
levels?: AppUserLevelsDto;
prettyDisplayUserId?: string;
prettyId?: string;
regionId?: number;
regionName?: string;
registerDevice?: string;
resources?: AppUserResourceDto[];
roles?: string[];
status?: string;
updatedAtMs?: number;
userId: string;
username?: string;
vip?: AppUserVIPDto;
cumulativeRechargeUsdMinor?: number;
}
export interface AppUserLevelDto {
displayLevel: number;
displayValue: number;
expiresAtMs: number;
realLevel: number;
realTotalValue: number;
startedAtMs: number;
temporaryLevelId?: string;
temporaryTargetLevel: number;
track: "wealth" | "game" | "charm" | string;
}
export interface AppUserLevelsDto {
charm: AppUserLevelDto;
game: AppUserLevelDto;
wealth: AppUserLevelDto;
}
export interface AppUserBanDto {
active: boolean;
expiresAtMs: number;
id?: string;
permanent: boolean;
reason?: string;
source?: "admin" | "manager" | "legacy" | string;
}
export interface AppUserOperatorDto {
action?: string;
adminId?: string;
name?: string;
}
export interface AppUserAssetBalanceDto {
@ -1131,6 +1174,30 @@ export interface AppUserUpdatePayload {
username?: string;
}
export interface AppUserBanPayload {
expires_at_ms: number;
reason?: string;
}
export interface AppUserUnbanPayload {
reason?: string;
}
export interface AppUserLevelAdjustmentPayload {
adjustments: Array<{
duration_days: number;
level: number;
track: "wealth" | "charm";
}>;
reason?: string;
}
export interface AppUserExportJobDto {
jobId: number;
snapshotAtMs: number;
status: string;
}
export interface AppUserPasswordPayload {
password: string;
}
@ -1509,8 +1576,8 @@ export interface H5LinkConfigDto {
appCode: string;
key: string;
label: string;
updatedAtMs?: number;
url?: string;
updatedAtMs: number;
url: string;
}
export interface H5LinkConfigPayload {

View File

@ -23,7 +23,7 @@
.kpi-grid,
.panel-grid,
.overview-charts,
.frequent-menu-grid,
.workspace-entry-grid,
.user-kpi-row,
.page-skeleton__kpis,
.page-skeleton__charts {
@ -102,7 +102,7 @@
.kpi-grid,
.panel-grid,
.overview-charts,
.frequent-menu-grid,
.workspace-entry-grid,
.user-kpi-row,
.alert-strip,
.page-skeleton__kpis,