完善后台管理功能
This commit is contained in:
parent
9065382192
commit
c206c72612
File diff suppressed because it is too large
Load Diff
@ -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();
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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"];
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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} />);
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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" });
|
||||
});
|
||||
|
||||
@ -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 ?? "";
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
152
src/features/app-users/components/AppUserValues.jsx
Normal file
152
src/features/app-users/components/AppUserValues.jsx
Normal 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}秒`;
|
||||
}
|
||||
@ -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`;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
28
src/features/app-users/schema.test.ts
Normal file
28
src/features/app-users/schema.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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>;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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" />
|
||||
|
||||
58
src/features/dashboard/components/DashboardWorkspaces.jsx
Normal file
58
src/features/dashboard/components/DashboardWorkspaces.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
|
||||
@ -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 />;
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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 = {}) {
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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: [
|
||||
|
||||
257
src/features/vip-config/api.test.ts
Normal file
257
src/features/vip-config/api.test.ts
Normal 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>;
|
||||
}
|
||||
@ -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), {
|
||||
// 金币返现只属于付费 VIP;API 边界再收敛一次,避免未来调用方绕过页面控件恢复体验卡返现。
|
||||
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);
|
||||
}
|
||||
|
||||
88
src/features/vip-config/constants.js
Normal file
88
src/features/vip-config/constants.js
Normal 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,
|
||||
};
|
||||
}
|
||||
32
src/features/vip-config/constants.test.js
Normal file
32
src/features/vip-config/constants.test.js
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
145
src/features/vip-config/hooks/useVipConfigPage.test.js
Normal file
145
src/features/vip-config/hooks/useVipConfigPage.test.js
Normal 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,
|
||||
};
|
||||
}
|
||||
79
src/features/vip-config/metadata.js
Normal file
79
src/features/vip-config/metadata.js
Normal 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;
|
||||
}
|
||||
57
src/features/vip-config/metadata.test.js
Normal file
57
src/features/vip-config/metadata.test.js
Normal 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
241
src/features/vip-config/pages/VipConfigPage.test.jsx
Normal file
241
src/features/vip-config/pages/VipConfigPage.test.jsx
Normal 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,
|
||||
};
|
||||
}
|
||||
36
src/features/vip-config/schema.js
Normal file
36
src/features/vip-config/schema.js
Normal 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
818
src/shared/api/generated/schema.d.ts
vendored
818
src/shared/api/generated/schema.d.ts
vendored
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user